M5: draw all ten PDF line-ending styles (#520)

* M5: draw all ten PDF line-ending styles

Imported lines, polylines and callouts kept Square, Circle, Diamond, Butt, Slash and reverse arrows in /LE but would not redraw them. Those styles now draw, and comment_add and Comment properties can set them.

* hu, uk: Line ending

---------

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
Márk BartalosandBrandon Thomas authored and GitHub committed 2026-10-09 13:15:44 -04:00
1 parent c9d0619226
commit 3af93c7585
25 files changed
+613 -87

No files matched your search

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