mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-15 15:22:54 +00:00
Merge remote-tracking branch 'origin/main' into feat/all-tools-expand-view
# Conflicts: # ATTRIBUTION.toml
This commit is contained in:
104 files changed
+4348
-428
No files matched your search
@@ -206,6 +206,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready),
|
||||
item("Crop pages", "crop", "page.crop", Ready),
|
||||
item("Duplicate pages", "copy-plus", "page.duplicate", Ready),
|
||||
item("Reverse pages", "arrow-up-down", "page.reverse", Ready),
|
||||
item("Page labels", "tag", "page.number", Ready),
|
||||
item("Page transitions", "presentation", "page.transitions", Planned("M4")),
|
||||
],
|
||||
|
||||
@@ -362,6 +362,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.insert_blank", "Insert blank page", PAGES, None, Assembly, "file-plus"),
|
||||
c("page.rotate_dialog", "Rotate pages…", PAGES, None, Assembly, "rotate-cw"),
|
||||
c("page.duplicate", "Duplicate pages", PAGES, None, Assembly, "copy-plus"),
|
||||
c("page.reverse", "Reverse pages", PAGES, None, Assembly, "arrow-up-down"),
|
||||
c("page.crop", "Crop pages", PAGES, None, Assembly, "crop"),
|
||||
c("page.boxes", "Set page boxes…", PAGES, None, Assembly, "square-dashed-mouse-pointer"),
|
||||
c("page.insert", "Insert pages from file…", PAGES, None, Assembly, "file-input"),
|
||||
|
||||
+18
-13
@@ -6,7 +6,7 @@
|
||||
//! Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript is [`Session::set_javascript`]; with it
|
||||
//! off, scripts don't run and AF calls keep working natively.
|
||||
|
||||
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, ScriptResult, Scripts};
|
||||
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
|
||||
pub use pdfcraft_js::{Limits, Outcome, Request};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Session};
|
||||
@@ -343,19 +343,24 @@ impl Session {
|
||||
let names = found.iter().map(|(_, c, _)| c.name.clone()).collect();
|
||||
let mut edits = Vec::with_capacity(found.len());
|
||||
for (page, c, turn) in found {
|
||||
// A turned field keeps its place: the rectangle is already the one on the page.
|
||||
let props = (turn != 0).then(|| pdfcraft_forms::FieldProps { rotation: Some((0, turn)), rect: Some((0, c.rect)), ..Default::default() });
|
||||
let tall = if turn % 180 == 0 { c.rect[3] - c.rect[1] } else { c.rect[2] - c.rect[0] };
|
||||
edits.push(Edit::AddField {
|
||||
page,
|
||||
rect: c.rect,
|
||||
kind: match c.kind {
|
||||
pdfcraft_forms::detect::Kind::Text => pdfcraft_forms::NewField::Text { multiline: tall > 30.0 },
|
||||
pdfcraft_forms::detect::Kind::CheckBox => pdfcraft_forms::NewField::CheckBox,
|
||||
},
|
||||
name: Some(c.name.clone()),
|
||||
});
|
||||
if let Some(props) = props {
|
||||
let (kind, comb) = match c.kind {
|
||||
pdfcraft_forms::detect::Kind::Text => (pdfcraft_forms::NewField::Text { multiline: tall > 30.0 }, None),
|
||||
pdfcraft_forms::detect::Kind::CheckBox => (pdfcraft_forms::NewField::CheckBox, None),
|
||||
pdfcraft_forms::detect::Kind::Comb { cells } => (pdfcraft_forms::NewField::Text { multiline: false }, Some(cells)),
|
||||
};
|
||||
let mut props = FieldProps::default();
|
||||
if turn != 0 {
|
||||
// A turned field keeps its place: the rectangle is already the one on the page.
|
||||
props.rotation = Some((0, turn));
|
||||
props.rect = Some((0, c.rect));
|
||||
}
|
||||
if let Some(cells) = comb {
|
||||
props.max_len = Some(Some(cells));
|
||||
props.flags = vec![(pdfcraft_forms::flags::COMB, true)];
|
||||
}
|
||||
edits.push(Edit::AddField { page, rect: c.rect, kind, name: Some(c.name.clone()) });
|
||||
if turn != 0 || comb.is_some() {
|
||||
edits.push(Edit::SetFieldProps { name: c.name, props: Box::new(props) });
|
||||
}
|
||||
}
|
||||
|
||||
+61
-19
@@ -36,7 +36,6 @@ pub mod signature_image;
|
||||
pub mod signature_scan;
|
||||
pub mod text_fonts;
|
||||
mod text_preview;
|
||||
mod upright_image;
|
||||
pub mod word_count;
|
||||
pub mod xfa;
|
||||
|
||||
@@ -306,6 +305,10 @@ struct Editor {
|
||||
saved: Arc<Vec<u8>>,
|
||||
/// Passwords that open [`Self::saved`].
|
||||
saved_keys: Keys,
|
||||
/// [`Self::saved`] was never written where the document belongs: a new document (combine,
|
||||
/// extract, create) or one restored from recovery. It stays unsaved until a save, whatever
|
||||
/// undo brings back, or closing it would lose the whole document without asking.
|
||||
unsaved_file: bool,
|
||||
undo: Vec<Snapshot>,
|
||||
redo: Vec<Snapshot>,
|
||||
keys: Keys,
|
||||
@@ -317,15 +320,15 @@ struct Editor {
|
||||
text_blocks: TextBlocksCache,
|
||||
}
|
||||
|
||||
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the unmodified document state they
|
||||
/// were read from. Opening a paragraph and applying its edit both need them, and grouping a long
|
||||
/// page is not cheap. Only an unmodified document is cached: its identity changes on every edit,
|
||||
/// undo and save (each reopens the working file), so a stale entry never matches.
|
||||
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read
|
||||
/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not
|
||||
/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns
|
||||
/// with undo, so a stale entry never matches, also after the document has been edited.
|
||||
#[derive(Default)]
|
||||
struct TextBlocksCache(std::sync::Mutex<Option<CachedTextBlocks>>);
|
||||
|
||||
/// The document state, the page (0-based) and its text blocks.
|
||||
type CachedTextBlocks = (pdfcraft_cos::SourceIdentity, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
|
||||
type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
|
||||
|
||||
impl Clone for TextBlocksCache {
|
||||
fn clone(&self) -> Self {
|
||||
@@ -337,20 +340,16 @@ impl Clone for TextBlocksCache {
|
||||
impl Editor {
|
||||
/// The text blocks on `page` (0-based), from the cache when the document hasn't changed.
|
||||
fn text_blocks(&self, page: usize) -> Result<Arc<Vec<pdfcraft_edit::TextBlock>>, EditError> {
|
||||
let source = (!self.cos.is_modified()).then(|| self.cos.source_identity());
|
||||
if let Some(source) = &source
|
||||
&& let Ok(cache) = self.text_blocks.0.lock()
|
||||
&& let Some((s, p, blocks)) = cache.as_ref()
|
||||
&& s == source
|
||||
if let Ok(cache) = self.text_blocks.0.lock()
|
||||
&& let Some((state, p, blocks)) = cache.as_ref()
|
||||
&& *p == page
|
||||
&& state.matches(&self.cos)
|
||||
{
|
||||
return Ok(blocks.clone());
|
||||
}
|
||||
let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?);
|
||||
if let Some(source) = source
|
||||
&& let Ok(mut cache) = self.text_blocks.0.lock()
|
||||
{
|
||||
*cache = Some((source, page, blocks.clone()));
|
||||
if let Ok(mut cache) = self.text_blocks.0.lock() {
|
||||
*cache = Some((self.cos.read_state(), page, blocks.clone()));
|
||||
}
|
||||
Ok(blocks)
|
||||
}
|
||||
@@ -984,6 +983,27 @@ fn render_threads() -> usize {
|
||||
std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 8) - 1
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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 };
|
||||
let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return };
|
||||
let Some(properties) = root.get(b"OCProperties").cloned() else { return };
|
||||
let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return };
|
||||
let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new);
|
||||
let refs =
|
||||
|visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect());
|
||||
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));
|
||||
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));
|
||||
}
|
||||
|
||||
/// Render `doc` with the layer visibility in its `info.layers`.
|
||||
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();
|
||||
@@ -1247,6 +1267,10 @@ pub enum Edit {
|
||||
pages: Vec<usize>,
|
||||
to: usize,
|
||||
},
|
||||
/// `pages` reversed among the positions they occupy.
|
||||
ReversePages {
|
||||
pages: Vec<usize>,
|
||||
},
|
||||
InsertBlankPage {
|
||||
at: usize,
|
||||
width: f64,
|
||||
@@ -1777,6 +1801,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Delete pages",
|
||||
"Move page",
|
||||
"Move pages",
|
||||
"Reverse pages",
|
||||
"Insert blank page",
|
||||
"Duplicate page",
|
||||
"Duplicate pages",
|
||||
@@ -1916,6 +1941,7 @@ impl Edit {
|
||||
Edit::RotatePages { pages, .. } => plural("Rotate page", "Rotate pages", pages.len()),
|
||||
Edit::DeletePages { pages } => plural("Delete page", "Delete pages", pages.len()),
|
||||
Edit::MovePages { pages, .. } => plural("Move page", "Move pages", pages.len()),
|
||||
Edit::ReversePages { .. } => Label::of("Reverse pages"),
|
||||
Edit::InsertBlankPage { .. } => Label::of("Insert blank page"),
|
||||
Edit::DuplicatePages { pages } => plural("Duplicate page", "Duplicate pages", pages.len()),
|
||||
Edit::ReplacePages { pages, .. } => plural("Replace page", "Replace pages", pages.len()),
|
||||
@@ -2088,6 +2114,7 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed
|
||||
Edit::RotatePages { .. }
|
||||
| Edit::DeletePages { .. }
|
||||
| Edit::MovePages { .. }
|
||||
| Edit::ReversePages { .. }
|
||||
| Edit::InsertBlankPage { .. }
|
||||
| Edit::DuplicatePages { .. }
|
||||
| Edit::ReplacePages { .. }
|
||||
@@ -2298,6 +2325,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
Edit::RotatePages { pages, degrees } => pdfcraft_organize::rotate_pages(doc, pages, *degrees)?,
|
||||
Edit::DeletePages { pages } => pdfcraft_organize::delete_pages(doc, pages)?,
|
||||
Edit::MovePages { pages, to } => pdfcraft_organize::move_pages(doc, pages, *to)?,
|
||||
Edit::ReversePages { pages } => pdfcraft_organize::reverse_pages(doc, pages)?,
|
||||
Edit::InsertBlankPage { at, width, height } => {
|
||||
pdfcraft_organize::insert_blank_page(doc, *at, *width, *height)?;
|
||||
}
|
||||
@@ -3526,6 +3554,7 @@ impl Session {
|
||||
cos,
|
||||
saved,
|
||||
saved_keys: keys.clone(),
|
||||
unsaved_file: false,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
keys,
|
||||
@@ -3823,7 +3852,7 @@ impl Session {
|
||||
/// file is clean; undo past a later save is not, because the restored snapshot's own base
|
||||
/// is an earlier revision.
|
||||
fn sync_dirty(doc: &mut Document) {
|
||||
doc.dirty = doc.editor.as_ref().is_some_and(|e| e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved));
|
||||
doc.dirty = doc.editor.as_ref().is_some_and(|e| e.unsaved_file || e.cos.is_modified() || !Arc::ptr_eq(e.cos.bytes(), &e.saved));
|
||||
}
|
||||
|
||||
/// Use the current state's password for viewing (protection edits change it).
|
||||
@@ -4108,6 +4137,7 @@ impl Session {
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
editor.saved = bytes;
|
||||
editor.saved_keys = keys;
|
||||
editor.unsaved_file = false;
|
||||
}
|
||||
if let Some(p) = path {
|
||||
doc.name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());
|
||||
@@ -4171,7 +4201,7 @@ impl Session {
|
||||
editor.undo.clear();
|
||||
editor.redo.clear();
|
||||
Self::adopt_keys(doc);
|
||||
doc.dirty = false;
|
||||
Self::sync_dirty(doc);
|
||||
doc.generation += 1;
|
||||
Self::refresh(doc).inspect_err(|_| Self::show_released(doc))
|
||||
}
|
||||
@@ -4332,12 +4362,18 @@ impl Session {
|
||||
};
|
||||
return pdfcraft_print::impose(&pictures, &remapped).map_err(|e| EditError::Print(e.to_string()));
|
||||
}
|
||||
let cos = match doc.editor.as_ref() {
|
||||
let mut 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()))?
|
||||
}
|
||||
};
|
||||
// #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() {
|
||||
set_default_layer_state(&mut cos, &doc.info.layers);
|
||||
}
|
||||
pdfcraft_print::impose(&cos, settings).map_err(|e| EditError::Print(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -4645,6 +4681,9 @@ impl Session {
|
||||
let id = self.open(name, None, bytes, None)?;
|
||||
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
d.dirty = true;
|
||||
if let Some(e) = d.editor.as_mut() {
|
||||
e.unsaved_file = true;
|
||||
}
|
||||
// Unsaved work with no edit yet still needs its first recovery snapshot.
|
||||
d.generation += 1;
|
||||
}
|
||||
@@ -4697,6 +4736,9 @@ impl Session {
|
||||
if let Some(d) = self.docs.iter_mut().find(|d| d.id == id) {
|
||||
d.path = path;
|
||||
d.dirty = true;
|
||||
if let Some(e) = d.editor.as_mut() {
|
||||
e.unsaved_file = true;
|
||||
}
|
||||
d.generation += 1;
|
||||
d.snapshot_generation = d.generation; // its bytes are already in the recovery store
|
||||
}
|
||||
@@ -4906,7 +4948,7 @@ fn embed_image(
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
|
||||
pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes))
|
||||
pdfcraft_create::image_xobject(doc, name, bytes)
|
||||
}
|
||||
|
||||
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
|
||||
|
||||
+196
-6
@@ -1987,6 +1987,59 @@ fn oversized_redaction_overlay_preserves_the_document_and_undo() {
|
||||
assert_eq!(page_texts(&s, id), ["Page 1"]);
|
||||
}
|
||||
|
||||
/// #3046: a layer hidden in the Layers panel stays off on paper, as it is on screen; with the
|
||||
/// layers untouched, the document's own configuration prints.
|
||||
#[test]
|
||||
fn printing_keeps_hidden_layers_hidden() {
|
||||
let content = b"/OC /A BDC 1 0 0 rg 10 10 80 80 re f EMC /OC /B BDC 0 1 0 rg 110 10 80 80 re f EMC";
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R] /D << /Order [5 0 R 6 0 R] >> >> >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Contents 4 0 R /Resources << /Properties << /A 5 0 R /B 6 0 R >> >> >>".to_vec(),
|
||||
[format!("<< /Length {} >>\nstream\n", content.len()).as_bytes(), content, b"\nendstream"].concat(),
|
||||
b"<< /Type /OCG /Name (Layer A) >>".to_vec(),
|
||||
b"<< /Type /OCG /Name (Layer B) >>".to_vec(),
|
||||
];
|
||||
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());
|
||||
|
||||
// Red and green pixels on the first printed sheet.
|
||||
let printed = |s: &Session, id: DocId| {
|
||||
let bytes = s.print_pdf(id, &print::Settings { pages: vec![0], ..Default::default() }).unwrap();
|
||||
let mut out = Session::new();
|
||||
let printed = out.open("print.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let page = render_page(&out, printed);
|
||||
let px = page.rgba.as_chunks::<4>().0;
|
||||
let red = px.iter().filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count();
|
||||
let green = px.iter().filter(|p| p[1] > 200 && p[0] < 60 && p[2] < 60).count();
|
||||
(red, green)
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let id = s.open("layers.pdf", None, Arc::new(pdf), None).unwrap();
|
||||
let (red, green) = printed(&s, id);
|
||||
assert!(red > 100 && green > 100, "untouched layers both print: {red} red, {green} green");
|
||||
let a = s.get(id).unwrap().info.layers.iter().position(|l| l.name == "Layer A").unwrap();
|
||||
assert!(s.set_layer_visible(id, a, false));
|
||||
let (red, green) = printed(&s, id);
|
||||
assert_eq!(red, 0, "Layer A was hidden, so it doesn't print");
|
||||
assert!(green > 100, "Layer B still prints: {green} green");
|
||||
assert!(s.set_layer_visible(id, a, true));
|
||||
let (red, _) = printed(&s, id);
|
||||
assert!(red > 100, "shown again, Layer A prints again: {red} red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing_lays_out_sheets_that_render() {
|
||||
let (s, id) = session_with(5);
|
||||
@@ -3286,6 +3339,35 @@ fn recognize_text_reads_the_euro_sign() {
|
||||
assert!(text.contains("€250"), "{text}");
|
||||
}
|
||||
|
||||
const SYNTHETIC_SCAN_DPI: f64 = 300.0;
|
||||
|
||||
/// A scan of the OCR sentence at the source page's physical size, without a text layer.
|
||||
fn synthetic_ocr_scan(s: &mut Session) -> (DocId, [f32; 2]) {
|
||||
let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap();
|
||||
let id = s.open("text.pdf", None, text, None).unwrap();
|
||||
let original = &s.get(id).unwrap().info.pages[0];
|
||||
let size = [original.width, original.height];
|
||||
// A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR.
|
||||
let png = export::Exporter::new(s.get(id).unwrap()).png(0, SYNTHETIC_SCAN_DPI).unwrap();
|
||||
// Exported PNGs have no resolution metadata. The 72 DPI import default would enlarge
|
||||
// the page and change the PDF-point coordinates the placement assertions exercise.
|
||||
let scan = s.create_from_images_with_resolution(&[("scan.png".into(), png)], ImageResolution::Dpi(SYNTHETIC_SCAN_DPI)).unwrap();
|
||||
(s.open("scan.pdf", None, scan, None).unwrap(), size)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_ocr_scan_keeps_the_original_page_size_without_models() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let (id, expected) = synthetic_ocr_scan(&mut s);
|
||||
let page = &s.get(id).unwrap().info.pages[0];
|
||||
// Rounding to whole raster pixels may change a page edge by at most one pixel.
|
||||
let tolerance = 72.0 / SYNTHETIC_SCAN_DPI;
|
||||
for (actual, expected) in [page.width, page.height].into_iter().zip(expected) {
|
||||
assert!(f64::from((actual - expected).abs()) <= tolerance, "page edge {actual} pt, expected {expected} pt");
|
||||
}
|
||||
assert_eq!(page_texts(&s, id), [""], "the scan has no text until OCR runs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_text_makes_a_scanned_page_searchable() {
|
||||
if !ocr::available() {
|
||||
@@ -3293,12 +3375,7 @@ fn recognize_text_makes_a_scanned_page_searchable() {
|
||||
return;
|
||||
}
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let text = s.create_from_text("t", "The quick brown fox jumps over the lazy dog.").unwrap();
|
||||
let id = s.open("text.pdf", None, text, None).unwrap();
|
||||
// A typical 300 DPI scan keeps the small leading glyph in "quick" legible to OCR.
|
||||
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 300.0).unwrap();
|
||||
let scan = s.create_from_images(&[("scan.png".into(), png)]).unwrap();
|
||||
let id = s.open("scan.pdf", None, scan, None).unwrap();
|
||||
let (id, _) = synthetic_ocr_scan(&mut s);
|
||||
assert_eq!(page_texts(&s, id), [""], "a picture has no text");
|
||||
|
||||
let found = s.recognize_text(id, &[], ocr::OcrSettings::default()).unwrap();
|
||||
@@ -3638,6 +3715,34 @@ fn detecting_fields_on_a_scanned_form() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detecting_comb_boxes_adds_one_comb_field_per_row() {
|
||||
// The rows from #1887: five 18 x 22 cells and ten 16 x 20 cells, each a stroked `re`.
|
||||
let mut body = String::from("BT /F1 10 Tf 40 205 Td (Postcode A) Tj ET BT /F1 10 Tf 40 105 Td (Postcode B) Tj ET\n");
|
||||
for i in 0..5 {
|
||||
body.push_str(&format!("{} 200 18 22 re S\n", 120 + i * 18));
|
||||
}
|
||||
for i in 0..10 {
|
||||
body.push_str(&format!("{} 100 16 20 re S\n", 120 + i * 16));
|
||||
}
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("comb.pdf", None, Arc::new(one_line_page("/Font << /F1 4 0 R >>", &body)), None).unwrap();
|
||||
let found: Vec<(String, pdfcraft_forms::detect::Kind)> = s.detect_fields(id, &[]).into_iter().map(|(_, c)| (c.name, c.kind)).collect();
|
||||
assert_eq!(
|
||||
found,
|
||||
[
|
||||
("Postcode A".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 5 }),
|
||||
("Postcode B".to_string(), pdfcraft_forms::detect::Kind::Comb { cells: 10 })
|
||||
]
|
||||
);
|
||||
s.auto_detect_fields(id, &[]).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
let combs: Vec<(&str, FormFieldKind, bool, Option<usize>)> =
|
||||
d.form.iter().map(|f| (f.name.as_str(), f.kind, f.has(pdfcraft_forms::flags::COMB), f.max_len)).collect();
|
||||
assert_eq!(combs, [("Postcode A", FormFieldKind::Text, true, Some(5)), ("Postcode B", FormFieldKind::Text, true, Some(10))]);
|
||||
assert_eq!(d.can_undo(), Some("Detect form fields"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comparing_two_versions_of_a_document() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
@@ -5801,3 +5906,88 @@ fn cropping_an_existing_image_clips_it_and_moves_with_it() {
|
||||
// No area, or a box off the image, is refused.
|
||||
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([10.0, 10.0, 10.0, 50.0]) }).is_err());
|
||||
}
|
||||
|
||||
/// A 60 x 20 JPEG at 144 x 72 dpi, tagged with EXIF Orientation 6 (shown turned 90 degrees clockwise).
|
||||
fn phone_jpeg() -> Vec<u8> {
|
||||
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.set_pixel_density(PixelDensity { density: (144, 72), unit: PixelDensityUnit::Inches });
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[6, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
|
||||
let mut session = Session::new();
|
||||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open_new("form.pdf", blank).unwrap();
|
||||
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
|
||||
// Named "Signature" so `signature_image` finds its picture.
|
||||
let file = MarkFile { name: "photo.jpg".into(), bytes: Arc::new(phone_jpeg()), page: 0 };
|
||||
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
|
||||
session.apply(id, stamp).unwrap();
|
||||
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
|
||||
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
|
||||
let obj = cos.get(picture);
|
||||
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
|
||||
let rect = pdfcraft_annot::summaries(cos)[0].rect;
|
||||
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
|
||||
}
|
||||
|
||||
/// Opening a paragraph and applying its edit read the page's text blocks; after an edit they are
|
||||
/// still read once per document state, and undo and redo never get another state's blocks.
|
||||
#[test]
|
||||
fn text_blocks_stay_cached_after_an_edit_and_follow_undo_and_redo() {
|
||||
let (mut session, id) = session_with(1);
|
||||
let blocks = |s: &Session| s.get(id).unwrap().editor.as_ref().unwrap().text_blocks(0).unwrap();
|
||||
let before = blocks(&session);
|
||||
assert_eq!(before[0].editor_text, "Page 1");
|
||||
let edit = Edit::EditTextBlock { page: 0, block: 0, text: "Edited".into(), style: pdfcraft_edit::BlockStyle::default() };
|
||||
session.apply(id, edit).unwrap();
|
||||
let edited = blocks(&session);
|
||||
assert_eq!(edited[0].editor_text, "Edited");
|
||||
assert!(Arc::ptr_eq(&edited, &blocks(&session)), "the edited page is grouped once");
|
||||
session.undo(id).unwrap();
|
||||
assert_eq!(blocks(&session)[0].editor_text, "Page 1");
|
||||
session.redo(id).unwrap();
|
||||
let redone = blocks(&session);
|
||||
assert_eq!(redone[0].editor_text, "Edited");
|
||||
assert!(Arc::ptr_eq(&redone, &blocks(&session)));
|
||||
}
|
||||
|
||||
/// A new document, or one restored from recovery, has never been written where it belongs: undo
|
||||
/// back to the bytes it was opened from must not make it clean, or closing it would discard the
|
||||
/// whole document without asking. Saving makes it clean; Revert of a saved one too.
|
||||
#[test]
|
||||
fn undo_never_makes_an_unsaved_document_clean() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let new = s.open_new("Combined.pdf", Arc::new(fixture(2))).unwrap();
|
||||
s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
s.undo(new).unwrap();
|
||||
assert!(s.get(new).unwrap().dirty, "a new document is unsaved until it is saved");
|
||||
assert_eq!(s.autosave_snapshots().len(), 1, "and still needs recovery");
|
||||
s.revert(new).unwrap();
|
||||
assert!(s.get(new).unwrap().dirty, "Revert goes back to the created bytes, which aren't saved anywhere");
|
||||
|
||||
let recovered = s.open("Report.pdf", None, Arc::new(fixture(2)), None).unwrap();
|
||||
s.mark_recovered(recovered, Some("Report.pdf".into()));
|
||||
s.apply(recovered, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
s.undo(recovered).unwrap();
|
||||
assert!(s.get(recovered).unwrap().dirty, "the recovered work was never saved");
|
||||
|
||||
let saved = s.save_bytes(new).unwrap();
|
||||
s.mark_saved(new, saved, Some("Combined.pdf".into())).unwrap();
|
||||
assert!(!s.get(new).unwrap().dirty);
|
||||
s.apply(new, Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
|
||||
s.undo(new).unwrap();
|
||||
assert!(!s.get(new).unwrap().dirty, "once saved, undo back to the saved state is clean");
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have
|
||||
//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page
|
||||
//! (stamps, added images, replacements) go through here first.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Cursor;
|
||||
|
||||
use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit};
|
||||
use image::metadata::Orientation;
|
||||
use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader};
|
||||
|
||||
/// Quality of a re-encoded JPEG (the camera's own is rarely higher).
|
||||
const QUALITY: u8 = 92;
|
||||
/// Larger photos are embedded as they are: turning them would take hundreds of MB.
|
||||
const MAX_PIXELS: u64 = 64 << 20;
|
||||
|
||||
/// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded);
|
||||
/// everything else, and any JPEG this can't read, is returned unchanged.
|
||||
pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> {
|
||||
match turned(bytes) {
|
||||
Some(out) => Cow::Owned(out),
|
||||
None => Cow::Borrowed(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
fn turned(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
if !bytes.starts_with(&[0xFF, 0xD8]) {
|
||||
return None;
|
||||
}
|
||||
let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?;
|
||||
let orientation = decoder.orientation().ok()?;
|
||||
let (w, h) = decoder.dimensions();
|
||||
if orientation == Orientation::NoTransforms || u64::from(w) * u64::from(h) > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let mut image = DynamicImage::from_decoder(decoder).ok()?;
|
||||
image.apply_orientation(orientation);
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut out, QUALITY);
|
||||
// Keep the resolution, which sets the image's natural size on the page.
|
||||
if let Some(mut density) = jfif_density(bytes) {
|
||||
if matches!(orientation, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH) {
|
||||
density.density = (density.density.1, density.density.0);
|
||||
}
|
||||
encoder.set_pixel_density(density);
|
||||
}
|
||||
match &image {
|
||||
DynamicImage::ImageLuma8(gray) => encoder.encode_image(gray).ok()?,
|
||||
_ => encoder.encode_image(&image.to_rgb8()).ok()?,
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The resolution in a JFIF header, if the file has one in inches or centimetres.
|
||||
fn jfif_density(bytes: &[u8]) -> Option<PixelDensity> {
|
||||
let mut i = 2usize;
|
||||
while let Some(&[0xFF, marker, hi, lo]) = bytes.get(i..i.saturating_add(4)) {
|
||||
let len = usize::from(u16::from_be_bytes([hi, lo]));
|
||||
let seg = bytes.get(i + 4..i + 2 + len)?;
|
||||
if marker == 0xE0 && seg.starts_with(b"JFIF\0") {
|
||||
let &[unit, x0, x1, y0, y1] = seg.get(7..12)? else { return None };
|
||||
let unit = match unit {
|
||||
1 => PixelDensityUnit::Inches,
|
||||
2 => PixelDensityUnit::Centimeters,
|
||||
_ => return None,
|
||||
};
|
||||
return Some(PixelDensity { density: (u16::from_be_bytes([x0, x1]), u16::from_be_bytes([y0, y1])), unit });
|
||||
}
|
||||
if marker == 0xDA {
|
||||
return None;
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Edit, MarkFile};
|
||||
|
||||
/// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90
|
||||
/// degrees clockwise).
|
||||
fn phone_jpeg(orientation: u8, dpi: u16) -> Vec<u8> {
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches });
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap();
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orientation_is_applied_and_plain_images_are_left_alone() {
|
||||
let photo = phone_jpeg(6, 144);
|
||||
let turned = upright(&photo);
|
||||
assert!(matches!(turned, Cow::Owned(_)));
|
||||
let decoded = image::load_from_memory(&turned).unwrap();
|
||||
assert_eq!((decoded.width(), decoded.height()), (20, 60));
|
||||
assert_eq!(jfif_density(&turned), Some(PixelDensity { density: (72, 144), unit: PixelDensityUnit::Inches }));
|
||||
let stored = phone_jpeg(1, 72);
|
||||
assert!(matches!(upright(&stored), Cow::Borrowed(_)));
|
||||
assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_)));
|
||||
assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() {
|
||||
let mut session = crate::Session::new();
|
||||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open_new("form.pdf", blank).unwrap();
|
||||
// A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright.
|
||||
// Named "Signature" so `signature_image` finds its picture.
|
||||
let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 };
|
||||
let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() };
|
||||
session.apply(id, stamp).unwrap();
|
||||
let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos;
|
||||
let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap();
|
||||
let obj = cos.get(picture);
|
||||
let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() };
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60)));
|
||||
let rect = pdfcraft_annot::summaries(cos)[0].rect;
|
||||
assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user