mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-15 03:37:03 -04:00
M5: draw all ten PDF line-ending styles (#520)
* M5: draw all ten PDF line-ending styles Imported lines, polylines and callouts kept Square, Circle, Diamond, Butt, Slash and reverse arrows in /LE but would not redraw them. Those styles now draw, and comment_add and Comment properties can set them. * hu, uk: Line ending --------- Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
1 parent
c9d0619226
commit
3af93c7585
25 files changed
+613
-87
No files matched your search
+7
-7
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "997f3479a8930bacfc330ee2c7bf882720ac4a382630b87ae808c86702586926"
|
||||
sha256 = "163a07f4dcf89deebb8bc44fa05f59a5d85a35ddfe82665dd650522fa9f1fe20"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "78f794a4fdcf870b15b5692f1ff5567d4afd439bb2647c637a7ec4bdb9bc2ddb"
|
||||
sha256 = "a5b888d86c9866ec8825a91e845cfa881c16e774be16d9be636e7dfb8dfe3b67"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "04f4cbb7d72d42059135bb2e49fcfcaa5812eae773047769c6024e289a0d149d"
|
||||
sha256 = "bdf30a0f69c3aafb6e660f2666534b16d05b053812ae344a4f9a281fe294851e"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "36d25dacd015fabf5e351407edbed3920bfdfb429d6a1482744c80e504c65c0d"
|
||||
sha256 = "09e063f1c9bb0a5c1485f5e8b4633df3b7f77b00f8f8cbfa4f8c169764e329c2"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "cec5873ded1b535b2f9413408db8cc61e5a99a4ac9a62d13caaaca5c22b086c9"
|
||||
sha256 = "1c9db94bc77c6d7dd046adae0a81bebe4cafb372a789711c37911f46756f8c9d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "1b164ae65c65e2902aebc1a12643cd51b2a67dd0ae2502726b8588fb3c6b54ff"
|
||||
sha256 = "b09f7de47fed031109de88a028b82ca86d9228d872f7f54e7e113d9df3d00ac2"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "0d4247fb4538391b8f65238c0a422e0bd751765c3e9e3c1e85428fb967f1296c"
|
||||
sha256 = "fde7607a8e14c8d39d2807a21f94b78da56207fcafab7271e41fc10e69f848e7"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
|
||||
Generated
+1
@@ -3487,6 +3487,7 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-organize",
|
||||
"pdfcraft-platform",
|
||||
|
||||
@@ -127,6 +127,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
|
||||
## Log
|
||||
|
||||
- **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
|
||||
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
|
||||
|
||||
+103
-20
@@ -26,23 +26,77 @@ pub fn cloud_radius(w: f64) -> f64 {
|
||||
6.0 + 1.5 * w.max(0.0)
|
||||
}
|
||||
|
||||
/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`. Open and closed arrows only.
|
||||
fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) {
|
||||
/// One of the ten `/LE` names (ISO 32000-2 Table 217). Unknown names are not drawn.
|
||||
pub(crate) fn known_ending(kind: &[u8]) -> bool {
|
||||
std::str::from_utf8(kind).ok().and_then(crate::LineEnding::parse).is_some()
|
||||
}
|
||||
|
||||
/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`.
|
||||
///
|
||||
/// Returns false for an unknown style or a non-finite direction, so the caller keeps the
|
||||
/// previous appearance instead of writing `NaN`. `None` and a zero-length direction draw nothing.
|
||||
fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) -> bool {
|
||||
if kind == b"None" {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if !known_ending(kind) {
|
||||
return false;
|
||||
}
|
||||
let (dx, dy) = (from.0 - tip.0, from.1 - tip.1);
|
||||
if !dx.is_finite() || !dy.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let len = dx.hypot(dy);
|
||||
if !len.is_finite() {
|
||||
return false;
|
||||
}
|
||||
if len == 0.0 {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
let (ux, uy) = (dx / len, dy / len);
|
||||
let s = arrow_size(w);
|
||||
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos));
|
||||
let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos));
|
||||
let op = if kind == b"ClosedArrow" { "h B" } else { "S" };
|
||||
c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1)));
|
||||
if !s.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let (ix, iy) = (dx / len, dy / len);
|
||||
match kind {
|
||||
b"OpenArrow" | b"ClosedArrow" | b"ROpenArrow" | b"RClosedArrow" => {
|
||||
// Open and closed arrows keep their original geometry. A reverse arrow uses the same
|
||||
// head, pointing back along the line.
|
||||
let (ux, uy) = if kind.starts_with(b"R") { (-ix, -iy) } else { (ix, iy) };
|
||||
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos));
|
||||
let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos));
|
||||
let op = if kind.ends_with(b"ClosedArrow") { "h B" } else { "S" };
|
||||
c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1)));
|
||||
}
|
||||
b"Square" | b"Circle" | b"Diamond" => {
|
||||
let h = s / 2.0;
|
||||
if kind == b"Square" {
|
||||
c.push_str(&format!("{} {} {} {} re h B\n", n(tip.0 - h), n(tip.1 - h), n(s), n(s)));
|
||||
} else if kind == b"Circle" {
|
||||
c.push_str(&ellipse(tip.0 - h, tip.1 - h, tip.0 + h, tip.1 + h));
|
||||
c.push_str("B\n");
|
||||
} else {
|
||||
let (px, py) = (-iy, ix);
|
||||
let v = |along: f64, across: f64| (tip.0 + ix * along + px * across, tip.1 + iy * along + py * across);
|
||||
let (a, b, d, e) = (v(h, 0.0), v(0.0, h), v(-h, 0.0), v(0.0, -h));
|
||||
c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h B\n", n(a.0), n(a.1), n(b.0), n(b.1), n(d.0), n(d.1), n(e.0), n(e.1)));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Butt is perpendicular to the line. Slash is that stroke turned 45 degrees.
|
||||
let (px, py) = (-iy, ix);
|
||||
let (sx, sy) = if kind == b"Slash" {
|
||||
let q = std::f64::consts::FRAC_1_SQRT_2;
|
||||
(px * q - py * q, px * q + py * q)
|
||||
} else {
|
||||
(px, py)
|
||||
};
|
||||
let h = s / 2.0;
|
||||
c.push_str(&format!("{} {} m {} {} l S\n", n(tip.0 - sx * h), n(tip.1 - sy * h), n(tip.0 + sx * h), n(tip.1 + sy * h)));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The cubic Bézier segments (first control point, second control point, end) of a smooth curve
|
||||
@@ -340,32 +394,45 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
});
|
||||
}
|
||||
b"Line" => {
|
||||
let l = nums(d, b"L").filter(|l| l.len() == 4)?;
|
||||
let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?;
|
||||
let col = stroke?;
|
||||
let ends: Vec<Vec<u8>> = match d.get(b"LE") {
|
||||
None => vec![b"None".to_vec(), b"None".to_vec()],
|
||||
Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::<Option<_>>()?,
|
||||
};
|
||||
if ends.len() != 2 || ends.iter().any(|e| !matches!(e.as_slice(), b"None" | b"OpenArrow" | b"ClosedArrow")) {
|
||||
if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) {
|
||||
return None;
|
||||
}
|
||||
let fill = color(d, b"IC")?.unwrap_or(col);
|
||||
c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d)));
|
||||
c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3])));
|
||||
for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) {
|
||||
line_end(&mut c, end, tip, from, w);
|
||||
if !line_end(&mut c, end, tip, from, w) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Polygon" | b"PolyLine" => {
|
||||
let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0)?;
|
||||
let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?;
|
||||
let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
|
||||
let col = stroke;
|
||||
let closed = subtype == b"Polygon";
|
||||
let fill = if closed { color(d, b"IC")? } else { None };
|
||||
if subtype == b"PolyLine" && d.get(b"LE").is_some_and(|e| e.as_array().is_none_or(|a| a.iter().any(|x| x.as_name() != Some(b"None")))) {
|
||||
// Line endings on connected lines aren't drawn yet.
|
||||
return None;
|
||||
}
|
||||
let ends = if subtype == b"PolyLine" {
|
||||
match d.get(b"LE") {
|
||||
None => None,
|
||||
Some(o) => {
|
||||
let names: Vec<Vec<u8>> =
|
||||
o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::<Option<_>>()?;
|
||||
if names.iter().any(|e| !known_ending(e)) {
|
||||
return None;
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(f) = fill {
|
||||
c.push_str(&rg(f));
|
||||
}
|
||||
@@ -389,6 +456,17 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
(false, true) => "S\n",
|
||||
(false, false) => "n\n",
|
||||
});
|
||||
if let Some(ends) = ends {
|
||||
let col = col?;
|
||||
let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None };
|
||||
let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None };
|
||||
let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None };
|
||||
let fill = color(d, b"IC")?.unwrap_or(col);
|
||||
c.push_str(&format!("[] 0 d\n{}", rg(fill)));
|
||||
if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Caret" => {
|
||||
// A filled caret: two curved flanks meeting at the top centre.
|
||||
@@ -553,17 +631,22 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
None => b"None".to_vec(),
|
||||
Some(o) => o.as_name()?.to_vec(),
|
||||
};
|
||||
if !matches!(end.as_slice(), b"None" | b"OpenArrow" | b"ClosedArrow") {
|
||||
if !known_ending(&end) {
|
||||
return None;
|
||||
}
|
||||
let lw = bw.max(0.5);
|
||||
let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
|
||||
if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw)));
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
|
||||
}
|
||||
c.push_str("S\n");
|
||||
line_end(&mut c, &end, pts[0], pts[1], lw);
|
||||
if !line_end(&mut c, &end, pts[0], pts[1], lw) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if let Some(bg) = bg {
|
||||
c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1])));
|
||||
|
||||
+150
-16
@@ -274,6 +274,55 @@ impl StampKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LineEnding {
|
||||
None,
|
||||
Square,
|
||||
Circle,
|
||||
Diamond,
|
||||
OpenArrow,
|
||||
ClosedArrow,
|
||||
Butt,
|
||||
ROpenArrow,
|
||||
RClosedArrow,
|
||||
Slash,
|
||||
}
|
||||
|
||||
impl LineEnding {
|
||||
pub const ALL: [Self; 10] = [
|
||||
Self::None,
|
||||
Self::Square,
|
||||
Self::Circle,
|
||||
Self::Diamond,
|
||||
Self::OpenArrow,
|
||||
Self::ClosedArrow,
|
||||
Self::Butt,
|
||||
Self::ROpenArrow,
|
||||
Self::RClosedArrow,
|
||||
Self::Slash,
|
||||
];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::None => "None",
|
||||
Self::Square => "Square",
|
||||
Self::Circle => "Circle",
|
||||
Self::Diamond => "Diamond",
|
||||
Self::OpenArrow => "OpenArrow",
|
||||
Self::ClosedArrow => "ClosedArrow",
|
||||
Self::Butt => "Butt",
|
||||
Self::ROpenArrow => "ROpenArrow",
|
||||
Self::RClosedArrow => "RClosedArrow",
|
||||
Self::Slash => "Slash",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|ending| ending.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometry of a new comment, in PDF user space of its page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Shape {
|
||||
@@ -294,11 +343,12 @@ pub enum Shape {
|
||||
Oval {
|
||||
rect: [f64; 4],
|
||||
},
|
||||
/// A line, with an open arrowhead at `to` when `arrow` is set.
|
||||
/// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool).
|
||||
Line {
|
||||
from: [f64; 2],
|
||||
to: [f64; 2],
|
||||
arrow: bool,
|
||||
start: LineEnding,
|
||||
end: LineEnding,
|
||||
},
|
||||
/// Freehand strokes (Draw tool).
|
||||
Ink {
|
||||
@@ -357,9 +407,11 @@ pub enum Shape {
|
||||
vertices: Vec<[f64; 2]>,
|
||||
cloud: bool,
|
||||
},
|
||||
/// Connected lines (Polygonal Line tool).
|
||||
/// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last.
|
||||
PolyLine {
|
||||
vertices: Vec<[f64; 2]>,
|
||||
start: LineEnding,
|
||||
end: LineEnding,
|
||||
},
|
||||
/// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via
|
||||
/// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`).
|
||||
@@ -368,6 +420,7 @@ pub enum Shape {
|
||||
knee: [f64; 2],
|
||||
point: [f64; 2],
|
||||
font_size: f64,
|
||||
ending: LineEnding,
|
||||
},
|
||||
/// Insert text: a caret in `rect` (its point at the top centre).
|
||||
Caret {
|
||||
@@ -726,6 +779,14 @@ fn grow(r: [f64; 4], by: f64) -> [f64; 4] {
|
||||
[r[0] - by, r[1] - by, r[2] + by, r[3] + by]
|
||||
}
|
||||
|
||||
fn ending_draws(ending: LineEnding) -> bool {
|
||||
ending != LineEnding::None
|
||||
}
|
||||
|
||||
fn ending_pair(start: LineEnding, end: LineEnding) -> Object {
|
||||
Object::Array(vec![Object::name(start.name()), Object::name(end.name())])
|
||||
}
|
||||
|
||||
/// Validate and compute `/Rect` for a new comment.
|
||||
fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
|
||||
let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}"));
|
||||
@@ -765,11 +826,11 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
|
||||
}
|
||||
r
|
||||
}
|
||||
Shape::Line { from, to, arrow } => {
|
||||
Shape::Line { from, to, start, end } => {
|
||||
if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 {
|
||||
return Err(bad("line (too short)"));
|
||||
}
|
||||
let pad = half + if *arrow { appearance::arrow_size(style.width) } else { 0.0 };
|
||||
let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
|
||||
grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0)
|
||||
}
|
||||
Shape::Ink { strokes } | Shape::Signature { strokes } => {
|
||||
@@ -790,10 +851,11 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
|
||||
let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?;
|
||||
grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 })
|
||||
}
|
||||
Shape::PolyLine { vertices } => {
|
||||
Shape::PolyLine { vertices, start, end } => {
|
||||
let b = bounds(vertices.iter().copied())
|
||||
.filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0);
|
||||
grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0)
|
||||
let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
|
||||
grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra)
|
||||
}
|
||||
Shape::Callout { rect, knee, point, .. } => {
|
||||
let r = normalize(*rect);
|
||||
@@ -846,7 +908,7 @@ fn subject(shape: &Shape) -> &'static str {
|
||||
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly",
|
||||
Shape::Rectangle { .. } => "Rectangle",
|
||||
Shape::Oval { .. } => "Oval",
|
||||
Shape::Line { arrow: true, .. } => "Arrow",
|
||||
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow",
|
||||
Shape::Line { .. } => "Line",
|
||||
Shape::Ink { .. } => "Pencil",
|
||||
Shape::TextBox { .. } => "Text Box",
|
||||
@@ -943,11 +1005,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::Line { from, to, arrow } => {
|
||||
Shape::Line { from, to, start, end } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]]));
|
||||
if *arrow {
|
||||
d.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name("OpenArrow")]));
|
||||
if ending_draws(*start) || ending_draws(*end) {
|
||||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
@@ -1008,9 +1070,12 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::PolyLine { vertices } => {
|
||||
Shape::PolyLine { vertices, start, end } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Vertices".to_vec(), num_array(&vertices.concat()));
|
||||
if ending_draws(*start) || ending_draws(*end) {
|
||||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::Caret { .. } => {
|
||||
@@ -1047,12 +1112,12 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
d.set(b"Contents".to_vec(), PdfString::text(file.trim()));
|
||||
}
|
||||
}
|
||||
Shape::Callout { rect: tb, knee, point, .. } => {
|
||||
Shape::Callout { rect: tb, knee, point, ending, .. } => {
|
||||
let tb = normalize(*tb);
|
||||
let attach = callout_attach(tb, *knee);
|
||||
d.set(b"IT".to_vec(), Object::name("FreeTextCallout"));
|
||||
d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]]));
|
||||
d.set(b"LE".to_vec(), Object::name("OpenArrow"));
|
||||
d.set(b"LE".to_vec(), Object::name(ending.name()));
|
||||
// The text box inside /Rect (§12.5.6.6 /RD).
|
||||
d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]]));
|
||||
}
|
||||
@@ -1526,7 +1591,8 @@ fn border_width_of(d: &Dict) -> f64 {
|
||||
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0)
|
||||
}
|
||||
|
||||
/// Change a comment's colour, opacity and/or line width, and redraw it.
|
||||
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_style(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
@@ -1534,6 +1600,7 @@ pub fn set_style(
|
||||
color: Option<Rgb>,
|
||||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
endings: Option<&[LineEnding]>,
|
||||
meta: &Meta,
|
||||
) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
@@ -1542,11 +1609,18 @@ pub fn set_style(
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
// Check before changing anything: a stale appearance would contradict the new style.
|
||||
if appearance::build(&d).is_none() {
|
||||
return Err(AnnotError::Unsupported(subtype));
|
||||
return Err(AnnotError::Unsupported(subtype.clone()));
|
||||
}
|
||||
if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid style".into()));
|
||||
}
|
||||
if let Some(ends) = endings {
|
||||
let callout = subtype == "FreeText" && d.contains(b"CL");
|
||||
let pair = matches!(subtype.as_str(), "Line" | "PolyLine");
|
||||
if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) {
|
||||
return Err(AnnotError::Invalid("this comment has no line endings to change".into()));
|
||||
}
|
||||
}
|
||||
let free_text = subtype == "FreeText";
|
||||
doc.update_dict(r, |d| {
|
||||
if let Some(c) = color {
|
||||
@@ -1573,11 +1647,55 @@ pub fn set_style(
|
||||
d.set(b"BS".to_vec(), Object::Dict(bs));
|
||||
d.remove(b"Border");
|
||||
}
|
||||
if let Some(ends) = endings {
|
||||
if ends.len() == 1 {
|
||||
if let Some(ending) = ends.first() {
|
||||
d.set(b"LE".to_vec(), Object::name(ending.name()));
|
||||
}
|
||||
} else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) {
|
||||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||||
}
|
||||
}
|
||||
ensure_ending_room(d);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
set_appearance(doc, r)
|
||||
}
|
||||
|
||||
/// 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();
|
||||
if !matches!(subtype, b"Line" | b"PolyLine") {
|
||||
return;
|
||||
}
|
||||
if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) {
|
||||
return;
|
||||
}
|
||||
let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" };
|
||||
let Some(pts) = pair_points(d, key) else { return };
|
||||
let Some(bounds) = bounds(pts.iter().copied()) else { return };
|
||||
let w = border_width_of(d);
|
||||
let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0);
|
||||
let vals: Vec<f64> = d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default();
|
||||
let Some(rect) = four(&vals) else { return };
|
||||
let union = [rect[0].min(need[0]), rect[1].min(need[1]), rect[2].max(need[2]), rect[3].max(need[3])];
|
||||
if union != rect {
|
||||
d.set(b"Rect".to_vec(), num_array(&union));
|
||||
}
|
||||
}
|
||||
|
||||
fn four(v: &[f64]) -> Option<[f64; 4]> {
|
||||
Some([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?])
|
||||
}
|
||||
|
||||
fn pair_points(d: &Dict, key: &[u8]) -> Option<Vec<[f64; 2]>> {
|
||||
let v: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect();
|
||||
if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(v.as_chunks::<2>().0.to_vec())
|
||||
}
|
||||
|
||||
// ── reading ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One comment as the viewer lists it (the same fields and rules as
|
||||
@@ -1723,6 +1841,8 @@ pub struct Props {
|
||||
pub restylable: bool,
|
||||
/// The Locked flag.
|
||||
pub locked: bool,
|
||||
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
|
||||
pub endings: Option<Vec<LineEnding>>,
|
||||
}
|
||||
|
||||
/// The current properties of the comment at `(page, index)`.
|
||||
@@ -1747,9 +1867,23 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
restylable: appearance::build(d).is_some(),
|
||||
locked: d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & FLAG_LOCKED != 0,
|
||||
subtype,
|
||||
endings: line_endings_of(d),
|
||||
})
|
||||
}
|
||||
|
||||
/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden.
|
||||
fn line_endings_of(d: &Dict) -> Option<Vec<LineEnding>> {
|
||||
let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse);
|
||||
match d.name(b"Subtype")? {
|
||||
b"Line" | b"PolyLine" => match d.get(b"LE") {
|
||||
None => Some(vec![LineEnding::None, LineEnding::None]),
|
||||
Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(),
|
||||
},
|
||||
b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the
|
||||
/// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they
|
||||
/// move, list and delete as one. Returns the strikeout's index.
|
||||
|
||||
+130
-13
@@ -84,7 +84,7 @@ fn every_tool_creates_a_drawable_comment() {
|
||||
Shape::TextMarkup { kind: Markup::Squiggly, quads: vec![[72.0, 412.0, 200.0, 412.0, 72.0, 400.0, 200.0, 400.0]] },
|
||||
Shape::Rectangle { rect: [300.0, 300.0, 200.0, 200.0] },
|
||||
Shape::Oval { rect: [300.0, 300.0, 400.0, 350.0] },
|
||||
Shape::Line { from: [50.0, 50.0], to: [150.0, 100.0], arrow: true },
|
||||
Shape::Line { from: [50.0, 50.0], to: [150.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow },
|
||||
Shape::Ink { strokes: vec![vec![[10.0, 10.0], [20.0, 30.0], [30.0, 10.0]], vec![[40.0, 40.0]]] },
|
||||
Shape::TextBox { rect: [300.0, 600.0, 500.0, 650.0], font_size: 14.0 },
|
||||
];
|
||||
@@ -179,7 +179,7 @@ fn invalid_geometry_is_rejected() {
|
||||
let bad = [
|
||||
Shape::TextMarkup { kind: Markup::Highlight, quads: vec![] },
|
||||
Shape::Rectangle { rect: [0.0, 0.0, 0.5, 10.0] },
|
||||
Shape::Line { from: [1.0, 1.0], to: [1.0, 1.0], arrow: false },
|
||||
Shape::Line { from: [1.0, 1.0], to: [1.0, 1.0], start: LineEnding::None, end: LineEnding::None },
|
||||
Shape::Ink { strokes: vec![vec![]] },
|
||||
Shape::Note { at: [f64::NAN, 0.0], icon: NoteIcon::Note },
|
||||
];
|
||||
@@ -261,7 +261,7 @@ fn move_shifts_all_geometry_and_the_popup() {
|
||||
fn restyle_and_resize_redraw_the_appearance() {
|
||||
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_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap();
|
||||
set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[r];
|
||||
@@ -271,10 +271,15 @@ fn restyle_and_resize_redraw_the_appearance() {
|
||||
assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5));
|
||||
// A stamp's appearance can't be regenerated, so restyling it changes nothing.
|
||||
let before = list(&doc, 1)[0].clone();
|
||||
assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into())));
|
||||
assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into())));
|
||||
assert_eq!(list(&doc, 1)[0], before);
|
||||
// Lines can't be resized as rectangles.
|
||||
let l = add_annotation(&mut doc, &new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], arrow: false }), &meta("l")).unwrap();
|
||||
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_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
||||
@@ -283,7 +288,7 @@ fn text_box_text_and_colour_changes_are_drawn() {
|
||||
let mut doc = fixture();
|
||||
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap();
|
||||
set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%");
|
||||
@@ -337,7 +342,16 @@ fn editing_a_callout_refits_the_box_and_keeps_the_leader() {
|
||||
let mut doc = fixture();
|
||||
let c = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Callout { rect: [300.0, 600.0, 500.0, 612.0], knee: [260.0, 560.0], point: [200.0, 520.0], font_size: 12.0 }),
|
||||
&new(
|
||||
0,
|
||||
Shape::Callout {
|
||||
rect: [300.0, 600.0, 500.0, 612.0],
|
||||
knee: [260.0, 560.0],
|
||||
point: [200.0, 520.0],
|
||||
font_size: 12.0,
|
||||
ending: LineEnding::OpenArrow,
|
||||
},
|
||||
),
|
||||
&meta("c"),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -429,8 +443,8 @@ fn fill_and_sign_items_are_drawn() {
|
||||
assert_eq!(text(&all[s], b"Subj"), "Signature");
|
||||
// Other stamps still can't be restyled; ours can.
|
||||
let mut doc = doc;
|
||||
set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, &meta("")).unwrap();
|
||||
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")).is_err());
|
||||
set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap();
|
||||
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -515,11 +529,24 @@ fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() {
|
||||
let tri = vec![[100.0, 100.0], [200.0, 100.0], [150.0, 180.0]];
|
||||
let i = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices: tri.clone(), cloud: false }), &meta("p")).unwrap();
|
||||
let c = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices: tri.clone(), cloud: true }), &meta("c")).unwrap();
|
||||
let l =
|
||||
add_annotation(&mut doc, &new(0, Shape::PolyLine { vertices: vec![[300.0, 300.0], [350.0, 320.0], [400.0, 300.0]] }), &meta("l")).unwrap();
|
||||
let l = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::PolyLine { vertices: vec![[300.0, 300.0], [350.0, 320.0], [400.0, 300.0]], start: LineEnding::None, end: LineEnding::None }),
|
||||
&meta("l"),
|
||||
)
|
||||
.unwrap();
|
||||
let co = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Callout { rect: [300.0, 500.0, 420.0, 540.0], knee: [260.0, 520.0], point: [220.0, 460.0], font_size: 10.0 }),
|
||||
&new(
|
||||
0,
|
||||
Shape::Callout {
|
||||
rect: [300.0, 500.0, 420.0, 540.0],
|
||||
knee: [260.0, 520.0],
|
||||
point: [220.0, 460.0],
|
||||
font_size: 10.0,
|
||||
ending: LineEnding::OpenArrow,
|
||||
},
|
||||
),
|
||||
&meta("co"),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -576,7 +603,7 @@ fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() {
|
||||
|
||||
for bad in [
|
||||
Shape::Polygon { vertices: vec![[0.0, 0.0], [10.0, 10.0]], cloud: false },
|
||||
Shape::PolyLine { vertices: vec![[0.0, 0.0]] },
|
||||
Shape::PolyLine { vertices: vec![[0.0, 0.0]], start: LineEnding::None, end: LineEnding::None },
|
||||
Shape::Caret { rect: [0.0, 0.0, 0.0, 10.0] },
|
||||
] {
|
||||
assert!(add_annotation(&mut doc, &new(0, bad), &meta("b")).is_err());
|
||||
@@ -735,3 +762,93 @@ fn comments_without_appearances_get_one_for_display_only() {
|
||||
]);
|
||||
assert!(with_missing_appearances(&complete).is_none());
|
||||
}
|
||||
|
||||
fn line_dict(coords: [f64; 4], ending: &str) -> pdfcraft_cos::Dict {
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Line"));
|
||||
d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 300.0, 300.0].into_iter().map(Object::Real).collect()));
|
||||
d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect()));
|
||||
d.set(b"L".to_vec(), Object::Array(coords.into_iter().map(Object::Real).collect()));
|
||||
d.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name(ending)]));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_line_ending_is_drawn() {
|
||||
let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow];
|
||||
let (mut open, mut reverse) = (String::new(), String::new());
|
||||
for ending in LineEnding::ALL {
|
||||
let mut doc = fixture();
|
||||
add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [80.0, 100.0], to: [200.0, 160.0], start: LineEnding::None, end: ending }),
|
||||
&meta("ending"),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
|
||||
let content = ap_content(&doc, &line);
|
||||
assert!(!content.is_empty(), "{ending:?}");
|
||||
let paints = content.contains("h B") || content.contains("B\n");
|
||||
assert_eq!(paints, filled.contains(&ending), "{ending:?} fill: {content}");
|
||||
if ending != LineEnding::None {
|
||||
let r = rect(&line);
|
||||
assert!(r[0] < 70.0 && r[2] > 210.0, "{ending:?} rectangle includes the ending: {r:?}");
|
||||
}
|
||||
if ending == LineEnding::OpenArrow {
|
||||
open = content.clone();
|
||||
}
|
||||
if ending == LineEnding::ROpenArrow {
|
||||
reverse = content;
|
||||
}
|
||||
}
|
||||
assert_ne!(open, reverse, "a reverse arrow points the other way");
|
||||
|
||||
let mut doc = fixture();
|
||||
let index = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [40.0, 40.0], to: [140.0, 40.0], start: LineEnding::None, end: LineEnding::None }),
|
||||
&meta("plain"),
|
||||
)
|
||||
.unwrap();
|
||||
let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"));
|
||||
set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
|
||||
let after = rect(&line);
|
||||
assert!(after[1] < before[1] - 5.0 && after[3] > before[3] + 5.0, "the square is not clipped: {before:?} → {after:?}");
|
||||
assert!(ap_content(&doc, &line).contains("re h B"), "square ending");
|
||||
|
||||
add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::PolyLine { vertices: vec![[40.0, 200.0], [90.0, 260.0], [150.0, 200.0]], start: LineEnding::Circle, end: LineEnding::Slash }),
|
||||
&meta("poly"),
|
||||
)
|
||||
.unwrap();
|
||||
add_annotation(
|
||||
&mut doc,
|
||||
&new(
|
||||
0,
|
||||
Shape::Callout {
|
||||
rect: [300.0, 500.0, 420.0, 540.0],
|
||||
knee: [260.0, 520.0],
|
||||
point: [220.0, 460.0],
|
||||
font_size: 10.0,
|
||||
ending: LineEnding::Diamond,
|
||||
},
|
||||
),
|
||||
&meta("call"),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = list(&doc, 0);
|
||||
let poly = all.iter().find(|d| d.name(b"Subtype") == Some(b"PolyLine")).expect("polyline");
|
||||
let poly_ap = ap_content(&doc, poly);
|
||||
assert!(poly_ap.contains("B\n") && poly_ap.contains(" l S"), "circle fills and the slash only strokes: {poly_ap}");
|
||||
let call = all.iter().find(|d| d.name(b"IT") == Some(b"FreeTextCallout")).expect("callout");
|
||||
assert_eq!(call.name(b"LE"), Some(b"Diamond".as_slice()));
|
||||
assert!(ap_content(&doc, call).contains("h B"), "diamond callout");
|
||||
|
||||
assert!(appearance::build(&line_dict([f64::NAN, 0.0, 10.0, 0.0], "OpenArrow")).is_none(), "a non-finite endpoint is rejected");
|
||||
assert!(appearance::build(&line_dict([0.0, 0.0, 10.0, 0.0], "Foo")).is_none(), "an unknown ending is not replaced");
|
||||
}
|
||||
@@ -24,5 +24,6 @@ default = ["mcp"]
|
||||
mcp = ["dep:base64"]
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
@@ -3,7 +3,9 @@
|
||||
//! Geometry follows the automation convention: points from the top-left of the displayed page,
|
||||
//! y down. It is converted to PDF user space (crop box, `/Rotate`) here.
|
||||
|
||||
use pdfcraft_engine::{Edit, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort};
|
||||
use pdfcraft_engine::{
|
||||
Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort,
|
||||
};
|
||||
use pdfcraft_render::{Annotation, PageInfo};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -34,6 +36,33 @@ pub(crate) fn parse_color(s: &str) -> Result<Rgb> {
|
||||
Ok([c(0), c(2), c(4)])
|
||||
}
|
||||
|
||||
const ENDING_NAMES: &str = "None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash";
|
||||
|
||||
fn line_endings(a: &Args) -> Result<Option<Vec<LineEnding>>> {
|
||||
let Some(v) = a.get("endings") else { return Ok(None) };
|
||||
let arr = v.as_array().ok_or_else(|| ToolError::InvalidArgs("endings must be an array of line-ending names".into()))?;
|
||||
if arr.is_empty() || arr.len() > 2 {
|
||||
return Err(ToolError::InvalidArgs("endings takes one name for a callout, or two for a line or polyline".into()));
|
||||
}
|
||||
arr.iter()
|
||||
.map(|item| {
|
||||
let name = item.as_str().ok_or_else(|| ToolError::InvalidArgs("endings must be line-ending names".into()))?;
|
||||
LineEnding::parse(name).ok_or_else(|| ToolError::InvalidArgs(format!("unknown line ending {name:?} ({ENDING_NAMES})")))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
/// `[start, end]` for a line, arrow or polyline. An arrow with no `endings` stays `[None, OpenArrow]`.
|
||||
fn ending_pair(kind: &str, endings: Option<&[LineEnding]>) -> Result<[LineEnding; 2]> {
|
||||
match endings {
|
||||
None if kind == "arrow" => Ok([LineEnding::None, LineEnding::OpenArrow]),
|
||||
None => Ok([LineEnding::None, LineEnding::None]),
|
||||
Some(v) if v.len() == 2 => Ok([v.first().copied().unwrap_or(LineEnding::None), v.get(1).copied().unwrap_or(LineEnding::None)]),
|
||||
Some(_) => Err(ToolError::InvalidArgs("a line or polyline needs two endings, the start and the end".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(c: [f32; 3]) -> String {
|
||||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!("#{:02X}{:02X}{:02X}", b(c[0]), b(c[1]), b(c[2]))
|
||||
@@ -181,6 +210,10 @@ impl Automation {
|
||||
let page = self.page(a)?;
|
||||
let info = self.doc(a)?.info.pages[page].clone();
|
||||
let kind = a.str("type")?;
|
||||
let endings = line_endings(a)?;
|
||||
if endings.is_some() && !matches!(kind, "line" | "arrow" | "polyline" | "callout") {
|
||||
return Err(ToolError::InvalidArgs("endings apply to a line, arrow, polyline or callout".into()));
|
||||
}
|
||||
let markup = match kind {
|
||||
"highlight" => Some(Markup::Highlight),
|
||||
"underline" => Some(Markup::Underline),
|
||||
@@ -254,7 +287,8 @@ impl Automation {
|
||||
}
|
||||
"line" | "arrow" => {
|
||||
let (f, t) = (a.need::<2>("from", "a line")?, a.need::<2>("to", "a line")?);
|
||||
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), arrow: kind == "arrow" }
|
||||
let [start, end] = ending_pair(kind, endings.as_deref())?;
|
||||
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end }
|
||||
}
|
||||
"ink" => {
|
||||
let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into());
|
||||
@@ -287,7 +321,10 @@ impl Automation {
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
match kind {
|
||||
"polyline" => Shape::PolyLine { vertices },
|
||||
"polyline" => {
|
||||
let [start, end] = ending_pair(kind, endings.as_deref())?;
|
||||
Shape::PolyLine { vertices, start, end }
|
||||
}
|
||||
_ => Shape::Polygon { vertices, cloud: kind == "cloud" },
|
||||
}
|
||||
}
|
||||
@@ -310,7 +347,17 @@ impl Automation {
|
||||
[(point[0] + side) / 2.0, mid]
|
||||
}
|
||||
};
|
||||
Shape::Callout { rect, knee, point, font_size: a.opt_num("font_size")?.unwrap_or(10.0) }
|
||||
Shape::Callout {
|
||||
rect,
|
||||
knee,
|
||||
point,
|
||||
font_size: a.opt_num("font_size")?.unwrap_or(10.0),
|
||||
ending: match endings.as_deref() {
|
||||
None => LineEnding::OpenArrow,
|
||||
Some([ending]) => *ending,
|
||||
Some(_) => return Err(ToolError::InvalidArgs("a callout needs one ending".into())),
|
||||
},
|
||||
}
|
||||
}
|
||||
"attachment" => {
|
||||
let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?;
|
||||
@@ -484,7 +531,7 @@ impl Automation {
|
||||
}
|
||||
let (color, opacity, width) = (a.color("color")?, a.opt_num("opacity")?, a.opt_num("width")?);
|
||||
if color.is_some() || opacity.is_some() || width.is_some() {
|
||||
edits.push(Edit::StyleAnnotation { page, index, color, opacity, width });
|
||||
edits.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings: None });
|
||||
}
|
||||
if let Some(r) = a.nums::<4>("rect")? {
|
||||
edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) });
|
||||
|
||||
@@ -528,7 +528,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"Add a comment",
|
||||
"Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \
|
||||
note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \
|
||||
rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. ink: `strokes` [[[x, y], …], …]. \
|
||||
rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. Optional `endings` (two names: start, end) for a line, arrow or polyline, or one name for a callout: None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash. An arrow without endings is None then OpenArrow. ink: `strokes` [[[x, y], …], …]. \
|
||||
polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
@@ -556,6 +556,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"fill": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"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." },
|
||||
"font_size": { "type": "number", "exclusiveMinimum": 0 },
|
||||
}),
|
||||
&["doc", "page", "type"],
|
||||
|
||||
@@ -2247,6 +2247,72 @@ fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
|
||||
assert!(matches!(a.call("comments_summarize", &json!({ "doc": doc, "sort": "colour" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_endings_through_the_tool() {
|
||||
let dir = workdir("endings");
|
||||
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": "line", "from": [20, 40], "to": [120, 80], "endings": ["None", "Diamond"] }));
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "polyline", "points": [[20, 120], [60, 100], [100, 140]], "endings": ["Circle", "Slash"] }),
|
||||
);
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 200, 190, 240], "to": [40, 160], "contents": "Look", "endings": ["Square"] }),
|
||||
);
|
||||
assert!(matches!(
|
||||
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 80], "endings": ["Sparkle", "None"] })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
a.call(
|
||||
"comment_add",
|
||||
&json!({ "doc": doc, "page": 1, "type": "callout", "rect": [10, 10, 80, 40], "to": [90, 80], "endings": ["None", "None"] })
|
||||
),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 40, 40], "endings": ["Circle"] })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "ended.pdf" }));
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("ended.pdf")).unwrap())).unwrap();
|
||||
for name in [b"Diamond".as_slice(), b"Circle", b"Slash", b"Square"] {
|
||||
assert!(
|
||||
saved.object_numbers().iter().any(|n| saved.try_get(*n).ok().is_some_and(|o| object_has_name(&o, name))),
|
||||
"missing /{}",
|
||||
String::from_utf8_lossy(name)
|
||||
);
|
||||
}
|
||||
let drawn: Vec<String> = saved
|
||||
.object_numbers()
|
||||
.into_iter()
|
||||
.filter_map(|n| {
|
||||
let obj = saved.try_get(n).ok()?;
|
||||
let pdfcraft_cos::Object::Stream(s) = &*obj else { return None };
|
||||
if s.dict.name(b"Subtype") != Some(b"Form") {
|
||||
return None;
|
||||
}
|
||||
s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned())
|
||||
})
|
||||
.collect();
|
||||
assert!(drawn.iter().any(|s| s.contains("h B")), "a filled ending was drawn: {drawn:?}");
|
||||
assert!(drawn.iter().any(|s| s.contains(" l S")), "an open ending was drawn: {drawn:?}");
|
||||
}
|
||||
|
||||
fn object_has_name(obj: &pdfcraft_cos::Object, name: &[u8]) -> bool {
|
||||
match obj {
|
||||
pdfcraft_cos::Object::Name(n) => n.as_slice() == name,
|
||||
pdfcraft_cos::Object::Array(items) => items.iter().any(|o| object_has_name(o, name)),
|
||||
pdfcraft_cos::Object::Dict(d) => d.iter().any(|(_, o)| object_has_name(o, name)),
|
||||
pdfcraft_cos::Object::Stream(s) => s.dict.iter().any(|(_, o)| object_has_name(o, name)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_comments_through_tools() {
|
||||
let dir = workdir("drawing");
|
||||
|
||||
@@ -94,8 +94,8 @@ pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i6
|
||||
pub use pdfcraft_annot::appearance as annot_text;
|
||||
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
|
||||
pub use pdfcraft_annot::{
|
||||
AttachIcon, FillMark, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape,
|
||||
StampGroup, StampKind, Style, rect_quad,
|
||||
AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb,
|
||||
Shape, StampGroup, StampKind, Style, rect_quad,
|
||||
};
|
||||
pub use pdfcraft_forms::detect;
|
||||
pub use pdfcraft_optimize as optimize;
|
||||
@@ -852,6 +852,8 @@ pub enum Edit {
|
||||
color: Option<Rgb>,
|
||||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
/// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged.
|
||||
endings: Option<Vec<pdfcraft_annot::LineEnding>>,
|
||||
},
|
||||
/// Comment properties ▸ General / note icon.
|
||||
SetAnnotationInfo {
|
||||
@@ -1197,7 +1199,7 @@ fn annotation_noun(s: &Shape) -> &'static str {
|
||||
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "squiggly underline",
|
||||
Shape::Rectangle { .. } => "rectangle",
|
||||
Shape::Oval { .. } => "oval",
|
||||
Shape::Line { arrow: true, .. } => "arrow",
|
||||
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "arrow",
|
||||
Shape::Line { .. } => "line",
|
||||
Shape::Ink { .. } => "drawing",
|
||||
Shape::TextBox { .. } => "text box",
|
||||
@@ -1494,8 +1496,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?,
|
||||
Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?,
|
||||
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
|
||||
Edit::StyleAnnotation { page, index, color, opacity, width } => {
|
||||
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, &cx.meta())?;
|
||||
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
|
||||
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &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())?;
|
||||
|
||||
@@ -79,7 +79,7 @@ fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() {
|
||||
for (actual, expected) in bounds.into_iter().zip([60.0, 80.0, 140.0, 120.0]) {
|
||||
assert!((actual - expected).abs() < 0.0001, "displayed bounds {bounds:?}");
|
||||
}
|
||||
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None }).unwrap();
|
||||
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap();
|
||||
check(&session, points);
|
||||
let resized = info.view_rect_to_user([40.0, 70.0, 160.0, 130.0]).map(f64::from);
|
||||
session.apply(id, Edit::ResizeAnnotation { page: 0, index: 0, rect: resized }).unwrap();
|
||||
|
||||
@@ -336,9 +336,12 @@ pub fn add(doc: &mut Document, new: &NewMeasurement, meta: &Meta) -> Result<usiz
|
||||
Kind::Distance => Shape::Line {
|
||||
from: new.points.first().copied().ok_or_else(|| invalid("missing start"))?,
|
||||
to: new.points.last().copied().ok_or_else(|| invalid("missing end"))?,
|
||||
arrow: true,
|
||||
start: pdfcraft_annot::LineEnding::None,
|
||||
end: pdfcraft_annot::LineEnding::OpenArrow,
|
||||
},
|
||||
Kind::Perimeter => Shape::PolyLine { vertices: new.points.clone() },
|
||||
Kind::Perimeter => {
|
||||
Shape::PolyLine { vertices: new.points.clone(), start: pdfcraft_annot::LineEnding::None, end: pdfcraft_annot::LineEnding::None }
|
||||
}
|
||||
Kind::Area => Shape::Polygon { vertices: new.points.clone(), cloud: false },
|
||||
};
|
||||
let index = pdfcraft_annot::add_annotation(
|
||||
|
||||
@@ -92,7 +92,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() {
|
||||
let (r, dict) = annot(&d, 0, 0).unwrap();
|
||||
assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..]));
|
||||
d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap();
|
||||
pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), &meta).unwrap();
|
||||
pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap();
|
||||
let (_, dict) = annot(&d, 0, 0).unwrap();
|
||||
assert_eq!(dict.int(b"VendorKey"), Some(42));
|
||||
let ap = d.resolve(dict.get(b"AP").unwrap());
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! subject, modified) and Review History (status changes), with Acrobat's Locked box.
|
||||
|
||||
use egui::{Align, Layout};
|
||||
use pdfcraft_engine::{CommentProps, Edit, NoteIcon};
|
||||
use pdfcraft_engine::{CommentProps, Edit, LineEnding, NoteIcon};
|
||||
|
||||
use crate::comments::swatch_grid;
|
||||
use crate::theme::{self, Tokens};
|
||||
@@ -108,8 +108,9 @@ pub fn edits(d: &PropsDraft) -> 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();
|
||||
if e.restylable && (color.is_some() || opacity.is_some() || width.is_some()) {
|
||||
out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width });
|
||||
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 author = (e.author != o.author).then(|| e.author.clone());
|
||||
let subject = (e.subject != o.subject).then(|| e.subject.clone());
|
||||
@@ -188,6 +189,19 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
ui.add(egui::Slider::new(w, 0.5..=12.0).step_by(0.5).suffix(" pt"));
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some(ends) = e.endings.as_mut() {
|
||||
ui.label(tl!("Line ending"));
|
||||
ui.horizontal(|ui| {
|
||||
for (i, ending) in ends.iter_mut().enumerate() {
|
||||
egui::ComboBox::from_id_salt(("line-ending", i)).selected_text(ending.name()).width(130.0).show_ui(ui, |ui| {
|
||||
for style in LineEnding::ALL {
|
||||
ui.selectable_value(ending, style, style.name());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! applied by the app, so each change is one undo step.
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use pdfcraft_engine::{Edit, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
|
||||
use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
|
||||
use pdfcraft_render::{Annotation, DocInfo};
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
@@ -200,14 +200,14 @@ impl CommentTool {
|
||||
Shape::TextMarkup { kind: self.markup().unwrap_or(Markup::Highlight), quads: Vec::new() }
|
||||
}
|
||||
Self::Ink => Shape::Ink { strokes: Vec::new() },
|
||||
Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: false },
|
||||
Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: true },
|
||||
Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::None },
|
||||
Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::OpenArrow },
|
||||
Self::Rectangle => Shape::Rectangle { rect: [0.0; 4] },
|
||||
Self::Oval => Shape::Oval { rect: [0.0; 4] },
|
||||
Self::Polygon => Shape::Polygon { vertices: Vec::new(), cloud: false },
|
||||
Self::Cloud => Shape::Polygon { vertices: Vec::new(), cloud: true },
|
||||
Self::PolyLine => Shape::PolyLine { vertices: Vec::new() },
|
||||
Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0 },
|
||||
Self::PolyLine => Shape::PolyLine { vertices: Vec::new(), start: LineEnding::None, end: LineEnding::None },
|
||||
Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0, ending: LineEnding::OpenArrow },
|
||||
Self::Caret => Shape::Caret { rect: [0.0; 4] },
|
||||
Self::ReplaceText => Shape::TextMarkup { kind: Markup::StrikeOut, quads: Vec::new() },
|
||||
Self::Eraser => Shape::Ink { strokes: Vec::new() },
|
||||
@@ -1008,13 +1008,20 @@ fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option<Shape> {
|
||||
let big = rect[2] - rect[0] >= 2.0 && rect[3] - rect[1] >= 2.0;
|
||||
match tool {
|
||||
CommentTool::Ink if points.len() >= 2 => Some(Shape::Ink { strokes: vec![points.to_vec()] }),
|
||||
CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line { from: first, to: last, arrow: tool == CommentTool::Arrow }),
|
||||
CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line {
|
||||
from: first,
|
||||
to: last,
|
||||
start: LineEnding::None,
|
||||
end: if tool == CommentTool::Arrow { LineEnding::OpenArrow } else { LineEnding::None },
|
||||
}),
|
||||
CommentTool::Rectangle if big => Some(Shape::Rectangle { rect }),
|
||||
CommentTool::Oval if big => Some(Shape::Oval { rect }),
|
||||
CommentTool::Polygon | CommentTool::Cloud if points.len() >= 3 => {
|
||||
Some(Shape::Polygon { vertices: points.to_vec(), cloud: tool == CommentTool::Cloud })
|
||||
}
|
||||
CommentTool::PolyLine if points.len() >= 2 => Some(Shape::PolyLine { vertices: points.to_vec() }),
|
||||
CommentTool::PolyLine if points.len() >= 2 => {
|
||||
Some(Shape::PolyLine { vertices: points.to_vec(), start: LineEnding::None, end: LineEnding::None })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1144,7 +1151,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
(rect[0] + rect[2]) / 2.0
|
||||
};
|
||||
let knee = [(point[0] + side) / 2.0, (rect[1] + rect[3]) / 2.0];
|
||||
Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size: 10.0 }, text))
|
||||
Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size: 10.0, ending: LineEnding::OpenArrow }, text))
|
||||
}
|
||||
ComposerKind::Replace => {
|
||||
view.comments.tool_done = true;
|
||||
@@ -1236,8 +1243,14 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
|
||||
});
|
||||
ui.menu_button(tl!("Colour"), |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 })));
|
||||
action = Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation {
|
||||
page,
|
||||
index,
|
||||
color: Some(c),
|
||||
opacity: None,
|
||||
width: None,
|
||||
endings: None,
|
||||
})));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
@@ -1404,7 +1417,10 @@ mod tests {
|
||||
assert_eq!(drawn_shape(CommentTool::Rectangle, &[[0.0, 0.0], [1.0, 1.0]]), None);
|
||||
assert_eq!(drawn_shape(CommentTool::Rectangle, &[[10.0, 0.0], [0.0, 10.0]]), Some(Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] }));
|
||||
assert_eq!(drawn_shape(CommentTool::Ink, &[[0.0, 0.0]]), None);
|
||||
assert!(matches!(drawn_shape(CommentTool::Arrow, &[[0.0, 0.0], [5.0, 0.0]]), Some(Shape::Line { arrow: true, .. })));
|
||||
assert!(matches!(
|
||||
drawn_shape(CommentTool::Arrow, &[[0.0, 0.0], [5.0, 0.0]]),
|
||||
Some(Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -608,6 +608,7 @@
|
||||
This comment's appearance can't be changed yet. Външният вид на този коментар все още не може да се променя.
|
||||
Icon Икона
|
||||
Thickness Дебелина
|
||||
Line ending Край на линията
|
||||
Modified Променено
|
||||
No status has been set. Не е зададено състояние.
|
||||
Compare Files Сравняване на файлове
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -896,6 +896,7 @@
|
||||
This comment's appearance can't be changed yet. Die Darstellung dieses Kommentars kann noch nicht geändert werden.
|
||||
Icon Symbol
|
||||
Thickness Stärke
|
||||
Line ending Linienende
|
||||
Modified Geändert
|
||||
No status has been set. Es wurde kein Status festgelegt.
|
||||
Compare Files Dateien vergleichen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -895,6 +895,7 @@
|
||||
This comment's appearance can't be changed yet. L'apparence de ce commentaire ne peut pas encore être modifiée.
|
||||
Icon Icône
|
||||
Thickness Épaisseur
|
||||
Line ending Extrémité de ligne
|
||||
Modified Modifié
|
||||
No status has been set. Aucun état n'a été défini.
|
||||
Compare Files Comparer des fichiers
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
@@ -2083,3 +2083,4 @@ action wizard Start Indítás
|
||||
Home / End Home / End
|
||||
H / Space (hold) H / szóköz (nyomva tartva)
|
||||
Rotation: Elforgatás:
|
||||
Line ending Vonalvég
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 44.
|
@@ -916,6 +916,7 @@
|
||||
This comment's appearance can't be changed yet. Внешний вид этого комментария пока нельзя изменить.
|
||||
Icon Значок
|
||||
Thickness Толщина
|
||||
Line ending Окончание линии
|
||||
Modified Изменено
|
||||
No status has been set. Статус не установлен.
|
||||
Compare Files Сравнить файлы
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -2105,3 +2105,4 @@ calendar date December грудня
|
||||
Home / End Home / End
|
||||
H / Space (hold) H / Пробіл (утримувати)
|
||||
Rotation: Поворот:
|
||||
Line ending Закінчення лінії
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -615,6 +615,7 @@
|
||||
This comment's appearance can't be changed yet. 此注释的外观尚无法更改。
|
||||
Icon 图标
|
||||
Thickness 粗细
|
||||
Line ending 线条端点
|
||||
Modified 修改时间
|
||||
No status has been set. 尚未设置状态。
|
||||
Compare Files 比较文件
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -264,6 +264,37 @@ fn the_panel_posts_comments_and_replies() {
|
||||
h.get_by_label_contains("Agreed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_ending_properties_change_and_undo() {
|
||||
let mut h = harness(|app| app.set_option("quick", "line").unwrap());
|
||||
drag_pt(&mut h, (40.0, 100.0), (160.0, 40.0));
|
||||
h.state_mut().open_comment_props(0, 0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Line ending");
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
assert_eq!(d.edited.endings.as_deref(), Some(&[pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::None][..]));
|
||||
d.edited.endings = Some(vec![pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::Diamond]);
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let endings = {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
assert_eq!(doc.can_undo(), Some("Change comment properties"));
|
||||
doc.comment_props(0, 0).unwrap().endings
|
||||
};
|
||||
assert_eq!(endings.as_deref(), Some(&[pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::Diamond][..]));
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(2);
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
assert_eq!(
|
||||
doc.comment_props(0, 0).unwrap().endings.as_deref(),
|
||||
Some(&[pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::None][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_properties_change_appearance_and_author() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
@@ -453,6 +484,7 @@ fn the_panel_filters_by_colour_and_checkmark() {
|
||||
color: Some([0.0, 0.47, 0.84]),
|
||||
opacity: None,
|
||||
width: None,
|
||||
endings: None,
|
||||
});
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::MarkAnnotation { page: 0, index: 0, marked: true, author: "Tester".into() });
|
||||
h.run_steps(2);
|
||||
@@ -487,6 +519,7 @@ fn make_current_properties_default() {
|
||||
color: Some([0.0, 0.47, 0.84]),
|
||||
opacity: Some(0.5),
|
||||
width: Some(5.0),
|
||||
endings: None,
|
||||
});
|
||||
h.run_steps(2);
|
||||
h.get_by_label("More").click();
|
||||
|
||||
@@ -3770,8 +3770,8 @@ milestone = "M5"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
|
||||
notes = "None, OpenArrow and ClosedArrow are drawn; other endings are kept but not redrawn"
|
||||
evidence = ["crates/annot/src/tests.rs::every_line_ending_is_drawn", "crates/automation/tests/automation.rs::line_endings_through_the_tool", "crates/ui-egui/tests/comments.rs::line_ending_properties_change_and_undo"]
|
||||
notes = "All ten /LE styles (None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash) are drawn on lines, polylines and callouts. comment_add endings and Comment properties set them. Dashed borders are drawn. Cloudy borders are still polygon-only."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.properties-dialog"
|
||||
|
||||
Reference in new issue
Block a user