M5: Polygon, Connected lines, Cloud (cloudy border appearance), Callout (leader line with arrow, /RD text box, resize re-attaches the leader) and Insert text (caret) comments: annot shapes and appearances, click-to-place-points and drag-to-callout tools in the Comment/Highlight/Draw flyouts, /IT carried to the comment list (Callout, Cloud labels), comment_add types polygon/cloud/polyline/callout/caret

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-02 02:11:52 -04:00
1 parent 18b0532e61
commit 7616535dd1
19 files changed
+856 -79

No files matched your search

+3 -1
View File
@@ -4,7 +4,7 @@
Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (219)
## In this repository (221)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -227,6 +227,8 @@ Every asset PrintCraft includes, bundles or uses to build its published material
| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PrintCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs) |
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
+22
View File
@@ -2750,3 +2750,25 @@ licence_file = "LICENSE-MIT"
kind = "document"
usage = "Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs)"
sha256 = "c6f8b656e225101129aa36bd5467adc8dba16730915f19caca17f6a546d0b040"
[[asset]]
path = "assets/icons/pentagon.svg"
title = "Lucide icon \"pentagon\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg)"
licence = "ISC"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "55a9c2b2531928957c23e7695ccd39b096a3b3f2a5ef3e66b3ad970742e20f26"
[[asset]]
path = "assets/icons/message-square-quote.svg"
title = "Lucide icon \"message-square-quote\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg)"
licence = "ISC"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "69a76b4cd352dbdea61c63ed1e8d1e02083ee1773b3ced2a1c8cdf8c90fb2055"
+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-message-square-quote"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14 14a2 2 0 0 0 2-2V8h-2" />
<path d="M22 17a2 2 0 0 1-2 2H6.828a2 2 0 0 0-1.414.586l-2.202 2.202A.71.71 0 0 1 2 21.286V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2z" />
<path d="M8 14a2 2 0 0 0 2-2V8H8" />
</svg>

After

Width:  |  Height:  |  Size: 510 B

+15
View File
@@ -0,0 +1,15 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-pentagon"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10.83 2.38a2 2 0 0 1 2.34 0l8 5.74a2 2 0 0 1 .73 2.25l-3.04 9.26a2 2 0 0 1-1.9 1.37H7.04a2 2 0 0 1-1.9-1.37L2.1 10.37a2 2 0 0 1 .73-2.25z" />
</svg>

After

Width:  |  Height:  |  Size: 441 B

+145 -22
View File
@@ -21,6 +21,61 @@ pub fn arrow_size(w: f64) -> f64 {
6.0 + 3.0 * w.max(0.0)
}
/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1).
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) {
if kind == b"None" {
return;
}
let (dx, dy) = (from.0 - tip.0, from.1 - tip.1);
let len = dx.hypot(dy);
if len == 0.0 {
return;
}
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)));
}
/// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of
/// half-circle bumps of about `r`, bulging outwards.
pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String {
let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum();
// Outward normal of an edge: to the right of travel for counter-clockwise outlines.
let sign = if area >= 0.0 { 1.0 } else { -1.0 };
let mut c = String::new();
for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len = dx.hypot(dy);
if i == 0 {
c.push_str(&format!("{} {} m\n", n(a.0), n(a.1)));
}
if len < 1e-6 {
continue;
}
let k = ((len / (2.0 * r)).round() as usize).clamp(1, 10_000);
let step = len / k as f64;
let (ux, uy) = (dx / len, dy / len);
let (nx, ny) = (sign * uy, -sign * ux);
let h = 4.0 / 3.0 * step / 2.0;
for j in 0..k {
let p0 = (a.0 + ux * step * j as f64, a.1 + uy * step * j as f64);
let p1 = (a.0 + ux * step * (j + 1) as f64, a.1 + uy * step * (j + 1) as f64);
c.push_str(&format!("{} {} {} {} {} {} c\n", n(p0.0 + nx * h), n(p0.1 + ny * h), n(p1.0 + nx * h), n(p1.1 + ny * h), n(p1.0), n(p1.1)));
}
}
c.push_str("h\n");
c
}
fn nums(d: &Dict, key: &[u8]) -> Option<Vec<f64>> {
d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect()
}
@@ -129,8 +184,9 @@ pub fn build(d: &Dict) -> Option<Stream> {
let subtype = d.name(b"Subtype")?.to_vec();
let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?;
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
// Cloudy borders (§12.5.4) are not drawn yet.
if d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")) {
// Cloudy borders (§12.5.4) are drawn for polygons only.
let cloudy = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
if cloudy && subtype != b"Polygon" {
return None;
}
let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0);
@@ -259,23 +315,67 @@ pub fn build(d: &Dict) -> Option<Stream> {
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]))]) {
if end.as_slice() == b"None" {
continue;
}
let (dx, dy) = (from.0 - tip.0, from.1 - tip.1);
let len = dx.hypot(dy);
if len == 0.0 {
continue;
}
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 end.as_slice() == 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)));
line_end(&mut c, end, tip, from, w);
}
}
b"Polygon" | b"PolyLine" => {
let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0)?;
let pts: Vec<(f64, f64)> = v.chunks_exact(2).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;
}
if let Some(f) = fill {
c.push_str(&rg(f));
}
if let Some(s) = col {
c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d)));
}
if cloudy {
let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0);
c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0)));
} else {
for (i, p) in pts.iter().enumerate() {
c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
}
if closed {
c.push_str("h\n");
}
}
c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) {
(true, true) => "B\n",
(true, false) => "f\n",
(false, true) => "S\n",
(false, false) => "n\n",
});
}
b"Caret" => {
// A filled caret: two curved flanks meeting at the top centre.
let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
let [x0, y0, x1, y1] = rect;
let (cx, h) = ((x0 + x1) / 2.0, y1 - y0);
c.push_str(&format!(
"{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n",
rg(col),
n(x0),
n(y0),
n(cx - (x1 - x0) * 0.1),
n(y0 + h * 0.2),
n(cx),
n(y1 - h * 0.25),
n(cx),
n(y1),
n(cx),
n(y1 - h * 0.25),
n(cx + (x1 - x0) * 0.1),
n(y0 + h * 0.2),
n(x1),
n(y0)
));
}
b"Ink" => {
let col = stroke?;
let list = d.get(b"InkList")?.as_array()?;
@@ -353,13 +453,36 @@ pub fn build(d: &Dict) -> Option<Stream> {
}
}
b"FreeText" => {
// Callouts (`/CL`) and rich text without plain contents aren't drawn yet.
if d.contains(b"CL") {
return None;
}
let (text_color, size) = parse_da(d);
let bg = stroke;
let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
// A callout: the leader line (arrowhead at its first point), and the text box inside
// `/Rect` by `/RD`.
let full = rect;
let mut rect = rect;
if d.contains(b"CL") {
let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?;
let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?;
rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]];
if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return None;
}
let end = match d.get(b"LE") {
None => b"None".to_vec(),
Some(o) => o.as_name()?.to_vec(),
};
if !matches!(end.as_slice(), b"None" | b"OpenArrow" | b"ClosedArrow") {
return None;
}
let lw = bw.max(0.5);
let pts: Vec<(f64, f64)> = cl.chunks_exact(2).map(|p| (p[0], p[1])).collect();
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 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])));
}
@@ -415,7 +538,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
res.set(b"Font".to_vec(), Object::Dict(fonts));
return Some(form(rect, &out, res));
return Some(form(full, &out, res));
}
_ => return None,
}
+144 -5
View File
@@ -333,6 +333,28 @@ pub enum Shape {
quads: Vec<[f64; 8]>,
overlay: String,
},
/// A closed polygon (Polygon tool); with `cloud`, Acrobat's Cloud tool: the same polygon
/// with a cloudy border (`/BE /S /C`, `/IT /PolygonCloud`).
Polygon {
vertices: Vec<[f64; 2]>,
cloud: bool,
},
/// Connected lines (Polygonal Line tool).
PolyLine {
vertices: Vec<[f64; 2]>,
},
/// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via
/// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`).
Callout {
rect: [f64; 4],
knee: [f64; 2],
point: [f64; 2],
font_size: f64,
},
/// Insert text: a caret in `rect` (its point at the top centre).
Caret {
rect: [f64; 4],
},
}
/// A rectangle `[x0 y0 x1 y1]` as a quad in Acrobat's order (top-left, top-right, bottom-left,
@@ -351,9 +373,12 @@ impl Shape {
Shape::Oval { .. } => "Circle",
Shape::Line { .. } => "Line",
Shape::Ink { .. } | Shape::Signature { .. } => "Ink",
Shape::TextBox { .. } | Shape::Typewriter { .. } => "FreeText",
Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText",
Shape::Mark { .. } | Shape::Stamp { .. } => "Stamp",
Shape::Redact { .. } => "Redact",
Shape::Polygon { .. } => "Polygon",
Shape::PolyLine { .. } => "PolyLine",
Shape::Caret { .. } => "Caret",
}
}
}
@@ -386,7 +411,11 @@ impl Style {
Shape::TextMarkup { kind: Markup::Underline, .. } => ([0.0, 0.47, 0.84], 1.0),
Shape::TextMarkup { kind: Markup::StrikeOut, .. } => ([0.89, 0.13, 0.13], 1.0),
Shape::TextMarkup { kind: Markup::Squiggly, .. } => ([0.18, 0.62, 0.36], 1.0),
Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } => ([0.89, 0.13, 0.13], 2.0),
Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Line { .. } | Shape::Polygon { .. } | Shape::PolyLine { .. } => {
([0.89, 0.13, 0.13], 2.0)
}
Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0),
Shape::Caret { .. } => ([0.0, 0.47, 0.84], 1.0),
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),
@@ -603,6 +632,33 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
}
grow(bounds(strokes.iter().flatten().copied()).unwrap_or_default(), half + 1.0)
}
Shape::Polygon { vertices, cloud } => {
let b = bounds(vertices.iter().copied())
.filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0);
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 } => {
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)
}
Shape::Callout { rect, knee, point, .. } => {
let r = normalize(*rect);
if !finite(rect) || !finite(knee) || !finite(point) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 {
return Err(bad("callout"));
}
let pad = half + appearance::arrow_size(style.width) + 1.0;
let lead = grow(bounds([*knee, *point].into_iter()).unwrap_or_default(), pad);
[r[0].min(lead[0]), r[1].min(lead[1]), r[2].max(lead[2]), r[3].max(lead[3])]
}
Shape::Caret { rect } => {
let r = normalize(*rect);
if !finite(rect) || r[2] - r[0] < 1.0 || r[3] - r[1] < 1.0 {
return Err(bad("caret"));
}
r
}
};
Ok(r)
}
@@ -651,6 +707,26 @@ fn subject(shape: &Shape) -> &'static str {
StampGroup::SignHere => "Sign Here",
StampGroup::StandardBusiness => "Stamp",
},
Shape::Polygon { cloud: true, .. } => "Cloud",
Shape::Polygon { .. } => "Polygon",
Shape::PolyLine { .. } => "Polygonal Line",
Shape::Callout { .. } => "Callout",
Shape::Caret { .. } => "Inserted Text",
}
}
/// Where a callout's leader line meets its text box: the middle of the side facing `knee`.
pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] {
let [x0, y0, x1, y1] = normalize(rect);
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
if knee[0] < x0 {
[x0, cy]
} else if knee[0] > x1 {
[x1, cy]
} else if knee[1] > y1 {
[cx, y1]
} else {
[cx, y0]
}
}
@@ -733,7 +809,43 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
d.set(b"InkList".to_vec(), Object::Array(list));
border(&mut d);
}
Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } => {
Shape::Polygon { vertices, cloud } => {
d.set(b"C".to_vec(), rgb(style.color));
if let Some(f) = style.fill {
d.set(b"IC".to_vec(), rgb(f));
}
d.set(b"Vertices".to_vec(), num_array(&vertices.concat()));
if *cloud {
d.set(b"IT".to_vec(), Object::name("PolygonCloud"));
let mut be = Dict::new();
be.set(b"S".to_vec(), Object::name("C"));
be.set(b"I".to_vec(), Object::Int(1));
d.set(b"BE".to_vec(), Object::Dict(be));
}
border(&mut d);
}
Shape::PolyLine { vertices } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Vertices".to_vec(), num_array(&vertices.concat()));
border(&mut d);
}
Shape::Caret { .. } => {
d.set(b"C".to_vec(), rgb(style.color));
d.set(b"Sy".to_vec(), Object::name("None"));
}
Shape::Callout { rect: tb, knee, point, .. } => {
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"));
// 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]]));
}
_ => {}
}
match &new.shape {
Shape::TextBox { font_size, .. } | Shape::Typewriter { font_size, .. } | Shape::Callout { font_size, .. } => {
if matches!(new.shape, Shape::Typewriter { .. }) {
d.set(b"IT".to_vec(), Object::name("FreeTextTypeWriter"));
}
@@ -746,6 +858,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
}
border(&mut d);
}
_ => {}
}
let r = doc.add(Object::Dict(d.clone()));
set_appearance(doc, r)?;
@@ -1015,14 +1128,38 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
}
// A callout's rectangle is its text box: the leader line re-attaches and `/Rect` grows to hold it.
let callout = d.get(b"CL").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect::<Vec<f64>>()).filter(|l| l.len() == 6);
let (outer, cl) = match callout {
Some(mut l) => {
let attach = callout_attach(rect, [l[2], l[3]]);
(l[4], l[5]) = (attach[0], attach[1]);
let pad = border_width_of(&d) / 2.0 + appearance::arrow_size(border_width_of(&d)) + 1.0;
let lead = grow(bounds([[l[0], l[1]], [l[2], l[3]]].into_iter()).unwrap_or_default(), pad);
([rect[0].min(lead[0]), rect[1].min(lead[1]), rect[2].max(lead[2]), rect[3].max(lead[3])], Some(l))
}
None => (rect, None),
};
doc.update_dict(r, |d| {
d.set(b"Rect".to_vec(), num_array(&rect));
d.remove(b"RD");
d.set(b"Rect".to_vec(), num_array(&outer));
match &cl {
Some(l) => {
d.set(b"CL".to_vec(), num_array(l));
d.set(b"RD".to_vec(), num_array(&[rect[0] - outer[0], rect[1] - outer[1], outer[2] - rect[2], outer[3] - rect[3]]));
}
None => {
d.remove(b"RD");
}
}
touch(d, meta);
})?;
set_appearance(doc, r)
}
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.
pub fn set_style(
doc: &mut Document,
@@ -1095,6 +1232,7 @@ pub struct Summary {
pub state: Option<String>,
pub quads: Vec<[f32; 8]>,
pub locked: bool,
pub intent: Option<String>,
}
fn text_value(doc: &Document, d: &Dict, key: &[u8]) -> Option<String> {
@@ -1157,6 +1295,7 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
state: text_value(doc, d, b"State"),
quads,
locked: d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & FLAG_LOCKED != 0,
intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())),
});
}
}
+96
View File
@@ -408,3 +408,99 @@ fn urls_are_found_in_text() {
let found: Vec<String> = crate::links::find_urls(&t).into_iter().map(|(_, u)| u).collect();
assert_eq!(found, ["https://example.org/a?b=1", "http://www.rust-lang.org"]);
}
#[test]
fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() {
let mut doc = fixture();
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 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 }),
&meta("co"),
)
.unwrap();
let k = add_annotation(&mut doc, &new(0, Shape::Caret { rect: [50.0, 600.0, 60.0, 612.0] }), &meta("k")).unwrap();
let doc = reopen(&doc);
let a = list(&doc, 0);
let p = &a[i];
assert_eq!(p.name(b"Subtype"), Some(&b"Polygon"[..]));
assert_eq!(text(p, b"Subj"), "Polygon");
let ap = ap_content(&doc, p);
assert!(ap.contains("100 100 m") && ap.contains("150 180 l") && ap.contains("h\nS"), "{ap}");
let r = rect(p);
assert!(r[0] <= 99.0 && r[3] >= 181.0, "{r:?}");
let cl = &a[c];
assert_eq!((text(cl, b"Subj").as_str(), cl.name(b"IT")), ("Cloud", Some(&b"PolygonCloud"[..])));
let ap = ap_content(&doc, cl);
assert!(ap.matches(" c\n").count() >= 6, "bumps along every edge: {ap}");
// Bumps bulge outwards: the outline reaches below the bottom edge.
let r = rect(cl);
assert!(r[1] < 100.0 - appearance::cloud_radius(2.0), "{r:?}");
let pl = &a[l];
assert_eq!((pl.name(b"Subtype"), text(pl, b"Subj").as_str()), (Some(&b"PolyLine"[..]), "Polygonal Line"));
let ap = ap_content(&doc, pl);
assert!(ap.contains("400 300 l") && !ap.contains("h\n"), "open path: {ap}");
let callout = &a[co];
assert_eq!(callout.name(b"IT"), Some(&b"FreeTextCallout"[..]));
let line: Vec<f64> = callout.get(b"CL").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(line, vec![220.0, 460.0, 260.0, 520.0, 300.0, 520.0], "attached to the box's left side");
let r = rect(callout);
assert!(r[0] < 220.0 && r[1] < 460.0 && r[2] >= 420.0 && r[3] >= 540.0, "{r:?}");
let ap = ap_content(&doc, callout);
assert!(ap.contains("220 460 m") && ap.contains("(hello) Tj"), "{ap}");
let caret = &a[k];
assert_eq!((caret.name(b"Subtype"), text(caret, b"Subj").as_str()), (Some(&b"Caret"[..]), "Inserted Text"));
assert!(ap_content(&doc, caret).contains(" c h f"));
// Resizing a callout moves its text box; the leader re-attaches.
let mut doc = doc;
set_rect(&mut doc, 0, co, [100.0, 500.0, 200.0, 540.0], &meta("x")).unwrap();
let callout = &list(&doc, 0)[co];
let line: Vec<f64> = callout.get(b"CL").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(&line[4..], &[200.0, 520.0], "now the right side");
assert!(ap_content(&doc, callout).contains("(hello) Tj"));
// Moving shifts vertices.
move_annotation(&mut doc, 0, i, 10.0, 0.0, &meta("m")).unwrap();
let v: Vec<f64> = list(&doc, 0)[i].get(b"Vertices").unwrap().as_array().unwrap().iter().map(|o| o.as_f64().unwrap()).collect();
assert_eq!(v[0], 110.0);
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::Caret { rect: [0.0, 0.0, 0.0, 10.0] },
] {
assert!(add_annotation(&mut doc, &new(0, bad), &meta("b")).is_err());
}
}
#[test]
fn cloud_paths_bulge_outwards_either_way_round() {
let ccw = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)];
let cw: Vec<(f64, f64)> = ccw.iter().rev().copied().collect();
for pts in [ccw.to_vec(), cw] {
let p = cloud_ys(&appearance::cloud_path(&pts, 5.0));
assert!(p.iter().any(|y| *y < -3.0) && p.iter().any(|y| *y > 103.0), "{p:?}");
assert!(p.iter().all(|y| *y > -10.0 && *y < 110.0));
}
}
/// Every y coordinate of the curve control points in a cloud path.
fn cloud_ys(path: &str) -> Vec<f64> {
path.lines()
.filter(|l| l.ends_with(" c"))
.flat_map(|l| {
let v: Vec<f64> = l.split_whitespace().filter_map(|t| t.parse().ok()).collect();
[v[1], v[3]]
})
.collect()
}
+40
View File
@@ -235,6 +235,46 @@ impl Automation {
.collect::<Result<Vec<_>>>()?;
Shape::Ink { strokes }
}
"polygon" | "cloud" | "polyline" => {
let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points"));
let pts = a.get("points").and_then(|p| p.as_array()).ok_or_else(wrong)?;
let vertices = pts
.iter()
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
_ => Err(wrong()),
})
.collect::<Result<Vec<_>>>()?;
match kind {
"polyline" => Shape::PolyLine { vertices },
_ => Shape::Polygon { vertices, cloud: kind == "cloud" },
}
}
"callout" => {
let rect = rect_to_user(&info, a.need::<4>("rect", "a callout (its text box)")?);
let t = a.need::<2>("to", "a callout (the point it points at)")?;
let point = to_user(&info, t[0], t[1]);
let knee = match a.nums::<2>("knee")? {
Some(k) => to_user(&info, k[0], k[1]),
// Halfway between the point and the box, level with the box's middle.
None => {
let mid = (rect[1] + rect[3]) / 2.0;
let side = if point[0] < rect[0] {
rect[0]
} else if point[0] > rect[2] {
rect[2]
} else {
(rect[0] + rect[2]) / 2.0
};
[(point[0] + side) / 2.0, mid]
}
};
Shape::Callout { rect, knee, point, font_size: a.opt_num("font_size")?.unwrap_or(10.0) }
}
"caret" => {
let [x, y] = a.need::<2>("at", "a caret (the insertion point on the baseline)")?;
Shape::Caret { rect: rect_to_user(&info, [x - 4.0, y, x + 4.0, y + 8.0]) }
}
other => return Err(ToolError::InvalidArgs(format!("unknown comment type {other:?}"))),
}
};
+5 -2
View File
@@ -451,13 +451,16 @@ 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], …], …]. Undoable.",
rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. 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. Undoable.",
)
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp"] },
"type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret"] },
"points": { "type": "array", "items": point(), "minItems": 2 },
"knee": point(),
"contents": { "type": "string", "description": "The comment text (what a text box shows)." },
"author": { "type": "string" },
"at": point(),
+33
View File
@@ -906,3 +906,36 @@ fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
assert!(found["count"].as_u64().unwrap() >= 1, "{found}");
assert!(matches!(a.call("comments_summarize", &json!({ "doc": doc, "sort": "colour" })), Err(ToolError::InvalidArgs(_))));
}
#[test]
fn drawing_comments_through_tools() {
let dir = workdir("drawing");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for (ty, extra) in [
("polygon", json!({ "points": [[20, 20], [80, 20], [50, 70]] })),
("cloud", json!({ "points": [[100, 20], [180, 20], [180, 80], [100, 80]], "color": "red" })),
("polyline", json!({ "points": [[20, 120], [60, 100], [100, 120]] })),
("callout", json!({ "rect": [110, 200, 190, 240], "to": [40, 160], "contents": "Look" })),
("caret", json!({ "at": [60, 150], "contents": "insert this" })),
] {
let mut args = json!({ "doc": doc, "page": 1, "type": ty });
args.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
ok(&mut a, "comment_add", args);
}
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
let types: Vec<&str> = list["comments"].as_array().unwrap().iter().map(|c| c["type"].as_str().unwrap()).collect();
for t in ["Polygon", "PolyLine", "FreeText", "Caret"] {
assert!(types.contains(&t), "{types:?}");
}
assert_eq!(types.iter().filter(|t| **t == "Polygon").count(), 2);
let callout = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap();
let r: Vec<f64> = callout["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!(r[0] < 40.0 && r[3] > 159.0, "the rect holds the leader line: {r:?}");
assert!(matches!(
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "polygon", "points": [[1, 1]] })),
Err(ToolError::Failed(_) | ToolError::InvalidArgs(_))
));
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
assert!(matches!(png[0], Content::Png { .. }));
}
+5
View File
@@ -175,6 +175,11 @@ pub const COMMANDS: &[CommandSpec] = &[
c("comment.arrow", "Draw an arrow", None, None, Annotate, "move-right"),
c("comment.square", "Draw a rectangle", None, None, Annotate, "square"),
c("comment.circle", "Draw an oval", None, None, Annotate, "circle"),
c("comment.polygon", "Draw a polygon", None, None, Annotate, "pentagon"),
c("comment.polyline", "Draw connected lines", None, None, Annotate, "spline"),
c("comment.cloud", "Draw a cloud", None, None, Annotate, "cloud"),
c("comment.callout", "Add a callout", None, None, Annotate, "message-square-quote"),
c("comment.caret", "Insert text", None, None, Annotate, "text-cursor-input"),
c("form.fields", "Form fields panel", VIEW, None, Document, "list"),
c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"),
c("comment.flatten", "Flatten comments", None, None, HasComments, "layers"),
+18 -2
View File
@@ -783,6 +783,11 @@ fn annotation_noun(s: &Shape) -> &'static str {
Shape::Signature { .. } => "signature",
Shape::Redact { .. } => "redaction mark",
Shape::Stamp { .. } => "stamp",
Shape::Polygon { cloud: true, .. } => "cloud",
Shape::Polygon { .. } => "polygon",
Shape::PolyLine { .. } => "connected lines",
Shape::Callout { .. } => "callout",
Shape::Caret { .. } => "inserted text",
}
}
@@ -1136,6 +1141,7 @@ fn comment_list(doc: &printcraft_cos::Document) -> Vec<printcraft_render::Annota
state: s.state,
quads: s.quads,
locked: s.locked,
intent: s.intent,
})
.collect()
}
@@ -1858,6 +1864,16 @@ pub fn comment_type_name(subtype: &str) -> &str {
}
}
/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`.
pub fn comment_kind(a: &printcraft_render::Annotation) -> &str {
match a.intent.as_deref() {
Some("FreeTextCallout") => "Callout",
Some("PolygonCloud") => "Cloud",
Some("FreeTextTypeWriter") => "Typewriter",
_ => comment_type_name(&a.subtype),
}
}
/// The text of a comment summary: one block per comment (replies indented under it), with a
/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are
/// pop-ups; the number is the comment's position on its page.
@@ -1882,7 +1898,7 @@ pub fn comment_summary(name: &str, all: &[printcraft_render::Annotation], sort:
SummarySort::Page => {}
SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)),
SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)),
SummarySort::Type => numbered.sort_by(|x, y| comment_type_name(&x.1.subtype).cmp(comment_type_name(&y.1.subtype))),
SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))),
}
let mut out = format!("Summary of Comments on {name}\n\n");
if numbered.is_empty() {
@@ -1895,7 +1911,7 @@ pub fn comment_summary(name: &str, all: &[printcraft_render::Annotation], sort:
heading = a.page;
let _ = writeln!(out, "Page: {}", a.page + 1);
}
let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_type_name(&a.subtype));
let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a));
if sort != SummarySort::Page {
let _ = write!(out, " Page: {}", a.page + 1);
}
+3
View File
@@ -138,6 +138,8 @@ pub struct Annotation {
pub quads: Vec<[f32; 8]>,
/// The Locked flag (`/F` bit 8): the comment can't be moved, resized, restyled or deleted.
pub locked: bool,
/// `/IT`, the intent: `FreeTextCallout`, `PolygonCloud`, `FreeTextTypeWriter`…
pub intent: Option<String>,
}
impl Annotation {
@@ -603,6 +605,7 @@ impl<'a> Inspector<'a> {
state: self.text(d, b"State"),
quads,
locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0,
intent: self.name(d, b"IT"),
});
}
}
+207 -12
View File
@@ -35,16 +35,35 @@ pub enum CommentTool {
Arrow,
Rectangle,
Oval,
Polygon,
PolyLine,
Cloud,
Callout,
Caret,
}
/// The quick-bar flyout groups, in Acrobat's order: Comment ▸, Highlight ▸, Draw ▸.
pub const GROUPS: [&[CommentTool]; 3] = [
&[CommentTool::Note, CommentTool::TextBox],
&[CommentTool::Highlight, CommentTool::Underline, CommentTool::StrikeOut],
&[CommentTool::Ink, CommentTool::Line, CommentTool::Arrow, CommentTool::Rectangle, CommentTool::Oval],
&[CommentTool::Note, CommentTool::TextBox, CommentTool::Callout],
&[CommentTool::Highlight, CommentTool::Underline, CommentTool::StrikeOut, CommentTool::Caret],
&[
CommentTool::Ink,
CommentTool::Line,
CommentTool::Arrow,
CommentTool::Rectangle,
CommentTool::Oval,
CommentTool::PolyLine,
CommentTool::Polygon,
CommentTool::Cloud,
],
];
pub const ALL: [CommentTool; 10] = [
pub const ALL: [CommentTool; 15] = [
CommentTool::Polygon,
CommentTool::PolyLine,
CommentTool::Cloud,
CommentTool::Callout,
CommentTool::Caret,
CommentTool::Note,
CommentTool::TextBox,
CommentTool::Highlight,
@@ -70,6 +89,11 @@ impl CommentTool {
Self::Arrow => "comment.arrow",
Self::Rectangle => "comment.square",
Self::Oval => "comment.circle",
Self::Polygon => "comment.polygon",
Self::PolyLine => "comment.polyline",
Self::Cloud => "comment.cloud",
Self::Callout => "comment.callout",
Self::Caret => "comment.caret",
}
}
@@ -89,6 +113,11 @@ impl CommentTool {
Self::Arrow => "Arrow",
Self::Rectangle => "Rectangle",
Self::Oval => "Oval",
Self::Polygon => "Polygon",
Self::PolyLine => "Connected lines",
Self::Cloud => "Cloud",
Self::Callout => "Add a callout",
Self::Caret => "Insert text",
}
}
@@ -104,6 +133,11 @@ impl CommentTool {
Self::Arrow => "move-right",
Self::Rectangle => "square",
Self::Oval => "circle",
Self::Polygon => "pentagon",
Self::PolyLine => "spline",
Self::Cloud => "cloud",
Self::Callout => "message-square-quote",
Self::Caret => "text-cursor-input",
}
}
@@ -125,9 +159,14 @@ impl CommentTool {
matches!(self, Self::Ink | Self::Line | Self::Arrow | Self::Rectangle | Self::Oval)
}
/// Tools that place points one click at a time (double-click or Enter finishes).
pub fn clicks_points(self) -> bool {
matches!(self, Self::Polygon | Self::PolyLine | Self::Cloud)
}
/// Whether the line-thickness control applies.
pub fn has_width(self) -> bool {
self.draws()
self.draws() || self.clicks_points()
}
/// A placeholder shape of this kind (for per-tool default styles).
@@ -143,6 +182,11 @@ impl CommentTool {
Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: true },
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::Caret => Shape::Caret { rect: [0.0; 4] },
}
}
}
@@ -223,7 +267,8 @@ fn login_name() -> String {
/// A drag in progress on a page.
#[derive(Clone, Debug, PartialEq)]
pub enum Gesture {
/// Drawing with a tool: points in user space (ink: the stroke; others: start and end).
/// Drawing with a tool: points in user space (ink: the stroke; polygons: the vertices
/// clicked so far; others: start and end).
Draw { page: usize, tool: CommentTool, points: Vec<[f64; 2]> },
/// Moving a comment, from the press position on screen.
Move { page: usize, index: usize, from: Pos2 },
@@ -231,10 +276,16 @@ pub enum Gesture {
Resize { page: usize, index: usize, handle: (i8, i8), from: Pos2 },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ComposerKind {
Note,
TextBox,
/// A callout whose arrow touches `point`; the composer's anchor is the text box's top-left.
Callout {
point: [f64; 2],
},
/// Insert text at the anchor (the caret's tip).
Caret,
/// Edit the text of the comment at this index on the composer's page.
Edit(usize),
}
@@ -359,8 +410,10 @@ fn is_markup(subtype: &str) -> bool {
matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly")
}
fn resizable(subtype: &str) -> bool {
matches!(subtype, "Square" | "Circle" | "FreeText")
/// Rectangles, ovals and text boxes. A callout's `/Rect` also holds its leader line, so it
/// only moves.
fn resizable(a: &Annotation) -> bool {
matches!(a.subtype.as_str(), "Square" | "Circle" | "FreeText") && a.intent.as_deref() != Some("FreeTextCallout")
}
const HANDLES: [(i8, i8); 8] = [(-1, -1), (0, -1), (1, -1), (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0)];
@@ -451,6 +504,105 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>,
}
true
}
QuickTool::Comment(tool) if tool.clicks_points() => {
if !cx.allowed {
return false;
}
if over_page {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter));
if resp.clicked()
&& over_page
&& let Some(p) = pointer
{
let u = cx.to_user(p);
match cv.gesture.as_mut() {
Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => {
// Clicking the first point again closes a polygon.
let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0;
let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0);
if closes {
let pts = std::mem::take(points);
cv.gesture = None;
if let Some(shape) = drawn_shape(tool, &pts) {
view.pending_edit = Some(new_comment(cx, tool, shape, String::new()));
}
return true;
}
if !repeat {
points.push(u);
}
}
_ => {
cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] });
cv.selected = None;
}
}
}
if finish
&& let Some(Gesture::Draw { page, tool: t, points }) = cv.gesture.clone()
&& page == cx.page
&& t == tool
{
cv.gesture = None;
if let Some(shape) = drawn_shape(tool, &points) {
view.pending_edit = Some(new_comment(cx, tool, shape, String::new()));
}
}
true
}
QuickTool::Comment(CommentTool::Callout) => {
// Drag from what the callout points at to where its text box goes.
if !cx.allowed {
return false;
}
if over_page {
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
}
if resp.drag_started()
&& pressed_here
&& let Some(o) = origin
{
cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] });
cv.selected = None;
}
if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut()
&& *page == cx.page
&& let Some(p) = pointer
{
points.truncate(1);
points.push(cx.to_user(clamp_to(page_rect, p)));
}
if resp.drag_stopped()
&& let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone()
&& page == cx.page
{
cv.gesture = None;
if let [point, at] = points[..]
&& (point[0] - at[0]).hypot(point[1] - at[1]) >= 8.0
{
cv.composer = Some(Composer { page, at, kind: ComposerKind::Callout { point }, text: String::new(), focus: true });
}
}
true
}
QuickTool::Comment(CommentTool::Caret) => {
if !cx.allowed {
return false;
}
if over_page {
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
}
if resp.clicked()
&& over_page
&& let Some(p) = pointer
{
cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind: ComposerKind::Caret, text: String::new(), focus: true });
cv.selected = None;
}
true
}
QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => {
if !cx.allowed {
return false;
@@ -491,7 +643,7 @@ fn select_input(
let selected = cv.selected.filter(|(p, _)| *p == cx.page).and_then(|(_, i)| cx.get(i));
// Handles of a selected, resizable comment.
let handle_at = |p: Pos2| -> Option<(i8, i8)> {
let a = selected.filter(|a| cx.allowed && resizable(&a.subtype))?;
let a = selected.filter(|a| cx.allowed && resizable(a))?;
let r = cx.screen_rect(a);
HANDLES.into_iter().find(|h| handle_pos(r, *h).distance(p) <= 7.0)
};
@@ -632,7 +784,7 @@ pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>
}
}
painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, SELECT_BLUE), egui::StrokeKind::Middle);
if cx.allowed && resizable(&a.subtype) {
if cx.allowed && resizable(a) {
for h in HANDLES {
let c = handle_pos(r, h);
painter.circle(c, 4.5, Color32::WHITE, Stroke::new(1.0, SELECT_BLUE));
@@ -672,6 +824,21 @@ fn paint_gesture(painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) {
let r = Rect::from_two_pos(pts[0], pts[1]);
painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, stroke));
}
CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => {
// The vertices so far, and a rubber band to the pointer.
let mut line = pts.clone();
if let Some(p) = painter.ctx().input(|i| i.pointer.hover_pos()) {
line.push(p);
}
painter.add(egui::Shape::line(line, stroke));
if let Some(first) = pts.first().filter(|_| *tool != CommentTool::PolyLine && pts.len() >= 3) {
painter.circle_stroke(*first, 5.0, Stroke::new(1.0, SELECT_BLUE));
}
}
CommentTool::Callout if pts.len() == 2 => {
painter.line_segment([pts[0], pts[1]], stroke);
painter.rect_stroke(Rect::from_min_size(pts[1], vec2(160.0, 40.0) * zoom), CornerRadius::ZERO, stroke, egui::StrokeKind::Inside);
}
_ => {}
}
}
@@ -688,6 +855,10 @@ fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option<Shape> {
CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line { from: first, to: last, arrow: tool == CommentTool::Arrow }),
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() }),
_ => None,
}
}
@@ -713,6 +884,8 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
let title = match c.kind {
ComposerKind::Note => "Sticky note",
ComposerKind::TextBox => "Text box",
ComposerKind::Callout { .. } => "Callout",
ComposerKind::Caret => "Inserted text",
ComposerKind::Edit(_) => "Edit comment",
};
let pos = pos2(anchor.x + 12.0, anchor.y);
@@ -724,7 +897,11 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
ui.label(egui::RichText::new(title).font(theme::regular(11.5)).color(t.text_faint));
});
ui.add_space(6.0);
let hint = if c.kind == ComposerKind::TextBox { "Type text" } else { "Add a comment" };
let hint = match c.kind {
ComposerKind::TextBox | ComposerKind::Callout { .. } => "Type text",
ComposerKind::Caret => "Text to insert",
_ => "Add a comment",
};
let edit =
ui.add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text"));
if c.focus {
@@ -772,6 +949,24 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
view.comments.tool_done = true;
Some(new_comment(&cx, CommentTool::TextBox, Shape::TextBox { rect: text_box_rect(c.at, &text, 12.0), font_size: 12.0 }, text))
}
ComposerKind::Callout { point } => {
view.comments.tool_done = true;
let rect = text_box_rect(c.at, &text, 10.0);
let side = if point[0] < rect[0] {
rect[0]
} else if point[0] > rect[2] {
rect[2]
} else {
(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))
}
ComposerKind::Caret => {
view.comments.tool_done = true;
let [x, y] = c.at;
Some(new_comment(&cx, CommentTool::Caret, Shape::Caret { rect: [x - 4.0, y - 8.0, x + 4.0, y] }, text))
}
}
}
+17 -7
View File
@@ -33,6 +33,16 @@ pub fn subtype_label(s: &str) -> &str {
}
}
/// The comment type as the panel names it, telling callouts and clouds apart by `/IT`.
pub fn kind_label(a: &Annotation) -> &str {
match a.intent.as_deref() {
Some("FreeTextCallout") => "Callout",
Some("PolygonCloud") => "Cloud",
Some("FreeTextTypeWriter") => "Typewriter",
_ => subtype_label(&a.subtype),
}
}
pub fn subtype_icon(s: &str) -> &'static str {
match s {
"Text" => "message-square-text",
@@ -71,7 +81,7 @@ pub fn count(info: &DocInfo) -> usize {
fn matches(a: &Annotation, q: &str) -> bool {
let q = q.to_lowercase();
[a.author.as_deref(), a.contents.as_deref(), Some(subtype_label(&a.subtype))].into_iter().flatten().any(|s| s.to_lowercase().contains(&q))
[a.author.as_deref(), a.contents.as_deref(), Some(kind_label(a))].into_iter().flatten().any(|s| s.to_lowercase().contains(&q))
}
/// Draw the panel body. Returns an edit to apply (posting, replying, deleting…).
@@ -146,7 +156,7 @@ pub(crate) fn show(
.iter()
.filter(|a| a.in_reply_to.is_none())
.filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q))))
.filter(|a| !cv.hidden_types.iter().any(|t| t == subtype_label(&a.subtype)))
.filter(|a| !cv.hidden_types.iter().any(|t| t == kind_label(a)))
.filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default()))
.filter(|a| !cv.hidden_statuses.contains(&status_of(a)))
.collect();
@@ -158,7 +168,7 @@ pub(crate) fn show(
SortBy::Page => {}
SortBy::Author => roots.sort_by_key(|a| a.author.clone().unwrap_or_default().to_lowercase()),
SortBy::Date => roots.sort_by(|a, b| b.modified.cmp(&a.modified)),
SortBy::Type => roots.sort_by_key(|a| subtype_label(&a.subtype).to_string()),
SortBy::Type => roots.sort_by_key(|a| kind_label(a).to_string()),
SortBy::Color => roots.sort_by_key(|a| color_key(a)),
}
if roots.is_empty() {
@@ -169,7 +179,7 @@ pub(crate) fn show(
match sort {
SortBy::Page => Some(format!("Page {}", info.pages.get(a.page).map(|p| p.label.as_str()).unwrap_or("?"))),
SortBy::Author => Some(a.author.clone().unwrap_or_else(|| "Unknown author".into())),
SortBy::Type => Some(subtype_label(&a.subtype).to_string()),
SortBy::Type => Some(kind_label(a).to_string()),
SortBy::Color => Some(if a.color.is_some() { format!("#{}", color_key(a)) } else { "No colour".into() }),
SortBy::Date => None,
}
@@ -279,7 +289,7 @@ fn card(
} else {
let body = a.contents.as_deref().unwrap_or("");
let text = if body.is_empty() {
egui::RichText::new(subtype_label(&a.subtype)).italics().color(t.text_faint)
egui::RichText::new(kind_label(a)).italics().color(t.text_faint)
} else {
egui::RichText::new(body).color(t.text)
};
@@ -359,7 +369,7 @@ fn card(
let click = ui
.interact(hit, ui.id().with(("card", key)), Sense::click())
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!("{} — click to show it on the page", subtype_label(&a.subtype)));
.on_hover_text(format!("{} — click to show it on the page", kind_label(a)));
if click.clicked() {
view.comments.selected = Some(key);
view.comments.reply.clear();
@@ -443,7 +453,7 @@ fn legible(c: Color32, t: &Tokens) -> Color32 {
pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocView, hidden: bool) -> Option<&'static str> {
let mut command = None;
let cv = &mut view.comments;
let mut types: Vec<String> = info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| subtype_label(&a.subtype).to_string()).collect();
let mut types: Vec<String> = info.annotations.iter().filter(|a| a.in_reply_to.is_none()).map(|a| kind_label(a).to_string()).collect();
types.sort();
types.dedup();
let mut authors: Vec<String> =
+4 -2
View File
@@ -48,8 +48,6 @@ pub static ICONS: &[(&str, &[u8])] = &[
("copy", include_bytes!("../../../assets/icons/copy.svg")),
("copy-plus", include_bytes!("../../../assets/icons/copy-plus.svg")),
("crop", include_bytes!("../../../assets/icons/crop.svg")),
("flip-horizontal-2", include_bytes!("../../../assets/icons/flip-horizontal-2.svg")),
("flip-vertical-2", include_bytes!("../../../assets/icons/flip-vertical-2.svg")),
("droplet", include_bytes!("../../../assets/icons/droplet.svg")),
("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")),
("ellipsis-vertical", include_bytes!("../../../assets/icons/ellipsis-vertical.svg")),
@@ -74,6 +72,8 @@ pub static ICONS: &[(&str, &[u8])] = &[
("files", include_bytes!("../../../assets/icons/files.svg")),
("film", include_bytes!("../../../assets/icons/film.svg")),
("filter", include_bytes!("../../../assets/icons/filter.svg")),
("flip-horizontal-2", include_bytes!("../../../assets/icons/flip-horizontal-2.svg")),
("flip-vertical-2", include_bytes!("../../../assets/icons/flip-vertical-2.svg")),
("folder", include_bytes!("../../../assets/icons/folder.svg")),
("folder-open", include_bytes!("../../../assets/icons/folder-open.svg")),
("folder-plus", include_bytes!("../../../assets/icons/folder-plus.svg")),
@@ -106,6 +106,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("message-circle-reply", include_bytes!("../../../assets/icons/message-circle-reply.svg")),
("message-square", include_bytes!("../../../assets/icons/message-square.svg")),
("message-square-plus", include_bytes!("../../../assets/icons/message-square-plus.svg")),
("message-square-quote", include_bytes!("../../../assets/icons/message-square-quote.svg")),
("message-square-text", include_bytes!("../../../assets/icons/message-square-text.svg")),
("messages-square", include_bytes!("../../../assets/icons/messages-square.svg")),
("mic", include_bytes!("../../../assets/icons/mic.svg")),
@@ -125,6 +126,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("pen-line", include_bytes!("../../../assets/icons/pen-line.svg")),
("pen-tool", include_bytes!("../../../assets/icons/pen-tool.svg")),
("pencil", include_bytes!("../../../assets/icons/pencil.svg")),
("pentagon", include_bytes!("../../../assets/icons/pentagon.svg")),
("pin", include_bytes!("../../../assets/icons/pin.svg")),
("pipette", include_bytes!("../../../assets/icons/pipette.svg")),
("plus", include_bytes!("../../../assets/icons/plus.svg")),
+4 -2
View File
@@ -172,11 +172,13 @@ impl PrintCraftApp {
}
},
None => {
let stem = name.trim_end_matches(".pdf").trim_end_matches(".PDF");
let path = match self.save_override.clone() {
Some(p) => Some(std::path::PathBuf::from(p)),
#[cfg(not(target_arch = "wasm32"))]
None => rfd::FileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf")).save_file(),
None => {
let stem = name.trim_end_matches(".pdf").trim_end_matches(".PDF");
rfd::FileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem} (print).pdf")).save_file()
}
#[cfg(target_arch = "wasm32")]
None => None,
};
+54
View File
@@ -334,3 +334,57 @@ fn comments_take_checkmarks_lock_hide_and_summarize() {
assert_eq!(s.views.len(), 2);
assert!(s.session.get(s.views[1].id).unwrap().name.starts_with("Summary of comments on text"));
}
#[test]
fn polygons_clouds_connected_lines_callouts_and_inserted_text() {
let mut h = harness(|app| app.set_option("quick", "polygon").unwrap());
// Polygon: click the corners, Enter finishes.
for p in [(40.0, 40.0), (120.0, 40.0), (80.0, 110.0)] {
click_pt(&mut h, p);
}
assert_eq!(comments(&h).len(), 0, "still drawing");
h.key_press(egui::Key::Enter);
h.run_steps(3);
let c = comments(&h);
assert_eq!(c.len(), 1);
assert_eq!(c[0].subtype, "Polygon");
// Cloud: clicking the first point again closes it.
h.state_mut().set_option("quick", "cloud").unwrap();
for p in [(160.0, 40.0), (260.0, 40.0), (260.0, 100.0), (160.0, 100.0), (160.0, 40.0)] {
click_pt(&mut h, p);
}
assert_eq!(comments(&h).len(), 2);
// Connected lines.
h.state_mut().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);
}
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert!(comments(&h).iter().any(|a| a.subtype == "PolyLine"));
// Callout: drag from the target to where the box goes, then type.
h.state_mut().set_option("quick", "callout").unwrap();
drag_pt(&mut h, (60.0, 160.0), (180.0, 190.0));
assert!(h.state().views[0].comments.composer.is_some(), "the composer opened");
field(&h, "Type text").type_text("Why?");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let callout = comments(&h).into_iter().find(|a| a.subtype == "FreeText").expect("a callout");
assert_eq!(callout.contents.as_deref(), Some("Why?"));
assert!(callout.rect[0] <= 60.0 && callout.rect[1] <= 160.0, "holds the leader: {:?}", callout.rect);
// Insert text at a click.
h.state_mut().set_option("quick", "caret").unwrap();
click_pt(&mut h, (95.0, 150.0));
field(&h, "Text to insert").type_text("very ");
h.run_steps(2);
h.get_by_label("Post").click();
h.run_steps(3);
let caret = comments(&h).into_iter().find(|a| a.subtype == "Caret").expect("a caret");
assert_eq!(caret.contents.as_deref(), Some("very"));
assert_eq!(h.state().quick_tool, QuickTool::Select);
}
+24 -24
View File
@@ -3527,11 +3527,11 @@ title = "Insert-text proposal (caret)"
area = "E"
tier = "P1"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["comment.caret"]
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
notes = "caret at a click; not yet snapped to the text insertion point"
[[feature]]
id = "comment.typewriter"
@@ -3563,11 +3563,11 @@ title = "Callout comment"
area = "E"
tier = "P1"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["comment.callout"]
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
notes = "drag from the target to the box; resizing re-attaches the leader"
[[feature]]
id = "comment.pencil"
@@ -3647,11 +3647,11 @@ title = "Polygon"
area = "E"
tier = "P1"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["comment.polygon"]
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
notes = "click the corners; Enter, double-click or the first point finishes"
[[feature]]
id = "comment.polyline"
@@ -3659,10 +3659,10 @@ title = "Connected lines (polyline)"
area = "E"
tier = "P1"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
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/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
notes = ""
[[feature]]
@@ -3671,11 +3671,11 @@ title = "Cloud shape"
area = "E"
tier = "P1"
milestone = "M5"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "shipped"
commands = ["comment.cloud"]
tools = ["comment_add"]
evidence = ["crates/annot/src/tests.rs::polygons_clouds_connected_lines_callouts_and_carets_are_drawn", "crates/annot/src/tests.rs::cloud_paths_bulge_outwards_either_way_round", "crates/ui-egui/tests/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/automation.rs::drawing_comments_through_tools"]
notes = "polygon with a cloudy border (/BE /S /C, /IT /PolygonCloud)"
[[feature]]
id = "comment.area-highlight"