Merge origin/main

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76
2026-11-06 06:54:33 -05:00
co-authored by Claude Opus 5.5
203 changed files with 30709 additions and 1218 deletions
+4 -4
View File
@@ -197,14 +197,14 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec<String>) ->
return Ok(None);
}
};
// Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step
// that didn't remove is: the file would be passed on as cleaned with the data still in it.
// A step that finds nothing to do is not a failure. Any other error fails the file, so nothing is
// published for it: the file would otherwise be passed on without the step it was asked to apply.
let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize);
match s.apply(id, edit) {
Ok(()) => log.push(step.label().to_string()),
Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())),
Err(e) if removes => return Err(e),
Err(e) => log.push(format!("{}: {e}", step.label())),
Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) => log.push(format!("{}: nothing to do", step.label())),
Err(e) => return Err(e),
}
Ok(None)
}
+1 -1
View File
@@ -267,7 +267,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
item("In multiple files", "files", "ocr.recognize_batch", Ready),
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),
],
}],
},
+5
View File
@@ -220,6 +220,10 @@ pub const COMMANDS: &[CommandSpec] = &[
// Alt-drag for moving windows (several Linux window managers).
// Copy events stay with the frontend so focused text inputs and page clipboard routing keep ownership.
c("edit.copy", "Copy", EDIT, None, Copying, "copy"),
// Comments travel between documents as XFDF (M14): ⌘C with comments selected copies them,
// ⌘V pastes the copies onto the current page; the strip's Copy/Paste in the menus too.
c("comment.copy", "Copy comments", EDIT, None, Document, "copy"),
c("comment.paste", "Paste comments", EDIT, None, Annotate, "clipboard-paste"),
c("edit.column_select", "Column select", EDIT, None, Document, "text-select"),
c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"),
c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"),
@@ -357,6 +361,7 @@ pub const COMMANDS: &[CommandSpec] = &[
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"),
+21 -2
View File
@@ -63,6 +63,15 @@ impl Session {
Ok(pdfcraft_compare::compare(&a.words(), &b.words()))
}
/// [`Session::compare`], keeping the changes on `pages` of `new` (0-based; empty: every page).
pub fn compare_pages(&self, old: DocId, new: DocId, pages: &[usize]) -> Result<Comparison, EditError> {
let mut c = self.compare(old, new)?;
if !pages.is_empty() {
c.changes.retain(|ch| pages.iter().any(|page| (ch.new.page..=ch.new.last_page).contains(page)));
}
Ok(c)
}
/// Visual compare: regions where page n of `new` looks different from page n of `old`
/// (rendered at `dpi`), as (page, user-space box in `new`).
pub fn compare_visual(&self, old: DocId, new: DocId, dpi: f32) -> Result<Vec<(usize, [f64; 4])>, EditError> {
@@ -95,7 +104,12 @@ impl Session {
/// The compare report as a new PDF (not opened).
pub fn compare_report(&self, old: DocId, new: DocId) -> Result<Arc<Vec<u8>>, EditError> {
let c = self.compare(old, new)?;
self.compare_report_of(old, new, &[])
}
/// [`Session::compare_report`] of the changes on `pages` ([`Session::compare_pages`]).
pub fn compare_report_of(&self, old: DocId, new: DocId, pages: &[usize]) -> Result<Arc<Vec<u8>>, EditError> {
let c = self.compare_pages(old, new, pages)?;
let name = |id| self.get(id).map(|d| d.name.clone()).unwrap_or_default();
self.create_report("Compare Report", &pdfcraft_compare::report(&c, &name(old), &name(new)))
}
@@ -104,7 +118,12 @@ impl Session {
/// (blue, green) and a note where text was deleted (red), authored "Compare". One undoable
/// step; returns how many comments were added.
pub fn mark_differences(&mut self, old: DocId, new: DocId) -> Result<usize, EditError> {
let c = self.compare(old, new)?;
self.mark_differences_of(old, new, &[])
}
/// [`Session::mark_differences`] of the changes on `pages` ([`Session::compare_pages`]).
pub fn mark_differences_of(&mut self, old: DocId, new: DocId, pages: &[usize]) -> Result<usize, EditError> {
let c = self.compare_pages(old, new, pages)?;
let mut edits = Vec::new();
for ch in &c.changes {
let color = colour(ch.kind);
+12
View File
@@ -582,6 +582,18 @@ pub fn date_language(code: &str) -> Option<&'static DateLanguage> {
DATE_LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code.trim()))
}
/// Form date fields (`AFDate_FormatEx`, `util.printd`, `util.scand`) write month and weekday
/// names in date language `code` from now on, as a localized Acrobat writes its own language's:
/// `dd-mmm-yy` is `18-ott-26` in Italian (#5303). An unknown code is English.
pub fn use_form_date_language(code: &str) {
let l = date_language(code).unwrap_or(&DATE_LANGUAGES[0]);
let names = pdfcraft_forms::af::DateNames { months: l.months, months_short: l.months_short, days: l.days, days_short: l.days_short };
if pdfcraft_forms::af::date_names() != names {
pdfcraft_forms::af::set_date_names(names);
pdfcraft_js::set_date_names(l.months, l.months_short, l.days, l.days_short);
}
}
/// The characters of `text` that Fill & Sign can't write into a PDF yet, each once. Its text is
/// drawn in Helvetica with WinAnsi encoding (Western European letters), so anything else (`ř`,
/// Japanese, Chinese) would become `?` in the file.
+1 -1
View File
@@ -539,7 +539,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 })
}
+37 -4
View File
@@ -3,10 +3,11 @@
use std::sync::Arc;
use crate::{
AddedContent, AddedText, Background, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, InitialView, Label,
LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, Protection, ReviewState,
Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, field_table, fit_picture,
image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, 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;
@@ -263,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,
@@ -561,6 +585,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>,
+9 -3
View File
@@ -71,6 +71,7 @@ pub const EDIT_LABELS: &[&str] = &[
"Add stamp",
"Add 3D model",
"Delete comment",
"Paste comments",
"Edit comment",
"Reply",
"Set status None",
@@ -211,6 +212,7 @@ impl Edit {
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.
@@ -230,9 +232,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"),
+16 -1
View File
@@ -49,6 +49,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
}
Edit::AddMeasurement(_)
| Edit::AddAnnotation(_)
| Edit::PasteComments { .. }
| Edit::AddCustomStamp { .. }
| Edit::AddModel3d { .. }
| Edit::DeleteAnnotation { .. }
@@ -64,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() {
@@ -495,6 +499,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));
@@ -575,7 +587,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())?;
}
@@ -703,7 +718,7 @@ 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)?,
+4
View File
@@ -53,6 +53,7 @@ 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 { .. }
@@ -68,7 +69,10 @@ 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,
+25 -6
View File
@@ -199,6 +199,24 @@ 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(),
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 +230,18 @@ 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(),
changes: changes(&states, &o.changed),
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
}
})
}
}
+3 -2
View File
@@ -115,8 +115,8 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, scr
pub use pdfcraft_annot::appearance as annot_text;
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
pub use pdfcraft_annot::{
Activation3d, 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;
@@ -131,6 +131,7 @@ pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
pub use pdfcraft_sign as sign;
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, 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};
+1741 -68
View File
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
//! The UI dictionary format: one case-insensitive word per UTF-8 line.
use std::collections::BTreeSet;
pub const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WORDS: usize = 65536;
fn word(value: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
return Err("dictionary words must be nonempty single lines of at most 1024 bytes".into());
}
let normalized = value.to_lowercase();
if normalized.len() > 1024 {
return Err("normalized dictionary words must be at most 1024 bytes".into());
}
Ok(normalized)
}
pub fn words(text: &str) -> Result<BTreeSet<String>, String> {
if text.len() > MAX_BYTES {
return Err("OCR dictionary exceeds 4 MiB".into());
}
let mut words = BTreeSet::new();
for line in text.lines().filter(|line| !line.trim().is_empty()) {
words.insert(word(line)?);
if words.len() > MAX_WORDS {
return Err("OCR dictionary exceeds 65536 words".into());
}
}
Ok(words)
}
/// Normalize, update and bound the complete dictionary before the caller writes it.
pub fn update(text: &str, add: &[&str], remove: &[&str]) -> Result<String, String> {
if add.len() > MAX_WORDS || remove.len() > MAX_WORDS {
return Err("OCR dictionary exceeds 65536 words".into());
}
let mut entries = words(text)?;
for value in remove {
entries.remove(&word(value)?);
}
for value in add {
entries.insert(word(value)?);
if entries.len() > MAX_WORDS {
return Err("OCR dictionary exceeds 65536 words".into());
}
}
let mut out = String::new();
for entry in entries {
if out.len().checked_add(entry.len()).and_then(|n| n.checked_add(1)).is_none_or(|n| n > MAX_BYTES) {
return Err("OCR dictionary exceeds 4 MiB".into());
}
out.push_str(&entry);
out.push('\n');
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dictionary_updates_round_trip_and_refuse_expanding_normalization() {
let text = update("Quartz\n日本語\n", &["QUARTZ", "extra"], &["日本語"]).unwrap();
assert_eq!(text, "extra\nquartz\n");
assert_eq!(words(&text).unwrap(), BTreeSet::from(["extra".into(), "quartz".into()]));
assert!(update("original\n", &[&"İ".repeat(512)], &[]).unwrap_err().contains("normalized"));
assert!(update("original\n", &["two\nlines"], &[]).is_err());
}
#[test]
fn dictionary_limits_cover_existing_and_added_entries() {
let full: String = (0..65536).map(|i| format!("word{i}\n")).collect();
assert_eq!(words(&full).unwrap().len(), 65536);
assert!(update(&full, &["extra"], &[]).is_err());
assert_eq!(update(&full, &["extra"], &["word0"]).unwrap().lines().count(), 65536);
assert!(words(&"a".repeat(MAX_BYTES + 1)).is_err());
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Unicode search text has no visible glyphs. A generated Type 3 font supplies advance widths
//! and a ToUnicode map, with empty character programs; no display font is needed or embedded.
//! Adapted from the searchable-layer implementation in #433 (32e09fbe).
use std::collections::BTreeMap;
use pdfcraft_cos::{Dict, Document, Object, Stream};
use pdfcraft_edit::EditError;
use pdfcraft_ocr::PlacedWord;
/// Refuse oversized text or invalid geometry before cloning words or allocating fonts.
/// These limits apply to the complete input, including all editable export pages.
pub(crate) fn validate_words<'a>(words: impl IntoIterator<Item = &'a PlacedWord>) -> Result<usize, EditError> {
let mut count = 0usize;
let mut length = 0usize;
for w in words {
count += 1;
length = length.checked_add(w.text.len()).ok_or_else(|| EditError::Invalid("OCR text layer is too large".into()))?;
if count > 65536 || length > 4 * 1024 * 1024 {
return Err(EditError::Invalid("OCR text layer is too large".into()));
}
if !w.origin.iter().chain(&w.across).chain(&w.up).all(|v| v.is_finite())
|| !w.across[0].hypot(w.across[1]).is_finite()
|| !w.up[0].hypot(w.up[1]).is_finite()
|| (0..2).any(|i| !(w.origin[i] + 0.2 * w.up[i]).is_finite() || !(w.origin[i] + w.across[i] + 0.2 * w.up[i]).is_finite())
{
return Err(EditError::Invalid("OCR text coordinates must be finite".into()));
}
}
Ok(count)
}
pub(crate) fn add_text(doc: &mut Document, page: usize, words: &[PlacedWord]) -> Result<(), EditError> {
validate_words(words)?;
// Keep the existing Latin layout, including Helvetica's proportional advance widths.
if words.iter().all(|w| {
w.text
.chars()
.all(|c| matches!(c, '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' | '€' | '‚' | '„' | '…' | '‘' | '’' | '“' | '”' | '•' | '–' | '—' | '™'))
}) {
pdfcraft_edit::stamp(doc, page, "OCR", pdfcraft_ocr::text_layer(words, &pdfcraft_fonts::TextPlan::default()))?;
return Ok(());
}
let p = pdfcraft_model::pages(doc).get(page).cloned().ok_or_else(|| EditError::Invalid("OCR page does not exist".into()))?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let chars: Vec<char> = words.iter().flat_map(|w| w.text.chars()).collect::<std::collections::BTreeSet<_>>().into_iter().collect();
// Match the extractor's em box (-200..800). Baselines below are offset by 0.2 em,
// so search/selection rectangles span the detected image box.
let blank = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"1000 0 0 -200 1000 800 d1\n".to_vec())));
let mut codes = BTreeMap::new();
let mut names = Vec::new();
for (index, chunk) in chars.chunks(255).enumerate() {
let mut name = format!("PCOcr{index}");
while fonts.contains(name.as_bytes()) {
name.push('x');
}
let mut differences = vec![Object::Int(1)];
let mut procs = Dict::new();
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (PdfCraft) /Ordering (Unicode) /Supplement 0 >> def\n/CMapName /PdfCraftOCR def\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
);
for (batch, part) in chunk.chunks(100).enumerate() {
cmap.push_str(&format!("{} beginbfchar\n", part.len()));
for (i, ch) in part.iter().enumerate() {
let code = (batch * 100 + i + 1) as u8;
let glyph = format!("g{code:02X}");
differences.push(Object::name(&glyph));
procs.set(glyph.into_bytes(), Object::Ref(blank));
let mut units = [0u16; 2];
let hex: String = ch.encode_utf16(&mut units).iter().map(|u| format!("{u:04X}")).collect();
cmap.push_str(&format!("<{code:02X}> <{hex}>\n"));
codes.insert(*ch, (index, code));
}
cmap.push_str("endbfchar\n");
}
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let map = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
let mut encoding = Dict::new();
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
encoding.set(b"Differences".to_vec(), Object::Array(differences));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"FontBBox".to_vec(), Object::Array(vec![0.into(), (-200).into(), 1000.into(), 800.into()]));
font.set(b"FontMatrix".to_vec(), Object::Array(vec![Object::Real(0.001), 0.into(), 0.into(), Object::Real(0.001), 0.into(), 0.into()]));
font.set(b"FirstChar".to_vec(), Object::Int(1));
font.set(b"LastChar".to_vec(), Object::Int(chunk.len() as i64));
font.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(1000); chunk.len()]));
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(procs));
font.set(b"Resources".to_vec(), Object::Dict(Dict::new()));
font.set(b"ToUnicode".to_vec(), Object::Ref(map));
fonts.set(name.as_bytes().to_vec(), Object::Ref(doc.add(Object::Dict(font))));
names.push(name);
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
// `3 Tr` and the font are graphics state, which `ET` doesn't reset: like
// `pdfcraft_ocr::text_layer` (#3029), the text is drawn inside `q … Q` so the invisible
// rendering mode cannot leak into later content streams on the page.
let mut content = String::from("/OCR BMC\nq\nBT\n3 Tr\n");
for w in words {
let count = w.text.chars().count();
if count == 0 || w.up[0].hypot(w.up[1]) <= 0.0 || w.across[0].hypot(w.across[1]) <= 0.0 {
continue;
}
let mut start = 0;
let mut run = Vec::new();
let mut font_index = None;
let emit = |content: &mut String, font: usize, start: usize, run: &[u8]| {
let n = count as f64;
let at = start as f64 / n;
let hex: String = run.iter().map(|b| format!("{b:02X}")).collect();
content.push_str(&format!(
"/{} 1 Tf\n{} {} {} {} {} {} Tm <{hex}> Tj\n",
names[font],
w.across[0] / n,
w.across[1] / n,
w.up[0],
w.up[1],
w.origin[0] + at * w.across[0] + 0.2 * w.up[0],
w.origin[1] + at * w.across[1] + 0.2 * w.up[1]
));
};
for (i, ch) in w.text.chars().enumerate() {
let Some(&(font, code)) = codes.get(&ch) else { continue };
if let Some(previous) = font_index
&& previous != font
{
emit(&mut content, previous, start, &run);
run.clear();
start = i;
}
font_index = Some(font);
run.push(code);
}
if let Some(font) = font_index {
emit(&mut content, font, start, &run);
}
}
content.push_str("ET\nQ\nEMC\n");
pdfcraft_edit::stamp(doc, page, "OCR", content.into_bytes())?;
Ok(())
}
+6 -2
View File
@@ -186,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| {
+14 -1
View File
@@ -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)?;
@@ -384,7 +397,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()))?;
+52 -9
View File
@@ -623,12 +623,12 @@ fn a_3d_model_needs_the_comments_permission_and_changes_the_comments() {
activation: Activation3d::default(),
name: String::new(),
};
let p = pdfcraft_cos::Permissions { bits: 0, owner: false };
let p = pdfcraft_cos::Permissions { bits: 0, owner: false, revision: 3 };
assert_eq!(check_permission(&edit, &p), Err(EditError::NotPermitted("comments")));
assert_eq!(crate::history::scope_of(&edit), crate::history::Scope::Comments);
}
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
pub(crate) fn page_texts(s: &Session, id: DocId) -> Vec<String> {
let doc = s.get(id).unwrap();
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
@@ -2211,7 +2211,7 @@ fn rect_comment(page: usize, rect: [f64; 4]) -> Edit {
Edit::AddAnnotation(NewAnnotation {
page,
shape: Shape::Rectangle { rect },
style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) },
style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]), ..Style::default() },
contents: "Look here".into(),
author: "Reviewer".into(),
})
@@ -5841,6 +5841,10 @@ fn comparing_two_versions_of_a_document() {
let got: Vec<(compare::Kind, &str, &str)> = c.changes.iter().map(|x| (x.kind, x.old.text.as_str(), x.new.text.as_str())).collect();
assert_eq!(got, [(compare::Kind::Replaced, "Monday.", "Tuesday."), (compare::Kind::Replaced, "days.", "days of invoice.")], "{got:?}");
assert!(s.compare(old, old).unwrap().identical());
// limited to pages of the new file: both changes are on the first
assert_eq!(s.compare_pages(old, new, &[]).unwrap().changes.len(), 2);
assert_eq!(s.compare_pages(old, new, &[0]).unwrap().changes.len(), 2);
assert!(s.compare_pages(old, new, &[1]).unwrap().identical());
let report = s.compare_report(old, new).unwrap();
let rid = s.open("report.pdf", None, report, None).unwrap();
@@ -6048,7 +6052,7 @@ fn measurement_edits_are_transactional_and_honour_annotation_permissions() {
assert!(session.get(id).unwrap().measurements().unwrap().measurements.is_empty());
session.redo(id).unwrap();
assert_eq!(session.get(id).unwrap().bytes, before);
let p = pdfcraft_cos::Permissions { bits: 0, owner: false };
let p = pdfcraft_cos::Permissions { bits: 0, owner: false, revision: 3 };
assert_eq!(check_permission(&Edit::AddMeasurement(m), &p), Err(EditError::NotPermitted("comments")));
assert_eq!(
check_permission(
@@ -7322,12 +7326,13 @@ fn datasets_streams(bytes: &Arc<Vec<u8>>) -> usize {
/// and noted.
#[test]
fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
// On open: a calculate setting its own value 50 000 times, an initialize script showing a
// thousand messages and asking to print, open a link and save.
// On open: a calculate setting its own value 20 000 times (50 000 sat right at the 1 s
// unasked-script timeout and abandoned whenever the test machine was loaded), an
// initialize script showing a thousand messages and asking to print, open a link and save.
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"<calculate><script contentType="application/x-javascript">qty.rawValue * price.rawValue</script></calculate>"#,
r#"<calculate><script contentType="application/x-javascript">for (var i = 0; i &lt; 50000; i++) this.rawValue = i;</script></calculate>"#,
r#"<calculate><script contentType="application/x-javascript">for (var i = 0; i &lt; 20000; i++) this.rawValue = i;</script></calculate>"#,
)
.replace(
r#"if (qty.rawValue === null) qty.rawValue = 2;"#,
@@ -7337,9 +7342,11 @@ fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("hostile.pdf", None, bytes, None).expect("opens");
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
// 30 s and not 5 s: the run is wall-clock timed and a loaded test machine (all suites in
// parallel) can take several seconds for the 20 000 iterations; a real hang still fails.
assert!(started.elapsed() < std::time::Duration::from_secs(30), "{:?}", started.elapsed());
let doc = s.get(id).unwrap();
assert_eq!(doc.form.iter().find(|f| f.name == "total").unwrap().value, vec!["49999".to_string()]);
assert_eq!(doc.form.iter().find(|f| f.name == "total").unwrap().value, vec!["19999".to_string()]);
// One datasets stream for the scripts' revision, not one per value set.
assert!(datasets_streams(&doc.bytes) <= 3, "{} datasets streams", datasets_streams(&doc.bytes));
// What opening changed is noted with the form's other XFA notes.
@@ -10185,6 +10192,42 @@ fn need_appearances_buttons_are_displayed_from_their_captions() {
}
}
/// Copy and paste: the copies of a comment and its reply land on the asked-for page of another
/// document under names of their own (nothing replaced; a second paste gives a second set),
/// undo takes a paste away as one step, and a clipboard without comments is refused.
#[test]
fn pasting_copied_comments_adds_copies_that_undo_as_one_step() {
let (mut s, id) = session_with(2);
s.apply(id, rect_comment(1, [80.0, 80.0, 120.0, 120.0])).unwrap();
s.apply(id, Edit::ReplyToAnnotation { page: 1, index: 0, text: "Done".into(), author: "Ada".into() }).unwrap();
let xfdf = s.export_comments(id, &[(1, 0)]).unwrap();
assert!(xfdf.contains("<square") && xfdf.contains("inreplyto="), "{xfdf}");
let other = s.open("other.pdf", None, Arc::new(fixture(2)), None).expect("opens");
s.apply(other, Edit::PasteComments { xfdf: xfdf.clone(), page: 0, offset: None }).unwrap();
let a = s.get(other).unwrap().info.annotations.clone();
assert_eq!(a.len(), 2, "{a:?}");
assert!(a.iter().all(|x| x.page == 0), "on the asked-for page: {a:?}");
let parent = a.iter().find(|x| x.in_reply_to.is_none()).unwrap();
let reply = a.iter().find(|x| x.in_reply_to.is_some()).unwrap();
assert_eq!(reply.in_reply_to, parent.name, "the reply follows its copied parent");
assert_ne!(parent.name, s.get(id).unwrap().info.annotations[0].name, "a name of its own");
assert_eq!(parent.contents.as_deref(), Some("Look here"));
assert_eq!(s.get(other).unwrap().can_undo(), Some("Paste comments"));
s.apply(other, Edit::PasteComments { xfdf: xfdf.clone(), page: 1, offset: Some([30.0, -10.0]) }).unwrap();
let a = s.get(other).unwrap().info.annotations.clone();
assert_eq!(a.len(), 4, "a second set");
let placed = a.iter().find(|x| x.page == 1 && x.in_reply_to.is_none()).unwrap();
assert!((placed.rect[0] - 110.0).abs() < 0.5 && (placed.rect[1] - 70.0).abs() < 0.5, "moved by the offset: {:?}", placed.rect);
assert_eq!(comment_count(&xfdf), 1);
assert_eq!(comments_bounds(&xfdf).map(|b| b.map(|v| v.round())), Some([80.0, 80.0, 120.0, 120.0]));
s.undo(other).unwrap();
assert_eq!(s.get(other).unwrap().info.annotations.len(), 2);
assert!(s.apply(other, Edit::PasteComments { xfdf: "hello".into(), page: 0, offset: None }).is_err(), "no comments in it");
assert!(s.apply(other, Edit::PasteComments { xfdf, page: 9, offset: None }).is_err(), "no such page");
assert_eq!(s.get(other).unwrap().info.annotations.len(), 2, "a refused paste changes nothing");
assert_eq!(s.get(id).unwrap().info.annotations.len(), 2, "the source is untouched");
}
#[test]
fn repeated_backdrop_artwork_survives_background_undo_redo_and_both_save_modes() {
let white = "1 g";