Merge remote-tracking branch 'origin/main' into feat/all-tools-expand-view

# Conflicts:
#	ATTRIBUTION.toml
This commit is contained in:
alehel committed 2026-10-18 16:17:28 +02:00
commit 522c1dcd22
104 files changed
+4348 -428

No files matched your search

+1
View File
@@ -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")),
],
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
}
-133
View File
@@ -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:?}");
}
}