mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
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:
1 parent
18b0532e61
commit
7616535dd1
19 files changed
+856
-79
No files matched your search
+3
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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 |
@@ -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
@@ -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
@@ -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())),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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:?}"))),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 { .. }));
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user