M5: cloudy squares, circles and text boxes are drawn (comment.cloud) (#4195)

This commit is contained in:
mtlcl410 authored and GitHub committed 2026-11-09 18:32:44 +08:00
1 parent f4ea21e4a3
commit b8d9497b93
6 files changed
+528 -49

No files matched your search

+128 -22
View File
@@ -3,8 +3,8 @@
//! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary and the
//! objects it points at, so the same code serves new comments and restyled ones. Drawings are in page space with
//! `/BBox = /Rect` (identity matrix), except note icons, which are drawn in a 20 × 20 box.
//! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line
//! endings), so callers never replace an appearance with a worse one. It resolves `/QuadPoints`
//! It returns `None` for anything it cannot draw faithfully (unknown line endings, a cloudy
//! callout), so callers never replace an appearance with a worse one. It resolves `/QuadPoints`
//! itself; for the other values, callers that hold the document follow references first
//! (`draw::with_direct_values`).
//!
@@ -55,7 +55,9 @@ 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).
/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1). ISO 32000-2
/// Table 169 leaves the size open; this is the size PdfCraft's polygon clouds already had, so
/// squares, circles and text boxes match them.
pub fn cloud_radius(w: f64) -> f64 {
6.0 + 1.5 * w.max(0.0)
}
@@ -180,6 +182,80 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String {
/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow.
pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000;
/// A cloudy border's intensity (ISO 32000-2 §12.5.4, Table 169: `/BE /I`, "in the range 0 to 2.
/// Default value: 0"), or `None` for no cloud: no `/BE`, a style other than `/C`, or intensity 0,
/// which draws the border plain.
pub(crate) fn cloud_intensity(d: &Dict) -> Option<f64> {
let be = d.get(b"BE").and_then(|b| b.as_dict())?;
if be.name(b"S") != Some(b"C") {
return None;
}
let i = be.get(b"I").and_then(|i| i.as_f64()).unwrap_or(0.0);
(i.is_finite() && i > 0.0).then(|| i.min(2.0))
}
/// The bump radius for a cloudy border of width `w` at `intensity` (0–2].
pub(crate) fn cloud_bump(w: f64, intensity: f64) -> f64 {
cloud_radius(w) * intensity.clamp(0.5, 2.0)
}
/// How far past its outline a cloud with bumps of radius `bump` around an outline `perimeter`
/// long can reach. `cloud_path` rounds each edge to whole bumps, so one may span up to three
/// radii and bulge 1.5 radii; it also grows the radius to keep within its bump budget.
pub(crate) fn cloud_reach(perimeter: f64, bump: f64) -> f64 {
1.5 * bump.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64))
}
/// `/RD` when it is four finite, non-negative numbers (Tables 170 and 180).
fn rd_of(d: &Dict) -> Option<Vec<f64>> {
nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0))
}
/// `rect` less `rd`, whose four numbers are the left, top, right and bottom differences.
fn less_rd(rect: [f64; 4], rd: &[f64]) -> [f64; 4] {
let at = |i: usize| rd.get(i).copied().unwrap_or(0.0);
[rect[0] + at(0), rect[1] + at(3), rect[2] - at(2), rect[3] - at(1)]
}
/// The box a plain (not cloudy) square or circle is drawn in: `/Rect` less a usable `/RD` (Table
/// 180: the differences between `/Rect` and "the actual boundaries of the underlying square or
/// circle"), or `None` when there is no `/RD`, it isn't four finite non-negative numbers, or it
/// leaves less than a point either way, so the caller draws in `/Rect` as without one.
fn rect_less_rd(d: &Dict, rect: [f64; 4]) -> Option<[f64; 4]> {
let inner = less_rd(rect, &rd_of(d)?);
(inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner)
}
/// The shape a cloudy square, circle or text box draws its bumps around: `/Rect` less `/RD`
/// (Table 180: the differences between "the left, top, right, and bottom coordinates of Rect and
/// those of the square or circle, respectively"), but at least the bumps' greatest reach and half
/// the line from each side, so they stay inside `/Rect`; without a usable `/RD`, that margin.
fn cloud_inner(d: &Dict, rect: [f64; 4], bump: f64, w: f64) -> Option<[f64; 4]> {
// The cloud's reach and half the line keep it inside `/Rect`; an `/RD` leaving more is
// followed, one leaving less is not.
let perimeter = 2.0 * ((rect[2] - rect[0]) + (rect[3] - rect[1]));
let m = cloud_reach(perimeter, bump) + w / 2.0;
let safe = [rect[0] + m, rect[1] + m, rect[2] - m, rect[3] - m];
let inner = match rd_of(d) {
Some(rd) => {
let r = less_rd(rect, &rd);
[r[0].max(safe[0]), r[1].max(safe[1]), r[2].min(safe[2]), r[3].min(safe[3])]
}
None => safe,
};
(inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner)
}
/// Points around the ellipse inscribed in `r`, counter-clockwise, one per bump of radius `bump`
/// (each edge of the outline becomes one bump), for a cloudy circle.
fn ellipse_points(r: [f64; 4], bump: f64) -> Vec<(f64, f64)> {
let (cx, cy, rx, ry) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0, (r[2] - r[0]) / 2.0, (r[3] - r[1]) / 2.0);
// Ramanujan's approximation of the perimeter; the count stays within the bump budget.
let perimeter = std::f64::consts::PI * (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).max(0.0).sqrt());
let count = (perimeter / (2.0 * bump.max(0.5))).round().clamp(8.0, MAX_CLOUD_BUMPS as f64) as u32;
(0..count).map(|i| f64::from(i) * std::f64::consts::TAU / f64::from(count)).map(|a| (cx + rx * a.cos(), cy + ry * a.sin())).collect()
}
/// 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 {
@@ -536,7 +612,8 @@ struct AppearanceContext<'a> {
dict: &'a Dict,
subtype: &'a [u8],
rect: [f64; 4],
cloudy: bool,
/// A cloudy border's intensity ([`cloud_intensity`]), or `None` for a plain one.
cloud: Option<f64>,
/// The line's opacity (`parts.line`).
opacity: f64,
parts: PartOpacity,
@@ -599,11 +676,13 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]>
return None;
}
let subtype = d.name(b"Subtype")?;
let rect = normalised_rect(d)?;
// A number too long for a double reads as infinite, and two near its limit are an infinite
// width apart: nothing is drawn rather than "inf" written into the appearance (review on #4195).
let rect = normalised_rect(d).filter(|r| r.iter().all(|x| x.is_finite()) && (r[2] - r[0]).is_finite() && (r[3] - r[1]).is_finite())?;
let rect = page.and_then(|page| point_reach(doc, d, rect, page)).unwrap_or(rect);
// 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" {
// Cloudy borders (§12.5.4, Table 169) apply to squares, circles, polygons and text boxes.
let cloud = cloud_intensity(d);
if cloud.is_some() && !matches!(subtype, b"Square" | b"Circle" | b"Polygon" | b"FreeText") {
return None;
}
Some(AppearanceContext {
@@ -611,7 +690,7 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]>
dict: d,
subtype,
rect,
cloudy,
cloud,
opacity: part_opacity(d).line,
parts: part_opacity(d),
stroke: color(d, b"C")?,
@@ -698,14 +777,18 @@ fn drawn_points(doc: &Document, d: &Dict) -> Option<(Vec<f64>, f64)> {
if count < 4 || count % 2 != 0 || count / 2 > MAX_REACH_POINTS {
return None;
}
let cloudy = d.get(b"BE").and_then(Object::as_dict).is_some_and(|b| b.name(b"S") == Some(b"C"));
// A cloud's bumps are drawn at up to twice their nominal radius (`draw_polygon`).
let reach = match subtype {
b"Polygon" if cloudy => 2.0 * cloud_radius(w),
b"PolyLine" if has_ending() => arrow_size(w),
let vertices = nums(d, b"Vertices")?;
// A cloud's bumps (`draw_polygon`) reach as far past the outline as `cloud_reach` says.
let reach = match (subtype, cloud_intensity(d)) {
(b"Polygon", Some(intensity)) => {
let pts: Vec<(f64, f64)> = vertices.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum();
cloud_reach(if perimeter.is_finite() { perimeter } else { 0.0 }, cloud_bump(w, intensity))
}
(b"PolyLine", _) if has_ending() => arrow_size(w),
_ => 0.0,
};
(nums(d, b"Vertices")?, w / 2.0 + reach)
(vertices, w / 2.0 + reach)
}
b"Line" => {
let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"L")?).ok()?;
@@ -921,15 +1004,27 @@ fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) ->
if stroke.is_none() && fill.is_none() {
return Some(());
}
// A plain square or circle lies within /Rect less /RD (Table 180), its stroke centred
// half the width inside that box.
let shape = rect_less_rd(d, rect).unwrap_or(rect);
let inset = w / 2.0;
let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset];
let [x0, y0, x1, y1] = [shape[0] + inset, shape[1] + inset, shape[2] - inset, shape[3] - inset];
if let Some(f) = fill {
c.push_str(&rg(f));
}
if let Some(s) = stroke {
c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d)));
}
if subtype == b"Square" {
if let Some(intensity) = context.cloud {
let bump = cloud_bump(w, intensity);
let inner = cloud_inner(d, rect, bump, w)?;
let pts = if subtype == b"Square" {
vec![(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])]
} else {
ellipse_points(inner, bump)
};
c.push_str(&format!("1 j\n{}", cloud_path(&pts, bump)));
} else if subtype == b"Square" {
c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0)));
} else {
c.push_str(&ellipse(x0, y0, x1, y1));
@@ -990,7 +1085,6 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing
let subtype = context.subtype;
let stroke = context.stroke;
let w = context.width;
let cloudy = context.cloudy;
let c = &mut drawing.content;
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();
@@ -1018,9 +1112,8 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing
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)));
if let Some(intensity) = context.cloud {
c.push_str(&cloud_path(&pts, cloud_bump(w, intensity)));
} 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" }));
@@ -1332,7 +1425,20 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
// `/RD` and keeps its place: background and border take the shape, the text is laid out
// in the text box as in a rectangle.
let shape = crate::BorderShape::of(d);
if shape != crate::BorderShape::Rectangle {
// A cloudy box (not a callout): its background and border follow the cloud around
// the text, which keeps to the cloud's inner rectangle.
if let Some(intensity) = context.cloud.filter(|_| !d.contains(b"CL")) {
let cw = if bw > 0.0 { bw } else { 1.0 };
let bump = cloud_bump(cw, intensity);
let inner = cloud_inner(d, rect, bump, cw)?;
let pts = [(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])];
c.push_str(&format!("{}{}{} w 1 j\n{}", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(cw), dash(d)));
c.push_str(&cloud_path(&pts, bump));
c.push_str(if bg.is_some() { "B\n[] 0 d\n" } else { "S\n[] 0 d\n" });
rect = inner;
} else if context.cloud.is_some() {
return None;
} else if shape != crate::BorderShape::Rectangle {
rect = shaped_border(d, shape, full, turn, bw, bg, line_color, &mut c)?;
} else {
if let Some(bg) = bg {
+15 -6
View File
@@ -103,12 +103,8 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr
}
grow(ink_bounds(strokes).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 })
}
// A new cloud has intensity 1 (`/BE /I 1`).
Shape::Polygon { vertices, cloud } => polygon_rect(vertices, style.width, cloud.then_some(1.0))?,
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);
@@ -135,6 +131,19 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr
Ok(r)
}
/// Validate a polygon's corners and compute its `/Rect`: room for half the line, and with a
/// cloud of `cloud` intensity, for the farthest its bumps reach (`appearance::cloud_reach`).
pub(crate) fn polygon_rect(vertices: &[[f64; 2]], width: f64, cloud: Option<f64>) -> Result<[f64; 4], AnnotError> {
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(|| AnnotError::Invalid("invalid polygon (it needs three points)".into()))?;
let reach = cloud.map_or(0.0, |intensity| {
let perimeter: f64 = vertices.iter().zip(vertices.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum();
appearance::cloud_reach(perimeter, appearance::cloud_bump(width, intensity))
});
Ok(grow(b, width.max(0.0) / 2.0 + 1.0 + reach))
}
pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("Annot"));
+11 -11
View File
@@ -1,5 +1,6 @@
use super::create::{
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb,
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, polygon_rect,
rect_for, rgb,
};
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
use super::info::{line_endings_of, text_value};
@@ -625,22 +626,21 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64;
if points.len() > MAX_POINTS {
return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points")));
}
let style = Style { width: border_width_of(&d), ..Style::default() };
// The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's.
let style = Style { width: appearance::border_width(&d), ..Style::default() };
let ends = line_endings_of(&d).unwrap_or_default();
let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None));
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) {
("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"),
// Each checks the points too: finite, and far enough apart to draw.
let (rect, key): ([f64; 4], &[u8]) = match (subtype.as_str(), points) {
("Line", [from, to]) => (rect_for(&Shape::Line { from: *from, to: *to, start, end }, &style)?, b"L"),
("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())),
("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"),
("Polygon", _) => {
let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C"));
(Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices")
}
("PolyLine", _) => (rect_for(&Shape::PolyLine { vertices: points.to_vec(), start, end }, &style)?, b"Vertices"),
// Room for the cloud as the appearance draws it: `/BE /S /C` at its own intensity
// (Table 169), which may reach further than a new cloud's intensity 1.
("Polygon", _) => (polygon_rect(points, style.width, appearance::cloud_intensity(&d))?, b"Vertices"),
(other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))),
};
// Checks the points too: finite, and far enough apart to draw.
let rect = rect_for(&shape, &style)?;
let before = d;
doc.update_dict(r, |d| {
d.set(key.to_vec(), num_array(&points.concat()));
+364 -3
View File
@@ -3166,6 +3166,366 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() {
assert!(!long.contains(&"w".repeat(257)));
}
/// A comment's built appearance for the dictionary `body` (no `/AP`), as text, or `None` when
/// PdfCraft can't draw it.
fn built(body: &str) -> Option<String> {
let doc = document_of(&[
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>",
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
body.as_bytes(),
]);
let d = list(&doc, 0).into_iter().next().unwrap();
crate::appearance::build(&doc, &d).map(|s| String::from_utf8_lossy(&s.decoded().unwrap()).into_owned())
}
/// The x and y range a drawing's path covers: its points, and each curve's middle (a curve's
/// control points lie outside it, so they aren't counted).
fn extent(ops: &str) -> [f64; 4] {
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
let mut at = (0.0, 0.0);
let add = |p: (f64, f64), b: &mut [f64; 4]| *b = [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)];
for line in ops.lines() {
let t: Vec<&str> = line.split_whitespace().collect();
let Some((op, nums)) = t.split_last() else { continue };
let v: Vec<f64> = nums.iter().filter_map(|n| n.parse().ok()).collect();
match (*op, v.as_slice()) {
("m" | "l", [x, y]) => {
at = (*x, *y);
add(at, &mut b);
}
("c", [x1, y1, x2, y2, x3, y3]) => {
let mid = ((at.0 + 3.0 * x1 + 3.0 * x2 + x3) / 8.0, (at.1 + 3.0 * y1 + 3.0 * y2 + y3) / 8.0);
add(mid, &mut b);
at = (*x3, *y3);
add(at, &mut b);
}
_ => {}
}
}
b
}
/// ISO 32000-2 §12.5.4, Table 169: a cloudy border (`/BE /S /C`) applies to squares, circles,
/// polygons and (PDF 1.6) text boxes, honours the `/BS` width and dashes, and its intensity
/// `/I` defaults to 0. Squares, circles and text boxes without an appearance were not drawn at
/// all, so a reviewer's marks vanished; intensity 0 (or none) drew a cloud on polygons.
#[test]
fn cloudy_borders_are_drawn_on_squares_circles_polygons_and_text_boxes() {
let base = "/Type /Annot /Rect [100 100 300 250] /C [1 0 0] /BS << /W 2 >>";
for kind in ["Square", "Circle"] {
let ops = built(&format!("<< {base} /Subtype /{kind} /BE << /S /C /I 1 >> >>")).unwrap_or_else(|| panic!("{kind} is drawn"));
assert!(ops.matches(" c\n").count() > 8 && !ops.contains(" re\n"), "{kind} as bumps: {ops}");
let e = extent(&ops);
assert!(e[0] >= 100.0 && e[1] >= 100.0 && e[2] <= 300.0 && e[3] <= 250.0, "{kind} stays in /Rect: {e:?}");
}
let dashed = built(&format!("<< {base} /Subtype /Square /BS << /W 2 /S /D /D [6 3] >> /BE << /S /C /I 1 >> >>")).unwrap();
assert!(dashed.contains("[6 3] 0 d") && dashed.contains(" c\n"), "dashes honoured: {dashed}");
let text = built(&format!("<< {base} /Subtype /FreeText /DA (0 0 1 rg /Helv 10 Tf) /Contents (Look here) /BE << /S /C /I 1 >> >>")).unwrap();
assert!(text.contains(" c\n") && text.contains("(Look here) Tj"), "a cloud around the text: {text}");
let dashed_text = built(
"<< /Type /Annot /Rect [100 100 300 250] /C [1 0 0] /BS << /W 2 /S /D /D [4 2] >> /Subtype /FreeText /DA (0 g /Helv 10 Tf) /Contents (x) /BE << /S /C /I 1 >> >>",
)
.unwrap();
assert!(dashed_text.contains("[4 2] 0 d"), "a text box's cloud is dashed too: {dashed_text}");
// Intensity 0, or none: a plain border (the default is 0).
for be in ["<< /S /C /I 0 >>", "<< /S /C >>", "<< /S /S /I 2 >>"] {
let square = built(&format!("<< {base} /Subtype /Square /BE {be} >>")).unwrap();
assert!(square.contains(" re\n") && !square.contains(" c\n"), "{be}: {square}");
let polygon =
built(&format!("<< /Type /Annot /Subtype /Polygon /Rect [0 0 100 100] /C [1 0 0] /Vertices [10 10 90 10 90 90 10 90] /BE {be} >>"))
.unwrap();
assert!(!polygon.contains(" c\n"), "{be}: polygon edges stay straight: {polygon}");
}
let cloudy_polygon =
built("<< /Type /Annot /Subtype /Polygon /Rect [0 0 100 100] /C [1 0 0] /Vertices [10 10 90 10 90 90 10 90] /BE << /S /C /I 1 >> >>")
.unwrap();
assert!(cloudy_polygon.contains(" c\n"));
// A cloudy callout is still left to its own appearance.
assert!(
built(&format!("<< {base} /Subtype /FreeText /DA (0 g /Helv 10 Tf) /CL [10 10 100 100] /RD [5 5 5 5] /BE << /S /C /I 1 >> >>")).is_none()
);
}
/// Table 180: `/RD` gives the differences between "the left, top, right, and bottom coordinates
/// of Rect and those of the square or circle, respectively"; a cloudy square's bumps sit on that
/// inner square.
#[test]
fn a_cloudy_square_follows_rd_left_top_right_bottom() {
let ops = built("<< /Type /Annot /Subtype /Square /Rect [100 100 300 300] /C [1 0 0] /BS << /W 1 >> /BE << /S /C /I 1 >> /RD [20 40 30 25] >>")
.unwrap();
// The corners of the inner square are path points: left 120, bottom 125, right 270, top 260.
for corner in ["120 125", "270 125", "270 260", "120 260"] {
assert!(ops.contains(corner), "corner {corner} in {ops}");
}
}
/// Review on #4195: without `/RD`, a small cloudy square still keeps its bumps inside `/Rect`.
/// Its edges are short enough that each rounds to a single bump about three radii long, which
/// bulges further than one radius. An `/RD` leaving less than that margin is widened to it, so
/// the cloud stays inside `/Rect` too.
#[test]
fn a_small_cloudy_square_without_rd_stays_inside_its_rect() {
for side in [40.0, 48.8, 60.0, 82.0] {
let ops = built(&format!(
"<< /Type /Annot /Subtype /Square /Rect [100 100 {x} {x}] /C [1 0 0] /BS << /W 2 >> /BE << /S /C /I 1 >> >>",
x = 100.0 + side
))
.unwrap_or_else(|| panic!("side {side} is drawn"));
let e = extent(&ops);
assert!(e[0] >= 99.0 && e[1] >= 99.0 && e[2] <= 101.0 + side && e[3] <= 101.0 + side, "side {side}: {e:?}");
}
let small_rd =
built("<< /Type /Annot /Subtype /Square /Rect [100 100 200 200] /C [1 0 0] /BS << /W 2 >> /BE << /S /C /I 1 >> /RD [1 1 1 1] >>").unwrap();
let e = extent(&small_rd);
assert!(e[0] >= 99.0 && e[1] >= 99.0 && e[2] <= 201.0 && e[3] <= 201.0, "/RD [1 1 1 1] keeps the cloud inside /Rect: {e:?}");
}
/// ISO 32000-2 Table 180: `/RD` gives the differences between `/Rect` and "the actual boundaries
/// of the underlying square or circle", so a plain square or circle (no cloudy border) is drawn
/// inside `/Rect` less `/RD` (left, top, right, bottom) too, its stroke centred half the width
/// inside that box. An `/RD` that isn't four finite non-negative numbers, or leaves no box, is
/// ignored and the shape fills `/Rect` as before.
#[test]
fn plain_squares_and_circles_are_drawn_inside_rect_less_rd() {
let square = |rd: &str| built(&format!("<< /Type /Annot /Subtype /Square /Rect [100 100 200 200] /C [1 0 0] /BS << /W 3 >> {rd} >>")).unwrap();
// Rect + 15 + 1.5 on every side: a 67 × 67 square from 116.5.
assert!(square("/RD [15 15 15 15]").contains("116.5 116.5 67 67 re"), "{}", square("/RD [15 15 15 15]"));
// Left 10, top 40, right 20, bottom 30: the box is 110..180 × 130..160.
assert!(square("/RD [10 40 20 30]").contains("111.5 131.5 67 27 re"), "{}", square("/RD [10 40 20 30]"));
for ignored in ["", "/RD [-1 0 0 0]", "/RD [1 2 3]", "/RD [60 0 60 0]", "/RD [0 50 0 49.5]", "/RD (x)"] {
assert!(square(ignored).contains("101.5 101.5 97 97 re"), "{ignored}: {}", square(ignored));
}
let circle = built("<< /Type /Annot /Subtype /Circle /Rect [100 100 200 200] /C [1 0 0] /BS << /W 3 >> /RD [15 15 15 15] >>").unwrap();
// The ellipse starts at its rightmost point: 200 - 15 - 1.5, at mid-height.
assert!(circle.starts_with("1 0 0 RG\n3 w\n183.5 150 m"), "{circle}");
}
/// The circles and rectangles PdfCraft draws write no `/RD`, so their appearance is unchanged by
/// it: the stroke still sits half its width inside `/Rect`.
#[test]
fn pdfcraft_rectangles_and_ellipses_still_fill_their_rect() {
let mut doc = fixture();
let rect = [10.0, 10.0, 110.0, 60.0];
for shape in [Shape::Rectangle { rect }, Shape::Oval { rect }] {
let oval = matches!(shape, Shape::Oval { .. });
let i = add_annotation(&mut doc, &new(0, shape), &meta("r")).unwrap();
let d = list(&doc, 0).remove(i);
assert!(!d.contains(b"RD"));
let w = appearance::border_width(&d);
let ap = ap_content(&doc, &d);
let r: Vec<f64> = d.get(b"Rect").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect();
let expected = if oval {
format!("{} {} m", n(r[2] - w / 2.0), n((r[1] + r[3]) / 2.0))
} else {
format!("{} {} {} {} re", n(r[0] + w / 2.0), n(r[1] + w / 2.0), n(r[2] - r[0] - w), n(r[3] - r[1] - w))
};
assert!(ap.contains(&expected), "{expected} in {ap}");
}
}
/// Review on #4195: a polygon reshaped with `/BE /S /C` and no `/I` (or `/I 0`) is a plain one,
/// as its appearance is drawn (Table 169: intensity 0 is no cloud), so its `/Rect` gets no room
/// for bumps; with an intensity above 0 it does.
#[test]
fn a_reshaped_polygon_pads_its_rect_only_for_a_drawn_cloud() {
// `/BE << /S /C >>` with `/I` when given; no `/BE` at all for `None`.
let reshaped = |be: Option<Option<i64>>| {
let mut doc = fixture();
let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: false };
let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap();
let (_, r) = annot_ref(&mut doc, 0, i).unwrap();
if let Some(intensity) = be {
let mut b = pdfcraft_cos::Dict::new();
b.set(b"S".to_vec(), Object::name("C"));
if let Some(i) = intensity {
b.set(b"I".to_vec(), Object::Int(i));
}
doc.update_dict(r, |d| d.set(b"BE".to_vec(), Object::Dict(b))).unwrap();
}
set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap();
rect(&list(&doc, 0)[i])
};
let plain = reshaped(None);
for be in [None, Some(0)] {
assert_eq!(reshaped(Some(be)), plain, "/I {be:?}: no cloud drawn, no room for one");
}
let cloudy = reshaped(Some(Some(1)));
assert!(cloudy[0] < plain[0] && cloudy[2] > plain[2], "room for the bumps: {cloudy:?} around {plain:?}");
}
/// Review on #4195: a reshaped polygon whose cloud has intensity 2 gets room for its bumps at
/// that intensity. With room only for intensity 1, the bumps on a 200 pt edge reached past
/// `/Rect` and the appearance's `/BBox` clipped them.
#[test]
fn a_reshaped_intensity_two_cloud_stays_inside_its_rect() {
let mut doc = fixture();
let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: true };
let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap();
let (_, r) = annot_ref(&mut doc, 0, i).unwrap();
let mut be = pdfcraft_cos::Dict::new();
be.set(b"S".to_vec(), Object::name("C"));
be.set(b"I".to_vec(), Object::Int(2));
doc.update_dict(r, |d| d.set(b"BE".to_vec(), Object::Dict(be))).unwrap();
set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap();
let d = &list(&doc, 0)[i];
let bbox = rect(d);
// Every point along the drawn bumps, with half the 2 pt line, is inside `/Rect`.
let content = ap_content(&doc, d);
let (mut at, mut lowest, mut leftmost) = ((0.0, 0.0), f64::MAX, f64::MAX);
for line in content.lines() {
let v: Vec<f64> = line.split_whitespace().filter_map(|t| t.parse().ok()).collect();
if line.ends_with(" m") && v.len() == 2 {
at = (v[0], v[1]);
} else if line.ends_with(" c") && v.len() == 6 {
for step in 0..=20 {
let t = f64::from(step) / 20.0;
let u = 1.0 - t;
let (a, b, c, e) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
lowest = lowest.min(a * at.1 + b * v[1] + c * v[3] + e * v[5]);
leftmost = leftmost.min(a * at.0 + b * v[0] + c * v[2] + e * v[4]);
}
at = (v[4], v[5]);
}
}
assert!(lowest < 100.0 - 8.0, "the bumps bulge below the bottom edge: {lowest}");
assert!(lowest - 1.0 >= bbox[1], "the bottom bumps ({lowest}) and the line fit in /Rect {bbox:?}");
assert!(leftmost - 1.0 >= bbox[0], "the left bumps ({leftmost}) and the line fit in /Rect {bbox:?}");
}
/// Review on #4195: a reshaped polygon gets room for the border it is drawn with. With no `/BS`,
/// a `/Border [0 0 5]` is drawn 5 pt wide (`appearance::border_width`), so the new `/Rect` is the
/// one `/BS /W 5` gets; it was sized for a 1-pt border, and the cloud's outer bumps were clipped.
#[test]
fn a_reshaped_polygon_has_room_for_the_border_it_is_drawn_with() {
let reshaped = |border: &[u8], width: Object| {
let mut doc = fixture();
let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: true };
let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap();
let (_, r) = annot_ref(&mut doc, 0, i).unwrap();
doc.update_dict(r, |d| {
d.remove(b"BS");
if border == b"BS" {
let mut bs = pdfcraft_cos::Dict::new();
bs.set(b"W".to_vec(), width);
d.set(b"BS".to_vec(), Object::Dict(bs));
} else {
d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), width]));
}
})
.unwrap();
set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap();
rect(&list(&doc, 0)[i])
};
let bs = reshaped(b"BS", Object::Int(5));
assert_eq!(reshaped(b"Border", Object::Int(5)), bs, "the same 5-pt border, the same room");
assert_ne!(reshaped(b"BS", Object::Int(1)), bs, "a 1-pt border needs less");
}
/// Every number a drawing writes, which must all be finite.
fn drawn_numbers(ops: &str) -> Vec<f64> {
ops.split_whitespace().filter(|t| t.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '.')).filter_map(|t| t.parse().ok()).collect()
}
/// Review on #4195: a cloud around a zero-size, inverted, sliver or huge `/Rect`, at any border
/// width and any `/BE /I` (tiny, negative, far above Table 169's 2), is either drawn inside
/// `/Rect` with finite numbers and a bounded number of bumps, or not built at all, never a
/// panic, a division by zero or a NaN.
#[test]
fn hostile_cloud_sizes_widths_and_intensities_draw_inside_rect_or_not_at_all() {
let rects = [
"0 0 0 0",
"100 100 100 100",
"100 100 100.5 300",
"100 100 101 101",
"300 250 100 100",
"100 100 300 250",
"100 100 110 110",
"-1000000000000000 -1000000000000000 1000000000000000 1000000000000000",
"0 0 99999999999999999999999 1",
&format!("0 0 {} 100", "9".repeat(400)),
&format!("-{n} -{n} {n} {n}", n = "9".repeat(308)),
];
let (mut drawn, mut refused) = (0, 0);
for rect in rects {
let r: Vec<f64> = rect.split_whitespace().filter_map(|t| t.parse().ok()).collect();
let bounds = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])];
for width in ["0", "0.001", "2", "1000000", "-3"] {
for intensity in ["0.0000001", "2", "1000000000000000000000000000000", "-1"] {
for kind in ["Square", "Circle", "FreeText"] {
let body = format!(
"<< /Type /Annot /Subtype /{kind} /Rect [{rect}] /C [1 0 0] /BS << /W {width} >> /BE << /S /C /I {intensity} >> /DA (0 g /Helv 10 Tf) /Contents (x) >>"
);
let Some(ops) = built(&body) else {
refused += 1;
continue;
};
drawn += 1;
assert!(!ops.contains("NaN") && !ops.contains("inf"), "{body}: {ops}");
assert!(drawn_numbers(&ops).iter().all(|v| v.is_finite()), "{body}");
assert!(ops.matches(" c\n").count() <= crate::appearance::MAX_CLOUD_BUMPS + 8, "{body}: bumps are capped");
// A cloud (intensity above 0); a negative one is a plain border (Table 169).
if !intensity.starts_with('-') && ops.contains(" c\n") {
let e = extent(&ops);
let slack = 0.5 + 1e-9 * (bounds[2] - bounds[0]).abs().max((bounds[3] - bounds[1]).abs());
assert!(
e[0] >= bounds[0] - slack && e[1] >= bounds[1] - slack && e[2] <= bounds[2] + slack && e[3] <= bounds[3] + slack,
"{body}: the cloud {e:?} stays in /Rect"
);
}
}
}
}
}
assert!(drawn > 0 && refused > 0, "both outcomes are exercised: {drawn} drawn, {refused} refused");
}
/// Review on #4195: a cloud around a rotated rectangle (a polygon turned 45°, its corners in
/// either order) bulges outwards the same way and stays inside its `/Rect`, and `cloud_path`
/// itself takes a zero, negative, infinite or NaN bump size and repeated corners without
/// dividing by zero.
#[test]
fn a_cloud_around_a_rotated_rectangle_bulges_outwards_either_way_round() {
let ccw = "150 100 200 150 150 200 100 150";
let cw = "150 100 100 150 150 200 200 150";
let extents: Vec<[f64; 4]> = [ccw, cw]
.iter()
.map(|v| {
let ops = built(&format!("<< /Type /Annot /Subtype /Polygon /Rect [80 80 220 220] /C [1 0 0] /Vertices [{v}] /BE << /S /C /I 1 >> >>"))
.unwrap();
assert!(ops.contains(" c\n"), "{ops}");
let e = extent(&ops);
assert!(e[0] >= 80.0 && e[1] >= 80.0 && e[2] <= 220.0 && e[3] <= 220.0, "inside /Rect: {e:?}");
// Each bump's middle lies outside the turned square |x − 150| + |y − 150| = 50.
let mut at = (0.0, 0.0);
for line in ops.lines() {
let v: Vec<f64> = line.split_whitespace().filter_map(|t| t.parse().ok()).collect();
if line.ends_with(" m") && v.len() == 2 {
at = (v[0], v[1]);
} else if line.ends_with(" c") && v.len() == 6 {
let mid = ((at.0 + 3.0 * v[0] + 3.0 * v[2] + v[4]) / 8.0, (at.1 + 3.0 * v[1] + 3.0 * v[3] + v[5]) / 8.0);
assert!((mid.0 - 150.0).abs() + (mid.1 - 150.0).abs() > 50.0 + 1.0, "the bump at {mid:?} bulges outwards");
at = (v[4], v[5]);
}
}
e
})
.collect();
for k in 0..4 {
assert!((extents[0][k] - extents[1][k]).abs() < 0.5, "the same cloud either way round: {extents:?}");
}
let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
for r in [0.0, -1.0, f64::INFINITY, f64::NAN, 1e-300, 1e300] {
let path = crate::appearance::cloud_path(&square, r);
assert!(!path.contains("NaN") && !path.contains("inf"), "r {r}: {path}");
assert!(path.ends_with("h\n"));
}
for pts in [vec![], vec![(5.0, 5.0)], vec![(5.0, 5.0); 4], vec![(0.0, 0.0), (10.0, 0.0), (10.0, 0.0), (0.0, 0.0)]] {
let path = crate::appearance::cloud_path(&pts, 2.0);
assert!(!path.contains("NaN") && !path.contains("inf"), "{pts:?}: {path}");
}
}
#[test]
fn freehand_highlight_is_compatible_ink_with_marker_blending() {
let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] };
@@ -3657,15 +4017,16 @@ fn polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them() {
fn polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them() {
let (_, display) = displayed(&[
b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 1 >> /C [0 0 1] /IC [1 1 0] /Vertices [100 100 200 100 150 180] >>",
b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C >> /Vertices [100 100 200 100 150 180] >>",
b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C /I 1 >> /Vertices [100 100 200 100 150 180] >>",
]);
let shown = list(&display, 0);
let rect = |d: &Dict| nums_of(d, b"Rect");
assert_eq!(rect(&shown[0]), [10.0, 10.0, 200.5, 180.5], "the polygon's /Rect takes in its vertices");
assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0]));
assert!(ap_content(&display, &shown[0]).contains("100 100 m"), "the polygon is drawn");
// Half the width (1) and twice the cloud radius (2 × (6 + 1.5 × 2)) past the vertices.
assert_eq!(rect(&shown[1]), [10.0, 10.0, 219.0, 199.0], "and its cloudy border");
// Half the width (1) and the cloud's reach (`cloud_reach`: 1.5 bumps of 6 + 1.5 × 2 at
// intensity 1) past the vertices.
assert_eq!(rect(&shown[1]), [10.0, 10.0, 214.5, 194.5], "and its cloudy border");
}
/// A line's `/L` (Table 178) outside its `/Rect` is drawn, with its line ending and its leader
+8 -5
View File
@@ -549,7 +549,7 @@ fn flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page() {
/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden
/// cloudy square and a cloudy square that already has an appearance keep their previous results.
#[test]
fn flattening_keeps_a_cloudy_square_it_cannot_draw() {
fn flattening_draws_cloudy_squares_and_keeps_one_it_cannot_draw() {
let page = stream("", " ");
let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream";
let case = |extra: &str, drawn: usize, left: bool| {
@@ -571,10 +571,13 @@ fn flattening_keeps_a_cloudy_square_it_cannot_draw() {
assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the comment");
}
};
case("/BE << /S /C /I 2 >>", 0, true);
// A cloudy square is drawn (ISO 32000-2 Table 169); one whose /RD leaves no square inside
// its /Rect (Table 180: the sums "shall be less than" the width and height) cannot be, and stays.
case("/BE << /S /C /I 2 >>", 1, false);
case("/BE << /S /C /I 2 >> /RD [60 30 60 30]", 0, true);
case("/BE << /S /S >>", 1, false);
case("/BE << /S /C /I 2 >> /F 2", 0, true);
case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false);
case("/BE << /S /C /I 2 >> /RD [60 30 60 30] /F 2", 0, true);
case("/BE << /S /C /I 2 >> /RD [60 30 60 30] /AP << /N 6 0 R >>", 1, false);
}
/// #3213: flattening wraps transferable optional-content memberships (`/OC` indirect OCGs and
@@ -815,7 +818,7 @@ fn flattening_takes_an_undrawable_reply_with_its_comment() {
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>",
&page,
"<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) /AP << /N 8 0 R >> /Popup 7 0 R >>",
"<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /IRT 5 0 R >>",
"<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /RD [60 30 60 30] /IRT 5 0 R >>",
"<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>",
"<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream",
]);
+2 -2
View File
@@ -3914,8 +3914,8 @@ milestone = "M5"
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/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools"]
notes = "polygon with a cloudy border (/BE /S /C, /IT /PolygonCloud)"
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/it/comments.rs::polygons_clouds_connected_lines_callouts_and_inserted_text", "crates/automation/tests/it/automation.rs::drawing_comments_through_tools", "crates/annot/src/tests.rs::cloudy_borders_are_drawn_on_squares_circles_polygons_and_text_boxes", "crates/annot/src/tests.rs::a_cloudy_square_follows_rd_left_top_right_bottom", "crates/annot/src/tests.rs::plain_squares_and_circles_are_drawn_inside_rect_less_rd"]
notes = "polygon with a cloudy border (/BE /S /C, /IT /PolygonCloud). Cloudy borders other PDFs carry are drawn too (ISO 32000-2 Table 169): on squares, circles and text boxes as well as polygons, with the /BS width and dashes, inside /Rect less /RD (left, top, right, bottom), where plain squares and circles without an appearance are drawn too (Table 180); /I defaults to 0, which draws the border plain. Not yet: a cloudy callout keeps its own appearance only, and the bump size is PdfCraft's own (the standard leaves it open)."
[[feature]]
id = "comment.area-highlight"