mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 03:54:35 +00:00
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:
commit
cf3ee49fbc
203 files changed
+30709
-1218
No files matched your search
@@ -197,14 +197,14 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec<String>) ->
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
// Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step
|
||||
// that didn't remove is: the file would be passed on as cleaned with the data still in it.
|
||||
// A step that finds nothing to do is not a failure. Any other error fails the file, so nothing is
|
||||
// published for it: the file would otherwise be passed on without the step it was asked to apply.
|
||||
let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize);
|
||||
match s.apply(id, edit) {
|
||||
Ok(()) => log.push(step.label().to_string()),
|
||||
Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())),
|
||||
Err(e) if removes => return Err(e),
|
||||
Err(e) => log.push(format!("{}: {e}", step.label())),
|
||||
Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) => log.push(format!("{}: nothing to do", step.label())),
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
||||
//! The UI dictionary format: one case-insensitive word per UTF-8 line.
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
pub const MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_WORDS: usize = 65536;
|
||||
|
||||
fn word(value: &str) -> Result<String, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
|
||||
return Err("dictionary words must be nonempty single lines of at most 1024 bytes".into());
|
||||
}
|
||||
let normalized = value.to_lowercase();
|
||||
if normalized.len() > 1024 {
|
||||
return Err("normalized dictionary words must be at most 1024 bytes".into());
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub fn words(text: &str) -> Result<BTreeSet<String>, String> {
|
||||
if text.len() > MAX_BYTES {
|
||||
return Err("OCR dictionary exceeds 4 MiB".into());
|
||||
}
|
||||
let mut words = BTreeSet::new();
|
||||
for line in text.lines().filter(|line| !line.trim().is_empty()) {
|
||||
words.insert(word(line)?);
|
||||
if words.len() > MAX_WORDS {
|
||||
return Err("OCR dictionary exceeds 65536 words".into());
|
||||
}
|
||||
}
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
/// Normalize, update and bound the complete dictionary before the caller writes it.
|
||||
pub fn update(text: &str, add: &[&str], remove: &[&str]) -> Result<String, String> {
|
||||
if add.len() > MAX_WORDS || remove.len() > MAX_WORDS {
|
||||
return Err("OCR dictionary exceeds 65536 words".into());
|
||||
}
|
||||
let mut entries = words(text)?;
|
||||
for value in remove {
|
||||
entries.remove(&word(value)?);
|
||||
}
|
||||
for value in add {
|
||||
entries.insert(word(value)?);
|
||||
if entries.len() > MAX_WORDS {
|
||||
return Err("OCR dictionary exceeds 65536 words".into());
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
for entry in entries {
|
||||
if out.len().checked_add(entry.len()).and_then(|n| n.checked_add(1)).is_none_or(|n| n > MAX_BYTES) {
|
||||
return Err("OCR dictionary exceeds 4 MiB".into());
|
||||
}
|
||||
out.push_str(&entry);
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dictionary_updates_round_trip_and_refuse_expanding_normalization() {
|
||||
let text = update("Quartz\n日本語\n", &["QUARTZ", "extra"], &["日本語"]).unwrap();
|
||||
assert_eq!(text, "extra\nquartz\n");
|
||||
assert_eq!(words(&text).unwrap(), BTreeSet::from(["extra".into(), "quartz".into()]));
|
||||
assert!(update("original\n", &[&"İ".repeat(512)], &[]).unwrap_err().contains("normalized"));
|
||||
assert!(update("original\n", &["two\nlines"], &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dictionary_limits_cover_existing_and_added_entries() {
|
||||
let full: String = (0..65536).map(|i| format!("word{i}\n")).collect();
|
||||
assert_eq!(words(&full).unwrap().len(), 65536);
|
||||
assert!(update(&full, &["extra"], &[]).is_err());
|
||||
assert_eq!(update(&full, &["extra"], &["word0"]).unwrap().lines().count(), 65536);
|
||||
assert!(words(&"a".repeat(MAX_BYTES + 1)).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Unicode search text has no visible glyphs. A generated Type 3 font supplies advance widths
|
||||
//! and a ToUnicode map, with empty character programs; no display font is needed or embedded.
|
||||
//! Adapted from the searchable-layer implementation in #433 (32e09fbe).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
use pdfcraft_edit::EditError;
|
||||
use pdfcraft_ocr::PlacedWord;
|
||||
|
||||
/// Refuse oversized text or invalid geometry before cloning words or allocating fonts.
|
||||
/// These limits apply to the complete input, including all editable export pages.
|
||||
pub(crate) fn validate_words<'a>(words: impl IntoIterator<Item = &'a PlacedWord>) -> Result<usize, EditError> {
|
||||
let mut count = 0usize;
|
||||
let mut length = 0usize;
|
||||
for w in words {
|
||||
count += 1;
|
||||
length = length.checked_add(w.text.len()).ok_or_else(|| EditError::Invalid("OCR text layer is too large".into()))?;
|
||||
if count > 65536 || length > 4 * 1024 * 1024 {
|
||||
return Err(EditError::Invalid("OCR text layer is too large".into()));
|
||||
}
|
||||
if !w.origin.iter().chain(&w.across).chain(&w.up).all(|v| v.is_finite())
|
||||
|| !w.across[0].hypot(w.across[1]).is_finite()
|
||||
|| !w.up[0].hypot(w.up[1]).is_finite()
|
||||
|| (0..2).any(|i| !(w.origin[i] + 0.2 * w.up[i]).is_finite() || !(w.origin[i] + w.across[i] + 0.2 * w.up[i]).is_finite())
|
||||
{
|
||||
return Err(EditError::Invalid("OCR text coordinates must be finite".into()));
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub(crate) fn add_text(doc: &mut Document, page: usize, words: &[PlacedWord]) -> Result<(), EditError> {
|
||||
validate_words(words)?;
|
||||
// Keep the existing Latin layout, including Helvetica's proportional advance widths.
|
||||
if words.iter().all(|w| {
|
||||
w.text
|
||||
.chars()
|
||||
.all(|c| matches!(c, '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' | '€' | '‚' | '„' | '…' | '‘' | '’' | '“' | '”' | '•' | '–' | '—' | '™'))
|
||||
}) {
|
||||
pdfcraft_edit::stamp(doc, page, "OCR", pdfcraft_ocr::text_layer(words, &pdfcraft_fonts::TextPlan::default()))?;
|
||||
return Ok(());
|
||||
}
|
||||
let p = pdfcraft_model::pages(doc).get(page).cloned().ok_or_else(|| EditError::Invalid("OCR page does not exist".into()))?;
|
||||
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
|
||||
let chars: Vec<char> = words.iter().flat_map(|w| w.text.chars()).collect::<std::collections::BTreeSet<_>>().into_iter().collect();
|
||||
// Match the extractor's em box (-200..800). Baselines below are offset by 0.2 em,
|
||||
// so search/selection rectangles span the detected image box.
|
||||
let blank = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"1000 0 0 -200 1000 800 d1\n".to_vec())));
|
||||
let mut codes = BTreeMap::new();
|
||||
let mut names = Vec::new();
|
||||
for (index, chunk) in chars.chunks(255).enumerate() {
|
||||
let mut name = format!("PCOcr{index}");
|
||||
while fonts.contains(name.as_bytes()) {
|
||||
name.push('x');
|
||||
}
|
||||
let mut differences = vec![Object::Int(1)];
|
||||
let mut procs = Dict::new();
|
||||
let mut cmap = String::from(
|
||||
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (PdfCraft) /Ordering (Unicode) /Supplement 0 >> def\n/CMapName /PdfCraftOCR def\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
|
||||
);
|
||||
for (batch, part) in chunk.chunks(100).enumerate() {
|
||||
cmap.push_str(&format!("{} beginbfchar\n", part.len()));
|
||||
for (i, ch) in part.iter().enumerate() {
|
||||
let code = (batch * 100 + i + 1) as u8;
|
||||
let glyph = format!("g{code:02X}");
|
||||
differences.push(Object::name(&glyph));
|
||||
procs.set(glyph.into_bytes(), Object::Ref(blank));
|
||||
let mut units = [0u16; 2];
|
||||
let hex: String = ch.encode_utf16(&mut units).iter().map(|u| format!("{u:04X}")).collect();
|
||||
cmap.push_str(&format!("<{code:02X}> <{hex}>\n"));
|
||||
codes.insert(*ch, (index, code));
|
||||
}
|
||||
cmap.push_str("endbfchar\n");
|
||||
}
|
||||
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
|
||||
let map = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
|
||||
let mut encoding = Dict::new();
|
||||
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
|
||||
encoding.set(b"Differences".to_vec(), Object::Array(differences));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type3"));
|
||||
font.set(b"FontBBox".to_vec(), Object::Array(vec![0.into(), (-200).into(), 1000.into(), 800.into()]));
|
||||
font.set(b"FontMatrix".to_vec(), Object::Array(vec![Object::Real(0.001), 0.into(), 0.into(), Object::Real(0.001), 0.into(), 0.into()]));
|
||||
font.set(b"FirstChar".to_vec(), Object::Int(1));
|
||||
font.set(b"LastChar".to_vec(), Object::Int(chunk.len() as i64));
|
||||
font.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(1000); chunk.len()]));
|
||||
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
|
||||
font.set(b"CharProcs".to_vec(), Object::Dict(procs));
|
||||
font.set(b"Resources".to_vec(), Object::Dict(Dict::new()));
|
||||
font.set(b"ToUnicode".to_vec(), Object::Ref(map));
|
||||
fonts.set(name.as_bytes().to_vec(), Object::Ref(doc.add(Object::Dict(font))));
|
||||
names.push(name);
|
||||
}
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
// `3 Tr` and the font are graphics state, which `ET` doesn't reset: like
|
||||
// `pdfcraft_ocr::text_layer` (#3029), the text is drawn inside `q … Q` so the invisible
|
||||
// rendering mode cannot leak into later content streams on the page.
|
||||
let mut content = String::from("/OCR BMC\nq\nBT\n3 Tr\n");
|
||||
for w in words {
|
||||
let count = w.text.chars().count();
|
||||
if count == 0 || w.up[0].hypot(w.up[1]) <= 0.0 || w.across[0].hypot(w.across[1]) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut start = 0;
|
||||
let mut run = Vec::new();
|
||||
let mut font_index = None;
|
||||
let emit = |content: &mut String, font: usize, start: usize, run: &[u8]| {
|
||||
let n = count as f64;
|
||||
let at = start as f64 / n;
|
||||
let hex: String = run.iter().map(|b| format!("{b:02X}")).collect();
|
||||
content.push_str(&format!(
|
||||
"/{} 1 Tf\n{} {} {} {} {} {} Tm <{hex}> Tj\n",
|
||||
names[font],
|
||||
w.across[0] / n,
|
||||
w.across[1] / n,
|
||||
w.up[0],
|
||||
w.up[1],
|
||||
w.origin[0] + at * w.across[0] + 0.2 * w.up[0],
|
||||
w.origin[1] + at * w.across[1] + 0.2 * w.up[1]
|
||||
));
|
||||
};
|
||||
for (i, ch) in w.text.chars().enumerate() {
|
||||
let Some(&(font, code)) = codes.get(&ch) else { continue };
|
||||
if let Some(previous) = font_index
|
||||
&& previous != font
|
||||
{
|
||||
emit(&mut content, previous, start, &run);
|
||||
run.clear();
|
||||
start = i;
|
||||
}
|
||||
font_index = Some(font);
|
||||
run.push(code);
|
||||
}
|
||||
if let Some(font) = font_index {
|
||||
emit(&mut content, font, start, &run);
|
||||
}
|
||||
}
|
||||
content.push_str("ET\nQ\nEMC\n");
|
||||
pdfcraft_edit::stamp(doc, page, "OCR", content.into_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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 < 50000; i++) this.rawValue = i;</script></calculate>"#,
|
||||
r#"<calculate><script contentType="application/x-javascript">for (var i = 0; i < 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";
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
//! Form date fields name months in the app's date language (#5303). Its own test binary: the
|
||||
//! names are process-wide, so other tests must not see them change, and its tests take turns.
|
||||
|
||||
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
|
||||
|
||||
use pdfcraft_cos::{Document, Object};
|
||||
use pdfcraft_engine::dates::use_form_date_language;
|
||||
use pdfcraft_engine::js::JsRunner;
|
||||
use pdfcraft_forms::FieldValue;
|
||||
use pdfcraft_forms::af::{DateTime, Format, format_date, format_value, keystroke, parse_date};
|
||||
use pdfcraft_js::{DocInfo, Event, Limits, run};
|
||||
|
||||
/// Held by each test while it changes the process-wide date names, which start as English (a
|
||||
/// failed test may have left others).
|
||||
fn names_lock() -> MutexGuard<'static, ()> {
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
let lock = LOCK.lock().unwrap_or_else(PoisonError::into_inner);
|
||||
use_form_date_language("en");
|
||||
lock
|
||||
}
|
||||
|
||||
/// `util.printd(fmt)` of 18 October 2026, then of what `util.scand(fmt, typed)` reads.
|
||||
fn printd(fmt: &str, typed: &str) -> String {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
|
||||
let script = format!("event.value = util.printd('{fmt}', new Date(2026, 9, 18)) + '|' + util.printd('{fmt}', util.scand('{fmt}', '{typed}'));");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
o.value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_dates_follow_the_date_language() {
|
||||
let _names = names_lock();
|
||||
let oct18 = DateTime { y: 2026, m: 10, d: 18, hh: 0, mm: 0, ss: 0 };
|
||||
let dd_mmm_yy = Format::Date("dd-mmm-yy".into());
|
||||
// English until the app says otherwise.
|
||||
assert_eq!(format_date(oct18, "dd-mmm-yy dddd ddd mmmm"), "18-Oct-26 Sunday Sun October");
|
||||
use_form_date_language("it");
|
||||
// AFDate_FormatEx("dd-mmm-yy"), what the issue's form used.
|
||||
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-ott-26");
|
||||
assert_eq!(format_date(oct18, "dddd d mmmm yyyy, ddd"), "domenica 18 ottobre 2026, dom");
|
||||
// What it writes reads back, and so does English typing.
|
||||
assert_eq!(parse_date("18-ott-26", "dd-mmm-yy"), Some(oct18));
|
||||
assert_eq!(parse_date("18 ottobre 2026", "dd mmmm yyyy"), Some(oct18));
|
||||
assert_eq!(parse_date("18-Oct-26", "dd-mmm-yy"), Some(oct18));
|
||||
assert!(keystroke(&dd_mmm_yy, "date", "18-ott-26").is_ok());
|
||||
// util.printd writes Italian names; util.scand reads them.
|
||||
assert_eq!(printd("dd-mmm-yy", "18-ott-26"), "18-ott-26|18-ott-26");
|
||||
assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-ott-26|18-ott-26", "English typing reads too");
|
||||
assert_eq!(printd("dddd d mmmm yyyy", "domenica 18 ottobre 2026"), "domenica 18 ottobre 2026|domenica 18 ottobre 2026");
|
||||
// German short names end in a dot; they are written and read whole.
|
||||
use_form_date_language("de");
|
||||
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Okt.-26");
|
||||
assert_eq!(parse_date("18-Okt.-26", "dd-mmm-yy"), Some(oct18));
|
||||
// An unknown language is English again.
|
||||
use_form_date_language("xx");
|
||||
assert_eq!(format_value(&dd_mmm_yy, "18-10-26"), "18-Oct-26");
|
||||
assert_eq!(printd("dd-mmm-yy", "18-Oct-26"), "18-Oct-26|18-Oct-26");
|
||||
every_date_language_reads_back_what_it_writes();
|
||||
// Hostile typing never panics and stays quick: long, odd and mixed-script text.
|
||||
use_form_date_language("th");
|
||||
let junk = ["", " ", "ม.", "ม.ค.ม.ค.ม.ค.", "\u{0}", "1月1月1月", "tháng tháng", "Ottobre", "🙂ott🙂", "İstanbul ott 26"];
|
||||
for t in junk.iter().map(|s| s.to_string()).chain(["ม.ค.1".repeat(5000), "9".repeat(100_000)]) {
|
||||
let started = std::time::Instant::now();
|
||||
let _ = parse_date(&t, "dd-mmm-yy");
|
||||
let _ = keystroke(&dd_mmm_yy, "date", &t);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2), "{} chars took {:?}", t.len(), started.elapsed());
|
||||
}
|
||||
use_form_date_language("en");
|
||||
}
|
||||
|
||||
/// An English weekday typed before the month fills the format's weekday, so a month name that
|
||||
/// the date language shares with a weekday (Italian, Spanish, Romanian and Albanian "mar") is
|
||||
/// still read as the month: "Tue 17 Mar 2026" read as "Tue 17 Tuesday 2026" (#5303).
|
||||
#[test]
|
||||
fn an_english_weekday_before_a_shared_month_name_reads_as_english() {
|
||||
let _names = names_lock();
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
|
||||
let mar17 = DateTime { y: 2026, m: 3, d: 17, hh: 0, mm: 0, ss: 0 };
|
||||
let mut bad = Vec::new();
|
||||
for language in ["it", "es", "ro", "sq"] {
|
||||
use_form_date_language(language);
|
||||
let ddd_d_mmm = "ddd d mmm yyyy";
|
||||
let typed = [
|
||||
// What the language writes, then English, then English and localized mixed.
|
||||
(ddd_d_mmm, format_date(mar17, ddd_d_mmm)),
|
||||
(ddd_d_mmm, "Tue 17 Mar 2026".into()),
|
||||
(ddd_d_mmm, "Tuesday 17 Mar 2026".into()),
|
||||
(ddd_d_mmm, "tue 17 mar 2026".into()),
|
||||
(ddd_d_mmm, format!("Tue 17 {}", format_date(mar17, "mmm yyyy"))),
|
||||
(ddd_d_mmm, format!("{} 17 Mar 2026", format_date(mar17, "ddd"))),
|
||||
("mmm d yyyy ddd", "Mar 17 2026 Tue".into()),
|
||||
("dddd d mmmm yyyy", "Tuesday 17 March 2026".into()),
|
||||
];
|
||||
for (fmt, text) in typed {
|
||||
if parse_date(&text, fmt) != Some(mar17) {
|
||||
bad.push(format!("{language} forms {fmt}: {text:?} -> {:?}", parse_date(&text, fmt)));
|
||||
}
|
||||
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
if o.value != "2026-03-17" {
|
||||
bad.push(format!("{language} scand {fmt}: {text:?} -> {:?} {:?}", o.value, o.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
use_form_date_language("en");
|
||||
assert!(bad.is_empty(), "{}", bad.join("; "));
|
||||
}
|
||||
|
||||
/// Every date language: what a date field writes reads back as the same date, for each date
|
||||
/// letter that writes a name, through the form path and through `util.scand`. Called from the
|
||||
/// one test, which owns the process-wide names.
|
||||
fn every_date_language_reads_back_what_it_writes() {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
|
||||
let mut bad = Vec::new();
|
||||
for l in pdfcraft_engine::dates::DATE_LANGUAGES.iter() {
|
||||
use_form_date_language(l.code);
|
||||
for m in 1..=12u32 {
|
||||
let dt = DateTime { y: 2026, m, d: 18, hh: 0, mm: 0, ss: 0 };
|
||||
for fmt in ["dd-mmm-yy", "d mmmm yyyy", "dddd, mmmm d, yyyy", "ddd d mmm yyyy", "mmm d, yyyy ddd", "yyyymmmdd"] {
|
||||
let text = format_date(dt, fmt);
|
||||
// A short month ending in a number ("Thg 1") glued to the day is ambiguous
|
||||
// ("Thg 118": January 18 or November 8): nothing can read it back.
|
||||
if fmt == "yyyymmmdd" && l.months_short.iter().any(|n| n.ends_with(|c: char| c.is_ascii_digit())) {
|
||||
continue;
|
||||
}
|
||||
if parse_date(&text, fmt).map(|p| (p.y, p.m, p.d)) != Some((2026, m, 18)) {
|
||||
bad.push(format!("{} forms {fmt}: {text:?}", l.code));
|
||||
}
|
||||
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
if o.value != format!("2026-{m:02}-18") {
|
||||
bad.push(format!("{} scand {fmt}: {text:?} -> {:?} {:?}", l.code, o.value, o.error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
use_form_date_language("en");
|
||||
assert!(bad.is_empty(), "{} failures:\n{}", bad.len(), bad.iter().take(40).cloned().collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
|
||||
/// A form whose text fields are each `(name, font, format script)`: `/Helv` is Helvetica with
|
||||
/// WinAnsi, `/Song` a Chinese font with a Unicode CMap, which also holds Cyrillic.
|
||||
fn form(fields: &[(&str, &str, &str)]) -> Document {
|
||||
let n = fields.len();
|
||||
let first = 6;
|
||||
let refs = |from: usize| (from..from + n).map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" ");
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", refs(first)),
|
||||
format!("<< /Fields [{}] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /Song {} 0 R >> >> >>", refs(first), first + n),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
];
|
||||
for (i, (name, font, js)) in fields.iter().enumerate() {
|
||||
let js = js.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
|
||||
let y = 700 - 30 * i;
|
||||
objs.push(format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /DA (/{font} 10 Tf 0 g) /Rect [50 {y} 300 {}] /P 3 0 R /AA << /F << /S /JavaScript /JS ({js}) >> >> >>",
|
||||
y + 20
|
||||
));
|
||||
}
|
||||
objs.push("<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into());
|
||||
let mut out = b"%PDF-1.7
|
||||
"
|
||||
.to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"{} 0 obj
|
||||
{o}
|
||||
endobj
|
||||
",
|
||||
i + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"xref
|
||||
0 {}
|
||||
0000000000 65535 f
|
||||
",
|
||||
objs.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
for o in offsets {
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"{o:010} 00000 n
|
||||
"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
}
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"trailer
|
||||
<< /Size {} /Root 1 0 R >>
|
||||
startxref
|
||||
{xref}
|
||||
%%EOF
|
||||
",
|
||||
objs.len() + 1
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
Document::open(Arc::new(out)).expect("opens")
|
||||
}
|
||||
|
||||
/// The content of field `name`'s normal appearance.
|
||||
fn appearance(doc: &Document, name: &str) -> Vec<u8> {
|
||||
let all = pdfcraft_forms::fields(doc);
|
||||
let f = all.iter().find(|f| f.name == name).expect("field");
|
||||
let widget = doc.get(f.widgets[0].obj);
|
||||
let normal = widget.as_dict().and_then(|d| d.get(b"AP")).and_then(|a| doc.resolve(a).as_dict().and_then(|a| a.reference(b"N"))).expect("/AP /N");
|
||||
match &*doc.get(normal) {
|
||||
Object::Stream(s) => s.decoded().expect("decodes"),
|
||||
other => panic!("not a stream: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a Unicode-CMap (UCS-2) font is given for `text`.
|
||||
fn ucs2(text: &str) -> Vec<u8> {
|
||||
text.encode_utf16().flat_map(u16::to_be_bytes).collect()
|
||||
}
|
||||
|
||||
fn contains(hay: &[u8], needle: &[u8]) -> bool {
|
||||
hay.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Fills `form`'s field `name` with 18 October 2026 through the JavaScript runner.
|
||||
fn fill(doc: &mut Document, name: &str) {
|
||||
fill_with(doc, name, "18-10-26");
|
||||
}
|
||||
|
||||
/// Fills `form`'s field `name` with `value` through the JavaScript runner.
|
||||
fn fill_with(doc: &mut Document, name: &str, value: &str) {
|
||||
let mut js = JsRunner::new(doc, "form.pdf");
|
||||
pdfcraft_forms::set_value_with(doc, name, &FieldValue::Text(value.into()), &mut js).expect("fills");
|
||||
}
|
||||
|
||||
/// Month names in the date language that no font of the field can draw are drawn in English,
|
||||
/// never as `?` ("18-????.-26" in Russian); a font that holds them keeps them (#5303).
|
||||
#[test]
|
||||
fn date_names_no_field_font_can_draw_are_drawn_in_english() {
|
||||
let _names = names_lock();
|
||||
let date = "AFDate_FormatEx(\"dd-mmm-yy\");";
|
||||
let fields = [("helv", "Helv", date), ("song", "Song", date)];
|
||||
for (language, local) in [("ru", "окт."), ("cs", "říj"), ("ja", "10月"), ("el", "Οκτ")] {
|
||||
use_form_date_language(language);
|
||||
let mut doc = form(&fields);
|
||||
fill(&mut doc, "helv");
|
||||
fill(&mut doc, "song");
|
||||
let helv = appearance(&doc, "helv");
|
||||
assert!(contains(&helv, b"(18-Oct-26) Tj"), "{language}: {}", String::from_utf8_lossy(&helv));
|
||||
assert!(!helv.contains(&b'?'), "{language}: {}", String::from_utf8_lossy(&helv));
|
||||
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty(), "{language}");
|
||||
let song = appearance(&doc, "song");
|
||||
assert!(contains(&song, &ucs2(&format!("18-{local}-26"))), "{language} keeps its name: {song:?}");
|
||||
}
|
||||
// Names Helvetica holds are kept: Italian "ott".
|
||||
use_form_date_language("it");
|
||||
let mut doc = form(&fields);
|
||||
fill(&mut doc, "helv");
|
||||
assert!(contains(&appearance(&doc, "helv"), b"(18-ott-26) Tj"));
|
||||
use_form_date_language("en");
|
||||
}
|
||||
|
||||
/// A Format script's `util.printd` is run again with English names for a field whose fonts
|
||||
/// can't draw the date language's, and kept where they can (#5303).
|
||||
#[test]
|
||||
fn format_scripts_print_dates_in_english_where_the_field_cannot_draw_them() {
|
||||
let _names = names_lock();
|
||||
let script = "event.value = event.value ? util.printd(\"dd-mmm-yy\", util.scand(\"dd-mm-yy\", event.value)) + \" !\" : \"\";";
|
||||
let fields = [("helv", "Helv", script), ("song", "Song", script)];
|
||||
use_form_date_language("ru");
|
||||
let mut doc = form(&fields);
|
||||
fill(&mut doc, "helv");
|
||||
fill(&mut doc, "song");
|
||||
let helv = appearance(&doc, "helv");
|
||||
assert!(contains(&helv, b"(18-Oct-26 !) Tj"), "{}", String::from_utf8_lossy(&helv));
|
||||
assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv));
|
||||
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty());
|
||||
assert!(contains(&appearance(&doc, "song"), &ucs2("18-окт.-26 !")));
|
||||
// A script whose text has no English either keeps it, reported as undrawn.
|
||||
let greeting = [("hi", "Helv", "event.value = \"Привет \" + util.printd(\"mmm\", new Date(2026, 9, 18));")];
|
||||
let mut doc = form(&greeting);
|
||||
fill(&mut doc, "hi");
|
||||
assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("hi".to_string(), 'П')]);
|
||||
// The English run is the appearance's alone: scripts go on printing the date language.
|
||||
assert_eq!(printd("dd-mmm-yy", "18-окт.-26"), "18-окт.-26|18-окт.-26");
|
||||
use_form_date_language("en");
|
||||
}
|
||||
|
||||
/// A Format script's English run is kept when it ran, even where it prints the stored value
|
||||
/// again: a Helvetica field storing "18-Oct-26", printed again as `dd-mmm-yy`, showed
|
||||
/// "18-????.-26" in Russian. A script that fails keeps what it showed, reported undrawn (#5303).
|
||||
#[test]
|
||||
fn an_english_run_that_prints_the_stored_value_is_drawn() {
|
||||
let _names = names_lock();
|
||||
let script = "event.value = util.printd(\"dd-mmm-yy\", util.scand(\"dd-mmm-yy\", event.value));";
|
||||
use_form_date_language("ru");
|
||||
let mut doc = form(&[("helv", "Helv", script), ("fails", "Helv", "throw new Error(\"no\");")]);
|
||||
fill_with(&mut doc, "helv", "18-Oct-26");
|
||||
let helv = appearance(&doc, "helv");
|
||||
assert!(contains(&helv, b"(18-Oct-26) Tj"), "{}", String::from_utf8_lossy(&helv));
|
||||
assert!(!helv.contains(&b'?'), "{}", String::from_utf8_lossy(&helv));
|
||||
assert!(pdfcraft_forms::undrawn_fields(&doc).is_empty());
|
||||
fill_with(&mut doc, "fails", "18-окт.-26");
|
||||
assert_eq!(pdfcraft_forms::undrawn_fields(&doc), [("fails".to_string(), 'о')]);
|
||||
use_form_date_language("en");
|
||||
}
|
||||
|
||||
/// What `util.scand(fmt, text)` reads, as `yyyy-mm-dd`, or `null`.
|
||||
fn scanned(fmt: &str, text: &str) -> String {
|
||||
let doc = DocInfo { file_name: "form.pdf".into(), num_pages: 1, page: 0, info: Vec::new(), xfa: false };
|
||||
let script = format!("var d = util.scand('{fmt}', {text:?}); event.value = d ? util.printd('yyyy-mm-dd', d) : 'null';");
|
||||
let o = run(&script, &Event::field("Format", "date", ""), &doc, &[], &[], Limits::default());
|
||||
assert_eq!(o.error, None);
|
||||
o.value
|
||||
}
|
||||
|
||||
/// A short weekday typed without its dot ("mar" for French "mar.", Tuesday) is the weekday where
|
||||
/// the format has one, not the English "Mar" (March) that is the only other name of its length:
|
||||
/// "mar 6 janv 2026" read as no date in French and Romanian (#5303).
|
||||
#[test]
|
||||
fn a_short_weekday_typed_without_its_dot_is_the_weekday() {
|
||||
let _names = names_lock();
|
||||
let mut bad = Vec::new();
|
||||
let cases = [
|
||||
("fr", "ddd d mmm yyyy", "Mar 6 janv 2026", "2026-01-06"),
|
||||
("fr", "ddd d mmm yyyy", "mar 17 mars 2026", "2026-03-17"),
|
||||
("fr", "mmm d yyyy ddd", "janv 6 2026 mar", "2026-01-06"),
|
||||
("fr", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"),
|
||||
("ro", "ddd d mmm yyyy", "mar 6 ian 2026", "2026-01-06"),
|
||||
("ro", "ddd dd/mm/yyyy", "mar 06/01/2026", "2026-01-06"),
|
||||
];
|
||||
for (language, fmt, text, want) in cases {
|
||||
use_form_date_language(language);
|
||||
let got = scanned(fmt, text);
|
||||
if got != want {
|
||||
bad.push(format!("{language} scand {fmt}: {text:?} -> {got}"));
|
||||
}
|
||||
let read = parse_date(text, fmt).map(|d| format!("{:04}-{:02}-{:02}", d.y, d.m, d.d));
|
||||
if read.as_deref() != Some(want) {
|
||||
bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}"));
|
||||
}
|
||||
}
|
||||
use_form_date_language("en");
|
||||
assert!(bad.is_empty(), "{}", bad.join("; "));
|
||||
}
|
||||
|
||||
/// A weekday left out of a format with one ("17 mar 2026" for "ddd d mmm yyyy") lets a name the
|
||||
/// date language shares between a weekday and a month be the month: Italian, Spanish and
|
||||
/// Albanian "mar" (Tuesday, March) and Albanian "sht" (Saturday, September) were read as the
|
||||
/// weekday, leaving no month (#5303). Typed weekdays still go first.
|
||||
#[test]
|
||||
fn a_shared_name_is_the_month_when_the_weekday_is_left_out() {
|
||||
let _names = names_lock();
|
||||
let mut bad = Vec::new();
|
||||
let cases = [
|
||||
("it", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
|
||||
("es", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
|
||||
("sq", "ddd d mmm yyyy", "17 Mar 2026", (2026, 3, 17)),
|
||||
("it", "ddd d mmm yyyy", "17 mar 2026", (2026, 3, 17)),
|
||||
("it", "dddd, mmmm d, yyyy", "mar 17, 2026", (2026, 3, 17)),
|
||||
("it", "dddd, mmm d, yyyy", "mar 17 2026", (2026, 3, 17)),
|
||||
("sq", "ddd d mmm yyyy", "5 sht 2026", (2026, 9, 5)),
|
||||
("it", "ddd d mmm yyyy", "mar 17 Mar 2026", (2026, 3, 17)),
|
||||
("it", "ddd d mmm yyyy", "mar 17 mar 2026", (2026, 3, 17)),
|
||||
];
|
||||
for (language, fmt, text, want) in cases {
|
||||
use_form_date_language(language);
|
||||
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
|
||||
if read != Some(want) {
|
||||
bad.push(format!("{language} forms {fmt}: {text:?} -> {read:?}"));
|
||||
}
|
||||
if keystroke(&Format::Date(fmt.into()), "date", text).is_err() {
|
||||
bad.push(format!("{language} keystroke {fmt}: {text:?} refused"));
|
||||
}
|
||||
}
|
||||
use_form_date_language("en");
|
||||
assert!(bad.is_empty(), "{}", bad.join("; "));
|
||||
}
|
||||
|
||||
/// Turkish names typed in capitals are read: lower-casing letter by letter made "MAYIS" "mayis"
|
||||
/// (not "mayıs"), "SALI" "sali" and "EKİM" "eki̇m" (an i with a dot above), so no name matched.
|
||||
/// Dotted, dotless and capital i are one letter when names are read (#5303).
|
||||
#[test]
|
||||
fn turkish_names_typed_in_capitals_are_read() {
|
||||
let _names = names_lock();
|
||||
use_form_date_language("tr");
|
||||
let mut bad = Vec::new();
|
||||
for (fmt, text, want) in [
|
||||
("d mmmm yyyy", "1 MAYIS 2026", (2026, 5, 1)),
|
||||
("d mmmm yyyy", "1 EKİM 2026", (2026, 10, 1)),
|
||||
("d mmmm yyyy", "1 Ekim 2026", (2026, 10, 1)),
|
||||
("d mmm yyyy", "1 EKİ 2026", (2026, 10, 1)),
|
||||
("dddd d mmmm yyyy", "SALI 6 OCAK 2026", (2026, 1, 6)),
|
||||
("dddd d mmmm yyyy", "Salı 6 Ocak 2026", (2026, 1, 6)),
|
||||
] {
|
||||
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
|
||||
if read != Some(want) {
|
||||
bad.push(format!("forms {fmt}: {text:?} -> {read:?}"));
|
||||
}
|
||||
let (y, m, d) = want;
|
||||
let got = scanned(fmt, text);
|
||||
if got != format!("{y:04}-{m:02}-{d:02}") {
|
||||
bad.push(format!("scand {fmt}: {text:?} -> {got}"));
|
||||
}
|
||||
}
|
||||
// English is read as before, capitals and all.
|
||||
use_form_date_language("en");
|
||||
for (fmt, text) in [("d mmmm yyyy", "1 APRIL 2026"), ("dddd d mmmm yyyy", "FRIDAY 1 MAY 2026"), ("d mmm yyyy", "1 MAY 2026")] {
|
||||
if parse_date(text, fmt).is_none() || scanned(fmt, text) == "null" {
|
||||
bad.push(format!("en {fmt}: {text:?}"));
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{}", bad.join("; "));
|
||||
}
|
||||
|
||||
/// #5464 review: Greek names typed in capitals end in a capital Σ, which is the final ς of the
|
||||
/// name ("ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος"); they read as they did before names were folded.
|
||||
#[test]
|
||||
fn greek_names_typed_in_capitals_are_read() {
|
||||
let _names = names_lock();
|
||||
use_form_date_language("el");
|
||||
let mut bad = Vec::new();
|
||||
for (fmt, text, want) in [
|
||||
("d mmmm yyyy", "1 ΙΑΝΟΥΆΡΙΟΣ 2026", (2026, 1, 1)),
|
||||
("d mmmm yyyy", "1 Ιανουάριος 2026", (2026, 1, 1)),
|
||||
("d mmmm yyyy", "1 ιανουάριοσ 2026", (2026, 1, 1)),
|
||||
("mmmm d, yyyy", "ΜΆΙΟΣ 5, 2026", (2026, 5, 5)),
|
||||
("dddd, mmmm d, yyyy", "FRIDAY, ΙΑΝΟΥΆΡΙΟΣ 2, 2026", (2026, 1, 2)),
|
||||
] {
|
||||
let read = parse_date(text, fmt).map(|d| (d.y, d.m, d.d));
|
||||
if read != Some(want) {
|
||||
bad.push(format!("forms {fmt}: {text:?} -> {read:?}"));
|
||||
}
|
||||
let (y, m, d) = want;
|
||||
let got = scanned(fmt, text);
|
||||
if got != format!("{y:04}-{m:02}-{d:02}") {
|
||||
bad.push(format!("scand {fmt}: {text:?} -> {got}"));
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "{}", bad.join("; "));
|
||||
}
|
||||
@@ -12,7 +12,14 @@ const WORD: [u32; 4] = [40, 40, 100, 60];
|
||||
|
||||
/// A 200 × 100 pt "scan" on tinted paper, with OCR text over its word.
|
||||
fn scanned() -> (Session, pdfcraft_engine::DocId) {
|
||||
scanned_with(vec![PlacedWord { text: "John".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0] }])
|
||||
scanned_with(vec![PlacedWord {
|
||||
text: "John".into(),
|
||||
origin: [40.0, 40.0],
|
||||
across: [60.0, 0.0],
|
||||
up: [0.0, 20.0],
|
||||
confidence: None,
|
||||
source: "ocrs".into(),
|
||||
}])
|
||||
}
|
||||
|
||||
fn scanned_with(words: Vec<PlacedWord>) -> (Session, pdfcraft_engine::DocId) {
|
||||
@@ -86,11 +93,18 @@ fn an_explicit_colour_is_kept_for_the_new_text() {
|
||||
/// descender line when it has descenders, up to the cap height.
|
||||
fn scanned_word(text: &str, x0: f64, x1: f64, baseline: f64, size: f64) -> PlacedWord {
|
||||
let low = if text.chars().any(|c| "gjpqy".contains(c)) { 0.21 * size } else { 0.0 };
|
||||
PlacedWord { text: text.into(), origin: [x0, baseline - low], across: [x1 - x0, 0.0], up: [0.0, 0.718 * size + low] }
|
||||
PlacedWord {
|
||||
text: text.into(),
|
||||
origin: [x0, baseline - low],
|
||||
across: [x1 - x0, 0.0],
|
||||
up: [0.0, 0.718 * size + low],
|
||||
confidence: None,
|
||||
source: "ocrs".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn word(text: &str, x0: f64, x1: f64, baseline: f64) -> PlacedWord {
|
||||
PlacedWord { text: text.into(), origin: [x0, baseline], across: [x1 - x0, 0.0], up: [0.0, 10.0] }
|
||||
PlacedWord { text: text.into(), origin: [x0, baseline], across: [x1 - x0, 0.0], up: [0.0, 10.0], confidence: None, source: "ocrs".into() }
|
||||
}
|
||||
|
||||
/// OCR words keep the scan's layout: a phrase is one paragraph, a value in the next table cell and
|
||||
|
||||
Reference in new issue
Block a user