mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 13:01:29 +00:00
M5: cloudy squares, circles and text boxes are drawn (comment.cloud) (#4195)
This commit is contained in:
1 parent
f4ea21e4a3
commit
b8d9497b93
6 files changed
+528
-49
No files matched your search
+128
-22
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user