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:
KenM76andClaude Opus 5.5 committed 2026-11-06 06:54:33 -05:00
commit cf3ee49fbc
203 files changed
+30709 -1218

No files matched your search

+4 -4
View File
@@ -197,14 +197,14 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec<String>) ->
return Ok(None);
}
};
// Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step
// that didn't remove is: the file would be passed on as cleaned with the data still in it.
// A step that finds nothing to do is not a failure. Any other error fails the file, so nothing is
// published for it: the file would otherwise be passed on without the step it was asked to apply.
let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize);
match s.apply(id, edit) {
Ok(()) => log.push(step.label().to_string()),
Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())),
Err(e) if removes => return Err(e),
Err(e) => log.push(format!("{}: {e}", step.label())),
Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) => log.push(format!("{}: nothing to do", step.label())),
Err(e) => return Err(e),
}
Ok(None)
}
+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";
+453
View File
@@ -0,0 +1,453 @@
//! Form date fields name months in the app's date language (#5303). Its own test binary: the
//! names are process-wide, so other tests must not see them change, and its tests take turns.
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use pdfcraft_cos::{Document, Object};
use pdfcraft_engine::dates::use_form_date_language;
use pdfcraft_engine::js::JsRunner;
use pdfcraft_forms::FieldValue;
use pdfcraft_forms::af::{DateTime, Format, format_date, format_value, keystroke, parse_date};
use pdfcraft_js::{DocInfo, Event, Limits, run};
/// Held by each test while it changes the process-wide date names, which start as English (a
/// failed test may have left others).
fn names_lock() -> MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
let lock = LOCK.lock().unwrap_or_else(PoisonError::into_inner);
use_form_date_language("en");
lock
}
/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads.
fn printd(fmt: &str, typed: &str) -> String {
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));");
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
assert_eq!(o.error, None);
o.value
}
#[test]
fn form_dates_follow_the_date_language() {
let _names = names_lock();
let oct18 = DateTime { y: 2026, m: 10, d: 18, hh: 0, mm: 0, ss: 0 };
let dd_mmm_yy = Format::Date("dd-mmm-yy".into());
// English until the app says otherwise.
assert_eq!(format_date(oct18, "dd-mmm-yy dddd ddd mmmm"), "18-Oct-26 Sunday Sun October");
use_form_date_language("it");
// AFDate_FormatEx("dd-mmm-yy"), what the issue's form used.
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-ott-26");
assert_eq!(format_date(oct18, "dddd d mmmm yyyy, ddd"), "domenica 18 ottobre 2026, dom");
// What it writes reads back, and so does English typing.
assert_eq!(parse_date("18-ott-26", "dd-mmm-yy"), Some(oct18));
assert_eq!(parse_date("18 ottobre 2026", "dd mmmm yyyy"), Some(oct18));
assert_eq!(parse_date("18-Oct-26", "dd-mmm-yy"), Some(oct18));
assert!(keystroke(&dd_mmm_yy, "date", "18-ott-26").is_ok());
// util.printd writes Italian names; util.scand reads them.
assert_eq!(printd("dd-mmm-yy", "18-ott-26"), "18-ott-26|18-ott-26");
assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-ott-26|18-ott-26", "English typing reads too");
assert_eq!(printd("dddd d mmmm yyyy", "domenica 18 ottobre 2026"), "domenica 18 ottobre 2026|domenica 18 ottobre 2026");
// German short names end in a dot; they are written and read whole.
use_form_date_language("de");
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Okt.-26");
assert_eq!(parse_date("18-Okt.-26", "dd-mmm-yy"), Some(oct18));
// An unknown language is English again.
use_form_date_language("xx");
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Oct-26");
assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-Oct-26|18-Oct-26");
every_date_language_reads_back_what_it_writes();
// Hostile typing never panics and stays quick: long, odd and mixed-script text.
use_form_date_language("th");
let junk = ["", " ", "ม.", "ม.ค.ม.ค.ม.ค.", "\u{0}", "1月1月1月", "tháng tháng", "Ottobre", "🙂ott🙂", "İstanbul ott 26"];
for t in junk.iter().map(|s| s.to_string()).chain(["ม.ค.1".repeat(5000), "9".repeat(100_000)]) {
let started = std::time::Instant::now();
let _ = parse_date(&t, "dd-mmm-yy");
let _ = keystroke(&dd_mmm_yy, "date", &t);
assert!(started.elapsed() < std::time::Duration::from_secs(2), "{} chars took {:?}", t.len(), started.elapsed());
}
use_form_date_language("en");
}
/// An English weekday typed before the month fills the format's weekday, so a month name that
/// the date language shares with a weekday (Italian, Spanish, Romanian and Albanian "mar") is
/// still read as the month: "Tue 17 Mar 2026" read as "Tue 17 Tuesday 2026" (#5303).
#[test]
fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
let _names = names_lock();
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 };
let mut bad = Vec::new();
for language in ["it", "es", "ro", "sq"] {
use_form_date_language(language);
let ddd_d_mmm = "ddd d mmm yyyy";
let typed = [
// What the language writes, then English, then English and localized mixed.
(ddd_d_mmm, format_date(mar17, ddd_d_mmm)),
(ddd_d_mmm, "Tue 17 Mar 2026".into()),
(ddd_d_mmm, "Tuesday 17 Mar 2026".into()),
(ddd_d_mmm, "tue 17 mar 2026".into()),
(ddd_d_mmm, format!("Tue 17 {}", format_date(mar17, "mmm yyyy"))),
(ddd_d_mmm, format!("{} 17 Mar 2026", format_date(mar17, "ddd"))),
("mmm d yyyy ddd", "Mar 17 2026 Tue".into()),
("dddd d mmmm yyyy", "Tuesday 17 March 2026".into()),
];
for (fmt, text) in typed {
if parse_date(&text, fmt) != Some(mar17) {
bad.push(format!("{language} forms {fmt}: {text:?} -> {:?}", parse_date(&text, fmt)));
}
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
if o.value != "2026-03-17" {
bad.push(format!("{language} scand {fmt}: {text:?} -> {:?} {:?}", o.value, o.error));
}
}
}
use_form_date_language("en");
assert!(bad.is_empty(), "{}", bad.join("; "));
}
/// Every date language: what a date field writes reads back as the same date, for each date
/// letter that writes a name, through the form path and through `util.scand`. Called from the
/// one test, which owns the process-wide names.
fn every_date_language_reads_back_what_it_writes() {
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let mut bad = Vec::new();
for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() {
use_form_date_language(l.code);
for m in 1..=12u32 {
let dt = DateTime { y: 2026, m, d: 18, hh: 0, mm: 0, ss: 0 };
for fmt in ["dd-mmm-yy", "d mmmm yyyy", "dddd, mmmm d, yyyy", "ddd d mmm yyyy", "mmm d, yyyy ddd", "yyyymmmdd"] {
let text = format_date(dt, fmt);
// A short month ending in a number ("Thg 1") glued to the day is ambiguous
// ("Thg 118": January 18 or November 8): nothing can read it back.
if fmt == "yyyymmmdd" && l.months_short.iter().any(|n| n.ends_with(|c: char| c.is_ascii_digit())) {
continue;
}
if parse_date(&text, fmt).map(|p| (p.y, p.m, p.d)) != Some((2026, m, 18)) {
bad.push(format!("{} forms {fmt}: {text:?}", l.code));
}
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
if o.value != format!("2026-{m:02}-18") {
bad.push(format!("{} scand {fmt}: {text:?} -> {:?} {:?}", l.code, o.value, o.error));
}
}
}
}
use_form_date_language("en");
assert!(bad.is_empty(), "{} failures:\n{}", bad.len(), bad.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
}
/// A form whose text fields are each `(name, font, format script)`: `/Helv` is Helvetica with
/// WinAnsi, `/Song` a Chinese font with a Unicode CMap, which also holds Cyrillic.
fn form(fields: &[(&str, &str, &str)]) -> Document {
let n = fields.len();
let first = 6;
let refs = |from: usize| (from..from + n).map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ");
let mut objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", refs(first)),
format!("<< /Fields [{}] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Song {} 0 R >> >> >>", refs(first), first + n),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
];
for (i, (name, font, js)) in fields.iter().enumerate() {
let js = js.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
let y = 700 - 30 * i;
objs.push(format!(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/{font} 10 Tf 0 g) /Rect [50 {y} 300 {}] /P 3 0 R /AA << /F << /S /JavaScript /JS ({js}) >> >> >>",
y + 20
));
}
objs.push("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into());
let mut out = b"%PDF-1.7
"
.to_vec();
let mut offsets = Vec::new();
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(
format!(
"{} 0 obj
{o}
endobj
",
i + 1
)
.as_bytes(),
);
}
let xref = out.len();
out.extend_from_slice(
format!(
"xref
0 {}
0000000000 65535 f
",
objs.len() + 1
)
.as_bytes(),
);
for o in offsets {
out.extend_from_slice(
format!(
"{o:010} 00000 n
"
)
.as_bytes(),
);
}
out.extend_from_slice(
format!(
"trailer
<< /Size {} /Root 1 0 R >>
startxref
{xref}
%%EOF
",
objs.len() + 1
)
.as_bytes(),
);
Document::open(Arc::new(out)).expect("opens")
}
/// The content of field `name`'s normal appearance.
fn appearance(doc: &Document, name: &str) -> Vec<u8> {
let all = pdfcraft_forms::fields(doc);
let f = all.iter().find(|f| f.name == name).expect("field");
let widget = doc.get(f.widgets[0].obj);
let normal = widget.as_dict().and_then(|d| d.get(b"AP")).and_then(|a| doc.resolve(a).as_dict().and_then(|a| a.reference(b"N"))).expect("/AP /N");
match &*doc.get(normal) {
Object::Stream(s) => s.decoded().expect("decodes"),
other => panic!("not a stream: {other:?}"),
}
}
/// What a Unicode-CMap (UCS-2) font is given for `text`.
fn ucs2(text: &str) -> Vec<u8> {
text.encode_utf16().flat_map(u16::to_be_bytes).collect()
}
fn contains(hay: &[u8], needle: &[u8]) -> bool {
hay.windows(needle.len()).any(|w| w == needle)
}
/// Fills `form`'s field `name` with 18 October 2026 through the JavaScript runner.
fn fill(doc: &mut Document, name: &str) {
fill_with(doc, name, "18-10-26");
}
/// Fills `form`'s field `name` with `value` through the JavaScript runner.
fn fill_with(doc: &mut Document, name: &str, value: &str) {
let mut js = JsRunner::new(doc, "form.pdf");
pdfcraft_forms::set_value_with(doc, name, &FieldValue::Text(value.into()), &mut js).expect("fills");
}
/// Month names in the date language that no font of the field can draw are drawn in English,
/// never as `?` ("18-????.-26" in Russian); a font that holds them keeps them (#5303).
#[test]
fn date_names_no_field_font_can_draw_are_drawn_in_english() {
let _names = names_lock();
let date = "AFDate_FormatEx(\"dd-mmm-yy\");";
let fields = [("helv", "Helv", date), ("song", "Song", date)];
for (language, local) in [("ru", "окт."), ("cs", "říj"), ("ja", "10月"), ("el", "Οκτ")] {
use_form_date_language(language);
let mut doc = form(&fields);
fill(&mut doc, "helv");
fill(&mut doc, "song");
let helv = appearance(&doc, "helv");
assert!(contains(&helv, b"(18-Oct-26) Tj"), "{language}: {}", String::from_utf8_lossy(&helv));
assert!(!helv.contains(&b'?'), "{language}: {}", String::from_utf8_lossy(&helv));
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty(), "{language}");
let song = appearance(&doc, "song");
assert!(contains(&song, &ucs2(&format!("18-{local}-26"))), "{language} keeps its name: {song:?}");
}
// Names Helvetica holds are kept: Italian "ott".
use_form_date_language("it");
let mut doc = form(&fields);
fill(&mut doc, "helv");
assert!(contains(&appearance(&doc, "helv"), b"(18-ott-26) Tj"));
use_form_date_language("en");
}
/// A Format script's `util.printd` is run again with English names for a field whose fonts
/// can't draw the date language's, and kept where they can (#5303).
#[test]
fn format_scripts_print_dates_in_english_where_the_field_cannot_draw_them() {
let _names = names_lock();
let script = "event.value = event.value ? util.printd(\"dd-mmm-yy\", util.scand(\"dd-mm-yy\", event.value)) + \" !\" : \"\";";
let fields = [("helv", "Helv", script), ("song", "Song", script)];
use_form_date_language("ru");
let mut doc = form(&fields);
fill(&mut doc, "helv");
fill(&mut doc, "song");
let helv = appearance(&doc, "helv");
assert!(contains(&helv, b"(18-Oct-26 !) Tj"), "{}", String::from_utf8_lossy(&helv));
assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv));
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty());
assert!(contains(&appearance(&doc, "song"), &ucs2("18-окт.-26 !")));
// A script whose text has no English either keeps it, reported as undrawn.
let greeting = [("hi", "Helv", "event.value = \"Привет \" + util.printd(\"mmm\", new Date(2026, 9, 18));")];
let mut doc = form(&greeting);
fill(&mut doc, "hi");
assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("hi".to_string(), 'П')]);
// The English run is the appearance's alone: scripts go on printing the date language.
assert_eq!(printd("dd-mmm-yy", "18-окт.-26"), "18-окт.-26|18-окт.-26");
use_form_date_language("en");
}
/// A Format script's English run is kept when it ran, even where it prints the stored value
/// again: a Helvetica field storing "18-Oct-26", printed again as `dd-mmm-yy`, showed
/// "18-????.-26" in Russian. A script that fails keeps what it showed, reported undrawn (#5303).
#[test]
fn an_english_run_that_prints_the_stored_value_is_drawn() {
let _names = names_lock();
let script = "event.value = util.printd(\"dd-mmm-yy\", util.scand(\"dd-mmm-yy\", event.value));";
use_form_date_language("ru");
let mut doc = form(&[("helv", "Helv", script), ("fails", "Helv", "throw new Error(\"no\");")]);
fill_with(&mut doc, "helv", "18-Oct-26");
let helv = appearance(&doc, "helv");
assert!(contains(&helv, b"(18-Oct-26) Tj"), "{}", String::from_utf8_lossy(&helv));
assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv));
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty());
fill_with(&mut doc, "fails", "18-окт.-26");
assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("fails".to_string(), 'о')]);
use_form_date_language("en");
}
/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`.
fn scanned(fmt: &str, text: &str) -> String {
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
assert_eq!(o.error, None);
o.value
}
/// A short weekday typed without its dot ("mar" for French "mar.", Tuesday) is the weekday where
/// the format has one, not the English "Mar" (March) that is the only other name of its length:
/// "mar 6 janv 2026" read as no date in French and Romanian (#5303).
#[test]
fn a_short_weekday_typed_without_its_dot_is_the_weekday() {
let _names = names_lock();
let mut bad = Vec::new();
let cases = [
("fr", "ddd d mmm yyyy", "Mar 6 janv 2026", "2026-01-06"),
("fr", "ddd d mmm yyyy", "mar 17 mars 2026", "2026-03-17"),
("fr", "mmm d yyyy ddd", "janv 6 2026 mar", "2026-01-06"),
("fr", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"),
("ro", "ddd d mmm yyyy", "mar 6 ian 2026", "2026-01-06"),
("ro", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"),
];
for (language, fmt, text, want) in cases {
use_form_date_language(language);
let got = scanned(fmt, text);
if got != want {
bad.push(format!("{language} scand {fmt}: {text:?} -> {got}"));
}
let read = parse_date(text, fmt).map(|d| format!("{:04}-{:02}-{:02}", d.y, d.m, d.d));
if read.as_deref() != Some(want) {
bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}"));
}
}
use_form_date_language("en");
assert!(bad.is_empty(), "{}", bad.join("; "));
}
/// A weekday left out of a format with one ("17 mar 2026" for "ddd d mmm yyyy") lets a name the
/// date language shares between a weekday and a month be the month: Italian, Spanish and
/// Albanian "mar" (Tuesday, March) and Albanian "sht" (Saturday, September) were read as the
/// weekday, leaving no month (#5303). Typed weekdays still go first.
#[test]
fn a_shared_name_is_the_month_when_the_weekday_is_left_out() {
let _names = names_lock();
let mut bad = Vec::new();
let cases = [
("it", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
("es", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
("sq", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
("it", "ddd d mmm yyyy", "17 mar 2026", (2026, 3, 17)),
("it", "dddd, mmmm d, yyyy", "mar 17, 2026", (2026, 3, 17)),
("it", "dddd, mmm d, yyyy", "mar 17 2026", (2026, 3, 17)),
("sq", "ddd d mmm yyyy", "5 sht 2026", (2026, 9, 5)),
("it", "ddd d mmm yyyy", "mar 17 Mar 2026", (2026, 3, 17)),
("it", "ddd d mmm yyyy", "mar 17 mar 2026", (2026, 3, 17)),
];
for (language, fmt, text, want) in cases {
use_form_date_language(language);
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
if read != Some(want) {
bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}"));
}
if keystroke(&Format::Date(fmt.into()), "date", text).is_err() {
bad.push(format!("{language} keystroke {fmt}: {text:?} refused"));
}
}
use_form_date_language("en");
assert!(bad.is_empty(), "{}", bad.join("; "));
}
/// Turkish names typed in capitals are read: lower-casing letter by letter made "MAYIS" "mayis"
/// (not "mayıs"), "SALI" "sali" and "EKİM" "eki̇m" (an i with a dot above), so no name matched.
/// Dotted, dotless and capital i are one letter when names are read (#5303).
#[test]
fn turkish_names_typed_in_capitals_are_read() {
let _names = names_lock();
use_form_date_language("tr");
let mut bad = Vec::new();
for (fmt, text, want) in [
("d mmmm yyyy", "1 MAYIS 2026", (2026, 5, 1)),
("d mmmm yyyy", "1 EKİM 2026", (2026, 10, 1)),
("d mmmm yyyy", "1 Ekim 2026", (2026, 10, 1)),
("d mmm yyyy", "1 EKİ 2026", (2026, 10, 1)),
("dddd d mmmm yyyy", "SALI 6 OCAK 2026", (2026, 1, 6)),
("dddd d mmmm yyyy", "Salı 6 Ocak 2026", (2026, 1, 6)),
] {
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
if read != Some(want) {
bad.push(format!("forms {fmt}: {text:?} -> {read:?}"));
}
let (y, m, d) = want;
let got = scanned(fmt, text);
if got != format!("{y:04}-{m:02}-{d:02}") {
bad.push(format!("scand {fmt}: {text:?} -> {got}"));
}
}
// English is read as before, capitals and all.
use_form_date_language("en");
for (fmt, text) in [("d mmmm yyyy", "1 APRIL 2026"), ("dddd d mmmm yyyy", "FRIDAY 1 MAY 2026"), ("d mmm yyyy", "1 MAY 2026")] {
if parse_date(text, fmt).is_none() || scanned(fmt, text) == "null" {
bad.push(format!("en {fmt}: {text:?}"));
}
}
assert!(bad.is_empty(), "{}", bad.join("; "));
}
/// #5464 review: Greek names typed in capitals end in a capital Σ, which is the final ς of the
/// name ("ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος"); they read as they did before names were folded.
#[test]
fn greek_names_typed_in_capitals_are_read() {
let _names = names_lock();
use_form_date_language("el");
let mut bad = Vec::new();
for (fmt, text, want) in [
("d mmmm yyyy", "1 ΙΑΝΟΥΆΡΙΟΣ 2026", (2026, 1, 1)),
("d mmmm yyyy", "1 Ιανουάριος 2026", (2026, 1, 1)),
("d mmmm yyyy", "1 ιανουάριοσ 2026", (2026, 1, 1)),
("mmmm d, yyyy", "ΜΆΙΟΣ 5, 2026", (2026, 5, 5)),
("dddd, mmmm d, yyyy", "FRIDAY, ΙΑΝΟΥΆΡΙΟΣ 2, 2026", (2026, 1, 2)),
] {
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
if read != Some(want) {
bad.push(format!("forms {fmt}: {text:?} -> {read:?}"));
}
let (y, m, d) = want;
let got = scanned(fmt, text);
if got != format!("{y:04}-{m:02}-{d:02}") {
bad.push(format!("scand {fmt}: {text:?} -> {got}"));
}
}
assert!(bad.is_empty(), "{}", bad.join("; "));
}
+17 -3
View File
@@ -12,7 +12,14 @@ const WORD: [u32; 4] = [40, 40, 100, 60];
/// A 200 × 100 pt "scan" on tinted paper, with OCR text over its word.
fn scanned() -> (Session, pdfcraft_engine::DocId) {
scanned_with(vec![PlacedWord { text: "John".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0] }])
scanned_with(vec![PlacedWord {
text: "John".into(),
origin: [40.0, 40.0],
across: [60.0, 0.0],
up: [0.0, 20.0],
confidence: None,
source: "ocrs".into(),
}])
}
fn scanned_with(words: Vec<PlacedWord>) -> (Session, pdfcraft_engine::DocId) {
@@ -86,11 +93,18 @@ fn an_explicit_colour_is_kept_for_the_new_text() {
/// descender line when it has descenders, up to the cap height.
fn scanned_word(text: &str, x0: f64, x1: f64, baseline: f64, size: f64) -> PlacedWord {
let low = if text.chars().any(|c| "gjpqy".contains(c)) { 0.21 * size } else { 0.0 };
PlacedWord { text: text.into(), origin: [x0, baseline - low], across: [x1 - x0, 0.0], up: [0.0, 0.718 * size + low] }
PlacedWord {
text: text.into(),
origin: [x0, baseline - low],
across: [x1 - x0, 0.0],
up: [0.0, 0.718 * size + low],
confidence: None,
source: "ocrs".into(),
}
}
fn word(text: &str, x0: f64, x1: f64, baseline: f64) -> PlacedWord {
PlacedWord { text: text.into(), origin: [x0, baseline], across: [x1 - x0, 0.0], up: [0.0, 10.0] }
PlacedWord { text: text.into(), origin: [x0, baseline], across: [x1 - x0, 0.0], up: [0.0, 10.0], confidence: None, source: "ocrs".into() }
}
/// OCR words keep the scan's layout: a phrase is one paragraph, a value in the next table cell and