mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
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:
commit
f996aef12f
654 files changed
+99220
-5792
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -26,29 +26,70 @@ pub struct JsOutput {
|
||||
pub doc_errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Console lines one output keeps, from every script an edit runs together (a form's
|
||||
/// calculations, say): each run's own cap ([`pdfcraft_js::xfa::MAX_CONSOLE`]) would otherwise
|
||||
/// be multiplied by the number of fields a document declares.
|
||||
pub const MAX_CONSOLE_LINES: usize = 10_000;
|
||||
/// Bytes of console text one output keeps, for the same reason.
|
||||
pub const MAX_CONSOLE_BYTES: usize = 1 << 20;
|
||||
/// Alerts one output keeps.
|
||||
pub const MAX_ALERTS: usize = 1_000;
|
||||
|
||||
impl JsOutput {
|
||||
/// Whether scripts produced nothing: no alerts, console lines, requests or errors.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.alerts.is_empty() && self.console.is_empty() && self.requests.is_empty() && self.errors.is_empty()
|
||||
}
|
||||
|
||||
/// Adds `lines` to the console within [`MAX_CONSOLE_LINES`] and [`MAX_CONSOLE_BYTES`]; what
|
||||
/// is past them is left out, which one closing line says.
|
||||
fn say(&mut self, lines: impl IntoIterator<Item = String>) {
|
||||
let note = format!("more console lines were left out: the scripts printed more than {MAX_CONSOLE_LINES} lines or {MAX_CONSOLE_BYTES} bytes");
|
||||
// Once the notice is written the console is closed: nothing may follow it, and a later
|
||||
// rejection must not add a second copy.
|
||||
if self.console.last() == Some(¬e) {
|
||||
return;
|
||||
}
|
||||
let mut bytes: usize = self.console.iter().map(String::len).sum();
|
||||
let mut dropped = false;
|
||||
for l in lines {
|
||||
if self.console.len() >= MAX_CONSOLE_LINES || bytes.saturating_add(l.len()) > MAX_CONSOLE_BYTES {
|
||||
dropped = true;
|
||||
break;
|
||||
}
|
||||
bytes += l.len();
|
||||
self.console.push(l);
|
||||
}
|
||||
if dropped && !self.console.iter().any(|l| l == ¬e) {
|
||||
self.console.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `alerts` within [`MAX_ALERTS`].
|
||||
fn alert(&mut self, alerts: impl IntoIterator<Item = Message>) {
|
||||
let room = MAX_ALERTS.saturating_sub(self.alerts.len());
|
||||
self.alerts.extend(alerts.into_iter().take(room));
|
||||
}
|
||||
|
||||
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
|
||||
/// except a request to show a side panel or open Save As, which is noted on the console
|
||||
/// and dropped.
|
||||
fn absorb(&mut self, o: &Outcome) {
|
||||
self.alerts.extend(o.alerts.iter().cloned());
|
||||
self.console.extend(o.console.iter().cloned());
|
||||
self.alert(o.alerts.iter().cloned());
|
||||
self.say(o.console.iter().cloned());
|
||||
// A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
|
||||
// button's may show or hide a side panel, or open Save As.
|
||||
// The warnings go through `say` like any other console line, so they count against the
|
||||
// line and byte caps instead of growing the console past them.
|
||||
let mut notes = Vec::new();
|
||||
for r in &o.requests {
|
||||
match r {
|
||||
Request::ShowPanel(_) => self.console.push("A field script asked to show a side panel; only a button may show a side panel".into()),
|
||||
Request::SaveAs => {
|
||||
self.console.push("A field script asked to save the document as a new file; only a button may open Save As".into())
|
||||
}
|
||||
Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()),
|
||||
Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()),
|
||||
r => self.requests.push(r.clone()),
|
||||
}
|
||||
}
|
||||
self.say(notes);
|
||||
self.errors.extend(o.error.iter().cloned());
|
||||
// The document scripts run before every field script, so a failing one would repeat
|
||||
// once per calculation; it is reported once.
|
||||
@@ -61,8 +102,8 @@ impl JsOutput {
|
||||
}
|
||||
|
||||
pub(crate) fn append(&mut self, other: JsOutput) {
|
||||
self.alerts.extend(other.alerts);
|
||||
self.console.extend(other.console);
|
||||
self.alert(other.alerts);
|
||||
self.say(other.console);
|
||||
self.requests.extend(other.requests);
|
||||
// As in `absorb`: a document script's error, already reported, is not repeated; a field
|
||||
// script's errors are each their own, even one worded like a document script's.
|
||||
@@ -118,6 +159,7 @@ fn field_state_with_display(f: &Field, display: i32) -> pdfcraft_js::FieldState
|
||||
s.readonly = f.read_only();
|
||||
s.required = f.has(pdfcraft_forms::flags::REQUIRED);
|
||||
s.display = display;
|
||||
s.rect = f.widgets.first().map(|w| w.rect);
|
||||
s.char_limit = f.max_len;
|
||||
s.options = match f.kind {
|
||||
FieldKind::CheckBox | FieldKind::Radio => f.widgets.iter().filter_map(|w| w.on_state.clone()).map(|o| (o.clone(), o)).collect(),
|
||||
@@ -147,8 +189,9 @@ fn changes(before: &[pdfcraft_js::FieldState], after: &[pdfcraft_js::FieldState]
|
||||
read_only: (a.readonly != b.readonly).then_some(a.readonly),
|
||||
required: (a.required != b.required).then_some(a.required),
|
||||
display: (a.display != b.display).then_some(a.display),
|
||||
rect: (a.rect != b.rect).then_some(a.rect).flatten(),
|
||||
};
|
||||
(c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some()).then_some(c)
|
||||
(c.value.is_some() || c.read_only.is_some() || c.required.is_some() || c.display.is_some() || c.rect.is_some()).then_some(c)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ©.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<b & 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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`]).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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("; "));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user