Merge upstream/main into fix/xfa-open-scripts-complete

Main's DataOp::Value now replaces a multi-select list's value-group picks in the
packet and in the data copy, so the reset writes through it and the separate
DataOp::Reset (and Want::picks) is dropped. The reset keeps the PR's indexed
targets and its pass over fields without widgets, beside main's floating-field
pass (which the unshown pass leaves embedded fields to), and finish() lays out
again for either an owed relayout or a changed embedded value. Parity notes and
evidence take both sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-09 10:39:06 +08:00
commit f996aef12f
654 files changed
+99220 -5792

No files matched your search

+4 -4
View File
@@ -197,14 +197,14 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec<String>) ->
return Ok(None);
}
};
// Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step
// that didn't remove is: the file would be passed on as cleaned with the data still in it.
// A step that finds nothing to do is not a failure. Any other error fails the file, so nothing is
// published for it: the file would otherwise be passed on without the step it was asked to apply.
let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize);
match s.apply(id, edit) {
Ok(()) => log.push(step.label().to_string()),
Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())),
Err(e) if removes => return Err(e),
Err(e) => log.push(format!("{}: {e}", step.label())),
Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) => log.push(format!("{}: nothing to do", step.label())),
Err(e) => return Err(e),
}
Ok(None)
}
+196 -39
View File
@@ -1,6 +1,8 @@
//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level): even out
//! the lighting so the paper turns white (room-light colour casts, a hand's shadow, darker
//! edges go with it), then raise the contrast and drop isolated specks. Colour ink stays colour.
//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level) or a
//! scanned page's picture (Enhance ▸ Scanned document, [`crate::scan_clean`]): even out the
//! lighting so the paper turns white (room-light colour casts, a hand's shadow, darker edges and
//! book gutters go with it), then raise the contrast and drop isolated specks. Colour ink stays
//! colour.
//!
//! The paper's illumination is estimated per channel on a coarse grid of blocks, from each
//! block's bright end (the paper, not the ink), smoothed into a continuous map and divided out.
@@ -8,6 +10,8 @@
/// The strongest a level can be.
pub const MAX_LEVEL: u8 = 100;
/// The level from which [`clean`] also drops isolated specks.
pub const DESPECKLE_FROM: u8 = 60;
/// How an enhanced camera page looks.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -66,34 +70,158 @@ pub fn apply(rgb: &[u8], width: u32, height: u32, look: Look, level: u8) -> Opti
let even = clean(rgb, width, height, 50)?;
let t = 0.45 + 0.4 * level.min(MAX_LEVEL) as f32 / MAX_LEVEL as f32;
let mut grey: Vec<u8> = even.as_chunks::<3>().0.iter().map(|p| if (lum(p) as f32 / 255.0) < t { 0 } else { 255 }).collect();
despeckle_grey(&mut grey, w, h);
despeckle(&mut grey, 1, w, h);
Pixels::Grey(grey)
}
})
}
/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest).
/// Returns `None` when the buffer doesn't match the size.
/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest), from
/// [`DESPECKLE_FROM`] without isolated specks. Returns `None` when the buffer doesn't match the
/// size.
pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option<Vec<u8>> {
clean_samples(rgb, 3, width, height, CleanUp { level, edge_shadows: false, despeckle: level >= DESPECKLE_FROM })
}
/// What [`clean_samples`] does.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CleanUp {
/// 0 (unchanged) … 100: even out the paper's illumination and raise the contrast, as
/// [`clean`]'s level.
pub level: u8,
/// Even out shadows along the picture's edges first (a book's gutter, a copier's dark edge):
/// lightened line by line to the paper beside them, within the outer fifth of each side.
pub edge_shadows: bool,
/// Drop isolated dark specks.
pub despeckle: bool,
}
/// [`clean`] for samples of `n` components (1: grey, 3: RGB), with each step chosen apart (each
/// works at level 0 too). Returns `None` when `n` is neither or the buffer doesn't match the size.
pub fn clean_samples(px: &[u8], n: usize, width: u32, height: u32, how: CleanUp) -> Option<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(3)? != rgb.len() {
if !(n == 1 || n == 3) || w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(n)? != px.len() {
return None;
}
let level = level.min(MAX_LEVEL);
if level == 0 {
return Some(rgb.to_vec());
let level = how.level.min(MAX_LEVEL);
let mut out = px.to_vec();
if how.edge_shadows {
even_edges(&mut out, n, w, h);
}
if level > 0 {
out = even_out(&out, n, w, h, level);
}
if how.despeckle {
despeckle(&mut out, n, w, h);
}
Some(out)
}
/// The share of the width (and of the height) at each side where [`even_edges`] works.
const EDGE_BAND: f32 = 0.2;
/// The most [`even_edges`] lightens a line by; a darker line (a scanner's black border, a
/// picture) is not taken for shadowed paper and stays as it is.
const MAX_EDGE_GAIN: f32 = 3.0;
/// Even out shadows that run along the picture's edges, in place: a book's gutter, a copier's
/// dark border, a page that curved away from the glass. Within the outer [`EDGE_BAND`] of each
/// side, each column (then each row) gets the paper's level along it (per channel, its 90th
/// percentile, so the text across it doesn't count), and is lightened to the brightest paper of
/// that band. The block grid of [`background`] is too coarse for such a narrow shadow: its
/// blocks borrow the brighter paper next to them.
fn even_edges(px: &mut [u8], n: usize, w: usize, h: usize) {
for vertical in [true, false] {
// Columns: lines across the width, each running down the height; rows the other way.
let (lines, along) = if vertical { (w, h) } else { (h, w) };
let band = ((lines as f32 * EDGE_BAND) as usize).min(lines / 2);
if band == 0 {
continue;
}
let at = |line: usize, i: usize| if vertical { i * w + line } else { line * w + i };
// The paper's level of every line in the bands and a few beyond them.
let reach = band.saturating_add(3).min(lines);
let measured = |line: usize| line < reach || line >= lines.saturating_sub(reach);
let mut level: Vec<Option<[f32; 3]>> = vec![None; lines];
let mut hist = [[0u32; 256]; 3];
for (line, slot) in level.iter_mut().enumerate().filter(|(line, _)| measured(*line)) {
hist.iter_mut().for_each(|hh| hh.fill(0));
for i in 0..along {
let p = at(line, i) * n;
for (c, hh) in hist.iter_mut().enumerate().take(n) {
if let Some(v) = px.get(p + c)
&& let Some(count) = hh.get_mut(*v as usize)
{
*count += 1;
}
}
}
let want = (along as f32 * 0.9) as u32;
*slot = Some(std::array::from_fn(|c| {
let mut seen = 0;
let v = hist.get(c).and_then(|hh| {
hh.iter().position(|&count| {
seen += count;
seen > want
})
});
v.unwrap_or(255) as f32 / 255.0
}));
}
// Smoothed: the median of five neighbouring lines (fewer at the very edge, centred all
// the same, so a gradient isn't shifted), so one odd line (a rule, a fold) doesn't stand
// out.
let smooth: Vec<Option<[f32; 3]>> = (0..lines)
.map(|line| {
level.get(line).copied().flatten()?;
let r = line.min(lines - 1 - line).min(2);
Some(std::array::from_fn(|c| {
let mut near: Vec<f32> = (line - r..=line + r).filter_map(|l| level.get(l).copied().flatten()).map(|v| v[c]).collect();
near.sort_by(f32::total_cmp);
near.get(near.len() / 2).copied().unwrap_or(1.0)
}))
})
.collect();
// Each side's band, and the lines its paper is measured on (the band and a few beyond).
for (shadowed, measured) in [(0..band, 0..reach), (lines - band..lines, lines.saturating_sub(reach)..lines)] {
// The brightest paper there: what the shadow is lightened to.
let paper: [f32; 3] =
std::array::from_fn(|c| measured.clone().filter_map(|l| smooth.get(l).copied().flatten()).map(|v| v[c]).fold(0.0, f32::max));
for line in shadowed {
let Some(own) = smooth.get(line).copied().flatten() else { continue };
let gain: [f32; 3] = std::array::from_fn(|c| {
let g = paper[c] / own[c].max(1e-3);
if (1.0..=MAX_EDGE_GAIN).contains(&g) { g } else { 1.0 }
});
if gain.iter().take(n).all(|&g| g == 1.0) {
continue;
}
for i in 0..along {
let p = at(line, i) * n;
for (c, g) in gain.iter().enumerate().take(n) {
if let Some(v) = px.get_mut(p + c) {
*v = (*v as f32 * g).round().clamp(0.0, 255.0) as u8;
}
}
}
}
}
}
}
/// The lighting evened out and the contrast raised at `level` (1–100); `px` holds `w` × `h`
/// pixels of `n` components.
fn even_out(px: &[u8], n: usize, w: usize, h: usize, level: u8) -> Vec<u8> {
let k = level as f32 / MAX_LEVEL as f32;
let background = background(rgb, w, h);
let background = background(px, n, w, h);
// Lighting is evened out fully from half strength on; contrast keeps growing to the top.
let flatten = (2.0 * k).min(1.0);
let white = 1.0 - 0.12 * k; // what reaches white
let black = 0.30 * k; // what reaches black
let mut out = Vec::with_capacity(rgb.len());
for (i, px) in rgb.as_chunks::<3>().0.iter().enumerate() {
let mut out = Vec::with_capacity(px.len());
for (i, p) in px.chunks_exact(n).enumerate() {
let (x, y) = (i % w, i / w);
let bg = background.at(x, y);
for (c, &v) in px.iter().enumerate() {
for (c, &v) in p.iter().enumerate() {
let v = v as f32 / 255.0;
let b = bg.get(c).copied().unwrap_or(1.0).max(0.05);
let even = (v / b).min(1.0);
@@ -102,10 +230,7 @@ pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option<Vec<u8>>
out.push((stretched * 255.0).round() as u8);
}
}
if level >= 60 {
despeckle(&mut out, w, h);
}
Some(out)
out
}
/// The paper's illumination: a grid of per-channel values, read back with bilinear
@@ -140,9 +265,10 @@ impl Background {
}
}
/// Estimate the paper's illumination: per block, the 90th percentile of each channel (bright
/// paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then smoothed.
fn background(rgb: &[u8], w: usize, h: usize) -> Background {
/// Estimate the paper's illumination: per block, the 90th percentile of each of the `n` channels
/// (bright paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then
/// smoothed. Channels past `n` stay white.
fn background(px: &[u8], n: usize, w: usize, h: usize) -> Background {
let block = (w.min(h) / 16).clamp(8, 128);
let (cols, rows) = (w.div_ceil(block), h.div_ceil(block));
let mut cells = Vec::with_capacity(cols * rows);
@@ -150,20 +276,20 @@ fn background(rgb: &[u8], w: usize, h: usize) -> Background {
for by in 0..rows {
for bx in 0..cols {
hist.iter_mut().for_each(|hh| hh.fill(0));
let mut n = 0u32;
let mut count = 0u32;
for y in by * block..((by + 1) * block).min(h) {
for x in bx * block..((bx + 1) * block).min(w) {
if let Some(p) = rgb.get((y * w + x) * 3..(y * w + x) * 3 + 3) {
if let Some(p) = px.get((y * w + x) * n..(y * w + x) * n + n) {
for (c, &v) in p.iter().enumerate() {
if let Some(slot) = hist.get_mut(c).and_then(|hh| hh.get_mut(v as usize)) {
*slot += 1;
}
}
n += 1;
count += 1;
}
}
}
let want = (n as f32 * 0.9) as u32;
let want = (count as f32 * 0.9) as u32;
cells.push(std::array::from_fn(|c| {
let mut seen = 0;
let v = hist.get(c).and_then(|hh| {
@@ -219,19 +345,15 @@ fn background(rgb: &[u8], w: usize, h: usize) -> Background {
Background { cols, rows, block: block as f32, cells }
}
/// [`despeckle`] for one grey byte per pixel.
fn despeckle_grey(grey: &mut [u8], w: usize, h: usize) {
let mut rgb: Vec<u8> = grey.iter().flat_map(|&v| [v, v, v]).collect();
despeckle(&mut rgb, w, h);
for (g, p) in grey.iter_mut().zip(rgb.as_chunks::<3>().0.iter()) {
*g = p[0];
}
}
/// Lighten dark pixels with at most one dark neighbour (specks), in place.
fn despeckle(rgb: &mut [u8], w: usize, h: usize) {
let lum = |p: &[u8; 3]| (p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000;
let dark: Vec<bool> = rgb.as_chunks::<3>().0.iter().map(|p| lum(p) < 128).collect();
/// Lighten dark pixels with at most one dark neighbour (specks), in place; `px` holds `w` × `h`
/// pixels of `n` components (1 or 3).
fn despeckle(px: &mut [u8], n: usize, w: usize, h: usize) {
let lum = |p: &[u8]| match p {
[r, g, b] => (*r as u32 * 299 + *g as u32 * 587 + *b as u32 * 114) / 1000,
[v, ..] => *v as u32,
[] => 255,
};
let dark: Vec<bool> = px.chunks_exact(n.max(1)).map(|p| lum(p) < 128).collect();
let is_dark = |x: isize, y: isize| {
x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && dark.get(y as usize * w + x as usize).copied().unwrap_or(false)
};
@@ -243,7 +365,7 @@ fn despeckle(rgb: &mut [u8], w: usize, h: usize) {
let neighbours =
(-1..=1).flat_map(|dy| (-1..=1).map(move |dx| (dx, dy))).filter(|&(dx, dy)| (dx, dy) != (0, 0) && is_dark(x + dx, y + dy)).count();
if neighbours <= 1
&& let Some(p) = rgb.get_mut((y as usize * w + x as usize) * 3..(y as usize * w + x as usize) * 3 + 3)
&& let Some(p) = px.get_mut((y as usize * w + x as usize) * n..(y as usize * w + x as usize) * n + n)
{
p.fill(255);
}
@@ -388,5 +510,40 @@ mod tests {
assert_eq!(clean(&[], 0, 0, 50), None);
assert_eq!(clean(&[0; 12], 2, 3, 50), None);
assert_eq!(clean(&[9; 3], 1, 1, 255).map(|v| v.len()), Some(3));
let every = CleanUp { level: 100, edge_shadows: true, despeckle: true };
assert_eq!(clean_samples(&[0; 12], 2, 2, 3, every), None, "two components");
assert_eq!(clean_samples(&[0; 5], 1, 2, 3, every), None);
for (w, h) in [(1, 1), (1, 9), (9, 1), (2, 2), (5, 3)] {
assert_eq!(clean_samples(&vec![100; w * h], 1, w as u32, h as u32, every).map(|v| v.len()), Some(w * h));
}
}
#[test]
fn edge_shadows_lighten_shadowed_paper_but_not_a_black_border_or_the_middle() {
// Grey paper (200) with a shadow darkening the left 20 columns to 120, a black scanner
// border in the right 8 columns, and a dark block in the middle.
let (w, h) = (100usize, 60usize);
let mut grey = vec![200u8; w * h];
for y in 0..h {
for x in 0..w {
let v = if x >= w - 8 {
5
} else if (40..60).contains(&x) && (20..40).contains(&y) {
30
} else if x < 20 {
120 + (80 * x / 20) as u8
} else {
200
};
grey[y * w + x] = v;
}
}
let out = clean_samples(&grey, 1, w as u32, h as u32, CleanUp { level: 0, edge_shadows: true, despeckle: false }).unwrap();
for x in [0, 5, 19] {
assert!(out[30 * w + x].abs_diff(200) <= 3, "column {x}: {} → {}", grey[30 * w + x], out[30 * w + x]);
}
assert_eq!(out[30 * w + w - 3], 5, "a black border is no shadow");
assert_eq!(out[30 * w + 50], 30, "the middle is untouched");
assert_eq!(out[30 * w + 30], 200);
}
}
+2 -2
View File
@@ -265,9 +265,9 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
items: &[
item("In this file", "scan-text", "ocr.recognize", Ready),
item("In multiple files", "files", "ocr.recognize_batch", Ready),
item("Enhance scanned file", "sparkles", "ocr.enhance", Planned("M10")),
item("Enhance scanned file", "sparkles", "ocr.enhance", Ready),
item("Enhance camera image", "camera", "ocr.enhance_camera", Ready),
item("Correct recognized text", "text-select", "ocr.correct", Planned("M10")),
item("Correct recognized text", "text-select", "ocr.correct", Ready),
],
}],
},
+6
View File
@@ -220,6 +220,10 @@ pub const COMMANDS: &[CommandSpec] = &[
// Alt-drag for moving windows (several Linux window managers).
// Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership.
c("edit.copy", "Copy", EDIT, None, Copying, "copy"),
// Comments travel between documents as XFDF (M14): ⌘C with comments selected copies them,
// ⌘V pastes the copies onto the current page; the strip's Copy/Paste in the menus too.
c("comment.copy", "Copy comments", EDIT, None, Document, "copy"),
c("comment.paste", "Paste comments", EDIT, None, Annotate, "clipboard-paste"),
c("edit.column_select", "Column select", EDIT, None, Document, "text-select"),
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
@@ -355,7 +359,9 @@ pub const COMMANDS: &[CommandSpec] = &[
c("tools.document_js", "Document JavaScripts…", None, None, Modification, "file-code"),
c("ocr.recognize", "Recognize text…", None, None, Modification, "scan-text"),
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
c("ocr.enhance", "Enhance scanned file…", None, None, Modification, "sparkles"),
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
c("ocr.correct", "Correct recognized text…", None, None, Document, "text-select"),
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
c("a11y.find_untagged", "Find untagged content", None, None, Document, "search"),
c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"),
+47 -12
View File
@@ -63,6 +63,15 @@ impl Session {
Ok(pdfcraft_compare::compare(&a.words(), &b.words()))
}
/// [`Session::compare`], keeping the changes on `pages` of `new` (0-based; empty: every page).
pub fn compare_pages(&self, old: DocId, new: DocId, pages: &[usize]) -> Result<Comparison, EditError> {
let mut c = self.compare(old, new)?;
if !pages.is_empty() {
c.changes.retain(|ch| pages.iter().any(|page| (ch.new.page..=ch.new.last_page).contains(page)));
}
Ok(c)
}
/// Visual compare: regions where page n of `new` looks different from page n of `old`
/// (rendered at `dpi`), as (page, user-space box in `new`).
pub fn compare_visual(&self, old: DocId, new: DocId, dpi: f32) -> Result<Vec<(usize, [f64; 4])>, EditError> {
@@ -95,7 +104,12 @@ impl Session {
/// The compare report as a new PDF (not opened).
pub fn compare_report(&self, old: DocId, new: DocId) -> Result<Arc<Vec<u8>>, EditError> {
let c = self.compare(old, new)?;
self.compare_report_of(old, new, &[])
}
/// [`Session::compare_report`] of the changes on `pages` ([`Session::compare_pages`]).
pub fn compare_report_of(&self, old: DocId, new: DocId, pages: &[usize]) -> Result<Arc<Vec<u8>>, EditError> {
let c = self.compare_pages(old, new, pages)?;
let name = |id| self.get(id).map(|d| d.name.clone()).unwrap_or_default();
self.create_report("Compare Report", &pdfcraft_compare::report(&c, &name(old), &name(new)))
}
@@ -104,7 +118,12 @@ impl Session {
/// (blue, green) and a note where text was deleted (red), authored "Compare". One undoable
/// step; returns how many comments were added.
pub fn mark_differences(&mut self, old: DocId, new: DocId) -> Result<usize, EditError> {
let c = self.compare(old, new)?;
self.mark_differences_of(old, new, &[])
}
/// [`Session::mark_differences`] of the changes on `pages` ([`Session::compare_pages`]).
pub fn mark_differences_of(&mut self, old: DocId, new: DocId, pages: &[usize]) -> Result<usize, EditError> {
let c = self.compare_pages(old, new, pages)?;
let mut edits = Vec::new();
for ch in &c.changes {
let color = colour(ch.kind);
@@ -187,6 +206,20 @@ pub struct OfficeExport {
/// `Err(false)`: it can't be encoded).
type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>;
/// Displayed points per user-space unit on `info`'s page (`/UserUnit`), 1 when the page has no
/// usable size.
pub(crate) fn display_unit(info: &pdfcraft_render::PageInfo) -> f64 {
let crop_width = f64::from(info.crop[2] - info.crop[0]);
let display_width = f64::from(if info.rotation.is_multiple_of(180) { info.width } else { info.height });
if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 }
}
/// Bold and italic as a font's name says them (`Arial-BoldItalicMT`).
pub(crate) fn weight_and_slant(base_font: &str) -> (bool, bool) {
let f = base_font.to_ascii_lowercase();
(f.contains("bold") || f.contains("black") || f.contains("heavy"), f.contains("italic") || f.contains("oblique"))
}
impl crate::Document {
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
/// form XObjects draw.
@@ -228,9 +261,7 @@ impl crate::Document {
let b = info.user_to_view(r[2] as f32, r[3] as f32);
[a[0].min(b[0]) as f64, (info.height - a[1].max(b[1])) as f64, a[0].max(b[0]) as f64, (info.height - a[1].min(b[1])) as f64]
};
let crop_width = f64::from(info.crop[2] - info.crop[0]);
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
let unit = display_unit(info);
let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default();
let forms_unread = reading.truncated;
let blocks = reading
@@ -238,7 +269,7 @@ impl crate::Document {
.into_iter()
.filter(|b| !b.text.trim().is_empty())
.map(|b| {
let f = b.base_font.to_ascii_lowercase();
let (bold, italic) = weight_and_slant(&b.base_font);
// `text` is trimmed; the editor text keeps the source's own spaces.
let space_around = [b.editor_text.starts_with(char::is_whitespace), b.editor_text.ends_with(char::is_whitespace)];
pdfcraft_export::Block {
@@ -246,8 +277,8 @@ impl crate::Document {
text: b.text,
rect: visible_rect(b.rect),
size: b.size * unit,
bold: f.contains("bold") || f.contains("black") || f.contains("heavy"),
italic: f.contains("italic") || f.contains("oblique"),
bold,
italic,
color: b.color,
rotation: b.rotation - f64::from(info.rotation),
}
@@ -282,7 +313,9 @@ impl crate::Document {
}
// The page colour (Edit a PDF ▸ Background, a producer's page fill) for formats that keep one.
let background = pdfcraft_edit::page_background(cos, i).ok().flatten();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background }
// The ruled lines tell a drawn table from text that merely lines up (#5149).
let rules = pdfcraft_edit::page_rules(cos, i).unwrap_or_default().into_iter().map(visible_rect).collect();
pdfcraft_export::Page { width: info.width as f64, height: info.height as f64, blocks, images, background, rules }
})
.collect();
(pages, truncated)
@@ -300,11 +333,13 @@ impl crate::Document {
let (pages, truncated_pages) =
self.export_pages_checked(!matches!(format, OfficeFormat::Rtf | OfficeFormat::Markdown), MAX_EXPORT_IMAGE_BYTES);
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
// Word and HTML name the document's language (`/Lang`), so spelling is checked in it.
let language = self.initial_view().language;
let bytes = match format {
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
OfficeFormat::Docx => pdfcraft_export::docx_with_language(&pages, &title, language.as_deref()),
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
OfficeFormat::HtmlSimple => pdfcraft_export::html_simple(&pages, &title).into_bytes(),
OfficeFormat::Html => pdfcraft_export::html_with_language(&pages, &title, language.as_deref()).into_bytes(),
OfficeFormat::HtmlSimple => pdfcraft_export::html_simple_with_language(&pages, &title, language.as_deref()).into_bytes(),
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(),
};
+39 -2
View File
@@ -2,7 +2,7 @@
//! file in its original format. A picture drawn through grouped
//! Form artwork (as Word, InDesign and many scanners write them) is found inside the group.
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document as Cos, ObjRef, Object};
@@ -13,6 +13,31 @@ const MAX_COPY_PIXELS: u64 = 64_000_000;
/// How deep Form artwork is searched for its picture.
const MAX_FORM_DEPTH: usize = 12;
/// A picture on a page, with its index in [`Document::page_images`].
pub type PagePicture = (usize, pdfcraft_edit::PageImage);
/// The topmost of `pictures` under user-space point `at` (`[x, y]`). `pictures` is bottom first,
/// as [`Document::page_pictures_job`] lists them, so the last one that holds `at` wins.
pub fn picture_at(pictures: &[PagePicture], at: [f64; 2]) -> Option<&PagePicture> {
pictures.iter().rev().find(|(_, image)| image.contains(at))
}
/// The pictures on `page`, bottom first: raster images, and Form artwork that holds one.
fn pictures(cos: &Cos, page: usize) -> Vec<PagePicture> {
let images = pdfcraft_edit::page_images(cos, page).unwrap_or_default();
// Artwork drawn many times is searched once.
let mut holds_one: HashMap<ObjRef, bool> = HashMap::new();
images
.into_iter()
.enumerate()
.filter(|(_, i)| match (i.is_form, i.object) {
(false, Some(_)) => true,
(true, Some(form)) => *holds_one.entry(form).or_insert_with(|| picture_in_form(cos, form).is_some()),
_ => false,
})
.collect()
}
impl Document {
/// The topmost picture on `page` under user-space point `at`, as its index in
/// [`Document::page_images`]: a raster image, or Form artwork that holds one.
@@ -28,6 +53,18 @@ impl Document {
})
}
/// Work that lists the pictures on `page`, bottom first, for another thread: it reads every
/// drawing operator on the page, seconds on a dense one, so it must not run on the window
/// thread. Look a point up with [`picture_at`].
///
/// `None` when the document can't be read. The work reads the document as it is now: later
/// edits aren't seen (key its answer by [`Document::edit_generation`]). It returns no pictures
/// for a page that doesn't exist or can't be read.
pub fn page_pictures_job(&self, page: usize) -> Option<impl FnOnce() -> Vec<PagePicture> + Send + 'static> {
let cos = self.editor.as_ref()?.cos.clone();
Some(move || crate::guard(|| pictures(&cos, page)).unwrap_or_default())
}
/// Copy Image in Original Format: picture `index` on `page` as a file (extension, bytes):
/// a JPEG as stored in the PDF, otherwise a lossless PNG.
pub fn page_image_copy_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
@@ -112,7 +149,7 @@ pub(crate) fn added_picture(cos: &Cos, file: &[u8]) -> Option<ObjRef> {
}
/// The largest raster image Form `form` draws through its resources (nested Forms included).
fn picture_in_form(cos: &Cos, form: ObjRef) -> Option<ObjRef> {
pub(crate) fn picture_in_form(cos: &Cos, form: ObjRef) -> Option<ObjRef> {
let mut seen = HashSet::from([form]);
let mut best: Option<(u64, ObjRef)> = None;
let res = resources(cos, form)?;
+12
View File
@@ -582,6 +582,18 @@ pub fn date_language(code: &str) -> Option<&'static DateLanguage> {
DATE_LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code.trim()))
}
/// Form date fields (`AFDate_FormatEx`, `util.printd`, `util.scand`) write month and weekday
/// names in date language `code` from now on, as a localized Acrobat writes its own language's:
/// `dd-mmm-yy` is `18-ott-26` in Italian (#5303). An unknown code is English.
pub fn use_form_date_language(code: &str) {
let l = date_language(code).unwrap_or(&DATE_LANGUAGES[0]);
let names = pdfcraft_forms::af::DateNames { months: l.months, months_short: l.months_short, days: l.days, days_short: l.days_short };
if pdfcraft_forms::af::date_names() != names {
pdfcraft_forms::af::set_date_names(names);
pdfcraft_js::set_date_names(l.months, l.months_short, l.days, l.days_short);
}
}
/// The characters of `text` that Fill & Sign can't write into a PDF yet, each once. Its text is
/// drawn in Helvetica with WinAnsi encoding (Western European letters), so anything else (`ř`,
/// Japanese, Chinese) would become `?` in the file.
+3
View File
@@ -26,6 +26,9 @@ pub enum ImageEdit {
Arrange(ArrangeOrder),
/// Remove any private crop on the image.
RemoveCrop,
/// Grouped Form artwork: draw its content in place, so its text and pictures can be edited
/// one by one (#2964).
Ungroup,
}
/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a
+126 -24
View File
@@ -8,7 +8,8 @@ use pdfcraft_render::{DocInfo, RenderConfig, RenderPool};
use crate::history::Editor;
use crate::{
CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary,
SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted,
SignatureInfo, StatusCaptions, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password,
signatures_of, unrestricted,
};
#[cfg(test)]
use crate::{record_link_scan, record_marker_scan};
@@ -19,8 +20,9 @@ pub struct Document {
pub path: Option<String>,
/// The working file: what Save writes.
pub bytes: Arc<Vec<u8>>,
/// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances
/// drawn for display only for comments that have none ([`display_bytes`]). Never saved.
/// What is displayed (the page view, `page_render`, page images): `bytes`, plus what is drawn
/// for display only ([`display_bytes`]): appearances for comments that have none, and the
/// states of named-layer signatures. Never saved.
pub display: Arc<Vec<u8>>,
pub info: DocInfo,
pub renderer: RenderPool,
@@ -30,7 +32,8 @@ pub struct Document {
pub dirty: bool,
/// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it.
pub(crate) generation: u64,
/// The generation last handed out by `autosave_snapshots`.
/// The generation last handed out by `autosave_snapshots`, or another one when storing that
/// snapshot failed (`autosave_failed`), so it is due again.
pub(crate) snapshot_generation: u64,
/// Why the document cannot be edited (e.g. encryption), if so.
pub read_only_reason: Option<String>,
@@ -41,6 +44,9 @@ pub struct Document {
/// Signature fields, validated against the session's trust store (Signatures panel).
pub signatures: Arc<Vec<SignatureInfo>>,
pub(crate) trust: Arc<TrustStore>,
/// The words signature status layers are drawn with (the session's, see
/// [`Session::set_signature_captions`](crate::Session::set_signature_captions)).
pub(crate) captions: Arc<StatusCaptions>,
pub(crate) sig_cache: Arc<pdfcraft_sign::DigestCache>,
pub(crate) editor: Option<Editor>,
pub(crate) config: RenderConfig,
@@ -255,6 +261,15 @@ impl Document {
pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?)
}
/// Edit text: whether grouped artwork `index` on `page` (0-based, as [`Self::page_images`]
/// lists them) shows text that [`Edit::PrepareText`](crate::Edit::PrepareText) would make
/// editable. Reads the page without changing it; `false` for a logo, a group that can't be
/// opened or a page that can't be read.
pub fn grouped_text_at(&self, page: usize, index: usize) -> bool {
let Some(editor) = self.editor.as_ref() else { return false };
crate::guard(|| pdfcraft_edit::text::forms::grouped_text_at(&editor.cos, page, index)).is_ok_and(|r| r.is_ok_and(|yes| yes))
}
/// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based).
pub fn text_blocks(&self, page: usize) -> Vec<pdfcraft_edit::TextBlock> {
self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default()
@@ -539,7 +554,7 @@ impl Document {
_ => "Attachments only",
};
let pending = editor.cos.encryption_changed();
let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() };
let permissions = if pending { pdfcraft_cos::Permissions { owner: false, ..h.permissions() } } else { h.permissions() };
Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending })
}
@@ -631,6 +646,12 @@ impl Document {
pub fn repair_log(&self) -> Vec<String> {
self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default()
}
/// Objects of the file that could not be read and read as null, one line each (see
/// [`pdfcraft_cos::Document::unreadable_objects`]): only those read so far.
pub fn unreadable_objects(&self) -> Vec<String> {
self.editor.as_ref().map(|e| e.cos.unreadable_objects()).unwrap_or_default()
}
}
/// A title that names no document: what a web browser writes when it saves a blank page or a
@@ -643,25 +664,92 @@ pub(crate) fn is_placeholder_title(title: &str) -> bool {
address || t == "untitled"
}
/// The working file plus appearances for comments that have none, and for form fields the file
/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the
/// viewer and never enter these bytes, which image/text exports and Print as Image also consume.
pub(crate) fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
/// What is displayed: the working file plus, in memory only and never saved, the appearances
/// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]) and
/// for form fields the file leaves to the viewer (#3470), and the signature states drawn into
/// named-layer signature appearances ([`pdfcraft_sign::show_status_layers`]). The working file is
/// unchanged, and so is everything that reads it (saving, signatures, …). Image and text exports
/// and Print as Image read these bytes, so they show the page as displayed; validation badges
/// belong to the viewer and never enter them.
///
/// `signatures` is input and output: each one whose state gets drawn comes back marked
/// (`verdict_shown`). A mark describes the bytes returned, so every mark is cleared first and a
/// copy that cannot be written takes its marks with it.
pub(crate) fn display_bytes(
editor: Option<&mut Editor>,
bytes: &Arc<Vec<u8>>,
signatures: &mut [SignatureInfo],
captions: &StatusCaptions,
) -> Arc<Vec<u8>> {
for sig in signatures.iter_mut() {
sig.verdict_shown = false;
}
let Some(e) = editor else { return bytes.clone() };
let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances);
let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances);
let Some(display) = fields.or(comments) else {
if let Some(mut display) = fields.or(comments) {
// Written whether or not a signature changed: the copy exists for the comments and fields.
let _ = pdfcraft_sign::show_status_layers(&mut display, signatures, captions);
return write_display_copy(e, bytes, &display, signatures);
}
// No comment or field needed an appearance, so there is no copy yet: build one only when a
// signature's state would change it.
if !pdfcraft_sign::has_status_layers(&e.cos, signatures) {
return bytes.clone();
};
match write_incremental(&display, &SaveOptions::default()) {
}
let mut display = e.cos.clone();
if !pdfcraft_sign::show_status_layers(&mut display, signatures, captions) {
return bytes.clone();
}
write_display_copy(e, bytes, &display, signatures)
}
/// Write the display copy, or keep the working file when it cannot be written. The page then
/// shows the file's own artwork, so the marks are cleared. Losing the copy is a repair, so it is
/// logged, once however many refreshes lose it again.
pub(crate) fn write_display_copy(
e: &mut Editor,
bytes: &Arc<Vec<u8>>,
display: &pdfcraft_cos::Document,
signatures: &mut [SignatureInfo],
) -> Arc<Vec<u8>> {
match write_incremental(display, &SaveOptions::default()) {
Ok(written) => Arc::new(written),
Err(err) => {
e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})"));
for sig in signatures.iter_mut() {
sig.verdict_shown = false;
}
let line = format!("the display copy could not be written, so pages show the file as stored ({err})");
if !e.cos.repair_log().contains(&line) {
e.cos.note_repair(line);
}
bytes.clone()
}
}
}
/// `doc` as the reader shows it: comments without an appearance stream drawn
/// (`pdfcraft_annot::with_missing_appearances`) and form fields drawn from their values where the
/// file leaves that to the viewer. `None` when `doc` needs neither. The vector print draws from
/// this copy too, so what prints is what the page shows (#371, #3475 draw them for display only).
pub(crate) fn as_displayed(doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
with_field_appearances(pdfcraft_annot::with_missing_appearances(doc), doc)
}
/// Carry the marks across a revalidation that leaves the display copy alone, when it may: only
/// when every verdict is the one the copy was drawn with. Returns whether it did; otherwise the
/// copy has to be drawn again.
pub(crate) fn carry_verdict_marks(previous: &[SignatureInfo], next: &mut [SignatureInfo]) -> bool {
let unchanged = previous.len() == next.len()
&& previous.iter().zip(next.iter()).all(|(p, n)| p.field == n.field && p.revision == n.revision && p.status == n.status);
if unchanged {
for (p, n) in previous.iter().zip(next.iter_mut()) {
n.verdict_shown = p.verdict_shown;
}
}
unchanged
}
/// `comments` (the display copy with comment appearances, if it needed any; else the document
/// itself) with its form fields drawn from their values where the file leaves that to the viewer.
fn with_field_appearances(comments: Option<pdfcraft_cos::Document>, doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
@@ -675,8 +763,9 @@ pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::
}
/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read
/// the way the full inspection reads them: NULs and spaces trimmed, empty is none.
pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option<String>, Option<String>); 6] {
/// the way the full inspection reads them: NULs and spaces trimmed, empty is none, dates shown as
/// [`pdfcraft_render::pretty_date`] shows them.
pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option<String>, Option<String>); 8] {
let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty());
[
(&mut info.title, read("Title")),
@@ -685,20 +774,33 @@ pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Documen
(&mut info.keywords, read("Keywords")),
(&mut info.creator, read("Creator")),
(&mut info.producer, read("Producer")),
(&mut info.created, read("CreationDate").map(|d| pdfcraft_render::pretty_date(&d))),
(&mut info.modified, read("ModDate").map(|d| pdfcraft_render::pretty_date(&d))),
]
}
/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for
/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page
/// images agree with the app and independent viewers. It never changes the source document, and an
/// unparseable file, a write failure or a parser panic falls back to the original bytes.
/// The copy PDFCraft displays when opening `bytes` with no trusted certificates and English
/// captions ([`display_bytes`]): appearances drawn, in memory only, for comments that have none
/// and form fields the file leaves to the viewer, and the state of each named-layer signature.
/// Render paths outside the app, such as the CLI, use this so their page images agree with the app
/// and independent viewers. It never changes the source document: a file with nothing to draw, an
/// unparseable file, a write failure or a parser panic gives back the original bytes.
pub fn display_bytes_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Arc<Vec<u8>> {
guard(|| {
pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
.ok()
.and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos))
.and_then(|display| write_incremental(&display, &SaveOptions::default()).ok())
.map(Arc::new)
let cos = pdfcraft_cos::Document::open_with_password(bytes.clone(), password).ok()?;
let mut signatures = signatures_of(&cos, &bytes, &TrustStore::default(), &pdfcraft_sign::DigestCache::default());
let layered = pdfcraft_sign::has_status_layers(&cos, &signatures);
let (mut display, added) = match as_displayed(&cos) {
Some(display) => (display, true),
None if layered => (cos, false),
None => return None,
};
// As in the app, a layer recognised but not drawn after all makes no copy.
let drawn = layered && pdfcraft_sign::show_status_layers(&mut display, &mut signatures, &StatusCaptions::default());
if !(added || drawn) {
return None;
}
write_incremental(&display, &SaveOptions::default()).ok().map(Arc::new)
})
.ok()
.flatten()
+81 -4
View File
@@ -3,10 +3,11 @@
use std::sync::Arc;
use crate::{
AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label,
LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState,
Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture,
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit,
InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox,
Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image,
field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted,
xfa, xfa_after_data_change,
};
mod labels;
@@ -119,6 +120,15 @@ pub enum Edit {
bold: Option<bool>,
italic: Option<bool>,
},
/// Write `bookmarks` as children of `parent` from child `index` on, or with `replace` in
/// place of the whole outline (`parent` must then be `[]`). A bookmark whose page this
/// document lacks is kept without a destination (see [`pdfcraft_organize::import_outline`]).
ImportBookmarks {
parent: Vec<usize>,
index: usize,
bookmarks: Vec<pdfcraft_organize::OutlineNode>,
replace: bool,
},
/// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first
/// "Untitled" bookmark (New Bookmarks from Structure).
BookmarksFromStructure,
@@ -157,6 +167,17 @@ pub enum Edit {
file: MarkFile,
author: String,
},
/// Rich Media ▸ Add 3D: a U3D or PRC `model` as a 3D annotation in `rect` on `page`, named
/// `name` (its alternate description). Its poster is the picture in `poster` (a PDF page or an
/// image) fitted inside `rect`, keeping its shape and centred, or a labelled frame without one.
AddModel3d {
page: usize,
rect: [f64; 4],
model: Arc<Vec<u8>>,
poster: Option<MarkFile>,
activation: pdfcraft_annot::Activation3d,
name: String,
},
/// Delete the comment at `index` in the page's `/Annots`, with its pop-up and replies.
DeleteAnnotation {
page: usize,
@@ -243,12 +264,35 @@ pub enum Edit {
index: usize,
fill: Option<Rgb>,
},
/// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an
/// opacity of its own (0–1), the line or border keeping the comment's; `None` leaves that
/// part as it is. `StyleAnnotation`'s opacity sets the whole comment's.
PartOpacity {
page: usize,
index: usize,
fill: Option<f64>,
text: Option<f64>,
},
/// Change the font size (pt) of a text box, typewriter text or callout.
FontSizeAnnotation {
page: usize,
index: usize,
size: f64,
},
/// Give a text box, typewriter text or callout a border colour of its own (`None`: the
/// text's again).
BorderColorAnnotation {
page: usize,
index: usize,
color: Option<Rgb>,
},
/// Give a text box or typewriter text an oval, a circle or a rectangle for its border: the
/// text box keeps its place, the oval or circle goes around it.
BorderShapeAnnotation {
page: usize,
index: usize,
shape: BorderShape,
},
/// Comment properties ▸ General / note icon.
SetAnnotationInfo {
page: usize,
@@ -386,6 +430,13 @@ pub enum Edit {
page: usize,
words: Vec<pdfcraft_ocr::PlacedWord>,
},
/// Scan & OCR ▸ Enhance ▸ Scanned document: put the scan images
/// [`ScanCleanJob::run`](crate::scan_clean::ScanCleanJob::run) cleaned in place of the old
/// ones (each object must still be an image). Built by
/// [`Session::apply_scan_clean`](crate::Session::apply_scan_clean).
EnhanceScan {
images: Vec<(pdfcraft_cos::ObjRef, pdfcraft_cos::Stream)>,
},
/// Edit a PDF ▸ Edit text: replace the text of line `line` (from `Document::text_lines`) on
/// `page`, in its own font when it can show it, else in Helvetica.
EditTextLine {
@@ -406,6 +457,12 @@ pub enum Edit {
objects: Vec<ObjectTarget>,
offset: [f64; 2],
},
/// Delete mixed existing and added objects together (references from one inventory of
/// `page`), as one step: what Delete and Cut do to objects selected together.
DeleteObjects {
page: usize,
objects: Vec<ObjectTarget>,
},
/// Edit text in a paragraph box: replace paragraph `block` (from `Document::text_blocks`),
/// rewrapped to the box's width.
EditTextBlock {
@@ -415,6 +472,10 @@ pub enum Edit {
/// Formatting changes (font, size, colour, alignment); default keeps the paragraph's.
style: pdfcraft_edit::BlockStyle,
},
/// Expose supported grouped source text on one page for direct editing.
PrepareText {
page: usize,
},
/// An explicit open-licensed full font program, completing subsets or selected by a user.
RegisterTextFont {
page: usize,
@@ -494,6 +555,13 @@ pub enum Edit {
content: AddedContent,
file: Option<(String, Arc<Vec<u8>>)>,
},
/// Edit ▸ Paste of objects copied together in Edit a PDF ([`Document::copy_objects`](crate::Document::copy_objects)), in this
/// document or another: drawn on top of `page` as they were, moved by `offset` (user space).
PasteObjects {
page: usize,
clip: Arc<crate::ObjectClip>,
offset: [f64; 2],
},
/// Apply redaction marks (all, or those on `pages`): remove what they cover for good.
ApplyRedactions {
pages: Option<Vec<usize>>,
@@ -534,6 +602,15 @@ pub enum Edit {
name: String,
bytes: Arc<Vec<u8>>,
},
/// Paste copied comments (XFDF from [`Session::export_comments`]) onto `page` as new
/// comments: copies under names of their own, so nothing is replaced and pasting twice
/// gives two sets; replies keep their copied parent.
PasteComments {
xfdf: String,
page: usize,
/// Where the copies go: moved by (dx, dy) in user space, or at their own positions.
offset: Option<[f64; 2]>,
},
/// Remove Hidden Information: the chosen categories.
RemoveHidden {
which: Vec<Hidden>,
+20 -5
View File
@@ -38,6 +38,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Set bookmark destination",
"Colour",
"Style",
"Import bookmarks",
"New bookmarks from structure",
"New bookmarks from text styles",
"Number pages",
@@ -68,7 +69,9 @@ pub const EDIT_LABELS: &[&str] = &[
"Add inserted text",
"Add file attachment",
"Add stamp",
"Add 3D model",
"Delete comment",
"Paste comments",
"Edit comment",
"Reply",
"Set status None",
@@ -99,6 +102,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Set alternate text",
"Mark figure as decorative",
"Recognize text",
"Enhance scanned file",
"Run JavaScript",
"Run form script",
"Save as {level}",
@@ -107,6 +111,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Delete document JavaScript",
"Edit document JavaScript",
"Edit text",
"Prepare grouped text",
"Load document font",
"Move object",
"Move objects",
@@ -193,6 +198,7 @@ impl Edit {
// The bookmark Style and Colour menus' own names, already said in every language.
Edit::SetBookmarkColor { .. } => Label::of("Colour"),
Edit::SetBookmarkStyle { .. } => Label::of("Style"),
Edit::ImportBookmarks { .. } => Label::of("Import bookmarks"),
Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"),
Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"),
Edit::NumberPages { .. } => Label::of("Number pages"),
@@ -205,7 +211,9 @@ impl Edit {
Edit::SetMeasurementScale { .. } => Label::of("Set measurement scale"),
Edit::AddAnnotation(a) => Label::of(add_annotation(&a.shape)),
Edit::AddCustomStamp { .. } => Label::of("Add stamp"),
Edit::AddModel3d { .. } => Label::of("Add 3D model"),
Edit::DeleteAnnotation { .. } => Label::of("Delete comment"),
Edit::PasteComments { .. } => Label::of("Paste comments"),
Edit::SetAnnotationContents { .. } => Label::of("Edit comment"),
Edit::ReplyToAnnotation { .. } => Label::of("Reply"),
// The state's name is part of the wording: a language may need to agree it with the rest.
@@ -225,9 +233,13 @@ impl Edit {
Edit::MoveAnnotation { .. } => Label::of("Move comment"),
Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"),
Edit::ResizeAnnotation { .. } => Label::of("Resize comment"),
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => {
Label::of("Change comment properties")
}
Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
| Edit::PartOpacity { .. }
| Edit::FontSizeAnnotation { .. }
| Edit::BorderColorAnnotation { .. }
| Edit::BorderShapeAnnotation { .. }
| Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"),
Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()),
Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()),
Edit::ResetForm { .. } => Label::of("Clear form"),
@@ -248,6 +260,7 @@ impl Edit {
Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"),
Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"),
Edit::AddOcrText { .. } => Label::of("Recognize text"),
Edit::EnhanceScan { .. } => Label::of("Enhance scanned file"),
Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"),
Edit::XfaEvent { .. } => Label::of("Run form script"),
// The level is the standard's own name ("PDF/A-2b"), so it goes in as it stands.
@@ -257,6 +270,7 @@ impl Edit {
Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"),
Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"),
Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"),
Edit::PrepareText { .. } => Label::of("Prepare grouped text"),
Edit::RegisterTextFont { .. } => Label::of("Load document font"),
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
Edit::EditPageImage { change, .. } => Label::of(match change {
@@ -274,6 +288,7 @@ impl Edit {
pdfcraft_edit::ArrangeOrder::SendToBack => "Send to back",
},
ImageEdit::RemoveCrop => "Remove crop",
ImageEdit::Ungroup => "Ungroup artwork",
}),
Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"),
Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"),
@@ -287,9 +302,9 @@ impl Edit {
Edit::AddText { .. } => Label::of("Add text"),
Edit::AddImage { .. } => Label::of("Add image"),
Edit::UpdateContent { .. } => Label::of("Edit content"),
Edit::DeleteContent { .. } => Label::of("Delete content"),
Edit::DeleteContent { .. } | Edit::DeleteObjects { .. } => Label::of("Delete content"),
Edit::ReplaceImage { .. } => Label::of("Replace image"),
Edit::PasteContent { .. } => Label::of("Paste"),
Edit::PasteContent { .. } | Edit::PasteObjects { .. } => Label::of("Paste"),
Edit::ApplyRedactions { .. } => Label::of("Apply redactions"),
Edit::ClearRedactions => Label::of("Remove redaction marks"),
Edit::RemoveHidden { .. } => Label::of("Remove hidden information"),
+56 -5
View File
@@ -1,6 +1,6 @@
use super::{
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
};
use crate::SourceProblem;
use crate::sources::{comment_source, import_pdf_comments};
@@ -27,6 +27,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::SetBookmarkPage { .. }
| Edit::SetBookmarkColor { .. }
| Edit::SetBookmarkStyle { .. }
| Edit::ImportBookmarks { .. }
| Edit::BookmarksFromStructure
| Edit::NumberPages { .. } => {
if p.assemble() {
@@ -48,7 +49,9 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
}
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::PasteComments { .. }
| Edit::AddCustomStamp { .. }
| Edit::AddModel3d { .. }
| Edit::DeleteAnnotation { .. }
| Edit::SetAnnotationContents { .. }
| Edit::ReplyToAnnotation { .. }
@@ -62,7 +65,10 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
| Edit::PartOpacity { .. }
| Edit::FontSizeAnnotation { .. }
| Edit::BorderColorAnnotation { .. }
| Edit::BorderShapeAnnotation { .. }
| Edit::SetAnnotationInfo { .. }
| Edit::SetMeasurementScale { .. } => {
if p.annotate() {
@@ -105,9 +111,11 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::AddImage { .. }
| Edit::UpdateContent { .. }
| Edit::MoveObjects { .. }
| Edit::DeleteObjects { .. }
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::PasteContent { .. }
| Edit::PasteObjects { .. }
| Edit::ClearRedactions
| Edit::RemoveHidden { .. }
| Edit::ImportData { .. }
@@ -136,11 +144,13 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
| Edit::MarkDecorative { .. }
| Edit::MarkTagArtifact { .. }
| Edit::AddOcrText { .. }
| Edit::EnhanceScan { .. }
| Edit::SetDocumentScript { .. }
| Edit::SetFieldScript { .. }
| Edit::RemoveStandardId { .. }
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::PrepareText { .. }
| Edit::RegisterTextFont { .. }
| Edit::EditPageImage { .. }
| Edit::Flatten { .. }
@@ -186,6 +196,9 @@ pub(crate) struct EditCtx {
/// The overlay streams the edit's applied redactions added, which alone the renderer's check
/// leaves out.
pub(crate) redaction_overlays: Vec<pdfcraft_cos::ObjRef>,
/// What [`Edit::PrepareText`] prepared, repaired and left grouped, for
/// [`Session::prepare_text`](crate::Session::prepare_text).
pub(crate) prepared: Option<pdfcraft_edit::text::forms::Preparation>,
}
impl EditCtx {
@@ -215,6 +228,7 @@ impl EditCtx {
rng: getrandom::fill,
renamed_fields: Vec::new(),
redaction_overlays: Vec::new(),
prepared: None,
}
}
@@ -299,6 +313,7 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order),
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
ImageEdit::Ungroup => pdfcraft_edit::ImageChange::Ungroup,
};
let page_model = pdfcraft_model::pages(doc)
.into_iter()
@@ -404,7 +419,7 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi
ai.image = new_image;
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
}
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => {
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete | ImageEdit::Ungroup => {
pdfcraft_edit::change_image(doc, page, index, &c)?;
}
}
@@ -454,6 +469,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::SetBookmarkPage { path, page } => pdfcraft_organize::set_bookmark_page(doc, path, *page)?,
Edit::SetBookmarkColor { path, color } => pdfcraft_organize::set_bookmark_color(doc, path, *color)?,
Edit::SetBookmarkStyle { path, bold, italic } => pdfcraft_organize::set_bookmark_style(doc, path, *bold, *italic)?,
Edit::ImportBookmarks { parent, index, bookmarks, replace } => {
pdfcraft_organize::import_outline(doc, parent, *index, bookmarks, *replace)?;
}
Edit::BookmarksFromStructure => {
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
.into_iter()
@@ -489,6 +507,14 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::AddAnnotation(a) => {
pdfcraft_annot::add_annotation(doc, a, &cx.meta())?;
}
Edit::PasteComments { xfdf, page, offset } => {
// Names from the edit's own id generator: unique to this paste within the document.
let prefix = cx.meta().id;
let report = pdfcraft_xfdf::import_comments(doc, xfdf, Some(*page), Some(&prefix), *offset)?;
if report.comments == 0 {
return Err(EditError::Invalid("the clipboard holds no comments".into()));
}
}
Edit::AddCustomStamp { page, rect, name, file, author } => {
let src = mark_source(doc, file)?;
let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0));
@@ -509,6 +535,16 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?;
pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?;
}
Edit::AddModel3d { page, rect, model, poster, activation, name } => {
// A file that is not a model is refused before its poster is brought in.
pdfcraft_annot::check_model(model)?;
let poster = match poster {
Some(file) => Some(model_poster(doc, *rect, file)?),
None => None,
};
let new = pdfcraft_annot::NewModel3d { page: *page, rect: *rect, model, activation: *activation, poster, contents: name };
pdfcraft_annot::add_3d_model(doc, &new, &cx.meta())?;
}
Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?,
Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?,
Edit::ReplyToAnnotation { page, index, text, author } => {
@@ -525,8 +561,8 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
}
Edit::DeleteAttachment { key } => pdfcraft_organize::delete_attachment(doc, key)?,
Edit::ReplaceText { page, quads, text, author, strike, caret } => {
// One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by
// the strikeout's `/NM` (#2666).
// One `/NM` each: the strikeout is grouped under the caret, and XFDF names that link by
// the caret's `/NM` (#2666).
let (strike_meta, caret_meta) = (cx.meta(), cx.meta());
pdfcraft_annot::add_text_replacement(
doc,
@@ -559,7 +595,10 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
)?;
}
Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?,
Edit::PartOpacity { page, index, fill, text } => pdfcraft_annot::set_part_opacity(doc, *page, *index, *fill, *text, &cx.meta())?,
Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?,
Edit::BorderColorAnnotation { page, index, color } => pdfcraft_annot::set_border_color(doc, *page, *index, *color, &cx.meta())?,
Edit::BorderShapeAnnotation { page, index, shape } => pdfcraft_annot::set_border_shape(doc, *page, *index, *shape, &cx.meta())?,
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
}
@@ -687,13 +726,25 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?,
Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?,
Edit::AddOcrText { page, words } => {
pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?;
crate::ocr::layer::add_text(doc, *page, words)?;
}
Edit::EnhanceScan { images } => crate::scan_clean::write_back(doc, images)?,
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
Edit::DeleteObjects { page, objects } => crate::object_clip::delete(doc, *page, objects, |doc, edit| run_edit(doc, edit, cx))?,
Edit::PasteObjects { page, clip, offset } => crate::object_clip::paste(doc, *page, clip, *offset)?,
Edit::EditTextBlock { page, block, text, style } => {
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
}
Edit::PrepareText { page } => {
let result = pdfcraft_edit::text::forms::prepare(doc, *page)?;
if result.expanded == 0 && result.repaired_fonts == 0 {
return Err(EditError::Invalid(
result.refused.first().cloned().unwrap_or_else(|| "this page has no supported grouped text to prepare".into()),
));
}
cx.prepared = Some(result);
}
Edit::RegisterTextFont { page, bytes } => {
pdfcraft_edit::text::register_text_font(doc, *page, bytes)?;
}
+6 -5
View File
@@ -1,8 +1,8 @@
//! Export a PDF ▸ Image and Text (execution plan M10.3, the first formats).
//!
//! [`Exporter`] renders pages to PNG, JPEG or TIFF at a resolution and extracts reading-order
//! text, from a
//! document's working file (so unsaved edits and hidden layers are respected, as on screen).
//! text, from the copy a document displays ([`Document::export_source`]): its working file, so
//! unsaved edits and hidden layers are respected, with what is drawn for display only.
use std::collections::BTreeMap;
use std::panic::{AssertUnwindSafe, catch_unwind};
@@ -67,9 +67,10 @@ pub struct ExportSource {
impl Document {
/// What an export reads, for exporting on another thread. These are the displayed
/// bytes, so they may carry missing comment appearances PdfCraft drew. Signature
/// artwork stays original; viewer validation badges are not document content and
/// do not enter image/text exports or Print as Image. Saving uses the working file.
/// bytes, so they may carry appearances PdfCraft drew that the file itself does not hold:
/// missing comment appearances, and the signature states of named-layer signature
/// appearances. Exports and Print as Image follow the page as shown; the viewer's badges
/// are not document content and never enter them. Saving uses the working file.
pub fn export_source(&self) -> ExportSource {
ExportSource {
bytes: self.display.clone(),
+14 -1
View File
@@ -53,7 +53,9 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::PasteComments { .. }
| Edit::AddCustomStamp { .. }
| Edit::AddModel3d { .. }
| Edit::DeleteAnnotation { .. }
| Edit::SetAnnotationContents { .. }
| Edit::ReplyToAnnotation { .. }
@@ -67,26 +69,37 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
| Edit::ResizeAnnotation { .. }
| Edit::StyleAnnotation { .. }
| Edit::FillAnnotation { .. }
| Edit::PartOpacity { .. }
| Edit::FontSizeAnnotation { .. }
| Edit::BorderColorAnnotation { .. }
| Edit::BorderShapeAnnotation { .. }
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links,
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
// Page content, resources and added content/marks only: the page tree, annotations,
// fields, links and document metadata are untouched (see `refresh_content`).
Edit::MoveObjects { .. }
| Edit::DeleteObjects { .. }
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::PrepareText { .. }
| Edit::EditPageImage { .. }
| Edit::EnhanceScan { .. }
| Edit::AddText { .. }
| Edit::AddImage { .. }
| Edit::UpdateContent { .. }
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::PasteContent { .. }
| Edit::PasteObjects { .. }
| Edit::AddHeaderFooter { .. }
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
| Edit::RemoveMarks { .. } => Scope::Content,
| Edit::RemoveMarks { .. }
// Recognized text is an invisible text layer stamped on its page, with its font in the
// page's resources (#6310): a full re-inspection made applying one word cost seconds on
// a large file.
| Edit::AddOcrText { .. } => Scope::Content,
Edit::Batch { edits, .. } => {
let mut scopes = edits.iter().map(scope_of);
let first = scopes.next().unwrap_or(Scope::Full);
+144 -22
View File
@@ -26,29 +26,70 @@ pub struct JsOutput {
pub doc_errors: Vec<String>,
}
/// Console lines one output keeps, from every script an edit runs together (a form's
/// calculations, say): each run's own cap ([`pdfcraft_js::xfa::MAX_CONSOLE`]) would otherwise
/// be multiplied by the number of fields a document declares.
pub const MAX_CONSOLE_LINES: usize = 10_000;
/// Bytes of console text one output keeps, for the same reason.
pub const MAX_CONSOLE_BYTES: usize = 1 << 20;
/// Alerts one output keeps.
pub const MAX_ALERTS: usize = 1_000;
impl JsOutput {
/// Whether scripts produced nothing: no alerts, console lines, requests or errors.
pub fn is_empty(&self) -> bool {
self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty()
}
/// Adds `lines` to the console within [`MAX_CONSOLE_LINES`] and [`MAX_CONSOLE_BYTES`]; what
/// is past them is left out, which one closing line says.
fn say(&mut self, lines: impl IntoIterator<Item = String>) {
let note = format!("more console lines were left out: the scripts printed more than {MAX_CONSOLE_LINES} lines or {MAX_CONSOLE_BYTES} bytes");
// Once the notice is written the console is closed: nothing may follow it, and a later
// rejection must not add a second copy.
if self.console.last() == Some(&note) {
return;
}
let mut bytes: usize = self.console.iter().map(String::len).sum();
let mut dropped = false;
for l in lines {
if self.console.len() >= MAX_CONSOLE_LINES || bytes.saturating_add(l.len()) > MAX_CONSOLE_BYTES {
dropped = true;
break;
}
bytes += l.len();
self.console.push(l);
}
if dropped && !self.console.iter().any(|l| l == &note) {
self.console.push(note);
}
}
/// Adds `alerts` within [`MAX_ALERTS`].
fn alert(&mut self, alerts: impl IntoIterator<Item = Message>) {
let room = MAX_ALERTS.saturating_sub(self.alerts.len());
self.alerts.extend(alerts.into_iter().take(room));
}
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
/// except a request to show a side panel or open Save As, which is noted on the console
/// and dropped.
fn absorb(&mut self, o: &Outcome) {
self.alerts.extend(o.alerts.iter().cloned());
self.console.extend(o.console.iter().cloned());
self.alert(o.alerts.iter().cloned());
self.say(o.console.iter().cloned());
// A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
// button's may show or hide a side panel, or open Save As.
// The warnings go through `say` like any other console line, so they count against the
// line and byte caps instead of growing the console past them.
let mut notes = Vec::new();
for r in &o.requests {
match r {
Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()),
Request::SaveAs => {
self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into())
}
Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()),
Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()),
r => self.requests.push(r.clone()),
}
}
self.say(notes);
self.errors.extend(o.error.iter().cloned());
// The document scripts run before every field script, so a failing one would repeat
// once per calculation; it is reported once.
@@ -61,8 +102,8 @@ impl JsOutput {
}
pub(crate) fn append(&mut self, other: JsOutput) {
self.alerts.extend(other.alerts);
self.console.extend(other.console);
self.alert(other.alerts);
self.say(other.console);
self.requests.extend(other.requests);
// As in `absorb`: a document script's error, already reported, is not repeated; a field
// script's errors are each their own, even one worded like a document script's.
@@ -118,6 +159,7 @@ fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState
s.readonly = f.read_only();
s.required = f.has(pdfcraft_forms::flags::REQUIRED);
s.display = display;
s.rect = f.widgets.first().map(|w| w.rect);
s.char_limit = f.max_len;
s.options = match f.kind {
FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(),
@@ -147,8 +189,9 @@ fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]
read_only: (a.readonly != b.readonly).then_some(a.readonly),
required: (a.required != b.required).then_some(a.required),
display: (a.display != b.display).then_some(a.display),
rect: (a.rect != b.rect).then_some(a.rect).flatten(),
};
(c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some()).then_some(c)
(c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.rect.is_some()).then_some(c)
})
.collect()
}
@@ -199,6 +242,25 @@ impl Scripts for JsRunner {
/// Run field `target`'s script for `event` over the form's fields, keeping what it showed and
/// asked for; a refusal carries its last message.
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
// As in Acrobat, a failing script leaves the value alone (the error goes to the console).
self.run_logged(event, script, target, value, fields, true).unwrap_or_else(|| ScriptResult {
rc: true,
value: value.to_string(),
value_set: false,
changes: Vec::new(),
message: None,
})
}
fn rerun_with_english_dates(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> Option<ScriptResult> {
pdfcraft_js::with_english_printd(true, || self.run_logged(event, script, target, value, fields, false))
}
}
impl JsRunner {
/// [`Scripts::run`], keeping what the script showed and asked for only when `log`; `None`
/// when the script failed.
fn run_logged(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field], log: bool) -> Option<ScriptResult> {
let states: Vec<_> = fields
.iter()
.map(|f| {
@@ -212,17 +274,19 @@ impl Scripts for JsRunner {
let states = std::sync::Arc::new(states);
let ev = pdfcraft_js::Event::field(event.name(), &target.name, value);
let o = pdfcraft_js::run_shared(script, &ev, &self.doc, std::sync::Arc::clone(&states), &self.doc_scripts, Limits::default());
self.output.absorb(&o);
if o.error.is_some() {
// As in Acrobat, a failing script leaves the value alone (the error goes to the console).
return ScriptResult { rc: true, value: value.to_string(), changes: Vec::new(), message: None };
if log {
self.output.absorb(&o);
}
ScriptResult {
if o.error.is_some() {
return None;
}
Some(ScriptResult {
rc: o.rc,
value: o.value.clone(),
value_set: o.value_set,
changes: changes(&states, &o.changed),
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
}
})
}
}
@@ -311,11 +375,9 @@ impl Session {
if !changes.is_empty() {
edits.push(Edit::ApplyScriptChanges { changes });
}
for names in resets {
let all: Vec<String> = fields.iter().map(|f| f.name.clone()).collect();
let names = (!names.is_empty())
.then(|| all.iter().filter(|n| names.iter().any(|x| *n == x || n.starts_with(&format!("{x}.")))).cloned().collect());
edits.push(Edit::ResetForm { names });
if !resets.is_empty() {
let all: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
edits.push(Edit::ResetForm { names: reset_targets(&all, &resets) });
}
if !edits.is_empty() {
self.apply(id, Edit::Batch { label: "Run JavaScript".into(), edits })?;
@@ -324,6 +386,21 @@ impl Session {
}
}
/// The fields every `resetForm` call of one script asked for, as one reset: a reset is to the
/// defaults, so resetting the union once does what each call in turn would, and recalculates
/// once. `None` is the whole form (a call with no names). A field is named by a selector that
/// is its name or the name of a field above it, looked up in a set of the selectors, so the
/// work is the fields times their depth, not the fields times the selectors, which a script
/// controls.
fn reset_targets(fields: &[&str], requests: &[Vec<String>]) -> Option<Vec<String>> {
if requests.iter().any(Vec::is_empty) {
return None;
}
let wanted: std::collections::HashSet<&str> = requests.iter().flatten().map(String::as_str).collect();
let matched = |name: &str| name.match_indices('.').map(|(i, _)| &name[..i]).chain(std::iter::once(name)).any(|p| wanted.contains(p));
Some(fields.iter().filter(|n| matched(n)).map(|n| n.to_string()).collect())
}
/// A page's words for form-field detection, in user space (underscore runs split from the
/// text around them).
fn detection_words(text: &pdfcraft_render::PageText, info: &pdfcraft_render::PageInfo) -> Vec<pdfcraft_forms::detect::Word> {
@@ -410,12 +487,16 @@ impl Session {
let mut out = Vec::new();
for page in list {
let Some(info) = doc.info.pages.get(page) else { continue };
let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
let shapes = pdfcraft_forms::detect::page_shapes(cos, page);
let existing: Vec<[f64; 4]> = doc.form.iter().flat_map(|f| f.widgets.iter()).filter(|w| w.page == Some(page)).map(|w| w.rect).collect();
let (found, turn) = if shapes.boxes.is_empty() && shapes.rules.is_empty() && crate::scan_fields::is_scan(doc, page, info) {
self.detect_on_scan(id, &mut r, page, res.text.as_deref(), &existing, &taken)
// Labels come from the page's own text. Words that only /ActualText names (a stamp
// drawn as outlines) are no labels, and leave the scan to be read.
let own = crate::ocr::own_text(&mut r, page).ok();
self.detect_on_scan(id, &mut r, page, own.as_ref(), &existing, &taken)
} else {
let res =
r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
let words = res.text.map(|t| detection_words(&t, info)).unwrap_or_default();
(pdfcraft_forms::detect::detect(&words, &shapes, &existing, &taken), 0)
};
@@ -469,6 +550,47 @@ impl Session {
mod review_tests {
use super::*;
/// Many `resetForm` calls with many selectors over a big form: one reset of the union, found
/// by looking each field's name and parents up, not by comparing every field with every
/// selector.
#[test]
fn reset_requests_are_coalesced_and_matched_through_an_index() {
let fields: Vec<String> = (0..20_000).map(|i| format!("group{}.row{i}.cell", i % 100)).collect();
let names: Vec<&str> = fields.iter().map(String::as_str).collect();
let requests: Vec<Vec<String>> = (0..1_000).map(|k| (0..20).map(|j| format!("nothing{}", k * 20 + j)).collect()).collect();
let started = std::time::Instant::now();
assert_eq!(reset_targets(&names, &requests), Some(Vec::new()), "no selector names a field");
let mut requests = requests;
requests.push(vec!["group7".into(), "group7.row7.cell".into(), "group8.row8".into()]);
let hit = reset_targets(&names, &requests).unwrap();
assert_eq!(hit.len(), 200 + 1, "group7 has 200 fields, one more by its exact name");
assert!(hit.iter().all(|n| n.starts_with("group7.") || n == "group8.row8.cell"));
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
requests.push(Vec::new());
assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form");
}
/// A form of many calculated fields, each printing its own cap of console lines, keeps a
/// bounded console: past the budget, lines are left out and one closing line says so.
#[test]
fn console_output_of_one_edit_is_bounded() {
let mut out = JsOutput::default();
let line = "x".repeat(4_096);
let run = Outcome { console: vec![line; 1_000], alerts: vec![Message::plain("hi"); 100], ..Default::default() };
for _ in 0..1_000 {
out.absorb(&run);
}
let bytes: usize = out.console.iter().map(String::len).sum();
assert!(bytes <= MAX_CONSOLE_BYTES + 256, "{bytes} bytes kept");
assert!(out.console.len() <= MAX_CONSOLE_LINES + 1, "{} lines kept", out.console.len());
assert!(out.console.last().unwrap().starts_with("more console lines were left out"));
assert_eq!(out.console.iter().filter(|l| l.starts_with("more console lines")).count(), 1, "said once");
assert_eq!(out.alerts.len(), MAX_ALERTS);
let mut merged = JsOutput::default();
merged.append(out);
assert!(merged.console.len() <= MAX_CONSOLE_LINES + 1);
}
/// A long run of underscores (a form's blank) is read in one pass: each glyph is compared with
/// the word so far without reading the word again.
#[test]
+112 -3
View File
@@ -6,10 +6,102 @@ use pdfcraft_render::{Layer, LayerOp, RenderPool};
use crate::{Document, display_config, render_threads};
/// Most usage application dictionaries (`/AS` entries) that [`print_layer_states`] reads, and
/// most category names and groups it reads in all of them together (entries can share one
/// array, so the count is over every entry, not each).
const MAX_AUTO_STATES: usize = 1024;
const MAX_AUTO_ITEMS: usize = 65_536;
/// The usage dictionary entry and its state key that a `/Category` name of a usage application
/// dictionary reads when printing (ISO 32000-2 Table 101): Print, View and Export each name a
/// state of the group's own. Zoom, User and Language depend on the magnification, the user and
/// the system language, which printing doesn't fix, so they give no recommendation here.
fn print_category(name: &[u8]) -> Option<(&'static [u8], &'static [u8])> {
match name {
b"Print" => Some((b"Print", b"PrintState")),
b"View" => Some((b"View", b"ViewState")),
b"Export" => Some((b"Export", b"ExportState")),
_ => None,
}
}
/// The layers as they print: `layers` as shown, then the default configuration's auto state for
/// the Print event (ISO 32000-2 §8.11.4.4, Table 101). Each usage application dictionary
/// (`/AS` entry) whose `/Event` is `/Print` recommends, for each group in its `/OCGs` and each of
/// its categories ([`print_category`]), the state the group's usage dictionary gives (for the
/// Print category, `/Usage /Print /PrintState`). A group with recommendations is on only if all
/// of them, in all the entries naming it, are ON; other groups keep their state. `/Category` is
/// an array; a single name is read as one. `None` when no `/AS` entry is for printing.
pub(crate) fn print_layer_states(cos: &pdfcraft_cos::Document, layers: &[Layer]) -> Option<Vec<Layer>> {
use pdfcraft_cos::Object;
let root = cos.trailer().get(b"Root").map(|r| cos.resolve(r))?;
let properties = root.as_dict()?.get(b"OCProperties").map(|p| cos.resolve(p))?;
let config = properties.as_dict()?.get(b"D").map(|d| cos.resolve(d))?;
let auto = config.as_dict()?.get(b"AS").map(|a| cos.resolve(a))?;
let mut printing = false;
let mut recommended: std::collections::HashMap<(u32, u16), bool> = std::collections::HashMap::new();
let mut items_left = MAX_AUTO_ITEMS;
for entry in auto.as_array()?.iter().take(MAX_AUTO_STATES) {
let entry = cos.resolve(entry);
let Some(entry) = entry.as_dict() else { continue };
if entry.get(b"Event").map(|e| cos.resolve(e)).as_deref().and_then(Object::as_name) != Some(b"Print") {
continue;
}
printing = true;
let categories = entry.get(b"Category").map(|c| cos.resolve(c));
let mut read: Vec<(&[u8], &[u8])> = Vec::new();
let mut consider = |name: Option<&[u8]>| {
if let Some(category) = name.and_then(print_category).filter(|c| !read.contains(c)) {
read.push(category);
}
};
match categories.as_deref() {
Some(Object::Array(names)) => {
let count = names.len().min(items_left);
items_left -= count;
names.iter().take(count).for_each(|n| consider(cos.resolve(n).as_name()));
}
Some(Object::Name(n)) => consider(Some(n.as_slice())),
_ => {}
}
if read.is_empty() {
continue;
}
let Some(groups) = entry.get(b"OCGs").map(|g| cos.resolve(g)) else { continue };
for group in groups.as_array().into_iter().flatten() {
let Some(left) = items_left.checked_sub(1) else { break };
items_left = left;
let Some(id) = group.as_ref() else { continue };
let ocg = cos.resolve(group);
let usage = ocg.as_dict().and_then(|g| g.get(b"Usage").map(|u| cos.resolve(u)));
for (entry_key, state_key) in &read {
// No state given (a Print category without `/PrintState`): no recommendation, so
// the group is left as it is.
let state = usage
.as_deref()
.and_then(|u| u.as_dict().and_then(|u| u.get(entry_key).map(|e| cos.resolve(e))))
.and_then(|e| e.as_dict().and_then(|e| e.get(state_key).map(|s| cos.resolve(s))))
.and_then(|s| match s.as_name() {
Some(b"ON") => Some(true),
Some(b"OFF") => Some(false),
_ => None,
});
if let Some(on) = state {
let all_on = recommended.entry((id.num, id.generation)).or_insert(true);
*all_on = *all_on && on;
}
}
}
}
printing.then(|| layers.iter().map(|l| Layer { visible: recommended.get(&l.id).copied().unwrap_or(l.visible), ..l.clone() }).collect())
}
/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy
/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its
/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties`
/// is left alone.
/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. The
/// states written are final, so what would set them again goes: the auto state (`/AS`), and the
/// layers' own `/Usage`, which some readers apply when they draw (a `/ViewState /ON` turns a layer
/// that prints off back on). The configuration's other entries (`/Order`, `/RBGroups`…) are kept.
/// A document without `/OCProperties` is left alone.
pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) {
use pdfcraft_cos::{Dict, ObjRef, Object};
let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return };
@@ -22,18 +114,35 @@ pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers:
config.set(b"BaseState".to_vec(), Object::name("ON"));
config.set(b"ON".to_vec(), refs(true));
config.set(b"OFF".to_vec(), refs(false));
config.remove(b"AS");
properties.set(b"D".to_vec(), Object::Dict(config));
root.set(b"OCProperties".to_vec(), Object::Dict(properties));
cos.set(root_ref, Object::Dict(root));
for layer in layers {
let id = ObjRef::new(layer.id.0, layer.id.1);
let Some(mut group) = cos.get(id).as_dict().cloned() else { continue };
if group.remove(b"Usage").is_some() {
cos.set(id, Object::Dict(group));
}
}
}
/// Render `doc` with the layer visibility in its `info.layers`.
pub(crate) fn use_layer_choices(doc: &mut Document) {
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
doc.config.layers = Arc::new(overrides);
doc.config.print_layers = print_preview_layers(doc.editor.as_ref().map(|e| &e.cos), &doc.info.layers);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// The layer overrides a print preview draws with (`RenderConfig::print_layers`): the layers as
/// they print ([`print_layer_states`]), so the preview shows the page as it prints. `None` when
/// the document has no auto state for printing (or no structure to read it from).
pub(crate) fn print_preview_layers(cos: Option<&pdfcraft_cos::Document>, layers: &[Layer]) -> Option<Arc<Vec<(i32, i32, bool)>>> {
let printed = print_layer_states(cos?, layers)?;
Some(Arc::new(printed.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect()))
}
/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the
/// state the changes before it left. Returns whether any layer ended up changed.
pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool {
+28 -11
View File
@@ -24,6 +24,7 @@ pub mod commands;
mod comments;
pub mod compare;
mod copy_image;
pub use copy_image::{PagePicture, picture_at};
pub mod dates;
mod document;
mod edit;
@@ -35,6 +36,8 @@ mod history;
pub mod js;
mod layers;
pub mod links;
pub mod model3d;
mod object_clip;
pub mod ocr;
mod open;
pub mod optimizer;
@@ -43,6 +46,8 @@ mod pictures;
pub mod print_raster;
pub mod printcheck;
mod protection;
mod rich_copy;
pub mod scan_clean;
mod scan_colours;
mod scan_fields;
mod session;
@@ -66,19 +71,23 @@ pub use links::{
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
};
pub use pdfcraft_edit::strip_subset_tag;
pub use rich_copy::RichCopy;
pub use signature_image::{ImageSignaturePreview, SignatureImage};
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
pub use stamp_preview::{CommentLayer, StampPreview};
pub use text_preview::{TextEditPreview, UnmarkedPages};
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
pub use pdfcraft_organize::{
BoxSpec, FieldConflicts, MAX_IMPORT_BOOKMARKS, OutlineNode, OutlineTree, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name,
split_ranges, unmapped as outline_unmapped, view_arity as outline_view_arity,
};
pub use pdfcraft_organize::LabelStyle;
pub use pdfcraft_organize::Standard;
pub use pdfcraft_organize::check_custom_key;
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
pub use pdfcraft_cos::Algorithm;
pub use pdfcraft_cos::{Algorithm, civil_from_days};
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
pub use pdfcraft_edit::added::{
CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,
@@ -86,7 +95,8 @@ pub use pdfcraft_edit::added::{
};
pub use pdfcraft_edit::paragraph_align;
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, Ink as TextInk,
MarkKind, Watermark,
};
pub use pdfcraft_forms::{
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
@@ -100,17 +110,19 @@ pub use pdfcraft_edit::{Backdrop, BlockKey, BlockStyle, PageImage, RangeStyle, T
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
pub use object_clip::{ObjectClip, ObjectClipboard};
pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message};
/// Comment geometry helpers (text-box line breaking) for frontends.
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle, LinkZoom};
pub use pdfcraft_annot::{
AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps,
ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
Activation3d, AppearanceCache, AttachIcon, BorderShape, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont,
OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
};
pub use pdfcraft_annot::{MAX_MODEL_BYTES as MAX_3D_MODEL_BYTES, ModelFormat as Model3dFormat};
pub use pdfcraft_forms::detect;
pub use pdfcraft_optimize as optimize;
pub use pdfcraft_preflight as pdfa;
@@ -120,9 +132,11 @@ pub use pdfcraft_redact::patterns::{
MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list,
};
pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
pub use pdfcraft_redact::user_regex::{UserRegex as RedactRegex, VALIDATORS as REDACT_VALIDATORS, Validator as RedactValidator};
pub use pdfcraft_sign as sign;
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore};
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, StatusCaptions, TrustStore};
pub use pdfcraft_xfdf::Format as DataFormat;
pub use pdfcraft_xfdf::{Outline as CommentOutline, comment_count, comment_outlines, comments_bounds, is_xfdf_comments};
use bookmarks::outline_rows;
pub use bookmarks::{BookmarkPage, BookmarkRow};
@@ -131,23 +145,26 @@ pub use comments::{SummarySort, comment_kind, comment_summary, comment_type_name
#[cfg(test)]
use document::page_runs;
pub use document::{Document, EditNotice, TextNotice, display_bytes_for, render_password_for};
use document::{display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices};
use document::{carry_verdict_marks, display_bytes, info_fields, recovered_render_password, same_pages, shown_under_redactions, undrawn_notices};
pub use edit::{BATCH_LABELS, EDIT_LABELS, Edit};
pub use error::{EditError, SourceProblem};
pub use fill_sign::{displayed_offset, text_box_at, typed_signature_graphic, typed_signature_shape};
pub use history::Label;
use layers::{apply_layer_state, set_default_layer_state, use_layer_choices};
use layers::{apply_layer_state, print_layer_states, print_preview_layers, set_default_layer_state, use_layer_choices};
pub use open::{OpenContext, PreparedDocument};
pub use page_filter::{PageOrientation, PageParity, filter_pages};
pub use pictures::{MarkFile, image_dimensions};
use pictures::{embed_image, fit_picture, mark_source};
use pictures::{embed_image, fit_picture, mark_source, model_poster};
use protection::unrestricted;
pub use protection::{Changes, Printing, Protection, SecuritySummary};
pub use session::Session;
pub use sources::{CombineSource, Combined, combine_source_check, source_page_count};
use sources::{open_source, open_source_with};
#[cfg(test)]
use test_hooks::{check_refresh_panic, panic_on_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts, secondary_scan_counts};
use test_hooks::{
check_refresh_panic, panic_on_refresh, record_full_refresh, record_link_scan, record_marker_scan, reset_secondary_scan_counts,
secondary_scan_counts, take_full_refreshes,
};
use xfa_form::{
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, decode_data_file, image_content_type, is_xfa_data_file, note_warnings, strip_xfa, sync_xfa_state,
undrawn_field_warnings, xfa_after_data_change, xfa_layout, xfa_redraw, xfa_sync_datasets, xfa_values_from_datasets,
+19
View File
@@ -0,0 +1,19 @@
//! The 3D models on a page (ISO 32000-2 §13.6), read so a renderer can draw them.
pub use pdfcraft_annot::{MAX_MODEL_BYTES, Model3d, Model3dKind};
use crate::Document;
impl Document {
/// The `/Annots` positions of the 3D annotations on 0-based `page`, in order.
pub fn model_3d_annotations(&self, page: usize) -> Result<Vec<usize>, String> {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
pdfcraft_annot::model_3d_annotations(&editor.cos, page).map_err(|e| e.to_string())
}
/// The decoded model of the 3D annotation at `/Annots` position `index` on 0-based `page`.
pub fn model_3d(&self, page: usize, index: usize) -> Result<Model3d, String> {
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
pdfcraft_annot::model_3d(&editor.cos, page, index).map_err(|e| e.to_string())
}
}
+211
View File
@@ -0,0 +1,211 @@
//! Edit ▸ Copy, Cut and Paste of objects selected together in Edit a PDF (#5989).
//!
//! A copy ([`ObjectClip`]) holds what the objects draw and the resources that drawing names
//! ([`pdfcraft_edit::copy_objects`]), with a snapshot of the document they came from, so it can
//! be pasted into any open document, also after the original changes or closes. Within the same
//! document, while the objects it refers to are unchanged, a paste draws them again from the same
//! fonts and images; otherwise it first copies them over ([`pdfcraft_organize::import_value`]):
//! each once, through cycles, sharing what the target already holds, and within a size limit.
use std::sync::Arc;
use pdfcraft_cos::Object;
use crate::{DocId, Document, Edit, EditError, EditableObject, ObjectTarget};
/// The most objects a paste copies into another document.
const MAX_IMPORT_OBJECTS: usize = 20_000;
/// The most stream data (fonts, images…) a paste copies into another document.
const MAX_IMPORT_BYTES: usize = 256 << 20;
/// How far each further paste onto the same page steps down and to the right, in points, as
/// pasted added content steps ([`pdfcraft_edit::added::PASTE_STEP`]).
const STEP: f64 = pdfcraft_edit::added::PASTE_STEP;
/// Objects copied from one page of a document.
#[derive(Clone)]
pub struct ObjectClip {
copied: pdfcraft_edit::CopiedObjects,
/// The document as it was when copied: what the copy's resources refer to.
source: pdfcraft_cos::Document,
doc: DocId,
page: usize,
}
impl PartialEq for ObjectClip {
fn eq(&self, other: &Self) -> bool {
self.copied == other.copied
&& self.doc == other.doc
&& self.page == other.page
&& self.source.source_identity() == other.source.source_identity()
}
}
impl std::fmt::Debug for ObjectClip {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ObjectClip").field("doc", &self.doc).field("page", &self.page).field("objects", &self.copied.objects.len()).finish()
}
}
impl ObjectClip {
/// The objects as they were listed when copied ([`Document::editable_objects`]).
pub fn objects(&self) -> &[EditableObject] {
&self.copied.objects
}
/// The text of the copied paragraphs and text boxes, one per line.
pub fn text(&self) -> String {
self.copied.text()
}
/// The union of the objects' boxes, in the user space of the page they came from.
pub fn rect(&self) -> [f64; 4] {
self.copied.rect
}
/// The document and page the objects were copied from.
pub fn source(&self) -> (DocId, usize) {
(self.doc, self.page)
}
}
impl Document {
/// Edit ▸ Copy of `targets` (references from one [`Document::editable_objects`] inventory of
/// `page`). Refused when the document doesn't allow copying. Changes nothing.
pub fn copy_objects(&self, page: usize, targets: &[ObjectTarget]) -> Result<ObjectClip, EditError> {
self.check_copying()?;
let editor = self.editor.as_ref().ok_or_else(|| EditError::ReadOnly("the document can't be read".into()))?;
let copied = pdfcraft_edit::copy_objects(&editor.cos, page, targets)?;
Ok(ObjectClip { copied, source: editor.cos.clone(), doc: self.id, page })
}
/// Where Paste puts `clip` on `page`: the user-space offset that moves it `steps` × 18 pt down
/// and to the right as displayed, or, when that leaves nothing of it on the page's crop box,
/// the offset that centres it there.
pub fn object_paste_offset(&self, page: usize, clip: &ObjectClip, steps: u32) -> Result<[f64; 2], String> {
let step = STEP * f64::from(steps);
let offset = self.object_move_offset(page, [step, step])?;
let p = self.info.pages.get(page).ok_or("the page no longer exists")?;
let crop = p.crop.map(f64::from);
let r = clip.rect();
let moved = [r[0] + offset[0], r[1] + offset[1], r[2] + offset[0], r[3] + offset[1]];
if moved[0] < crop[2] && moved[2] > crop[0] && moved[1] < crop[3] && moved[3] > crop[1] {
return Ok(offset);
}
let centre = |a: f64, b: f64| (a + b) / 2.0;
Ok([centre(crop[0], crop[2]) - centre(r[0], r[2]), centre(crop[1], crop[3]) - centre(r[1], r[3])])
}
}
/// The objects copied in an application or a script, and how many times each page has had them
/// pasted, which sets how far the next paste there steps ([`Document::object_paste_offset`]).
#[derive(Clone, Debug)]
pub struct ObjectClipboard {
clip: Arc<ObjectClip>,
pastes: Vec<(DocId, usize, u32)>,
}
impl ObjectClipboard {
pub fn new(clip: ObjectClip) -> Self {
ObjectClipboard { clip: Arc::new(clip), pastes: Vec::new() }
}
pub fn clip(&self) -> &ObjectClip {
&self.clip
}
/// The edit that pastes the copy onto `page` of `doc`: where it was, or on the page it came
/// from one step down and to the right (as Acrobat offsets a paste onto the same page), and
/// one step further for each earlier paste onto that page.
pub fn paste(&mut self, doc: &Document, page: usize) -> Result<Edit, String> {
let at = self.pastes.iter().position(|(d, p, _)| *d == doc.id && *p == page);
let earlier = at.and_then(|i| self.pastes.get(i)).map_or(0, |(_, _, n)| *n);
let own = u32::from(self.clip.source() == (doc.id, page));
let offset = doc.object_paste_offset(page, &self.clip, earlier.saturating_add(own))?;
match at.and_then(|i| self.pastes.get_mut(i)) {
Some(entry) => entry.2 = entry.2.saturating_add(1),
None => self.pastes.push((doc.id, page, 1)),
}
Ok(Edit::PasteObjects { page, clip: Arc::clone(&self.clip), offset })
}
}
impl Edit {
/// The edit that deletes `targets` (indexes into one inventory of `page`) as one undo step:
/// what Delete and Cut do to objects selected together. One object is deleted by its own
/// edit, several by [`Edit::DeleteObjects`]; none is no edit.
pub fn delete_objects(page: usize, mut targets: Vec<ObjectTarget>) -> Option<Edit> {
targets.sort();
targets.dedup();
match targets.as_slice() {
[] => None,
[t] => Some(delete_one(page, *t)),
_ => Some(Edit::DeleteObjects { page, objects: targets }),
}
}
}
fn delete_one(page: usize, t: ObjectTarget) -> Edit {
use crate::ObjectKind;
match t.kind {
ObjectKind::Text => Edit::EditTextBlock { page, block: t.index, text: String::new(), style: crate::BlockStyle::default() },
ObjectKind::Image => Edit::EditPageImage { page, index: t.index, change: crate::ImageEdit::Delete },
ObjectKind::Added => Edit::DeleteContent { page, index: t.index },
}
}
/// [`Edit::DeleteObjects`], each object by its own edit (`run`). A deletion renumbers what follows
/// it in its own inventory, so pictures go from the last to the first, and added content, which
/// the other inventories leave out, after them. Deleting a paragraph also removes a copy drawn
/// exactly over it (fake bold, a paste onto itself), which renumbers paragraphs before it too: so
/// each paragraph is found again by its text and box, and one already gone with another is done.
pub(crate) fn delete(
doc: &mut pdfcraft_cos::Document,
page: usize,
objects: &[ObjectTarget],
mut run: impl FnMut(&mut pdfcraft_cos::Document, &Edit) -> Result<(), EditError>,
) -> Result<(), EditError> {
use crate::ObjectKind;
let mut targets = objects.to_vec();
targets.sort_by_key(|t| (t.kind == ObjectKind::Added, std::cmp::Reverse(t.index)));
targets.dedup();
let blocks = pdfcraft_edit::text_blocks(doc, page)?;
for t in targets {
let edit = match t.kind {
ObjectKind::Text => {
let key = blocks.get(t.index).map(crate::BlockKey::of).ok_or_else(|| {
EditError::Edit(pdfcraft_edit::EditError::Invalid("a selected paragraph no longer exists; select the objects again".into()))
})?;
let Some(block) = key.find(&pdfcraft_edit::text_blocks(doc, page)?, t.index) else { continue };
delete_one(page, ObjectTarget { kind: t.kind, index: block })
}
_ => delete_one(page, t),
};
run(doc, &edit)?;
}
Ok(())
}
/// [`Edit::PasteObjects`]: the copy's resources as `doc` holds them, then the paste.
pub(crate) fn paste(doc: &mut pdfcraft_cos::Document, page: usize, clip: &ObjectClip, offset: [f64; 2]) -> Result<(), EditError> {
let resources = Object::Dict(clip.copied.resources.clone());
let refused =
|e: pdfcraft_organize::OrganizeError| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("the copied objects can't be pasted: {e}")));
let used = pdfcraft_organize::reachable(&clip.source, &resources, MAX_IMPORT_OBJECTS, MAX_IMPORT_BYTES).map_err(refused)?;
// Still the objects the copy was made from: the same document, and each of them unchanged.
let same = doc.source_identity() == clip.source.source_identity()
&& used.iter().all(|r| {
let (here, there) = (doc.get(*r), clip.source.get(*r));
Arc::ptr_eq(&here, &there) || here == there
});
let resources = if same { resources } else { pdfcraft_organize::import_value(doc, &clip.source, &resources).map_err(refused)? };
let Object::Dict(resources) = resources else {
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the copied objects' resources could not be copied".into())));
};
let copied = pdfcraft_edit::CopiedObjects { resources, ..clip.copied.clone() };
pdfcraft_edit::paste_objects(doc, page, &copied, offset)?;
Ok(())
}
#[cfg(test)]
#[path = "object_clip_tests.rs"]
mod tests;
+257
View File
@@ -0,0 +1,257 @@
use super::*;
use crate::{ObjectKind, Session};
/// A PDF of 300 × 300 pages, each drawing its `content` with `resources` and objects `extra`
/// (numbered after the pages' content streams).
fn pdf(pages: &[(&str, &str)], extra: &[String]) -> Arc<Vec<u8>> {
let n = pages.len();
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 3 + i)).collect();
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 300 300] >>", kids.join(" ")),
];
for (i, (_, resources)) in pages.iter().enumerate() {
objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources {resources} >>", 3 + n + i));
}
for (content, _) in pages {
objs.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()));
}
objs.extend(extra.iter().cloned());
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());
Arc::new(out)
}
const HELVETICA: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>";
const COURIER: &str = "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>";
/// A 2 × 2 RGB picture of the twelve bytes `data` (ASCII, so the file's lengths are its bytes).
fn picture(data: &str) -> String {
assert!(data.is_ascii() && data.len() == 12);
format!(
"<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 12 >>\nstream\n{data}\nendstream"
)
}
/// A one-page document whose paragraph "Copied" (blue, half opaque, Helvetica /F1 at objects 5)
/// and picture /Im0 (object 6) are copied; "Left" is not.
fn source() -> Arc<Vec<u8>> {
let content = "0 0 1 rg /GS0 gs BT /F1 24 Tf 20 200 Td (Copied) Tj ET q 60 0 0 40 20 100 cm /Im0 Do Q BT /F1 12 Tf 200 40 Td (Left) Tj ET";
pdf(
&[(content, "<< /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> /ExtGState << /GS0 << /ca 0.5 >> >> >>")],
&[HELVETICA.into(), picture("\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{0}\u{0}\u{0}\u{7f}\u{7f}\u{7f}\u{0}")],
)
}
/// A one-page document that already draws with Courier as /F1 and has a picture of its own as /Im0.
fn target() -> Arc<Vec<u8>> {
pdf(
&[("BT /F1 10 Tf 250 280 Td (x) Tj ET q 10 0 0 10 280 5 cm /Im0 Do Q", "<< /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >>")],
&[COURIER.into(), picture("@@@@@@@@@@@@")],
)
}
fn copy_all_but_left(s: &Session, id: DocId) -> ObjectClip {
let doc = s.get(id).unwrap();
let targets: Vec<_> = doc.editable_objects(0).unwrap().into_iter().filter(|o| o.text.as_deref() != Some("Left")).map(|o| o.target).collect();
assert_eq!(targets.len(), 2);
doc.copy_objects(0, &targets).unwrap()
}
fn render(s: &Session, id: DocId) -> pdfcraft_render::RenderedPage {
let doc = s.get(id).unwrap();
let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone());
renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
}
/// The pixels in user-space box `r` of a 300 pt page rendered at scale 1.
fn pixels(page: &pdfcraft_render::RenderedPage, r: [f64; 4]) -> Vec<u8> {
let mut out = Vec::new();
for y in (300.0 - r[3]) as u32..(300.0 - r[1]) as u32 {
for x in r[0] as u32..r[2] as u32 {
let i = ((y * page.width + x) * 4) as usize;
out.extend_from_slice(&page.rgba[i..i + 4]);
}
}
out
}
fn images(s: &Session, id: DocId) -> Vec<Option<pdfcraft_cos::ObjRef>> {
s.get(id).unwrap().page_images(0).into_iter().map(|i| i.object).collect()
}
#[test]
fn objects_copied_from_one_document_paste_into_another_and_draw_the_same() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let a = s.open("a.pdf", None, source(), None).unwrap();
let b = s.open("b.pdf", None, target(), None).unwrap();
let clip = copy_all_but_left(&s, a);
assert_eq!(clip.text(), "Copied");
let mut clipboard = ObjectClipboard::new(clip);
// The source can change, or close: the copy is a snapshot.
s.apply(a, Edit::delete_objects(0, vec![ObjectTarget { kind: ObjectKind::Image, index: 0 }]).unwrap()).unwrap();
s.close(a);
let a = s.open("a.pdf", None, source(), None).unwrap();
let paste = clipboard.paste(s.get(b).unwrap(), 0).unwrap();
assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [0.0, 0.0]), "another document: where it was");
s.apply(b, paste).unwrap();
assert_eq!(s.get(b).unwrap().can_undo(), Some("Paste"));
let area = clipboard.clip().rect().map(|v| v.round());
let area = [area[0] - 3.0, area[1] - 3.0, area[2] + 3.0, area[3] + 3.0];
let (original, pasted) = (pixels(&render(&s, a), area), pixels(&render(&s, b), area));
assert!(original.chunks(4).any(|p| p[2] > 200 && p[0] < 200), "the blue paragraph is drawn");
assert_eq!(original, pasted, "the pasted paragraph and picture draw the same pixels");
// Its own /F1 and /Im0 are left alone; what the copy uses came over under other names.
let doc = s.get(b).unwrap();
let texts: Vec<_> = doc.editable_objects(0).unwrap().into_iter().filter_map(|o| o.text).collect();
assert!(texts.contains(&"Copied".to_string()) && !texts.contains(&"Left".to_string()), "{texts:?}");
let pictures = images(&s, b);
assert_eq!(pictures.len(), 2);
assert_ne!(pictures[0], pictures[1]);
// A second paste steps down and to the right, and shares the fonts and pictures the first
// brought instead of copying them again.
let paste = clipboard.paste(s.get(b).unwrap(), 0).unwrap();
assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [18.0, -18.0]), "{paste:?}");
s.apply(b, paste).unwrap();
let pictures = images(&s, b);
assert_eq!(pictures.len(), 3);
assert_eq!(pictures[1], pictures[2], "one picture object for both pastes");
// Saved and reopened, the pastes are there; undone, the page is as it was.
let saved = s.save_bytes(b).unwrap();
let mut again = Session::new();
let reopened = again.open("b.pdf", None, saved, None).unwrap();
assert_eq!(again.get(reopened).unwrap().page_images(0).len(), 3);
s.undo(b).unwrap();
s.undo(b).unwrap();
assert_eq!(images(&s, b).len(), 1);
assert_eq!(s.get(b).unwrap().can_undo(), None);
}
#[test]
fn a_paste_onto_the_page_it_came_from_steps_and_draws_from_the_same_objects() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let a = s.open("a.pdf", None, source(), None).unwrap();
let mut clipboard = ObjectClipboard::new(copy_all_but_left(&s, a));
let paste = clipboard.paste(s.get(a).unwrap(), 0).unwrap();
assert!(matches!(&paste, Edit::PasteObjects { offset, .. } if *offset == [18.0, -18.0]), "one step down and right: {paste:?}");
s.apply(a, paste).unwrap();
let pictures = images(&s, a);
assert_eq!(pictures.len(), 2);
assert_eq!(pictures[0], pictures[1], "the same picture object, not a copy");
// Far off the page (a copy from a larger page), a paste is centred instead.
let doc = s.get(a).unwrap();
let mut far = clipboard.clip().clone();
far.copied.rect = [1000.0, 1000.0, 1100.0, 1050.0];
assert_eq!(doc.object_paste_offset(0, &far, 0).unwrap(), [150.0 - 1050.0, 150.0 - 1025.0]);
}
#[test]
fn pasting_into_another_document_follows_cycles_and_refuses_past_its_limit() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
// A font that refers to itself and to an object that refers back.
let cyclic = pdf(
&[("BT /F1 20 Tf 20 200 Td (Loop) Tj ET", "<< /Font << /F1 5 0 R >> >>")],
&[
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding /Self 5 0 R /Next [6 0 R] >>".into(),
"<< /Back 5 0 R /Again 6 0 R >>".into(),
],
);
let a = s.open("cyclic.pdf", None, cyclic, None).unwrap();
let b = s.open("b.pdf", None, target(), None).unwrap();
let all: Vec<_> = s.get(a).unwrap().editable_objects(0).unwrap().into_iter().map(|o| o.target).collect();
let mut clipboard = ObjectClipboard::new(s.get(a).unwrap().copy_objects(0, &all).unwrap());
s.apply(b, clipboard.paste(s.get(b).unwrap(), 0).unwrap()).unwrap();
assert!(s.get(b).unwrap().editable_objects(0).unwrap().iter().any(|o| o.text.as_deref() == Some("Loop")));
// An ExtGState that drags in more objects than a paste copies is refused with a reason.
let many = 20_001;
let refs: Vec<String> = (0..many).map(|i| format!("{} 0 R", 6 + i)).collect();
let mut extra = vec![format!("<< /ca 0.5 /Junk [{}] >>", refs.join(" "))];
extra.extend((0..many).map(|i| format!("<< /N {i} >>")));
let resources = format!("<< /Font << /F1 {HELVETICA} >> /ExtGState << /GS0 5 0 R >> >>");
let big = pdf(&[("/GS0 gs BT /F1 20 Tf 20 200 Td (Big) Tj ET", &resources)], &extra);
let c = s.open("big.pdf", None, big, None).unwrap();
let all: Vec<_> = s.get(c).unwrap().editable_objects(0).unwrap().into_iter().map(|o| o.target).collect();
assert_eq!(all.len(), 1);
let mut clipboard = ObjectClipboard::new(s.get(c).unwrap().copy_objects(0, &all).unwrap());
let before = s.get(b).unwrap().can_undo().map(str::to_string);
let err = s.apply(b, clipboard.paste(s.get(b).unwrap(), 0).unwrap()).unwrap_err().to_string();
assert!(err.contains("copy fewer objects"), "{err}");
assert_eq!(s.get(b).unwrap().can_undo().map(str::to_string), before, "a refused paste changes nothing");
}
#[test]
fn copying_refuses_a_stale_selection_and_deleting_several_objects_is_one_step() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let a = s.open("a.pdf", None, source(), None).unwrap();
let doc = s.get(a).unwrap();
assert!(doc.copy_objects(0, &[ObjectTarget { kind: ObjectKind::Text, index: 7 }]).is_err());
assert!(doc.copy_objects(3, &[ObjectTarget { kind: ObjectKind::Text, index: 0 }]).is_err());
let delete = Edit::delete_objects(0, vec![ObjectTarget { kind: ObjectKind::Text, index: 0 }, ObjectTarget { kind: ObjectKind::Image, index: 0 }]);
assert!(matches!(delete, Some(Edit::DeleteObjects { .. })));
assert!(Edit::delete_objects(0, Vec::new()).is_none());
s.apply(a, delete.unwrap()).unwrap();
assert_eq!(s.get(a).unwrap().can_undo(), Some("Delete content"));
assert!(s.get(a).unwrap().editable_objects(0).unwrap().iter().all(|o| o.text.as_deref() == Some("Left")));
}
/// Two pastes of a paragraph and a picture exactly over each other: deleting a paragraph also
/// removes a copy drawn exactly over it, so deleting all four objects together found the second
/// paragraph's number gone and refused the whole delete. Both copies go, in one step.
#[test]
fn objects_pasted_exactly_over_each_other_delete_together_in_one_step() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let a = s.open("a.pdf", None, source(), None).unwrap();
let blank = s.create_blank(300.0, 300.0, 1).unwrap();
let b = s.open_new("b.pdf", blank).unwrap();
let mut clipboard = ObjectClipboard::new(copy_all_but_left(&s, a));
let Edit::PasteObjects { clip, .. } = clipboard.paste(s.get(b).unwrap(), 0).unwrap() else { panic!("a paste") };
for _ in 0..2 {
s.apply(b, Edit::PasteObjects { page: 0, clip: Arc::clone(&clip), offset: [0.0, 0.0] }).unwrap();
}
let pasted = s.get(b).unwrap().editable_objects(0).unwrap();
assert_eq!(pasted.iter().filter(|o| o.text.as_deref() == Some("Copied")).count(), 2, "{pasted:?}");
assert_eq!(pasted.len(), 4);
let delete = Edit::delete_objects(0, pasted.iter().map(|o| o.target).collect()).unwrap();
s.apply(b, delete).unwrap();
assert!(s.get(b).unwrap().editable_objects(0).unwrap().is_empty());
assert_eq!(s.get(b).unwrap().can_undo(), Some("Delete content"));
s.undo(b).unwrap();
assert_eq!(s.get(b).unwrap().editable_objects(0).unwrap(), pasted, "one undo brings both copies back");
}
/// Deleting renumbers what follows in the same inventory, so paragraphs and pictures go from
/// the last to the first, and added content (outside those inventories) after them.
#[test]
fn several_objects_delete_last_first() {
let content =
"BT /F1 12 Tf 20 250 Td (A) Tj ET BT /F1 12 Tf 20 180 Td (B) Tj ET BT /F1 12 Tf 20 110 Td (C) Tj ET BT /F1 12 Tf 20 40 Td (D) Tj ET";
let mut doc = pdfcraft_cos::Document::open(pdf(&[(content, "<< /Font << /F1 5 0 R >> >>")], &[HELVETICA.into()])).unwrap();
let t = |kind, index| ObjectTarget { kind, index };
let targets = [t(ObjectKind::Added, 0), t(ObjectKind::Text, 1), t(ObjectKind::Image, 0), t(ObjectKind::Text, 3), t(ObjectKind::Added, 2)];
let mut order = Vec::new();
delete(&mut doc, 0, &targets, |_, e| {
order.push(match e {
Edit::EditTextBlock { block, text, .. } if text.is_empty() => ('t', *block),
Edit::EditPageImage { index, change: crate::ImageEdit::Delete, .. } => ('i', *index),
Edit::DeleteContent { index, .. } => ('a', *index),
other => panic!("unexpected {other:?}"),
});
Ok(())
})
.unwrap();
assert_eq!(order, [('t', 3), ('t', 1), ('i', 0), ('a', 2), ('a', 0)]);
}
+2115 -111
View File
File diff suppressed because it is too large. Load diff
+80
View File
@@ -0,0 +1,80 @@
//! The UI dictionary format: one case-insensitive word per UTF-8 line.
use std::collections::BTreeSet;
pub const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WORDS: usize = 65536;
fn word(value: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
return Err("dictionary words must be nonempty single lines of at most 1024 bytes".into());
}
let normalized = value.to_lowercase();
if normalized.len() > 1024 {
return Err("normalized dictionary words must be at most 1024 bytes".into());
}
Ok(normalized)
}
pub fn words(text: &str) -> Result<BTreeSet<String>, String> {
if text.len() > MAX_BYTES {
return Err("OCR dictionary exceeds 4 MiB".into());
}
let mut words = BTreeSet::new();
for line in text.lines().filter(|line| !line.trim().is_empty()) {
words.insert(word(line)?);
if words.len() > MAX_WORDS {
return Err("OCR dictionary exceeds 65536 words".into());
}
}
Ok(words)
}
/// Normalize, update and bound the complete dictionary before the caller writes it.
pub fn update(text: &str, add: &[&str], remove: &[&str]) -> Result<String, String> {
if add.len() > MAX_WORDS || remove.len() > MAX_WORDS {
return Err("OCR dictionary exceeds 65536 words".into());
}
let mut entries = words(text)?;
for value in remove {
entries.remove(&word(value)?);
}
for value in add {
entries.insert(word(value)?);
if entries.len() > MAX_WORDS {
return Err("OCR dictionary exceeds 65536 words".into());
}
}
let mut out = String::new();
for entry in entries {
if out.len().checked_add(entry.len()).and_then(|n| n.checked_add(1)).is_none_or(|n| n > MAX_BYTES) {
return Err("OCR dictionary exceeds 4 MiB".into());
}
out.push_str(&entry);
out.push('\n');
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dictionary_updates_round_trip_and_refuse_expanding_normalization() {
let text = update("Quartz\n日本語\n", &["QUARTZ", "extra"], &["日本語"]).unwrap();
assert_eq!(text, "extra\nquartz\n");
assert_eq!(words(&text).unwrap(), BTreeSet::from(["extra".into(), "quartz".into()]));
assert!(update("original\n", &[&"İ".repeat(512)], &[]).unwrap_err().contains("normalized"));
assert!(update("original\n", &["two\nlines"], &[]).is_err());
}
#[test]
fn dictionary_limits_cover_existing_and_added_entries() {
let full: String = (0..65536).map(|i| format!("word{i}\n")).collect();
assert_eq!(words(&full).unwrap().len(), 65536);
assert!(update(&full, &["extra"], &[]).is_err());
assert_eq!(update(&full, &["extra"], &["word0"]).unwrap().lines().count(), 65536);
assert!(words(&"a".repeat(MAX_BYTES + 1)).is_err());
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Unicode search text has no visible glyphs. A generated Type 3 font supplies advance widths
//! and a ToUnicode map, with empty character programs; no display font is needed or embedded.
//! Adapted from the searchable-layer implementation in #433 (32e09fbe).
use std::collections::BTreeMap;
use pdfcraft_cos::{Dict, Document, Object, Stream};
use pdfcraft_edit::EditError;
use pdfcraft_ocr::PlacedWord;
/// Refuse oversized text or invalid geometry before cloning words or allocating fonts.
/// These limits apply to the complete input, including all editable export pages.
pub(crate) fn validate_words<'a>(words: impl IntoIterator<Item = &'a PlacedWord>) -> Result<usize, EditError> {
let mut count = 0usize;
let mut length = 0usize;
for w in words {
count += 1;
length = length.checked_add(w.text.len()).ok_or_else(|| EditError::Invalid("OCR text layer is too large".into()))?;
if count > 65536 || length > 4 * 1024 * 1024 {
return Err(EditError::Invalid("OCR text layer is too large".into()));
}
if !w.origin.iter().chain(&w.across).chain(&w.up).all(|v| v.is_finite())
|| !w.across[0].hypot(w.across[1]).is_finite()
|| !w.up[0].hypot(w.up[1]).is_finite()
|| (0..2).any(|i| !(w.origin[i] + 0.2 * w.up[i]).is_finite() || !(w.origin[i] + w.across[i] + 0.2 * w.up[i]).is_finite())
{
return Err(EditError::Invalid("OCR text coordinates must be finite".into()));
}
}
Ok(count)
}
pub(crate) fn add_text(doc: &mut Document, page: usize, words: &[PlacedWord]) -> Result<(), EditError> {
validate_words(words)?;
// Keep the existing Latin layout, including Helvetica's proportional advance widths.
if words.iter().all(|w| {
w.text
.chars()
.all(|c| matches!(c, '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' | '€' | '‚' | '„' | '…' | '‘' | '’' | '“' | '”' | '•' | '–' | '—' | '™'))
}) {
pdfcraft_edit::stamp(doc, page, "OCR", pdfcraft_ocr::text_layer(words, &pdfcraft_fonts::TextPlan::default()))?;
return Ok(());
}
let p = pdfcraft_model::pages(doc).get(page).cloned().ok_or_else(|| EditError::Invalid("OCR page does not exist".into()))?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let chars: Vec<char> = words.iter().flat_map(|w| w.text.chars()).collect::<std::collections::BTreeSet<_>>().into_iter().collect();
// Match the extractor's em box (-200..800). Baselines below are offset by 0.2 em,
// so search/selection rectangles span the detected image box.
let blank = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"1000 0 0 -200 1000 800 d1\n".to_vec())));
let mut codes = BTreeMap::new();
let mut names = Vec::new();
for (index, chunk) in chars.chunks(255).enumerate() {
let mut name = format!("PCOcr{index}");
while fonts.contains(name.as_bytes()) {
name.push('x');
}
let mut differences = vec![Object::Int(1)];
let mut procs = Dict::new();
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (PdfCraft) /Ordering (Unicode) /Supplement 0 >> def\n/CMapName /PdfCraftOCR def\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
);
for (batch, part) in chunk.chunks(100).enumerate() {
cmap.push_str(&format!("{} beginbfchar\n", part.len()));
for (i, ch) in part.iter().enumerate() {
let code = (batch * 100 + i + 1) as u8;
let glyph = format!("g{code:02X}");
differences.push(Object::name(&glyph));
procs.set(glyph.into_bytes(), Object::Ref(blank));
let mut units = [0u16; 2];
let hex: String = ch.encode_utf16(&mut units).iter().map(|u| format!("{u:04X}")).collect();
cmap.push_str(&format!("<{code:02X}> <{hex}>\n"));
codes.insert(*ch, (index, code));
}
cmap.push_str("endbfchar\n");
}
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let map = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
let mut encoding = Dict::new();
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
encoding.set(b"Differences".to_vec(), Object::Array(differences));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"FontBBox".to_vec(), Object::Array(vec![0.into(), (-200).into(), 1000.into(), 800.into()]));
font.set(b"FontMatrix".to_vec(), Object::Array(vec![Object::Real(0.001), 0.into(), 0.into(), Object::Real(0.001), 0.into(), 0.into()]));
font.set(b"FirstChar".to_vec(), Object::Int(1));
font.set(b"LastChar".to_vec(), Object::Int(chunk.len() as i64));
font.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(1000); chunk.len()]));
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(procs));
font.set(b"Resources".to_vec(), Object::Dict(Dict::new()));
font.set(b"ToUnicode".to_vec(), Object::Ref(map));
fonts.set(name.as_bytes().to_vec(), Object::Ref(doc.add(Object::Dict(font))));
names.push(name);
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
// `3 Tr` and the font are graphics state, which `ET` doesn't reset: like
// `pdfcraft_ocr::text_layer` (#3029), the text is drawn inside `q … Q` so the invisible
// rendering mode cannot leak into later content streams on the page.
let mut content = String::from("/OCR BMC\nq\nBT\n3 Tr\n");
for w in words {
let count = w.text.chars().count();
if count == 0 || w.up[0].hypot(w.up[1]) <= 0.0 || w.across[0].hypot(w.across[1]) <= 0.0 {
continue;
}
let mut start = 0;
let mut run = Vec::new();
let mut font_index = None;
let emit = |content: &mut String, font: usize, start: usize, run: &[u8]| {
let n = count as f64;
let at = start as f64 / n;
let hex: String = run.iter().map(|b| format!("{b:02X}")).collect();
content.push_str(&format!(
"/{} 1 Tf\n{} {} {} {} {} {} Tm <{hex}> Tj\n",
names[font],
w.across[0] / n,
w.across[1] / n,
w.up[0],
w.up[1],
w.origin[0] + at * w.across[0] + 0.2 * w.up[0],
w.origin[1] + at * w.across[1] + 0.2 * w.up[1]
));
};
for (i, ch) in w.text.chars().enumerate() {
let Some(&(font, code)) = codes.get(&ch) else { continue };
if let Some(previous) = font_index
&& previous != font
{
emit(&mut content, previous, start, &run);
run.clear();
start = i;
}
font_index = Some(font);
run.push(code);
}
if let Some(font) = font_index {
emit(&mut content, font, start, &run);
}
}
content.push_str("ET\nQ\nEMC\n");
pdfcraft_edit::stamp(doc, page, "OCR", content.into_bytes())?;
Ok(())
}
+36 -14
View File
@@ -7,8 +7,9 @@ use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect};
use crate::history::{Editor, Keys, TextBlocksCache};
use crate::{
AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard, info_fields, js,
note_warnings, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets,
AppearanceCache, DocId, Document, Session, StatusCaptions, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, display_config, guard,
info_fields, js, note_warnings, print_preview_layers, recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout,
xfa_values_from_datasets,
};
/// Immutable session state needed while a document is prepared away from its owning thread.
@@ -17,6 +18,7 @@ pub struct OpenContext {
now: Option<i64>,
js_off: bool,
trust: Arc<TrustStore>,
captions: Arc<StatusCaptions>,
xfa_time_scale: u32,
}
@@ -54,7 +56,13 @@ impl OpenContext {
impl Session {
/// Snapshot the session state that affects open-time scripts, dates and signature trust.
pub fn open_context(&self) -> OpenContext {
OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) }
OpenContext {
now: self.now(),
js_off: self.js_off,
trust: self.trust.clone(),
captions: self.captions.clone(),
xfa_time_scale: self.xfa_time_scale.unwrap_or(1),
}
}
/// Prepare a document without adding it to this session.
@@ -74,6 +82,14 @@ impl Session {
let id = DocId(self.next_id);
let mut document = prepared.document;
document.id = id;
// Trust or the interface language may have changed while it was prepared on another
// thread.
if !Arc::ptr_eq(&document.trust, &self.trust) {
document.captions = self.captions.clone();
Self::revalidate(&mut document, self.trust.clone());
} else if *document.captions != *self.captions {
Self::draw_captions(&mut document, self.captions.clone());
}
self.docs.push(document);
id
}
@@ -110,11 +126,17 @@ impl Session {
// pages and fields so the rest of the engine works on it. The original bytes stay: the
// laid-out form is one appended revision.
let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
// Write `work` as one more revision and reopen it: what the document then is.
// Write `work` as one more revision and reopen it: what the document then is. The notes
// about damage repaired while reading the file stay with it (Document Properties), once.
let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc<Vec<u8>>, DocInfo, pdfcraft_cos::Document), String> {
let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?;
let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?;
let mut new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?;
for line in work.repair_log() {
if !new_cos.repair_log().contains(line) {
new_cos.note_repair(line.clone());
}
}
Ok((new_bytes, new_info, new_cos))
};
let (bytes, info, cos, xfa) = match (info.xfa, cos) {
@@ -239,9 +261,6 @@ impl Session {
};
match rebuilt {
Some((b, i, mut c)) => {
for line in cos.repair_log() {
c.note_repair(line.clone());
}
c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len()));
(b, i, Ok(Ok(c)))
}
@@ -253,7 +272,7 @@ impl Session {
// XFA notes survive the document being re-read after edits.
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
let mut document =
Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa });
Self::build_document(context, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa });
// A script that ran away on open turns the form's scripts off for this document.
document.xfa_template = if ran_away { None } else { xfa_template };
document.js_output.append(script_output);
@@ -266,9 +285,9 @@ impl Session {
}
/// The last step of opening: build the document record and register it.
fn build_document(trust: &Arc<TrustStore>, data: DocumentOpenData<'_>) -> Document {
fn build_document(context: &OpenContext, data: DocumentOpenData<'_>) -> Document {
let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data;
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let mut config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
let (mut editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
@@ -293,7 +312,7 @@ impl Session {
Err(_) => (None, Some("the document structure could not be read for editing".into())),
};
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
let mut signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &context.trust, &sig_cache)).unwrap_or_default();
// The inspection reads only `/Info`; a file stating its title or author only in XMP still
// shows them (#2373).
if let Some(e) = &editor {
@@ -303,7 +322,9 @@ impl Session {
}
}
}
let display = display_bytes(editor.as_mut(), &bytes);
// The print preview draws the layers as they print.
config.print_layers = print_preview_layers(editor.as_ref().map(|e| &e.cos), &info.layers);
let display = display_bytes(editor.as_mut(), &bytes, &mut signatures, &context.captions);
let renderer = RenderPool::new(display.clone(), render_threads(), display_config(&config));
let (form, adopted) = editor
.as_ref()
@@ -341,7 +362,8 @@ impl Session {
marker_scan: OnceLock::new(),
links: OnceLock::new(),
signatures: Arc::new(signatures),
trust: trust.clone(),
trust: context.trust.clone(),
captions: context.captions.clone(),
sig_cache,
editor,
config,
+18 -9
View File
@@ -12,11 +12,12 @@ use pdfcraft_cos::{SaveOptions, write_full};
use crate::{DocId, EditError, Hidden, OptimizeReport, Session, guard, optimize};
/// Where an optimization is.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OptimizeStage {
/// Removing the user data chosen under Discard User Data.
#[default]
/// Removing the user data chosen under Discard User Data, if any was.
Discarding,
/// Reading what the pages draw: `done` of `total` read.
Reading { done: usize, total: usize },
/// Merging the fragments of a page picture into one image.
Fragments,
/// About to process image `done` (0-based) of `total`.
@@ -29,14 +30,21 @@ pub enum OptimizeStage {
Writing,
}
impl Default for OptimizeStage {
fn default() -> Self {
OptimizeStage::Reading { done: 0, total: 0 }
}
}
impl OptimizeStage {
/// How far along the whole run is, from 0 to 1. Images take most of the time; the rest are
/// small fixed steps.
/// How far along the whole run is, from 0 to 1, weighed by where the time goes: reading pages
/// and pictures. The rest are small fixed steps.
pub fn fraction(self) -> f32 {
let part = |done: usize, total: usize| if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 };
match self {
OptimizeStage::Discarding => 0.0,
OptimizeStage::Fragments => 0.03,
OptimizeStage::Images { done, total } => 0.05 + 0.75 * if total == 0 { 1.0 } else { done.min(total) as f32 / total as f32 },
OptimizeStage::Discarding | OptimizeStage::Fragments => 0.0,
OptimizeStage::Reading { done, total } => 0.25 * if total == 0 { 0.0 } else { part(done, total) },
OptimizeStage::Images { done, total } => 0.25 + 0.55 * part(done, total),
OptimizeStage::CleaningUp => 0.8,
OptimizeStage::Merging => 0.85,
OptimizeStage::Writing => 0.9,
@@ -91,10 +99,10 @@ impl OptimizeJob {
pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
step(OptimizeStage::Discarding)?;
let discarded = if discard.is_empty() {
Vec::new()
} else {
step(OptimizeStage::Discarding)?;
// Every ticked category, as `remove_hidden` decides between them: with form fields
// ticked, discarding hidden text also takes the hidden text the flattened appearances
// bring onto the page, which no scan before flattening counts. It refuses when its scan
@@ -112,6 +120,7 @@ impl OptimizeJob {
};
let report = optimize::optimize_with_progress(&mut cos, &settings, &mut |s| {
step(match s {
optimize::Stage::Reading { done, total } => OptimizeStage::Reading { done, total },
optimize::Stage::Fragments => OptimizeStage::Fragments,
optimize::Stage::Images { done, total } => OptimizeStage::Images { done, total },
optimize::Stage::CleanUp => OptimizeStage::CleaningUp,
+12
View File
@@ -60,6 +60,18 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res
}
}
/// A 3D model's poster: the picture in `file` brought into `doc` and fitted inside `rect`
/// (user space), keeping its shape, and centred. It is drawn in user space and not counter-rotated
/// (unlike a stamp), so on a page with `/Rotate` it turns with the page, as the model does.
pub(crate) fn model_poster(doc: &mut pdfcraft_cos::Document, rect: [f64; 4], file: &MarkFile) -> Result<pdfcraft_annot::PosterPicture, EditError> {
let src = mark_source(doc, file)?;
let (sw, sh) = src.size;
if !(sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0) {
return Err(EditError::Source(format!("{}: the poster has no area", file.name)));
}
Ok(pdfcraft_annot::PosterPicture { xobject: src.xobject, image: src.image, size: (sw, sh), at: fit_picture(rect, sw, sh) })
}
/// The dimensions of an image from its bytes (JPEG, PNG, TIFF), without decoding the full raster.
pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
let format = image::guess_format(bytes).ok()?;
+8 -3
View File
@@ -11,7 +11,7 @@ use pdfcraft_print::{Content, Raster};
use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest};
use crate::export::encode_png;
use crate::{Document, guard};
use crate::{Document, guard, print_layer_states};
/// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one
/// page per entry, the size of the source page as displayed, drawing what `content` prints.
@@ -22,11 +22,16 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont
let mut config = src.config.clone();
// The copy below holds only what prints, so the viewer's own comment hiding plays no part.
config.hide_comments = false;
let printed = guard(|| -> Result<Vec<u8>, String> {
let (printed, layers) = guard(|| -> Result<(Vec<u8>, Option<Vec<pdfcraft_render::Layer>>), String> {
let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?;
pdfcraft_print::keep_printed(&mut cos, content);
write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string())
// The layers as the vector print sets them: as shown, then the Print event's auto state.
let layers = print_layer_states(&cos, &doc.info.layers);
Ok((write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string())?, layers))
})??;
if let Some(layers) = layers {
config.layers = std::sync::Arc::new(layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect());
}
let mut renderer = PageRenderer::new(std::sync::Arc::new(printed), config);
let mut images: Vec<(String, Vec<u8>)> = Vec::with_capacity(pages.len());
let mut used: Option<f64> = None;
+389
View File
@@ -0,0 +1,389 @@
//! Copy with formatting: the selected glyphs of a page as paragraphs of styled spans and an
//! HTML fragment ([`pdfcraft_export::clip_html`]) for the clipboard, next to the plain text.
//!
//! Glyphs come from the text layer (view space); style comes from the page's reading blocks,
//! which never mix styles, mapped into view space. A glyph takes the style of the block its
//! centre lies in; a glyph in no block is copied unstyled.
use pdfcraft_export::{ClipBuilder, ClipStyle, ClipText, clip_html};
use pdfcraft_render::{PageInfo, PageText};
use crate::EditError;
use crate::compare::{display_unit, weight_and_slant};
/// Block tests spent matching glyphs to blocks for one copy; past it the rest is unstyled.
const MAX_MATCH_STEPS: usize = 20_000_000;
/// How far outside a block's box (points) a glyph centre still belongs to it.
const SLACK: f32 = 0.5;
/// Selected text with its formatting.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RichCopy {
/// The paragraphs and spans.
pub text: ClipText,
/// The HTML fragment for the clipboard (empty when nothing was selected).
pub html: String,
/// Styles were left out: the document has no editor to read them, reading stopped at its
/// limits on this page, or matching glyphs to blocks ran out of steps.
pub styles_incomplete: bool,
/// A cap left part of the selection out of `text` and `html`; the plain text has all of it.
pub truncated: bool,
}
struct StyledBlock {
rect: [f32; 4],
style: ClipStyle,
}
impl crate::Document {
/// Selected `glyphs` of `page` (indices into `text`, in copy order) with their formatting.
/// Refused when the document does not allow copying.
pub fn rich_copy(&self, page: usize, text: &PageText, glyphs: &[usize]) -> Result<RichCopy, EditError> {
self.rich_copy_within(page, text, glyphs, MAX_MATCH_STEPS)
}
/// [`Self::rich_copy`] with `steps` block tests to match glyphs to blocks.
fn rich_copy_within(&self, page: usize, text: &PageText, glyphs: &[usize], mut steps: usize) -> Result<RichCopy, EditError> {
self.check_copying()?;
let (blocks, mut incomplete) = self.styled_blocks(page);
let mut nested = vec![None; blocks.len()];
let mut b = ClipBuilder::new();
// The previous glyph's line, block and box, and whether it was a hyphen.
let mut prev: Option<(usize, Option<usize>, [f32; 4], bool)> = None;
for &i in glyphs {
let (Some(g), Some(&line)) = (text.glyphs.get(i), text.line_of.get(i)) else { continue };
let hint = prev.and_then(|(_, b, _, _)| b).filter(|&k| !holds_smaller(&blocks, &mut nested, k, &mut steps));
let found = block_at(&blocks, g.rect, hint, &mut steps).unwrap_or_else(|| {
incomplete = true;
None
});
let line = usize::try_from(line).unwrap_or(usize::MAX);
if let Some((prev_line, prev_block, prev_rect, hyphen)) = prev {
if line != prev_line {
let style = |k: Option<usize>| k.and_then(|k| blocks.get(k)).map(|s| &s.style);
if found.is_none() && prev_block.is_none() {
b.line_break();
} else if found == prev_block || wraps(style(prev_block), style(found), prev_rect, g.rect) {
// A paragraph wrapping: one space, none after a hyphen.
if !hyphen {
b.space();
}
} else {
b.paragraph();
}
} else if text.space_before.get(i).copied().unwrap_or(false) {
b.space();
}
}
let style = found.and_then(|k| blocks.get(k)).map(|s| &s.style);
if !b.push(&g.text, style.unwrap_or(&ClipStyle::default())) {
break;
}
prev = Some((line, found, g.rect, g.text.ends_with('-')));
}
Ok(finish(b, incomplete))
}
/// `page`'s reading blocks in view space with their style, and whether reading stopped at
/// its limits.
fn styled_blocks(&self, page: usize) -> (Vec<StyledBlock>, bool) {
let Some(info) = self.info.pages.get(page) else { return (Vec::new(), false) };
let Some(editor) = self.editor.as_ref() else { return (Vec::new(), true) };
let Ok(reading) = pdfcraft_edit::reading_blocks_checked(&editor.cos, page) else { return (Vec::new(), true) };
let unit = display_unit(info);
let blocks = reading
.blocks
.into_iter()
.filter(|b| !b.text.trim().is_empty())
.map(|b| {
let (bold, italic) = weight_and_slant(&b.base_font);
let style = ClipStyle {
families: pdfcraft_export::font_family(&b.base_font),
size: b.size * unit,
bold: b.bold || bold,
italic: b.italic || italic,
color: b.color,
};
StyledBlock { rect: view_rect(info, b.rect), style }
})
.collect();
(blocks, reading.truncated)
}
}
/// A user-space box as a view-space box (points from the displayed page's top-left, y down).
fn view_rect(info: &PageInfo, r: [f64; 4]) -> [f32; 4] {
let a = info.user_to_view(r[0] as f32, r[1] as f32);
let b = info.user_to_view(r[2] as f32, r[3] as f32);
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
}
/// The copy `b` collected, with its HTML; `truncated` when a cap cut the text or the HTML.
fn finish(b: ClipBuilder, styles_incomplete: bool) -> RichCopy {
let text = b.finish();
let (html, cut) = clip_html(&text);
RichCopy { html, truncated: text.truncated || cut, text, styles_incomplete }
}
/// Whether the next line starts inside the same paragraph although its first glyph is in another
/// block: blocks split where the style changes, so a style change at a line's end is still a
/// wrap when the size stays and the lines sit no further apart than their own height allows.
/// Two blocks of the same style, a change of size, or a gap of over half a line are paragraphs.
fn wraps(prev: Option<&ClipStyle>, next: Option<&ClipStyle>, prev_rect: [f32; 4], rect: [f32; 4]) -> bool {
let (Some(prev), Some(next)) = (prev, next) else { return false };
let diff = (prev.size - next.size).abs();
if prev == next || diff.is_nan() || diff > 0.01 {
return false;
}
let height = (prev_rect[3] - prev_rect[1]).max(rect[3] - rect[1]);
// The next line starts just below the last one; a line far above it is another column.
let gap = rect[1] - prev_rect[3];
height > 0.0 && gap.is_finite() && gap >= -height / 2.0 && gap <= height / 2.0
}
/// Whether a block smaller than block `k` overlaps it, so a glyph inside `k` may belong to that
/// one instead. Worked out once per block (`nested` keeps it), at a step per block tested; when
/// the steps run out the answer is yes, so the full search decides.
fn holds_smaller(blocks: &[StyledBlock], nested: &mut [Option<bool>], k: usize, steps: &mut usize) -> bool {
if let Some(Some(known)) = nested.get(k) {
return *known;
}
let Some(outer) = blocks.get(k) else { return true };
let area = |r: &[f32; 4]| (r[2] - r[0]) * (r[3] - r[1]);
let overlaps = |r: &[f32; 4]| r[0] < outer.rect[2] && r[2] > outer.rect[0] && r[1] < outer.rect[3] && r[3] > outer.rect[1];
let mut found = false;
for (j, b) in blocks.iter().enumerate() {
if *steps == 0 {
return true;
}
*steps -= 1;
if j != k && overlaps(&b.rect) && area(&b.rect) < area(&outer.rect) {
found = true;
break;
}
}
if let Some(slot) = nested.get_mut(k) {
*slot = Some(found);
}
found
}
/// The block whose box holds the centre of `glyph` (view space): `hint` first (a block no
/// smaller one overlaps), then the smallest that holds it. Each block tested costs a step;
/// `None` when the steps ran out first.
fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option<usize>, steps: &mut usize) -> Option<Option<usize>> {
let (x, y) = ((glyph[0] + glyph[2]) / 2.0, (glyph[1] + glyph[3]) / 2.0);
let holds = |r: &[f32; 4]| x >= r[0] - SLACK && x <= r[2] + SLACK && y >= r[1] - SLACK && y <= r[3] + SLACK;
let area = |r: &[f32; 4]| (r[2] - r[0]) * (r[3] - r[1]);
if let Some(k) = hint
&& *steps > 0
{
*steps -= 1;
if blocks.get(k).is_some_and(|b| holds(&b.rect)) {
return Some(Some(k));
}
}
let mut best: Option<(usize, f32)> = None;
for (k, b) in blocks.iter().enumerate() {
if *steps == 0 {
return None;
}
*steps -= 1;
if holds(&b.rect) && best.is_none_or(|(_, a)| area(&b.rect) < a) {
best = Some((k, area(&b.rect)));
}
}
Some(best.map(|(k, _)| k))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use pdfcraft_export::{ClipBuilder, ClipStyle};
use crate::Session;
/// A heading in bold, a body paragraph of two lines with HTML-special characters, and a red
/// word on the body's first line.
fn styled_page() -> Vec<u8> {
let content = b"BT /F1 24 Tf 20 250 Td (Title) Tj ET \
BT /F2 12 Tf 20 200 Td (Body text) Tj ET \
BT 1 0 0 rg /F2 12 Tf 90 200 Td (red) Tj ET \
BT /F2 12 Tf 20 186 Td (a<b & c) Tj ET";
page_with(content)
}
/// A one-page PDF drawing `content` with `/F1` Helvetica-Bold and `/F2` Helvetica.
fn page_with(content: &[u8]) -> Vec<u8> {
pdfcraft_testkit::Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>")
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>")
.stream("", content)
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>")
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
.build()
}
fn page_text(s: &Session, id: crate::DocId) -> Arc<pdfcraft_render::PageText> {
let doc = s.get(id).unwrap();
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
let request = pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
r.render(request).text.unwrap()
}
#[test]
fn copy_keeps_headings_body_and_colour_as_html() {
let mut s = Session::new();
let id = s.open("styled.pdf", None, Arc::new(styled_page()), None).unwrap();
let text = page_text(&s, id);
let all: Vec<usize> = (0..text.glyphs.len()).collect();
let copy = s.get(id).unwrap().rich_copy(0, &text, &all).unwrap();
let html = &copy.html;
assert!(!copy.styles_incomplete);
assert!(
html.starts_with("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>Title</strong></span></p>\n"),
"{html}"
);
assert!(html.contains("font-size:12pt\">Body text "), "{html}");
assert!(html.contains("font-size:12pt;color:#FF0000\">red</span>"), "{html}");
assert!(html.contains("a&lt;b &amp; c"), "{html}");
assert!(!html.contains("<b &"), "{html}");
assert_eq!(copy.text.paragraphs.len(), html.matches("<p ").count());
assert!(copy.text.paragraphs.len() >= 2, "{html}");
// The heading and the body are separate paragraphs; the red word shares the body's.
let body = copy.text.paragraphs.iter().find(|p| p.iter().any(|s| s.text.contains("Body"))).unwrap();
assert!(body.iter().any(|s| s.text == "red" && s.style.color == [1.0, 0.0, 0.0]), "{body:?}");
// A part of the selection: only those glyphs.
let part = s.get(id).unwrap().rich_copy(0, &text, &all[..5]).unwrap();
assert_eq!(part.text.paragraphs.len(), 1);
assert!(part.html.contains("<strong>Title</strong>"), "{}", part.html);
// Nothing selected, and indices past the text: an empty copy, not a panic.
let none = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap();
assert!(none.html.is_empty() && none.text.paragraphs.is_empty());
let past = s.get(id).unwrap().rich_copy(7, &text, &[usize::MAX, text.glyphs.len()]).unwrap();
assert!(past.html.is_empty());
}
#[test]
fn running_out_of_match_steps_copies_the_rest_unstyled() {
let mut s = Session::new();
let id = s.open("styled.pdf", None, Arc::new(styled_page()), None).unwrap();
let text = page_text(&s, id);
let all: Vec<usize> = (0..text.glyphs.len()).collect();
let doc = s.get(id).unwrap();
let full = doc.rich_copy(0, &text, &all).unwrap();
// Enough for the first glyphs only: the text is all there, the rest has no style.
let short = doc.rich_copy_within(0, &text, &all, 8).unwrap();
assert!(short.styles_incomplete);
let plain = |c: &super::RichCopy| c.text.paragraphs.iter().flatten().map(|s| s.text.clone()).collect::<String>();
assert_eq!(plain(&short).replace([' ', '\n'], ""), plain(&full).replace([' ', '\n'], ""));
let last = short.text.paragraphs.last().and_then(|p| p.last()).unwrap();
assert_eq!(last.style, pdfcraft_export::ClipStyle::default(), "{last:?}");
assert!(short.html.starts_with("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>T"), "{}", short.html);
// No steps at all: nothing styled, still no panic.
let none = doc.rich_copy_within(0, &text, &all, 0).unwrap();
assert!(none.styles_incomplete && !none.html.contains("font-size"), "{}", none.html);
// A page without blocks to match needs no steps.
let page_less = doc.rich_copy_within(5, &text, &all, 0).unwrap();
assert!(!page_less.styles_incomplete);
}
fn copy_all(s: &Session, id: crate::DocId) -> super::RichCopy {
let text = page_text(s, id);
let all: Vec<usize> = (0..text.glyphs.len()).collect();
s.get(id).unwrap().rich_copy(0, &text, &all).unwrap()
}
#[test]
fn a_style_change_at_a_line_end_stays_in_its_paragraph() {
let content = b"BT /F2 12 Tf 20 200 Td (plain then) Tj ET \
BT /F1 12 Tf 85 200 Td (bold) Tj ET \
BT /F2 12 Tf 20 186 Td (continues here) Tj ET \
BT /F2 12 Tf 20 120 Td (Another paragraph) Tj ET";
let mut s = Session::new();
let id = s.open("wrap.pdf", None, Arc::new(page_with(content)), None).unwrap();
let copy = copy_all(&s, id);
let text = |p: &Vec<pdfcraft_export::ClipSpan>| p.iter().map(|s| s.text.as_str()).collect::<String>();
let paragraphs: Vec<String> = copy.text.paragraphs.iter().map(text).collect();
assert!(paragraphs.iter().any(|p| p.contains("bold") && p.contains("continues")), "{paragraphs:?}");
assert!(paragraphs.iter().any(|p| p.contains("Another") && !p.contains("continues")), "a wide gap is still a paragraph: {paragraphs:?}");
}
#[test]
fn a_style_change_into_the_next_column_starts_a_paragraph() {
let content = b"BT /F2 12 Tf 20 200 Td (left top) Tj ET \
BT /F2 12 Tf 20 186 Td (left end) Tj ET \
BT /F1 12 Tf 170 200 Td (Right head) Tj ET \
BT /F2 12 Tf 170 186 Td (right body) Tj ET";
let mut s = Session::new();
let id = s.open("columns.pdf", None, Arc::new(page_with(content)), None).unwrap();
let copy = copy_all(&s, id);
let text = |p: &Vec<pdfcraft_export::ClipSpan>| p.iter().map(|s| s.text.as_str()).collect::<String>();
let paragraphs: Vec<String> = copy.text.paragraphs.iter().map(text).collect();
assert!(paragraphs.iter().any(|p| p.contains("Right")), "{paragraphs:?}");
assert!(!paragraphs.iter().any(|p| p.contains("left end") && p.contains("Right")), "{paragraphs:?}");
}
#[test]
fn a_styled_word_inside_a_paragraph_keeps_its_style() {
let content = b"BT /F2 12 Tf 20 200 Td (first row of the text) Tj ET \
BT /F2 12 Tf 20 186 Td (second) Tj ET \
BT /F1 12 Tf 62 186 Td (bold) Tj ET \
BT /F2 12 Tf 92 186 Td (row) Tj ET";
let mut s = Session::new();
let id = s.open("nested.pdf", None, Arc::new(page_with(content)), None).unwrap();
let copy = copy_all(&s, id);
let spans: Vec<_> = copy.text.paragraphs.iter().flatten().collect();
assert!(spans.iter().any(|s| s.text.contains("bold") && s.style.bold), "{spans:?}");
assert!(spans.iter().any(|s| s.text.contains("second") && !s.style.bold), "{spans:?}");
}
#[test]
fn a_capped_copy_is_marked_truncated() {
let (plain, bold) = (ClipStyle::default(), ClipStyle { bold: true, ..ClipStyle::default() });
let mut b = ClipBuilder::new();
for k in 0..=pdfcraft_export::MAX_CLIP_SPANS {
b.push("x", if k % 2 == 0 { &plain } else { &bold });
}
let copy = super::finish(b, false);
assert!(copy.truncated && copy.text.truncated);
let mut b = ClipBuilder::new();
b.push("x", &plain);
assert!(!super::finish(b, false).truncated);
}
#[test]
fn without_an_editor_the_styles_are_incomplete() {
let mut s = Session::new();
let id = s.open("styled.pdf", None, Arc::new(styled_page()), None).unwrap();
let text = page_text(&s, id);
let all: Vec<usize> = (0..text.glyphs.len()).collect();
s.doc_mut(id).unwrap().editor = None;
let copy = s.get(id).unwrap().rich_copy(0, &text, &all).unwrap();
assert!(copy.styles_incomplete && !copy.html.contains("font-size"), "{}", copy.html);
assert!(!copy.truncated);
}
#[test]
fn copy_with_formatting_is_refused_when_copying_is_withheld() {
let mut cos = pdfcraft_cos::Document::open(Arc::new(styled_page())).unwrap();
cos.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: "",
owner_password: "owner",
permissions: 0b0100, // print only
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap());
let mut s = Session::new();
let id = s.open("locked.pdf", None, bytes, None).unwrap();
let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new() };
let err = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap_err();
assert!(matches!(err, crate::EditError::NotPermitted(_)), "{err:?}");
}
}
+308
View File
@@ -0,0 +1,308 @@
//! Scan & OCR ▸ Enhance ▸ Scanned document: clean up the pictures scanned pages are made of.
//!
//! Unlike Enhance ▸ Camera image, the page is not rendered and replaced: each scan image is
//! decoded, cleaned ([`crate::camera_clean::clean_samples`]) and written back **into the same
//! image object**, at the same size and in the same colour space. So everything else on the
//! page stays as it was: an invisible OCR text layer, comments, links, form fields, other
//! content, and the image's own soft mask, `/Interpolate` and other entries. A JPEG stays a JPEG
//! (at quality 90), any other grey or RGB image is stored losslessly (Flate), and a black and
//! white (1-bit) image stays 1-bit. An image several pages draw is cleaned once.
//!
//! A page is a scan when its pictures cover at least half of it (as for Recognize text); the
//! pictures each covering at least a tenth of the page are its scan. What is not cleaned is
//! reported, never skipped silently: pages that are no scan, and scan images in a form PdfCraft
//! can't rewrite yet (CMYK, indexed, JPEG 2000, JBIG2, CCITT fax, a stencil mask, a remapping
//! decode array, a scan drawn inside grouped Form artwork) or whose data is broken.
//!
//! Like Optimize, the work is a job: [`Session::scan_clean_job`] captures the document,
//! [`ScanCleanJob::run`] cleans anywhere (a worker thread), and [`Session::apply_scan_clean`]
//! makes the result one undoable step, "Enhance scanned file" (the name the Scan & OCR tool has).
use pdfcraft_cos::{ObjRef, Object, Stream};
use crate::camera_clean::{CleanUp, MAX_LEVEL, clean_samples};
use crate::edit::check_permission;
use crate::{DocId, Edit, EditError, Session, guard, optimize};
pub use crate::camera_clean::DESPECKLE_FROM;
/// The JPEG quality a cleaned JPEG scan is written back at (as Enhance camera image).
const QUALITY: u8 = 90;
/// On a scanned page, an image covering at least this share of the page is part of the scan;
/// smaller ones (a logo or a signature pasted on) are left alone.
const PART_OF_SCAN: f64 = 0.1;
/// Why a scan drawn through grouped Form artwork stays as it is.
const IN_FORM: &str = "it is drawn inside grouped artwork (a form XObject), which can't be cleaned up yet";
/// How scanned pages are cleaned up.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ScanCleanSettings {
/// Background removal, 0 (off) … 100 (strongest), as Enhance camera image's clean-up level:
/// the paper's illumination is evened out (fully from 50), so a grey, yellowed or unevenly lit
/// background turns white; then the contrast is raised so faint strokes darken. Colour ink
/// stays colour.
pub level: u8,
/// Edge shadow removal: shadows along the scan's edges (a book's gutter, a copier's dark
/// border) are lightened to the paper next to them, line by line, before the level applies.
pub edge_shadows: bool,
/// Despeckle: isolated dark specks (dust, scanner noise), dark pixels with at most one dark
/// neighbour, turn white. The only change a black and white (1-bit) scan takes.
pub despeckle: bool,
}
impl ScanCleanSettings {
/// The level `level` (capped at 100) with edge shadow removal (unless the level is 0), and
/// despeckling from [`DESPECKLE_FROM`] as the camera tool does.
pub fn at(level: u8) -> Self {
let level = level.min(MAX_LEVEL);
Self { level, edge_shadows: level > 0, despeckle: level >= DESPECKLE_FROM }
}
/// Whether these settings change anything at all.
pub fn changes_something(self) -> bool {
self.level > 0 || self.edge_shadows || self.despeckle
}
}
impl Default for ScanCleanSettings {
fn default() -> Self {
Self::at(50)
}
}
/// What a clean-up did.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ScanCleanReport {
/// Pages (0-based) whose scan was cleaned, in order.
pub pages: Vec<usize>,
/// Images rewritten. One drawn on several pages counts once.
pub images: usize,
/// Pages left alone because no picture covers most of them.
pub not_scanned: Vec<usize>,
/// Scan images left as they were, and why.
pub skipped: Vec<SkippedImage>,
}
/// A scan image that was not cleaned.
#[derive(Clone, Debug, PartialEq)]
pub struct SkippedImage {
/// The pages (0-based) that draw it, among those asked for.
pub pages: Vec<usize>,
/// Its resource name on the first of them.
pub name: String,
/// Why, in a sentence that follows "skipped because".
pub reason: String,
}
/// One image to clean: its object, its name and the pages that draw it.
struct Target {
object: ObjRef,
name: String,
pages: Vec<usize>,
}
/// What cleaning scanned pages needs, independent of the session.
pub struct ScanCleanJob {
cos: pdfcraft_cos::Document,
targets: Vec<Target>,
settings: ScanCleanSettings,
/// The document's edit generation it was taken at: a result is for that state only.
generation: u64,
report: ScanCleanReport,
}
/// The cleaned images, ready for [`Session::apply_scan_clean`], and the report.
pub struct ScanCleaned {
images: Vec<(ObjRef, Stream)>,
generation: u64,
pub report: ScanCleanReport,
}
impl Session {
/// Capture what cleaning up the scans on `pages` (0-based; empty = all) of document `id`
/// needs. Errors when the document can't be changed (read-only, or its permissions forbid
/// changes) or a page doesn't exist.
pub fn scan_clean_job(&self, id: DocId, pages: &[usize], settings: ScanCleanSettings) -> Result<ScanCleanJob, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
if let Some(why) = &doc.read_only_reason {
return Err(EditError::ReadOnly(why.clone()));
}
let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(String::new()))?;
if let Some(p) = editor.cos.permissions() {
check_permission(&Edit::EnhanceScan { images: Vec::new() }, &p)?;
}
let all = doc.info.pages.len();
let mut wanted: Vec<usize> = if pages.is_empty() { (0..all).collect() } else { pages.to_vec() };
if let Some(p) = wanted.iter().find(|p| **p >= all) {
return Err(EditError::Invalid(format!("there is no page {}: the document has {all}", p.saturating_add(1))));
}
wanted.sort_unstable();
wanted.dedup();
let mut report = ScanCleanReport::default();
let mut targets: Vec<Target> = Vec::new();
// Form artwork already reported, and where in `report.skipped`.
let mut forms_reported: Vec<(ObjRef, usize)> = Vec::new();
for page in wanted {
let Some(info) = doc.info.pages.get(page) else { continue };
// Ordinary images and scan-holding Forms are classified together, so a page that
// mixes them gets its images cleaned and its Forms reported.
let Some((images, forms, own_scan)) = crate::scan_fields::scan_parts(doc, page, info) else {
report.not_scanned.push(page);
continue;
};
let forms: Vec<_> = forms.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN).collect();
if !own_scan && forms.is_empty() {
report.not_scanned.push(page);
continue;
}
// A scan drawn through a Form XObject is a scan all the same: say why it stays.
for (form, _) in forms {
let known = form.object.and_then(|o| forms_reported.iter().find(|(r, _)| *r == o)).and_then(|(_, k)| report.skipped.get_mut(*k));
match known {
Some(s) if !s.pages.contains(&page) => s.pages.push(page),
Some(_) => {}
None => {
if let Some(o) = form.object {
forms_reported.push((o, report.skipped.len()));
}
report.skipped.push(SkippedImage { pages: vec![page], name: form.name, reason: IN_FORM.into() });
}
}
}
for (image, _) in images.into_iter().filter(|(_, share)| *share >= PART_OF_SCAN) {
let Some(object) = image.object else {
report.skipped.push(SkippedImage {
pages: vec![page],
name: image.name,
reason: "it is stored inside the page's resources rather than as an object of its own".into(),
});
continue;
};
match targets.iter_mut().find(|t| t.object == object) {
Some(t) if !t.pages.contains(&page) => t.pages.push(page),
Some(_) => {}
None => targets.push(Target { object, name: image.name, pages: vec![page] }),
}
}
}
Ok(ScanCleanJob { cos: editor.cos.clone(), targets, settings, generation: doc.edit_generation(), report })
}
/// Write the images [`ScanCleanJob::run`] cleaned into document `id` as one undoable step,
/// "Enhance scanned file" (none when nothing was cleaned), and return the report. Refused
/// when the document changed since the job was taken.
pub fn apply_scan_clean(&mut self, id: DocId, cleaned: ScanCleaned) -> Result<ScanCleanReport, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
if doc.edit_generation() != cleaned.generation {
return Err(EditError::Invalid("the document changed while its scans were being cleaned up; nothing was changed, try again".into()));
}
if !cleaned.images.is_empty() {
self.apply(id, Edit::EnhanceScan { images: cleaned.images })?;
}
Ok(cleaned.report)
}
/// Enhance ▸ Scanned document in one call: [`Self::scan_clean_job`], run, then
/// [`Self::apply_scan_clean`] (the CLI and agents use this).
pub fn enhance_scanned_pages(&mut self, id: DocId, pages: &[usize], settings: ScanCleanSettings) -> Result<ScanCleanReport, EditError> {
let job = self.scan_clean_job(id, pages, settings)?;
let cleaned = job.run(|_, _| true)?;
self.apply_scan_clean(id, cleaned)
}
}
impl ScanCleanJob {
/// The images to clean (one drawn on several pages counts once).
pub fn total(&self) -> usize {
self.targets.len()
}
/// Clean every scan image. `progress(done, total)` is called before each image; returning
/// `false` stops with [`EditError::Cancelled`]. A panic is returned as an error, so a worker
/// thread always reports back.
pub fn run(self, mut progress: impl FnMut(usize, usize) -> bool) -> Result<ScanCleaned, EditError> {
guard(move || self.work(&mut progress)).map_err(|m| EditError::Invalid(format!("cleaning up the scans failed unexpectedly ({m})")))?
}
fn work(self, progress: &mut dyn FnMut(usize, usize) -> bool) -> Result<ScanCleaned, EditError> {
let ScanCleanJob { cos, targets, settings, generation, mut report } = self;
let total = targets.len();
let mut images = Vec::new();
let mut changed_pages: Vec<usize> = Vec::new();
// One decoding allowance for the whole run, sized by the file.
let mut budget = pdfcraft_cos::ImageBudget::new(&cos);
for (done, target) in targets.into_iter().enumerate() {
if !progress(done, total) {
return Err(EditError::Cancelled);
}
let mut skip = |reason: String| report.skipped.push(SkippedImage { pages: target.pages.clone(), name: target.name.clone(), reason });
match clean_image(&cos, target.object, settings, &mut budget) {
Ok(Some(stream)) => {
images.push((target.object, stream));
changed_pages.extend(target.pages.iter().copied());
}
Ok(None) => skip("it is already clean: nothing changes at these settings".into()),
Err(reason) => skip(reason),
}
}
changed_pages.sort_unstable();
changed_pages.dedup();
report.images = images.len();
report.pages = changed_pages;
report.skipped.sort_by_key(|s| s.pages.first().copied());
Ok(ScanCleaned { images, generation, report })
}
}
/// Image `object` cleaned with `settings`: the new stream, `None` when nothing changes, or why it
/// can't be cleaned.
fn clean_image(
cos: &pdfcraft_cos::Document,
object: ObjRef,
settings: ScanCleanSettings,
budget: &mut pdfcraft_cos::ImageBudget,
) -> Result<Option<Stream>, String> {
let obj = cos.get(object);
let Object::Stream(s) = &*obj else { return Err("it is not an image".into()) };
if s.dict.name(b"Subtype") != Some(b"Image") {
return Err("it is not an image".into());
}
// A colour-key mask names sample values to leave unpainted: cleaned samples would match others.
if matches!(s.dict.get(b"Mask").map(|m| cos.resolve(m)).as_deref(), Some(Object::Array(_))) {
return Err("it has a colour-key mask, which cleaning would change".into());
}
let samples = optimize::decode_samples(cos, s, budget).map_err(|e| e.to_string())?;
let cleaned = if samples.bilevel {
if !settings.despeckle {
return Err("it is black and white, which only Despeckle changes, and Despeckle is off".into());
}
clean_samples(&samples.data, 1, samples.width, samples.height, CleanUp { level: 0, edge_shadows: false, despeckle: true })
} else {
if !settings.changes_something() {
return Ok(None);
}
let how = CleanUp { level: settings.level, edge_shadows: settings.edge_shadows, despeckle: settings.despeckle };
clean_samples(&samples.data, samples.components, samples.width, samples.height, how)
}
.ok_or("its samples don't match its size")?;
if cleaned == samples.data {
return Ok(None);
}
let cleaned = optimize::Samples { data: cleaned, ..samples };
optimize::encode_samples(s, &cleaned, QUALITY).map(Some).ok_or_else(|| "it could not be written back".into())
}
/// [`Edit::EnhanceScan`]: put the cleaned images in place. Each object must still be an image.
pub(crate) fn write_back(doc: &mut pdfcraft_cos::Document, images: &[(ObjRef, Stream)]) -> Result<(), EditError> {
for (object, stream) in images {
let is_image = matches!(&*doc.get(*object), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image"));
if !is_image {
return Err(EditError::Invalid(format!("object {} is no longer an image; the scan was not changed", object.num)));
}
doc.set(*object, Object::Stream(stream.clone()));
}
Ok(())
}
#[cfg(test)]
mod tests;
+463
View File
@@ -0,0 +1,463 @@
use std::sync::Arc;
use pdfcraft_testkit::Pdf;
use super::*;
const W: usize = 300;
const H: usize = 400;
/// Isolated specks on the paper, away from the text rows.
const SPECKS: [(usize, usize); 3] = [(150, 25), (200, 85), (120, 125)];
/// The light falling on the paper at `x`: full in the middle, darker towards a shadowed left
/// edge (as a book's gutter) and a little at the right edge.
fn light(x: usize) -> f32 {
let left = (x as f32 / 60.0).min(1.0);
let right = ((W - 1 - x) as f32 / 30.0).min(1.0);
(0.55 + 0.45 * left) * (0.8 + 0.2 * right)
}
fn is_stroke(x: usize, y: usize) -> bool {
(60..360).contains(&y) && (y - 60) % 40 < 6 && (60..240).contains(&x)
}
/// A synthetic scan, `n` components (1 grey, 3 RGB): greyish yellowed paper, a shadowed gutter,
/// dark text strokes and a few specks.
fn scan_pixels(n: usize) -> Vec<u8> {
let mut out = Vec::with_capacity(W * H * n);
for y in 0..H {
for x in 0..W {
let ink = if is_stroke(x, y) || SPECKS.contains(&(x, y)) { 0.12 } else { 0.8 };
let v = ink * light(x);
let px = [v, v, v * 0.85];
for c in px.iter().take(n) {
out.push((c * 255.0).round().clamp(0.0, 255.0) as u8);
}
}
}
out
}
fn jpeg(px: &[u8], n: usize) -> Vec<u8> {
let mut out = Vec::new();
let ty = if n == 1 { image::ExtendedColorType::L8 } else { image::ExtendedColorType::Rgb8 };
image::ImageEncoder::write_image(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 95), px, W as u32, H as u32, ty).unwrap();
out
}
/// Three pages: 1 a scan (the image `image_dict`/`data` as object 4) with an invisible text
/// layer and a comment, 2 a page of real text, 3 the same scan image drawn again.
fn scan_pdf(image_dict: &str, data: &[u8]) -> Vec<u8> {
let resources = "/Resources << /XObject << /Im0 4 0 R >> /Font << /F1 5 0 R >> >>";
Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R 8 0 R 9 0 R] /Count 3 >>")
.obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 6 0 R /Annots [7 0 R] >>"))
.stream(&format!("/Type /XObject /Subtype /Image {image_dict}"), data)
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
.stream("", format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q BT 3 Tr /F1 12 Tf 20 380 Td (Invisible OCR words) Tj ET"))
.obj("<< /Type /Annot /Subtype /Square /Rect [10 10 50 50] /C [1 0 0] /Contents (A comment) >>")
.obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 10 0 R >>"))
.obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] {resources} /Contents 11 0 R >>"))
.stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET")
.stream("", format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"))
.build()
}
fn grey_scan() -> Vec<u8> {
scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate true"), &scan_pixels(1))
}
fn open(bytes: Vec<u8>) -> (Session, DocId) {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("scan.pdf", None, Arc::new(bytes), None).unwrap();
(s, id)
}
fn object(s: &Session, id: DocId, num: u32) -> Arc<Object> {
s.get(id).unwrap().editor.as_ref().unwrap().cos.get(ObjRef::new(num, 0))
}
/// The samples of the image page 1 draws, now.
fn samples(s: &Session, id: DocId) -> optimize::Samples {
let doc = s.get(id).unwrap();
let object = doc.page_images(0)[0].object.unwrap();
let cos = &doc.editor.as_ref().unwrap().cos;
let Object::Stream(img) = &*cos.get(object) else { panic!("the image") };
optimize::decode_samples(cos, img, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX)).unwrap()
}
fn grey_at(px: &optimize::Samples, x: usize, y: usize) -> u8 {
px.data[(y * W + x) * px.components]
}
#[test]
fn a_scan_gets_white_paper_even_edges_dark_strokes_and_no_specks_in_place() {
let (mut s, id) = open(grey_scan());
let before = samples(&s, id);
let kept: Vec<Arc<Object>> = [3, 5, 6, 7, 8, 10, 11].iter().map(|&n| object(&s, id, n)).collect();
let text_before = s.get(id).unwrap().text_lines(0);
let original = s.get(id).unwrap().bytes.clone();
let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
assert_eq!(report.pages, vec![0, 2], "the shared image counts for both its pages");
assert_eq!(report.images, 1);
assert_eq!(report.not_scanned, vec![1], "the text page is no scan");
assert!(report.skipped.is_empty(), "{:?}", report.skipped);
assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance scanned file"));
let after = samples(&s, id);
assert_eq!((after.width, after.height, after.components, after.jpeg), (W as u32, H as u32, 1, false));
// Paper, also in the shadowed gutter and at the right edge, turns (nearly) white.
for (x, y) in [(150, 40), (150, 380), (3, 40), (3, 380), (30, 200), (W - 2, 40)] {
assert!(grey_at(&before, x, y) < 210, "the fixture's paper is grey at ({x}, {y})");
assert!(grey_at(&after, x, y) >= 235, "paper at ({x}, {y}): {} → {}", grey_at(&before, x, y), grey_at(&after, x, y));
}
// The edge is evened out: the gutter is as white as the middle.
assert!(grey_at(&after, 150, 40).abs_diff(grey_at(&after, 3, 40)) <= 12);
// Strokes stay dark (darker, with the contrast), also in the gutter's light.
for (x, y) in [(150, 62), (61, 102), (238, 342)] {
assert!(grey_at(&after, x, y) <= 40, "stroke at ({x}, {y}): {}", grey_at(&after, x, y));
}
for (x, y) in SPECKS {
assert!(grey_at(&before, x, y) < 60);
assert!(grey_at(&after, x, y) >= 200, "speck at ({x}, {y}): {}", grey_at(&after, x, y));
}
// Everything but the image is as it was: the page, its invisible text layer, the comment, the
// font, the other pages. The image keeps its entries.
let now: Vec<Arc<Object>> = [3, 5, 6, 7, 8, 10, 11].iter().map(|&n| object(&s, id, n)).collect();
assert_eq!(kept, now);
assert_eq!(s.get(id).unwrap().info.pages.len(), 3);
let text_after = s.get(id).unwrap().text_lines(0);
assert!(text_after.iter().any(|l| l.text.contains("Invisible OCR words")), "{text_after:?}");
assert_eq!(text_before.len(), text_after.len());
let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") };
assert_eq!(img.dict.get(b"Interpolate"), Some(&Object::Bool(true)));
assert_eq!(img.dict.name(b"ColorSpace"), Some(&b"DeviceGray"[..]));
// Undo brings the original image, and the original file, back.
s.undo(id).unwrap();
assert_eq!(samples(&s, id), before);
assert_eq!(s.get(id).unwrap().bytes, original);
assert_eq!(s.get(id).unwrap().can_undo(), None);
}
#[test]
fn a_jpeg_scan_stays_a_jpeg_in_colour() {
let px = scan_pixels(3);
let (mut s, id) = open(scan_pdf(&format!("/Width {W} /Height {H} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), &jpeg(&px, 3)));
let report = s.enhance_scanned_pages(id, &[0], ScanCleanSettings::at(80)).unwrap();
// Only page 1 was asked for; the image it shares with page 3 is changed for both.
assert_eq!((report.pages.as_slice(), report.images), (&[0][..], 1));
let after = samples(&s, id);
assert!(after.jpeg && after.components == 3);
let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") };
assert_eq!(img.dict.name(b"Filter"), Some(&b"DCTDecode"[..]));
// Yellowed paper becomes white; the strokes stay dark.
let at = |x: usize, y: usize| after.data[(y * W + x) * 3..][..3].to_vec();
for (x, y) in [(150, 40), (5, 300)] {
assert!(at(x, y).iter().all(|&v| v >= 230), "paper at ({x}, {y}): {:?}", at(x, y));
}
assert!(at(150, 63).iter().all(|&v| v <= 60), "stroke: {:?}", at(150, 63));
// The file still renders.
let doc = s.get(id).unwrap();
let shot = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()).render(pdfcraft_render::RenderRequest {
page: 0,
scale: 1.0,
..Default::default()
});
assert!(shot.error.is_none(), "{:?}", shot.error);
}
#[test]
fn despeckle_is_its_own_switch() {
let speck = |settings: ScanCleanSettings| {
let (mut s, id) = open(grey_scan());
s.enhance_scanned_pages(id, &[0], settings).unwrap();
let after = samples(&s, id);
SPECKS.iter().map(|&(x, y)| grey_at(&after, x, y)).max().unwrap_or(0)
};
assert!(speck(ScanCleanSettings { despeckle: false, ..ScanCleanSettings::at(80) }) < 100, "kept without despeckle");
assert!(speck(ScanCleanSettings::at(80)) >= 200);
// Despeckle alone, at level 0, changes nothing but the specks.
let (mut s, id) = open(grey_scan());
let before = samples(&s, id);
s.enhance_scanned_pages(id, &[], ScanCleanSettings { level: 0, edge_shadows: false, despeckle: true }).unwrap();
let after = samples(&s, id);
let changed: Vec<usize> = before.data.iter().zip(&after.data).enumerate().filter(|(_, (a, b))| a != b).map(|(i, _)| i).collect();
let mut specks: Vec<usize> = SPECKS.iter().map(|&(x, y)| y * W + x).collect();
specks.sort_unstable();
assert_eq!(changed, specks);
// Nothing to do: no undo step, and the report says why.
let (mut s, id) = open(grey_scan());
let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings { level: 0, edge_shadows: false, despeckle: false }).unwrap();
assert_eq!(report.images, 0);
assert!(report.pages.is_empty());
assert_eq!(report.skipped.len(), 1);
assert!(report.skipped[0].reason.contains("already clean"), "{:?}", report.skipped);
assert_eq!(s.get(id).unwrap().can_undo(), None);
assert!(!s.get(id).unwrap().dirty);
}
#[test]
fn a_black_and_white_scan_stays_one_bit_and_loses_its_specks() {
// 64 × 64, one bit per pixel, 1 = white: a black bar and two specks.
let (w, h) = (64usize, 64usize);
let mut bits = vec![0xFFu8; w / 8 * h];
let mut black = |x: usize, y: usize| bits[y * w / 8 + x / 8] &= !(0x80 >> (x % 8));
for y in 20..30 {
for x in 10..50 {
black(x, y);
}
}
black(5, 5);
black(58, 58);
let bytes = Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
.obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 64 64] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>")
.stream("/Type /XObject /Subtype /Image /Width 64 /Height 64 /ColorSpace /DeviceGray /BitsPerComponent 1", &bits)
.stream("", "q 64 0 0 64 0 0 cm /Im0 Do Q")
.build();
let (mut s, id) = open(bytes);
// Only Despeckle changes a black and white scan: without it the image is reported.
let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings { despeckle: false, ..ScanCleanSettings::at(100) }).unwrap();
assert_eq!(report.images, 0);
assert!(report.skipped.first().is_some_and(|k| k.reason.contains("black and white")), "{:?}", report.skipped);
let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
assert_eq!(report.images, 1);
let Object::Stream(img) = &*object(&s, id, 4) else { panic!("the image") };
assert_eq!(img.dict.int(b"BitsPerComponent"), Some(1));
let px = samples(&s, id);
assert!(px.bilevel);
let at = |x: usize, y: usize| px.data[y * w + x];
assert_eq!((at(5, 5), at(58, 58)), (255, 255), "specks gone");
assert_eq!((at(10, 20), at(30, 25), at(49, 29)), (0, 0, 0), "the bar stays");
assert_eq!(px.data.iter().filter(|&&v| v == 0).count(), 40 * 10);
}
#[test]
fn hostile_images_are_reported_not_a_panic() {
// Each page draws one image over the whole page.
let images: [(&str, Vec<u8>); 6] = [
("/Width 100000 /Height 100000 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![0; 64]),
("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /DCTDecode", vec![0xFF, 0xD8, 0xFF, 0xE0, 0, 3, 9]),
("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![200; 1000]),
("/Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8", vec![128]),
("/Width 8 /Height 8 /ColorSpace /DeviceCMYK /BitsPerComponent 8", vec![0; 256]),
("/Width 300 /Height 400 /ColorSpace /DeviceGray /BitsPerComponent 8 /Mask [250 255]", vec![200; 300 * 400]),
];
let n = images.len();
let kids: String = (0..n).map(|i| format!("{} 0 R ", 3 + 3 * i)).collect();
let mut pdf = Pdf::new().obj("<< /Type /Catalog /Pages 2 0 R >>").obj(format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>"));
for (i, (dict, data)) in images.iter().enumerate() {
let (img, contents) = (4 + 3 * i, 5 + 3 * i);
pdf = pdf
.obj(format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Resources << /XObject << /Im0 {img} 0 R >> >> /Contents {contents} 0 R >>"
))
.stream(&format!("/Type /XObject /Subtype /Image {dict}"), data)
.stream("", "q 300 0 0 400 0 0 cm /Im0 Do Q");
}
let (mut s, id) = open(pdf.build());
let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
assert_eq!(report.images, 0);
assert_eq!(report.not_scanned, vec![3], "a 1 × 1 image is no scan");
let why: Vec<(usize, &str)> = report.skipped.iter().map(|k| (k.pages[0], k.reason.as_str())).collect();
assert_eq!(why.len(), 5, "{why:?}");
assert!(why[0].0 == 0 && why[0].1.contains("80 million"), "{why:?}");
assert!(why[1].0 == 1 && why[1].1.contains("could not be decoded"), "{why:?}");
assert!(why[2].0 == 2 && why[2].1.contains("shorter"), "{why:?}");
assert!(why[3].0 == 4 && why[3].1.contains("colour space"), "{why:?}");
assert!(why[4].0 == 5 && why[4].1.contains("colour-key mask"), "{why:?}");
assert_eq!(s.get(id).unwrap().can_undo(), None);
// Pages that don't exist are an error, not a panic.
assert!(matches!(s.enhance_scanned_pages(id, &[99], ScanCleanSettings::default()), Err(EditError::Invalid(_))));
}
#[test]
fn a_scan_drawn_inside_a_form_xobject_is_reported_not_called_no_scan() {
// Pages 1 and 3 draw the scan through Form Fm0 (as some scanners write it), page 2 is text.
let page = |contents: usize| {
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> /Font << /F1 9 0 R >> >> /Contents {contents} 0 R >>"
)
};
let bytes = Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R 7 0 R 10 0 R] /Count 3 >>")
.obj(page(6))
.stream(
&format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"),
format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"),
)
.stream(&format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8"), scan_pixels(1))
.stream("", "/Fm0 Do")
.obj(page(8))
.stream("", "BT /F1 12 Tf 20 380 Td (Real text) Tj ET")
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
.obj(page(11))
.stream("", "q /Fm0 Do Q")
.build();
let (mut s, id) = open(bytes);
let before = object(&s, id, 5);
let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
assert!(report.pages.is_empty() && report.images == 0, "{report:?}");
assert_eq!(report.not_scanned, vec![1], "only the text page is no scan");
assert_eq!(report.skipped.len(), 1, "the form is reported once: {report:?}");
let k = &report.skipped[0];
assert_eq!((k.pages.as_slice(), k.name.as_str()), (&[0, 2][..], "Fm0"));
assert!(k.reason.contains("form XObject"), "{k:?}");
assert_eq!(object(&s, id, 5), before, "the picture is left as it was");
assert_eq!(s.get(id).unwrap().can_undo(), None);
// A page of vector artwork in a Form, holding only a small logo, is no scan.
let logo = Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
.obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R >> >> /Contents 6 0 R >>"))
.stream(
&format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"),
format!("0 0 {W} {H} re f q 40 0 0 20 10 370 cm /Im0 Do Q"),
)
.stream("/Type /XObject /Subtype /Image /Width 40 /Height 20 /ColorSpace /DeviceGray /BitsPerComponent 8", [128; 800])
.stream("", "/Fm0 Do")
.build();
let (mut s, id) = open(logo);
let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
assert_eq!((report.not_scanned.as_slice(), report.skipped.len()), (&[0][..], 0), "{report:?}");
}
#[test]
fn a_page_mixing_its_own_image_and_a_scan_in_a_form_cleans_the_one_and_reports_the_other() {
// Page 1: its own image covers 40% and a Form holding a scan 20%: a scan only together.
// Page 2: its own image covers the page and the Form 20% more. Both pages clean Im1 and
// report Fm0.
let page = |contents: usize| {
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Fm0 4 0 R /Im1 6 0 R >> >> /Contents {contents} 0 R >>"
)
};
let gray = format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8");
let bytes = Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 >>")
.obj(page(7))
.stream(
&format!("/Type /XObject /Subtype /Form /BBox [0 0 {W} {H}] /Resources << /XObject << /Im0 5 0 R >> >>"),
format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q"),
)
.stream(&gray, scan_pixels(1))
.stream(&gray, scan_pixels(1))
.stream("", format!("q {W} 0 0 160 0 240 cm /Im1 Do Q q 1 0 0 0.2 0 0 cm /Fm0 Do Q"))
.obj(page(9))
.stream("", format!("q {W} 0 0 {H} 0 0 cm /Im1 Do Q q 1 0 0 0.2 0 0 cm /Fm0 Do Q"))
.build();
let (mut s, id) = open(bytes);
let (form_picture, own) = (object(&s, id, 5), object(&s, id, 6));
let report = s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap();
assert_eq!((report.pages.as_slice(), report.images), (&[0, 1][..], 1), "Im1 is cleaned on both pages: {report:?}");
assert!(report.not_scanned.is_empty(), "{report:?}");
assert_eq!(report.skipped.len(), 1, "the form is reported once: {report:?}");
let k = &report.skipped[0];
assert_eq!((k.pages.as_slice(), k.name.as_str()), (&[0, 1][..], "Fm0"));
assert!(k.reason.contains("form XObject"), "{k:?}");
assert_ne!(object(&s, id, 6), own, "the page's own image is cleaned");
assert_eq!(object(&s, id, 5), form_picture, "the picture in the form is left as it was");
}
#[test]
fn protected_documents_keep_their_encryption_and_restrictions() {
let protect = |user: &str, permissions: i32| {
let mut doc = pdfcraft_cos::Document::open(Arc::new(grey_scan())).unwrap();
doc.set_encryption(&pdfcraft_cos::NewEncryption {
algorithm: pdfcraft_cos::Algorithm::Aes256,
user_password: user,
owner_password: "owner",
permissions,
encrypt_metadata: true,
seed: [7; 32],
})
.unwrap();
Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap())
};
// Opened with the password: cleaned, saved still encrypted, and the clean scan reads back.
let mut s = Session::new();
let id = s.open("x.pdf", None, protect("pw", -1), Some("pw")).unwrap();
assert_eq!(s.enhance_scanned_pages(id, &[], ScanCleanSettings::at(100)).unwrap().images, 1);
let saved = s.save_bytes(id).unwrap();
assert!(pdfcraft_cos::Document::open(saved.clone()).is_err(), "still protected");
let id2 = s.open("y.pdf", None, saved, Some("pw")).unwrap();
assert!(grey_at(&samples(&s, id2), 3, 40) >= 235);
// Changes withheld: refused before any work.
let id = s.open("r.pdf", None, protect("", 0b0100), None).unwrap();
assert_eq!(s.enhance_scanned_pages(id, &[], ScanCleanSettings::default()).unwrap_err(), EditError::NotPermitted("changes to the document"));
assert!(!s.get(id).unwrap().dirty);
}
#[test]
fn a_result_for_an_older_state_is_refused() {
let (mut s, id) = open(grey_scan());
let job = s.scan_clean_job(id, &[], ScanCleanSettings::at(70)).unwrap();
assert_eq!(job.total(), 1);
let cleaned = job.run(|_, _| true).unwrap();
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
assert!(matches!(s.apply_scan_clean(id, cleaned), Err(EditError::Invalid(_))));
assert_eq!(s.get(id).unwrap().can_undo(), Some("Rotate page"));
// Cancelled from the progress callback.
let job = s.scan_clean_job(id, &[], ScanCleanSettings::at(70)).unwrap();
assert!(matches!(job.run(|_, _| false), Err(EditError::Cancelled)));
}
#[test]
fn edge_shadow_removal_whitens_the_gutter_background_removal_alone_leaves() {
let gutter = |edge_shadows: bool| {
let (mut s, id) = open(grey_scan());
s.enhance_scanned_pages(id, &[0], ScanCleanSettings { edge_shadows, ..ScanCleanSettings::at(100) }).unwrap();
let after = samples(&s, id);
[(3, 40), (3, 380), (10, 200)].map(|(x, y)| grey_at(&after, x, y))
};
assert!(gutter(false).iter().any(|&v| v < 200), "{:?}", gutter(false));
assert!(gutter(true).iter().all(|&v| v >= 235), "{:?}", gutter(true));
// On its own, at level 0, it lightens the gutter's paper to the paper beside it.
let (mut s, id) = open(grey_scan());
let before = samples(&s, id);
s.enhance_scanned_pages(id, &[0], ScanCleanSettings { level: 0, edge_shadows: true, despeckle: false }).unwrap();
let after = samples(&s, id);
assert!(grey_at(&after, 3, 40).abs_diff(grey_at(&before, 150, 40)) <= 4, "{} vs {}", grey_at(&after, 3, 40), grey_at(&before, 150, 40));
assert_eq!(grey_at(&after, 150, 40), grey_at(&before, 150, 40), "the middle is left alone");
}
/// Two pages, each with its own scan image (objects 4 and 7), so cleaning one page can be told
/// apart from cleaning the other.
fn two_scans_pdf() -> Vec<u8> {
let dict = format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent 8");
let draw = format!("q {W} 0 0 {H} 0 0 cm /Im0 Do Q");
Pdf::new()
.obj("<< /Type /Catalog /Pages 2 0 R >>")
.obj("<< /Type /Pages /Kids [3 0 R 6 0 R] /Count 2 >>")
.obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>"))
.stream(&dict, scan_pixels(1))
.stream("", &draw)
.obj(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {W} {H}] /Resources << /XObject << /Im0 7 0 R >> >> /Contents 8 0 R >>"))
.stream(&dict, scan_pixels(1))
.stream("", &draw)
.build()
}
#[test]
fn only_the_chosen_pages_scan_is_cleaned() {
// Clean page 1, then page 2 of a fresh copy: the other page's image stays byte-identical.
for (chosen, image, other) in [(0usize, 4u32, 7u32), (1, 7, 4)] {
let (mut s, id) = open(two_scans_pdf());
let (image_before, other_before) = (object(&s, id, image), object(&s, id, other));
assert_eq!(s.get(id).unwrap().page_images(chosen)[0].object, Some(ObjRef::new(image, 0)), "the fixture");
let report = s.enhance_scanned_pages(id, &[chosen], ScanCleanSettings::at(100)).unwrap();
assert_eq!((report.pages.as_slice(), report.images), (&[chosen][..], 1), "page {}", chosen + 1);
assert_ne!(object(&s, id, image), image_before, "page {}'s image was not cleaned", chosen + 1);
assert_eq!(object(&s, id, other), other_before, "cleaning page {} changed the other page's image", chosen + 1);
let (Object::Stream(was), Object::Stream(now)) = (&*other_before, &*object(&s, id, other)) else { panic!("the images") };
assert_eq!(was.raw, now.raw, "the other image's stream bytes");
assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance scanned file"));
}
}
+86 -18
View File
@@ -19,30 +19,94 @@ const SCALE: f32 = 2.0;
const MAX_SIDE: f32 = 3000.0;
/// A page is a scan when its images cover at least this share of it.
const COVERED: f64 = 0.5;
/// A picture inside Form artwork is taken for a scan when it has at least this resolution over
/// the area the artwork covers (a quarter of the pixels a 72 dpi scan would have).
const MIN_FORM_DPI: f64 = 36.0;
/// Recognised "words" less tall than this (points) are specks read as letters, and would make
/// the box they sit on look filled in.
const MIN_WORD_HEIGHT: f64 = 4.0;
/// Whether `page` is a picture of a page: images cover most of it.
pub(crate) fn is_scan(doc: &Document, page: usize, info: &PageInfo) -> bool {
let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from);
let page_area = (cx1 - cx0) * (cy1 - cy0);
if !page_area.is_finite() || page_area <= 0.0 {
return false;
}
let covered: f64 = doc
scan_images(doc, page, info).is_some()
}
/// The raster images (larger than one pixel) `page` draws, each with the share of the page (its
/// crop box) it covers, when together they cover most of it ([`is_scan`]); `None` otherwise.
/// Enhance ▸ Scanned document cleans these up ([`crate::scan_clean`]).
pub(crate) fn scan_images(doc: &Document, page: usize, info: &PageInfo) -> Option<Vec<(pdfcraft_edit::PageImage, f64)>> {
let area = page_area(info)?;
let images: Vec<(pdfcraft_edit::PageImage, f64)> = doc
.page_images(page)
.iter()
.into_iter()
.filter(|i| !i.is_form && i.width > 1 && i.height > 1)
.map(|i| {
let r = i.visible_rect.unwrap_or(i.rect);
let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0);
let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0);
w * h
})
.filter(|a| a.is_finite())
.sum();
covered >= COVERED * page_area
.filter_map(|i| Some((share(info, area, &i)?, i)))
.map(|(share, i)| (i, share))
.collect();
let covered: f64 = images.iter().map(|(_, share)| share).sum();
(covered >= COVERED).then_some(images)
}
/// A page that is a scan, classified in one pass: its raster images (larger than one pixel) and
/// the grouped Form artwork that holds a raster picture, each with the share of the page it
/// covers, when together they cover most of the page; `None` otherwise. The flag says whether the
/// raster images alone do ([`scan_images`]). Enhance ▸ Scanned document cleans the images and
/// reports the Forms, which it can't clean yet, so a page mixing both gets both.
#[allow(clippy::type_complexity)]
pub(crate) fn scan_parts(
doc: &Document,
page: usize,
info: &PageInfo,
) -> Option<(Vec<(pdfcraft_edit::PageImage, f64)>, Vec<(pdfcraft_edit::PageImage, f64)>, bool)> {
let area = page_area(info)?;
let editor = doc.editor.as_ref();
let (mut images, mut forms) = (Vec::new(), Vec::new());
let (mut own, mut covered) = (0.0, 0.0);
for i in doc.page_images(page) {
let Some(share) = share(info, area, &i) else { continue };
if !i.is_form {
if i.width > 1 && i.height > 1 {
own += share;
covered += share;
images.push((i, share));
}
} else if let (Some(form), Some(editor)) = (i.object, editor)
&& holds_scan(&editor.cos, form, share * area)
{
covered += share;
forms.push((i, share));
}
}
(covered >= COVERED).then_some((images, forms, own >= COVERED))
}
/// Whether Form `form`, covering `points` square points of the page, draws a picture that could
/// be a scan of it: one with at least [`MIN_FORM_DPI`] over that area, not a logo inside a page
/// of vector artwork.
fn holds_scan(cos: &pdfcraft_cos::Document, form: pdfcraft_cos::ObjRef, points: f64) -> bool {
let Some(picture) = crate::copy_image::picture_in_form(cos, form) else { return false };
let obj = cos.get(picture);
let pdfcraft_cos::Object::Stream(s) = &*obj else { return false };
let px = |k: &[u8]| s.dict.int(k).unwrap_or(0).max(0) as f64;
let per_point = MIN_FORM_DPI / 72.0;
px(b"Width") * px(b"Height") >= points * per_point * per_point
}
/// The area of `info`'s crop box, when it is a usable one.
fn page_area(info: &PageInfo) -> Option<f64> {
let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from);
let area = (cx1 - cx0) * (cy1 - cy0);
(area.is_finite() && area > 0.0).then_some(area)
}
/// The share of the crop box (of area `area`) that `image` covers, when it is a number.
fn share(info: &PageInfo, area: f64, image: &pdfcraft_edit::PageImage) -> Option<f64> {
let [cx0, cy0, cx1, cy1] = info.crop.map(f64::from);
let r = image.visible_rect.unwrap_or(image.rect);
let w = (r[2].min(cx1) - r[0].max(cx0)).max(0.0);
let h = (r[3].min(cy1) - r[1].max(cy0)).max(0.0);
let share = w * h / area;
share.is_finite().then_some(share)
}
/// The page as displayed, in points with y up: where detection on a scan works.
@@ -122,10 +186,14 @@ impl Session {
if !ocr::available() {
return Vec::new();
}
let (Ok(engine), Some(job)) = (ocr::engine(), self.ocr_job(id, &[page], ocr::OcrSettings::default())) else {
let settings = ocr::OcrSettings::default();
let Some(job) = self.ocr_job(id, &[page], settings.clone()) else {
return Vec::new();
};
job.run(&engine, |_, _| true)
let Ok(recognizers) = ocr::recognizers(&settings) else {
return Vec::new();
};
job.run(&recognizers, |_, _| true)
.into_iter()
.flat_map(|p| p.words)
.filter_map(|w| {
+7 -5
View File
@@ -11,11 +11,11 @@ use crate::edit::{EditCtx, check_permission, run_edit};
use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts};
use crate::{
BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution,
LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, SummarySort, TrustStore,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, comment_list, comment_summary, decode_text, display_bytes, display_config, guard,
info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, parse_ymd, print, print_raster,
render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices,
use_layer_choices, writable, xfa, xfa_sync_datasets,
LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort,
TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, comment_summary, decode_text,
display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows,
parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of,
source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets,
};
mod editing;
@@ -36,6 +36,8 @@ pub struct Session {
utc_offset: Option<fn() -> i64>,
/// Certificates trusted for signing (Acrobat: Trusted Certificates).
pub(crate) trust: Arc<TrustStore>,
/// The words signature status layers are drawn with, in the interface language.
pub(crate) captions: Arc<StatusCaptions>,
/// Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript, inverted (on by default).
pub(crate) js_off: bool,
/// Preferences ▸ Date format, when not the default (see [`dates`]).
+44 -14
View File
@@ -2,11 +2,11 @@
use super::check_refresh_panic;
use super::{
Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, display_config, guard, info_fields, inspect, js, keys_after,
note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices,
uses_scripts, write_incremental, xfa, xfa_sync_datasets,
XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, carry_verdict_marks, check_permission, comment_list, display_bytes, display_config, guard, info_fields,
inspect, js, keys_after, note_warnings, render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state,
undrawn_notices, uses_scripts, write_incremental, xfa, xfa_sync_datasets,
};
use crate::{EditNotice, MarkKind};
use crate::{EditNotice, MarkKind, print_preview_layers};
/// Whether the edit, or any edit inside a batch, deletes pages.
fn deletes_pages(edit: &Edit) -> bool {
@@ -20,6 +20,19 @@ fn deletes_pages(edit: &Edit) -> bool {
impl Session {
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> {
self.apply_reporting(id, edit).map(|_| ())
}
/// Edit a PDF ▸ Edit text: make the grouped text on `page` (0-based) editable, as one undoable
/// [`Edit::PrepareText`], and say what it prepared, which fonts' maps it repaired and which
/// groups it left as they were (and why). The counts are this edit's own, not a preview's.
pub fn prepare_text(&mut self, id: DocId, page: usize) -> Result<pdfcraft_edit::text::forms::Preparation, EditError> {
self.apply_reporting(id, Edit::PrepareText { page })?
.ok_or_else(|| EditError::Invalid("preparing grouped text gave no result; the document was not changed".into()))
}
/// [`Session::apply`], giving back what an [`Edit::PrepareText`] did (`None` for other edits).
fn apply_reporting(&mut self, id: DocId, edit: Edit) -> Result<Option<pdfcraft_edit::text::forms::Preparation>, EditError> {
let now = self.now();
let today = self.today();
let js_off = self.js_off;
@@ -61,7 +74,7 @@ impl Session {
};
// Nothing to bake: leave undo history and the dirty flag alone.
if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) {
return Ok(());
return Ok(None);
}
let mut next = editor.cos.clone();
if !js_off && uses_scripts(&edit) {
@@ -228,7 +241,7 @@ impl Session {
doc.xfa_scripts_off = true;
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
}
Ok(())
Ok(cx.prepared)
}
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
@@ -342,7 +355,14 @@ impl Session {
#[cfg(test)]
check_refresh_panic();
if !doc.signatures.is_empty() {
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
let mut signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
// The display copy is kept for a metadata edit, and so are its marks, unless the
// edit changed a verdict (a certification can forbid it): the page then draws the
// new one.
if !carry_verdict_marks(&doc.signatures, &mut signatures) {
doc.display = display_bytes(Some(editor), &bytes, &mut signatures, &doc.captions);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
doc.signatures = Arc::new(signatures);
}
doc.bytes = bytes;
@@ -386,8 +406,8 @@ impl Session {
}
doc.info.file_size = bytes.len();
doc.form = form;
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes);
let mut signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes, &mut signatures, &doc.captions);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
@@ -414,7 +434,7 @@ impl Session {
| Edit::ReplaceImage { .. }
| Edit::PasteContent { .. }
| Edit::EditPageImage { .. }
) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
) || matches!(e, Edit::MoveObjects { objects, .. } | Edit::DeleteObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
});
let touches_marks = edit.is_none_or(|e| {
matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. })
@@ -444,8 +464,8 @@ impl Session {
doc.marker_scan = OnceLock::new();
}
doc.info.file_size = bytes.len();
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes);
let mut signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &bytes, &mut signatures, &doc.captions);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
@@ -467,6 +487,13 @@ impl Session {
doc.renderer = RenderPool::new_inline(Arc::new(Vec::new()), doc.config.clone());
doc.display = original.clone();
doc.bytes = original;
// The page shows the original's own artwork again, so nothing counts as drawn, even if
// the refresh that follows fails.
if doc.signatures.iter().any(|s| s.verdict_shown) {
for sig in Arc::make_mut(&mut doc.signatures) {
sig.verdict_shown = false;
}
}
#[cfg(test)]
check_refresh_panic();
}
@@ -488,6 +515,8 @@ impl Session {
}
fn refresh_with_indexes(doc: &mut Document, invalidate_markers: bool, invalidate_links: bool) -> Result<(), EditError> {
#[cfg(test)]
crate::record_full_refresh();
// A full refresh may follow structural or annotation changes: cached appearances
// may be stale, so they are rebuilt below.
if let Some(editor) = doc.editor.as_mut() {
@@ -518,6 +547,7 @@ impl Session {
}
}
doc.info = info;
doc.config.print_layers = print_preview_layers(Some(&editor.cos), &doc.info.layers);
doc.fonts_stale = false;
let mut form = pdfcraft_forms::fields(&editor.cos);
xfa::mark_script_buttons(&editor.cos, &mut form);
@@ -528,8 +558,8 @@ impl Session {
if invalidate_links {
doc.links = OnceLock::new();
}
let signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
doc.display = display_bytes(Some(editor), &bytes);
let mut signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
doc.display = display_bytes(Some(editor), &bytes, &mut signatures, &doc.captions);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
doc.bytes = bytes;
+33 -4
View File
@@ -1,7 +1,7 @@
use super::{
Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions,
Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state,
source_kind, writable, write_full,
Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_layer_states, print_raster,
set_default_layer_state, source_kind, writable, write_full,
};
impl Session {
@@ -114,6 +114,19 @@ impl Session {
.map_err(EditError::Write)
}
/// XFDF with these comments (0-based page, position in the page's `/Annots`) and their
/// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in.
pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result<String, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
let cos = match doc.editor.as_ref() {
Some(e) => e.cos.clone(),
None => {
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
}
};
guard(|| pdfcraft_xfdf::export_xfdf_comments(&cos, which, &doc.name)).map_err(EditError::Write)
}
/// The print-ready PDF for `settings` (sheets laid out for the paper; see `pdfcraft-print`).
pub fn print_pdf(&self, id: DocId, settings: &print::Settings) -> Result<Vec<u8>, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
@@ -151,12 +164,28 @@ impl Session {
pdfcraft_cos::Document::open_with_password(doc.bytes.clone(), doc.password.as_deref()).map_err(|e| EditError::Write(e.to_string()))?
}
};
// Comments and fields without an appearance print as the page shows them (and as Print
// as Image already printed them): the vector print drew only appearance streams, so a
// text box without one was on screen but not on paper. ISO 32000-2 §12.5.3: an
// annotation with the Print flag is printed.
if let Some(displayed) = guard(|| crate::document::as_displayed(&cos)).ok().flatten() {
cos = displayed;
}
// #3046: the layers print as they are shown. Once the Layers panel (or a set-layer
// action) has changed one, the print copy's default configuration says which are on, so a
// hidden layer stays off on paper too; untouched, the document's own configuration prints.
if !doc.config.layers.is_empty() {
// On top of either, the configuration's auto state for the Print event sets the layers it
// names (`print_layer_states`), as printing does in a viewer (ISO 32000-2 §8.11.4.4).
if let Some(printed) = print_layer_states(&cos, &doc.info.layers) {
set_default_layer_state(&mut cos, &printed);
} else if !doc.config.layers.is_empty() {
set_default_layer_state(&mut cos, &doc.info.layers);
}
// Signature states as the screen shows them, so not when its display copy could not be
// written. This copy is only printed, never saved.
if doc.signatures.iter().any(|s| s.verdict_shown) {
let _ = pdfcraft_sign::show_status_layers(&mut cos, &mut doc.signatures.to_vec(), &doc.captions);
}
pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string()))
}
@@ -384,7 +413,7 @@ impl Session {
/// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone
/// writes it (#1698, #2166): it reopens, and shows the pages it was built from.
fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
pub(crate) fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
let bytes = self.write_unchecked(doc)?;
let expected = pdfcraft_organize::page_count(doc)?;
pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?;
+134 -12
View File
@@ -1,6 +1,6 @@
use super::{
Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes,
display_config, guard, render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets,
Arc, DocId, Document, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, StatusCaptions, TrustStore,
display_bytes, display_config, guard, render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets,
};
impl Session {
@@ -217,6 +217,15 @@ impl Session {
out
}
/// The snapshot [`Self::autosave_snapshots`] handed out for `id` could not be stored: the
/// document is due again, so the next autosave tries it again.
pub fn autosave_failed(&mut self, id: DocId) {
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
// Any generation but the current one leaves it due.
d.snapshot_generation = d.generation.wrapping_sub(1);
}
}
/// Mark a document opened from a recovery file: it has unsaved changes and belongs at
/// `path` (where Save writes), as when the session ended.
pub fn mark_recovered(&mut self, id: DocId, path: Option<String>) {
@@ -242,17 +251,55 @@ impl Session {
pub fn set_trusted_certificates(&mut self, certs: Vec<pdfcraft_sign::Certificate>) {
self.trust = Arc::new(TrustStore { certs });
for doc in &mut self.docs {
doc.trust = self.trust.clone();
let Some(editor) = doc.editor.as_mut() else { continue };
// Keep verdict refresh and renderer refresh together. Viewer caches are
// invalidated by the UI trust path; trust alone changes no document artwork.
let signatures = if doc.signatures.is_empty() { Vec::new() } else { signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache) };
doc.display = display_bytes(Some(editor), &doc.bytes);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
Self::revalidate(doc, self.trust.clone());
}
}
/// Validate `doc`'s signatures against `trust`, and rebuild its display copy and renderer
/// with them: the verdicts choose what the copy draws. An unsigned document has nothing to
/// validate and keeps both. Viewer caches are invalidated by the UI trust path.
pub(crate) fn revalidate(doc: &mut Document, trust: Arc<TrustStore>) {
doc.trust = trust;
if doc.signatures.is_empty() {
return;
}
let Some(editor) = doc.editor.as_mut() else { return };
let mut signatures = signatures_of(&editor.cos, &doc.bytes, &doc.trust, &doc.sig_cache);
doc.display = display_bytes(Some(editor), &doc.bytes, &mut signatures, &doc.captions);
doc.signatures = Arc::new(signatures);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// The words the caption of a named-layer signature appearance is drawn with
/// ([`pdfcraft_sign::show_status_layers`]), in the interface language. When they change, every
/// open document's display copy and renderer are rebuilt; like trust, this is not an edit.
/// Returns whether they changed.
pub fn set_signature_captions(&mut self, captions: StatusCaptions) -> bool {
if *self.captions == captions {
return false;
}
self.captions = Arc::new(captions);
for doc in &mut self.docs {
Self::draw_captions(doc, self.captions.clone());
}
true
}
/// Draw `doc`'s signature status layers with `captions`: the display copy and renderer are
/// rebuilt when it draws a signature's state (working file, generation and dirty unchanged).
/// Whether a state is drawn does not depend on the words: a caption that can't be drawn in
/// them is drawn in English.
pub(crate) fn draw_captions(doc: &mut Document, captions: Arc<StatusCaptions>) {
doc.captions = captions;
if !doc.signatures.iter().any(|s| s.verdict_shown) {
return;
}
let Some(editor) = doc.editor.as_mut() else { return };
let signatures = Arc::make_mut(&mut doc.signatures);
doc.display = display_bytes(Some(editor), &doc.bytes, signatures, &doc.captions);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), display_config(&doc.config));
}
/// The signing time as a PDF date in local time with its offset (`D:…+02'00'`).
pub fn signing_date(&self) -> String {
let offset = self.local_offset();
@@ -276,7 +323,7 @@ impl Session {
opts.date = self.signing_date();
}
// A typed Password-field value is not signed into the file (#3926), as it isn't saved.
let cos = writable(&editor.cos)?;
let cos = drawn_for_first_signature(writable(&editor.cos)?);
Ok(Arc::new(pdfcraft_sign::sign(&cos, id, &opts)?))
}
@@ -294,7 +341,7 @@ impl Session {
if opts.date.is_empty() {
opts.date = self.signing_date();
}
let cos = writable(&editor.cos)?;
let cos = drawn_for_first_signature(writable(&editor.cos)?);
let signed = Arc::new(pdfcraft_sign::sign_with_timestamp(&cos, id, &opts, tsa)?);
// Checked like a save before anyone writes it (#2166).
self.check_save(doc, &signed)?;
@@ -336,3 +383,78 @@ impl Session {
Ok(())
}
}
/// `cos` with its form fields drawn, when the signature about to be made is the document's first
/// and the form sets `/NeedAppearances` (#5939). That signature removes the flag, after which
/// viewers show each field's stored appearance; a filled field whose stored appearance draws no
/// text (an empty placeholder such as `/Tx BMC EMC`, or a stale look) would go blank. So every
/// text and choice field is drawn from its value first, as the document is displayed
/// ([`pdfcraft_forms::with_field_appearances`]), and the signature covers that look.
///
/// One exception: where PdfCraft's drawing would show a character as `?` (the field's font can't
/// hold it) and the field's own appearance draws text, the field keeps its own appearance, which
/// the program that filled it drew with a font that has the character. A field whose own
/// appearance draws no text gets PdfCraft's drawing, `?` and all. Later signatures change
/// nothing here: redrawing would count as a change to the earlier signatures.
fn drawn_for_first_signature(cos: std::borrow::Cow<'_, pdfcraft_cos::Document>) -> std::borrow::Cow<'_, pdfcraft_cos::Document> {
use pdfcraft_forms::FieldKind;
if !pdfcraft_forms::need_appearances(&cos) || pdfcraft_sign::has_signatures(&cos) {
return cos;
}
let Some(mut drawn) = pdfcraft_forms::with_field_appearances(&cos) else { return cos };
for f in pdfcraft_forms::fields(&cos) {
if !matches!(f.kind, FieldKind::Text | FieldKind::Combo | FieldKind::List) {
continue;
}
for w in &f.widgets {
let own = cos.get(w.obj).as_dict().filter(|d| draws_text(&cos, d)).and_then(|d| d.get(b"AP").cloned());
// `.1`: the first character the drawing showed as `?`.
if let Some(ap) = own
&& pdfcraft_forms::appearance::field_appearance_drawn(&cos, &f, w, &f.value, true).1.is_some()
{
drawn.update_dict(w.obj, |d| d.set(b"AP".to_vec(), ap)).ok();
}
}
}
std::borrow::Cow::Owned(drawn)
}
/// Whether the widget's normal appearance stream (`/AP /N`) shows text, itself or through the
/// form XObjects it paints (a background-only one doesn't count).
fn draws_text(cos: &pdfcraft_cos::Document, widget: &pdfcraft_cos::Dict) -> bool {
let ap = widget.get(b"AP").map(|ap| cos.resolve(ap));
let normal = ap.as_deref().and_then(|ap| ap.as_dict()).and_then(|ap| ap.get(b"N")).map(|n| cos.resolve(n));
let Some(pdfcraft_cos::Object::Stream(s)) = normal.as_deref() else { return false };
stream_draws_text(cos, s, &mut 16)
}
/// Whether the form stream `s` shows text, following `Do` into its form XObjects. `budget` caps
/// the streams opened along the way; running out (deep nesting, a cycle, many XObjects) counts
/// as text, so the field keeps its own appearance as it would without the look inside.
fn stream_draws_text(cos: &pdfcraft_cos::Document, s: &pdfcraft_cos::Stream, budget: &mut u8) -> bool {
let Ok(data) = s.decoded() else { return false };
let resources = s.dict.get(b"Resources").map(|r| cos.resolve(r));
let xobjects = resources.as_deref().and_then(|r| r.as_dict()).and_then(|r| r.get(b"XObject")).map(|x| cos.resolve(x));
for op in pdfcraft_content::ops(&data) {
match op.op.as_slice() {
b"Tj" | b"TJ" | b"'" | b"\"" => return true,
b"Do" => {
let name = op.operands.first().and_then(|n| n.as_name());
let x = name.and_then(|n| xobjects.as_deref().and_then(|x| x.as_dict()).and_then(|x| x.get(n))).map(|x| cos.resolve(x));
let Some(pdfcraft_cos::Object::Stream(f)) = x.as_deref() else { continue };
if f.dict.name(b"Subtype") != Some(b"Form") {
continue;
}
if *budget == 0 {
return true;
}
*budget -= 1;
if stream_draws_text(cos, f, budget) {
return true;
}
}
_ => {}
}
}
false
}
+118 -11
View File
@@ -120,17 +120,7 @@ impl Session {
let rects: Vec<[f64; 4]> = text
.line_rects(glyphs)
.into_iter()
.map(|v| {
let q = doc.info.pages[page].view_rect_to_quad(v);
let xs = [q[0], q[2], q[4], q[6]];
let ys = [q[1], q[3], q[5], q[7]];
[
xs.iter().copied().fold(f64::MAX, f64::min),
ys.iter().copied().fold(f64::MAX, f64::min),
xs.iter().copied().fold(f64::MIN, f64::max),
ys.iter().copied().fold(f64::MIN, f64::max),
]
})
.filter_map(|v| line_user_rect(&doc.info.pages[page], v))
.filter(|r| {
!doc.links().iter().any(|l| l.page == page && l.rect[0] < r[2] && r[0] < l.rect[2] && l.rect[1] < r[3] && r[1] < l.rect[3])
})
@@ -168,6 +158,14 @@ impl Session {
Some(BookmarkPage { rows, next: (end < all.len()).then_some(end), truncated: doc.info.outline_more })
}
/// The bookmark at `path` with everything under it, or the whole outline for `[]`, as a tree
/// another document can take ([`crate::Edit::ImportBookmarks`]). `None` when no document has
/// `id`, or its structure could not be read for editing.
pub fn outline_tree(&self, id: DocId, path: &[usize]) -> Option<Result<pdfcraft_organize::OutlineTree, pdfcraft_organize::OutlineError>> {
let editor = self.get(id)?.editor.as_ref()?;
Some(pdfcraft_organize::outline_tree(&editor.cos, path))
}
/// Top-level bookmarks as split points: (first page of each part, its bookmark's title).
pub fn bookmark_splits(&self, id: DocId) -> Vec<(usize, String)> {
let Some(doc) = self.get(id) else { return Vec::new() };
@@ -305,3 +303,112 @@ impl Session {
&self.docs
}
}
/// Convert a view-space text line rectangle into a normalized, finite PDF user-space rectangle.
fn line_user_rect(page: &pdfcraft_render::PageInfo, v: [f32; 4]) -> Option<[f64; 4]> {
if !v.iter().all(|x| x.is_finite()) {
return None;
}
let r = page.view_rect_to_user(v).map(f64::from);
r.iter().all(|x| x.is_finite()).then_some(r)
}
#[cfg(test)]
mod tests {
use super::line_user_rect;
use crate::{Edit, Session};
use pdfcraft_render::PageInfo;
use std::sync::Arc;
/// Build a sample `PageInfo` with a non-origin crop box `[10.0, 20.0, 210.0, 320.0]` (`200 × 300`).
fn sample_page(rotation: u16) -> PageInfo {
let (width, height) = if rotation % 180 == 90 { (300.0, 200.0) } else { (200.0, 300.0) };
PageInfo { width, height, label: String::new(), crop: [10.0, 20.0, 210.0, 320.0], rotation }
}
#[test]
fn line_user_rect_rejects_non_finite_coordinates() {
let page = sample_page(0);
assert_eq!(line_user_rect(&page, [f32::NAN; 4]), None);
assert_eq!(line_user_rect(&page, [f32::NAN, 10.0, 50.0, 20.0]), None);
assert_eq!(line_user_rect(&page, [10.0, 20.0, f32::INFINITY, 30.0]), None);
assert_eq!(line_user_rect(&page, [10.0, f32::NEG_INFINITY, 50.0, 30.0]), None);
for bad_crop_box in [[f32::NAN, 0.0, 200.0, 300.0], [0.0, f32::INFINITY, 200.0, 300.0], [0.0, 0.0, f32::NEG_INFINITY, 300.0]] {
let mut bad_crop = sample_page(0);
bad_crop.crop = bad_crop_box;
assert_eq!(line_user_rect(&bad_crop, [10.0, 20.0, 50.0, 40.0]), None);
}
// Finite inputs whose crop span overflows f32 during view-to-user mapping must also be rejected.
let overflow_page = PageInfo { width: 1.0, height: 1.0, label: String::new(), crop: [-f32::MAX, 0.0, f32::MAX, 300.0], rotation: 0 };
assert_eq!(line_user_rect(&overflow_page, [1.0, 10.0, 2.0, 20.0]), None);
}
#[test]
fn line_user_rect_matches_view_rect_to_user_across_rotations() {
let v = [12.0, 24.0, 96.0, 48.0];
let cases = [
(0, [22.0, 272.0, 106.0, 296.0]),
(90, [34.0, 32.0, 58.0, 116.0]),
(180, [114.0, 44.0, 198.0, 68.0]),
(270, [162.0, 224.0, 186.0, 308.0]),
];
for (rotation, expected) in cases {
let page = sample_page(rotation);
let got = line_user_rect(&page, v).expect("finite view rect maps to user space");
for (g, e) in got.into_iter().zip(expected) {
assert!((g - e).abs() < 1e-4, "rotation {rotation}: got {got:?}, expected {expected:?}");
}
}
}
#[test]
fn find_urls_returns_finite_user_rects_on_rotated_page() {
let mut objs: Vec<Vec<u8>> = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(),
];
let body = "BT /F1 12 Tf 20 150 Td (Visit https://example.com today) Tj ET";
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
let mut pdf = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
pdf.extend_from_slice(o);
pdf.extend_from_slice(b"\nendobj\n");
}
let xref = pdf.len();
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offsets {
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
let mut session = Session::new();
let id = session.open("urls.pdf", None, Arc::new(pdf), None).expect("opens");
let expected_rect = [46.004, 147.6, 153.38, 159.6];
let base_urls = session.find_urls(id);
assert_eq!(base_urls.len(), 1);
let base_rect = base_urls[0].1[0];
for (got, exp) in base_rect.into_iter().zip(expected_rect) {
assert!((got - exp).abs() < 1e-2, "0 deg: got {base_rect:?}, expected {expected_rect:?}");
}
for step in [90, 180, 270] {
session.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("rotates");
let urls = session.find_urls(id);
assert_eq!(urls.len(), 1);
assert_eq!(urls[0].0, 0);
assert_eq!(urls[0].2, "https://example.com");
assert_eq!(urls[0].1.len(), 1);
let r = urls[0].1[0];
for (got, exp) in r.into_iter().zip(expected_rect) {
assert!((got - exp).abs() < 1e-2, "rotation {step} deg: got {r:?}, expected {expected_rect:?}");
}
}
}
}
+14
View File
@@ -40,3 +40,17 @@ pub(crate) fn record_link_scan() {
counts.set((markers, links + 1));
});
}
thread_local! {
static FULL_REFRESHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// A whole-document refresh (re-inspection) ran on this thread.
pub(crate) fn record_full_refresh() {
FULL_REFRESHES.with(|n| n.set(n.get() + 1));
}
/// How many whole-document refreshes ran on this thread since the last call, and starts over.
pub(crate) fn take_full_refreshes() -> usize {
FULL_REFRESHES.with(|n| n.replace(0))
}
+1562 -12
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -78,6 +78,7 @@ fn count_inner(source: ExportSource, pages: &[usize], options: Options, mut prog
tables: options.exclude_tables,
code: options.exclude_code,
images: options.exclude_images,
painted: false,
mcids: excluded.remove(&page).unwrap_or_default(),
};
let text = renderer.filtered_text(page, filter).map_err(|e| format!("page {}: {e}", page.saturating_add(1)))?.plain_text();
+201 -13
View File
@@ -355,6 +355,9 @@ struct Runner<'a, 'b> {
/// rebuilt when the structure changed).
session: Option<XfaSession>,
changes: Changes,
/// A field embedded in rich text (a floating field) took a new value since the form was
/// last laid out: the text that shows it is laid out again when the event ends.
embedded_changed: bool,
}
impl<'a, 'b> Runner<'a, 'b> {
@@ -387,6 +390,7 @@ impl<'a, 'b> Runner<'a, 'b> {
js_root: None,
session: None,
changes: Changes::default(),
embedded_changed: false,
}
}
@@ -508,6 +512,9 @@ impl<'a, 'b> Runner<'a, 'b> {
fn set_value(&mut self, som: &str, value: &str) -> bool {
use pdfcraft_forms::FieldKind as K;
let path = pdfcraft_xfa::data_path(self.tpl, som);
// A floating field shows the value of its data node in the text that embeds it (XFA 3.3
// pp. 221–222): a page area's, or one another field shares, as well.
let embedded = pdfcraft_xfa::path_is_embedded(self.tpl, &path);
let names = self.widgets_at(&path);
// The data text: the widget at `som` says how its kind writes the value; without one,
// the first widget bound to the node, else the value itself.
@@ -557,6 +564,7 @@ impl<'a, 'b> Runner<'a, 'b> {
}
if changed {
self.changes.values += 1;
self.embedded_changed |= embedded;
}
changed
}
@@ -607,6 +615,7 @@ impl<'a, 'b> Runner<'a, 'b> {
}
self.relayouts += 1;
self.flush()?;
self.embedded_changed = false;
let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?;
self.console(report.warnings);
crate::xfa_redraw(self.doc)?;
@@ -644,19 +653,24 @@ impl<'a, 'b> Runner<'a, 'b> {
out
};
for som in unshown {
// A floating field rich text embeds goes back to its default with every row that
// holds it (below).
if pdfcraft_xfa::field_is_embedded(self.tpl, &som) {
continue;
}
let Some(text) = pdfcraft_xfa::reset_default(self.tpl, &som) else { continue };
let path = pdfcraft_xfa::data_path(self.tpl, &som);
self.reset_data(path, text);
}
if listed.as_ref().is_some_and(Vec::is_empty) {
self.invalidate();
return Ok(());
// Names no widget answers to may still name a floating field (below).
let widgets = !listed.as_ref().is_some_and(Vec::is_empty);
if widgets {
pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?;
}
pdfcraft_forms::reset(self.doc, listed.as_deref()).map_err(|e| e.to_string())?;
self.fields = None;
let infos = self.fields().clone();
let listed: Option<HashSet<&String>> = listed.as_ref().map(|l| l.iter().collect());
for (som, name) in &by_som {
for (som, name) in by_som.iter().filter(|_| widgets) {
if listed.as_ref().is_some_and(|l| !l.contains(name)) {
continue;
}
@@ -667,15 +681,33 @@ impl<'a, 'b> Runner<'a, 'b> {
let path = pdfcraft_xfa::data_path(self.tpl, som);
self.reset_data(path, data_text(info));
}
// A floating field without a widget (a hidden one, as forms place them) goes back to
// its default too, and the text that embeds it shows that.
for (som, default) in pdfcraft_xfa::embedded_fields(self.tpl) {
if by_som.contains_key(&som) || !wanted(&som) {
continue;
}
// Every row that holds it, not only the first.
let names: Vec<String> = pdfcraft_xfa::data_path(self.tpl, &som).into_iter().map(|(name, _)| name).collect();
let paths = self.data.as_ref().map(|d| d.paths_named(&names, pdfcraft_xfa::MAX_FORM_NODES)).unwrap_or_default();
for path in paths {
if self.data.as_ref().and_then(|d| d.text_at(&path)).unwrap_or("") != default {
self.embedded_changed = true;
self.data_op(pdfcraft_xfa::DataOp::Value { path, text: default.clone() });
self.changes.values += 1;
}
}
}
self.invalidate();
Ok(())
}
/// Put a reset's `text` in the data at `path`, unless it already holds it: a multi-select
/// list's picks (`value` elements) are compared as its lines, and replaced.
/// list's picks (`value` elements) are compared as its lines (the write replaces them).
fn reset_data(&mut self, path: pdfcraft_xfa::DataPath, text: String) {
if self.data.as_ref().and_then(|d| d.value_at(&path)).unwrap_or_default() != text {
self.data_op(pdfcraft_xfa::DataOp::Reset { path, text });
self.embedded_changed |= pdfcraft_xfa::path_is_embedded(self.tpl, &path);
self.data_op(pdfcraft_xfa::DataOp::Value { path, text });
self.changes.values += 1;
}
}
@@ -854,7 +886,7 @@ impl<'a, 'b> Runner<'a, 'b> {
fn finish(mut self) -> Result<Changes, String> {
#[cfg(test)]
self.check_live();
if self.relayouts_skipped || self.relayout_owed {
if self.relayouts_skipped || self.relayout_owed || self.embedded_changed {
self.relayouts = 0;
self.relayouts_skipped = false;
self.relayout_owed = false;
@@ -915,7 +947,9 @@ fn collect_fields(n: &FormNode, out: &mut Vec<(String, String, String, String)>,
}
}
/// Reflow a laid-out XFA form when an edited multiline field can grow. The datasets must
/// Reflow a laid-out XFA form when an edited multiline field can grow, or an edited text field,
/// choice list or check box is embedded in rich text (a floating field, whose value that text
/// shows). The datasets must
/// already hold the final values, including calculation results. Fixed-height fields keep
/// their existing geometry; forms without scripts parse their template only when needed.
/// `Some` means pages/widgets were rebuilt and the caller must fully refresh its snapshot.
@@ -930,12 +964,14 @@ pub(crate) fn relayout_changed_text(
let current = &*doc;
let mut changed = before
.iter()
.filter(|f| f.kind == pdfcraft_forms::FieldKind::Text && f.has(pdfcraft_forms::flags::MULTILINE))
.filter(|f| {
use pdfcraft_forms::FieldKind as K;
matches!(f.kind, K::Text | K::Combo | K::List | K::CheckBox)
})
.filter_map(|f| {
let object = current.get(f.obj);
let dict = object.as_dict()?;
let value = dict.get(b"V").and_then(|v| current.resolve(v).as_string().map(|s| s.to_text())).unwrap_or_default();
if f.value.first().map_or("", String::as_str) == value {
if value_unchanged(current, f, dict.get(b"V")) {
return None;
}
dict.get(pdfcraft_xfa::SOM_KEY).and_then(|s| current.resolve(s).as_string().map(|s| s.to_text()))
@@ -948,7 +984,7 @@ pub(crate) fn relayout_changed_text(
Some(tpl) => std::borrow::Cow::Borrowed(tpl),
None => std::borrow::Cow::Owned(pdfcraft_xfa::template_of(current).map_err(|e| e.to_string())?),
};
let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som));
let reflow = changed.any(|som| pdfcraft_xfa::field_grows_with_text(&template, &som) || pdfcraft_xfa::field_is_embedded(&template, &som));
drop(changed);
if !reflow {
return Ok(None);
@@ -958,6 +994,60 @@ pub(crate) fn relayout_changed_text(
Ok(Some(report.warnings))
}
/// Whether field `f` still holds the value it had (`f.value`), its `/V` being `v`: a list box's
/// picks are an array, a check box's state a name (Off for none, as the field's value has
/// none), and of a text field only the first value counts. Compared in place, so an edit costs
/// no copy of every field's value.
fn value_unchanged(doc: &pdfcraft_cos::Document, f: &pdfcraft_forms::Field, v: Option<&pdfcraft_cos::Object>) -> bool {
use pdfcraft_cos::Object as O;
let text = f.kind == pdfcraft_forms::FieldKind::Text;
let first = f.value.first().map_or("", String::as_str);
// The value is the one string `is` accepts.
let one = |is: &dyn Fn(&str) -> bool| if text { is(first) } else { matches!(f.value.as_slice(), [old] if is(old)) };
let none = if text { first.is_empty() } else { f.value.is_empty() };
let held;
let v = match v {
Some(r @ O::Ref(_)) => {
held = doc.resolve(r);
Some(&*held)
}
v => v,
};
match v {
Some(O::Array(items)) => {
let mut old = f.value.iter();
for item in items {
let held_item;
let item = match item {
O::Ref(_) => {
held_item = doc.resolve(item);
&*held_item
}
i => i,
};
let Some(s) = item.as_string() else { continue };
if text {
return s.text_eq(first);
}
if !old.next().is_some_and(|o| s.text_eq(o)) {
return false;
}
}
if text { none } else { old.next().is_none() }
}
Some(O::Name(n)) if n.as_slice() == b"Off" => none,
Some(O::Name(n)) => {
let n = String::from_utf8_lossy(n);
one(&|old| old == n)
}
Some(v) => match v.as_string() {
Some(s) => one(&|old| s.text_eq(old)),
None => none,
},
None => none,
}
}
/// On open: initialize scripts, then calculations. Returns what they changed.
pub(crate) fn on_open(doc: &mut pdfcraft_cos::Document, tpl: &Template, run: &mut XfaRun) -> Result<Changes, String> {
let mut r = Runner::new(doc, tpl, run, OPEN_BUDGET_MS);
@@ -1152,4 +1242,102 @@ mod tests {
Some((2, "form[0].table[0]".into(), "row".into(), 2))
);
}
fn field(obj: pdfcraft_cos::ObjRef, kind: pdfcraft_forms::FieldKind, value: &[&str]) -> pdfcraft_forms::Field {
pdfcraft_forms::Field {
name: "f".into(),
obj,
kind,
value: value.iter().map(|v| (*v).to_owned()).collect(),
default: Vec::new(),
flags: 0,
max_len: None,
options: Vec::new(),
da: String::new(),
quadding: 0,
tooltip: None,
widgets: Vec::new(),
actions: pdfcraft_forms::af::Actions::default(),
button: None,
}
}
/// A field dictionary whose `/V` is `v`, with a SOM path.
fn field_dict(doc: &mut pdfcraft_cos::Document, v: Option<pdfcraft_cos::Object>) -> pdfcraft_cos::ObjRef {
let mut d = pdfcraft_cos::Dict::new();
if let Some(v) = v {
d.set(b"V".to_vec(), v);
}
d.set(pdfcraft_xfa::SOM_KEY.to_vec(), pdfcraft_cos::Object::String(pdfcraft_cos::PdfString::text("form[0].f[0]")));
doc.add(d)
}
#[test]
fn changed_values_are_found_in_place() {
use pdfcraft_cos::{Object as O, PdfString};
use pdfcraft_forms::FieldKind as K;
let s = |t: &str| O::String(PdfString::text(t));
let mut doc = pdfcraft_cos::Document::new_empty();
let same = |doc: &mut pdfcraft_cos::Document, kind, old: &[&str], v: Option<O>| {
let r = field_dict(doc, v);
let dict = doc.get(r);
value_unchanged(doc, &field(r, kind, old), dict.as_dict().and_then(|d| d.get(b"V")))
};
// Text: the first value only, an absent or non-text value being empty.
assert!(same(&mut doc, K::Text, &["Café"], Some(s("Café"))));
assert!(!same(&mut doc, K::Text, &["Café"], Some(s("Cafe"))));
assert!(same(&mut doc, K::Text, &[], None));
assert!(same(&mut doc, K::Text, &[], Some(s(""))));
assert!(!same(&mut doc, K::Text, &["a"], None));
assert!(same(&mut doc, K::Text, &["a", "b"], Some(O::Array(vec![s("a"), s("c")]))));
// A value given by reference is followed.
let inner = doc.add(s("日本"));
assert!(same(&mut doc, K::Text, &["日本"], Some(O::Ref(inner))));
assert!(!same(&mut doc, K::Combo, &["日"], Some(O::Ref(inner))));
// Combo box: one value.
assert!(same(&mut doc, K::Combo, &["Two"], Some(s("Two"))));
assert!(!same(&mut doc, K::Combo, &["Two"], Some(s("Three"))));
assert!(!same(&mut doc, K::Combo, &[], Some(s("Two"))));
assert!(!same(&mut doc, K::Combo, &["Two"], None));
// List box: every pick, in order, by count; non-strings in the array are skipped.
let picks = |v: &[&str]| O::Array(v.iter().map(|t| s(t)).collect());
assert!(same(&mut doc, K::List, &["a", "b"], Some(picks(&["a", "b"]))));
assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["b", "a"]))));
assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["a"]))));
assert!(!same(&mut doc, K::List, &["a"], Some(picks(&["a", "b"]))));
assert!(!same(&mut doc, K::List, &["a", "b"], Some(picks(&["a", "c"]))));
assert!(same(&mut doc, K::List, &[], Some(picks(&[]))));
let item = doc.add(s("b"));
assert!(same(&mut doc, K::List, &["a", "b"], Some(O::Array(vec![s("a"), O::Int(3), O::Ref(item)]))));
// Check box: a state name, Off for none.
assert!(same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("Yes"))));
assert!(!same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("Off"))));
assert!(same(&mut doc, K::CheckBox, &[], Some(O::name("Off"))));
assert!(!same(&mut doc, K::CheckBox, &[], Some(O::name("Yes"))));
assert!(!same(&mut doc, K::CheckBox, &["Yes"], Some(O::name("On"))));
}
#[test]
fn unchanged_values_return_before_the_template_is_read() {
use pdfcraft_cos::{Object as O, PdfString};
use pdfcraft_forms::FieldKind as K;
// Laid out, but with no XFA packets: reading the template fails.
let mut doc = pdfcraft_cos::Document::new_empty();
let mut acro = pdfcraft_cos::Dict::new();
acro.set(pdfcraft_xfa::LAYOUT_KEY.to_vec(), pdfcraft_cos::Dict::new());
let acro = doc.add(acro);
let root = doc.root().unwrap_or_else(|| panic!("no catalog"));
let mut catalog = doc.get(root).as_dict().cloned().unwrap_or_default();
catalog.set(b"AcroForm".to_vec(), O::Ref(acro));
doc.set(root, catalog);
assert!(pdfcraft_xfa::existing_layout(&doc).is_some());
let text = field_dict(&mut doc, Some(O::String(PdfString::text("same"))));
let list = field_dict(&mut doc, Some(O::Array(vec![O::String(PdfString::text("a")), O::String(PdfString::text("b"))])));
let check = field_dict(&mut doc, Some(O::name("Yes")));
let before = [field(text, K::Text, &["same"]), field(list, K::List, &["a", "b"]), field(check, K::CheckBox, &["Yes"])];
assert_eq!(relayout_changed_text(&mut doc, None, &before), Ok(None));
// A changed pick reaches the template, which this document doesn't have.
let before = [field(text, K::Text, &["same"]), field(list, K::List, &["b", "a"]), field(check, K::CheckBox, &["Yes"])];
assert!(relayout_changed_text(&mut doc, None, &before).is_err());
}
}
+453
View File
@@ -0,0 +1,453 @@
//! Form date fields name months in the app's date language (#5303). Its own test binary: the
//! names are process-wide, so other tests must not see them change, and its tests take turns.
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use pdfcraft_cos::{Document, Object};
use pdfcraft_engine::dates::use_form_date_language;
use pdfcraft_engine::js::JsRunner;
use pdfcraft_forms::FieldValue;
use pdfcraft_forms::af::{DateTime, Format, format_date, format_value, keystroke, parse_date};
use pdfcraft_js::{DocInfo, Event, Limits, run};
/// Held by each test while it changes the process-wide date names, which start as English (a
/// failed test may have left others).
fn names_lock() -> MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
let lock = LOCK.lock().unwrap_or_else(PoisonError::into_inner);
use_form_date_language("en");
lock
}
/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads.
fn printd(fmt: &str, typed: &str) -> String {
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));");
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
assert_eq!(o.error, None);
o.value
}
#[test]
fn form_dates_follow_the_date_language() {
let _names = names_lock();
let oct18 = DateTime { y: 2026, m: 10, d: 18, hh: 0, mm: 0, ss: 0 };
let dd_mmm_yy = Format::Date("dd-mmm-yy".into());
// English until the app says otherwise.
assert_eq!(format_date(oct18, "dd-mmm-yy dddd ddd mmmm"), "18-Oct-26 Sunday Sun October");
use_form_date_language("it");
// AFDate_FormatEx("dd-mmm-yy"), what the issue's form used.
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-ott-26");
assert_eq!(format_date(oct18, "dddd d mmmm yyyy, ddd"), "domenica 18 ottobre 2026, dom");
// What it writes reads back, and so does English typing.
assert_eq!(parse_date("18-ott-26", "dd-mmm-yy"), Some(oct18));
assert_eq!(parse_date("18 ottobre 2026", "dd mmmm yyyy"), Some(oct18));
assert_eq!(parse_date("18-Oct-26", "dd-mmm-yy"), Some(oct18));
assert!(keystroke(&dd_mmm_yy, "date", "18-ott-26").is_ok());
// util.printd writes Italian names; util.scand reads them.
assert_eq!(printd("dd-mmm-yy", "18-ott-26"), "18-ott-26|18-ott-26");
assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-ott-26|18-ott-26", "English typing reads too");
assert_eq!(printd("dddd d mmmm yyyy", "domenica 18 ottobre 2026"), "domenica 18 ottobre 2026|domenica 18 ottobre 2026");
// German short names end in a dot; they are written and read whole.
use_form_date_language("de");
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Okt.-26");
assert_eq!(parse_date("18-Okt.-26", "dd-mmm-yy"), Some(oct18));
// An unknown language is English again.
use_form_date_language("xx");
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Oct-26");
assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-Oct-26|18-Oct-26");
every_date_language_reads_back_what_it_writes();
// Hostile typing never panics and stays quick: long, odd and mixed-script text.
use_form_date_language("th");
let junk = ["", " ", "ม.", "ม.ค.ม.ค.ม.ค.", "\u{0}", "1月1月1月", "tháng tháng", "Ottobre", "🙂ott🙂", "İstanbul ott 26"];
for t in junk.iter().map(|s| s.to_string()).chain(["ม.ค.1".repeat(5000), "9".repeat(100_000)]) {
let started = std::time::Instant::now();
let _ = parse_date(&t, "dd-mmm-yy");
let _ = keystroke(&dd_mmm_yy, "date", &t);
assert!(started.elapsed() < std::time::Duration::from_secs(2), "{} chars took {:?}", t.len(), started.elapsed());
}
use_form_date_language("en");
}
/// An English weekday typed before the month fills the format's weekday, so a month name that
/// the date language shares with a weekday (Italian, Spanish, Romanian and Albanian "mar") is
/// still read as the month: "Tue 17 Mar 2026" read as "Tue 17 Tuesday 2026" (#5303).
#[test]
fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
let _names = names_lock();
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 };
let mut bad = Vec::new();
for language in ["it", "es", "ro", "sq"] {
use_form_date_language(language);
let ddd_d_mmm = "ddd d mmm yyyy";
let typed = [
// What the language writes, then English, then English and localized mixed.
(ddd_d_mmm, format_date(mar17, ddd_d_mmm)),
(ddd_d_mmm, "Tue 17 Mar 2026".into()),
(ddd_d_mmm, "Tuesday 17 Mar 2026".into()),
(ddd_d_mmm, "tue 17 mar 2026".into()),
(ddd_d_mmm, format!("Tue 17 {}", format_date(mar17, "mmm yyyy"))),
(ddd_d_mmm, format!("{} 17 Mar 2026", format_date(mar17, "ddd"))),
("mmm d yyyy ddd", "Mar 17 2026 Tue".into()),
("dddd d mmmm yyyy", "Tuesday 17 March 2026".into()),
];
for (fmt, text) in typed {
if parse_date(&text, fmt) != Some(mar17) {
bad.push(format!("{language} forms {fmt}: {text:?} -> {:?}", parse_date(&text, fmt)));
}
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
if o.value != "2026-03-17" {
bad.push(format!("{language} scand {fmt}: {text:?} -> {:?} {:?}", o.value, o.error));
}
}
}
use_form_date_language("en");
assert!(bad.is_empty(), "{}", bad.join("; "));
}
/// Every date language: what a date field writes reads back as the same date, for each date
/// letter that writes a name, through the form path and through `util.scand`. Called from the
/// one test, which owns the process-wide names.
fn every_date_language_reads_back_what_it_writes() {
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let mut bad = Vec::new();
for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() {
use_form_date_language(l.code);
for m in 1..=12u32 {
let dt = DateTime { y: 2026, m, d: 18, hh: 0, mm: 0, ss: 0 };
for fmt in ["dd-mmm-yy", "d mmmm yyyy", "dddd, mmmm d, yyyy", "ddd d mmm yyyy", "mmm d, yyyy ddd", "yyyymmmdd"] {
let text = format_date(dt, fmt);
// A short month ending in a number ("Thg 1") glued to the day is ambiguous
// ("Thg 118": January 18 or November 8): nothing can read it back.
if fmt == "yyyymmmdd" && l.months_short.iter().any(|n| n.ends_with(|c: char| c.is_ascii_digit())) {
continue;
}
if parse_date(&text, fmt).map(|p| (p.y, p.m, p.d)) != Some((2026, m, 18)) {
bad.push(format!("{} forms {fmt}: {text:?}", l.code));
}
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
if o.value != format!("2026-{m:02}-18") {
bad.push(format!("{} scand {fmt}: {text:?} -> {:?} {:?}", l.code, o.value, o.error));
}
}
}
}
use_form_date_language("en");
assert!(bad.is_empty(), "{} failures:\n{}", bad.len(), bad.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
}
/// A form whose text fields are each `(name, font, format script)`: `/Helv` is Helvetica with
/// WinAnsi, `/Song` a Chinese font with a Unicode CMap, which also holds Cyrillic.
fn form(fields: &[(&str, &str, &str)]) -> Document {
let n = fields.len();
let first = 6;
let refs = |from: usize| (from..from + n).map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ");
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", refs(first)),
format!("<< /Fields [{}] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Song {} 0 R >> >> >>", refs(first), first + n),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
];
for (i, (name, font, js)) in fields.iter().enumerate() {
let js = js.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
let y = 700 - 30 * i;
objs.push(format!(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/{font} 10 Tf 0 g) /Rect [50 {y} 300 {}] /P 3 0 R /AA << /F << /S /JavaScript /JS ({js}) >> >> >>",
y + 20
));
}
objs.push("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into());
let mut out = b"%PDF-1.7
"
.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
{o}
endobj
",
i + 1
)
.as_bytes(),
);
}
let xref = out.len();
out.extend_from_slice(
format!(
"xref
0 {}
0000000000 65535 f
",
objs.len() + 1
)
.as_bytes(),
);
for o in offsets {
out.extend_from_slice(
format!(
"{o:010} 00000 n
"
)
.as_bytes(),
);
}
out.extend_from_slice(
format!(
"trailer
<< /Size {} /Root 1 0 R >>
startxref
{xref}
%%EOF
",
objs.len() + 1
)
.as_bytes(),
);
Document::open(Arc::new(out)).expect("opens")
}
/// The content of field `name`'s normal appearance.
fn appearance(doc: &Document, name: &str) -> Vec<u8> {
let all = pdfcraft_forms::fields(doc);
let f = all.iter().find(|f| f.name == name).expect("field");
let widget = doc.get(f.widgets[0].obj);
let normal = widget.as_dict().and_then(|d| d.get(b"AP")).and_then(|a| doc.resolve(a).as_dict().and_then(|a| a.reference(b"N"))).expect("/AP /N");
match &*doc.get(normal) {
Object::Stream(s) => s.decoded().expect("decodes"),
other => panic!("not a stream: {other:?}"),
}
}
/// What a Unicode-CMap (UCS-2) font is given for `text`.
fn ucs2(text: &str) -> Vec<u8> {
text.encode_utf16().flat_map(u16::to_be_bytes).collect()
}
fn contains(hay: &[u8], needle: &[u8]) -> bool {
hay.windows(needle.len()).any(|w| w == needle)
}
/// Fills `form`'s field `name` with 18 October 2026 through the JavaScript runner.
fn fill(doc: &mut Document, name: &str) {
fill_with(doc, name, "18-10-26");
}
/// Fills `form`'s field `name` with `value` through the JavaScript runner.
fn fill_with(doc: &mut Document, name: &str, value: &str) {
let mut js = JsRunner::new(doc, "form.pdf");
pdfcraft_forms::set_value_with(doc, name, &FieldValue::Text(value.into()), &mut js).expect("fills");
}
/// Month names in the date language that no font of the field can draw are drawn in English,
/// never as `?` ("18-????.-26" in Russian); a font that holds them keeps them (#5303).
#[test]
fn date_names_no_field_font_can_draw_are_drawn_in_english() {
let _names = names_lock();
let date = "AFDate_FormatEx(\"dd-mmm-yy\");";
let fields = [("helv", "Helv", date), ("song", "Song", date)];
for (language, local) in [("ru", "окт."), ("cs", "říj"), ("ja", "10月"), ("el", "Οκτ")] {
use_form_date_language(language);
let mut doc = form(&fields);
fill(&mut doc, "helv");
fill(&mut doc, "song");
let helv = appearance(&doc, "helv");
assert!(contains(&helv, b"(18-Oct-26) Tj"), "{language}: {}", String::from_utf8_lossy(&helv));
assert!(!helv.contains(&b'?'), "{language}: {}", String::from_utf8_lossy(&helv));
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty(), "{language}");
let song = appearance(&doc, "song");
assert!(contains(&song, &ucs2(&format!("18-{local}-26"))), "{language} keeps its name: {song:?}");
}
// Names Helvetica holds are kept: Italian "ott".
use_form_date_language("it");
let mut doc = form(&fields);
fill(&mut doc, "helv");
assert!(contains(&appearance(&doc, "helv"), b"(18-ott-26) Tj"));
use_form_date_language("en");
}
/// A Format script's `util.printd` is run again with English names for a field whose fonts
/// can't draw the date language's, and kept where they can (#5303).
#[test]
fn format_scripts_print_dates_in_english_where_the_field_cannot_draw_them() {
let _names = names_lock();
let script = "event.value = event.value ? util.printd(\"dd-mmm-yy\", util.scand(\"dd-mm-yy\", event.value)) + \" !\" : \"\";";
let fields = [("helv", "Helv", script), ("song", "Song", script)];
use_form_date_language("ru");
let mut doc = form(&fields);
fill(&mut doc, "helv");
fill(&mut doc, "song");
let helv = appearance(&doc, "helv");
assert!(contains(&helv, b"(18-Oct-26 !) Tj"), "{}", String::from_utf8_lossy(&helv));
assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv));
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty());
assert!(contains(&appearance(&doc, "song"), &ucs2("18-окт.-26 !")));
// A script whose text has no English either keeps it, reported as undrawn.
let greeting = [("hi", "Helv", "event.value = \"Привет \" + util.printd(\"mmm\", new Date(2026, 9, 18));")];
let mut doc = form(&greeting);
fill(&mut doc, "hi");
assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("hi".to_string(), 'П')]);
// The English run is the appearance's alone: scripts go on printing the date language.
assert_eq!(printd("dd-mmm-yy", "18-окт.-26"), "18-окт.-26|18-окт.-26");
use_form_date_language("en");
}
/// A Format script's English run is kept when it ran, even where it prints the stored value
/// again: a Helvetica field storing "18-Oct-26", printed again as `dd-mmm-yy`, showed
/// "18-????.-26" in Russian. A script that fails keeps what it showed, reported undrawn (#5303).
#[test]
fn an_english_run_that_prints_the_stored_value_is_drawn() {
let _names = names_lock();
let script = "event.value = util.printd(\"dd-mmm-yy\", util.scand(\"dd-mmm-yy\", event.value));";
use_form_date_language("ru");
let mut doc = form(&[("helv", "Helv", script), ("fails", "Helv", "throw new Error(\"no\");")]);
fill_with(&mut doc, "helv", "18-Oct-26");
let helv = appearance(&doc, "helv");
assert!(contains(&helv, b"(18-Oct-26) Tj"), "{}", String::from_utf8_lossy(&helv));
assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv));
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty());
fill_with(&mut doc, "fails", "18-окт.-26");
assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("fails".to_string(), 'о')]);
use_form_date_language("en");
}
/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`.
fn scanned(fmt: &str, text: &str) -> String {
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
assert_eq!(o.error, None);
o.value
}
/// A short weekday typed without its dot ("mar" for French "mar.", Tuesday) is the weekday where
/// the format has one, not the English "Mar" (March) that is the only other name of its length:
/// "mar 6 janv 2026" read as no date in French and Romanian (#5303).
#[test]
fn a_short_weekday_typed_without_its_dot_is_the_weekday() {
let _names = names_lock();
let mut bad = Vec::new();
let cases = [
("fr", "ddd d mmm yyyy", "Mar 6 janv 2026", "2026-01-06"),
("fr", "ddd d mmm yyyy", "mar 17 mars 2026", "2026-03-17"),
("fr", "mmm d yyyy ddd", "janv 6 2026 mar", "2026-01-06"),
("fr", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"),
("ro", "ddd d mmm yyyy", "mar 6 ian 2026", "2026-01-06"),
("ro", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"),
];
for (language, fmt, text, want) in cases {
use_form_date_language(language);
let got = scanned(fmt, text);
if got != want {
bad.push(format!("{language} scand {fmt}: {text:?} -> {got}"));
}
let read = parse_date(text, fmt).map(|d| format!("{:04}-{:02}-{:02}", d.y, d.m, d.d));
if read.as_deref() != Some(want) {
bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}"));
}
}
use_form_date_language("en");
assert!(bad.is_empty(), "{}", bad.join("; "));
}
/// A weekday left out of a format with one ("17 mar 2026" for "ddd d mmm yyyy") lets a name the
/// date language shares between a weekday and a month be the month: Italian, Spanish and
/// Albanian "mar" (Tuesday, March) and Albanian "sht" (Saturday, September) were read as the
/// weekday, leaving no month (#5303). Typed weekdays still go first.
#[test]
fn a_shared_name_is_the_month_when_the_weekday_is_left_out() {
let _names = names_lock();
let mut bad = Vec::new();
let cases = [
("it", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
("es", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
("sq", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
("it", "ddd d mmm yyyy", "17 mar 2026", (2026, 3, 17)),
("it", "dddd, mmmm d, yyyy", "mar 17, 2026", (2026, 3, 17)),
("it", "dddd, mmm d, yyyy", "mar 17 2026", (2026, 3, 17)),
("sq", "ddd d mmm yyyy", "5 sht 2026", (2026, 9, 5)),
("it", "ddd d mmm yyyy", "mar 17 Mar 2026", (2026, 3, 17)),
("it", "ddd d mmm yyyy", "mar 17 mar 2026", (2026, 3, 17)),
];
for (language, fmt, text, want) in cases {
use_form_date_language(language);
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
if read != Some(want) {
bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}"));
}
if keystroke(&Format::Date(fmt.into()), "date", text).is_err() {
bad.push(format!("{language} keystroke {fmt}: {text:?} refused"));
}
}
use_form_date_language("en");
assert!(bad.is_empty(), "{}", bad.join("; "));
}
/// Turkish names typed in capitals are read: lower-casing letter by letter made "MAYIS" "mayis"
/// (not "mayıs"), "SALI" "sali" and "EKİM" "eki̇m" (an i with a dot above), so no name matched.
/// Dotted, dotless and capital i are one letter when names are read (#5303).
#[test]
fn turkish_names_typed_in_capitals_are_read() {
let _names = names_lock();
use_form_date_language("tr");
let mut bad = Vec::new();
for (fmt, text, want) in [
("d mmmm yyyy", "1 MAYIS 2026", (2026, 5, 1)),
("d mmmm yyyy", "1 EKİM 2026", (2026, 10, 1)),
("d mmmm yyyy", "1 Ekim 2026", (2026, 10, 1)),
("d mmm yyyy", "1 EKİ 2026", (2026, 10, 1)),
("dddd d mmmm yyyy", "SALI 6 OCAK 2026", (2026, 1, 6)),
("dddd d mmmm yyyy", "Salı 6 Ocak 2026", (2026, 1, 6)),
] {
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
if read != Some(want) {
bad.push(format!("forms {fmt}: {text:?} -> {read:?}"));
}
let (y, m, d) = want;
let got = scanned(fmt, text);
if got != format!("{y:04}-{m:02}-{d:02}") {
bad.push(format!("scand {fmt}: {text:?} -> {got}"));
}
}
// English is read as before, capitals and all.
use_form_date_language("en");
for (fmt, text) in [("d mmmm yyyy", "1 APRIL 2026"), ("dddd d mmmm yyyy", "FRIDAY 1 MAY 2026"), ("d mmm yyyy", "1 MAY 2026")] {
if parse_date(text, fmt).is_none() || scanned(fmt, text) == "null" {
bad.push(format!("en {fmt}: {text:?}"));
}
}
assert!(bad.is_empty(), "{}", bad.join("; "));
}
/// #5464 review: Greek names typed in capitals end in a capital Σ, which is the final ς of the
/// name ("ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος"); they read as they did before names were folded.
#[test]
fn greek_names_typed_in_capitals_are_read() {
let _names = names_lock();
use_form_date_language("el");
let mut bad = Vec::new();
for (fmt, text, want) in [
("d mmmm yyyy", "1 ΙΑΝΟΥΆΡΙΟΣ 2026", (2026, 1, 1)),
("d mmmm yyyy", "1 Ιανουάριος 2026", (2026, 1, 1)),
("d mmmm yyyy", "1 ιανουάριοσ 2026", (2026, 1, 1)),
("mmmm d, yyyy", "ΜΆΙΟΣ 5, 2026", (2026, 5, 5)),
("dddd, mmmm d, yyyy", "FRIDAY, ΙΑΝΟΥΆΡΙΟΣ 2, 2026", (2026, 1, 2)),
] {
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
if read != Some(want) {
bad.push(format!("forms {fmt}: {text:?} -> {read:?}"));
}
let (y, m, d) = want;
let got = scanned(fmt, text);
if got != format!("{y:04}-{m:02}-{d:02}") {
bad.push(format!("scand {fmt}: {text:?} -> {got}"));
}
}
assert!(bad.is_empty(), "{}", bad.join("; "));
}
+316 -3
View File
@@ -12,7 +12,14 @@ const WORD: [u32; 4] = [40, 40, 100, 60];
/// A 200 × 100 pt "scan" on tinted paper, with OCR text over its word.
fn scanned() -> (Session, pdfcraft_engine::DocId) {
scanned_with(vec![PlacedWord { text: "John".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0] }])
scanned_with(vec![PlacedWord {
text: "John".into(),
origin: [40.0, 40.0],
across: [60.0, 0.0],
up: [0.0, 20.0],
confidence: None,
source: "ocrs".into(),
}])
}
fn scanned_with(words: Vec<PlacedWord>) -> (Session, pdfcraft_engine::DocId) {
@@ -86,11 +93,18 @@ fn an_explicit_colour_is_kept_for_the_new_text() {
/// descender line when it has descenders, up to the cap height.
fn scanned_word(text: &str, x0: f64, x1: f64, baseline: f64, size: f64) -> PlacedWord {
let low = if text.chars().any(|c| "gjpqy".contains(c)) { 0.21 * size } else { 0.0 };
PlacedWord { text: text.into(), origin: [x0, baseline - low], across: [x1 - x0, 0.0], up: [0.0, 0.718 * size + low] }
PlacedWord {
text: text.into(),
origin: [x0, baseline - low],
across: [x1 - x0, 0.0],
up: [0.0, 0.718 * size + low],
confidence: None,
source: "ocrs".into(),
}
}
fn word(text: &str, x0: f64, x1: f64, baseline: f64) -> PlacedWord {
PlacedWord { text: text.into(), origin: [x0, baseline], across: [x1 - x0, 0.0], up: [0.0, 10.0] }
PlacedWord { text: text.into(), origin: [x0, baseline], across: [x1 - x0, 0.0], up: [0.0, 10.0], confidence: None, source: "ocrs".into() }
}
/// OCR words keep the scan's layout: a phrase is one paragraph, a value in the next table cell and
@@ -146,6 +160,116 @@ fn a_rewritten_ocr_phrase_keeps_its_word_widths() {
}
}
/// Recognize text sizes each word from its box and a guess at how much of the em its letters fill,
/// so one phrase's words come out several points apart (#3313). Here the first word is the
/// largest and the middle one the smallest; the phrase is set in 11 pt.
fn uneven_phrase() -> (Session, pdfcraft_engine::DocId) {
scanned_with(vec![
scanned_word("John", 20.0, 45.0, 70.0, 15.0),
scanned_word("Smith", 49.0, 78.0, 70.0, 9.0),
scanned_word("Example", 82.0, 124.0, 70.0, 11.0),
])
}
/// An edited OCR phrase is redrawn in one size, the median of its words', and each word keeps
/// the width the scan shows it at (#3313).
#[test]
fn an_edited_ocr_phrase_is_drawn_at_one_size() {
let (mut s, id) = uneven_phrase();
let block = s.get(id).unwrap().text_blocks(0).remove(0);
assert!((block.size - 11.0).abs() < 0.05, "the phrase's size is its words' median: {}", block.size);
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Example".into(), style: Default::default() }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert_eq!(lines.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["John", "Example"]);
for line in &lines {
assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size);
}
// Each word keeps its width; the rest of the phrase closes up where Smith was.
assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
assert!((lines[1].rect[2] - lines[1].rect[0] - 42.0).abs() < 0.5, "Example keeps its width: {:?}", lines[1].rect);
assert!(lines[1].rect[0] >= lines[0].rect[2] - 0.5, "Example runs into John");
}
/// Only invisible OCR text is set in one phrase size: visible text keeps each run's own size, and
/// a phrase whose size is NaN, zero, negative or infinite falls back to its words' sizes (#3313).
#[test]
fn only_an_ocr_phrase_with_a_usable_size_is_set_in_one_size() {
let (s, id) = uneven_phrase();
let block = s.get(id).unwrap().text_blocks(0).remove(0);
assert!(block.invisible);
assert_eq!(block.phrase_size(), Some(block.size));
let visible = pdfcraft_engine::TextBlock { invisible: false, ..block.clone() };
assert_eq!(visible.phrase_size(), None, "visible text keeps its runs' sizes");
for size in [f64::NAN, 0.0, -3.0, f64::INFINITY] {
assert_eq!(pdfcraft_engine::TextBlock { size, ..block.clone() }.phrase_size(), None, "a {size} pt phrase falls back to its words");
}
}
/// Choosing a font sends the phrase through the single-style rewrite, which takes its size from
/// the first run: that is the phrase's size too, not the first word's guess.
#[test]
fn a_new_font_for_an_ocr_phrase_keeps_its_one_size() {
let (mut s, id) = uneven_phrase();
let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Times, false, false)), ..Default::default() };
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Example".into(), style }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert!(!lines.is_empty());
for line in &lines {
assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size);
}
}
/// Two neighbouring words OCR sized alike join into one run; the median still counts each word,
/// so 15, 9 and 9 pt make a 9 pt phrase, not the 12 pt of its two runs.
#[test]
fn words_joined_into_one_run_each_count_toward_the_phrase_size() {
let (s, id) = scanned_with(vec![
scanned_word("John", 20.0, 45.0, 70.0, 15.0),
scanned_word("SMITH", 49.0, 78.0, 70.0, 9.0),
scanned_word("JONES", 82.0, 112.0, 70.0, 9.0),
]);
let doc = s.get(id).unwrap();
assert_eq!(doc.text_lines(0).len(), 2, "the two 9 pt words share a run");
let size = doc.text_blocks(0)[0].size;
assert!((size - 9.0).abs() < 0.05, "the phrase is set in its words' median, 9 pt: {size}");
}
/// A word OCR sized at more than twice the phrase's size still keeps the width it has on the scan.
#[test]
fn a_word_far_larger_than_its_phrase_keeps_its_width() {
let (mut s, id) = scanned_with(vec![
scanned_word("John", 20.0, 45.0, 70.0, 25.0),
scanned_word("Smith", 49.0, 78.0, 70.0, 10.0),
scanned_word("Example", 82.0, 124.0, 70.0, 10.5),
]);
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Exam".into(), style: Default::default() }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert_eq!(lines[0].text.trim(), "John");
assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
}
/// With a new font the phrase is one run in its first word's text state. How wide it is must not
/// depend on how far OCR misjudged that word's size: the same boxes give the same line.
#[test]
fn a_new_font_for_an_ocr_phrase_is_as_wide_whatever_its_first_word_was_sized() {
let width = |first: f64| {
let (mut s, id) = scanned_with(vec![
scanned_word("John", 20.0, 45.0, 70.0, first),
scanned_word("Smith", 49.0, 78.0, 70.0, 11.0),
scanned_word("Example", 82.0, 124.0, 70.0, 11.0),
]);
let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Helvetica, false, false)), ..Default::default() };
// Short enough not to wrap in the phrase's box either way.
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith".into(), style }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert_eq!(lines.len(), 1, "one line: {lines:?}");
lines.iter().map(|l| l.rect[2]).fold(f64::MIN, f64::max) - lines.iter().map(|l| l.rect[0]).fold(f64::MAX, f64::min)
};
let (guessed, right) = (width(15.0), width(11.0));
assert!((guessed - right).abs() < 0.5, "a 15 pt guess for John makes the line {guessed} wide, an 11 pt one {right}");
}
/// Visible text set with a horizontally stretched text matrix (3 × 1), as some generators write.
fn stretched_visible() -> std::sync::Arc<Vec<u8>> {
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
@@ -207,3 +331,192 @@ fn visible_stretched_text_rewrites_unchanged() {
let i = ((y * out.width + x) * 4) as usize;
assert_eq!(&out.rgba[i..i + 3], &[255, 255, 255]);
}
/// A scan whose OCR layer is drawn first and the opaque page image over it, as some OCR tools
/// write it (#3973): invisible Helvetica `text` on the scanned word, then the image.
fn ocr_under_scan(text: &str) -> std::sync::Arc<Vec<u8>> {
under_scan(&format!("BT /F1 20 Tf 3 Tr 42 44 Td ({text}) Tj ET"))
}
/// A page whose `text` content is followed by the opaque 200 × 100 scan image.
fn under_scan(text: &str) -> std::sync::Arc<Vec<u8>> {
under_scan_with(text, |_| pdfcraft_cos::Dict::new())
}
/// As [`under_scan`], with the page's ExtGState resources made by `ext_gstates`.
fn under_scan_with(text: &str, ext_gstates: impl FnOnce(&mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict) -> std::sync::Arc<Vec<u8>> {
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
let (w, h) = (200u32, 100u32);
let mut rgb = Vec::new();
for y in 0..h {
for x in 0..w {
let ink = (WORD[0]..WORD[2]).contains(&x) && (WORD[1]..WORD[3]).contains(&y);
rgb.extend(if ink { INK } else { PAPER });
}
}
let mut d = Document::new_empty();
let pages = d.add(Object::Dict(Dict::new()));
let content = format!("{text} q 200 0 0 100 0 0 cm /Im Do Q");
let contents = d.add(Object::Stream(Stream::flate(Dict::new(), content.as_bytes())));
let mut image = Dict::new();
image.set(b"Type".to_vec(), Object::name("XObject"));
image.set(b"Subtype".to_vec(), Object::name("Image"));
image.set(b"Width".to_vec(), Object::Int(i64::from(w)));
image.set(b"Height".to_vec(), Object::Int(i64::from(h)));
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
let image = d.add(Object::Stream(Stream::flate(image, &rgb)));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Dict(font));
let mut xobjects = Dict::new();
xobjects.set(b"Im".to_vec(), Object::Ref(image));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
res.set(b"ExtGState".to_vec(), Object::Dict(ext_gstates(&mut d)));
let mut p = Dict::new();
p.set(b"Type".to_vec(), Object::name("Page"));
p.set(b"Parent".to_vec(), Object::Ref(pages));
p.set(b"Contents".to_vec(), Object::Ref(contents));
p.set(b"Resources".to_vec(), Object::Dict(res));
p.set(b"MediaBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec()));
let page = d.add(Object::Dict(p));
d.update_dict(pages, |p| {
p.set(b"Type".to_vec(), Object::name("Pages"));
p.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)]));
p.set(b"Count".to_vec(), Object::Int(1));
})
.unwrap();
let mut root = Dict::new();
root.set(b"Type".to_vec(), Object::name("Catalog"));
root.set(b"Pages".to_vec(), Object::Ref(pages));
let root = d.add(Object::Dict(root));
d.trailer_mut().set(b"Root".to_vec(), Object::Ref(root));
std::sync::Arc::new(pdfcraft_cos::write_full(&d, &SaveOptions::default()).unwrap())
}
/// The scanned word's box shows the cover and new ink, not the scan.
fn assert_edit_shows(s: &Session, id: pdfcraft_engine::DocId, label: &str) {
let px = word_pixels(s, id);
let paper = px.iter().filter(|p| near(**p, PAPER)).count();
let ink = px.iter().filter(|p| near(**p, INK)).count();
assert!(paper * 10 > px.len() * 4, "{label}: the cover is drawn over the scan: {paper} of {} paper", px.len());
assert!(ink * 100 > px.len() * 3, "{label}: the new text shows: {ink} of {} ink", px.len());
}
fn texts(s: &Session, id: pdfcraft_engine::DocId) -> Vec<String> {
s.get(id).unwrap().text_blocks(0).iter().map(|b| b.text.replace('\n', " ")).collect()
}
fn edit_block(s: &mut Session, id: pdfcraft_engine::DocId, block: usize, text: &str) {
s.apply(id, Edit::EditTextBlock { page: 0, block, text: text.into(), style: Default::default() }).unwrap();
}
/// The OCR layer is under the scan image: the rewritten paragraph is drawn on top of the image,
/// not in the OCR text's place where the image would hide it (#3973). One line takes the
/// single-style rewrite, a typed line break the styled one. Undo restores the hidden layer, and
/// the edit survives redo, a save and reopen, and being edited again.
#[test]
fn an_ocr_layer_under_the_scan_image_shows_its_edit() {
for edit in ["Jane", "Jane\nDoe"] {
let mut s = Session::new();
let id = s.open("ocr-under-scan.pdf", None, ocr_under_scan("John"), None).unwrap();
let blocks = s.get(id).unwrap().text_blocks(0);
assert_eq!(blocks.len(), 1);
assert!(blocks[0].invisible, "the OCR layer is invisible text");
assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "the scanned word fills its box");
edit_block(&mut s, id, 0, edit);
assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}");
assert_edit_shows(&s, id, edit);
s.undo(id).unwrap();
assert_eq!(texts(&s, id), ["John"], "{edit:?}: undo");
assert!(s.get(id).unwrap().text_blocks(0)[0].invisible, "{edit:?}: undo hides the layer again");
assert!(word_pixels(&s, id).iter().all(|p| near(*p, INK)), "{edit:?}: undo shows the scan again");
s.redo(id).unwrap();
assert_edit_shows(&s, id, &format!("{edit:?} redo"));
let saved = s.save_bytes(id).unwrap();
let mut reopened = Session::new();
let id = reopened.open("saved.pdf", None, saved, None).unwrap();
assert_eq!(texts(&reopened, id), [edit.replace('\n', " ")], "{edit:?}: reopened");
assert_edit_shows(&reopened, id, &format!("{edit:?} reopened"));
edit_block(&mut reopened, id, 0, "Ann");
assert_eq!(texts(&reopened, id), ["Ann"], "{edit:?}: edited again");
assert_edit_shows(&reopened, id, &format!("{edit:?} edited again"));
}
}
/// Visible text earlier in the same text object makes the rewrite split it; the OCR phrase still
/// goes on top of the scan, and the visible text stays.
#[test]
fn an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit() {
for edit in ["Jane", "Jane\nDoe"] {
let mut s = Session::new();
let page = under_scan("BT /F1 20 Tf 10 80 Td (Head) Tj 3 Tr 32 -36 Td (John) Tj ET");
let id = s.open("split.pdf", None, page, None).unwrap();
let blocks = s.get(id).unwrap().text_blocks(0);
let john = blocks.iter().position(|b| b.text == "John").unwrap();
assert!(blocks[john].invisible && blocks.iter().any(|b| b.text == "Head" && !b.invisible), "{blocks:?}");
edit_block(&mut s, id, john, edit);
let mut after = texts(&s, id);
after.sort();
let mut want = vec!["Head".to_string(), edit.replace('\n', " ")];
want.sort();
assert_eq!(after, want, "{edit:?}");
assert_edit_shows(&s, id, &format!("{edit:?} split"));
}
}
/// `/SM`: a luminosity soft mask that is white over 30..110 × 30..70 in the space it is selected
/// in, black elsewhere.
fn soft_mask_state(d: &mut pdfcraft_cos::Document) -> pdfcraft_cos::Dict {
use pdfcraft_cos::{Dict, Object, Stream};
let mut group = Dict::new();
group.set(b"S".to_vec(), Object::name("Transparency"));
group.set(b"CS".to_vec(), Object::name("DeviceRGB"));
let mut form = Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
form.set(b"BBox".to_vec(), Object::Array([0, 0, 200, 100].map(Object::Int).to_vec()));
form.set(b"Group".to_vec(), Object::Dict(group));
let mut mask = Dict::new();
mask.set(b"Type".to_vec(), Object::name("Mask"));
mask.set(b"S".to_vec(), Object::name("Luminosity"));
mask.set(b"G".to_vec(), Object::Ref(d.add(Object::Stream(Stream::flate(form, b"1 g 30 30 80 40 re f")))));
let mut state = Dict::new();
state.set(b"Type".to_vec(), Object::name("ExtGState"));
state.set(b"SMask".to_vec(), Object::Dict(mask));
let mut states = Dict::new();
states.set(b"SM".to_vec(), Object::Dict(state));
states
}
/// The soft mask is selected before a translation, so it is placed by the CTM before it. The
/// paragraph lifted over the scan selects it again after its own `cm`: there it must be placed
/// as it was, not 100 pt to the right where it would hide the new text, so the edit renders
/// exactly as the same edit on the page without the mask. Both rewrite paths.
#[test]
fn a_soft_mask_selected_before_a_cm_stays_put_when_the_edit_is_lifted() {
for edit in ["Jane", "Jane\nDoe"] {
let mut plain = Session::new();
let page = under_scan("q 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q");
let plain_id = plain.open("plain.pdf", None, page, None).unwrap();
edit_block(&mut plain, plain_id, 0, edit);
let mut s = Session::new();
let page = under_scan_with("q /SM gs 1 0 0 1 100 0 cm BT /F1 20 Tf 3 Tr -58 44 Td (John) Tj ET Q", soft_mask_state);
let id = s.open("masked.pdf", None, page, None).unwrap();
assert_eq!(texts(&s, id), ["John"]);
edit_block(&mut s, id, 0, edit);
assert_eq!(texts(&s, id), [edit.replace('\n', " ")], "{edit:?}");
assert_edit_shows(&s, id, &format!("{edit:?} masked"));
let ink = |s: &Session, id| word_pixels(s, id).iter().filter(|p| near(**p, INK)).count();
assert_eq!(ink(&s, id), ink(&plain, plain_id), "{edit:?}: the mask does not hide the new text");
}
}