mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
Merge remote-tracking branch 'origin/main' into refactor/page-press-edit-text
# Conflicts: # crates/ui-egui/src/canvas.rs # crates/ui-egui/src/canvas/page_input.rs
This commit is contained in:
63 files changed
+3831
-313
No files matched your search
@@ -306,6 +306,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged.
|
||||
- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged.
|
||||
- **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged.
|
||||
- **2026-10-24 (M5, part opacity):** Comment Properties sets a text box's text, border and fill, and a shape's line and fill, each at an opacity of its own (a see-through fill with an opaque line on a plan); stored as /CA, /ca (PDF 2.0) and /PCTextOpacity and drawn with separate graphics states, so other viewers show it. Make Properties Default and the settings carry it; comment_add / comment_edit `fill_opacity`, `text_opacity`. Four new labels in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
|
||||
- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
|
||||
|
||||
@@ -348,6 +349,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
- **2026-10-20 (M3, #3541/#3620):** Comment hover follows the individual marked lines instead of the enclosing multiline rectangle, and overlapping comments follow paint order. Synthetic UI regressions cover all four text-markup kinds, page/view rotations, neighboring highlights, legacy rectangle fallback and unchanged PDF bytes. Estimates unchanged.
|
||||
- **2026-10-20 (M10, #3532 partial):** Vector printing retains existing widget artwork when its appearance lacks Subtype. A print-owned copy adds Form dispatch metadata. The source stream and all explicit subtypes stay unchanged. Synthetic source/output raster checks cover resources, selected/shared appearances, placement, flags and content modes. Appearance regeneration and physical printer validation remain outside this fix. Estimates unchanged.
|
||||
- **2026-10-20 (M5, several comments):** Comment Properties opened on one of several selected comments, the context menu's Colour and the quick bar's colour change all of them as one undo step, each where the property applies (a font size to text boxes, a fill to shapes, a width to lines and shapes); one that stays locked keeps its look. Copied Fill & Sign ticks, crosses, dots and lines hang on the pointer as themselves rather than as empty boxes, and copied text boxes show their text (`FillMark::strokes` is shared by the appearance and the outline). Estimates unchanged.
|
||||
- **2026-10-19 (M5, comments between documents):** Comments copy and paste, within and between documents: ⌘C with comments selected copies them as XFDF (each with its replies) to the app's and the system clipboard, ⌘V / Edit ▸ Paste comments / the page menu pastes them onto the current page as new comments under names of their own (`Edit::PasteComments`, `pdfcraft_xfdf::import_comments` with a target page and fresh names, so nothing is replaced and pasting twice gives two sets; `export_xfdf_comments` writes a selection with its replies). Several comments select at once (⌘-click, ⇧-click, on the page and in the panel); Delete removes them as one step and a drag moves them together. Connected lines, polygons and clouds finish from the right-click menu's Complete (Undo last point, Cancel drawing beside it), as other editors do. Tools `comments_copy` / `comments_paste`. Estimates unchanged.
|
||||
|
||||
- **2026-10-20 (M5, arrow keys move comments):** With a comment selected, the arrow keys move it a screen pixel and ⌘/Ctrl or Shift and an arrow ten, instead of scrolling the view or turning the page; up is up on screen at any zoom and rotation, and each press is one undo step. Text markup and locked comments stay put. Estimates unchanged.
|
||||
|
||||
|
||||
@@ -262,19 +262,47 @@ pub fn parse_da(d: &Dict) -> (Rgb, f64) {
|
||||
(col.map(|x| x.clamp(0.0, 1.0)), size.min(400.0))
|
||||
}
|
||||
|
||||
fn ext_gstate(opacity: f64, multiply: bool) -> Option<Dict> {
|
||||
if opacity >= 1.0 && !multiply {
|
||||
/// How opaque a comment's parts are drawn, 0–1: its line or border (`/CA`); its fill or
|
||||
/// background (`/ca`, which PDF 2.0 gives the non-stroking operations; without it, as the line);
|
||||
/// and a text box's text (`/PCTextOpacity`, PdfCraft's own; without it, as the fill, since text
|
||||
/// is filled too). A comment with only `/CA` is drawn at that opacity throughout, as before.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PartOpacity {
|
||||
pub line: f64,
|
||||
pub fill: f64,
|
||||
pub text: f64,
|
||||
}
|
||||
|
||||
/// The parts' opacities of the comment `d` (see [`PartOpacity`]).
|
||||
pub fn part_opacity(d: &Dict) -> PartOpacity {
|
||||
let get = |key: &[u8]| d.get(key).and_then(|o| o.as_f64()).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0));
|
||||
let line = get(b"CA").unwrap_or(1.0);
|
||||
let fill = get(b"ca").unwrap_or(line);
|
||||
let text = get(b"PCTextOpacity").unwrap_or(fill);
|
||||
PartOpacity { line, fill, text }
|
||||
}
|
||||
|
||||
/// `/GS0`: the line's opacity for strokes and the fill's for everything filled; `/GS1`, where the
|
||||
/// text's differs from the fill's: the text's for its glyphs.
|
||||
fn ext_gstate(parts: PartOpacity, multiply: bool) -> Option<Dict> {
|
||||
if parts.line >= 1.0 && parts.fill >= 1.0 && parts.text >= 1.0 && !multiply {
|
||||
return None;
|
||||
}
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||||
if multiply {
|
||||
gs.set(b"BM".to_vec(), Object::name("Multiply"));
|
||||
}
|
||||
gs.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
gs.set(b"ca".to_vec(), Object::Real(opacity));
|
||||
let gs = |stroke: f64, fill: f64| {
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||||
if multiply {
|
||||
gs.set(b"BM".to_vec(), Object::name("Multiply"));
|
||||
}
|
||||
gs.set(b"CA".to_vec(), Object::Real(stroke));
|
||||
gs.set(b"ca".to_vec(), Object::Real(fill));
|
||||
Object::Dict(gs)
|
||||
};
|
||||
let mut res = Dict::new();
|
||||
res.set(b"GS0".to_vec(), Object::Dict(gs));
|
||||
res.set(b"GS0".to_vec(), gs(parts.line, parts.fill));
|
||||
if parts.text != parts.fill {
|
||||
res.set(b"GS1".to_vec(), gs(parts.line, parts.text));
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
|
||||
@@ -391,7 +419,9 @@ struct AppearanceContext<'a> {
|
||||
subtype: &'a [u8],
|
||||
rect: [f64; 4],
|
||||
cloudy: bool,
|
||||
/// The line's opacity (`parts.line`).
|
||||
opacity: f64,
|
||||
parts: PartOpacity,
|
||||
stroke: Option<Rgb>,
|
||||
width: f64,
|
||||
}
|
||||
@@ -408,7 +438,7 @@ impl AppearanceDrawing {
|
||||
fn new(context: &AppearanceContext<'_>) -> Self {
|
||||
let mut drawing = Self::default();
|
||||
if let Some(gs) = ext_gstate(
|
||||
context.opacity,
|
||||
context.parts,
|
||||
context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")),
|
||||
) {
|
||||
drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
@@ -455,7 +485,8 @@ fn appearance_context(d: &Dict) -> Option<AppearanceContext<'_>> {
|
||||
subtype,
|
||||
rect,
|
||||
cloudy,
|
||||
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0),
|
||||
opacity: part_opacity(d).line,
|
||||
parts: part_opacity(d),
|
||||
stroke: color(d, b"C")?,
|
||||
width: border_width(d),
|
||||
})
|
||||
@@ -845,46 +876,25 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
let [x0, y0, x1, y1] = draw;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
let lw = w.min(h) * 0.12;
|
||||
// The strokes come from `FillMark::strokes`, which a copy's outline draws too.
|
||||
let path = |mark: crate::FillMark| {
|
||||
let ops: Vec<String> = mark
|
||||
.strokes(draw)
|
||||
.iter()
|
||||
.flat_map(|s| s.iter().enumerate().map(|(i, p)| format!("{} {} {}", n(p[0]), n(p[1]), if i == 0 { "m" } else { "l" })))
|
||||
.collect();
|
||||
format!("{} S\n", ops.join(" "))
|
||||
};
|
||||
match name {
|
||||
b"PCCheck" => c.push_str(&format!(
|
||||
"{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(x0 + w * 0.15),
|
||||
n(y0 + h * 0.5),
|
||||
n(x0 + w * 0.4),
|
||||
n(y0 + h * 0.2),
|
||||
n(x0 + w * 0.88),
|
||||
n(y0 + h * 0.85)
|
||||
)),
|
||||
b"PCCross" => c.push_str(&format!(
|
||||
"{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(lw),
|
||||
n(x0 + w * 0.18),
|
||||
n(y0 + h * 0.18),
|
||||
n(x1 - w * 0.18),
|
||||
n(y1 - h * 0.18),
|
||||
n(x0 + w * 0.18),
|
||||
n(y1 - h * 0.18),
|
||||
n(x1 - w * 0.18),
|
||||
n(y0 + h * 0.18)
|
||||
)),
|
||||
b"PCCheck" => c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Check))),
|
||||
b"PCCross" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(lw), path(crate::FillMark::Cross))),
|
||||
b"PCDot" => {
|
||||
let r = w.min(h) * 0.3;
|
||||
c.push_str(&rg(col));
|
||||
c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r));
|
||||
c.push_str("f\n");
|
||||
}
|
||||
b"PCLine" => c.push_str(&format!(
|
||||
"{}{} w 1 J\n{} {} m {} {} l S\n",
|
||||
rg_stroke(col),
|
||||
n(h.clamp(0.5, 2.0)),
|
||||
n(x0),
|
||||
n(y0 + h / 2.0),
|
||||
n(x1),
|
||||
n(y0 + h / 2.0)
|
||||
)),
|
||||
b"PCLine" => c.push_str(&format!("{}{} w 1 J\n{}", rg_stroke(col), n(h.clamp(0.5, 2.0)), path(crate::FillMark::Line))),
|
||||
other => {
|
||||
// Only stamps PdfCraft made: others keep their own artwork.
|
||||
if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) {
|
||||
@@ -904,6 +914,7 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
|
||||
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option<Stream> {
|
||||
let d = context.dict;
|
||||
let parts = context.parts;
|
||||
let rect = context.rect;
|
||||
let stroke = context.stroke;
|
||||
let mut c = std::mem::take(&mut drawing.content);
|
||||
@@ -977,6 +988,10 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
|
||||
let pad = 2.0 + bw;
|
||||
let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0);
|
||||
let q = d.int(b"Q").unwrap_or(0);
|
||||
// The text at its own opacity, where it has one.
|
||||
if parts.text != parts.fill {
|
||||
c.push_str("/GS1 gs\n");
|
||||
}
|
||||
c.push_str(&format!(
|
||||
"{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
|
||||
n(rect[0]),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
|
||||
use super::{Dict, Object, PartOpacity, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
|
||||
|
||||
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
|
||||
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn display_stamp(d: &Dict) -> Option<Stream> {
|
||||
// A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp.
|
||||
let stroke = color(d, b"C").flatten();
|
||||
let mut res = Dict::new();
|
||||
if let Some(gs) = ext_gstate(opacity, false) {
|
||||
if let Some(gs) = ext_gstate(PartOpacity { line: opacity, fill: opacity, text: opacity }, false) {
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
}
|
||||
// A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::draw::draw_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
|
||||
use super::modify::{n, rich_text};
|
||||
use super::modify::{n, rich_text, set_part_keys};
|
||||
use super::types::{
|
||||
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
|
||||
};
|
||||
@@ -214,8 +214,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
let rect = rect_for(&new.shape, style)?;
|
||||
let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta);
|
||||
d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape)));
|
||||
let opacity = style.opacity.clamp(0.0, 1.0);
|
||||
set_non_opaque_style(&mut d, opacity);
|
||||
if style.fill_opacity.into_iter().chain(style.text_opacity).any(|o| !o.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid opacity".into()));
|
||||
}
|
||||
let line = style.opacity.clamp(0.0, 1.0);
|
||||
let fill = style.fill_opacity.map_or(line, |o| o.clamp(0.0, 1.0));
|
||||
let text = style.text_opacity.map_or(fill, |o| o.clamp(0.0, 1.0));
|
||||
set_part_keys(&mut d, appearance::PartOpacity { line, fill, text });
|
||||
let border = |d: &mut Dict| {
|
||||
let mut bs = Dict::new();
|
||||
bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0)));
|
||||
@@ -443,12 +448,6 @@ fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]],
|
||||
}
|
||||
}
|
||||
|
||||
fn set_non_opaque_style(dict: &mut Dict, opacity: f64) {
|
||||
if opacity < 1.0 {
|
||||
dict.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
}
|
||||
}
|
||||
|
||||
fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec<Object>) -> Result<(), AnnotError> {
|
||||
let Some(rect) = rect else { return Ok(()) };
|
||||
let mut popup = Dict::new();
|
||||
|
||||
@@ -217,6 +217,11 @@ pub struct Props {
|
||||
pub fill: Option<Rgb>,
|
||||
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
|
||||
pub font_size: Option<f64>,
|
||||
/// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its
|
||||
/// line's or border's).
|
||||
pub fill_opacity: Option<f64>,
|
||||
/// How opaque a text box's, typewriter text's or callout's text is drawn.
|
||||
pub text_opacity: Option<f64>,
|
||||
}
|
||||
|
||||
/// The current properties of the comment at `(page, index)`.
|
||||
@@ -239,7 +244,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
author: text_value(doc, d, b"T").unwrap_or_default(),
|
||||
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
|
||||
color,
|
||||
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0),
|
||||
opacity: appearance::part_opacity(d).line,
|
||||
width,
|
||||
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
|
||||
modified: text_value(doc, d, b"M"),
|
||||
@@ -248,6 +253,8 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
fillable: fillable(&subtype),
|
||||
fill: fill_of(d),
|
||||
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
|
||||
fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill),
|
||||
text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text),
|
||||
subtype,
|
||||
endings: line_endings_of(d),
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
|
||||
pub(crate) use modify::n;
|
||||
pub use modify::{
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents,
|
||||
set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
};
|
||||
pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
|
||||
pub use types::{
|
||||
|
||||
@@ -558,7 +558,8 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> {
|
||||
Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half])
|
||||
}
|
||||
|
||||
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
|
||||
/// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and
|
||||
/// redraw it.
|
||||
pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let StyleChange { color, opacity, width, endings } = change;
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
@@ -593,13 +594,10 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
|
||||
d.set(b"C".to_vec(), rgb(c));
|
||||
}
|
||||
}
|
||||
// The whole comment: its fill and text drop opacities of their own.
|
||||
if let Some(o) = opacity {
|
||||
let o = o.clamp(0.0, 1.0);
|
||||
if o < 1.0 {
|
||||
d.set(b"CA".to_vec(), Object::Real(o));
|
||||
} else {
|
||||
d.remove(b"CA");
|
||||
}
|
||||
set_part_keys(d, appearance::PartOpacity { line: o, fill: o, text: o });
|
||||
}
|
||||
if let Some(w) = width {
|
||||
let old_w = border_width_of(d);
|
||||
@@ -707,6 +705,63 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// 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), its line or border keeping the comment's (`/CA`), and redraw it.
|
||||
/// `None` leaves that part as it is drawn now. The fill goes in `/ca` (PDF 2.0's opacity for
|
||||
/// what is filled), the text in `/PCTextOpacity`, each only where it differs from the one it
|
||||
/// would otherwise follow.
|
||||
pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Option<f64>, text: Option<f64>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
if fill.is_some() && !fillable(&subtype) {
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes have a fill of their own".into()));
|
||||
}
|
||||
if text.is_some() && subtype != "FreeText" {
|
||||
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have text of their own".into()));
|
||||
}
|
||||
if fill.into_iter().chain(text).any(|o| !o.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid opacity".into()));
|
||||
}
|
||||
// Check before changing anything: a stale appearance would contradict the new opacity.
|
||||
if appearance::build(&d).is_none() {
|
||||
return Err(AnnotError::Unsupported(subtype));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let mut parts = appearance::part_opacity(d);
|
||||
if let Some(f) = fill {
|
||||
parts.fill = f.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(t) = text {
|
||||
parts.text = t.clamp(0.0, 1.0);
|
||||
}
|
||||
set_part_keys(d, parts);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Write a comment's parts' opacities (see [`appearance::PartOpacity`]): the line's as `/CA`, the
|
||||
/// fill's as `/ca` and the text's as `/PCTextOpacity`, each only where it differs from the one
|
||||
/// it would otherwise follow (the fill the line, the text the fill), so a comment drawn at one
|
||||
/// opacity keeps just `/CA`.
|
||||
pub(crate) fn set_part_keys(d: &mut Dict, parts: appearance::PartOpacity) {
|
||||
let appearance::PartOpacity { line, fill, text } = parts;
|
||||
let mut put = |key: &[u8], value: f64, unless: f64| {
|
||||
if value == unless {
|
||||
d.remove(key);
|
||||
} else {
|
||||
d.set(key.to_vec(), Object::Real(value));
|
||||
}
|
||||
};
|
||||
put(b"CA", line, 1.0);
|
||||
put(b"ca", fill, line);
|
||||
put(b"PCTextOpacity", text, fill);
|
||||
}
|
||||
|
||||
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.
|
||||
fn ensure_ending_room(d: &mut Dict) {
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
|
||||
@@ -40,7 +40,7 @@ fn new(page: usize, shape: Shape) -> NewAnnotation {
|
||||
NewAnnotation {
|
||||
page,
|
||||
shape,
|
||||
style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None },
|
||||
style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None, ..Style::default() },
|
||||
contents: "hello".into(),
|
||||
author: "Ada".into(),
|
||||
}
|
||||
@@ -356,6 +356,83 @@ fn restyle_and_resize_redraw_the_appearance() {
|
||||
assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
||||
/// A rectangle's fill, and a text box's border, background and text, each drawn at an opacity of
|
||||
/// its own: the line's in `/CA`, the fill's in `/ca` (PDF 2.0), the text's in `/PCTextOpacity`, and
|
||||
/// in the appearance `/GS0` (strokes at the line's, fills at the fill's) and `/GS1` for the text.
|
||||
#[test]
|
||||
fn parts_are_drawn_at_opacities_of_their_own() {
|
||||
let num = |d: &Dict, k: &[u8]| d.get(k).and_then(|o| o.as_f64());
|
||||
let gstate = |doc: &Document, d: &Dict, name: &[u8]| -> Option<(f64, f64)> {
|
||||
let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N")))?;
|
||||
let obj = doc.get(n);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
let res = s.dict.get(b"Resources").map(|r| doc.resolve(r))?;
|
||||
let gs = res.as_dict()?.get(b"ExtGState").map(|g| doc.resolve(g))?;
|
||||
let gs = gs.as_dict()?.get(name).map(|g| doc.resolve(g))?;
|
||||
let gs = gs.as_dict()?;
|
||||
Some((gs.get(b"CA")?.as_f64()?, gs.get(b"ca")?.as_f64()?))
|
||||
};
|
||||
let mut doc = fixture();
|
||||
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
|
||||
set_fill(&mut doc, 0, r, Some([1.0, 1.0, 0.0]), &meta("")).unwrap();
|
||||
set_part_opacity(&mut doc, 0, r, Some(0.3), None, &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[r];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca")), (None, Some(0.3)), "an opaque line, a fill of its own");
|
||||
assert_eq!(gstate(&doc2, d, b"GS0"), Some((1.0, 0.3)));
|
||||
let ap = ap_content(&doc2, d);
|
||||
assert!(ap.starts_with("/GS0 gs") && ap.contains("\nB\n"), "filled and stroked in one go: {ap}");
|
||||
let p = props(&doc2, 0, r).unwrap();
|
||||
assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.3), None));
|
||||
// The whole comment's opacity (StyleAnnotation, the quick bar): the fill goes along again.
|
||||
set_style(&mut doc, 0, r, StyleChange::new(None, Some(0.6), None, None), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[r];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca")), (Some(0.6), None));
|
||||
assert_eq!(props(&doc, 0, r).unwrap().fill_opacity, Some(0.6));
|
||||
|
||||
// A text box with its border opaque, its background at 20 % and its text opaque, as made.
|
||||
let mut tb = new(0, Shape::TextBox { rect: [200.0, 600.0, 400.0, 650.0], font_size: 12.0 });
|
||||
tb.style.fill = Some([1.0, 1.0, 0.6]);
|
||||
tb.style.width = 1.0;
|
||||
(tb.style.fill_opacity, tb.style.text_opacity) = (Some(0.2), Some(1.0));
|
||||
let t = add_annotation(&mut doc, &tb, &meta("t")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[t];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, Some(0.2), Some(1.0)));
|
||||
assert_eq!((gstate(&doc2, d, b"GS0"), gstate(&doc2, d, b"GS1")), (Some((1.0, 0.2)), Some((1.0, 1.0))));
|
||||
let ap = ap_content(&doc2, d);
|
||||
let (gs1, text) = (ap.find("/GS1 gs\n").expect("the text's own state, an operator of its own"), ap.find("BT").expect("text"));
|
||||
assert!(ap.find(" re f").is_some_and(|fill| fill < gs1) && gs1 < text, "background at 20 %, then the text at its own: {ap}");
|
||||
// A new fill opacity leaves the text as it is drawn.
|
||||
set_part_opacity(&mut doc, 0, t, Some(0.5), None, &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!((num(d, b"ca"), num(d, b"PCTextOpacity")), (Some(0.5), Some(1.0)));
|
||||
let p = props(&doc, 0, t).unwrap();
|
||||
assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.5), Some(1.0)));
|
||||
// All at one opacity again: only /CA stays.
|
||||
set_part_opacity(&mut doc, 0, t, Some(1.0), Some(1.0), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, None, None));
|
||||
|
||||
// Only the parts a comment has, and finite values; nothing changes when refused.
|
||||
let l = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }),
|
||||
&meta("l"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, l, Some(0.5), None, &meta("")), Err(AnnotError::Invalid(_))), "a line has no fill");
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, r, None, Some(0.5), &meta("")), Err(AnnotError::Invalid(_))), "a rectangle has no text");
|
||||
let before = list(&doc, 0)[r].clone();
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, r, Some(f64::NAN), None, &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert_eq!(list(&doc, 0)[r], before);
|
||||
// Another app's comment with /CA alone is drawn at it throughout, as before.
|
||||
assert_eq!(
|
||||
appearance::part_opacity(&Dict::from_iter([(b"CA".to_vec(), Object::Real(0.4))])),
|
||||
appearance::PartOpacity { line: 0.4, fill: 0.4, text: 0.4 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_can_be_filled_and_unfilled() {
|
||||
let mut doc = fixture();
|
||||
@@ -873,8 +950,8 @@ fn cloud_ys(path: &str) -> Vec<f64> {
|
||||
fn replace_text_groups_a_strikeout_and_a_caret() {
|
||||
let mut doc = fixture();
|
||||
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
|
||||
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let i = add_text_replacement(
|
||||
&mut doc,
|
||||
TextReplacement {
|
||||
|
||||
@@ -124,6 +124,28 @@ impl FillMark {
|
||||
let (w, h) = if matches!(rotation.rem_euclid(360), 90 | 270) { (h, w) } else { (w, h) };
|
||||
[at[0] - w / 2.0, at[1] - h / 2.0, at[0] + w / 2.0, at[1] + h / 2.0]
|
||||
}
|
||||
|
||||
/// The mark whose `/Name` this is.
|
||||
pub fn from_name(n: &[u8]) -> Option<Self> {
|
||||
[FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line].into_iter().find(|m| m.name().as_bytes() == n)
|
||||
}
|
||||
|
||||
/// The pen strokes the mark is drawn with in `rect` (user space): what its appearance strokes,
|
||||
/// and what a copy of it shows while it is being placed. The dot is a filled circle (radius
|
||||
/// 0.3 × the shorter side, centred) and has none.
|
||||
pub fn strokes(self, rect: [f64; 4]) -> Vec<Vec<[f64; 2]>> {
|
||||
let [x0, y0, x1, y1] = rect;
|
||||
let (w, h) = (x1 - x0, y1 - y0);
|
||||
match self {
|
||||
FillMark::Check => vec![vec![[x0 + w * 0.15, y0 + h * 0.5], [x0 + w * 0.4, y0 + h * 0.2], [x0 + w * 0.88, y0 + h * 0.85]]],
|
||||
FillMark::Cross => vec![
|
||||
vec![[x0 + w * 0.18, y0 + h * 0.18], [x1 - w * 0.18, y1 - h * 0.18]],
|
||||
vec![[x0 + w * 0.18, y1 - h * 0.18], [x1 - w * 0.18, y0 + h * 0.18]],
|
||||
],
|
||||
FillMark::Dot => Vec::new(),
|
||||
FillMark::Line => vec![vec![[x0, y0 + h / 2.0], [x1, y0 + h / 2.0]]],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stamps of Acrobat's stamp palette (drawn in PdfCraft's own style).
|
||||
@@ -566,6 +588,11 @@ pub struct Style {
|
||||
pub width: f64,
|
||||
/// Interior fill for rectangles and ovals (`/IC`), background for text boxes.
|
||||
pub fill: Option<Rgb>,
|
||||
/// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the
|
||||
/// line's or border's); `None`: as `opacity`.
|
||||
pub fill_opacity: Option<f64>,
|
||||
/// A text box's text's own opacity, 0–1 (`/PCTextOpacity`); `None`: as the fill's.
|
||||
pub text_opacity: Option<f64>,
|
||||
}
|
||||
|
||||
/// The style properties changed by one annotation edit. `None` leaves a property unchanged.
|
||||
@@ -597,7 +624,7 @@ pub struct TextReplacement<'a> {
|
||||
|
||||
impl Default for Style {
|
||||
fn default() -> Self {
|
||||
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None }
|
||||
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +642,7 @@ impl Style {
|
||||
}
|
||||
Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0),
|
||||
Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0),
|
||||
Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None },
|
||||
Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, ..Self::default() },
|
||||
Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0),
|
||||
Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5),
|
||||
@@ -624,9 +651,9 @@ impl Style {
|
||||
Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
// Red outline while marked; a black box once applied.
|
||||
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) },
|
||||
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], fill: Some([0.0, 0.0, 0.0]), ..Self::default() },
|
||||
};
|
||||
Self { color, opacity: 1.0, width, fill: None }
|
||||
Self { color, width, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,19 @@ impl Automation {
|
||||
"rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))),
|
||||
"color": c.color.map(hex),
|
||||
});
|
||||
// How opaque it is drawn: as a whole, or its fill and text each at their own.
|
||||
// Not for replies, which are drawn with the comment they answer.
|
||||
if !c.is_reply()
|
||||
&& let Some(p) = doc.comment_props(c.page, c.index)
|
||||
{
|
||||
v["opacity"] = json!(p.opacity);
|
||||
if let Some(f) = p.fill_opacity {
|
||||
v["fill_opacity"] = json!(f);
|
||||
}
|
||||
if let Some(t) = p.text_opacity {
|
||||
v["text_opacity"] = json!(t);
|
||||
}
|
||||
}
|
||||
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
|
||||
// comment_edit's `points` takes them.
|
||||
// Unchanged on a page the viewer could not read, as `rect` is.
|
||||
@@ -437,6 +450,19 @@ impl Automation {
|
||||
if let Some(o) = a.opt_num("opacity")? {
|
||||
style.opacity = o.clamp(0.0, 1.0);
|
||||
}
|
||||
// The fill's and a text box's text's own opacities (the line keeps `opacity`).
|
||||
let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?);
|
||||
let texts = matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. });
|
||||
if fill_opacity.is_some() && !(texts || matches!(shape, Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Polygon { .. })) {
|
||||
return Err(ToolError::InvalidArgs(
|
||||
"fill_opacity applies to a rectangle, oval, polygon, cloud, text box, typewriter text or callout".into(),
|
||||
));
|
||||
}
|
||||
if text_opacity.is_some() && !texts {
|
||||
return Err(ToolError::InvalidArgs("text_opacity applies to a text box, typewriter text or callout".into()));
|
||||
}
|
||||
style.fill_opacity = fill_opacity.map(|o| o.clamp(0.0, 1.0));
|
||||
style.text_opacity = text_opacity.map(|o| o.clamp(0.0, 1.0));
|
||||
if let Some(w) = a.opt_num("width")? {
|
||||
style.width = w.max(0.0);
|
||||
}
|
||||
@@ -668,6 +694,14 @@ impl Automation {
|
||||
let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) };
|
||||
edits.push(Edit::FillAnnotation { page, index, fill });
|
||||
}
|
||||
// After `opacity`, which sets the whole comment's.
|
||||
let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?);
|
||||
if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) {
|
||||
return Err(ToolError::InvalidArgs("fill_opacity and text_opacity are 0–1".into()));
|
||||
}
|
||||
if fill_opacity.is_some() || text_opacity.is_some() {
|
||||
edits.push(Edit::PartOpacity { page, index, fill: fill_opacity, text: text_opacity });
|
||||
}
|
||||
if let Some(size) = a.opt_num("font_size")? {
|
||||
if !(1.0..=400.0).contains(&size) {
|
||||
return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into()));
|
||||
@@ -701,7 +735,7 @@ impl Automation {
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return Err(ToolError::InvalidArgs(
|
||||
"nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(),
|
||||
"nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, font_size, rect, move or points".into(),
|
||||
));
|
||||
}
|
||||
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
|
||||
@@ -712,6 +746,49 @@ impl Automation {
|
||||
let (page, index) = self.comment_target(a)?;
|
||||
self.apply(a, Edit::DeleteAnnotation { page, index })
|
||||
}
|
||||
|
||||
/// The XFDF of the listed comments and their replies (`comments`: 1-based [page, index] pairs).
|
||||
pub(crate) fn comments_copy(&self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let all = self.comments(a)?;
|
||||
let shape = || ToolError::InvalidArgs("comments: [[page, index], …] (1-based, as comment_list reports)".into());
|
||||
let list = a.get("comments").and_then(Value::as_array).ok_or_else(shape)?;
|
||||
let mut which = Vec::with_capacity(list.len());
|
||||
for item in list {
|
||||
let pair = item.as_array().filter(|p| p.len() == 2).and_then(|p| Some((p.first()?.as_u64()?, p.get(1)?.as_u64()?)));
|
||||
let Some((page, index)) = pair.filter(|(p, i)| *p >= 1 && *i >= 1) else { return Err(shape()) };
|
||||
let (page, index) = (usize::try_from(page - 1).map_err(|_| shape())?, usize::try_from(index - 1).map_err(|_| shape())?);
|
||||
if !all.iter().any(|c| c.page == page && c.index == index) {
|
||||
return Err(failed(format!("there is no comment {} on page {} (see comment_list)", index + 1, page + 1)));
|
||||
}
|
||||
which.push((page, index));
|
||||
}
|
||||
let xfdf = self.session.export_comments(id, &which).map_err(failed)?;
|
||||
Ok(json!({ "xfdf": xfdf, "comments": which.len() }))
|
||||
}
|
||||
|
||||
/// Add the comments of `xfdf` (from `comments_copy`) to `page` as new comments.
|
||||
pub(crate) fn comments_paste(&mut self, a: &Args) -> Result<Value> {
|
||||
let page = self.page(a)?;
|
||||
let xfdf = a.str("xfdf")?.to_owned();
|
||||
if !pdfcraft_engine::is_xfdf_comments(&xfdf) {
|
||||
return Err(ToolError::InvalidArgs("xfdf: the text comments_copy returned (XFDF with <annots>)".into()));
|
||||
}
|
||||
let offset = match a.nums::<2>("at")? {
|
||||
Some([x, y]) => {
|
||||
let info = self.doc(a)?.info.pages.get(page).cloned().ok_or_else(|| failed(format!("no page {}", page + 1)))?;
|
||||
let [cx, cy] = to_user(&info, x, y);
|
||||
let b =
|
||||
pdfcraft_engine::comments_bounds(&xfdf).ok_or_else(|| ToolError::InvalidArgs("xfdf: no comment with a rect to place".into()))?;
|
||||
Some([cx - (b[0] + b[2]) / 2.0, cy - (b[1] + b[3]) / 2.0])
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let before = self.comments(a)?.len();
|
||||
let mut out = self.apply(a, Edit::PasteComments { xfdf, page, offset })?;
|
||||
out["comments"] = json!(self.comments(a)?.len().saturating_sub(before));
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
|
||||
@@ -867,6 +867,8 @@ impl Automation {
|
||||
"comments_summarize" => self.comments_summarize(&a)?,
|
||||
"comment_edit" => self.comment_edit(&a)?,
|
||||
"comment_delete" => self.comment_delete(&a)?,
|
||||
"comments_copy" => self.comments_copy(&a)?,
|
||||
"comments_paste" => self.comments_paste(&a)?,
|
||||
other => return Err(ToolError::UnknownTool(other.into())),
|
||||
};
|
||||
Ok(vec![Content::Json(out)])
|
||||
|
||||
@@ -858,7 +858,9 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph (the standard names), Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow; attachment: PushPin, Paperclip, Graph, Tag." },
|
||||
"color": color(),
|
||||
"fill": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The whole comment's opacity (its line or border's, where its fill or text has its own)." },
|
||||
"fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity: rectangle, oval, polygon, cloud, text box, typewriter text or callout." },
|
||||
"text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity: text box, typewriter text or callout." },
|
||||
"width": { "type": "number", "minimum": 0, "description": "Line width in points." },
|
||||
"endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." },
|
||||
"ortho": { "type": "boolean", "description": "line/arrow/polygon/cloud/polyline: hold each end or corner level or plumb with the one before, whichever is nearer (Shift while drawing)." },
|
||||
@@ -992,11 +994,13 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
comment_ref(json!({
|
||||
"contents": { "type": "string" },
|
||||
"color": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The whole comment's opacity: its fill and text drop opacities of their own." },
|
||||
"fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity (rectangle, oval, polygon, cloud, text box, typewriter text, callout); the line or border keeps the comment's." },
|
||||
"text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity (text box, typewriter text, callout)." },
|
||||
"width": { "type": "number", "minimum": 0 },
|
||||
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." },
|
||||
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
|
||||
@@ -1009,6 +1013,25 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.")
|
||||
.ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])),
|
||||
t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies, or one reply (its id, or page and index, from comment_list) with its pop-up and the replies to it; the comment and its other replies stay. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])),
|
||||
t(
|
||||
"comments_copy",
|
||||
"Copy comments",
|
||||
"The XFDF of these comments, each with its replies: what Copy puts on the clipboard. Paste it into this or another document with comments_paste. `comments`: [[page, index], …], 1-based as comment_list reports them.",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"comments": { "type": "array", "items": { "type": "array", "items": { "type": "integer", "minimum": 1 }, "minItems": 2, "maxItems": 2 }, "minItems": 1 }
|
||||
}),
|
||||
&["doc", "comments"],
|
||||
)),
|
||||
t(
|
||||
"comments_paste",
|
||||
"Paste comments",
|
||||
"Add the comments of an XFDF text (from comments_copy) to `page` as new comments: copies under names of their own, so nothing in the document is replaced and pasting twice gives two sets; replies keep their copied parent. `at` [x, y] (points, origin at the top-left of the displayed page, y down, as comment_add takes them) puts the centre of the copies' bounding box there; without it they keep their positions. Undoable.",
|
||||
)
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "xfdf": { "type": "string" }, "at": point() }), &["doc", "page", "xfdf"])),
|
||||
t(
|
||||
"doc_protect",
|
||||
"Protect with passwords",
|
||||
|
||||
@@ -2135,6 +2135,55 @@ fn numbering_pages_through_tools() {
|
||||
assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// A rectangle's fill and a text box's text each take an opacity of their own through the tools
|
||||
/// (the line or border keeps the comment's `opacity`); `opacity` sets the whole comment's again.
|
||||
#[test]
|
||||
fn comment_parts_take_opacities_of_their_own_through_the_tools() {
|
||||
let dir = workdir("part-opacity");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "fill": "yellow", "fill_opacity": 0.25 }),
|
||||
);
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "fill": "#FFFF99", "width": 1, "fill_opacity": 0.2, "text_opacity": 1 }),
|
||||
);
|
||||
let find = |a: &mut Automation, kind: &str| {
|
||||
let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
list["comments"].as_array().unwrap().iter().find(|c| c["type"] == kind).unwrap().clone()
|
||||
};
|
||||
let rect = find(&mut a, "Square");
|
||||
assert_eq!((rect["opacity"].as_f64(), rect["fill_opacity"].as_f64(), rect.get("text_opacity")), (Some(1.0), Some(0.25), None));
|
||||
let tb = find(&mut a, "FreeText");
|
||||
assert_eq!((tb["opacity"].as_f64(), tb["fill_opacity"].as_f64(), tb["text_opacity"].as_f64()), (Some(1.0), Some(0.2), Some(1.0)));
|
||||
// The fill changes, the text stays; an undo step.
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "fill_opacity": 0.5 }));
|
||||
let tb = find(&mut a, "FreeText");
|
||||
assert_eq!((tb["fill_opacity"].as_f64(), tb["text_opacity"].as_f64()), (Some(0.5), Some(1.0)));
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change comment properties");
|
||||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||||
// The whole comment's opacity: everything at it again.
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "opacity": 0.6 }));
|
||||
let rect = find(&mut a, "Square");
|
||||
assert_eq!((rect["opacity"].as_f64(), rect["fill_opacity"].as_f64()), (Some(0.6), Some(0.6)));
|
||||
// Only the parts a comment has, at 0–1.
|
||||
for (tool, args) in [
|
||||
("comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [0, 0], "to": [50, 50], "fill_opacity": 0.5 })),
|
||||
("comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [80, 10, 120, 40], "text_opacity": 0.5 })),
|
||||
("comment_edit", json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "fill_opacity": 2 })),
|
||||
] {
|
||||
assert!(matches!(a.call(tool, &args), Err(ToolError::InvalidArgs(_))), "{args}");
|
||||
}
|
||||
assert!(matches!(
|
||||
a.call("comment_edit", &json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "text_opacity": 0.5 })),
|
||||
Err(ToolError::Failed(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_through_tools() {
|
||||
let dir = workdir("comments");
|
||||
@@ -10926,3 +10975,49 @@ fn a_bad_bookmark_tree_is_refused_whole_and_names_the_node() {
|
||||
assert!(!out.exists());
|
||||
assert_eq!(ok(&mut a, "bookmark_list", json!({ "doc": doc }))["bookmarks"], json!([]));
|
||||
}
|
||||
|
||||
/// Copy and paste comments between documents through the tools: the copies carry their replies,
|
||||
/// land on the asked-for page under their own names (a second paste gives a second set), and a
|
||||
/// plain text is refused.
|
||||
#[test]
|
||||
fn comments_copy_and_paste_between_documents_through_tools() {
|
||||
let dir = workdir("comments-clipboard");
|
||||
let mut a = auto(&dir);
|
||||
let src = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "comment_add", json!({ "doc": src, "page": 2, "type": "note", "at": [30, 30], "contents": "Please review", "author": "Ada" }));
|
||||
ok(&mut a, "comment_add", json!({ "doc": src, "page": 2, "type": "rectangle", "rect": [20, 20, 80, 60], "contents": "Box" }));
|
||||
ok(&mut a, "comment_reply", json!({ "doc": src, "page": 2, "index": 1, "text": "Done", "author": "Bob" }));
|
||||
// Positions as comment_list reports them (a note's pop-up takes a position of its own).
|
||||
let listed = ok(&mut a, "comment_list", json!({ "doc": src, "page": 2 }));
|
||||
let pairs: Vec<Value> = listed["comments"].as_array().unwrap().iter().map(|c| json!([c["page"], c["index"]])).collect();
|
||||
assert_eq!(pairs.len(), 2, "{listed}");
|
||||
let copied = ok(&mut a, "comments_copy", json!({ "doc": src, "comments": pairs }));
|
||||
assert_eq!(copied["comments"], 2);
|
||||
let xfdf = copied["xfdf"].as_str().unwrap().to_owned();
|
||||
assert!(xfdf.contains("inreplyto=") && xfdf.contains("<square"), "{xfdf}");
|
||||
let dst = ok(&mut a, "doc_open", json!({ "path": "b.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "comments_paste", json!({ "doc": dst, "page": 1, "xfdf": xfdf }));
|
||||
assert_eq!(r["comments"], 3, "two comments and the reply: {r}");
|
||||
assert_eq!(r["undo"], "Paste comments");
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": dst }));
|
||||
let comments = list["comments"].as_array().unwrap();
|
||||
assert_eq!(comments.len(), 2, "the reply is in its thread, not a comment of its own: {list}");
|
||||
assert!(comments.iter().all(|c| c["page"] == 1), "{list}");
|
||||
assert_eq!(comments.iter().filter(|c| c["contents"] == "Please review").count(), 1);
|
||||
// The same again: a second set, not a replacement of the first.
|
||||
ok(&mut a, "comments_paste", json!({ "doc": dst, "page": 1, "xfdf": copied["xfdf"] }));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": dst }))["comments"].as_array().unwrap().len(), 4);
|
||||
// Placed: the copies' box is centred at `at` (top-left page coordinates, like comment_add).
|
||||
let r = ok(&mut a, "comments_paste", json!({ "doc": dst, "page": 2, "xfdf": copied["xfdf"], "at": [100, 150] }));
|
||||
assert_eq!(r["comments"], 3);
|
||||
let placed = ok(&mut a, "comment_list", json!({ "doc": dst, "page": 2 }));
|
||||
let rects: Vec<Vec<f64>> =
|
||||
placed["comments"].as_array().unwrap().iter().map(|c| c["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect()).collect();
|
||||
let (x0, y0, x1, y1) =
|
||||
rects.iter().fold((f64::MAX, f64::MAX, f64::MIN, f64::MIN), |(a, b, c, d), r| (a.min(r[0]), b.min(r[1]), c.max(r[2]), d.max(r[3])));
|
||||
assert!(((x0 + x1) / 2.0 - 100.0).abs() < 1.0 && ((y0 + y1) / 2.0 - 150.0).abs() < 1.0, "centred at [100, 150]: {rects:?}");
|
||||
assert!(matches!(a.call("comments_paste", &json!({ "doc": dst, "page": 1, "xfdf": "hello" })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(a.call("comments_copy", &json!({ "doc": src, "comments": [[9, 9]] })).is_err(), "no such comment");
|
||||
assert!(matches!(a.call("comments_copy", &json!({ "doc": src, "comments": [[2]] })), Err(ToolError::InvalidArgs(_))));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched");
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -263,6 +263,15 @@ 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,
|
||||
@@ -561,6 +570,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,11 @@ 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::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,6 +65,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::PartOpacity { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. }
|
||||
| Edit::SetMeasurementScale { .. } => {
|
||||
@@ -495,6 +497,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,6 +585,7 @@ 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::SetAnnotationInfo { page, index, author, subject, icon } => {
|
||||
pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
|
||||
|
||||
@@ -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,6 +69,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::PartOpacity { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
|
||||
Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links,
|
||||
|
||||
+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(),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,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};
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -10185,6 +10185,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("; "));
|
||||
}
|
||||
@@ -20,7 +20,10 @@ mod script;
|
||||
#[cfg(test)]
|
||||
use calc::{Notation, eval_notation, notation_name, notation_tokens, unique};
|
||||
pub use calc::{calculate, calculation_reads, notation_js, reads_built_names, rename_in_calculation};
|
||||
pub use format::{DATE_PRESETS, DateTime, TIME_PRESETS, format_date, format_value, js_number, keystroke, make_number, parse_date, validate};
|
||||
pub use format::{
|
||||
DATE_PRESETS, DateNames, DateTime, ENGLISH_DATE_NAMES, TIME_PRESETS, date_names, format_date, format_date_in, format_value, format_value_in,
|
||||
js_number, keystroke, make_number, parse_date, set_date_names, validate,
|
||||
};
|
||||
use script::{Arg, call};
|
||||
pub use script::{NOTATION_NOT_STORABLE, calculate_js, format_js, notation_storable, parse_calculate, parse_format, parse_validate, validate_js};
|
||||
|
||||
|
||||
+191
-11
@@ -89,6 +89,165 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun
|
||||
|
||||
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
/// The month and weekday names `mmmm`, `mmm`, `dddd` and `ddd` write (weekdays from Sunday), as
|
||||
/// a localized Acrobat writes them in its own language (#5303).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DateNames {
|
||||
pub months: [&'static str; 12],
|
||||
pub months_short: [&'static str; 12],
|
||||
pub days: [&'static str; 7],
|
||||
pub days_short: [&'static str; 7],
|
||||
}
|
||||
|
||||
/// English, the names until the app sets others.
|
||||
pub const ENGLISH_DATE_NAMES: DateNames = DateNames {
|
||||
months: MONTHS,
|
||||
months_short: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
days: DAYS,
|
||||
days_short: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||
};
|
||||
|
||||
static DATE_NAMES: std::sync::RwLock<DateNames> = std::sync::RwLock::new(ENGLISH_DATE_NAMES);
|
||||
|
||||
/// Sets the names date formats write from now on (the app's date language); typed dates are read
|
||||
/// in them and in English.
|
||||
pub fn set_date_names(names: DateNames) {
|
||||
*DATE_NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = names;
|
||||
}
|
||||
|
||||
/// The names date formats write.
|
||||
pub fn date_names() -> DateNames {
|
||||
*DATE_NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// `text` with the month and weekday names of `lists` (in the order months, short months,
|
||||
/// weekdays, short weekdays) written as their English names, so a parser that reads English
|
||||
/// reads them. The longest name wins at each place, case ignored, and only a whole name: one
|
||||
/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold
|
||||
/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a
|
||||
/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian
|
||||
/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are
|
||||
/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a
|
||||
/// localized one does.
|
||||
fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String {
|
||||
const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS];
|
||||
// No date is this long: hostile text is left as it is rather than scanned name by name.
|
||||
if text.len() > MAX_DATE_TEXT_BYTES {
|
||||
return text.to_string();
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let typed_english: [&[&str]; 4] = [&MONTHS, &ENGLISH_DATE_NAMES.months_short, &DAYS, &ENGLISH_DATE_NAMES.days_short];
|
||||
// Each name as folded chars, whether it is a month, and its English name.
|
||||
let mut names: Vec<(Vec<String>, bool, &str)> = Vec::new();
|
||||
for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() {
|
||||
for (name, english) in list.iter().zip(en.iter()) {
|
||||
// A short name ending in a dot ("mar.") is also read typed without it, as the same
|
||||
// kind of name, so that where it equals another ("Mar", March) the format decides.
|
||||
let bare = name.trim_end_matches('.');
|
||||
for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) {
|
||||
let n: Vec<String> = name.chars().map(fold).collect();
|
||||
if !n.is_empty() && n.len() <= MAX_NAME_CHARS {
|
||||
names.push((n, k % 4 < 2, english));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len()));
|
||||
// The format's name fields in order: true for a month, false for a weekday.
|
||||
let f: Vec<char> = fmt.chars().collect();
|
||||
let mut expected = Vec::new();
|
||||
let mut j = 0;
|
||||
while let Some(&c) = f.get(j) {
|
||||
let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1);
|
||||
if run >= 3 && (c == 'm' || c == 'd') {
|
||||
expected.push(c == 'm');
|
||||
}
|
||||
j = j.saturating_add(run);
|
||||
}
|
||||
let mut slot = 0;
|
||||
// A name's first letter may not continue a word; its last may not run on into a letter, nor
|
||||
// (a digit) into a number.
|
||||
let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic());
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
let fits = |n: &Vec<String>| {
|
||||
let end = i.saturating_add(n.len());
|
||||
let first = n.first().and_then(|e| e.chars().next());
|
||||
let last = n.last().and_then(|e| e.chars().next());
|
||||
chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned()))
|
||||
&& !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p))))
|
||||
&& !match last {
|
||||
Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit),
|
||||
Some(l) if l.is_alphabetic() => letter(chars.get(end)),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len());
|
||||
let hit = longest.and_then(|len| {
|
||||
let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n));
|
||||
let want = expected.get(slot).copied();
|
||||
let all: Vec<_> = same.by_ref().collect();
|
||||
all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied()
|
||||
});
|
||||
match hit {
|
||||
Some((n, month, english)) => {
|
||||
if expected.get(slot) == Some(month) {
|
||||
slot += 1;
|
||||
}
|
||||
// Apart from what touches it ("10月18"), but no space added at either end.
|
||||
if out.ends_with(char::is_alphanumeric) {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(english);
|
||||
i = i.saturating_add(n.len());
|
||||
if chars.get(i).is_some_and(|c| c.is_alphanumeric()) {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless
|
||||
/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are
|
||||
/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i,
|
||||
/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which
|
||||
/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so
|
||||
/// positions in the text stay aligned.
|
||||
fn fold(c: char) -> String {
|
||||
match c {
|
||||
'I' | 'ı' | 'İ' => "i".to_string(),
|
||||
'ς' => "σ".to_string(),
|
||||
_ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` [`fold`]ed char by char.
|
||||
fn folded(s: &str) -> String {
|
||||
s.chars().map(fold).collect()
|
||||
}
|
||||
|
||||
/// Typed dates longer than this are not searched for names.
|
||||
const MAX_DATE_TEXT_BYTES: usize = 1024;
|
||||
|
||||
/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap).
|
||||
const MAX_NAME_CHARS: usize = 32;
|
||||
|
||||
/// The month (1 to 12) a typed word names: a start of at least three letters of a month's name,
|
||||
/// or its whole short name, in `names` or in English.
|
||||
fn month_named(word: &str, names: &DateNames) -> Option<u32> {
|
||||
let w = folded(word);
|
||||
let starts = |list: &[&str; 12]| list.iter().position(|m| w.chars().count() >= 3 && folded(m).starts_with(&w));
|
||||
let whole = |list: &[&str; 12]| list.iter().position(|m| folded(m).trim_end_matches('.') == w);
|
||||
starts(&names.months).or_else(|| whole(&names.months_short)).or_else(|| starts(&MONTHS)).map(|i| i as u32 + 1)
|
||||
}
|
||||
|
||||
/// A date and time read from text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DateTime {
|
||||
@@ -202,7 +361,20 @@ fn order(fmt: &str) -> Vec<Part> {
|
||||
/// Parse typed text against a date/time format (AFDate_KeystrokeEx's leniency: any
|
||||
/// separators, month names, two-digit years). `None` when it isn't a valid date.
|
||||
pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||||
let t = text.trim();
|
||||
let names = date_names();
|
||||
let lists: [&[&str]; 4] = [&names.months, &names.months_short, &names.days, &names.days_short];
|
||||
// Weekdays are shown, not typed (see `order`): where the format has one but none was typed, a
|
||||
// name the language shares between a weekday and a month (Italian "mar") is the month, as
|
||||
// the names are read again with no weekday expected (#5303).
|
||||
read_date(&english_names(text, fmt, lists), fmt, &names).or_else(|| {
|
||||
let no_weekday = fmt.replace("dddd", " ").replace("ddd", " ");
|
||||
(no_weekday != fmt).then(|| read_date(&english_names(text, &no_weekday, lists), fmt, &names)).flatten()
|
||||
})
|
||||
}
|
||||
|
||||
/// [`parse_date`] of `english`, typed text whose month and weekday names are English.
|
||||
fn read_date(english: &str, fmt: &str, names: &DateNames) -> Option<DateTime> {
|
||||
let t = english.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -223,10 +395,8 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||||
}
|
||||
if cur.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
nums.push_back(std::mem::take(cur));
|
||||
} else if cur.len() >= 3
|
||||
&& let Some(i) = MONTHS.iter().position(|m| m.to_lowercase().starts_with(&cur.to_lowercase()))
|
||||
{
|
||||
*month = Some(i as u32 + 1);
|
||||
} else if let Some(m) = month_named(cur, names) {
|
||||
*month = Some(m);
|
||||
}
|
||||
cur.clear();
|
||||
};
|
||||
@@ -337,6 +507,11 @@ pub fn parse_date(text: &str, fmt: &str) -> Option<DateTime> {
|
||||
|
||||
/// Format a date/time with Acrobat's date format letters.
|
||||
pub fn format_date(dt: DateTime, fmt: &str) -> String {
|
||||
format_date_in(dt, fmt, &date_names())
|
||||
}
|
||||
|
||||
/// [`format_date`] with month and weekday names from `names`.
|
||||
pub fn format_date_in(dt: DateTime, fmt: &str, names: &DateNames) -> String {
|
||||
let chars: Vec<char> = fmt.chars().collect();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
@@ -354,9 +529,9 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String {
|
||||
('y', _) => {
|
||||
let _ = write!(out, "{:02}", dt.y.rem_euclid(100));
|
||||
}
|
||||
('m', 3..) => match MONTHS.get((dt.m as usize).wrapping_sub(1)) {
|
||||
Some(name) if n >= 4 => out.push_str(name),
|
||||
Some(name) => out.push_str(name.get(..3).unwrap_or(name)),
|
||||
('m', 3..) => match (dt.m as usize).checked_sub(1).and_then(|i| names.months.get(i).zip(names.months_short.get(i))) {
|
||||
Some((name, _)) if n >= 4 => out.push_str(name),
|
||||
Some((_, short)) => out.push_str(short),
|
||||
None => {
|
||||
let _ = write!(out, "{}", dt.m);
|
||||
}
|
||||
@@ -368,8 +543,8 @@ pub fn format_date(dt: DateTime, fmt: &str) -> String {
|
||||
let _ = write!(out, "{}", dt.m);
|
||||
}
|
||||
('d', 3..) => {
|
||||
let name = DAYS.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default();
|
||||
out.push_str(if n >= 4 { name } else { name.get(..3).unwrap_or(name) });
|
||||
let list = if n >= 4 { &names.days } else { &names.days_short };
|
||||
out.push_str(list.get(weekday(dt.y, dt.m, dt.d)).copied().unwrap_or_default());
|
||||
}
|
||||
('d', 2) => {
|
||||
let _ = write!(out, "{:02}", dt.d);
|
||||
@@ -451,6 +626,11 @@ fn apply_mask(mask: &str, text: &str) -> Option<String> {
|
||||
|
||||
/// The text shown for a value (the Format event).
|
||||
pub fn format_value(f: &Format, value: &str) -> String {
|
||||
format_value_in(f, value, &date_names())
|
||||
}
|
||||
|
||||
/// [`format_value`] with month and weekday names from `names`.
|
||||
pub fn format_value_in(f: &Format, value: &str, names: &DateNames) -> String {
|
||||
if value.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
@@ -479,7 +659,7 @@ pub fn format_value(f: &Format, value: &str) -> String {
|
||||
),
|
||||
None => value.to_string(),
|
||||
},
|
||||
Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date(dt, fmt)),
|
||||
Format::Date(fmt) | Format::Time(fmt) => parse_date(value, fmt).map_or_else(|| value.to_string(), |dt| format_date_in(dt, fmt, names)),
|
||||
Format::Special(n) => apply_mask(SPECIAL_MASKS[(*n).min(3) as usize], value).unwrap_or_else(|| value.to_string()),
|
||||
Format::Mask(m) => apply_mask(m, value).unwrap_or_else(|| value.to_string()),
|
||||
}
|
||||
|
||||
@@ -739,8 +739,18 @@ pub fn value_overflow(doc: &Document, f: &Field, w: &Widget) -> Option<Overflow>
|
||||
field_appearance_fit(doc, f, w, &f.value, true).2
|
||||
}
|
||||
|
||||
/// Whether `text` draws in widget `wd` of `f` with no `?`: in the field's `/DR` font when that
|
||||
/// holds it, as [`field_appearance_fit`] picks, or else in the Helvetica (WinAnsi) stand-in.
|
||||
fn draws(doc: &Document, f: &Field, wd: &Dict, text: &str) -> bool {
|
||||
let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da));
|
||||
let enc = dr_font(doc, &da.font).map(|(_, enc)| enc).filter(|enc| enc.can_encode(text)).unwrap_or(FontText::WinAnsi);
|
||||
enc.first_unencodable(text).is_none()
|
||||
}
|
||||
|
||||
/// [`field_appearance_drawn`], and whether the text drawn is cut off by the box.
|
||||
fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>, Option<Overflow>) {
|
||||
let wobj = doc.get(w.obj);
|
||||
let wd = wobj.as_dict().cloned().unwrap_or_default();
|
||||
// The Format event: what is shown, not what is stored.
|
||||
let formatted: Vec<String>;
|
||||
let values = if format
|
||||
@@ -748,13 +758,16 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
|
||||
&& values.len() == 1
|
||||
&& f.actions.format != crate::af::Format::None
|
||||
{
|
||||
formatted = vec![crate::af::format_value(&f.actions.format, &values[0])];
|
||||
let shown = crate::af::format_value(&f.actions.format, &values[0]);
|
||||
// Month and weekday names in the date language that no font here can draw ("окт." in
|
||||
// Helvetica) are drawn in English rather than as `?` (#5303).
|
||||
let english = || crate::af::format_value_in(&f.actions.format, &values[0], &crate::af::ENGLISH_DATE_NAMES);
|
||||
let dated = matches!(f.actions.format, crate::af::Format::Date(_) | crate::af::Format::Time(_));
|
||||
formatted = vec![if !dated || draws(doc, f, &wd, &shown) { shown } else { english() }];
|
||||
&formatted[..]
|
||||
} else {
|
||||
values
|
||||
};
|
||||
let wobj = doc.get(w.obj);
|
||||
let wd = wobj.as_dict().cloned().unwrap_or_default();
|
||||
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
|
||||
let place = placement(mk_rotation(doc, &wd), page_w, page_h);
|
||||
let (width, height) = (place.layout_w, place.layout_h);
|
||||
|
||||
@@ -60,6 +60,21 @@ pub struct ScriptResult {
|
||||
pub trait Scripts {
|
||||
/// Run `script` for `event` on `target`, whose value is `value`; `fields` is the whole form.
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult;
|
||||
|
||||
/// [`Self::run`] again for an appearance, with dates written in English names (`util.printd`)
|
||||
/// because no font of the field can draw the date language's (#5303); what it logs was
|
||||
/// already logged by the first run. `None` when it can't: no English dates here, or the
|
||||
/// script failed. What it returns is what the script made, even when that is the value.
|
||||
fn rerun_with_english_dates(
|
||||
&mut self,
|
||||
_event: FieldEvent,
|
||||
_script: &str,
|
||||
_target: &Field,
|
||||
_value: &str,
|
||||
_fields: &[Field],
|
||||
) -> Option<ScriptResult> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// No script engine (or JavaScript turned off): scripts change nothing.
|
||||
@@ -98,6 +113,13 @@ pub(crate) fn formatted(doc: &Document, f: &Field, value: &str, scripts: &mut dy
|
||||
Some(r.value)
|
||||
}
|
||||
|
||||
/// [`formatted`] with dates in English names, for widgets whose fonts can't draw the date
|
||||
/// language's; `None` when the field has no Format script or the English run didn't happen.
|
||||
pub(crate) fn formatted_in_english(doc: &Document, f: &Field, value: &str, scripts: &mut dyn Scripts) -> Option<String> {
|
||||
let js = f.actions.scripts.format.as_ref()?;
|
||||
scripts.rerun_with_english_dates(FieldEvent::Format, js, f, value, &fields(doc)).map(|r| r.value)
|
||||
}
|
||||
|
||||
/// Apply script changes to other fields (values written without re-running their scripts;
|
||||
/// read-only, required and visibility as flags). `except` is the event's own field, whose value
|
||||
/// the event sets.
|
||||
|
||||
@@ -426,9 +426,24 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
|
||||
};
|
||||
let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD);
|
||||
let mut undrawn = false;
|
||||
// The Format script again with English month and weekday names, once, for widgets whose
|
||||
// fonts can't draw the date language's (#5303).
|
||||
let mut english: Option<Option<String>> = None;
|
||||
for w in &f.widgets {
|
||||
let (stream, missing) = match &shown {
|
||||
Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false),
|
||||
Some(s) => {
|
||||
let drawn = appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false);
|
||||
// Taken when the English run happened and draws whole, even where it is the
|
||||
// stored value ("18-Oct-26" printed again from itself).
|
||||
let again = match (&drawn.1, values) {
|
||||
(Some(_), [one]) => english.get_or_insert_with(|| scripting::formatted_in_english(doc, f, one, scripts)).as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
match again.map(|e| appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(e), false)) {
|
||||
Some(english) if english.1.is_none() => english,
|
||||
_ => drawn,
|
||||
}
|
||||
}
|
||||
None => appearance::field_appearance_drawn(doc, f, w, values, true),
|
||||
};
|
||||
undrawn |= missing.is_some();
|
||||
|
||||
+186
-9
@@ -134,6 +134,177 @@ const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "Jun
|
||||
|
||||
const DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
|
||||
/// Month and weekday names (weekdays from Sunday): full, then short.
|
||||
type Names = ([&'static str; 12], [&'static str; 12], [&'static str; 7], [&'static str; 7]);
|
||||
|
||||
const ENGLISH: Names = (
|
||||
MONTHS,
|
||||
["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
DAYS,
|
||||
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||
);
|
||||
|
||||
static NAMES: std::sync::RwLock<Names> = std::sync::RwLock::new(ENGLISH);
|
||||
|
||||
/// Sets the month and weekday names `util.printd` writes for `mmmm`, `mmm`, `dddd` and `ddd`
|
||||
/// (weekdays from Sunday), as a localized Acrobat writes its own language's (#5303).
|
||||
/// `util.scand` reads them as well as English.
|
||||
pub fn set_date_names(months: [&'static str; 12], months_short: [&'static str; 12], days: [&'static str; 7], days_short: [&'static str; 7]) {
|
||||
*NAMES.write().unwrap_or_else(std::sync::PoisonError::into_inner) = (months, months_short, days, days_short);
|
||||
}
|
||||
|
||||
/// `text` with the month and weekday names of `lists` (in the order months, short months,
|
||||
/// weekdays, short weekdays) written as their English names, so a parser that reads English
|
||||
/// reads them. The longest name wins at each place, case ignored, and only a whole name: one
|
||||
/// that doesn't continue a word, nor end in a digit that a number continues. A name may hold
|
||||
/// digits ("10月"), spaces ("tháng 10"), dots ("ม.ค.") or combining vowel signs, which a
|
||||
/// word-by-word reader would split (#5303). Where a weekday and a month share a name (Italian
|
||||
/// "mar"), the order of the format's name fields (`mmm`, `ddd`) decides. English names are
|
||||
/// found too, so that one typed ("Tue" before "Mar") fills its field of the format as a
|
||||
/// localized one does.
|
||||
fn english_names(text: &str, fmt: &str, lists: [&[&str]; 4]) -> String {
|
||||
const EN: [&[&str]; 4] = [&MONTHS, &MONTHS, &DAYS, &DAYS];
|
||||
// No date is this long: hostile text is left as it is rather than scanned name by name.
|
||||
if text.len() > MAX_DATE_TEXT_BYTES {
|
||||
return text.to_string();
|
||||
}
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let typed_english: [&[&str]; 4] = [&ENGLISH.0, &ENGLISH.1, &ENGLISH.2, &ENGLISH.3];
|
||||
// Each name as folded chars, whether it is a month, and its English name.
|
||||
let mut names: Vec<(Vec<String>, bool, &str)> = Vec::new();
|
||||
for (k, (list, en)) in lists.into_iter().zip(EN).chain(typed_english.into_iter().zip(EN)).enumerate() {
|
||||
for (name, english) in list.iter().zip(en.iter()) {
|
||||
// A short name ending in a dot ("mar.") is also read typed without it, as the same
|
||||
// kind of name, so that where it equals another ("Mar", March) the format decides.
|
||||
let bare = name.trim_end_matches('.');
|
||||
for name in std::iter::once(*name).chain((bare.len() < name.len()).then_some(bare)) {
|
||||
let n: Vec<String> = name.chars().map(fold).collect();
|
||||
if !n.is_empty() && n.len() <= MAX_NAME_CHARS {
|
||||
names.push((n, k % 4 < 2, english));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort_by_key(|(n, ..)| std::cmp::Reverse(n.len()));
|
||||
// The format's name fields in order: true for a month, false for a weekday.
|
||||
let f: Vec<char> = fmt.chars().collect();
|
||||
let mut expected = Vec::new();
|
||||
let mut j = 0;
|
||||
while let Some(&c) = f.get(j) {
|
||||
let run = f.get(j..).map_or(1, |r| r.iter().take_while(|x| **x == c).count()).max(1);
|
||||
if run >= 3 && (c == 'm' || c == 'd') {
|
||||
expected.push(c == 'm');
|
||||
}
|
||||
j = j.saturating_add(run);
|
||||
}
|
||||
let mut slot = 0;
|
||||
// A name's first letter may not continue a word; its last may not run on into a letter, nor
|
||||
// (a digit) into a number.
|
||||
let letter = |c: Option<&char>| c.is_some_and(|c| c.is_alphabetic());
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
let fits = |n: &Vec<String>| {
|
||||
let end = i.saturating_add(n.len());
|
||||
let first = n.first().and_then(|e| e.chars().next());
|
||||
let last = n.last().and_then(|e| e.chars().next());
|
||||
chars.get(i..end).is_some_and(|t| t.iter().map(|c| fold(*c)).eq(n.iter().cloned()))
|
||||
&& !(first.is_some_and(char::is_alphabetic) && letter(i.checked_sub(1).and_then(|p| chars.get(p))))
|
||||
&& !match last {
|
||||
Some(l) if l.is_ascii_digit() => chars.get(end).is_some_and(char::is_ascii_digit),
|
||||
Some(l) if l.is_alphabetic() => letter(chars.get(end)),
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
let longest = names.iter().find(|(n, ..)| fits(n)).map(|(n, ..)| n.len());
|
||||
let hit = longest.and_then(|len| {
|
||||
let mut same = names.iter().filter(|(n, ..)| n.len() == len && fits(n));
|
||||
let want = expected.get(slot).copied();
|
||||
let all: Vec<_> = same.by_ref().collect();
|
||||
all.iter().find(|(_, month, _)| Some(*month) == want).or_else(|| all.first()).copied()
|
||||
});
|
||||
match hit {
|
||||
Some((n, month, english)) => {
|
||||
if expected.get(slot) == Some(month) {
|
||||
slot += 1;
|
||||
}
|
||||
// Apart from what touches it ("10月18"), but no space added at either end.
|
||||
if out.ends_with(char::is_alphanumeric) {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(english);
|
||||
i = i.saturating_add(n.len());
|
||||
if chars.get(i).is_some_and(|c| c.is_alphanumeric()) {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
None => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `c` as names are compared: lower case, with the capital I and the Turkish dotted and dotless
|
||||
/// i all "i", and a combining dot above (U+0307) dropped, so that "EKİM", "SALI" and "MAYIS" are
|
||||
/// Turkish "Ekim", "Salı" and "Mayıs", which lower-casing alone makes "ekim" with a dotted i,
|
||||
/// "sali" and "mayis" (#5303). The Greek final sigma is a sigma, since a capital Σ, which
|
||||
/// lower-cases to σ on its own, ends "ΙΑΝΟΥΆΡΙΟΣ" for "Ιανουάριος". One piece for each char, so
|
||||
/// positions in the text stay aligned.
|
||||
fn fold(c: char) -> String {
|
||||
match c {
|
||||
'I' | 'ı' | 'İ' => "i".to_string(),
|
||||
'ς' => "σ".to_string(),
|
||||
_ => c.to_lowercase().filter(|&l| l != '\u{307}').collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` [`fold`]ed char by char.
|
||||
fn folded(s: &str) -> String {
|
||||
s.chars().map(fold).collect()
|
||||
}
|
||||
|
||||
/// Typed dates longer than this are not searched for names.
|
||||
const MAX_DATE_TEXT_BYTES: usize = 1024;
|
||||
|
||||
/// Names longer than this are not looked for (settings are trusted, but a cap keeps the scan cheap).
|
||||
const MAX_NAME_CHARS: usize = 32;
|
||||
|
||||
fn names() -> Names {
|
||||
*NAMES.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
std::thread_local! {
|
||||
/// Whether `util.printd` on this thread writes English names, whatever the date language.
|
||||
static PRINTD_ENGLISH: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Runs `run` with `util.printd` writing English month and weekday names when `english` (scripts
|
||||
/// it starts carry this to their own thread): for an appearance whose fonts can't draw the date
|
||||
/// language's names, which would come out as `?` (#5303). `util.scand` still reads both.
|
||||
pub fn with_english_printd<R>(english: bool, run: impl FnOnce() -> R) -> R {
|
||||
struct Restore(bool);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
PRINTD_ENGLISH.with(|e| e.set(self.0));
|
||||
}
|
||||
}
|
||||
let _restore = Restore(PRINTD_ENGLISH.with(|e| e.replace(english)));
|
||||
run()
|
||||
}
|
||||
|
||||
/// Whether [`with_english_printd`] is on for this thread.
|
||||
pub(crate) fn printd_english() -> bool {
|
||||
PRINTD_ENGLISH.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// The names `util.printd` writes.
|
||||
fn printd_names() -> Names {
|
||||
if printd_english() { ENGLISH } else { names() }
|
||||
}
|
||||
|
||||
/// `util.printd(cFormat, oDate)`: yyyy yy mmmm mmm mm m dddd ddd dd d HH H hh h MM M ss s tt,
|
||||
/// and the numeric formats 0 (D:yyyymmddHHMMss), 1 (yyyy.mm.dd HH:MM:ss) and 2 (m/d/yy h:MM:ss tt).
|
||||
pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
@@ -161,6 +332,7 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu
|
||||
let (y, mo, day, wd, h, mi, sec) =
|
||||
(part("getFullYear")?, part("getMonth")?, part("getDate")?, part("getDay")?, part("getHours")?, part("getMinutes")?, part("getSeconds")?);
|
||||
let mut out = String::new();
|
||||
let (months, months_short, days, days_short) = printd_names();
|
||||
let b = fmt.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
@@ -186,13 +358,13 @@ pub(crate) fn printd(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResu
|
||||
}
|
||||
}
|
||||
b'm' => match run {
|
||||
r if r >= 4 => out.push_str(MONTHS[mo as usize % 12]),
|
||||
3 => out.push_str(&MONTHS[mo as usize % 12][..3]),
|
||||
r if r >= 4 => out.push_str(months.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()),
|
||||
3 => out.push_str(months_short.get(mo.rem_euclid(12) as usize).copied().unwrap_or_default()),
|
||||
n => push_num(&mut out, mo + 1, n),
|
||||
},
|
||||
b'd' => match run {
|
||||
r if r >= 4 => out.push_str(DAYS[wd as usize % 7]),
|
||||
3 => out.push_str(&DAYS[wd as usize % 7][..3]),
|
||||
r if r >= 4 => out.push_str(days.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()),
|
||||
3 => out.push_str(days_short.get(wd.rem_euclid(7) as usize).copied().unwrap_or_default()),
|
||||
n => push_num(&mut out, day, n),
|
||||
},
|
||||
b'H' => push_num(&mut out, h, run),
|
||||
@@ -435,11 +607,13 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option<Scanned> {
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::MonthName, .. }, ScanToken::Letters(l)) => {
|
||||
v.mo = Some(named(&MONTHS, l)?.saturating_add(1));
|
||||
let (months, months_short, ..) = names();
|
||||
v.mo = Some(named(&months, l).or_else(|| named(&months_short, l)).or_else(|| named(&MONTHS, l))?.saturating_add(1));
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::Weekday, .. }, ScanToken::Letters(l)) => {
|
||||
named(&DAYS, l)?;
|
||||
let (.., days, days_short) = names();
|
||||
named(&days, l).or_else(|| named(&days_short, l)).or_else(|| named(&DAYS, l))?;
|
||||
p += 1;
|
||||
}
|
||||
(ScanPart::Field { unit: DateUnit::AmPm, .. }, ScanToken::Letters(l)) => {
|
||||
@@ -509,10 +683,11 @@ fn scan_date(fmt: &str, text: &str, this_year: i32) -> Option<Scanned> {
|
||||
|
||||
/// The index of the name `word` is, in full or as its first three letters, in any case.
|
||||
fn named(names: &[&str], word: &str) -> Option<u32> {
|
||||
let word = word.to_lowercase();
|
||||
let word = folded(word);
|
||||
let i = names.iter().position(|n| {
|
||||
let n = n.to_lowercase();
|
||||
word == n || n.get(..3) == Some(word.as_str())
|
||||
let n = folded(n);
|
||||
// A short name may end in a dot ("Okt."); three letters may be cut from any name.
|
||||
word == n || word == n.trim_end_matches('.') || n.chars().take(3).collect::<String>() == word
|
||||
})?;
|
||||
u32::try_from(i).ok()
|
||||
}
|
||||
@@ -524,6 +699,8 @@ pub(crate) fn scand(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResul
|
||||
return Ok(JsValue::null());
|
||||
};
|
||||
let fmt = text(&arg(args, 0), ctx)?;
|
||||
let (months, months_short, days, days_short) = names();
|
||||
let typed = english_names(&typed, &fmt, [&months, &months_short, &days, &days_short]);
|
||||
let date = JsDate::new(ctx);
|
||||
let this_year = date.get_full_year(ctx)?.to_number(ctx)?;
|
||||
let this_year = if this_year.is_finite() { this_year as i32 } else { 2000 };
|
||||
|
||||
@@ -44,6 +44,7 @@ mod run;
|
||||
#[cfg(test)]
|
||||
pub(crate) use format::PRINTD_SCANNED;
|
||||
use format::{printd, printf, printx, scand};
|
||||
pub use format::{set_date_names, with_english_printd};
|
||||
pub(crate) use globals::menu_item;
|
||||
use globals::platform;
|
||||
use host::{arg, as_number, error, function, s, text};
|
||||
|
||||
@@ -129,11 +129,13 @@ pub fn run_shared(script: &str, event: &Event, doc: &DocInfo, fields: Arc<Vec<Fi
|
||||
let (kept, skipped) = (&kept, &skipped);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// The script's thread writes dates as this one asked (`with_english_printd`).
|
||||
let english = crate::format::printd_english();
|
||||
std::thread::scope(|scope| {
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("pdfcraft-js".into())
|
||||
.stack_size(SCRIPT_STACK)
|
||||
.spawn_scoped(scope, || run_here(script, event, doc, fields, kept, skipped, limits));
|
||||
.spawn_scoped(scope, || crate::format::with_english_printd(english, || run_here(script, event, doc, fields, kept, skipped, limits)));
|
||||
match spawned {
|
||||
Ok(thread) => thread.join().unwrap_or_else(|_| failed("the script stopped with an internal error".into())),
|
||||
Err(e) => failed(format!("the script engine could not start: {e}")),
|
||||
|
||||
@@ -233,6 +233,7 @@ impl PdfCraftApp {
|
||||
scan_clean_run: None,
|
||||
progress_notice: None,
|
||||
page_clipboard: None,
|
||||
comment_clipboard: None,
|
||||
grid_drop: None,
|
||||
last_snapshot: None,
|
||||
last_copied_image: None,
|
||||
@@ -452,6 +453,9 @@ impl eframe::App for PdfCraftApp {
|
||||
// Notices raised outside `ui` (opened files, OS events, the control channel) translate too.
|
||||
let lang = i18n::Lang::from_pref(&self.language);
|
||||
i18n::set_current(lang);
|
||||
// Form date fields name months in the date language (Preferences ▸ Date format), which
|
||||
// follows the interface language until one is picked (#5303).
|
||||
pdfcraft_engine::dates::use_form_date_language(self.session.date_language().unwrap_or(lang.code()));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(worker) = &self.open_worker {
|
||||
|
||||
@@ -11,7 +11,7 @@ pub(crate) fn print_refused() -> &'static str {
|
||||
}
|
||||
|
||||
enum ClipboardPaste {
|
||||
Text,
|
||||
Text(String),
|
||||
Image(std::sync::Arc<egui::ColorImage>),
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ impl PdfCraftApp {
|
||||
false
|
||||
}
|
||||
|
||||
/// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus).
|
||||
/// ⌘C / ⌘X / ⌘V copy, cut and paste text, images or comments on canvas (unless a text field
|
||||
/// has focus). With no text selected, ⌘C copies the selected comments, each with its replies.
|
||||
pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let pastes: Vec<_> = ctx.input(|input| {
|
||||
@@ -35,7 +36,7 @@ impl PdfCraftApp {
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
egui::Event::Paste(_) => Some(ClipboardPaste::Text),
|
||||
egui::Event::Paste(text) => Some(ClipboardPaste::Text(text.clone())),
|
||||
egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())),
|
||||
_ => None,
|
||||
})
|
||||
@@ -52,7 +53,9 @@ impl PdfCraftApp {
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
|
||||
let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
|
||||
|
||||
let text_selected = copy && self.views.get(i).and_then(|v| v.selected_text()).is_some_and(|t| !t.is_empty());
|
||||
if copy
|
||||
&& (text_selected || !self.copy_selected_comments(i))
|
||||
&& !self.copy_selected_text(i, ctx)
|
||||
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
@@ -89,16 +92,21 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, images_allowed: bool) {
|
||||
/// `on_canvas`: the paste is the canvas's (no text field, dialog or page grid has it), so
|
||||
/// pictures and copied comments land on the page.
|
||||
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, on_canvas: bool) {
|
||||
let page = self.views.get(view).map_or(0, |view| view.current);
|
||||
for paste in pastes {
|
||||
match paste {
|
||||
ClipboardPaste::Text => {
|
||||
ClipboardPaste::Text(text) => {
|
||||
// Text fields may own the paste; the canvas cache still observes its contents.
|
||||
self.last_copied_image = None;
|
||||
self.last_copied_file = None;
|
||||
if on_canvas {
|
||||
self.paste_comment_text(view, text);
|
||||
}
|
||||
}
|
||||
ClipboardPaste::Image(image) if images_allowed => {
|
||||
ClipboardPaste::Image(image) if on_canvas => {
|
||||
self.paste_received_image(page, &image);
|
||||
}
|
||||
ClipboardPaste::Image(_) => {}
|
||||
@@ -106,6 +114,82 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A text pasted onto the canvas: XFDF with comments is pasted as comments. Copied comments
|
||||
/// come from the system clipboard when it holds them, from the app's own only when it holds
|
||||
/// nothing legible (a word copied in another app since is that word, not the comments copied
|
||||
/// before it).
|
||||
fn paste_comment_text(&mut self, i: usize, text: String) {
|
||||
if pdfcraft_engine::is_xfdf_comments(&text) {
|
||||
self.paste_comments(i, Some(text), None);
|
||||
} else if text.trim().is_empty() && self.comment_clipboard.is_some() {
|
||||
self.paste_comments(i, None, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the selected comments of tab `i` as XFDF, each with its replies: into the app's
|
||||
/// clipboard (for ⌘V in any tab or window) and onto the system clipboard as text. False
|
||||
/// when nothing is selected.
|
||||
pub fn copy_selected_comments(&mut self, i: usize) -> bool {
|
||||
let Some(view) = self.views.get(i) else { return false };
|
||||
let keys = view.comments.selection();
|
||||
if keys.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let id = view.id;
|
||||
match self.session.export_comments(id, &keys) {
|
||||
Ok(xfdf) => {
|
||||
if let Some(ctx) = &self.ctx {
|
||||
ctx.copy_text(xfdf.clone());
|
||||
}
|
||||
self.comment_clipboard = Some(CommentClip { xfdf, count: keys.len() });
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
self.notify_error(e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste copied comments (`text`, XFDF from the system clipboard, or else the app's
|
||||
/// clipboard) into tab `i`. With `at` (a page and a user-space point, from the page's menu)
|
||||
/// the bundle is placed at once, centred there; otherwise it hangs on the pointer as one
|
||||
/// bundle until a click places it (`Gesture::Place`), so a copy never lands on its original
|
||||
/// and a bundle from a large page goes where it fits on a small one. The copies are selected
|
||||
/// once placed (see `apply_edit`).
|
||||
pub fn paste_comments(&mut self, i: usize, text: Option<String>, at: Option<(usize, [f64; 2])>) -> bool {
|
||||
let Some(xfdf) = text.or_else(|| self.comment_clipboard.as_ref().map(|c| c.xfdf.clone())) else {
|
||||
self.notify_tr("Copy comments first");
|
||||
return false;
|
||||
};
|
||||
let Some(view) = self.views.get(i) else { return false };
|
||||
let id = view.id;
|
||||
let Some(doc) = self.session.get(id) else { return false };
|
||||
if !doc.allows_annotation() {
|
||||
self.notify_tr("The document's security settings don't allow this change");
|
||||
return false;
|
||||
}
|
||||
let count = pdfcraft_engine::comment_count(&xfdf);
|
||||
let Some(bounds) = pdfcraft_engine::comments_bounds(&xfdf).filter(|_| count > 0) else {
|
||||
self.notify_tr("The clipboard holds no comments");
|
||||
return false;
|
||||
};
|
||||
match at {
|
||||
Some((page, [x, y])) => {
|
||||
let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0];
|
||||
self.apply_edit(pdfcraft_engine::Edit::PasteComments { xfdf, page, offset: Some(offset) })
|
||||
}
|
||||
None => {
|
||||
let Some(view) = self.views.get_mut(i) else { return false };
|
||||
view.comments.clear_selection();
|
||||
let outlines = pdfcraft_engine::comment_outlines(&xfdf);
|
||||
view.comments.gesture = Some(comments::Gesture::Place { xfdf, bounds, count, outlines });
|
||||
self.notify_tr("Click where the copied comments go; Escape lets them go");
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared permission gate for copying selected page text from menus and keyboard events.
|
||||
/// After Select All of the document (#2639), pages whose text wasn't read for display are read
|
||||
/// off the UI thread ([`DocumentCopy`]), and the clipboard gets the text when they're done.
|
||||
|
||||
@@ -393,7 +393,7 @@ impl PdfCraftApp {
|
||||
let (p, i) = value.split_once(':').ok_or("comment: PAGE:INDEX")?;
|
||||
let (p, i): (usize, usize) =
|
||||
(p.trim().parse().map_err(|_| "comment: PAGE:INDEX")?, i.trim().parse().map_err(|_| "comment: PAGE:INDEX")?);
|
||||
v.comments.selected = Some((p.saturating_sub(1), i.saturating_sub(1)));
|
||||
v.comments.select_only((p.saturating_sub(1), i.saturating_sub(1)));
|
||||
v.comments.reveal = true;
|
||||
}
|
||||
// `--combine-view list`: Combine files as thumbnails (grid) or as the table (list).
|
||||
|
||||
@@ -116,6 +116,8 @@ impl PdfCraftApp {
|
||||
"tool": t.command(),
|
||||
"color": s.color,
|
||||
"opacity": s.opacity,
|
||||
"fill_opacity": s.fill_opacity,
|
||||
"text_opacity": s.text_opacity,
|
||||
"width": s.width,
|
||||
"fill": s.fill,
|
||||
}))
|
||||
@@ -356,6 +358,8 @@ impl PdfCraftApp {
|
||||
if let Some(o) = e["opacity"].as_f64() {
|
||||
self.comment_prefs.set_opacity(tool, o);
|
||||
}
|
||||
// The fill's and text's own opacities, where saved (older settings have none).
|
||||
self.comment_prefs.set_part_opacity(tool, e["fill_opacity"].as_f64(), e["text_opacity"].as_f64());
|
||||
if let Some(w) = e["width"].as_f64() {
|
||||
self.comment_prefs.set_width(tool, w);
|
||||
}
|
||||
|
||||
@@ -776,6 +776,8 @@ pub(crate) enum PageMenuAction {
|
||||
MarqueeZoom,
|
||||
Copy,
|
||||
Paste(usize, [f64; 2]),
|
||||
/// Paste the copied comments, centred at this point of the page.
|
||||
PasteComments(usize, [f64; 2]),
|
||||
AddComment(usize, [f64; 2]),
|
||||
AddBookmark,
|
||||
TakeSnapshot,
|
||||
@@ -797,6 +799,8 @@ pub(crate) struct PageMenuPerms {
|
||||
pub can_copy: bool,
|
||||
pub can_copy_items: bool,
|
||||
pub can_paste: bool,
|
||||
/// Copied comments wait to be pasted, and comments may be added.
|
||||
pub can_paste_comments: bool,
|
||||
pub has_annotations: bool,
|
||||
}
|
||||
|
||||
@@ -838,6 +842,9 @@ pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64
|
||||
ui,
|
||||
A::Paste(page, at),
|
||||
);
|
||||
if perms.can_paste_comments {
|
||||
pick(ui.button(tl!("Paste comments")), ui, A::PasteComments(page, at));
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
|
||||
@@ -1153,6 +1160,10 @@ struct PageEnv<'a> {
|
||||
by_line: &'a String,
|
||||
calc_order: bool,
|
||||
can_copy: bool,
|
||||
/// Copied comments wait to be pasted.
|
||||
can_paste_comments: bool,
|
||||
/// Copied comments hang on the pointer (`Gesture::Place`): the click is theirs.
|
||||
placing: bool,
|
||||
can_crop: bool,
|
||||
can_fill: bool,
|
||||
can_modify: bool,
|
||||
@@ -1209,6 +1220,8 @@ struct PageEnv<'a> {
|
||||
struct PageOut {
|
||||
canvas_action: Option<comments::CanvasAction>,
|
||||
clicked_link: Option<LinkTarget>,
|
||||
comment_notice: Option<&'static str>,
|
||||
copy_comments: bool,
|
||||
copy_selected: bool,
|
||||
field_menu: Option<FieldMenu>,
|
||||
field_placed: bool,
|
||||
@@ -1220,6 +1233,7 @@ struct PageOut {
|
||||
open_signature: bool,
|
||||
open_table: Option<(usize, [f64; 4])>,
|
||||
page_action: Option<PageMenuAction>,
|
||||
paste_comments_at: Option<(usize, [f64; 2])>,
|
||||
primary_down: bool,
|
||||
refused: Option<String>,
|
||||
/// The current page or spread where it is drawn, every page of it: one off screen (the second
|
||||
@@ -1606,6 +1620,7 @@ fn draw_pages(
|
||||
coarse_scroll,
|
||||
calc_order,
|
||||
can_copy,
|
||||
can_paste_comments,
|
||||
can_modify,
|
||||
content_h,
|
||||
content_w,
|
||||
@@ -1760,6 +1775,7 @@ fn draw_pages(
|
||||
refused,
|
||||
row_on_screen: _,
|
||||
stamp_placed,
|
||||
..
|
||||
} = page_out;
|
||||
let mut page_action: Option<PageMenuAction> = None;
|
||||
let painter = ui.painter();
|
||||
@@ -1920,10 +1936,20 @@ fn draw_pages(
|
||||
crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation);
|
||||
return;
|
||||
}
|
||||
if let Some((page, Owner::Comment { index, .. })) = view.menu_for
|
||||
&& view.comments.selected == Some((page, index))
|
||||
{
|
||||
canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify);
|
||||
// The comment the right-click went to, when it is the one selected, or connected lines being
|
||||
// drawn (Complete, Undo last point): the comment menu.
|
||||
let on_selected_comment =
|
||||
matches!(view.menu_for, Some((page, Owner::Comment { index, .. })) if view.comments.selected == Some((page, index)));
|
||||
let drawing_points = matches!(&view.comments.gesture, Some(comments::Gesture::Draw { tool, .. }) if tool.clicks_points());
|
||||
if on_selected_comment || drawing_points {
|
||||
canvas_action = comments::context_menu(
|
||||
ui,
|
||||
view,
|
||||
info,
|
||||
&|page, index| doc.annotation_deletable(page, index),
|
||||
prefs,
|
||||
comments::MenuRights { allowed, can_paste: can_paste_comments, can_modify },
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Over a picture (and no comment): Copy Image, as in Acrobat's page menu.
|
||||
@@ -1967,13 +1993,20 @@ fn draw_pages(
|
||||
can_copy,
|
||||
can_copy_items,
|
||||
can_paste,
|
||||
can_paste_comments: allowed && can_paste_comments,
|
||||
has_annotations: !info.annotations.is_empty(),
|
||||
};
|
||||
default_page_context_menu(ui, page, at, perms, &mut page_action);
|
||||
});
|
||||
// Copy comments / Paste comments from the page's menu or a Comments panel card.
|
||||
let copy_comments = std::mem::take(&mut view.comments.copy_request);
|
||||
let paste_comments_at = view.comments.paste_request.take();
|
||||
let comment_notice = view.comments.notice.take();
|
||||
*out = PageOut {
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
comment_notice,
|
||||
copy_comments,
|
||||
copy_selected,
|
||||
field_menu,
|
||||
field_placed,
|
||||
@@ -1985,6 +2018,7 @@ fn draw_pages(
|
||||
open_signature,
|
||||
open_table,
|
||||
page_action,
|
||||
paste_comments_at,
|
||||
primary_down: ptr.primary_down,
|
||||
refused,
|
||||
row_on_screen,
|
||||
@@ -2767,6 +2801,15 @@ fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> V
|
||||
glide
|
||||
}
|
||||
|
||||
/// The tool the page sees this frame, and whether copied comments hang on the pointer. Those
|
||||
/// take the next click whatever tool is on: the page sees the Select tool until the bundle is
|
||||
/// placed or let go, and no form field, added content or field being prepared takes the click
|
||||
/// either.
|
||||
fn page_tool(view: &DocView, chosen: QuickTool) -> (QuickTool, bool) {
|
||||
let placing = matches!(view.comments.gesture, Some(comments::Gesture::Place { .. }));
|
||||
(if placing { QuickTool::Select } else { chosen }, placing)
|
||||
}
|
||||
|
||||
/// Text selection runs for the Select tool and for the markup tools (highlight…).
|
||||
fn tool_selects_text(tool: QuickTool, preparing: bool) -> bool {
|
||||
match tool {
|
||||
@@ -2969,7 +3012,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let sharp_held = hold_sharp_renders(ui, view, scale);
|
||||
view.current_scale_tag = tag;
|
||||
let hand = app.quick_tool == QuickTool::Hand;
|
||||
let tool = app.quick_tool;
|
||||
let (tool, placing) = page_tool(view, app.quick_tool);
|
||||
// Text selection runs for the Select tool and for the markup tools (highlight…).
|
||||
let selects_text = tool_selects_text(tool, preparing);
|
||||
let prefs = &app.comment_prefs;
|
||||
@@ -2977,6 +3020,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let units = app.units;
|
||||
let allowed = doc.allows_annotation();
|
||||
let can_copy = doc.allows_copying();
|
||||
let can_paste_comments = app.comment_clipboard.is_some();
|
||||
let comments_hidden = doc.comments_hidden();
|
||||
let form = doc.form.clone();
|
||||
let generation = doc.edit_generation();
|
||||
@@ -2989,11 +3033,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
// In Preferences ▸ Date format, or why it can't be stamped into the PDF.
|
||||
let date_text = crate::date_text_for_pdf(&app.session);
|
||||
let by_line = app.session.stamp_by_line(&author);
|
||||
let custom_stamp = match app.quick_tool {
|
||||
let custom_stamp = match tool {
|
||||
QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(),
|
||||
_ => None,
|
||||
};
|
||||
let mut open_props: Option<(usize, usize)> = None;
|
||||
let mut recolour: Option<pdfcraft_engine::Rgb> = None;
|
||||
// The table being set up in its window, shown on its own document's page.
|
||||
let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form)));
|
||||
let can_modify = doc.allows_modification();
|
||||
@@ -3025,6 +3070,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
by_line: &by_line,
|
||||
calc_order,
|
||||
can_copy,
|
||||
can_paste_comments,
|
||||
placing,
|
||||
can_crop,
|
||||
can_fill,
|
||||
can_modify,
|
||||
@@ -3082,6 +3129,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let PageOut {
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
comment_notice,
|
||||
copy_comments,
|
||||
mut copy_selected,
|
||||
field_menu,
|
||||
field_placed,
|
||||
@@ -3093,6 +3142,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
open_signature,
|
||||
open_table,
|
||||
page_action,
|
||||
paste_comments_at,
|
||||
primary_down,
|
||||
mut refused,
|
||||
row_on_screen,
|
||||
@@ -3177,6 +3227,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
app.comments_panel_closed = false;
|
||||
}
|
||||
Some(comments::CanvasAction::Properties(p, i)) => open_props = Some((p, i)),
|
||||
Some(comments::CanvasAction::Colour(c)) => recolour = Some(c),
|
||||
Some(comments::CanvasAction::CopyImage(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::Copy(p, i)),
|
||||
Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)),
|
||||
None => {}
|
||||
@@ -3201,6 +3252,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
open_table,
|
||||
form_notice,
|
||||
copy_selected,
|
||||
copy_comments,
|
||||
paste_comments_at,
|
||||
comment_notice,
|
||||
recolour,
|
||||
remote_open,
|
||||
form,
|
||||
generation,
|
||||
@@ -3223,6 +3278,13 @@ struct FrameRequests {
|
||||
open_table: Option<(usize, [f64; 4])>,
|
||||
form_notice: Option<crate::forms_ui::FormNotice>,
|
||||
copy_selected: bool,
|
||||
/// Copy comments / Paste comments from the page's menu or a Comments panel card, and what
|
||||
/// the comment tools have to say.
|
||||
copy_comments: bool,
|
||||
paste_comments_at: Option<(usize, [f64; 2])>,
|
||||
comment_notice: Option<&'static str>,
|
||||
/// A colour picked for every selected comment (Colour in the page's menu).
|
||||
recolour: Option<pdfcraft_engine::Rgb>,
|
||||
remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)>,
|
||||
form: Arc<Vec<pdfcraft_engine::FormField>>,
|
||||
generation: u64,
|
||||
@@ -3245,6 +3307,10 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
open_table,
|
||||
form_notice,
|
||||
copy_selected,
|
||||
copy_comments,
|
||||
paste_comments_at,
|
||||
comment_notice,
|
||||
recolour,
|
||||
remote_open,
|
||||
form,
|
||||
generation,
|
||||
@@ -3256,6 +3322,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
if let Some((p, i)) = open_props {
|
||||
app.open_comment_props(p, i);
|
||||
}
|
||||
if let Some(c) = recolour {
|
||||
app.colour_selected_comments(c);
|
||||
}
|
||||
if let Some((p, i)) = app.views[index].comments.default_request.take() {
|
||||
app.make_comment_default(p, i);
|
||||
}
|
||||
@@ -3351,6 +3420,9 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
Some(PageMenuAction::Paste(page, at)) => {
|
||||
app.paste_image_at(page, at);
|
||||
}
|
||||
Some(PageMenuAction::PasteComments(page, at)) => {
|
||||
app.paste_comments(index, None, Some((page, at)));
|
||||
}
|
||||
Some(PageMenuAction::RotateCCW) => {
|
||||
app.views[index].rotate_view(false);
|
||||
}
|
||||
@@ -3439,6 +3511,15 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
if copy_selected {
|
||||
app.execute("edit.copy");
|
||||
}
|
||||
if copy_comments {
|
||||
app.execute("comment.copy");
|
||||
}
|
||||
if let Some(at) = paste_comments_at {
|
||||
app.paste_comments(index, None, Some(at));
|
||||
}
|
||||
if let Some(notice) = comment_notice {
|
||||
app.notify_tr(notice);
|
||||
}
|
||||
// Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459);
|
||||
// the quick tools come back with the window.
|
||||
if app.mode != crate::Mode::Read && !app.full_screen {
|
||||
|
||||
@@ -58,6 +58,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
|
||||
editing_content,
|
||||
form,
|
||||
info,
|
||||
placing,
|
||||
prefs,
|
||||
preparing,
|
||||
redact_mark,
|
||||
@@ -169,14 +170,14 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
|
||||
let over_mark = matches!(tool, QuickTool::Fill(_))
|
||||
&& (matches!(press, PagePress::Mine(_)) || comments::moving_on(view, i) || !ptr.any_down && hovered.is_some());
|
||||
tool_input(ui, view, frame, slot, &pcx, press, over_mark);
|
||||
let on_field = if preparing {
|
||||
let on_field = if preparing && !placing {
|
||||
let o = crate::prepare::page_input(cx, form, field_tool, prepare_allowed, press, view);
|
||||
frame.out.field_props |= o.properties;
|
||||
frame.out.field_placed |= o.placed;
|
||||
frame.out.open_table = frame.out.open_table.or(o.table);
|
||||
o.consumed || field_tool.is_some()
|
||||
} else {
|
||||
tool == QuickTool::Select && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view)
|
||||
tool == QuickTool::Select && !placing && crate::forms_ui::page_input(cx, form, can_fill, press, released_over, view)
|
||||
};
|
||||
// Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat).
|
||||
let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed;
|
||||
@@ -206,7 +207,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
|
||||
edit_on_click: matches!(tool, QuickTool::AddText | QuickTool::EditText),
|
||||
style: text_style,
|
||||
};
|
||||
let on_content = editing_content && can_modify && crate::content_ui::page_input(cx, ccx, view);
|
||||
let on_content = !placing && editing_content && can_modify && crate::content_ui::page_input(cx, ccx, view);
|
||||
// Add text acts on the page's own paragraphs: the one its press was on, until the button is up,
|
||||
// and the one under the pointer. Edit text boxes every paragraph and picture.
|
||||
let paragraph = |o: Option<Owner>| matches!(o, Some(Owner::Paragraph { .. }));
|
||||
|
||||
@@ -26,6 +26,7 @@ pub(crate) const NEXT_VIEW: Shortcut = Shortcut::cmd("]");
|
||||
pub(crate) const FIND_NEXT: Shortcut = Shortcut::cmd("G");
|
||||
pub(crate) const FIND_PREV: Shortcut = Shortcut::cmd_shift("G");
|
||||
pub(crate) const COPY: Shortcut = Shortcut::cmd("C");
|
||||
pub(crate) const PASTE: Shortcut = Shortcut::cmd("V");
|
||||
pub(crate) const SELECT_ALL: Shortcut = Shortcut::cmd("A");
|
||||
pub(crate) const PAGE_PREV: Shortcut = Shortcut::cmd("←");
|
||||
pub(crate) const PAGE_NEXT: Shortcut = Shortcut::cmd("→");
|
||||
@@ -95,6 +96,8 @@ impl PdfCraftApp {
|
||||
.is_some_and(|v| !v.organize && (v.all_text || v.selected_text().is_some_and(|t| !t.is_empty()))))
|
||||
&& (spec.needs != commands::Needs::TwoPageView || self.active.and_then(|i| self.views.get(i)).is_some_and(crate::DocView::cover_applies))
|
||||
&& match spec.id {
|
||||
"comment.copy" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| v.comments.selected.is_some()),
|
||||
"comment.paste" => self.comment_clipboard.is_some(),
|
||||
"view.previous_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.back.is_empty()),
|
||||
"view.next_view" => self.active.and_then(|i| self.views.get(i)).is_some_and(|v| !v.forward.is_empty()),
|
||||
_ => true,
|
||||
@@ -570,6 +573,14 @@ impl PdfCraftApp {
|
||||
}
|
||||
return Some(false);
|
||||
}
|
||||
"comment.copy" => {
|
||||
let Some(i) = active else { return Some(false) };
|
||||
return Some(self.copy_selected_comments(i));
|
||||
}
|
||||
"comment.paste" => {
|
||||
let Some(i) = active else { return Some(false) };
|
||||
return Some(self.paste_comments(i, None, None));
|
||||
}
|
||||
"comment.hide_all" => {
|
||||
if let Some(i) = active {
|
||||
let id = self.views[i].id;
|
||||
|
||||
@@ -96,6 +96,8 @@ impl Host for crate::PdfCraftApp {
|
||||
// Where pages are on screen (points), to aim ui.click / ui.drag at page content.
|
||||
"pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::<Vec<_>>(),
|
||||
"selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })),
|
||||
// Every selected comment (⌘-click adds), the one with the handles first.
|
||||
"selected_comments": v.comments.selection().iter().map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })).collect::<Vec<_>>(),
|
||||
"comment_composer_open": v.comments.composer.is_some(),
|
||||
})),
|
||||
// The Combine files tab: how its files show, and each grid thumbnail's state.
|
||||
|
||||
@@ -26,6 +26,9 @@ pub struct PropsDraft {
|
||||
/// Acrobat's Make Properties Default box: on OK, new comments of this kind take the edited
|
||||
/// colour, opacity and line width too.
|
||||
pub make_default: bool,
|
||||
/// The other selected comments, each with its own properties: OK makes the same changes to
|
||||
/// them where they apply (a colour to every one, a font size to text boxes, …).
|
||||
pub others: Vec<(usize, usize, CommentProps)>,
|
||||
}
|
||||
|
||||
const ICONS: [NoteIcon; 14] = NoteIcon::ALL;
|
||||
@@ -108,56 +111,150 @@ impl PdfCraftApp {
|
||||
style.fill = p.fill;
|
||||
}
|
||||
self.comment_prefs.set_style(tool, style);
|
||||
// The fill's and text's own opacities where they show (else the opacity is the whole
|
||||
// comment's). With neither a border nor a fill the text is the whole comment: its drawn
|
||||
// opacity, which Comment Properties shows, is the whole comment's default.
|
||||
let (fill, text) = parts_shown(p);
|
||||
// Keep invisible defaults out of tools where a zero stroke would make them disappear.
|
||||
self.comment_prefs.set_opacity(tool, p.opacity);
|
||||
self.comment_prefs.set_opacity(tool, p.text_opacity.filter(|_| !text).unwrap_or(p.opacity));
|
||||
self.comment_prefs.set_part_opacity(tool, p.fill_opacity.filter(|_| fill), p.text_opacity.filter(|_| text));
|
||||
if let Some(w) = p.width {
|
||||
self.comment_prefs.set_width(tool, w);
|
||||
}
|
||||
self.notify_fmt("New {tool} comments will look like this one", &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))]);
|
||||
}
|
||||
|
||||
/// Open Comment Properties for the comment at `(page, index)` of the active document.
|
||||
/// Give every selected comment of the active document `color`, as one undo step.
|
||||
pub fn colour_selected_comments(&mut self, color: pdfcraft_engine::Rgb) {
|
||||
self.colour_selection(color, |_| true);
|
||||
}
|
||||
|
||||
/// Give the selected comments that `only` picks `color`, as one undo step: those whose look can
|
||||
/// change and that aren't locked (a locked one would refuse the whole step).
|
||||
pub(crate) fn colour_selection(&mut self, color: pdfcraft_engine::Rgb, only: impl Fn(&pdfcraft_render::Annotation) -> bool) {
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let keys = self.views.get(i).map(|v| v.comments.selection()).unwrap_or_default();
|
||||
let mut edits: Vec<Edit> = keys
|
||||
.into_iter()
|
||||
.filter(|(p, ix)| doc.info.annotations.iter().any(|a| a.page == *p && a.index == *ix && !a.is_reply() && only(a)))
|
||||
.filter(|(p, ix)| doc.comment_props(*p, *ix).is_some_and(|c| c.restylable && !c.locked))
|
||||
.map(|(page, index)| Edit::StyleAnnotation { page, index, color: Some(color), opacity: None, width: None, endings: None })
|
||||
.collect();
|
||||
match edits.len() {
|
||||
0 => {}
|
||||
1 => {
|
||||
self.apply_edit(edits.remove(0));
|
||||
}
|
||||
_ => {
|
||||
self.apply_edit(Edit::Batch { label: "Change comment properties".into(), edits });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open Comment Properties for the comment at `(page, index)` of the active document. When it
|
||||
/// is one of several selected comments, the changes go to all of them.
|
||||
pub fn open_comment_props(&mut self, page: usize, index: usize) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
let Some(p) = self.session.get(id).and_then(|d| d.comment_props(page, index)) else { return };
|
||||
self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p, make_default: false });
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let Some(p) = doc.comment_props(page, index) else { return };
|
||||
let selection = self.views.get(i).map(|v| &v.comments).filter(|c| c.is_selected((page, index))).map(|c| c.selection()).unwrap_or_default();
|
||||
let others =
|
||||
selection.into_iter().filter(|k| *k != (page, index)).filter_map(|(pg, ix)| doc.comment_props(pg, ix).map(|c| (pg, ix, c))).collect();
|
||||
self.comment_props = Some(PropsDraft { page, index, tab: PropsTab::Appearance, original: p.clone(), edited: p, make_default: false, others });
|
||||
self.dialog = Some(crate::Dialog::CommentProps);
|
||||
}
|
||||
}
|
||||
|
||||
/// The edits that make the comment match the draft (empty when nothing changed).
|
||||
/// Which of the comment's parts show an opacity of their own: (its fill, its text). A text box's
|
||||
/// text, border and fill each do once it has a border or a fill; a filled shape's line and fill
|
||||
/// do; anything else has one opacity for the whole comment.
|
||||
pub fn parts_shown(p: &CommentProps) -> (bool, bool) {
|
||||
let fill = p.fill_opacity.is_some() && p.fill.is_some();
|
||||
let text = p.text_opacity.is_some() && (fill || has_border(p));
|
||||
(fill, text)
|
||||
}
|
||||
|
||||
/// A text box's, typewriter text's or callout's border is drawn.
|
||||
fn has_border(p: &CommentProps) -> bool {
|
||||
p.font_size.is_some() && p.width.is_some_and(|w| w > 0.0)
|
||||
}
|
||||
|
||||
/// The edits that make the comment match the draft (empty when nothing changed), and make the
|
||||
/// same changes to the other selected comments where they apply. Locks come off first and go on
|
||||
/// last, as a locked comment refuses other changes; another comment that stays locked is left
|
||||
/// as it is.
|
||||
pub fn edits(d: &PropsDraft) -> Vec<Edit> {
|
||||
let (o, e) = (&d.original, &d.edited);
|
||||
let targets: Vec<(usize, usize, &CommentProps)> =
|
||||
std::iter::once((d.page, d.index, o)).chain(d.others.iter().map(|(p, i, c)| (*p, *i, c))).collect();
|
||||
let mut out = Vec::new();
|
||||
// A locked comment refuses other changes: unlock first, lock last.
|
||||
if o.locked && !e.locked {
|
||||
out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: false });
|
||||
// Unticking Locked unlocks every selected comment.
|
||||
let unlocking = o.locked && !e.locked;
|
||||
for &(page, index, t) in &targets {
|
||||
if unlocking && t.locked {
|
||||
out.push(Edit::LockAnnotation { page, index, locked: false });
|
||||
}
|
||||
}
|
||||
for (n, &(page, index, t)) in targets.iter().enumerate() {
|
||||
if n > 0 && t.locked && !unlocking {
|
||||
continue;
|
||||
}
|
||||
changes(o, e, page, index, t, &mut out);
|
||||
}
|
||||
for &(page, index, t) in &targets {
|
||||
if !o.locked && e.locked && !t.locked {
|
||||
out.push(Edit::LockAnnotation { page, index, locked: true });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The changes from `o` to `e` (the draft) for the comment at `(page, index)`, whose own
|
||||
/// properties are `t`: only those its kind has (a line width for shapes and lines, a fill for
|
||||
/// shapes, a font size for text, …).
|
||||
fn changes(o: &CommentProps, e: &CommentProps, page: usize, index: usize, t: &CommentProps, out: &mut Vec<Edit>) {
|
||||
let color = (e.color != o.color).then_some(e.color).flatten();
|
||||
let opacity = ((e.opacity - o.opacity).abs() > 1e-6).then_some(e.opacity);
|
||||
let width = (e.width != o.width).then_some(e.width).flatten();
|
||||
let endings = (e.endings != o.endings).then(|| e.endings.clone()).flatten();
|
||||
if e.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) {
|
||||
out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width, endings });
|
||||
let width = (e.width != o.width).then_some(e.width).flatten().filter(|_| t.width.is_some());
|
||||
let endings =
|
||||
(e.endings != o.endings).then(|| e.endings.clone()).flatten().filter(|ends| t.endings.as_ref().is_some_and(|own| own.len() == ends.len()));
|
||||
if t.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) {
|
||||
out.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings });
|
||||
}
|
||||
if e.restylable && e.fillable && e.fill != o.fill {
|
||||
out.push(Edit::FillAnnotation { page: d.page, index: d.index, fill: e.fill });
|
||||
if t.restylable && t.fillable && e.fill != o.fill {
|
||||
out.push(Edit::FillAnnotation { page, index, fill: e.fill });
|
||||
}
|
||||
if e.restylable
|
||||
// After the opacity, which is the whole comment's: the parts shown are set again. Which
|
||||
// parts show is the comment's own once the draft's fill and line width are on it, so another
|
||||
// selected comment without a border or a fill keeps one opacity for the whole comment.
|
||||
let mut after = t.clone();
|
||||
if t.fillable && e.fill != o.fill {
|
||||
after.fill = e.fill;
|
||||
}
|
||||
if let Some(w) = width {
|
||||
after.width = Some(w);
|
||||
}
|
||||
let (fill, text) = parts_shown(&after);
|
||||
let moved = |a: Option<f64>, b: Option<f64>| a.zip(b).is_some_and(|(a, b)| (a - b).abs() > 1e-6);
|
||||
if t.restylable
|
||||
&& (fill || text)
|
||||
&& (opacity.is_some() || fill && moved(e.fill_opacity, o.fill_opacity) || text && moved(e.text_opacity, o.text_opacity))
|
||||
{
|
||||
out.push(Edit::PartOpacity { page, index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) });
|
||||
}
|
||||
if t.restylable
|
||||
&& t.font_size.is_some()
|
||||
&& let Some(size) = e.font_size.filter(|s| Some(*s) != o.font_size)
|
||||
{
|
||||
out.push(Edit::FontSizeAnnotation { page: d.page, index: d.index, size });
|
||||
out.push(Edit::FontSizeAnnotation { page, index, size });
|
||||
}
|
||||
let author = (e.author != o.author).then(|| e.author.clone());
|
||||
let subject = (e.subject != o.subject).then(|| e.subject.clone());
|
||||
let icon = (e.icon != o.icon).then_some(e.icon).flatten();
|
||||
let icon = (e.icon != o.icon).then_some(e.icon).flatten().filter(|_| t.icon.is_some());
|
||||
if author.is_some() || subject.is_some() || icon.is_some() {
|
||||
out.push(Edit::SetAnnotationInfo { page: d.page, index: d.index, author, subject, icon });
|
||||
out.push(Edit::SetAnnotationInfo { page, index, author, subject, icon });
|
||||
}
|
||||
if !o.locked && e.locked {
|
||||
out.push(Edit::LockAnnotation { page: d.page, index: d.index, locked: true });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Draw the dialog; returns (apply, cancel).
|
||||
@@ -185,6 +282,10 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
let Some(d) = app.comment_props.as_mut() else { return (false, true) };
|
||||
let kind = tl!(crate::comments_panel::subtype_label(&d.edited.subtype)).to_string();
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("{kind} Properties"), &[("kind", &kind)])).font(theme::semibold(18.0)));
|
||||
if !d.others.is_empty() {
|
||||
let n = (d.others.len() + 1).to_string();
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Changes here apply to all {n} selected comments"), &[("n", &n)])).color(t.text_muted));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (tab, label) in
|
||||
@@ -231,12 +332,51 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
ui.add(egui::DragValue::new(size).range(1.0..=400.0).speed(0.5).suffix(" pt"));
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(tl!("Opacity"));
|
||||
let mut pct = e.opacity * 100.0;
|
||||
if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() {
|
||||
e.opacity = pct / 100.0;
|
||||
// One opacity for the whole comment, or each part's of its own: a text box's
|
||||
// text, border and fill, a shape's line and fill. The rows stay put; a part
|
||||
// not drawn (no fill, no border) is greyed.
|
||||
let percent = |ui: &mut egui::Ui, label: &str, value: &mut f64, enabled: bool| {
|
||||
let name = ui.add_enabled(enabled, egui::Label::new(label));
|
||||
let mut pct = *value * 100.0;
|
||||
if ui.add_enabled(enabled, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).labelled_by(name.id).changed() {
|
||||
*value = pct / 100.0;
|
||||
}
|
||||
ui.end_row();
|
||||
};
|
||||
let (fill, text) = parts_shown(e);
|
||||
let border = has_border(e);
|
||||
match (e.text_opacity.is_some(), e.fill_opacity.is_some()) {
|
||||
(true, _) => {
|
||||
// With neither a border nor a fill, the text is the whole comment:
|
||||
// shown at the opacity it is drawn at, a change sets the whole
|
||||
// comment's (which its parts then take).
|
||||
match e.text_opacity.as_mut() {
|
||||
Some(t) if text => percent(ui, tl!("Text opacity"), t, true),
|
||||
drawn => {
|
||||
let mut shown = drawn.as_deref().copied().unwrap_or(e.opacity);
|
||||
let before = shown;
|
||||
percent(ui, tl!("Text opacity"), &mut shown, true);
|
||||
if shown != before {
|
||||
e.opacity = shown;
|
||||
if let Some(t) = drawn {
|
||||
*t = shown;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if text {
|
||||
percent(ui, tl!("Border opacity"), &mut e.opacity, border);
|
||||
} else {
|
||||
let mut whole = e.opacity;
|
||||
percent(ui, tl!("Border opacity"), &mut whole, false);
|
||||
}
|
||||
}
|
||||
(false, true) => percent(ui, tl!("Line opacity"), &mut e.opacity, true),
|
||||
(false, false) => percent(ui, tl!("Opacity"), &mut e.opacity, true),
|
||||
}
|
||||
if let Some(f) = e.fill_opacity.as_mut() {
|
||||
percent(ui, tl!("Fill opacity"), f, fill);
|
||||
}
|
||||
ui.end_row();
|
||||
let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 };
|
||||
if let Some(w) = e.width.as_mut() {
|
||||
ui.label(tl!("Thickness"));
|
||||
|
||||
@@ -28,7 +28,7 @@ fn new_text_typed(v: &crate::DocView) -> bool {
|
||||
}
|
||||
|
||||
/// Keeps the pages (or comment) the user acted on selected, where they now are. `annots_before`
|
||||
/// is a deleted annotation's page's annotations before the delete (empty for other edits).
|
||||
/// is the page's annotations before a delete or a paste of comments (empty for other edits).
|
||||
fn select_after_edit(
|
||||
view: &mut crate::DocView,
|
||||
edit: &Edit,
|
||||
@@ -58,11 +58,12 @@ fn select_after_edit(
|
||||
crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight)
|
||||
) =>
|
||||
{
|
||||
view.comments.selected = None;
|
||||
view.comments.clear_selection();
|
||||
}
|
||||
Edit::AddAnnotation(a) => {
|
||||
// Select the new comment (appended last among the page's comments).
|
||||
// Select the new comment alone (appended last among the page's comments).
|
||||
let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max();
|
||||
view.comments.clear_selection();
|
||||
view.comments.selected = newest.map(|n| (a.page, n));
|
||||
view.comments.reveal = true;
|
||||
// A new highlight opens its note for typing straight away, as in Acrobat,
|
||||
@@ -75,6 +76,25 @@ fn select_after_edit(
|
||||
}
|
||||
}
|
||||
Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)),
|
||||
// A group moved together stays selected (its positions don't change), so it
|
||||
// can be moved or copied again; anything else in a batch clears the selection.
|
||||
Edit::Batch { edits, .. } if edits.iter().all(|e| matches!(e, Edit::MoveAnnotation { .. })) => {}
|
||||
Edit::Batch { .. } => view.comments.clear_selection(),
|
||||
// The copies the paste added (and nothing that was there before): select them all.
|
||||
Edit::PasteComments { page, .. } => {
|
||||
let mut new: Vec<(usize, usize)> = info
|
||||
.annotations
|
||||
.iter()
|
||||
.filter(|x| x.page == *page && !x.is_reply() && !annots_before.iter().any(|b| b.name == x.name))
|
||||
.map(|x| (*page, x.index))
|
||||
.collect();
|
||||
view.comments.clear_selection();
|
||||
if !new.is_empty() {
|
||||
view.comments.selected = Some(new.remove(0));
|
||||
view.comments.also = new;
|
||||
view.comments.reveal = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -142,7 +162,10 @@ impl PdfCraftApp {
|
||||
// A deletion moves the comments after it on the page: these say where the selected one,
|
||||
// and the one open in the panel's editor, went.
|
||||
let annots_before = match (&edit, self.session.get(id)) {
|
||||
(Edit::DeleteAnnotation { page, .. }, Some(d)) => d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect(),
|
||||
// A paste selects what it adds: the page's comments before it are the ones it didn't.
|
||||
(Edit::DeleteAnnotation { page, .. } | Edit::PasteComments { page, .. }, Some(d)) => {
|
||||
d.info.annotations.iter().filter(|a| a.page == *page).cloned().collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
match self.session.apply(id, edit.clone()) {
|
||||
@@ -904,6 +927,7 @@ fn comment_page(edit: &Edit) -> Option<usize> {
|
||||
| Edit::ResizeAnnotation { page, .. }
|
||||
| Edit::StyleAnnotation { page, .. }
|
||||
| Edit::FillAnnotation { page, .. }
|
||||
| Edit::PartOpacity { page, .. }
|
||||
| Edit::FontSizeAnnotation { page, .. }
|
||||
| Edit::SetAnnotationInfo { page, .. } => Some(*page),
|
||||
_ => None,
|
||||
|
||||
@@ -368,19 +368,12 @@ fn active_tool_controls(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, ve
|
||||
}
|
||||
if let QuickTool::Comment(tool) = app.quick_tool {
|
||||
controls_divider(ui, t, vertical);
|
||||
// The selected comments this tool made take the colour too (locked ones keep theirs).
|
||||
if let Some(color) = comments::quick_bar_controls(ui, tool, &mut app.comment_prefs)
|
||||
&& let Some((index, id)) = app.active_ids()
|
||||
&& let Some((page, annotation)) = app.views.get(index).and_then(|view| view.comments.selected)
|
||||
&& app.session.get(id).is_some_and(|doc| {
|
||||
doc.allows_annotation()
|
||||
&& doc
|
||||
.info
|
||||
.annotations
|
||||
.iter()
|
||||
.any(|a| a.page == page && a.index == annotation && !a.is_reply() && !a.locked && comments::made_by(a, tool))
|
||||
})
|
||||
&& let Some((_, id)) = app.active_ids()
|
||||
&& app.session.get(id).is_some_and(|doc| doc.allows_annotation())
|
||||
{
|
||||
app.apply_edit(Edit::StyleAnnotation { page, index: annotation, color: Some(color), opacity: None, width: None, endings: None });
|
||||
app.colour_selection(color, |a| comments::made_by(a, tool));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,8 +147,9 @@ use state::CopyPage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use state::OpenRequest;
|
||||
pub use state::{
|
||||
CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt,
|
||||
PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel, UnlockPrompt,
|
||||
CommentClip, CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft,
|
||||
PasswordPrompt, PendingLink, PendingRemoteLink, PropsTab, QuickTool, RECENT_FILES_DEFAULT, RECENT_FILES_MAX, RecentFile, RightPanel,
|
||||
UnlockPrompt,
|
||||
};
|
||||
use state::{DocumentCopyJob, GridFile};
|
||||
pub use tools::bulk_fields;
|
||||
@@ -561,6 +562,8 @@ pub struct PdfCraftApp {
|
||||
pub progress_notice: Option<widgets::ProgressNotice>,
|
||||
/// Pages copied or cut in Organize Pages, ready to paste (into any document).
|
||||
pub page_clipboard: Option<PageClip>,
|
||||
/// Copied comments (XFDF), to paste into this or another document (M14).
|
||||
pub comment_clipboard: Option<CommentClip>,
|
||||
/// Files dropped on the page grid, waiting for the pointer to say which gap they go to.
|
||||
grid_drop: Option<GridDrop>,
|
||||
/// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the
|
||||
|
||||
@@ -141,6 +141,9 @@ const EDIT: &[&str] = &[
|
||||
PASTE,
|
||||
DELETE,
|
||||
SEP,
|
||||
"comment.copy",
|
||||
"comment.paste",
|
||||
SEP,
|
||||
SELECT_ALL,
|
||||
SEP,
|
||||
"edit.find",
|
||||
|
||||
@@ -239,6 +239,9 @@ pub(crate) fn show(
|
||||
{
|
||||
view.comments.collapsed_pages.retain(|&c| c != p);
|
||||
}
|
||||
// The cards in the order shown, for a ⇧-click range (folded pages' cards aren't shown).
|
||||
let order: Vec<(usize, usize)> =
|
||||
roots.iter().filter(|a| !(sort == SortBy::Page && view.comments.collapsed_pages.contains(&a.page))).map(|a| (a.page, a.index)).collect();
|
||||
let mut page = usize::MAX;
|
||||
let mut group: Option<String> = None;
|
||||
// A card (or page header) out of view that was measured at this width only takes up its
|
||||
@@ -287,7 +290,7 @@ pub(crate) fn show(
|
||||
}
|
||||
let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation);
|
||||
let top = ui.cursor().min.y;
|
||||
if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav }) {
|
||||
if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, doc, prefs, allowed, nav }) {
|
||||
edit = Some(e);
|
||||
}
|
||||
if virtual_list {
|
||||
@@ -376,6 +379,8 @@ struct CardContext<'a> {
|
||||
// The clockwise `/Rotate` of the comment's page.
|
||||
rotation: u16,
|
||||
thread: &'a [&'a Annotation],
|
||||
/// The cards in the order shown, for a ⇧-click range.
|
||||
order: &'a [(usize, usize)],
|
||||
doc: &'a pdfcraft_engine::Document,
|
||||
prefs: &'a CommentPrefs,
|
||||
allowed: bool,
|
||||
@@ -383,11 +388,14 @@ struct CardContext<'a> {
|
||||
}
|
||||
|
||||
fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Option<Edit> {
|
||||
let CardContext { theme: t, annotation: a, rotation, thread, doc, prefs, allowed, nav } = context;
|
||||
let CardContext { theme: t, annotation: a, rotation, thread, order, doc, prefs, allowed, nav } = context;
|
||||
let info = &doc.info;
|
||||
let mut edit = None;
|
||||
let key = (a.page, a.index);
|
||||
let selected = view.comments.selected == Some(key);
|
||||
let selected = view.comments.is_selected(key);
|
||||
// Several comments may be selected; the reply field (one draft) belongs to the one with
|
||||
// the handles.
|
||||
let primary = view.comments.selected == Some(key);
|
||||
let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent);
|
||||
let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref());
|
||||
let marked = is_marked(thread);
|
||||
@@ -504,7 +512,7 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
|
||||
}
|
||||
above = me;
|
||||
}
|
||||
if selected && allowed {
|
||||
if primary && allowed {
|
||||
ui.add_space(8.0);
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: 34, right: 0, top: 0, bottom: 0 }).show(ui, |ui| {
|
||||
// The field fills its line and Post sits right-aligned under it: a field sized
|
||||
@@ -552,7 +560,16 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))]));
|
||||
if click.clicked() {
|
||||
view.comments.selected = Some(key);
|
||||
// ⌘/Ctrl-click adds the comment to the selection, as on the page; ⇧-click selects the
|
||||
// cards from the one with the handles to this one, as lists do.
|
||||
let modifiers = ui.input(|i| i.modifiers);
|
||||
if modifiers.shift && !modifiers.command {
|
||||
view.comments.select_range(order, key);
|
||||
} else if modifiers.command {
|
||||
view.comments.toggle(key);
|
||||
} else {
|
||||
view.comments.select_only(key);
|
||||
}
|
||||
view.comments.reply.clear();
|
||||
*nav = Some(Nav::Flash(a.page, a.shown_rect(rotation)));
|
||||
}
|
||||
@@ -603,10 +620,16 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti
|
||||
view.comments.default_request = Some((a.page, a.index));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Copy comment")).clicked() {
|
||||
// The card's comment alone (the page's menu copies the whole selection).
|
||||
view.comments.select_only(key);
|
||||
view.comments.copy_request = true;
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
// A locked reply can't be deleted, so it keeps the comment it answers too.
|
||||
if ui.add_enabled(doc.annotation_deletable(a.page, a.index), egui::Button::new(tl!("Delete"))).clicked() {
|
||||
view.comments.selected = None;
|
||||
view.comments.clear_selection();
|
||||
edit = Some(Edit::DeleteAnnotation { page: a.page, index: a.index });
|
||||
ui.close();
|
||||
}
|
||||
|
||||
@@ -233,6 +233,14 @@ pub struct PagePropsDraft {
|
||||
pub tab_order: Option<pdfcraft_engine::TabOrder>,
|
||||
}
|
||||
|
||||
/// Copied comments: their XFDF (each with its replies), for `Edit::PasteComments`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CommentClip {
|
||||
pub xfdf: String,
|
||||
/// How many comments (replies not counted) were copied.
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
/// Copied pages: their document's name and bytes (as it was when copied) and the pages.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PageClip {
|
||||
|
||||
@@ -39,9 +39,9 @@ pub use geometry::{drawn_text_box_rect, text_box_rect};
|
||||
use input::hover_tip;
|
||||
pub(crate) use input::{anchor, context, context_hit, describe, hit, keys, moving_on, nudge_keys, page_after_text, page_input};
|
||||
pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid};
|
||||
pub(crate) use menu::{MenuRights, attachment_items, context_menu, quick_bar_colour, quick_bar_controls};
|
||||
#[cfg(test)]
|
||||
use menu::{RECENT_CUSTOM, same_colour};
|
||||
pub(crate) use menu::{attachment_items, context_menu, quick_bar_colour, quick_bar_controls};
|
||||
pub(crate) use paint::paint_page;
|
||||
#[cfg(test)]
|
||||
use paint::{changing_points, drawn_shape, edited_in_view};
|
||||
@@ -353,6 +353,15 @@ impl CommentPrefs {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool's fill's own opacity (0–100 %) and a text box's or callout's text's (10–100 %),
|
||||
/// for new comments; `None`: as the tool's opacity.
|
||||
pub fn set_part_opacity(&mut self, tool: CommentTool, fill: Option<f64>, text: Option<f64>) {
|
||||
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
|
||||
s.fill_opacity = fill.filter(|o| o.is_finite() && tool.has_fill()).map(|o| o.clamp(0.0, 1.0));
|
||||
s.text_opacity = text.filter(|o| o.is_finite() && matches!(tool, CommentTool::TextBox | CommentTool::Callout)).map(|o| o.clamp(0.1, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool's fill (`None`: no fill).
|
||||
pub fn set_fill(&mut self, tool: CommentTool, fill: Option<Rgb>) {
|
||||
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::geometry::{
|
||||
};
|
||||
use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment};
|
||||
use super::state::{Composer, ComposerKind, Gesture, PageCx};
|
||||
use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
|
||||
use super::{Annotation, CommentPrefs, CommentTool, CommentView, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
|
||||
use crate::page_cursor::{Layer, Subject};
|
||||
use crate::page_router::{CommentPart, Hit, Owner, PagePress};
|
||||
|
||||
@@ -31,6 +31,39 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
|
||||
cv.gesture = None;
|
||||
return true;
|
||||
}
|
||||
// Copied comments on the pointer: a click places the bundle, centred where it is clicked,
|
||||
// on this page; a right-click lets it go. Nothing else on the page takes the click.
|
||||
if let Some(Gesture::Place { xfdf, bounds, .. }) = cv.gesture.clone() {
|
||||
if over_page {
|
||||
view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing);
|
||||
}
|
||||
let cv = &mut view.comments;
|
||||
if resp.secondary_clicked() {
|
||||
cv.gesture = None;
|
||||
return true;
|
||||
}
|
||||
if resp.clicked()
|
||||
&& over_page
|
||||
&& cx.allowed
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
let [x, y] = cx.to_user(p);
|
||||
let offset = [x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0];
|
||||
// Larger than the page it lands on (an A0 bundle on an A4 page): placed as it is,
|
||||
// and said so.
|
||||
if let Some(page) = cx.info.pages.get(cx.page) {
|
||||
let a = page.user_to_view((bounds[0] + offset[0]) as f32, (bounds[1] + offset[1]) as f32);
|
||||
let b = page.user_to_view((bounds[2] + offset[0]) as f32, (bounds[3] + offset[1]) as f32);
|
||||
let (w, h) = ((a[0] - b[0]).abs(), (a[1] - b[1]).abs());
|
||||
if w > page.width + 1.0 || h > page.height + 1.0 {
|
||||
cv.notice = Some("The copied comments are larger than this page; they were placed as they are");
|
||||
}
|
||||
}
|
||||
cv.gesture = None;
|
||||
view.pending_edit = Some(Edit::PasteComments { xfdf, page: cx.page, offset: Some(offset) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
let moving = moving_on(view, cx.page);
|
||||
let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, free };
|
||||
match cx.tool {
|
||||
@@ -216,7 +249,20 @@ fn select_input(
|
||||
consumed = true;
|
||||
}
|
||||
CommentPart::Body if !is_markup(&a.subtype) => {
|
||||
cv.selected = Some((cx.page, a.index));
|
||||
// Dragging one of several selected comments moves them all; any other drag starts
|
||||
// a selection of its own.
|
||||
let grabbed = (cx.page, a.index);
|
||||
if !cv.is_selected(grabbed) {
|
||||
cv.select_only(grabbed);
|
||||
} else {
|
||||
// The grabbed one takes the handles; the one that had them stays selected.
|
||||
if let Some(previous) = cv.selected.replace(grabbed)
|
||||
&& previous != grabbed
|
||||
{
|
||||
cv.also.push(previous);
|
||||
}
|
||||
cv.also.retain(|k| *k != grabbed);
|
||||
}
|
||||
cv.reveal = true;
|
||||
if cx.allowed {
|
||||
cv.gesture = Some(Gesture::Move { page: cx.page, index: a.index, from: o });
|
||||
@@ -240,38 +286,9 @@ fn select_input(
|
||||
if resp.drag_stopped()
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
match cv.gesture.clone() {
|
||||
Some(Gesture::Move { page, index, from }) if page == cx.page => {
|
||||
cv.gesture = None;
|
||||
let (a, b) = (cx.to_user(from), cx.to_user(p));
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
if from.distance(p) >= 2.0 {
|
||||
view.pending_edit = Some(Edit::MoveAnnotation { page, index, dx, dy });
|
||||
}
|
||||
}
|
||||
Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => {
|
||||
cv.gesture = None;
|
||||
if let Some(a) = cx.get(index) {
|
||||
let from_rect = cx.screen_rect(a);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift));
|
||||
let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation());
|
||||
if r.width() >= 4.0 && r.height() >= 4.0 {
|
||||
view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect });
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => {
|
||||
cv.gesture = None;
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0);
|
||||
if moved {
|
||||
view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
finish_select_gesture(ui, cx, view, p);
|
||||
}
|
||||
let cv = &mut view.comments;
|
||||
// (egui clears the press origin on release, so clicks are located by the pointer.)
|
||||
let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p));
|
||||
if resp.clicked() && on_page {
|
||||
@@ -279,11 +296,16 @@ fn select_input(
|
||||
// form field; added text) leaves comments unselected.
|
||||
match owner {
|
||||
Some((a, _)) => {
|
||||
cv.selected = Some((cx.page, a.index));
|
||||
// ⌘/Ctrl-click (or ⇧-click) adds the comment to the selection, as in Acrobat.
|
||||
if ui.input(|i| i.modifiers.command || i.modifiers.shift) {
|
||||
cv.toggle((cx.page, a.index));
|
||||
} else {
|
||||
cv.select_only((cx.page, a.index));
|
||||
}
|
||||
cv.reveal = true;
|
||||
consumed = true;
|
||||
}
|
||||
None if press != PagePress::Elsewhere => cv.selected = None,
|
||||
None if press != PagePress::Elsewhere => cv.clear_selection(),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -292,7 +314,7 @@ fn select_input(
|
||||
&& cx.allowed
|
||||
&& let Some((a, _)) = owner
|
||||
{
|
||||
cv.selected = Some((cx.page, a.index));
|
||||
cv.select_only((cx.page, a.index));
|
||||
cv.composer = Some(Composer {
|
||||
page: cx.page,
|
||||
at: edit_anchor(cx.info, a),
|
||||
@@ -305,6 +327,57 @@ fn select_input(
|
||||
consumed
|
||||
}
|
||||
|
||||
/// The end of a move, resize or reshape the Select tool began on this page: the edit it makes,
|
||||
/// for the pointer released at `p`.
|
||||
fn finish_select_gesture(ui: &egui::Ui, cx: &PageCx<'_>, view: &mut DocView, p: Pos2) {
|
||||
match view.comments.gesture.clone() {
|
||||
Some(Gesture::Move { page, index, from }) if page == cx.page => {
|
||||
view.comments.gesture = None;
|
||||
let (a, b) = (cx.to_user(from), cx.to_user(p));
|
||||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||||
if from.distance(p) >= 2.0 {
|
||||
// The other selected comments on this page (not text markup, which is where
|
||||
// its text is, nor a locked one, which would refuse the whole step) move with
|
||||
// the dragged one, as one undo step.
|
||||
let others: Vec<usize> = view
|
||||
.comments
|
||||
.also
|
||||
.iter()
|
||||
.filter(|(p, i)| *p == page && *i != index && cx.get(*i).is_some_and(|a| !is_markup(&a.subtype) && !a.locked))
|
||||
.map(|(_, i)| *i)
|
||||
.collect();
|
||||
view.pending_edit = Some(if others.is_empty() {
|
||||
Edit::MoveAnnotation { page, index, dx, dy }
|
||||
} else {
|
||||
let edits = std::iter::once(index).chain(others).map(|index| Edit::MoveAnnotation { page, index, dx, dy }).collect();
|
||||
Edit::Batch { label: tl!("Move comments").to_owned(), edits }
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Gesture::Resize { page, index, handle, from, aspect_ratio }) if page == cx.page => {
|
||||
view.comments.gesture = None;
|
||||
if let Some(a) = cx.get(index) {
|
||||
let from_rect = cx.screen_rect(a);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let r = resized(from_rect, handle, p - from, corner_ratio(from_rect, aspect_ratio, shift));
|
||||
let rect = a.rect_for_shown(cx.rect_to_user(r), cx.rotation());
|
||||
if r.width() >= 4.0 && r.height() >= 4.0 {
|
||||
view.pending_edit = Some(Edit::ResizeAnnotation { page, index, rect });
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Gesture::Reshape { page, index, point, points }) if page == cx.page => {
|
||||
view.comments.gesture = None;
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
let moved = points.get(point).is_some_and(|q| cx.to_screen(*q).distance(p) >= 2.0);
|
||||
if moved {
|
||||
view.pending_edit = Some(Edit::ReshapeAnnotation { page, index, points: reshaped(&points, point, cx.to_user(p), shift) });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// After text selection ran: a markup tool turns a finished selection into a comment; Replace
|
||||
/// Text asks for the replacement.
|
||||
pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut DocView) {
|
||||
@@ -430,7 +503,18 @@ impl Gestures<'_> {
|
||||
if over_page {
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
}
|
||||
let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
// The menu's Complete is taken by the page the drawing is on: every page on screen
|
||||
// passes here in a frame, and an earlier one must not swallow it. With no drawing
|
||||
// left, a request is stale.
|
||||
let drawing_here = match &cv.gesture {
|
||||
Some(Gesture::Draw { page, .. }) => *page == cx.page,
|
||||
_ => {
|
||||
cv.finish_request = false;
|
||||
false
|
||||
}
|
||||
};
|
||||
let finish =
|
||||
resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) || (drawing_here && std::mem::take(&mut cv.finish_request));
|
||||
// Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too.
|
||||
// Only while a drawing is in progress: otherwise Delete belongs to the selected comment.
|
||||
if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page)
|
||||
@@ -624,17 +708,36 @@ pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool
|
||||
// One step back per press: cancel the gesture, then deselect, then drop the tool (a
|
||||
// comment tool, or a Fill & Sign one: its Text tool would otherwise open a box at every
|
||||
// click with no way out by the keyboard).
|
||||
let cancelled = cv.gesture.take().is_some() || cv.selected.take().is_some();
|
||||
let had_selection = cv.selected.is_some();
|
||||
cv.clear_selection();
|
||||
let cancelled = cv.gesture.take().is_some() || had_selection;
|
||||
if !cancelled && matches!(tool, QuickTool::Comment(_) | QuickTool::Fill(_)) {
|
||||
*tool = QuickTool::Select;
|
||||
}
|
||||
}
|
||||
if allowed
|
||||
&& let Some((page, index)) = cv.selected
|
||||
&& cv.selected.is_some()
|
||||
&& ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||||
&& let Some(edit) = delete_selected(cv)
|
||||
{
|
||||
cv.selected = None;
|
||||
view.pending_edit = Some(Edit::DeleteAnnotation { page, index });
|
||||
view.pending_edit = Some(edit);
|
||||
}
|
||||
}
|
||||
|
||||
/// The edit that deletes every selected comment (one undo step for several), and clears the
|
||||
/// selection. Later positions go first, so each deletion leaves the earlier ones where they are.
|
||||
pub(crate) fn delete_selected(cv: &mut CommentView) -> Option<Edit> {
|
||||
let mut keys = cv.selection();
|
||||
cv.clear_selection();
|
||||
keys.sort_unstable_by(|a, b| b.cmp(a));
|
||||
keys.dedup();
|
||||
match keys.as_slice() {
|
||||
[] => None,
|
||||
[(page, index)] => Some(Edit::DeleteAnnotation { page: *page, index: *index }),
|
||||
_ => Some(Edit::Batch {
|
||||
label: tl!("Delete comments").to_owned(),
|
||||
edits: keys.into_iter().map(|(page, index)| Edit::DeleteAnnotation { page, index }).collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::input::delete_selected;
|
||||
use super::paint::edit_anchor;
|
||||
use super::state::{Composer, ComposerKind};
|
||||
use super::state::{Composer, ComposerKind, Gesture};
|
||||
use super::{
|
||||
Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32,
|
||||
icons, theme, tool_for, vec2,
|
||||
@@ -22,8 +23,18 @@ pub(crate) fn attachment_items(ui: &mut egui::Ui, view: &mut DocView, info: &Doc
|
||||
ui.separator();
|
||||
}
|
||||
|
||||
/// What the canvas context menu may offer.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct MenuRights {
|
||||
/// Comments may be added and changed.
|
||||
pub(crate) allowed: bool,
|
||||
/// Copied comments wait to be pasted.
|
||||
pub(crate) can_paste: bool,
|
||||
/// The document's pages may change (Flatten comments draws into them).
|
||||
pub(crate) can_modify: bool,
|
||||
}
|
||||
|
||||
/// Items of the canvas context menu for the selected comment (or the page).
|
||||
/// `can_modify`: the document's pages may change (Flatten comments draws into them).
|
||||
/// `deletable`: whether Delete would delete the comment at (page, index) (the engine's
|
||||
/// `Document::annotation_deletable`: unlocked, and no locked reply to it).
|
||||
pub(crate) fn context_menu(
|
||||
@@ -32,11 +43,32 @@ pub(crate) fn context_menu(
|
||||
info: &DocInfo,
|
||||
deletable: &dyn Fn(usize, usize) -> bool,
|
||||
prefs: &CommentPrefs,
|
||||
allowed: bool,
|
||||
can_modify: bool,
|
||||
rights: MenuRights,
|
||||
) -> Option<CanvasAction> {
|
||||
let MenuRights { allowed, can_paste, can_modify } = rights;
|
||||
let mut action = None;
|
||||
// Connected lines, a polygon or a cloud being drawn: finish it from the menu, as other
|
||||
// editors do (Enter and a double-click finish it too), take the last point back, or stop.
|
||||
if let Some(Gesture::Draw { tool, points, .. }) = &view.comments.gesture
|
||||
&& tool.clicks_points()
|
||||
{
|
||||
let enough = points.len() >= if *tool == CommentTool::PolyLine { 2 } else { 3 };
|
||||
if ui.add_enabled(enough, egui::Button::new(tl!("Complete"))).clicked() {
|
||||
view.comments.finish_request = true;
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Undo last point")).clicked() {
|
||||
view.comments.undo_point();
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(tl!("Cancel drawing")).clicked() {
|
||||
view.comments.gesture = None;
|
||||
ui.close();
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let selected = view.comments.selected.and_then(|(p, i)| info.annotations.iter().find(|a| a.page == p && a.index == i && !a.is_reply()));
|
||||
let several = view.comments.also.len();
|
||||
match selected {
|
||||
Some(a) => {
|
||||
let (page, index) = (a.page, a.index);
|
||||
@@ -69,14 +101,19 @@ pub(crate) fn context_menu(
|
||||
let keep_open = egui::containers::menu::MenuConfig::new().close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
|
||||
let _ = egui::containers::menu::SubMenuButton::new(tl!("Colour")).config(keep_open).ui(ui, |ui| {
|
||||
if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) {
|
||||
action = Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation {
|
||||
page,
|
||||
index,
|
||||
color: Some(c),
|
||||
opacity: None,
|
||||
width: None,
|
||||
endings: None,
|
||||
})));
|
||||
// Several selected: the app gives each of them the colour.
|
||||
action = Some(if several > 0 {
|
||||
CanvasAction::Colour(c)
|
||||
} else {
|
||||
CanvasAction::Edit(Box::new(Edit::StyleAnnotation {
|
||||
page,
|
||||
index,
|
||||
color: Some(c),
|
||||
opacity: None,
|
||||
width: None,
|
||||
endings: None,
|
||||
}))
|
||||
});
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
@@ -92,10 +129,24 @@ pub(crate) fn context_menu(
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
// The engine's own check: a locked comment, or one with a locked reply, stays.
|
||||
if ui.add_enabled(allowed && deletable(page, index), egui::Button::new(tl!("Delete"))).clicked() {
|
||||
view.comments.selected = None;
|
||||
action = Some(CanvasAction::Edit(Box::new(Edit::DeleteAnnotation { page, index })));
|
||||
// The comments themselves, to paste into this or another document (⌘C / ⌘V too).
|
||||
let copy_key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY);
|
||||
if ui.add(egui::Button::new(tl!(if several > 0 { "Copy comments" } else { "Copy comment" })).shortcut_text(copy_key)).clicked() {
|
||||
view.comments.copy_request = true;
|
||||
ui.close();
|
||||
}
|
||||
let paste_key = crate::commands::shortcut_label(ui.ctx(), crate::commands::PASTE);
|
||||
if ui.add_enabled(allowed && can_paste, egui::Button::new(tl!("Paste comments")).shortcut_text(paste_key)).clicked() {
|
||||
let at = view.comments.context_at.unwrap_or((page, [f64::from(a.rect[0]), f64::from(a.rect[1])]));
|
||||
view.comments.paste_request = Some(at);
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
// The engine's own check, for each selected comment: a locked comment, or one with a
|
||||
// locked reply, stays.
|
||||
let deletes = deletable(page, index) && view.comments.also.iter().all(|&(p, i)| deletable(p, i));
|
||||
if ui.add_enabled(allowed && deletes, egui::Button::new(tl!(if several > 0 { "Delete comments" } else { "Delete" }))).clicked() {
|
||||
action = delete_selected(&mut view.comments).map(|e| CanvasAction::Edit(Box::new(e)));
|
||||
ui.close();
|
||||
}
|
||||
if ui.add_enabled(allowed, egui::Button::new(tl!("Properties…"))).clicked() {
|
||||
@@ -140,6 +191,8 @@ pub enum CanvasAction {
|
||||
OpenComments,
|
||||
/// Comment Properties for (page, index).
|
||||
Properties(usize, usize),
|
||||
/// Give every selected comment this colour.
|
||||
Colour(Rgb),
|
||||
/// Copy Image: image (page, index in `page_images`) to the clipboard.
|
||||
CopyImage(usize, usize),
|
||||
/// Copy Image in Original Format: the same image as a file (JPEG as stored, else PNG).
|
||||
@@ -327,8 +380,10 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m
|
||||
ui.set_min_width(180.0);
|
||||
let mut percent = (style.opacity * 100.0).round();
|
||||
ui.label(egui::RichText::new(tl!("Opacity")).font(theme::semibold(12.0)));
|
||||
// The whole comment's: its fill and text take it too.
|
||||
if ui.add(egui::Slider::new(&mut percent, 10.0..=100.0).step_by(5.0).suffix(" %")).changed() {
|
||||
prefs.set_opacity(tool, percent / 100.0);
|
||||
prefs.set_part_opacity(tool, None, None);
|
||||
}
|
||||
});
|
||||
if tool.has_width() {
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, resh
|
||||
use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS};
|
||||
use super::{
|
||||
Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape,
|
||||
Stroke, color32, vec2,
|
||||
Stroke, color32, pos2, vec2,
|
||||
};
|
||||
use crate::page_router::{CommentPart, Hit, Owner};
|
||||
|
||||
@@ -38,6 +38,12 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
|
||||
painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.7)), egui::StrokeKind::Outside);
|
||||
}
|
||||
}
|
||||
// The comments selected besides the one with the handles: outlined, not resizable.
|
||||
for a in cv.also.iter().filter(|(p, _)| *p == cx.page).filter_map(|(_, i)| cx.get(*i)) {
|
||||
for r in cx.screen_rects(a) {
|
||||
painter.rect_stroke(r.expand(2.0), CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
if let Some((page, index)) = cv.selected
|
||||
&& page == cx.page
|
||||
&& let Some(a) = cx.get(index)
|
||||
@@ -93,6 +99,11 @@ pub(super) fn changing_points(
|
||||
}),
|
||||
(_, Some(Edit::ReshapeAnnotation { page: p, index: i, points: to })) if (*p, *i) == (page, index) => Some(to.clone()),
|
||||
(_, Some(Edit::MoveAnnotation { page: p, index: i, dx, dy })) if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)),
|
||||
// Moved with the rest of the selection: its own move in the batch.
|
||||
(_, Some(Edit::Batch { edits, .. })) => edits.iter().find_map(|e| match e {
|
||||
Edit::MoveAnnotation { page: p, index: i, dx, dy } if (*p, *i) == (page, index) => Some(shifted(*dx, *dy)),
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -128,6 +139,82 @@ fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
|
||||
{
|
||||
paint_draw(painter, cx, tool, points);
|
||||
}
|
||||
// The bundle of copied comments, hanging on the pointer over this page: its box, and each
|
||||
// comment's outline in its colour, so the drop can be aimed to the line.
|
||||
if let Some(Gesture::Place { bounds, count, outlines, .. }) = &view.comments.gesture
|
||||
&& let Some(p) = cx.ptr.pos
|
||||
&& cx.xf.rect.contains(p)
|
||||
{
|
||||
let [x, y] = cx.to_user(p);
|
||||
let (dx, dy) = (x - (bounds[0] + bounds[2]) / 2.0, y - (bounds[1] + bounds[3]) / 2.0);
|
||||
let r = Rect::from_two_pos(cx.to_screen([bounds[0] + dx, bounds[1] + dy]), cx.to_screen([bounds[2] + dx, bounds[3] + dy]));
|
||||
painter.rect_stroke(r.expand(2.0), CornerRadius::same(2), Stroke::new(1.0, SELECT_BLUE.gamma_multiply(0.6)), egui::StrokeKind::Middle);
|
||||
let at = |q: [f64; 2]| cx.to_screen([q[0] + dx, q[1] + dy]);
|
||||
for outline in outlines {
|
||||
paint_outline(painter, outline, &at);
|
||||
}
|
||||
let label = crate::i18n::fmt(tl!("Comments: {n}"), &[("n", &count.to_string())]);
|
||||
painter.text(r.left_top() + vec2(0.0, -4.0), egui::Align2::LEFT_BOTTOM, label, crate::theme::regular(11.0), SELECT_BLUE);
|
||||
}
|
||||
}
|
||||
|
||||
/// One copied comment's outline on screen (`at` maps a user-space point, moved to where the
|
||||
/// bundle hangs), in the comment's colour.
|
||||
fn paint_outline(painter: &egui::Painter, outline: &pdfcraft_engine::CommentOutline, at: &dyn Fn([f64; 2]) -> Pos2) {
|
||||
use pdfcraft_engine::CommentOutline as O;
|
||||
let color = |c: &Option<[f64; 3]>| {
|
||||
c.map(|[r, g, b]| Color32::from_rgb((r.clamp(0.0, 1.0) * 255.0) as u8, (g.clamp(0.0, 1.0) * 255.0) as u8, (b.clamp(0.0, 1.0) * 255.0) as u8))
|
||||
.unwrap_or(SELECT_BLUE)
|
||||
};
|
||||
match outline {
|
||||
O::Rect { rect, round, color: c } => {
|
||||
let stroke = Stroke::new(1.5, color(c));
|
||||
let r = Rect::from_two_pos(at([rect[0], rect[1]]), at([rect[2], rect[3]]));
|
||||
if *round {
|
||||
let (cx, cy, rx, ry) = (r.center().x, r.center().y, r.width() / 2.0, r.height() / 2.0);
|
||||
let points: Vec<Pos2> =
|
||||
(0..48).map(|i| i as f32 / 48.0 * std::f32::consts::TAU).map(|t| pos2(cx + rx * t.cos(), cy + ry * t.sin())).collect();
|
||||
painter.add(egui::Shape::closed_line(points, stroke));
|
||||
} else {
|
||||
painter.rect_stroke(r, CornerRadius::ZERO, stroke, egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
O::Line { points, color: c } => {
|
||||
let stroke = Stroke::new(1.5, color(c));
|
||||
painter.add(egui::Shape::line(points.iter().map(|p| at(*p)).collect(), stroke));
|
||||
}
|
||||
O::Poly { points, closed, color: c } => {
|
||||
let stroke = Stroke::new(1.5, color(c));
|
||||
let points: Vec<Pos2> = points.iter().map(|p| at(*p)).collect();
|
||||
painter.add(if *closed { egui::Shape::closed_line(points, stroke) } else { egui::Shape::line(points, stroke) });
|
||||
}
|
||||
O::Ink { strokes, color: c } => {
|
||||
let stroke = Stroke::new(1.5, color(c));
|
||||
for s in strokes {
|
||||
painter.add(egui::Shape::line(s.iter().map(|p| at(*p)).collect(), stroke));
|
||||
}
|
||||
}
|
||||
O::Quads { quads, color: c } => {
|
||||
let fill = color(c).gamma_multiply(0.35);
|
||||
for q in quads {
|
||||
// Upper left, upper right, lower right, lower left: a closed shape in that order.
|
||||
let points = vec![at([q[0], q[1]]), at([q[2], q[3]]), at([q[6], q[7]]), at([q[4], q[5]])];
|
||||
painter.add(egui::Shape::convex_polygon(points, fill, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
O::Text { rect, text, size, color: c } => {
|
||||
// At the page's zoom, wrapped in the box; too small to read at this zoom, left out.
|
||||
let r = Rect::from_two_pos(at([rect[0], rect[1]]), at([rect[2], rect[3]]));
|
||||
let zoom = at([1.0, 0.0]).distance(at([0.0, 0.0]));
|
||||
let px = *size as f32 * zoom;
|
||||
if !(px.is_finite() && px >= 3.0) {
|
||||
return;
|
||||
}
|
||||
let pad = 2.0 * zoom;
|
||||
let galley = painter.layout(text.clone(), egui::FontId::proportional(px.min(400.0)), color(c), (r.width() - 2.0 * pad).max(1.0));
|
||||
painter.with_clip_rect(r.intersect(painter.clip_rect())).galley(r.min + vec2(pad, pad), galley, color(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing tool's shape from its points (a live drag, or a released one still settling).
|
||||
|
||||
@@ -29,6 +29,10 @@ pub enum Gesture {
|
||||
/// Dragging point `point` of a line, connected lines, a polygon or a cloud (its `points`
|
||||
/// before the drag, in user space) to where the pointer is.
|
||||
Reshape { page: usize, index: usize, point: usize, points: Vec<[f64; 2]> },
|
||||
/// Copied comments hanging on the pointer as one bundle (their box `bounds` in user space,
|
||||
/// `count` of them, their `outlines` drawn along so the drop can be aimed): a click puts
|
||||
/// their centre there, on the page clicked; Escape or a right-click lets them go.
|
||||
Place { xfdf: String, bounds: [f64; 4], count: usize, outlines: Vec<pdfcraft_engine::CommentOutline> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -127,8 +131,21 @@ impl CommentView {
|
||||
/// Per-document commenting state.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CommentView {
|
||||
/// The selected comment: (page, index in `/Annots`).
|
||||
/// The selected comment: (page, index in `/Annots`). With several selected (`also`), the one
|
||||
/// the handles, the properties and the context menu act on.
|
||||
pub selected: Option<(usize, usize)>,
|
||||
/// The other selected comments (⌘/Ctrl-click or ⇧-click on the page adds one, ⇧-click in the
|
||||
/// Comments panel a range): Delete, Copy, Properties and a drag act on them with `selected`.
|
||||
pub also: Vec<(usize, usize)>,
|
||||
/// Copy the selected comments (set by the menus; the app has the clipboard).
|
||||
pub copy_request: bool,
|
||||
/// Paste the copied comments onto this page, centred at this user-space point (set by the
|
||||
/// page's menu, which knows where it was opened).
|
||||
pub paste_request: Option<(usize, [f64; 2])>,
|
||||
/// A notice for the app to show (the page input has no app).
|
||||
pub notice: Option<&'static str>,
|
||||
/// Finish the connected lines, polygon or cloud being drawn (the context menu's Complete).
|
||||
pub finish_request: bool,
|
||||
pub gesture: Option<Gesture>,
|
||||
/// Drawings just released: shown from their points until the page raster that includes the
|
||||
/// committed annotation arrives, so the stroke never vanishes in between (#1858).
|
||||
@@ -187,6 +204,55 @@ pub(crate) enum EditorFocus {
|
||||
}
|
||||
|
||||
impl CommentView {
|
||||
/// Every selected comment, the one the handles belong to first.
|
||||
pub fn selection(&self) -> Vec<(usize, usize)> {
|
||||
let mut all: Vec<(usize, usize)> = self.selected.into_iter().collect();
|
||||
all.extend(self.also.iter().copied().filter(|k| Some(*k) != self.selected));
|
||||
all
|
||||
}
|
||||
|
||||
/// Whether `key` (page, index) is among the selected comments.
|
||||
pub fn is_selected(&self, key: (usize, usize)) -> bool {
|
||||
self.selected == Some(key) || self.also.contains(&key)
|
||||
}
|
||||
|
||||
/// Select `key` alone.
|
||||
pub fn select_only(&mut self, key: (usize, usize)) {
|
||||
self.selected = Some(key);
|
||||
self.also.clear();
|
||||
}
|
||||
|
||||
/// ⇧-click in a list (`order`, as shown): every comment from the one with the handles to
|
||||
/// `key` is selected, and only those, so another ⇧-click from the same comment changes the
|
||||
/// range. Without a comment with the handles, or with either end not in the list, `key` is
|
||||
/// added as by ⌘/Ctrl-click.
|
||||
pub fn select_range(&mut self, order: &[(usize, usize)], key: (usize, usize)) {
|
||||
let ends = self.selected.and_then(|anchor| Some((order.iter().position(|k| *k == anchor)?, order.iter().position(|k| *k == key)?)));
|
||||
let Some((a, b)) = ends else { return self.toggle(key) };
|
||||
let anchor = self.selected;
|
||||
self.also = order.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().copied().filter(|k| Some(*k) != anchor).collect();
|
||||
}
|
||||
|
||||
/// ⌘/Ctrl-click: add `key` to the selection, or take it out again. The handles go to the
|
||||
/// comment clicked last.
|
||||
pub fn toggle(&mut self, key: (usize, usize)) {
|
||||
if self.selected == Some(key) {
|
||||
self.selected = self.also.pop();
|
||||
} else if let Some(at) = self.also.iter().position(|k| *k == key) {
|
||||
self.also.remove(at);
|
||||
} else {
|
||||
if let Some(previous) = self.selected.replace(key) {
|
||||
self.also.push(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Nothing selected.
|
||||
pub fn clear_selection(&mut self) {
|
||||
self.selected = None;
|
||||
self.also.clear();
|
||||
}
|
||||
|
||||
/// Takes back the last point of a polygon, cloud or connected lines being drawn; the first
|
||||
/// point's removal cancels the drawing. Returns whether there was such a drawing.
|
||||
pub fn undo_point(&mut self) -> bool {
|
||||
@@ -226,6 +292,7 @@ impl CommentView {
|
||||
/// went too.
|
||||
pub(crate) fn annotation_deleted(&mut self, before: &[Annotation], after: &[Annotation], gone: (usize, usize)) {
|
||||
self.selected = self.selected.and_then(|k| after_delete(before, after, gone, k));
|
||||
self.also = std::mem::take(&mut self.also).into_iter().filter_map(|k| after_delete(before, after, gone, k)).collect();
|
||||
self.editing = self.editing.take().and_then(|(p, i, text)| after_delete(before, after, gone, (p, i)).map(|(p, i)| (p, i, text)));
|
||||
if self.editing.is_none() {
|
||||
self.editor_focus = None;
|
||||
@@ -237,7 +304,7 @@ impl CommentView {
|
||||
/// editor close, its unsaved text with it, rather than Save writing that text over whatever
|
||||
/// took its place.
|
||||
pub(crate) fn history_stepped(&mut self) {
|
||||
self.selected = None;
|
||||
self.clear_selection();
|
||||
self.editing = None;
|
||||
self.editor_focus = None;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ fn a_moved_or_reshaped_line_is_drawn_at_its_new_points() {
|
||||
let to = Some(pos2(60.0, 10.0));
|
||||
assert_eq!(changing_points(Some(&dragging), None, (0, 3), &line, to_user, to, false), Some(vec![[10.0, 10.0], [60.0, 10.0]]));
|
||||
assert_eq!(changing_points(None, None, (0, 3), &line, to_user, to, false), None, "not changing");
|
||||
// Released with the rest of the selection: its own move in the batch.
|
||||
let together = Edit::Batch {
|
||||
label: "Move comments".into(),
|
||||
edits: vec![Edit::MoveAnnotation { page: 0, index: 1, dx: 1.0, dy: 1.0 }, Edit::MoveAnnotation { page: 0, index: 3, dx: 5.0, dy: 20.0 }],
|
||||
};
|
||||
assert_eq!(changing_points(None, Some(&together), (0, 3), &line, to_user, None, false), Some(vec![[15.0, 30.0], [55.0, 30.0]]));
|
||||
assert_eq!(changing_points(None, Some(&together), (0, 2), &line, to_user, None, false), None, "not in the batch");
|
||||
}
|
||||
|
||||
/// A sticky note on a page turned a quarter is drawn upright beside its `/Rect`: an edit
|
||||
@@ -407,7 +414,8 @@ fn context_menu_colour_submenu_keeps_its_picker_open_and_a_swatch_closes_the_men
|
||||
let mut h = egui_kittest::Harness::new_ui_state(
|
||||
|ui, (view, actions): &mut (DocView, Vec<CanvasAction>)| {
|
||||
ui.button("Page").context_menu(|ui| {
|
||||
if let Some(a) = context_menu(ui, view, &info, &|_, _| true, &prefs, true, true) {
|
||||
let rights = MenuRights { allowed: true, can_paste: false, can_modify: true };
|
||||
if let Some(a) = context_menu(ui, view, &info, &|_, _| true, &prefs, rights) {
|
||||
actions.push(a);
|
||||
}
|
||||
});
|
||||
@@ -490,6 +498,24 @@ fn drawn_shapes_ignore_slips() {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_click_selects_the_range_from_the_comment_with_the_handles() {
|
||||
let order = [(0, 3), (0, 1), (0, 0), (1, 2), (1, 0)];
|
||||
let mut cv = CommentView::default();
|
||||
cv.select_only((0, 1));
|
||||
cv.select_range(&order, (1, 2));
|
||||
assert_eq!(cv.selection(), vec![(0, 1), (0, 0), (1, 2)], "from the anchor down, the anchor keeps the handles");
|
||||
// Another ⇧-click from the same anchor changes the range, upwards too.
|
||||
cv.select_range(&order, (0, 3));
|
||||
assert_eq!(cv.selection(), vec![(0, 1), (0, 3)]);
|
||||
// Nothing to start from, or a card not in the list: added as by ⌘/Ctrl-click.
|
||||
let mut none = CommentView::default();
|
||||
none.select_range(&order, (0, 0));
|
||||
assert_eq!(none.selection(), vec![(0, 0)]);
|
||||
cv.select_range(&order, (9, 9));
|
||||
assert_eq!(cv.selected, Some((9, 9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_boxes_fit_their_text() {
|
||||
let r = text_box_rect([100.0, 500.0], "Hi", 12.0, 0);
|
||||
|
||||
@@ -494,7 +494,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, free: bo
|
||||
// sees its lost focus, so replacing the box first would drop what was typed. A new box
|
||||
// deselects, as a comment tool's note or text box does: Escape then closes the box and
|
||||
// leaves the tool in two presses.
|
||||
view.comments.selected = None;
|
||||
view.comments.clear_selection();
|
||||
let open = view.fill_text.replace(TypeBox { page, at: top, text: String::new(), size: text_size, color: text_color, focus: true });
|
||||
open.filter(|tb| !tb.text.trim().is_empty()).map(|tb| {
|
||||
FillAction::Edit(Box::new(typed_with_color(
|
||||
|
||||
@@ -578,7 +578,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) {
|
||||
}
|
||||
if let Some((page, annotation, reading, scale)) = select {
|
||||
app.views[index].current = page;
|
||||
app.views[index].comments.selected = Some((page, annotation));
|
||||
app.views[index].comments.select_only((page, annotation));
|
||||
app.views[index].measure.reading = Some(reading);
|
||||
app.views[index].measure.scale = Some(scale);
|
||||
app.quick_tool = QuickTool::Select;
|
||||
|
||||
@@ -100,6 +100,22 @@ fn every_registered_command_is_implemented() {
|
||||
if spec.id == "edit.redo" {
|
||||
app.undo();
|
||||
}
|
||||
// Copying comments needs a selected comment; pasting needs copied ones.
|
||||
if matches!(spec.id, "comment.copy" | "comment.paste") {
|
||||
let shape = pdfcraft_engine::Shape::Rectangle { rect: [10.0, 10.0, 50.0, 50.0] };
|
||||
let style = pdfcraft_engine::Style::default_for(&shape);
|
||||
assert!(app.apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: String::new(),
|
||||
author: String::new(),
|
||||
})));
|
||||
app.views[0].comments.select_only((0, 0));
|
||||
if spec.id == "comment.paste" {
|
||||
assert!(app.copy_selected_comments(0));
|
||||
}
|
||||
}
|
||||
if spec.id.starts_with("edit.") && (spec.id.ends_with(".update") || spec.id.ends_with(".remove")) {
|
||||
use pdfcraft_engine::{Background, Edit, HeaderFooter, Watermark};
|
||||
let mut hf = HeaderFooter::default();
|
||||
|
||||
@@ -84,6 +84,19 @@ fn click_pt(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)) {
|
||||
let p = at(h, p.0, p.1);
|
||||
click(h, p);
|
||||
}
|
||||
/// Click a page point with modifier keys held (⌘-click adds a comment to the selection).
|
||||
fn click_pt_with(h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32), modifiers: egui::Modifiers) {
|
||||
let p = at(h, p.0, p.1);
|
||||
h.hover_at(p);
|
||||
h.event(egui::Event::ModifiersChanged(modifiers));
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// A text field by its placeholder ("Add a comment", "Add a reply").
|
||||
fn field<'a>(h: &'a Harness<'static, PdfCraftApp>, placeholder: &'a str) -> egui_kittest::Node<'a> {
|
||||
@@ -1360,6 +1373,91 @@ fn shapes_take_a_fill_colour_from_the_quick_bar_and_properties() {
|
||||
assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill, Some([0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
/// A filled rectangle's line and fill, and a text box's text, border and fill, each take an
|
||||
/// opacity of their own in Properties; Make Properties Default carries them into the tool, so
|
||||
/// the next rectangle is drawn with a see-through fill and an opaque line.
|
||||
#[test]
|
||||
fn comment_properties_set_each_parts_opacity() {
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
h.state_mut().comment_prefs.set_fill(CommentTool::Rectangle, Some([1.0, 1.0, 0.0]));
|
||||
h.run_steps(2);
|
||||
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
|
||||
let props = |h: &Harness<'static, PdfCraftApp>, index: usize| {
|
||||
let s = h.state();
|
||||
let p = s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap();
|
||||
(p.opacity, p.fill_opacity, p.text_opacity)
|
||||
};
|
||||
h.state_mut().open_comment_props(0, 0);
|
||||
h.run_steps(2);
|
||||
// The sliders (each labelled by its row, which shares the name).
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Slider, "Line opacity");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Slider, "Fill opacity");
|
||||
assert_eq!(h.query_all_by_label("Text opacity").count(), 0, "a rectangle has no text");
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
d.edited.fill_opacity = Some(0.3);
|
||||
d.make_default = true;
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(props(&h, 0), (1.0, Some(0.3), None));
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Change comment properties"));
|
||||
assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill_opacity, Some(0.3));
|
||||
h.state_mut().set_option("quick", "square").unwrap();
|
||||
h.run_steps(2);
|
||||
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
|
||||
assert_eq!(props(&h, 1), (1.0, Some(0.3), None), "the next rectangle takes the default");
|
||||
|
||||
// A text box with a border and a fill: its text, border and fill.
|
||||
let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 };
|
||||
let mut style = pdfcraft_engine::Style::default_for(&shape);
|
||||
(style.width, style.fill) = (1.0, Some([1.0, 1.0, 0.6]));
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: "Note".into(),
|
||||
author: "Tester".into(),
|
||||
})));
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
h.run_steps(2);
|
||||
h.state_mut().open_comment_props(0, 2);
|
||||
h.run_steps(2);
|
||||
for label in ["Text opacity", "Border opacity", "Fill opacity"] {
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Slider, label);
|
||||
}
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||||
let _gpu = crate::gpu();
|
||||
h.render().unwrap().save(format!("{dir}/comment-part-opacity.png")).unwrap();
|
||||
}
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
(d.edited.opacity, d.edited.fill_opacity) = (0.5, Some(0.2));
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(props(&h, 2), (0.5, Some(0.2), Some(1.0)), "the border at half, the fill at a fifth, the text opaque");
|
||||
// Without its fill and border, the text is the whole text box: its opacity is the comment's.
|
||||
h.state_mut().open_comment_props(0, 2);
|
||||
h.run_steps(2);
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
(d.edited.fill, d.edited.width) = (None, Some(0.0));
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
h.state_mut().open_comment_props(0, 2);
|
||||
h.run_steps(2);
|
||||
let shown = egui_kittest::kittest::NodeT::accesskit_node(&h.get_by_role_and_label(egui::accesskit::Role::Slider, "Text opacity")).numeric_value();
|
||||
assert_eq!(shown, Some(100.0), "the text's slider shows the opacity it is drawn at, not the border's half");
|
||||
h.state_mut().comment_props.as_mut().expect("open").edited.opacity = 0.4;
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(props(&h, 2), (0.4, Some(0.4), Some(0.4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_filters_and_sorts() {
|
||||
let mut h = harness(|app| {
|
||||
@@ -2247,6 +2345,43 @@ fn properties_dialog_makes_the_strikethrough_colour_default() {
|
||||
assert!(made.iter().zip(green).all(|(m, r)| (f64::from(*m) - r).abs() < 0.01), "the next strikethrough is green: {made:?}");
|
||||
}
|
||||
|
||||
/// A text box with neither a border nor a fill is all text: Make Properties Default (and the
|
||||
/// context menu's Make Current Properties Default) gives the tool the opacity its text is drawn
|
||||
/// at, the one Comment Properties shows, not the comment's /CA.
|
||||
#[test]
|
||||
fn a_text_only_text_box_made_default_gives_the_tool_its_texts_opacity() {
|
||||
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
let mut h = harness(|_| {});
|
||||
let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 };
|
||||
let style = Style { width: 0.0, fill: None, ..Style::default_for(&shape) };
|
||||
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: "Faint".into(),
|
||||
author: "Tester".into()
|
||||
})));
|
||||
h.run_steps(2);
|
||||
let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Faint")).map(|a| a.index).expect("the text box");
|
||||
// As the automation's comment_edit sets it.
|
||||
assert!(h.state_mut().apply_edit(Edit::PartOpacity { page: 0, index, fill: None, text: Some(0.5) }));
|
||||
h.run_steps(2);
|
||||
let opacity = |h: &Harness<'static, PdfCraftApp>| h.state().comment_prefs.style(CommentTool::TextBox).opacity;
|
||||
assert!((opacity(&h) - 1.0).abs() < 1e-6);
|
||||
h.state_mut().open_comment_props(0, index);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Make Properties Default").click();
|
||||
h.run_steps(1);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h));
|
||||
// The context menu's way, from a tool at 100 % again.
|
||||
h.state_mut().comment_prefs.set_opacity(CommentTool::TextBox, 1.0);
|
||||
h.state_mut().make_comment_default(0, index);
|
||||
assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h));
|
||||
}
|
||||
|
||||
/// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines.
|
||||
#[test]
|
||||
fn an_arrows_default_does_not_restyle_lines() {
|
||||
@@ -2988,7 +3123,14 @@ fn a_moved_comment_is_drawn_where_it_goes_before_release() {
|
||||
for shape in
|
||||
[pdfcraft_engine::Shape::Rectangle { rect: [30.0, 30.0, 70.0, 70.0] }, pdfcraft_engine::Shape::Oval { rect: [230.0, 30.0, 270.0, 70.0] }]
|
||||
{
|
||||
let style = pdfcraft_engine::Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) };
|
||||
let style = pdfcraft_engine::Style {
|
||||
color: [1.0, 0.0, 0.0],
|
||||
opacity: 1.0,
|
||||
width: 2.0,
|
||||
fill: Some([1.0, 0.0, 0.0]),
|
||||
fill_opacity: None,
|
||||
text_opacity: None,
|
||||
};
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
@@ -3587,3 +3729,442 @@ fn other_edit_composers_keep_trimming_trailing_whitespace() {
|
||||
edit_composer_preserves_only_typewriter_whitespace(kind);
|
||||
}
|
||||
}
|
||||
|
||||
/// ⌘-click (Ctrl on Linux and Windows) adds comments to the selection and takes one out again;
|
||||
/// Delete removes every selected comment as one undo step.
|
||||
#[test]
|
||||
fn ctrl_click_selects_several_comments_and_delete_removes_them_all() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] {
|
||||
drag_pt(&mut h, a, b);
|
||||
}
|
||||
assert_eq!(comments(&h).len(), 3);
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
h.run_steps(2);
|
||||
click_pt(&mut h, (40.0, 40.0));
|
||||
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 0)]);
|
||||
click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND);
|
||||
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND);
|
||||
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 0), (0, 1)], "the handles go to the one clicked last");
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 2)));
|
||||
// ⌘-click on a selected comment takes it out again.
|
||||
click_pt_with(&mut h, (40.0, 40.0), egui::Modifiers::COMMAND);
|
||||
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 2), (0, 1)]);
|
||||
// Delete: both go, as one step.
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
let left = comments(&h);
|
||||
assert_eq!(left.len(), 1, "{left:?}");
|
||||
assert!((left[0].rect[0] - 20.0).abs() < 1.0, "the unselected first rectangle stays: {:?}", left[0].rect);
|
||||
assert!(h.state().views[0].comments.selection().is_empty());
|
||||
let id = h.state().views[0].id;
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comments"));
|
||||
h.state_mut().undo();
|
||||
assert_eq!(comments(&h).len(), 3);
|
||||
// A plain click selects one alone again.
|
||||
click_pt(&mut h, (120.0, 40.0));
|
||||
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]);
|
||||
// On the page ⇧-click adds one too, and takes it out again (a range is the panel's).
|
||||
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT);
|
||||
assert_eq!(h.state().views[0].comments.selection().len(), 2, "{:?}", h.state().views[0].comments.selection());
|
||||
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::SHIFT);
|
||||
assert_eq!(h.state().views[0].comments.selection(), vec![(0, 1)]);
|
||||
}
|
||||
|
||||
/// Dragging one of several selected comments moves them all, whichever of them is grabbed: the
|
||||
/// grabbed one takes the handles and the one that had them stays selected.
|
||||
#[test]
|
||||
fn dragging_any_selected_comment_moves_the_whole_selection() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
for (a, b) in [((20.0, 20.0), (60.0, 60.0)), ((100.0, 20.0), (140.0, 60.0)), ((180.0, 20.0), (220.0, 60.0))] {
|
||||
drag_pt(&mut h, a, b);
|
||||
}
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
h.run_steps(2);
|
||||
click_pt(&mut h, (40.0, 40.0));
|
||||
click_pt_with(&mut h, (120.0, 40.0), egui::Modifiers::COMMAND);
|
||||
click_pt_with(&mut h, (200.0, 40.0), egui::Modifiers::COMMAND);
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 2)), "the last one clicked has the handles");
|
||||
// Drag the first one (not the one with the handles) 30 points up.
|
||||
let before = comments(&h);
|
||||
drag_pt(&mut h, (40.0, 40.0), (40.0, 70.0));
|
||||
let after = comments(&h);
|
||||
assert_eq!(after.len(), 3);
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert!((b.rect[1] - a.rect[1] - 30.0).abs() < 1.5, "every selected rectangle moved up 30 pt: {:?} from {:?}", b.rect, a.rect);
|
||||
}
|
||||
let selection = h.state().views[0].comments.selection();
|
||||
assert_eq!(selection.len(), 3, "all three stay selected: {selection:?}");
|
||||
assert_eq!(selection[0], (0, 0), "the grabbed one has the handles");
|
||||
let id = h.state().views[0].id;
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments"));
|
||||
// A locked one in the selection stays where it is; the others still move (it would refuse
|
||||
// the whole step otherwise, and nothing would move).
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::LockAnnotation { page: 0, index: 1, locked: true }));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].comments.selection().len(), 3);
|
||||
let before = comments(&h);
|
||||
drag_pt(&mut h, (40.0, 70.0), (40.0, 90.0));
|
||||
let after = comments(&h);
|
||||
// By index: the list isn't kept in index order.
|
||||
let bottom = |list: &[Annotation], i: usize| list.iter().find(|a| a.index == i).map(|a| a.rect[1]).expect("still there");
|
||||
for i in 0..3 {
|
||||
let moved = bottom(&after, i) - bottom(&before, i);
|
||||
if i == 1 {
|
||||
assert!(moved.abs() < 0.01, "the locked one stays: moved {moved}");
|
||||
} else {
|
||||
assert!((moved - 20.0).abs() < 1.5, "rectangle {i} moved up 20 pt: moved {moved}");
|
||||
}
|
||||
}
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Move comments"));
|
||||
}
|
||||
|
||||
/// Copied comments on the pointer take the click whatever tool is on: with Fill & Sign's Text
|
||||
/// tool or a stamp tool still on, the click places the bundle and opens no text box or stamp.
|
||||
#[test]
|
||||
fn copied_comments_take_the_click_from_the_tool_that_is_on() {
|
||||
use pdfcraft_ui_egui::fill_sign::FillTool;
|
||||
let mut h = harness(|_| {});
|
||||
let id = h.state().views[0].id;
|
||||
let shape = pdfcraft_engine::Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] };
|
||||
let style = pdfcraft_engine::Style::default_for(&shape);
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: String::new(),
|
||||
author: "Tester".into(),
|
||||
})));
|
||||
h.run_steps(2);
|
||||
h.state_mut().views[0].comments.select_only((0, 0));
|
||||
assert!(h.state_mut().execute("comment.copy"));
|
||||
let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.annotations.len();
|
||||
for (tool, at) in [(QuickTool::Fill(FillTool::Text), (150.0, 100.0)), (QuickTool::Stamp(pdfcraft_engine::StampKind::Approved), (200.0, 160.0))] {
|
||||
h.state_mut().quick_tool = tool;
|
||||
h.run_steps(2);
|
||||
let before = count(&h);
|
||||
assert!(h.state_mut().execute("comment.paste"));
|
||||
click_pt(&mut h, at);
|
||||
assert_eq!(count(&h), before + 1, "{tool:?}: the copy, and nothing else");
|
||||
assert!(h.state().views[0].fill_text.is_none(), "{tool:?}: no text box opens");
|
||||
assert!(h.state().views[0].comments.gesture.is_none());
|
||||
let placed = h.state().session.get(id).unwrap().info.annotations.iter().max_by_key(|a| a.index).map(|a| a.rect).expect("placed");
|
||||
assert!(((placed[0] + placed[2]) / 2.0 - at.0).abs() < 1.0, "{tool:?}: centred on the click: {placed:?}");
|
||||
assert_eq!(h.state().quick_tool, tool, "the tool stays on for the next click");
|
||||
}
|
||||
// Afterwards the tool takes its clicks again.
|
||||
h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text);
|
||||
h.run_steps(2);
|
||||
click_pt(&mut h, (100.0, 200.0));
|
||||
assert!(h.state().views[0].fill_text.is_some(), "Fill & Sign's Text tool opens its box again");
|
||||
}
|
||||
|
||||
/// Two boxes on page 1 of the active document, and a reply to the first.
|
||||
fn add_two_boxes_and_a_reply(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
for rect in [[20.0, 20.0, 60.0, 60.0], [100.0, 20.0, 140.0, 60.0]] {
|
||||
let shape = pdfcraft_engine::Shape::Rectangle { rect };
|
||||
let style = pdfcraft_engine::Style::default_for(&shape);
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: "Box".into(),
|
||||
author: "Tester".into(),
|
||||
})));
|
||||
}
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::ReplyToAnnotation { page: 0, index: 0, text: "Reply".into(), author: "Tester".into() }));
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Copied comments paste into another document on its current page, each with its replies, as
|
||||
/// new comments (a second paste gives a second set); the copies are selected afterwards.
|
||||
#[test]
|
||||
fn copied_comments_paste_into_another_document() {
|
||||
let mut h = harness(|app| {
|
||||
app.open_bytes("other.pdf", None, TEXT_FIXTURE.to_vec()).expect("opens");
|
||||
app.active = Some(0);
|
||||
});
|
||||
let (source, target) = (h.state().views[0].id, h.state().views[1].id);
|
||||
add_two_boxes_and_a_reply(&mut h);
|
||||
// Adding a comment selects it; with nothing selected there is nothing to copy.
|
||||
h.state_mut().views[0].comments.clear_selection();
|
||||
assert!(!h.state_mut().execute("comment.copy"), "nothing selected: nothing to copy");
|
||||
{
|
||||
let cv = &mut h.state_mut().views[0].comments;
|
||||
cv.select_only((0, 0));
|
||||
cv.also.push((0, 1));
|
||||
}
|
||||
assert!(h.state_mut().execute("comment.copy"));
|
||||
assert_eq!(h.state().comment_clipboard.as_ref().map(|c| c.count), Some(2));
|
||||
// Into the other document: Edit ▸ Paste comments hangs the bundle on the pointer, a click
|
||||
// places it, centred there (the two boxes span 20..140 × 20..60: centre 80, 40).
|
||||
h.state_mut().active = Some(1);
|
||||
h.run_steps(2);
|
||||
assert!(h.state_mut().execute("comment.paste"));
|
||||
let count = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(target).unwrap().info.annotations.len();
|
||||
assert_eq!(count(&h), 0, "nothing placed yet");
|
||||
assert!(matches!(h.state().views[1].comments.gesture, Some(pdfcraft_ui_egui::comments::Gesture::Place { count: 2, .. })));
|
||||
click_pt(&mut h, (150.0, 100.0));
|
||||
let pasted = h.state().session.get(target).unwrap().info.annotations.clone();
|
||||
assert_eq!(pasted.len(), 3, "two comments and the reply: {pasted:?}");
|
||||
assert!(pasted.iter().all(|a| a.page == 0), "on the page clicked: {pasted:?}");
|
||||
let parent = pasted.iter().find(|a| a.in_reply_to.is_none() && (a.rect[0] - 90.0).abs() < 1.0).expect("the first box, moved by (70, 60)");
|
||||
assert!((parent.rect[1] - 80.0).abs() < 1.0 && (parent.rect[2] - 130.0).abs() < 1.0, "{:?}", parent.rect);
|
||||
assert_eq!(pasted.iter().find(|a| a.in_reply_to.is_some()).and_then(|r| r.in_reply_to.clone()), parent.name, "the reply follows its copy");
|
||||
assert_eq!(h.state().views[1].comments.selection().len(), 2, "the copies are selected");
|
||||
assert!(h.state().views[1].comments.gesture.is_none());
|
||||
assert_eq!(h.state().session.get(target).unwrap().can_undo(), Some("Paste comments"));
|
||||
assert_eq!(h.state().session.get(source).unwrap().info.annotations.len(), 3, "the source is untouched");
|
||||
// The page menu's Paste comments places the bundle where the menu was opened: page 2 here.
|
||||
h.state_mut().views[1].comments.paste_request = Some((1, [100.0, 100.0]));
|
||||
h.run_steps(3);
|
||||
assert_eq!(count(&h), 6);
|
||||
let second: Vec<_> =
|
||||
h.state().session.get(target).unwrap().info.annotations.iter().filter(|a| a.page == 1 && a.in_reply_to.is_none()).cloned().collect();
|
||||
assert_eq!(second.len(), 2);
|
||||
let centre_x = (second.iter().map(|a| a.rect[0]).fold(f32::MAX, f32::min) + second.iter().map(|a| a.rect[2]).fold(f32::MIN, f32::max)) / 2.0;
|
||||
assert!((centre_x - 100.0).abs() < 1.0, "centred at the menu's point: {second:?}");
|
||||
// ⌘V (a Paste event) hangs the bundle on the pointer again; Escape lets it go.
|
||||
h.event(egui::Event::Paste(String::new()));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].comments.gesture.is_some());
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].comments.gesture.is_none());
|
||||
assert_eq!(count(&h), 6, "nothing placed");
|
||||
h.state_mut().undo();
|
||||
assert_eq!(count(&h), 3);
|
||||
// XFDF arriving from the system clipboard pastes too, even without the app's clipboard.
|
||||
let xfdf = h.state().comment_clipboard.clone().unwrap().xfdf;
|
||||
h.state_mut().comment_clipboard = None;
|
||||
assert!(!h.state_mut().execute("comment.paste"), "nothing copied: disabled");
|
||||
h.event(egui::Event::Paste(xfdf.clone()));
|
||||
h.run_steps(3);
|
||||
click_pt(&mut h, (200.0, 120.0));
|
||||
assert_eq!(count(&h), 6);
|
||||
h.event(egui::Event::Paste("just text".into()));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].comments.gesture.is_none(), "plain text is not a paste of comments");
|
||||
// Nor when comments were copied earlier: a word copied in another app since is that word.
|
||||
h.state_mut().comment_clipboard = Some(pdfcraft_ui_egui::CommentClip { xfdf, count: 2 });
|
||||
h.event(egui::Event::Paste("a word from elsewhere".into()));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].comments.gesture.is_none(), "the old comments don't hang on the pointer");
|
||||
// Nothing legible on the system clipboard: the app's own copied comments.
|
||||
h.event(egui::Event::Paste(String::new()));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].comments.gesture.is_some());
|
||||
}
|
||||
|
||||
/// Connected lines (and polygons) finish from the right-click menu too, as other editors do;
|
||||
/// the menu also takes the last point back and cancels the drawing.
|
||||
#[test]
|
||||
fn connected_lines_are_completed_from_the_context_menu() {
|
||||
let mut h = harness(|app| app.set_option("quick", "polyline").unwrap());
|
||||
for p in [(20.0, 150.0), (60.0, 130.0), (100.0, 150.0)] {
|
||||
click_pt(&mut h, p);
|
||||
}
|
||||
assert_eq!(comments(&h).len(), 0, "still drawing");
|
||||
let right_click = |h: &mut Harness<'static, PdfCraftApp>, p: (f32, f32)| {
|
||||
let p = at(h, p.0, p.1);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
};
|
||||
right_click(&mut h, (120.0, 100.0));
|
||||
h.get_by_label("Complete").click();
|
||||
h.run_steps(3);
|
||||
let c = comments(&h);
|
||||
assert_eq!(c.len(), 1, "{c:?}");
|
||||
assert_eq!(c[0].subtype, "PolyLine");
|
||||
// A polygon needs three points: with two, Complete is greyed; Undo last point and Cancel drawing work.
|
||||
h.state_mut().set_option("quick", "polygon").unwrap();
|
||||
for p in [(150.0, 150.0), (200.0, 150.0)] {
|
||||
click_pt(&mut h, p);
|
||||
}
|
||||
assert_eq!(drawn_points(&h), 2);
|
||||
right_click(&mut h, (220.0, 100.0));
|
||||
assert!(h.get_by_label("Complete").accesskit_node().is_disabled());
|
||||
h.get_by_label("Undo last point").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(drawn_points(&h), 1);
|
||||
right_click(&mut h, (220.0, 100.0));
|
||||
h.get_by_label("Cancel drawing").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(drawn_points(&h), 0);
|
||||
assert!(h.state().views[0].comments.gesture.is_none());
|
||||
assert_eq!(comments(&h).len(), 1, "nothing else was drawn");
|
||||
// On the second page, with the first on screen in the same frame: Complete reaches the page
|
||||
// the drawing is on (the first page passes first and must not take the request).
|
||||
h.state_mut().set_option("quick", "polyline").unwrap();
|
||||
h.state_mut().set_option("zoom", "50").unwrap();
|
||||
crate::support::settle(&mut h);
|
||||
assert!(h.state().views[0].page_screen_rect(0).is_some(), "page 1 is on screen too");
|
||||
let at2 = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| {
|
||||
let r = h.state().views[0].page_screen_rect(1).expect("page 2 on screen");
|
||||
pos2(r.left() + x / 300.0 * r.width(), r.top() + (200.0 - y) / 200.0 * r.height())
|
||||
};
|
||||
for (x, y) in [(20.0, 100.0), (60.0, 80.0)] {
|
||||
let p = at2(&h, x, y);
|
||||
click(&mut h, p);
|
||||
}
|
||||
assert_eq!(drawn_points(&h), 2);
|
||||
let p = at2(&h, 150.0, 150.0);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Complete").click();
|
||||
h.run_steps(3);
|
||||
let c = comments(&h);
|
||||
assert_eq!(c.len(), 2, "{c:?}");
|
||||
assert!(c.iter().any(|a| a.page == 1 && a.subtype == "PolyLine"), "{c:?}");
|
||||
assert!(h.state().views[0].comments.gesture.is_none());
|
||||
}
|
||||
|
||||
/// Properties on one of several selected comments changes all of them, each where it applies: the
|
||||
/// colour goes to every one, the line width to the shapes, the font size to the text box alone;
|
||||
/// a locked one stays as it is. One undo step. Colouring the selection works the same way.
|
||||
#[test]
|
||||
fn properties_change_every_selected_comment_where_it_applies() {
|
||||
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
|
||||
let mut h = harness(|_| {});
|
||||
let shapes = [
|
||||
Shape::Rectangle { rect: [20.0, 20.0, 60.0, 60.0] },
|
||||
Shape::Oval { rect: [80.0, 20.0, 120.0, 60.0] },
|
||||
Shape::TextBox { rect: [140.0, 20.0, 200.0, 40.0], font_size: 12.0 },
|
||||
Shape::Rectangle { rect: [220.0, 20.0, 260.0, 60.0] },
|
||||
];
|
||||
for shape in shapes {
|
||||
let style = Style::default_for(&shape);
|
||||
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: "x".into(),
|
||||
author: "Tester".into()
|
||||
})));
|
||||
}
|
||||
assert!(h.state_mut().apply_edit(Edit::LockAnnotation { page: 0, index: 3, locked: true }));
|
||||
{
|
||||
let c = &mut h.state_mut().views[0].comments;
|
||||
c.selected = Some((0, 0));
|
||||
c.also = vec![(0, 1), (0, 2), (0, 3)];
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.state_mut().open_comment_props(0, 0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Changes here apply to all 4 selected comments");
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
assert_eq!(d.others.len(), 3);
|
||||
d.edited.color = Some([0.0, 0.0, 1.0]);
|
||||
d.edited.width = Some(4.0);
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let props = |h: &Harness<'static, PdfCraftApp>, index: usize| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap()
|
||||
};
|
||||
for index in [0, 1] {
|
||||
let p = props(&h, index);
|
||||
assert_eq!((p.color, p.width), (Some([0.0, 0.0, 1.0]), Some(4.0)), "shape {index}");
|
||||
}
|
||||
assert_eq!(props(&h, 2).color, Some([0.0, 0.0, 1.0]), "the text box's text colour");
|
||||
let locked = props(&h, 3);
|
||||
assert_ne!(locked.color, Some([0.0, 0.0, 1.0]), "the locked rectangle stays as it was");
|
||||
let s = h.state();
|
||||
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Change comment properties"), "one undo step");
|
||||
// From the text box: its font size changes, and the shapes, which have none, keep their look.
|
||||
h.state_mut().views[0].comments.selected = Some((0, 2));
|
||||
h.state_mut().views[0].comments.also = vec![(0, 0), (0, 1)];
|
||||
h.state_mut().open_comment_props(0, 2);
|
||||
h.run_steps(2);
|
||||
h.state_mut().comment_props.as_mut().expect("open").edited.font_size = Some(20.0);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(props(&h, 2).font_size, Some(20.0));
|
||||
assert_eq!((props(&h, 0).font_size, props(&h, 0).width), (None, Some(4.0)));
|
||||
// Colouring the selection (the context menu's Colour with several selected).
|
||||
h.state_mut().colour_selected_comments([1.0, 0.0, 0.0]);
|
||||
h.run_steps(2);
|
||||
for index in [0, 1, 2] {
|
||||
assert_eq!(props(&h, index).color, Some([1.0, 0.0, 0.0]), "comment {index}");
|
||||
}
|
||||
assert_ne!(props(&h, 3).color, Some([1.0, 0.0, 0.0]));
|
||||
}
|
||||
|
||||
/// Comment Properties' text opacity, set with several comments selected, goes to the other text
|
||||
/// boxes whose text shows an opacity of its own (a border or a fill), and leaves a text-only box,
|
||||
/// whose one opacity is the whole comment's, as it was.
|
||||
#[test]
|
||||
fn part_opacity_changes_every_selected_comment_that_shows_the_part() {
|
||||
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
|
||||
let mut h = harness(|_| {});
|
||||
for (n, width) in [1.0, 1.0, 0.0].into_iter().enumerate() {
|
||||
let x = 20.0 + 100.0 * n as f64;
|
||||
let shape = Shape::TextBox { rect: [x, 20.0, x + 80.0, 40.0], font_size: 12.0 };
|
||||
let style = Style { width, ..Style::default_for(&shape) };
|
||||
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: "x".into(),
|
||||
author: "Tester".into()
|
||||
})));
|
||||
}
|
||||
let props = |h: &Harness<'static, PdfCraftApp>, index: usize| {
|
||||
let s = h.state();
|
||||
s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap()
|
||||
};
|
||||
let text_only = props(&h, 2);
|
||||
{
|
||||
let c = &mut h.state_mut().views[0].comments;
|
||||
c.selected = Some((0, 0));
|
||||
c.also = vec![(0, 1), (0, 2)];
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.state_mut().open_comment_props(0, 0);
|
||||
h.run_steps(2);
|
||||
h.state_mut().comment_props.as_mut().expect("open").edited.text_opacity = Some(0.4);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
for index in [0, 1] {
|
||||
let text = props(&h, index).text_opacity.expect("a text box's text opacity");
|
||||
assert!((text - 0.4).abs() < 1e-3, "bordered box {index}: {text}");
|
||||
}
|
||||
let after = props(&h, 2);
|
||||
assert_eq!((after.text_opacity, after.opacity), (text_only.text_opacity, text_only.opacity), "the text-only box keeps its look");
|
||||
}
|
||||
|
||||
/// Escape with a Fill & Sign tool on and several comments selected deselects them all first,
|
||||
/// then drops the tool.
|
||||
#[test]
|
||||
fn escape_with_a_fill_tool_deselects_every_comment_then_drops_the_tool() {
|
||||
use pdfcraft_ui_egui::fill_sign::FillTool;
|
||||
let mut h = harness(|_| {});
|
||||
add_two_boxes_and_a_reply(&mut h);
|
||||
h.state_mut().quick_tool = QuickTool::Fill(FillTool::Text);
|
||||
{
|
||||
let c = &mut h.state_mut().views[0].comments;
|
||||
c.selected = Some((0, 0));
|
||||
c.also = vec![(0, 1)];
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
let c = &h.state().views[0].comments;
|
||||
assert!(c.selected.is_none() && c.also.is_empty(), "the whole selection goes");
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Fill(FillTool::Text), "the tool stays for now");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Select);
|
||||
}
|
||||
@@ -911,6 +911,28 @@ fn comment_style_settings_clamp_and_refuse_hostile_values() {
|
||||
assert_eq!(note.color, [1.0, 0.82, 0.0], "the default colour stays");
|
||||
}
|
||||
|
||||
/// A tool's fill's and text's own opacities survive a restart, clamped; a tool without a fill or
|
||||
/// text keeps none, and the quick bar's Opacity (the whole comment's) clears them.
|
||||
#[test]
|
||||
fn part_opacity_defaults_persist_and_clamp() {
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
let mut a = PdfCraftApp::new();
|
||||
a.comment_prefs.set_part_opacity(CommentTool::Rectangle, Some(0.3), None);
|
||||
a.comment_prefs.set_part_opacity(CommentTool::TextBox, Some(0.2), Some(1.0));
|
||||
a.comment_prefs.set_part_opacity(CommentTool::Line, Some(0.5), Some(0.5));
|
||||
let mut b = PdfCraftApp::new();
|
||||
b.restore(&a.persist());
|
||||
let parts = |app: &PdfCraftApp, tool| {
|
||||
let s = app.comment_prefs.style(tool);
|
||||
(s.fill_opacity, s.text_opacity)
|
||||
};
|
||||
assert_eq!(parts(&b, CommentTool::Rectangle), (Some(0.3), None));
|
||||
assert_eq!(parts(&b, CommentTool::TextBox), (Some(0.2), Some(1.0)));
|
||||
assert_eq!(parts(&b, CommentTool::Line), (None, None), "a line has no fill or text of its own");
|
||||
b.restore(r#"{"comment_styles":[{"tool":"comment.freetext","fill_opacity":7,"text_opacity":-1}]}"#);
|
||||
assert_eq!(parts(&b, CommentTool::TextBox), (Some(1.0), Some(0.1)), "clamped; the text stays visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_keeps_the_point_under_the_cursor_still() {
|
||||
let mut h = harness(|app| {
|
||||
|
||||
@@ -29,9 +29,9 @@ pub use fdf::export_fdf;
|
||||
use fdf::{ImportBudget, fdf_body, import_fdf, walk_fdf};
|
||||
#[cfg(test)]
|
||||
use fdf::{MAX_FDF_BYTES, MAX_FDF_OBJECTS};
|
||||
pub use xfdf::export_xfdf;
|
||||
#[cfg(test)]
|
||||
use xfdf::{LOCKED, find_by_name};
|
||||
pub use xfdf::{Outline, comment_count, comment_outlines, comments_bounds, export_xfdf, export_xfdf_comments, import_comments, is_xfdf_comments};
|
||||
use xfdf::{apply_values, import_xfdf};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+322
-1
@@ -4,6 +4,7 @@ use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, TextReplacement,
|
||||
use pdfcraft_cos::{SaveOptions, write_full, write_incremental};
|
||||
use pdfcraft_forms::{NewField, add_field, fields, set_value};
|
||||
|
||||
use super::xfdf::PDFCRAFT_NS;
|
||||
use super::*;
|
||||
|
||||
/// Two blank pages with three fields (text, check box, list).
|
||||
@@ -149,7 +150,7 @@ fn values(doc: &Document) -> Vec<(String, Vec<String>)> {
|
||||
fn a_replace_text_pair_survives_an_xfdf_round_trip() {
|
||||
let mut src = blank();
|
||||
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
|
||||
let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
|
||||
let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() };
|
||||
add_text_replacement(
|
||||
&mut src,
|
||||
@@ -2099,3 +2100,323 @@ fn an_fdf_comment_popup_without_a_rect_is_dropped() {
|
||||
let popups = page.iter().filter(|a| dst.resolve(a).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Popup".as_slice()))).count();
|
||||
assert_eq!(popups, 0);
|
||||
}
|
||||
|
||||
/// Copy: the chosen comments travel with their replies and nothing else.
|
||||
#[test]
|
||||
fn exporting_selected_comments_takes_them_and_their_replies() {
|
||||
let src = filled();
|
||||
let note = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap();
|
||||
let xfdf = export_xfdf_comments(&src, &[(note.page, note.index)], "form.pdf");
|
||||
assert!(xfdf.contains("<text page=\"0\""), "{xfdf}");
|
||||
assert!(xfdf.contains("inreplyto=\"n1\""), "the reply comes along: {xfdf}");
|
||||
assert!(!xfdf.contains("<highlight") && !xfdf.contains("<ink"), "other comments stay: {xfdf}");
|
||||
assert!(!xfdf.contains("<fields>"), "no field values");
|
||||
assert!(is_xfdf_comments(&xfdf));
|
||||
assert!(!is_xfdf_comments("<xfdf xmlns=\"x\"><fields/></xfdf>") && !is_xfdf_comments("just text"));
|
||||
// A position that is no comment exports nothing but a valid, empty file.
|
||||
let empty = export_xfdf_comments(&src, &[(7, 7)], "form.pdf");
|
||||
assert!(empty.contains("<annots>\n</annots>"), "{empty}");
|
||||
}
|
||||
|
||||
/// Paste: the copies land on the asked-for page under names of their own, so nothing is
|
||||
/// replaced and a second paste gives a second set; the reply keeps its copied parent.
|
||||
#[test]
|
||||
fn importing_comments_as_copies_adds_them_on_the_page_with_fresh_names() {
|
||||
let src = filled();
|
||||
let note = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap();
|
||||
let xfdf = export_xfdf_comments(&src, &[(note.page, note.index)], "form.pdf");
|
||||
let mut dst = blank();
|
||||
let r = import_comments(&mut dst, &xfdf, Some(1), Some("paste1"), None).unwrap();
|
||||
assert_eq!((r.comments, r.fields), (2, 0), "{r:?}");
|
||||
let first: Vec<_> = summaries(&dst);
|
||||
assert!(first.iter().all(|s| s.page == 1), "every copy is on page 2: {first:?}");
|
||||
let parent = first.iter().find(|s| s.subtype == "Text").unwrap();
|
||||
let reply = first.iter().find(|s| s.in_reply_to.is_some()).expect("the reply came along");
|
||||
assert_eq!(reply.in_reply_to.as_deref(), Some("paste1-1"), "and replies to the copied note, by its new name ({parent:?})");
|
||||
// Again: two sets, not one replaced set.
|
||||
import_comments(&mut dst, &xfdf, Some(0), Some("paste2"), None).unwrap();
|
||||
let second = summaries(&dst);
|
||||
assert_eq!(second.len(), 4, "{second:?}");
|
||||
assert_eq!(second.iter().filter(|s| s.page == 0).count(), 2);
|
||||
// The copies' names are their own, not the originals'.
|
||||
let xfdf2 = export_xfdf(&dst, true, false, "dst.pdf");
|
||||
assert!(!xfdf2.contains("name=\"n1\"") && !xfdf2.contains("name=\"r1\""), "{xfdf2}");
|
||||
assert!(xfdf2.contains("name=\"paste1-1\"") && xfdf2.contains("inreplyto=\"paste1-1\""), "{xfdf2}");
|
||||
// A page the document doesn't have is refused, and nothing changes.
|
||||
assert!(import_comments(&mut dst, &xfdf, Some(9), Some("paste3"), None).is_err());
|
||||
assert_eq!(summaries(&dst).len(), 4);
|
||||
// Without a target page the copies keep their own pages; without a prefix the import is
|
||||
// the usual one (same names replace).
|
||||
let mut other = blank();
|
||||
import_comments(&mut other, &xfdf, None, Some("p"), None).unwrap();
|
||||
assert!(summaries(&other).iter().all(|s| s.page == 0));
|
||||
import_comments(&mut other, &xfdf, None, None, None).unwrap();
|
||||
import_comments(&mut other, &xfdf, None, None, None).unwrap();
|
||||
assert_eq!(summaries(&other).len(), 4, "the named import replaced its own earlier copy");
|
||||
}
|
||||
|
||||
/// Other tools write comments straight into the page's `/Annots` array, without an object of
|
||||
/// their own: they copy like any other, and two source comments sharing a name give two copies.
|
||||
#[test]
|
||||
fn comments_written_directly_into_the_page_array_copy_and_shared_names_give_two_copies() {
|
||||
let mut doc = blank();
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
let mut direct = Dict::new();
|
||||
direct.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
direct.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
direct.set(b"Rect".to_vec(), arr(&[10.0, 10.0, 60.0, 40.0]));
|
||||
direct.set(b"Contents".to_vec(), PdfString::text("drawn elsewhere"));
|
||||
let mut twin = direct.clone();
|
||||
twin.set(b"NM".to_vec(), PdfString::text("shared"));
|
||||
let mut other_twin = twin.clone();
|
||||
other_twin.set(b"Rect".to_vec(), arr(&[100.0, 10.0, 150.0, 40.0]));
|
||||
doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(vec![Object::Dict(direct), Object::Dict(twin), Object::Dict(other_twin)])))
|
||||
.unwrap();
|
||||
assert_eq!(summaries(&doc).len(), 3);
|
||||
let xfdf = export_xfdf_comments(&doc, &[(0, 0)], "x.pdf");
|
||||
assert!(xfdf.contains("<square") && xfdf.contains("drawn elsewhere"), "a direct annotation copies: {xfdf}");
|
||||
assert_eq!(comment_count(&xfdf), 1);
|
||||
let both = export_xfdf_comments(&doc, &[(0, 1), (0, 2)], "x.pdf");
|
||||
assert_eq!(comment_count(&both), 2);
|
||||
let mut dst = blank();
|
||||
import_comments(&mut dst, &both, Some(0), Some("p"), None).unwrap();
|
||||
let copies = summaries(&dst);
|
||||
assert_eq!(copies.len(), 2, "two copies, not one replacing the other: {copies:?}");
|
||||
assert_ne!(copies[0].name, copies[1].name);
|
||||
assert!(export_xfdf_comments(&doc, &[(0, 9)], "x.pdf").contains("<annots>\n</annots>"), "a position that is no annotation copies nothing");
|
||||
}
|
||||
|
||||
/// A selected comment without a name of its own (other tools leave /NM out) gets one for the
|
||||
/// export, so its replies still find it when the copies are pasted.
|
||||
#[test]
|
||||
fn replies_to_a_selected_comment_without_a_name_follow_it_into_the_copy() {
|
||||
let meta = |id: &str| Meta { date: None, id: id.into() };
|
||||
let mut doc = blank();
|
||||
let shape = Shape::Rectangle { rect: [10.0, 10.0, 60.0, 40.0] };
|
||||
add_annotation(
|
||||
&mut doc,
|
||||
&NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: "nameless".into(), author: "Ada".into() },
|
||||
&meta("gone"),
|
||||
)
|
||||
.unwrap();
|
||||
let index = summaries(&doc)[0].index;
|
||||
add_reply(&mut doc, 0, index, "Agreed", "Bob", &meta("r1")).unwrap();
|
||||
// The parent loses its /NM, as another tool may have left it.
|
||||
let annots = pdfcraft_model::pages(&doc)[0].dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap();
|
||||
let parent = annots[index].as_ref().unwrap();
|
||||
doc.update_dict(parent, |d| {
|
||||
d.remove(b"NM");
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(threads(&doc)[0].0, None, "{:?}", threads(&doc));
|
||||
// A comment written straight into the page array already carries the name the parent would
|
||||
// get: the parent's export name steers clear of it.
|
||||
let mut direct = Dict::new();
|
||||
direct.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
direct.set(b"Subtype".to_vec(), Object::name("Square"));
|
||||
direct.set(b"Rect".to_vec(), arr(&[100.0, 100.0, 150.0, 140.0]));
|
||||
direct.set(b"NM".to_vec(), PdfString::text(&format!("pdfcraft-0-{index}")));
|
||||
let page = pdfcraft_model::pages(&doc)[0].obj;
|
||||
doc.update_dict(page, |p| {
|
||||
if let Some(Object::Array(annots)) = p.get_mut(b"Annots") {
|
||||
annots.push(Object::Dict(direct));
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
let xfdf = export_xfdf_comments(&doc, &[(0, index)], "x.pdf");
|
||||
let given = format!("pdfcraft-0-{index}x");
|
||||
assert!(xfdf.contains(&format!("name=\"{given}\"")) && xfdf.contains(&format!("inreplyto=\"{given}\"")), "{xfdf}");
|
||||
let mut dst = blank();
|
||||
import_comments(&mut dst, &xfdf, Some(0), Some("p"), None).unwrap();
|
||||
let copies = threads(&dst);
|
||||
assert_eq!(copies.len(), 2, "{copies:?}");
|
||||
let parent_name = copies.iter().find(|(_, irt, _)| irt.is_none()).and_then(|(n, _, _)| n.clone());
|
||||
assert!(parent_name.is_some(), "{copies:?}");
|
||||
let reply_to = copies.iter().find(|(_, irt, _)| irt.is_some()).and_then(|(_, irt, _)| irt.clone());
|
||||
assert_eq!(reply_to, parent_name, "the reply follows its copied parent: {copies:?}");
|
||||
}
|
||||
|
||||
/// A paste placed elsewhere: every copy moves by the offset, rect, quads, ink and vertices
|
||||
/// alike; the bounds and count of a clipboard text say what hangs on the pointer.
|
||||
#[test]
|
||||
fn copies_placed_with_an_offset_move_whole_and_the_text_reports_its_bounds() {
|
||||
let src = filled();
|
||||
let all: Vec<(usize, usize)> = summaries(&src).iter().filter(|s| s.in_reply_to.is_none()).map(|s| (s.page, s.index)).collect();
|
||||
let xfdf = export_xfdf_comments(&src, &all, "form.pdf");
|
||||
assert_eq!(comment_count(&xfdf), 3, "note, highlight, ink (the reply is not counted)");
|
||||
let bounds = comments_bounds(&xfdf).unwrap();
|
||||
assert!(bounds[0] <= 10.0 && bounds[1] <= 10.0 && bounds[2] >= 100.0 && bounds[3] >= 500.0, "{bounds:?}");
|
||||
assert_eq!(comment_count("just text"), 0);
|
||||
assert_eq!(comments_bounds("<xfdf><annots></annots></xfdf>"), None);
|
||||
// A reply with a rect of its own (some tools write one, at 0,0 or away from its parent)
|
||||
// doesn't widen the box the paste is centred on: it has no place on the page.
|
||||
let with_reply = r#"<xfdf xmlns="http://ns.adobe.com/xfdf/"><annots>
|
||||
<square page="0" name="a" rect="100,100,140,120"/>
|
||||
<text page="0" name="r" inreplyto="a" rect="500,600,520,620"><contents>re</contents></text>
|
||||
</annots></xfdf>"#;
|
||||
assert_eq!(comments_bounds(with_reply), Some([100.0, 100.0, 140.0, 120.0]));
|
||||
let mut dst = blank();
|
||||
import_comments(&mut dst, &xfdf, Some(0), Some("p"), Some([100.0, -50.0])).unwrap();
|
||||
let moved = summaries(&dst);
|
||||
let note = moved.iter().find(|s| s.subtype == "Text").unwrap();
|
||||
let original = summaries(&src).into_iter().find(|s| s.subtype == "Text").unwrap();
|
||||
let (a, b) = (note.rect, original.rect);
|
||||
assert!((a[0] - b[0] - 100.0).abs() < 0.01 && (a[1] - b[1] + 50.0).abs() < 0.01, "{a:?} from {b:?}");
|
||||
let again = export_xfdf(&dst, true, false, "dst.pdf");
|
||||
assert!(again.contains("coords=\"110,60,190,60,110,50,190,50\""), "quads moved too: {again}");
|
||||
assert!(again.contains("<gesture>110,-40;120,-20;140,-30</gesture>"), "ink moved too: {again}");
|
||||
// The outlines say what hangs on the pointer: a box for the note, quads for the highlight,
|
||||
// strokes for the ink, each in its colour; the reply draws nothing of its own.
|
||||
let outlines = comment_outlines(&xfdf);
|
||||
assert_eq!(outlines.len(), 3, "{outlines:?}");
|
||||
assert!(outlines.iter().any(|o| matches!(o, Outline::Rect { round: false, .. })));
|
||||
assert!(outlines.iter().any(|o| matches!(o, Outline::Quads { quads, .. } if quads.len() == 1 && quads[0][0] == 10.0)));
|
||||
assert!(outlines.iter().any(|o| matches!(o, Outline::Ink { strokes, .. } if strokes == &vec![vec![[10.0, 10.0], [20.0, 30.0], [40.0, 20.0]]])));
|
||||
assert!(comment_outlines("no xfdf").is_empty());
|
||||
let drawn = "<?xml version=\"1.0\"?><xfdf xmlns=\"http://ns.adobe.com/xfdf/\"><annots>\
|
||||
<line page=\"0\" rect=\"0,0,50,50\" start=\"1,2\" end=\"3,4\" color=\"#FF0000\"/>\
|
||||
<polyline page=\"0\" rect=\"0,0,9,9\"><vertices>1,1;5,5;9,1</vertices></polyline>\
|
||||
<polygon page=\"0\" rect=\"0,0,9,9\"><vertices>1,1;5,5;9,1</vertices></polygon>\
|
||||
<circle page=\"0\" rect=\"10,10,30,20\"/>\
|
||||
<freetext page=\"0\" rect=\"40,40,80,60\" callout=\"20,20,40,50\"/>\
|
||||
<text page=\"0\" rect=\"1,1,2,2\" inreplyto=\"x\"/></annots></xfdf>";
|
||||
let outlines = comment_outlines(drawn);
|
||||
assert_eq!(outlines.len(), 6, "{outlines:?}");
|
||||
assert_eq!(outlines[0], Outline::Line { points: vec![[1.0, 2.0], [3.0, 4.0]], color: Some([1.0, 0.0, 0.0]) });
|
||||
assert!(matches!(&outlines[1], Outline::Poly { closed: false, points, .. } if points.len() == 3));
|
||||
assert!(matches!(&outlines[2], Outline::Poly { closed: true, .. }));
|
||||
assert!(matches!(&outlines[3], Outline::Rect { round: true, .. }));
|
||||
assert!(matches!(&outlines[5], Outline::Line { points, .. } if points == &vec![[20.0, 20.0], [40.0, 50.0]]));
|
||||
// A non-finite offset is ignored rather than written into the document.
|
||||
import_comments(&mut dst, &xfdf, Some(1), Some("q"), Some([f64::NAN, 0.0])).unwrap();
|
||||
assert_eq!(summaries(&dst).iter().filter(|s| s.page == 1 && s.subtype == "Text").map(|s| s.rect[0]).next(), Some(b[0]));
|
||||
}
|
||||
|
||||
/// Fill & Sign marks and text boxes copied: the bundle on the pointer shows the tick, cross, dot
|
||||
/// and line themselves (not empty boxes) and a text box's text, and the pasted copies are drawn
|
||||
/// with their mark.
|
||||
#[test]
|
||||
fn copied_marks_and_text_boxes_show_what_they_are_while_placed_and_once_pasted() {
|
||||
use pdfcraft_annot::FillMark;
|
||||
let mut src = blank();
|
||||
let meta = Meta { date: Some("D:20261020120000Z".into()), id: "m".into() };
|
||||
let marks = [FillMark::Check, FillMark::Cross, FillMark::Dot, FillMark::Line];
|
||||
for (k, mark) in marks.into_iter().enumerate() {
|
||||
let shape = Shape::Mark { rect: [100.0 + 40.0 * k as f64, 600.0, 120.0 + 40.0 * k as f64, 620.0], mark };
|
||||
let new = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: "Ada".into() };
|
||||
add_annotation(&mut src, &new, &meta).unwrap();
|
||||
}
|
||||
let text = Shape::TextBox { rect: [100.0, 400.0, 200.0, 430.0], font_size: 14.0 };
|
||||
let new = NewAnnotation { page: 0, style: Style::default_for(&text), shape: text, contents: "Abnahme\nok".into(), author: "Ada".into() };
|
||||
add_annotation(&mut src, &new, &meta).unwrap();
|
||||
let all: Vec<(usize, usize)> = summaries(&src).iter().filter(|s| s.in_reply_to.is_none()).map(|s| (s.page, s.index)).collect();
|
||||
let xfdf = export_xfdf_comments(&src, &all, "protocol.pdf");
|
||||
let outlines = comment_outlines(&xfdf);
|
||||
// The tick is one stroke, the cross two, the dot a circle, the line one stroke: the strokes
|
||||
// the appearance draws, not the stamps' boxes.
|
||||
let check = FillMark::Check.strokes([100.0, 600.0, 120.0, 620.0]);
|
||||
assert!(outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if *points == check[0])), "{outlines:?}");
|
||||
assert!(!outlines.iter().any(|o| matches!(o, Outline::Rect { rect, round: false, .. } if *rect == [100.0, 600.0, 120.0, 620.0])), "{outlines:?}");
|
||||
let cross = FillMark::Cross.strokes([140.0, 600.0, 160.0, 620.0]);
|
||||
assert!(cross.iter().all(|s| outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if points == s))), "{outlines:?}");
|
||||
assert!(
|
||||
outlines
|
||||
.iter()
|
||||
.any(|o| matches!(o, Outline::Rect { rect, round: true, .. } if (rect[0] - 184.0).abs() < 1e-9 && (rect[2] - 196.0).abs() < 1e-9)),
|
||||
"{outlines:?}"
|
||||
);
|
||||
assert!(outlines.iter().any(|o| matches!(o, Outline::Line { points, .. } if *points == vec![[220.0, 610.0], [240.0, 610.0]])), "{outlines:?}");
|
||||
assert!(
|
||||
outlines.iter().any(|o| matches!(o, Outline::Text { text, size, .. } if text == "Abnahme\nok" && (*size - 14.0).abs() < 1e-9)),
|
||||
"the text box shows its text at its size: {outlines:?}"
|
||||
);
|
||||
// Pasted into another document, each mark is drawn with its own strokes.
|
||||
let mut dst = blank();
|
||||
import_comments(&mut dst, &xfdf, Some(1), Some("p"), Some([10.0, -20.0])).unwrap();
|
||||
let page = pdfcraft_model::pages(&dst)[1].obj;
|
||||
let annots =
|
||||
dst.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| dst.resolve(&a).as_array().cloned().unwrap_or_default()).unwrap();
|
||||
let stamps: Vec<Dict> =
|
||||
annots.iter().filter_map(|a| dst.resolve(a).as_dict().cloned()).filter(|d| d.name(b"Subtype") == Some(b"Stamp".as_slice())).collect();
|
||||
assert_eq!(stamps.len(), 4);
|
||||
for d in &stamps {
|
||||
let ap = d.get(b"AP").map(|a| dst.resolve(a)).and_then(|a| a.as_dict().cloned()).and_then(|a| a.get(b"N").cloned()).expect("an appearance");
|
||||
let normal = dst.resolve(&ap);
|
||||
let Object::Stream(s) = normal.as_ref() else { panic!("no appearance stream") };
|
||||
let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
|
||||
let drawn =
|
||||
if d.name(b"Name") == Some(b"PCDot".as_slice()) { content.contains(" c\n") || content.contains(" c ") } else { content.contains(" S\n") };
|
||||
assert!(drawn, "{:?}: {content}", d.name(b"Name"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A pasted copy is what the original was: typewriter text stays typewriter text (`/IT`) and a
|
||||
/// Fill & Sign item, a cloud keeps its cloudy border, and PdfCraft's own keys (a turned comment's
|
||||
/// `/PCBox` and `/PCAngle`) come along. A reference into the source document stays behind, and
|
||||
/// keys that aren't PdfCraft's, or a malformed attribute, are not taken from the attribute.
|
||||
#[test]
|
||||
fn copies_keep_their_intent_cloud_and_pdfcraft_keys() {
|
||||
let mut src = blank();
|
||||
let meta = |id: &str| Meta { date: Some("D:20261023120000Z".into()), id: id.into() };
|
||||
let typed = Shape::Typewriter { rect: [50.0, 500.0, 150.0, 520.0], font_size: 12.0 };
|
||||
let cloud = Shape::Polygon { vertices: vec![[200.0, 200.0], [300.0, 200.0], [250.0, 280.0]], cloud: true };
|
||||
let square = Shape::Rectangle { rect: [100.0, 100.0, 180.0, 160.0] };
|
||||
for (shape, id) in [(typed, "t1"), (cloud, "c1"), (square, "s1")] {
|
||||
let new = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: format!("{id} text"), author: "Ada".into() };
|
||||
add_annotation(&mut src, &new, &meta(id)).unwrap();
|
||||
}
|
||||
let annots = |doc: &Document, page: usize| -> Vec<Dict> {
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
let list = pages[page].dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
|
||||
list.iter().filter_map(|a| doc.resolve(a).as_dict().cloned()).filter(|d| !matches!(d.name(b"Subtype"), Some(b"Popup" | b"Widget"))).collect()
|
||||
};
|
||||
let refs: Vec<ObjRef> = {
|
||||
let pages = pdfcraft_model::pages(&src);
|
||||
let list = pages[0].dict.get(b"Annots").and_then(|a| src.resolve(a).as_array().cloned()).unwrap();
|
||||
list.iter().filter_map(Object::as_ref).collect()
|
||||
};
|
||||
let target = src.add(Object::Dict(Dict::new()));
|
||||
let square_ref = *refs.iter().find(|r| src.get(**r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Square".as_slice()))).unwrap();
|
||||
src.update_dict(square_ref, |d| {
|
||||
d.set(b"PCBox".to_vec(), arr(&[110.0, 110.0, 170.0, 150.0]));
|
||||
d.set(b"PCAngle".to_vec(), Object::Real(30.0));
|
||||
d.set(b"PCElsewhere".to_vec(), Object::Ref(target));
|
||||
})
|
||||
.unwrap();
|
||||
let which: Vec<(usize, usize)> = summaries(&src).iter().map(|c| (c.page, c.index)).collect();
|
||||
let xfdf = export_xfdf_comments(&src, &which, "src.pdf");
|
||||
assert!(xfdf.contains("intent=\"FreeTextTypeWriter\"") && xfdf.contains("style=\"cloudy\""), "{xfdf}");
|
||||
let mut dst = blank();
|
||||
import_comments(&mut dst, &xfdf, Some(1), Some("p"), Some([5.0, -5.0])).unwrap();
|
||||
let copies = annots(&dst, 1);
|
||||
assert_eq!(copies.len(), 3, "{copies:?}");
|
||||
let typed = copies.iter().find(|d| d.name(b"Subtype") == Some(b"FreeText".as_slice())).unwrap();
|
||||
assert_eq!(typed.name(b"IT"), Some(b"FreeTextTypeWriter".as_slice()), "{typed:?}");
|
||||
assert!(pdfcraft_annot::is_fill_sign(&dst, typed), "still a Fill & Sign item: {typed:?}");
|
||||
assert!(matches!(typed.get(b"PCFillSign"), Some(Object::Bool(true))), "{typed:?}");
|
||||
let cloud = copies.iter().find(|d| d.name(b"Subtype") == Some(b"Polygon".as_slice())).unwrap();
|
||||
assert_eq!(cloud.name(b"IT"), Some(b"PolygonCloud".as_slice()));
|
||||
let be = cloud.get(b"BE").and_then(Object::as_dict).expect("a cloudy border");
|
||||
assert_eq!(be.name(b"S"), Some(b"C".as_slice()));
|
||||
assert_eq!(be.get(b"I").and_then(Object::as_f64), Some(1.0));
|
||||
let square = copies.iter().find(|d| d.name(b"Subtype") == Some(b"Square".as_slice())).unwrap();
|
||||
assert_eq!(square.get(b"PCAngle").and_then(Object::as_f64), Some(30.0));
|
||||
assert!(square.get(b"PCBox").and_then(Object::as_array).is_some_and(|b| b.len() == 4));
|
||||
assert!(square.get(b"PCElsewhere").is_none(), "a reference into the source stays behind: {square:?}");
|
||||
// File ▸ Export comments writes the standard attributes only.
|
||||
let exported = export_xfdf(&src, true, false, "src.pdf");
|
||||
assert!(exported.contains("intent=\"FreeTextTypeWriter\"") && !exported.contains("pdfcraft:keys"), "{exported}");
|
||||
// A hand-made attribute: only PdfCraft keys with values of their own are taken.
|
||||
let crafted = format!(
|
||||
"<xfdf xmlns=\"http://ns.adobe.com/xfdf/\" xmlns:pdfcraft=\"{PDFCRAFT_NS}\"><annots>\
|
||||
<square page=\"0\" rect=\"10,10,50,50\" pdfcraft:keys=\"<< /PCAngle 45 /Subtype /Ink /PCRef 3 0 R >>\"/>\
|
||||
<square page=\"0\" rect=\"60,10,90,50\" pdfcraft:keys=\"<< /PCAngle\"/></annots></xfdf>"
|
||||
);
|
||||
let mut third = blank();
|
||||
import_comments(&mut third, &crafted, Some(0), Some("q"), None).unwrap();
|
||||
let got = annots(&third, 0);
|
||||
assert_eq!(got.len(), 2, "{got:?}");
|
||||
assert_eq!(got[0].get(b"PCAngle").and_then(Object::as_f64), Some(45.0));
|
||||
assert_eq!(got[0].name(b"Subtype"), Some(b"Square".as_slice()), "not taken from the attribute");
|
||||
assert!(got[0].get(b"PCRef").is_none() && got[1].get(b"PCAngle").is_none(), "{got:?}");
|
||||
}
|
||||
+507
-61
@@ -6,22 +6,188 @@ use super::*;
|
||||
|
||||
// ── export ──────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Every comment as an XFDF element (one line per comment, replies included).
|
||||
fn xfdf_annots(doc: &Document, out: &mut String) {
|
||||
/// The annotations of `which` (0-based page, position in the page's `/Annots`) as positions,
|
||||
/// with every reply to them (and to those replies), so a thread travels whole. Positions, not
|
||||
/// references: other tools write comments straight into the page's array, without an object of
|
||||
/// their own, and those copy too.
|
||||
fn annots_with_replies(doc: &Document, which: &[(usize, usize)]) -> HashSet<(usize, usize)> {
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
// NM of every annotation, for inreplyto.
|
||||
let annots = |p: &pdfcraft_model::Page| -> Vec<Object> {
|
||||
p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default()
|
||||
};
|
||||
/// An annotation's position, its reference (when it has one) and its parent's (`/IRT`).
|
||||
struct Entry {
|
||||
pos: (usize, usize),
|
||||
obj: Option<ObjRef>,
|
||||
parent: Option<ObjRef>,
|
||||
}
|
||||
let all: Vec<Entry> = pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(pi, p)| {
|
||||
annots(p).into_iter().enumerate().map(move |(ai, a)| {
|
||||
let parent = doc.resolve(&a).as_dict().and_then(|d| d.get(b"IRT")).and_then(Object::as_ref);
|
||||
Entry { pos: (pi, ai), obj: a.as_ref(), parent }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut set: HashSet<(usize, usize)> = which.iter().copied().filter(|w| all.iter().any(|e| e.pos == *w)).collect();
|
||||
// Replies point at their parent through /IRT: a pass per reply depth, bounded by the count.
|
||||
for _ in 0..all.len() {
|
||||
let before = set.len();
|
||||
let parents: HashSet<ObjRef> = all.iter().filter(|e| set.contains(&e.pos)).filter_map(|e| e.obj).collect();
|
||||
for e in &all {
|
||||
if e.parent.is_some_and(|parent| parents.contains(&parent)) {
|
||||
set.insert(e.pos);
|
||||
}
|
||||
}
|
||||
if set.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Every comment as an XFDF element (one line per comment, replies included), or only those at
|
||||
/// the positions in `only`.
|
||||
/// The namespace of PdfCraft's own attributes in XFDF (`pdfcraft:keys`): other readers skip them.
|
||||
pub(crate) const PDFCRAFT_NS: &str = "https://github.com/storytold/pdfcraft#xfdf";
|
||||
/// At most this much text in a `pdfcraft:keys` attribute is read.
|
||||
const MAX_OWN_KEYS: usize = 64 * 1024;
|
||||
|
||||
/// A value that means the same in another document: no reference or stream into this one.
|
||||
fn portable(o: &Object, depth: usize) -> bool {
|
||||
match o {
|
||||
Object::Ref(_) | Object::Stream(_) => false,
|
||||
Object::Array(a) => depth < 4 && a.iter().all(|o| portable(o, depth + 1)),
|
||||
Object::Dict(d) => depth < 4 && d.iter().all(|(_, o)| portable(o, depth + 1)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The annotation's own PdfCraft keys (`/PC…`: a Fill & Sign item, a turned comment's box and
|
||||
/// angle, …) as one PDF dictionary, for a copy that is what the original was. Values that point
|
||||
/// into this document stay behind.
|
||||
fn own_keys(d: &Dict) -> Option<String> {
|
||||
let mut keys = Dict::new();
|
||||
// Each value must also write as text: XML can't hold raw bytes.
|
||||
let writes_as_text = |v: &Object| {
|
||||
let mut out = Vec::new();
|
||||
pdfcraft_cos::serialize(v, &mut out);
|
||||
std::str::from_utf8(&out).is_ok()
|
||||
};
|
||||
for (k, v) in d.iter().filter(|(k, v)| k.starts_with(b"PC") && portable(v, 0) && writes_as_text(v)) {
|
||||
keys.set(k.clone(), v.clone());
|
||||
}
|
||||
if keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
pdfcraft_cos::serialize(&Object::Dict(keys), &mut out);
|
||||
String::from_utf8(out).ok()
|
||||
}
|
||||
|
||||
/// The `pdfcraft:keys` of an XFDF comment back as dictionary entries: PdfCraft's keys only, with
|
||||
/// values that mean the same here. Anything malformed is skipped.
|
||||
fn read_own_keys(node: roxmltree::Node, d: &mut Dict) {
|
||||
let Some(text) = node.attribute((PDFCRAFT_NS, "keys")).filter(|t| t.len() <= MAX_OWN_KEYS) else { return };
|
||||
let Ok(Object::Dict(keys)) = pdfcraft_cos::Lexer::new(text.as_bytes(), 0).object() else { return };
|
||||
for (k, v) in keys.iter().filter(|(k, v)| k.starts_with(b"PC") && portable(v, 0)) {
|
||||
if !d.contains(k) {
|
||||
d.set(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of every annotation, for `inreplyto`: its /NM, or, for one in `only` without one
|
||||
/// (other tools leave /NM out), a name new to the document for this export, so a reply copied
|
||||
/// with it still finds it.
|
||||
fn annotation_names(doc: &Document, pages: &[pdfcraft_model::Page], only: Option<&HashSet<(usize, usize)>>) -> HashMap<ObjRef, String> {
|
||||
let mut names: HashMap<ObjRef, String> = HashMap::new();
|
||||
for p in &pages {
|
||||
for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() {
|
||||
if let Some(r) = a.as_ref()
|
||||
&& let Some(nm) = doc.get(r).as_dict().and_then(|d| text_of(doc, d, b"NM"))
|
||||
{
|
||||
names.insert(r, nm);
|
||||
let mut unnamed: Vec<(usize, usize, ObjRef)> = Vec::new();
|
||||
// Every name in use, those of annotations written straight into the page array too, so a
|
||||
// given name is new.
|
||||
let mut taken: HashSet<String> = HashSet::new();
|
||||
for (pi, p) in pages.iter().enumerate() {
|
||||
for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() {
|
||||
let nm = doc.resolve(&a).as_dict().and_then(|d| text_of(doc, d, b"NM"));
|
||||
if let Some(nm) = &nm {
|
||||
taken.insert(nm.clone());
|
||||
}
|
||||
let Some(r) = a.as_ref() else { continue };
|
||||
match nm {
|
||||
Some(nm) => {
|
||||
names.insert(r, nm);
|
||||
}
|
||||
None if only.is_some_and(|only| only.contains(&(pi, ai))) => unnamed.push((pi, ai, r)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (pi, ai, r) in unnamed {
|
||||
let mut name = format!("pdfcraft-{pi}-{ai}");
|
||||
while taken.contains(&name) {
|
||||
name.push('x');
|
||||
}
|
||||
taken.insert(name.clone());
|
||||
names.insert(r, name);
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// The elements inside a comment's XFDF element: its text, default appearance, ink, vertices
|
||||
/// and pop-up (on page `pi`).
|
||||
fn xfdf_annot_children(doc: &Document, out: &mut String, d: &Dict, pi: usize) {
|
||||
if let Some(c) = text_of(doc, d, b"Contents") {
|
||||
let _ = write!(out, "<contents>{}</contents>", esc(&c));
|
||||
}
|
||||
if let Some(da) = text_of(doc, d, b"DA") {
|
||||
let _ = write!(out, "<defaultappearance>{}</defaultappearance>", esc(&da));
|
||||
}
|
||||
let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
if !ink.is_empty() {
|
||||
out.push_str("<inklist>");
|
||||
for g in ink {
|
||||
let v = nums_of(doc, Some(&g));
|
||||
let pts: Vec<String> = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect();
|
||||
let _ = write!(out, "<gesture>{}</gesture>", pts.join(";"));
|
||||
}
|
||||
out.push_str("</inklist>");
|
||||
}
|
||||
// Polygon and polyline points (XFDF `<vertices>`: `x,y;x,y;…`).
|
||||
let vertices = nums_of(doc, d.get(b"Vertices"));
|
||||
let pts: Vec<String> = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect();
|
||||
if !pts.is_empty() {
|
||||
let _ = write!(out, "<vertices>{}</vertices>", pts.join(";"));
|
||||
}
|
||||
if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) {
|
||||
let r = nums_of(doc, pd.get(b"Rect"));
|
||||
if r.len() == 4 {
|
||||
let open = matches!(pd.get(b"Open"), Some(Object::Bool(true)));
|
||||
let _ = write!(
|
||||
out,
|
||||
"<popup page=\"{pi}\" rect=\"{},{},{},{}\" open=\"{}\"/>",
|
||||
n(r[0]),
|
||||
n(r[1]),
|
||||
n(r[2]),
|
||||
n(r[3]),
|
||||
if open { "yes" } else { "no" }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `own`: write each comment's PdfCraft keys too (see [`own_keys`]), for a copy and paste.
|
||||
fn xfdf_annots(doc: &Document, out: &mut String, only: Option<&HashSet<(usize, usize)>>, own: bool) {
|
||||
let pages = pdfcraft_model::pages(doc);
|
||||
// NM of every annotation, for inreplyto. A selected annotation without one (other tools
|
||||
// leave /NM out) gets a name for this export, so a reply copied with it still finds it.
|
||||
let names = annotation_names(doc, &pages, only);
|
||||
for (pi, p) in pages.iter().enumerate() {
|
||||
for a in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default() {
|
||||
for (ai, a) in p.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default().into_iter().enumerate() {
|
||||
if only.is_some_and(|only| !only.contains(&(pi, ai))) {
|
||||
continue;
|
||||
}
|
||||
let Some(d) = doc.resolve(&a).as_dict().cloned() else { continue };
|
||||
let Some(sub) = d.name(b"Subtype") else { continue };
|
||||
let Some((_, tag)) = SUBTYPES.iter().find(|(s, _)| s.as_bytes() == sub) else { continue };
|
||||
@@ -37,9 +203,12 @@ fn xfdf_annots(doc: &Document, out: &mut String) {
|
||||
n(rect[1].max(rect[3]))
|
||||
);
|
||||
}
|
||||
// The name: /NM, or the one given above for this export.
|
||||
if let Some(nm) = text_of(doc, &d, b"NM").or_else(|| a.as_ref().and_then(|r| names.get(&r).cloned())) {
|
||||
let _ = write!(attrs, " name=\"{}\"", esc(&nm));
|
||||
}
|
||||
for (key, attr) in [
|
||||
(&b"NM"[..], "name"),
|
||||
(b"T", "title"),
|
||||
(&b"T"[..], "title"),
|
||||
(b"Subj", "subject"),
|
||||
(b"M", "date"),
|
||||
(b"CreationDate", "creationdate"),
|
||||
@@ -81,7 +250,7 @@ fn xfdf_annots(doc: &Document, out: &mut String) {
|
||||
let name = |o: &Object| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned()).unwrap_or_else(|| "None".into());
|
||||
let _ = write!(attrs, " head=\"{}\" tail=\"{}\"", esc(&name(&le[0])), esc(&name(&le[1])));
|
||||
}
|
||||
// A callout: its leader path, text-box inset, intent and (single-name) line ending.
|
||||
// A callout: its leader path, text-box inset and (single-name) line ending.
|
||||
let cl = nums_of(doc, d.get(b"CL"));
|
||||
if cl.len() == 4 || cl.len() == 6 {
|
||||
let _ = write!(attrs, " callout=\"{}\"", cl.iter().map(|v| n(*v)).collect::<Vec<_>>().join(","));
|
||||
@@ -89,54 +258,31 @@ fn xfdf_annots(doc: &Document, out: &mut String) {
|
||||
if rd.len() == 4 {
|
||||
let _ = write!(attrs, " fringe=\"{}\"", rd.iter().map(|v| n(*v)).collect::<Vec<_>>().join(","));
|
||||
}
|
||||
if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) {
|
||||
let _ = write!(attrs, " intent=\"{}\"", esc(&it));
|
||||
}
|
||||
if let Some(le) = d.get(b"LE").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) {
|
||||
let _ = write!(attrs, " head=\"{}\"", esc(&le));
|
||||
}
|
||||
}
|
||||
// What the comment is meant as (/IT): typewriter text, a callout, a cloud, an arrow.
|
||||
if let Some(it) = d.get(b"IT").map(|o| doc.resolve(o)).and_then(|o| o.as_name().map(|x| String::from_utf8_lossy(x).into_owned())) {
|
||||
let _ = write!(attrs, " intent=\"{}\"", esc(&it));
|
||||
}
|
||||
// A cloudy border (/BE /S /C) and how far its arcs bulge.
|
||||
if let Some(be) =
|
||||
d.get(b"BE").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).filter(|b| b.name(b"S") == Some(b"C".as_slice()))
|
||||
{
|
||||
attrs.push_str(" style=\"cloudy\"");
|
||||
if let Some(i) = be.get(b"I").and_then(Object::as_f64) {
|
||||
let _ = write!(attrs, " intensity=\"{}\"", n(i));
|
||||
}
|
||||
}
|
||||
if let Some(irt) = d.get(b"IRT").and_then(Object::as_ref).and_then(|r| names.get(&r)) {
|
||||
let _ = write!(attrs, " inreplyto=\"{}\"", esc(irt));
|
||||
}
|
||||
if own && let Some(keys) = own_keys(&d) {
|
||||
let _ = write!(attrs, " pdfcraft:keys=\"{}\"", esc(&keys));
|
||||
}
|
||||
let _ = write!(out, "<{tag}{attrs}>");
|
||||
if let Some(c) = text_of(doc, &d, b"Contents") {
|
||||
let _ = write!(out, "<contents>{}</contents>", esc(&c));
|
||||
}
|
||||
if let Some(da) = text_of(doc, &d, b"DA") {
|
||||
let _ = write!(out, "<defaultappearance>{}</defaultappearance>", esc(&da));
|
||||
}
|
||||
let ink = d.get(b"InkList").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default();
|
||||
if !ink.is_empty() {
|
||||
out.push_str("<inklist>");
|
||||
for g in ink {
|
||||
let v = nums_of(doc, Some(&g));
|
||||
let pts: Vec<String> = v.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect();
|
||||
let _ = write!(out, "<gesture>{}</gesture>", pts.join(";"));
|
||||
}
|
||||
out.push_str("</inklist>");
|
||||
}
|
||||
// Polygon and polyline points (XFDF `<vertices>`: `x,y;x,y;…`).
|
||||
let vertices = nums_of(doc, d.get(b"Vertices"));
|
||||
let pts: Vec<String> = vertices.as_chunks::<2>().0.iter().map(|p| format!("{},{}", n(p[0]), n(p[1]))).collect();
|
||||
if !pts.is_empty() {
|
||||
let _ = write!(out, "<vertices>{}</vertices>", pts.join(";"));
|
||||
}
|
||||
if let Some(pd) = d.get(b"Popup").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) {
|
||||
let r = nums_of(doc, pd.get(b"Rect"));
|
||||
if r.len() == 4 {
|
||||
let open = matches!(pd.get(b"Open"), Some(Object::Bool(true)));
|
||||
let _ = write!(
|
||||
out,
|
||||
"<popup page=\"{pi}\" rect=\"{},{},{},{}\" open=\"{}\"/>",
|
||||
n(r[0]),
|
||||
n(r[1]),
|
||||
n(r[2]),
|
||||
n(r[3]),
|
||||
if open { "yes" } else { "no" }
|
||||
);
|
||||
}
|
||||
}
|
||||
xfdf_annot_children(doc, out, &d, pi);
|
||||
let _ = writeln!(out, "</{tag}>");
|
||||
}
|
||||
}
|
||||
@@ -191,12 +337,235 @@ fn xfdf_fields(doc: &Document, out: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// XFDF with these comments alone (0-based page and position in the page's `/Annots`), each
|
||||
/// with its replies: what Copy puts on the clipboard, and Export selected comments writes.
|
||||
pub fn export_xfdf_comments(doc: &Document, which: &[(usize, usize)], file: &str) -> String {
|
||||
let only = annots_with_replies(doc, which);
|
||||
let mut out = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xfdf xmlns=\"http://ns.adobe.com/xfdf/\" xmlns:pdfcraft=\"{PDFCRAFT_NS}\" xml:space=\"preserve\">\n"
|
||||
);
|
||||
out.push_str("<annots>\n");
|
||||
xfdf_annots(doc, &mut out, Some(&only), true);
|
||||
out.push_str("</annots>\n");
|
||||
let _ = writeln!(out, "<f href=\"{}\"/>", esc(file));
|
||||
out.push_str("</xfdf>\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `text` is XFDF with comments in it (what a paste of copied comments carries).
|
||||
pub fn is_xfdf_comments(text: &str) -> bool {
|
||||
let head: String = text.chars().take(400).collect();
|
||||
head.contains("<xfdf") && text.contains("<annots")
|
||||
}
|
||||
|
||||
/// What a comment of an XFDF text looks like, for drawing a bundle of copied comments before
|
||||
/// it is placed: its geometry in user space and its colour (RGB 0–1).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Outline {
|
||||
/// A box: notes, squares, text boxes, stamps, attachments, carets (`round` for a circle).
|
||||
Rect { rect: [f64; 4], round: bool, color: Option<[f64; 3]> },
|
||||
/// A line or arrow, and a callout's leader.
|
||||
Line { points: Vec<[f64; 2]>, color: Option<[f64; 3]> },
|
||||
/// A polygon (closed) or connected lines.
|
||||
Poly { points: Vec<[f64; 2]>, closed: bool, color: Option<[f64; 3]> },
|
||||
/// Freehand strokes.
|
||||
Ink { strokes: Vec<Vec<[f64; 2]>>, color: Option<[f64; 3]> },
|
||||
/// Text markup: each quad is x1,y1 (upper left), x2,y2 (upper right), x3,y3 (lower left), x4,y4.
|
||||
Quads { quads: Vec<[f64; 8]>, color: Option<[f64; 3]> },
|
||||
/// A text box's text, from the top-left of its box (`rect`, which it wraps in), at its font
|
||||
/// `size` in points and in its text colour.
|
||||
Text { rect: [f64; 4], text: String, size: f64, color: Option<[f64; 3]> },
|
||||
}
|
||||
|
||||
/// Every comment element of an XFDF text, in order (none when it is no XFDF).
|
||||
fn with_comment_nodes(text: &str, mut f: impl FnMut(roxmltree::Node<'_, '_>)) {
|
||||
if check_form_xml_depth(text).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(xml) = parse_form_xml(text) else { return };
|
||||
let root = xml.root_element();
|
||||
if root.tag_name().name() != "xfdf" {
|
||||
return;
|
||||
}
|
||||
let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) else { return };
|
||||
for node in annots.children().filter(|c| c.is_element()) {
|
||||
if SUBTYPES.iter().any(|(_, t)| *t == node.tag_name().name().to_ascii_lowercase()) {
|
||||
f(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The comment elements of an XFDF text: tag, `inreplyto`, rect.
|
||||
fn comment_nodes(text: &str) -> Vec<(String, Option<String>, Vec<f64>)> {
|
||||
let mut out = Vec::new();
|
||||
with_comment_nodes(text, |node| {
|
||||
out.push((
|
||||
node.tag_name().name().to_owned(),
|
||||
node.attribute("inreplyto").map(str::to_owned),
|
||||
node.attribute("rect").map(csv).unwrap_or_default(),
|
||||
));
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// Pairs `x,y;x,y;…` (XFDF vertices and ink gestures).
|
||||
fn pairs(text: &str) -> Vec<[f64; 2]> {
|
||||
text.split(';').filter_map(|p| if let [x, y] = csv(p).as_slice() { Some([*x, *y]) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// The outlines of the comments in an XFDF text (replies, which have no place of their own,
|
||||
/// left out): what a paste shows hanging on the pointer.
|
||||
pub fn comment_outlines(text: &str) -> Vec<Outline> {
|
||||
let mut out = Vec::new();
|
||||
with_comment_nodes(text, |node| {
|
||||
if node.attribute("inreplyto").is_some() {
|
||||
return;
|
||||
}
|
||||
let tag = node.tag_name().name().to_ascii_lowercase();
|
||||
let color = node.attribute("color").and_then(parse_hex);
|
||||
let rect = node
|
||||
.attribute("rect")
|
||||
.map(csv)
|
||||
.filter(|r| r.len() == 4 && r.iter().all(|v| v.is_finite()))
|
||||
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]);
|
||||
match tag.as_str() {
|
||||
"line" => {
|
||||
if let (Some(a), Some(b)) = (node.attribute("start").map(csv), node.attribute("end").map(csv))
|
||||
&& let ([ax, ay], [bx, by]) = (a.as_slice(), b.as_slice())
|
||||
{
|
||||
out.push(Outline::Line { points: vec![[*ax, *ay], [*bx, *by]], color });
|
||||
}
|
||||
}
|
||||
"polygon" | "polyline" => {
|
||||
let points = node.children().find(|c| c.has_tag_name("vertices")).and_then(|v| v.text()).map(pairs).unwrap_or_default();
|
||||
if points.len() >= 2 {
|
||||
out.push(Outline::Poly { points, closed: tag == "polygon", color });
|
||||
}
|
||||
}
|
||||
"ink" => {
|
||||
let strokes: Vec<Vec<[f64; 2]>> = node
|
||||
.children()
|
||||
.find(|c| c.has_tag_name("inklist"))
|
||||
.map(|list| {
|
||||
list.children().filter(|g| g.has_tag_name("gesture")).filter_map(|g| g.text()).map(pairs).filter(|s| !s.is_empty()).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !strokes.is_empty() {
|
||||
out.push(Outline::Ink { strokes, color });
|
||||
}
|
||||
}
|
||||
"highlight" | "underline" | "strikeout" | "squiggly" | "redact" => {
|
||||
let coords = node.attribute("coords").map(csv).unwrap_or_default();
|
||||
let quads: Vec<[f64; 8]> = coords.as_chunks::<8>().0.to_vec();
|
||||
if quads.is_empty() {
|
||||
if let Some(rect) = rect {
|
||||
out.push(Outline::Rect { rect, round: false, color });
|
||||
}
|
||||
} else {
|
||||
out.push(Outline::Quads { quads, color });
|
||||
}
|
||||
}
|
||||
// A Fill & Sign mark: its tick, cross, dot or line, as its appearance draws it.
|
||||
"stamp"
|
||||
if let Some(rect) = rect
|
||||
&& let Some(mark) = node.attribute("icon").and_then(|i| pdfcraft_annot::FillMark::from_name(i.as_bytes())) =>
|
||||
{
|
||||
if mark == pdfcraft_annot::FillMark::Dot {
|
||||
let r = (rect[2] - rect[0]).min(rect[3] - rect[1]) * 0.3;
|
||||
let (cx, cy) = ((rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0);
|
||||
out.push(Outline::Rect { rect: [cx - r, cy - r, cx + r, cy + r], round: true, color });
|
||||
}
|
||||
for points in mark.strokes(rect) {
|
||||
out.push(Outline::Line { points, color });
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(rect) = rect {
|
||||
out.push(Outline::Rect { rect, round: tag == "circle", color });
|
||||
}
|
||||
// A callout's leader, from the box to what it points at.
|
||||
let callout = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6);
|
||||
if tag == "freetext"
|
||||
&& let Some(cl) = &callout
|
||||
{
|
||||
out.push(Outline::Line { points: cl.as_chunks::<2>().0.to_vec(), color });
|
||||
}
|
||||
// A text box's text, in its own colour and size, inside the box (a callout's box
|
||||
// is its rect inset by the fringe).
|
||||
if tag == "freetext"
|
||||
&& let Some(rect) = rect
|
||||
&& let Some(text) = node.children().find(|c| c.has_tag_name("contents")).and_then(|c| c.text()).filter(|t| !t.trim().is_empty())
|
||||
{
|
||||
let mut da = Dict::new();
|
||||
if let Some(s) = node.children().find(|c| c.has_tag_name("defaultappearance")).and_then(|c| c.text()) {
|
||||
da.set(b"DA".to_vec(), PdfString::literal(s.as_bytes().to_vec()));
|
||||
}
|
||||
let (text_color, size) = pdfcraft_annot::appearance::parse_da(&da);
|
||||
let fringe = node.attribute("fringe").map(csv).filter(|f| callout.is_some() && f.len() == 4 && f.iter().all(|v| v.is_finite()));
|
||||
let rect = match fringe.as_deref() {
|
||||
Some([l, b, r, t]) => [rect[0] + l, rect[1] + b, rect[2] - r, rect[3] - t],
|
||||
_ => rect,
|
||||
};
|
||||
out.push(Outline::Text { rect, text: text.replace("\r\n", "\n").replace('\r', "\n"), size, color: Some(text_color) });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// How many comments (replies not counted) an XFDF text holds.
|
||||
pub fn comment_count(text: &str) -> usize {
|
||||
comment_nodes(text).iter().filter(|(_, irt, _)| irt.is_none()).count()
|
||||
}
|
||||
|
||||
/// The box around every comment in an XFDF text, in the pages' user space: [x0, y0, x1, y1],
|
||||
/// or `None` without a comment with a rect. What a paste shows hanging on the pointer and centres
|
||||
/// on the point: replies, which have no place of their own (some tools still give them a rect, at
|
||||
/// 0,0 or away from their parent), are left out, as [`comment_outlines`] leaves them out.
|
||||
pub fn comments_bounds(text: &str) -> Option<[f64; 4]> {
|
||||
let mut bounds: Option<[f64; 4]> = None;
|
||||
for (_, in_reply_to, rect) in comment_nodes(text) {
|
||||
if in_reply_to.is_some() {
|
||||
continue;
|
||||
}
|
||||
if let [x0, y0, x1, y1] = rect.as_slice()
|
||||
&& rect.iter().all(|v| v.is_finite())
|
||||
{
|
||||
let r = [x0.min(*x1), y0.min(*y1), x0.max(*x1), y0.max(*y1)];
|
||||
bounds = Some(match bounds {
|
||||
None => r,
|
||||
Some(b) => [b[0].min(r[0]), b[1].min(r[1]), b[2].max(r[2]), b[3].max(r[3])],
|
||||
});
|
||||
}
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
/// Move an annotation's geometry by (dx, dy) in user space: its rect, quads, vertices, ink,
|
||||
/// line ends and callout leader (what a copy placed elsewhere needs).
|
||||
fn translate(d: &mut Dict, dx: f64, dy: f64) {
|
||||
let shift = |o: &Object| -> Option<Object> {
|
||||
let v: Vec<f64> = o.as_array()?.iter().map(|x| x.as_f64()).collect::<Option<Vec<f64>>>()?;
|
||||
Some(arr(&v.iter().enumerate().map(|(i, x)| if i % 2 == 0 { x + dx } else { x + dy }).collect::<Vec<f64>>()))
|
||||
};
|
||||
for key in [&b"Rect"[..], b"QuadPoints", b"Vertices", b"L", b"CL"] {
|
||||
if let Some(moved) = d.get(key).and_then(shift) {
|
||||
d.set(key.to_vec(), moved);
|
||||
}
|
||||
}
|
||||
if let Some(Object::Array(strokes)) = d.get(b"InkList").cloned() {
|
||||
let moved: Vec<Object> = strokes.iter().map(|s| shift(s).unwrap_or_else(|| s.clone())).collect();
|
||||
d.set(b"InkList".to_vec(), Object::Array(moved));
|
||||
}
|
||||
}
|
||||
|
||||
/// XFDF with the document's comments and/or field values.
|
||||
pub fn export_xfdf(doc: &Document, comments: bool, fields: bool, file: &str) -> String {
|
||||
let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<xfdf xmlns=\"http://ns.adobe.com/xfdf/\" xml:space=\"preserve\">\n");
|
||||
if comments {
|
||||
out.push_str("<annots>\n");
|
||||
xfdf_annots(doc, &mut out);
|
||||
xfdf_annots(doc, &mut out, None, false);
|
||||
out.push_str("</annots>\n");
|
||||
}
|
||||
if fields {
|
||||
@@ -311,6 +680,16 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt
|
||||
Object::Array(vec![Object::name(node.attribute("head").unwrap_or("None")), Object::name(node.attribute("tail").unwrap_or("None"))]),
|
||||
);
|
||||
}
|
||||
if let Some(it) = node.attribute("intent").filter(|it| !it.is_empty()) {
|
||||
d.set(b"IT".to_vec(), Object::name(it));
|
||||
}
|
||||
if node.attribute("style") == Some("cloudy") {
|
||||
let mut be = Dict::new();
|
||||
be.set(b"S".to_vec(), Object::name("C"));
|
||||
let intensity = node.attribute("intensity").and_then(|i| i.parse::<f64>().ok()).filter(|i| i.is_finite()).unwrap_or(1.0);
|
||||
be.set(b"I".to_vec(), Object::Real(intensity.clamp(0.0, 2.0)));
|
||||
d.set(b"BE".to_vec(), Object::Dict(be));
|
||||
}
|
||||
if let Some(cl) = node.attribute("callout").map(csv).filter(|c| c.len() == 4 || c.len() == 6) {
|
||||
d.set(b"CL".to_vec(), arr(&cl));
|
||||
if let Some(rd) = node.attribute("fringe").map(csv).filter(|r| r.len() == 4) {
|
||||
@@ -352,6 +731,7 @@ fn annot_from_xml(node: roxmltree::Node, subtype: &str, page_ref: ObjRef) -> Opt
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
read_own_keys(node, &mut d);
|
||||
Some(d)
|
||||
}
|
||||
|
||||
@@ -472,7 +852,32 @@ pub(crate) fn add_popup(doc: &mut Document, page: ObjRef, parent: ObjRef, rect:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import XFDF comments as copies: every comment lands on `page` (0-based; its own page when
|
||||
/// `None`) and, with `fresh` (a prefix unique to this import), under a new name, so nothing in
|
||||
/// the document is replaced and pasting the same comments twice gives two copies. Replies keep
|
||||
/// their thread among the pasted comments; a reply to a comment that was not copied stands
|
||||
/// alone. Field values in the file are ignored.
|
||||
pub fn import_comments(
|
||||
doc: &mut Document,
|
||||
text: &str,
|
||||
page: Option<usize>,
|
||||
fresh: Option<&str>,
|
||||
offset: Option<[f64; 2]>,
|
||||
) -> Result<Report, DataError> {
|
||||
import_xfdf_with(doc, text, page, fresh, offset.filter(|o| o.iter().all(|v| v.is_finite())))
|
||||
}
|
||||
|
||||
pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, DataError> {
|
||||
import_xfdf_with(doc, text, None, None, None)
|
||||
}
|
||||
|
||||
fn import_xfdf_with(
|
||||
doc: &mut Document,
|
||||
text: &str,
|
||||
target_page: Option<usize>,
|
||||
fresh: Option<&str>,
|
||||
offset: Option<[f64; 2]>,
|
||||
) -> Result<Report, DataError> {
|
||||
check_form_xml_depth(text)?;
|
||||
let xml = parse_form_xml(text).map_err(|e| DataError::Malformed(e.to_string()))?;
|
||||
let root = xml.root_element();
|
||||
@@ -481,28 +886,65 @@ pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, Data
|
||||
}
|
||||
let mut report = Report::default();
|
||||
let pages: Vec<ObjRef> = pdfcraft_model::pages(doc).iter().map(|p| p.obj).collect();
|
||||
if let Some(p) = target_page
|
||||
&& p >= pages.len()
|
||||
{
|
||||
return Err(DataError::Malformed(format!("page {} is not in the document", p + 1)));
|
||||
}
|
||||
// Comments: parents first, then replies (whose /IRT needs the parent's reference).
|
||||
let mut by_name: HashMap<String, ObjRef> = HashMap::new();
|
||||
let mut later: HashMap<ObjRef, String> = HashMap::new();
|
||||
// Fresh names: every copy gets one from its own position, and `name` → the first copy's,
|
||||
// so replies find their copied parent (a name two source comments share, as other tools
|
||||
// write, still gives two copies).
|
||||
let mut renamed: HashMap<String, String> = HashMap::new();
|
||||
if let (Some(prefix), Some(annots)) = (fresh, root.children().find(|c| c.has_tag_name("annots"))) {
|
||||
for (n, node) in annots.children().filter(|c| c.is_element()).enumerate() {
|
||||
if let Some(nm) = node.attribute("name") {
|
||||
renamed.entry(nm.to_string()).or_insert_with(|| format!("{prefix}-{}", n + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(annots) = root.children().find(|c| c.has_tag_name("annots")) {
|
||||
for node in annots.children().filter(|c| c.is_element()) {
|
||||
for (n, node) in annots.children().filter(|c| c.is_element()).enumerate() {
|
||||
let tag = node.tag_name().name().to_ascii_lowercase();
|
||||
let Some((subtype, _)) = SUBTYPES.iter().find(|(_, t)| *t == tag) else { continue };
|
||||
let Some(page) = node.attribute("page").and_then(|p| p.parse::<usize>().ok()).and_then(|p| pages.get(p).copied()) else { continue };
|
||||
let Some(d) = annot_from_xml(node, subtype, page) else { continue };
|
||||
let own_page = node.attribute("page").and_then(|p| p.parse::<usize>().ok());
|
||||
let Some(page) = target_page.or(own_page).and_then(|p| pages.get(p).copied()) else { continue };
|
||||
let Some(mut d) = annot_from_xml(node, subtype, page) else { continue };
|
||||
let assigned = fresh.map(|prefix| format!("{prefix}-{}", n + 1));
|
||||
if let Some(name) = &assigned {
|
||||
// A copy under a name of its own (one even where the original had none).
|
||||
d.set(b"NM".to_vec(), PdfString::text(name));
|
||||
}
|
||||
if let Some([dx, dy]) = offset {
|
||||
translate(&mut d, dx, dy);
|
||||
}
|
||||
let r = place(doc, page, d)?;
|
||||
// A later same-name record replaces this comment, including its reply relation.
|
||||
later.remove(&r);
|
||||
if let Some(nm) = node.attribute("name") {
|
||||
by_name.insert(nm.to_string(), r);
|
||||
if let Some(name) = assigned.clone().or_else(|| node.attribute("name").map(str::to_owned)) {
|
||||
by_name.insert(name, r);
|
||||
}
|
||||
if let Some(irt) = node.attribute("inreplyto") {
|
||||
later.insert(r, irt.to_string());
|
||||
match (fresh, renamed.get(irt)) {
|
||||
// The parent is among the copies: the reply follows it.
|
||||
(Some(_), Some(parent)) => later.insert(r, parent.clone()),
|
||||
// A copy's parent was not copied: the reply stands alone rather than
|
||||
// attaching to a same-named comment the document happens to have.
|
||||
(Some(_), None) => None,
|
||||
(None, _) => later.insert(r, irt.to_string()),
|
||||
};
|
||||
}
|
||||
// A pop-up window.
|
||||
if let Some(pop) = node.children().find(|c| c.has_tag_name("popup"))
|
||||
&& let Some(rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4)
|
||||
&& let Some(mut rect) = pop.attribute("rect").map(csv).filter(|r| r.len() == 4)
|
||||
{
|
||||
if let Some([dx, dy]) = offset {
|
||||
for (i, v) in rect.iter_mut().enumerate() {
|
||||
*v += if i % 2 == 0 { dx } else { dy };
|
||||
}
|
||||
}
|
||||
add_popup(doc, page, r, &rect, pop.attribute("open") == Some("yes"))?;
|
||||
}
|
||||
report.comments += 1;
|
||||
@@ -513,6 +955,10 @@ pub(crate) fn import_xfdf(doc: &mut Document, text: &str) -> Result<Report, Data
|
||||
doc.update_dict(r, |d| d.set(b"IRT".to_vec(), Object::Ref(parent)))?;
|
||||
}
|
||||
}
|
||||
// Comments placed as copies: the file's field values are not part of the paste.
|
||||
if fresh.is_some() || target_page.is_some() || offset.is_some() {
|
||||
return Ok(report);
|
||||
}
|
||||
// Fields.
|
||||
if let Some(fields) = root.children().find(|c| c.has_tag_name("fields")) {
|
||||
let mut values = Vec::new();
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it; a comment's text, border or line, and fill each at an opacity of its own) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the
|
||||
2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is
|
||||
@@ -76,7 +76,8 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
|
||||
| Print dialog with preview | yes | yes | shipped |
|
||||
| Document Properties (description, security, fonts, initial view, custom, advanced) | yes | all but Custom and XMP | partial |
|
||||
| Field Properties (General, Appearance, Position, Options, Actions, Format, Validate, Calculate, Signed) | yes | all but Signed | partial |
|
||||
| Comment properties and properties bar | yes | yes | shipped |
|
||||
| Comment properties and properties bar | yes | yes; a text box's text, border and fill and a shape's line and fill each take an opacity of their own (Acrobat: one per comment) | shipped |
|
||||
| Select several comments; copy and paste comments between documents | yes | yes: ⌘/Ctrl-click adds, Delete, a drag, Properties and Colour act on all, ⌘C/⌘V as XFDF (also across PdfCraft processes) | shipped |
|
||||
| Preferences | ≈ 20 pages | Interface language, Documents and view, Identity, Fill & Sign, JavaScript | partial (gap 19) |
|
||||
|
||||
## Revision history
|
||||
@@ -84,6 +85,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once |
|
||||
| 2026-10-24 | minor | Comment Properties sets a text box's text, border and fill opacity and a shape's line and fill opacity each on its own (Acrobat: one per comment); Make Properties Default carries them into the tool |
|
||||
| 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. |
|
||||
| 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) |
|
||||
| 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) |
|
||||
|
||||
@@ -3878,8 +3878,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.polyline"]
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"]
|
||||
notes = ""
|
||||
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/ui-egui/tests/it/comments.rs::connected_lines_are_completed_from_the_context_menu", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"]
|
||||
notes = "Click the corners; Enter, a double-click or the right-click menu's Complete finishes the lines (the menu also takes the last point back and cancels the drawing), Backspace takes a point back."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.cloud"
|
||||
@@ -3965,6 +3965,18 @@ tools = ["comment_edit"]
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/it/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/it/comments.rs::text_box_properties_change_the_font_size", "crates/ui-egui/tests/it/comments.rs::text_box_and_callout_properties_include_fill_and_thickness", "crates/annot/src/tests.rs::text_boxes_and_callouts_can_change_fill_and_border", "crates/automation/tests/it/automation.rs::text_box_and_callout_fill_and_border_through_tools", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/it/automation.rs::text_box_font_size_through_the_tool"]
|
||||
notes = "Appearance (colour, fill colour for rectangles/ovals/polygons/text boxes/callouts, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.part-opacity"
|
||||
title = "A comment's text, border or line, and fill each at an opacity of its own"
|
||||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["comment_add", "comment_edit", "comment_list"]
|
||||
evidence = ["crates/annot/src/tests.rs::parts_are_drawn_at_opacities_of_their_own", "crates/ui-egui/tests/it/comments.rs::comment_properties_set_each_parts_opacity", "crates/automation/tests/it/automation.rs::comment_parts_take_opacities_of_their_own_through_the_tools", "crates/ui-egui/tests/it/ui.rs::part_opacity_defaults_persist_and_clamp"]
|
||||
notes = "Comment Properties shows an opacity for each part instead of one for the whole comment: a text box, typewriter text or callout its Text, Border and Fill opacity, a rectangle, oval, polygon or cloud its Line and Fill opacity (a part that isn't drawn, such as an unset fill, is greyed); lines, drawings and the rest keep one Opacity. A see-through fill with an opaque line marks an area on a plan without hiding it. Stored as the line's /CA, the fill's /ca (PDF 2.0's opacity for what is filled) and the text's /PCTextOpacity (PdfCraft's own), each only where it differs, and drawn with two graphics states in the appearance, so every viewer shows it; an app that redraws the comment from its keys (Acrobat after editing it) uses one opacity again. The quick bar's Opacity and comment_edit `opacity` set the whole comment's; Make Properties Default carries the parts into the tool (saved with the settings). Headless: comment_add / comment_edit `fill_opacity`, `text_opacity`; comment_list reports `opacity`, `fill_opacity`, `text_opacity`. Acrobat has one opacity per comment."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.properties-bar"
|
||||
title = "Comment properties bar"
|
||||
@@ -4331,11 +4343,35 @@ title = "Export selected comments"
|
||||
area = "E"
|
||||
tier = "P2"
|
||||
milestone = "M5"
|
||||
status = "planned"
|
||||
status = "partial"
|
||||
commands = ["comment.copy"]
|
||||
tools = ["comments_copy"]
|
||||
evidence = ["crates/xfdf/src/tests.rs::exporting_selected_comments_takes_them_and_their_replies", "crates/automation/tests/it/automation.rs::comments_copy_and_paste_between_documents_through_tools", "crates/xfdf/src/tests.rs::copies_keep_their_intent_cloud_and_pdfcraft_keys"]
|
||||
notes = "The selected comments, each with its replies, as XFDF: Copy puts it on the clipboard (desktop) and comments_copy returns it headlessly. Each keeps what it is meant as (`intent`: typewriter text, a cloud, an arrow), a cloudy border (`style`, `intensity`; both also in File ▸ Export comments) and PdfCraft's own keys (`pdfcraft:keys`, in a namespace of its own: a Fill & Sign item, a turned comment's box and angle), so a paste is the same comment. Not yet: a Save as… to a data file from the interface."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.multi-select"
|
||||
title = "Select several comments (⌘-click, ⇧-click)"
|
||||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::ctrl_click_selects_several_comments_and_delete_removes_them_all", "crates/ui-egui/tests/it/comments.rs::properties_change_every_selected_comment_where_it_applies", "crates/ui-egui/tests/it/comments.rs::dragging_any_selected_comment_moves_the_whole_selection", "crates/ui-egui/src/tools/comments/tests.rs::shift_click_selects_the_range_from_the_comment_with_the_handles"]
|
||||
notes = "On the page, ⌘-click (Ctrl on Windows and Linux) or ⇧-click adds a comment to the selection or takes it out again; in the Comments panel ⌘/Ctrl-click does, and ⇧-click selects the cards from the one with the handles to the one clicked. The one clicked last has the handles and is what Reply acts on. Delete (key or menu) removes every selected comment as one undo step, dragging one of them moves them all (text markup stays with its text, a locked one where it is), and the context menu copies them all. Properties (opened on one of them), the context menu's Colour and the quick bar's colour change every selected comment as one undo step, each where the property applies (a font size to text, a fill to shapes, a width to lines and shapes); one that stays locked keeps its look. Escape and a plain click select one alone again. Not yet: a rubber-band (marquee) selection on the page."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.copy-paste"
|
||||
title = "Copy and paste comments, within and between documents"
|
||||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["comment.copy", "comment.paste"]
|
||||
tools = ["comments_copy", "comments_paste"]
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::copied_comments_paste_into_another_document", "crates/ui-egui/tests/it/comments.rs::copied_comments_take_the_click_from_the_tool_that_is_on", "crates/engine/src/tests.rs::pasting_copied_comments_adds_copies_that_undo_as_one_step", "crates/xfdf/src/tests.rs::importing_comments_as_copies_adds_them_on_the_page_with_fresh_names", "crates/xfdf/src/tests.rs::copied_marks_and_text_boxes_show_what_they_are_while_placed_and_once_pasted", "crates/automation/tests/it/automation.rs::comments_copy_and_paste_between_documents_through_tools"]
|
||||
notes = "⌘C with comments selected (and no text selected) copies them as XFDF, each with its replies, to the app's clipboard (every tab and window) and as text to the system clipboard. ⌘V or Edit ▸ Paste comments hangs the copies on the pointer as one bundle (their box, with a count, and each comment's outline in its colour: Fill & Sign ticks, crosses, dots and lines as themselves, text boxes with their text); a click places them, centred there, on the page clicked (whatever tool is on: Fill & Sign's Text tool opens no box, a stamp tool places no stamp), so a copy never lands on its original and a bundle from an A0 page goes where it fits on an A4 one (placed as it is when larger than the page, with a notice); Escape or a right-click lets the bundle go. The page's context menu places the bundle at the point the menu was opened at. The copies are new comments under names of their own (nothing is replaced; pasting twice gives two sets) and are selected afterwards. XFDF with comments on the system clipboard pastes too, so comments travel between PdfCraft processes. The Comments panel's card menu copies that card's comment. Headless: comments_copy / comments_paste (`at` places the bundle's centre), undoable as one step. Not yet: appearance streams (the copies are drawn again), file attachments' data (the attachment comment is copied without its file), pasting in the web build's other tabs through the system clipboard."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.import-from-pdf"
|
||||
|
||||
Reference in new issue
Block a user