mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
M5: a widened comment box takes in squiggle peaks, ink curve bulges, line captions and strokes reaching onto the page; a redraw reads only the comment's page (view.render-annotation-appearances)
- Text markup reaches past its quads by half its line width, and a squiggle by its peaks over a low quad; the drawing and the reach share the stroke sizes. - Ink takes in the turning points of its smooth curve (cubic extrema), not only its points. - A line takes in its caption's box and the caption tick. - Points off the page whose strokes reach onto it widen the box as far as the page shows. - page_box_listing reads the page /P names when that page lists the comment (ISO 32000-2 §12.5.2: one page lists an annotation), inheriting its boxes up its /Parent chain, before walking the page tree. - AppearanceCache entries carry the crop they were drawn for, so a changed crop box redraws. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pq7HyCHD1R3rywQBPCHXZA
This commit is contained in:
1 parent
98f163652a
commit
1015a51645
3 files changed
+230
-29
No files matched your search
@@ -120,6 +120,29 @@ pub fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The points where the cubic Bézier segment from `p0` (control points `c1`, `c2`) to `p3` turns
|
||||
/// back in x or in y, strictly between its ends: with its ends, they bound the segment.
|
||||
fn cubic_extremes(p0: (f64, f64), c1: (f64, f64), c2: (f64, f64), p3: (f64, f64)) -> Vec<(f64, f64)> {
|
||||
let at = |t: f64| {
|
||||
let u = 1.0 - t;
|
||||
let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t];
|
||||
(w[0] * p0.0 + w[1] * c1.0 + w[2] * c2.0 + w[3] * p3.0, w[0] * p0.1 + w[1] * c1.1 + w[2] * c2.1 + w[3] * p3.1)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (q0, q1, q2, q3) in [(p0.0, c1.0, c2.0, p3.0), (p0.1, c1.1, c2.1, p3.1)] {
|
||||
// The derivative over 3: a t² + 2 b t + c.
|
||||
let (a, b, c) = (-q0 + 3.0 * q1 - 3.0 * q2 + q3, q0 - 2.0 * q1 + q2, q1 - q0);
|
||||
let roots = if a.abs() < 1e-12 {
|
||||
if b.abs() < 1e-12 { vec![] } else { vec![-c / (2.0 * b)] }
|
||||
} else {
|
||||
let disc = b * b - a * c;
|
||||
if disc < 0.0 { vec![] } else { vec![(-b + disc.sqrt()) / a, (-b - disc.sqrt()) / a] }
|
||||
};
|
||||
out.extend(roots.into_iter().filter(|t| *t > 0.0 && *t < 1.0).map(at));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `c` operators for [`smooth_segments`] (the path already starts at the first point).
|
||||
fn smooth_curve(pts: &[(f64, f64)]) -> String {
|
||||
smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect()
|
||||
@@ -566,13 +589,17 @@ fn drawn_points(d: &Dict) -> Option<(Vec<f64>, f64)> {
|
||||
let w = border_width(d);
|
||||
let has_ending = || d.get(b"LE").and_then(Object::as_array).is_some_and(|e| e.iter().any(|e| e.as_name().is_some_and(|e| e != b"None")));
|
||||
let (points, margin) = match d.name(b"Subtype")? {
|
||||
b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => {
|
||||
subtype @ (b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly") => {
|
||||
// Counted before the numbers are read.
|
||||
let count = d.get(b"QuadPoints")?.as_array()?.len();
|
||||
if count == 0 || count % 8 != 0 || count / 8 > MAX_MARKUP_QUADS {
|
||||
return None;
|
||||
}
|
||||
(nums(d, b"QuadPoints")?, 0.0)
|
||||
let quads = nums(d, b"QuadPoints")?;
|
||||
// How far each quad's stroke reaches past it: half the line's width, and a squiggle's
|
||||
// peaks above a quad lower than they are (`draw_markup`).
|
||||
let margin = quads.as_chunks::<8>().0.iter().map(|q| markup_overreach(subtype, (q[0] - q[4]).hypot(q[1] - q[5]))).fold(0.0, f64::max);
|
||||
(quads, margin)
|
||||
}
|
||||
b"Ink" => {
|
||||
let strokes = d.get(b"InkList")?.as_array()?;
|
||||
@@ -580,11 +607,21 @@ fn drawn_points(d: &Dict) -> Option<(Vec<f64>, f64)> {
|
||||
if count / 2 > MAX_REACH_POINTS {
|
||||
return None;
|
||||
}
|
||||
let mut points = Vec::with_capacity(count);
|
||||
let mut points = Vec::with_capacity(count.saturating_mul(3));
|
||||
for s in strokes {
|
||||
let stroke: Vec<f64> = s.as_array()?.iter().map(Object::as_f64).collect::<Option<_>>()?;
|
||||
// A stroke's odd last number is no point (`draw_ink` skips it too).
|
||||
points.extend(stroke.as_chunks::<2>().0.iter().flatten());
|
||||
let pts: Vec<(f64, f64)> = stroke.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
|
||||
points.extend(pts.iter().flat_map(|p| [p.0, p.1]));
|
||||
// The smooth curve through three or more points (`smooth_segments`) can bulge
|
||||
// past them: its turning points count too.
|
||||
if pts.len() > 2 {
|
||||
let mut start = pts.first().copied().unwrap_or_default();
|
||||
for [c1, c2, end] in smooth_segments(&pts) {
|
||||
points.extend(cubic_extremes(start, c1, c2, end).iter().flat_map(|p| [p.0, p.1]));
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
(points, w / 2.0)
|
||||
}
|
||||
@@ -610,9 +647,17 @@ fn drawn_points(d: &Dict) -> Option<(Vec<f64>, f64)> {
|
||||
// Leader lines move the line off `/L` (§12.5.6.7, Table 178).
|
||||
let layout = line_layout(d, [(x0, y0), (x1, y1)], w);
|
||||
let mut points = vec![x0, y0, x1, y1];
|
||||
for (x, y) in layout.line.iter().chain(layout.leaders.iter().flatten()) {
|
||||
for (x, y) in layout.line.iter().chain(layout.leaders.iter().flatten()).chain(layout.tick.iter().flatten()) {
|
||||
points.extend([*x, *y]);
|
||||
}
|
||||
// The caption's box: its width along the text matrix, from the descender to the
|
||||
// cap height and a little over (Helvetica, as `draw_line` sets it).
|
||||
if let Some((text, m)) = &layout.caption {
|
||||
let width = text_width(text, CAPTION_SIZE);
|
||||
for (x, y) in [(0.0, -0.25), (width, -0.25), (0.0, 1.0), (width, 1.0)].map(|(x, y)| (x, y * CAPTION_SIZE)) {
|
||||
points.extend([m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]);
|
||||
}
|
||||
}
|
||||
(points, w / 2.0 + if has_ending() { arrow_size(w) } else { 0.0 })
|
||||
}
|
||||
_ => return None,
|
||||
@@ -641,14 +686,18 @@ fn point_reach(d: &Dict, rect: [f64; 4], page: [f64; 4]) -> Option<[f64; 4]> {
|
||||
}
|
||||
let clip = |b: [f64; 4]| [b[0].max(page[0]), b[1].max(page[1]), b[2].min(page[2]), b[3].min(page[3])];
|
||||
let shown = clip(reach);
|
||||
// Points wholly off the page widen nothing, and neither do points `/Rect` already holds.
|
||||
if shown[0] > shown[2] || shown[1] > shown[3] {
|
||||
// Strokes wholly off the page widen nothing. Points `/Rect` already holds don't either, and
|
||||
// points off the page whose strokes reach onto it only as far as `/Rect` holds.
|
||||
let stroked = clip([reach[0] - margin, reach[1] - margin, reach[2] + margin, reach[3] + margin]);
|
||||
if stroked[0] > stroked[2] || stroked[1] > stroked[3] {
|
||||
return None;
|
||||
}
|
||||
if rect[0] <= shown[0] && rect[1] <= shown[1] && shown[2] <= rect[2] && shown[3] <= rect[3] {
|
||||
let inside = |b: [f64; 4]| rect[0] <= b[0] && rect[1] <= b[1] && b[2] <= rect[2] && b[3] <= rect[3];
|
||||
let on_page = shown[0] <= shown[2] && shown[1] <= shown[3];
|
||||
if inside(if on_page { shown } else { stroked }) {
|
||||
return None;
|
||||
}
|
||||
let reach = clip([reach[0] - margin, reach[1] - margin, reach[2] + margin, reach[3] + margin]);
|
||||
let reach = stroked;
|
||||
let out = [rect[0].min(reach[0]), rect[1].min(reach[1]), rect[2].max(reach[2]), rect[3].max(reach[3])];
|
||||
// A `/Rect` of its own can still be too wide for a width or height (`-1e308` to `1e308`).
|
||||
let usable = (out[2] - out[0]).is_finite() && (out[3] - out[1]).is_finite();
|
||||
@@ -701,6 +750,30 @@ fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDra
|
||||
Some(form(context.rect, &out, drawing.resources))
|
||||
}
|
||||
|
||||
/// The line width of an underline or strike-out over a quad `h` high.
|
||||
fn markup_line_width(h: f64) -> f64 {
|
||||
(h * 0.07).clamp(0.5, 3.0)
|
||||
}
|
||||
|
||||
/// A squiggle's line width and amplitude over a quad `h` high: its peaks stand twice the
|
||||
/// amplitude above the quad's bottom edge.
|
||||
fn squiggle(h: f64) -> (f64, f64) {
|
||||
((h * 0.05).clamp(0.5, 2.0), (h * 0.06).max(0.75))
|
||||
}
|
||||
|
||||
/// How far a text markup of `subtype` drawn over a quad `h` high reaches past the quad: half its
|
||||
/// line's width, and for a squiggle, how far its peaks stand above a quad lower than they are.
|
||||
fn markup_overreach(subtype: &[u8], h: f64) -> f64 {
|
||||
match subtype {
|
||||
b"Underline" | b"StrikeOut" => markup_line_width(h) / 2.0,
|
||||
b"Squiggly" => {
|
||||
let (lw, amp) = squiggle(h);
|
||||
lw / 2.0 + (amp * 2.0 - h).max(0.0)
|
||||
}
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
|
||||
let d = context.dict;
|
||||
let subtype = context.subtype;
|
||||
@@ -720,15 +793,14 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing)
|
||||
c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1)));
|
||||
}
|
||||
b"Underline" | b"StrikeOut" => {
|
||||
let lw = (h * 0.07).clamp(0.5, 3.0);
|
||||
let lw = markup_line_width(h);
|
||||
let t = if subtype == b"Underline" { 0.08 } else { 0.42 };
|
||||
let (a, b) = (at(p3, p1, t), at(p4, p2, t));
|
||||
c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1)));
|
||||
}
|
||||
_ => {
|
||||
// Squiggly: a zigzag along the bottom of the quad.
|
||||
let lw = (h * 0.05).clamp(0.5, 2.0);
|
||||
let amp = (h * 0.06).max(0.75);
|
||||
let (lw, amp) = squiggle(h);
|
||||
let len = (p4.0 - p3.0).hypot(p4.1 - p3.1);
|
||||
let step = (h / 4.0).max(1.5);
|
||||
let steps = ((len / step).ceil() as usize).clamp(1, 10_000);
|
||||
|
||||
+46
-11
@@ -38,11 +38,44 @@ pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), Annot
|
||||
apply_appearance(doc, r, &d, stream)
|
||||
}
|
||||
|
||||
/// The visible region (`Page::crop`) of the first page that lists `r` among its annotations.
|
||||
/// The visible region (`Page::crop`) of the page that lists `r` among its annotations: the page
|
||||
/// its `/P` names when that page lists it, read up its own `/Parent` chain, else the first page
|
||||
/// of the tree that lists it. An annotation is listed by one page only (ISO 32000-2 §12.5.2), so
|
||||
/// both find the same page, and the first reads one page instead of walking the whole tree for
|
||||
/// every comment redrawn.
|
||||
fn page_box_listing(doc: &Document, r: ObjRef) -> Option<[f64; 4]> {
|
||||
let named = annot_dict(doc, r).reference(b"P").filter(|p| annots(doc, *p).iter().any(|a| a.as_ref() == Some(r)));
|
||||
if let Some(page) = named {
|
||||
return Some(page_with_inherited_boxes(doc, page).crop(doc));
|
||||
}
|
||||
pdfcraft_model::pages(doc).into_iter().find(|p| annots(doc, p.obj).iter().any(|a| a.as_ref() == Some(r))).map(|p| p.crop(doc))
|
||||
}
|
||||
|
||||
/// The page `page` with the `/MediaBox` and `/CropBox` it inherits from its ancestors filled in
|
||||
/// (ISO 32000-2 §7.7.3.4), enough for [`pdfcraft_model::Page::crop`]. Follows at most 64
|
||||
/// `/Parent` links and stops at a loop.
|
||||
fn page_with_inherited_boxes(doc: &Document, page: ObjRef) -> pdfcraft_model::Page {
|
||||
let mut dict = doc.get(page).as_dict().cloned().unwrap_or_default();
|
||||
let mut seen = std::collections::HashSet::from([page]);
|
||||
let mut up = dict.reference(b"Parent");
|
||||
while let Some(node) = up {
|
||||
if seen.len() > 64 || !seen.insert(node) {
|
||||
break;
|
||||
}
|
||||
let obj = doc.get(node);
|
||||
let Some(d) = obj.as_dict() else { break };
|
||||
for key in [&b"MediaBox"[..], b"CropBox"] {
|
||||
if !dict.contains(key)
|
||||
&& let Some(v) = d.get(key)
|
||||
{
|
||||
dict.set(key.to_vec(), v.clone());
|
||||
}
|
||||
}
|
||||
up = d.reference(b"Parent");
|
||||
}
|
||||
pdfcraft_model::Page { obj: page, dict }
|
||||
}
|
||||
|
||||
/// Draw the appearance for an annotation dictionary on a page whose visible region is `page`
|
||||
/// (used by annotations drawn through points of their own, [`appearance::build_on_page`]): the expensive half of [`set_appearance`].
|
||||
/// Pure from the dictionary, the objects it points at and the page, so [`AppearanceCache`] can
|
||||
@@ -111,7 +144,9 @@ fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) ->
|
||||
/// refreshes) and keep it for content-only ones.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct AppearanceCache {
|
||||
entries: std::collections::HashMap<ObjRef, Stream>,
|
||||
/// Each appearance with the page region it was drawn for (see [`appearance::build_on_page`]):
|
||||
/// a changed crop box draws it again.
|
||||
entries: std::collections::HashMap<ObjRef, (Option<[f64; 4]>, Stream)>,
|
||||
bytes: usize,
|
||||
hits: u64,
|
||||
builds: u64,
|
||||
@@ -126,20 +161,20 @@ impl AppearanceCache {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
fn get(&mut self, r: ObjRef) -> Option<Stream> {
|
||||
let stream = self.entries.get(&r)?.clone();
|
||||
fn get(&mut self, r: ObjRef, crop: Option<[f64; 4]>) -> Option<Stream> {
|
||||
let stream = self.entries.get(&r).filter(|(drawn_for, _)| *drawn_for == crop)?.1.clone();
|
||||
self.hits += 1;
|
||||
Some(stream)
|
||||
}
|
||||
|
||||
fn insert(&mut self, r: ObjRef, stream: &Stream) {
|
||||
fn insert(&mut self, r: ObjRef, crop: Option<[f64; 4]>, stream: &Stream) {
|
||||
self.builds += 1;
|
||||
if self.entries.len() >= 1_000_000 {
|
||||
self.clear();
|
||||
}
|
||||
if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES {
|
||||
self.bytes += stream.raw.len();
|
||||
self.entries.insert(r, stream.clone());
|
||||
self.entries.insert(r, (crop, stream.clone()));
|
||||
}
|
||||
// Else over budget: keep serving the entries already cached instead of growing.
|
||||
}
|
||||
@@ -188,7 +223,10 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac
|
||||
if has_normal {
|
||||
continue;
|
||||
}
|
||||
if let Some(stream) = cache.get(r) {
|
||||
let crop = appearance::is_drawn_from_points(&d)
|
||||
.then(|| crops.get_or_insert_with(|| pdfcraft_model::pages(doc).iter().map(|p| (p.obj, p.crop(doc))).collect()).get(&page).copied())
|
||||
.flatten();
|
||||
if let Some(stream) = cache.get(r, crop) {
|
||||
// Fails only when `r` isn't a dictionary, which the cached build ruled out.
|
||||
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
|
||||
continue;
|
||||
@@ -200,11 +238,8 @@ pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCac
|
||||
bare.remove(b"AP");
|
||||
appearance::display_stamp(&bare)
|
||||
};
|
||||
let crop = appearance::is_drawn_from_points(&d)
|
||||
.then(|| crops.get_or_insert_with(|| pdfcraft_model::pages(doc).iter().map(|p| (p.obj, p.crop(doc))).collect()).get(&page).copied())
|
||||
.flatten();
|
||||
let Some(stream) = build_appearance(doc, &d, crop).or_else(other_stamp) else { continue };
|
||||
cache.insert(r, &stream);
|
||||
cache.insert(r, crop, &stream);
|
||||
// Fails only when `r` isn't a dictionary, which the build just ruled out.
|
||||
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
|
||||
}
|
||||
|
||||
+100
-6
@@ -2811,12 +2811,13 @@ fn text_markup_whose_quads_reach_past_its_rect_is_drawn_over_them() {
|
||||
assert_eq!(rect(&shown[0]), [324.6, 277.0, 471.0, 471.78], "the highlight's /Rect takes in its text");
|
||||
assert_eq!(bbox(&shown[0]), rect(&shown[0]));
|
||||
assert!(ap_content(&display, &shown[0]).contains("370 287 m 471 287 l 471 277 l 370 277 l h f"), "the quad is filled");
|
||||
assert_eq!(rect(&shown[1]), [10.0, 10.0, 200.0, 120.0], "an underline's too");
|
||||
// Half the underline's width (0.07 × 20 / 2) past the quad.
|
||||
assert_eq!(rect(&shown[1]), [10.0, 10.0, 200.7, 120.7], "an underline's too");
|
||||
assert_eq!(bbox(&shown[1]), rect(&shown[1]));
|
||||
assert_eq!(rect(&shown[2]), [50.0, 50.0, 150.0, 70.0], "a /Rect that holds the quads stays as it is");
|
||||
assert_eq!(rect(&shown[3]), [10.0, 10.0, 200.0, 220.0], "a strikeout's too");
|
||||
assert_eq!(rect(&shown[3]), [10.0, 10.0, 200.7, 220.7], "a strikeout's too");
|
||||
assert_eq!(bbox(&shown[3]), rect(&shown[3]));
|
||||
assert_eq!(rect(&shown[4]), [10.0, 10.0, 200.0, 320.0], "a squiggly's too");
|
||||
assert_eq!(rect(&shown[4]), [10.0, 10.0, 200.5, 320.5], "a squiggly's too");
|
||||
assert_eq!(bbox(&shown[4]), rect(&shown[4]));
|
||||
assert_eq!(rect(&shown[5]), [0.0, 10.0, 612.0, 20.0], "quads far past the page reach only as far as it shows");
|
||||
// A /Rect too wide for its width to be a number keeps its size: the on-page quad above it
|
||||
@@ -2902,7 +2903,7 @@ fn restyled_text_markup_saves_the_rect_its_appearance_covers() {
|
||||
assert_eq!(ap_bbox(&saved, &shown[0]), rect(&shown[0]));
|
||||
assert!(ap_content(&saved, &shown[0]).contains("370 287 m 471 287 l 471 277 l 370 277 l h f"), "the quad is filled");
|
||||
assert_eq!(rect(&shown[1]), [150.0, 70.0, 50.0, 50.0], "a /Rect that holds the quads is kept as written");
|
||||
assert_eq!(rect(&shown[2]), [10.0, 10.0, 612.0, 120.0], "the saved /Rect stops at the page");
|
||||
assert_eq!(rect(&shown[2]), [10.0, 10.0, 612.0, 120.7], "the saved /Rect stops at the page");
|
||||
assert_eq!(ap_bbox(&saved, &shown[2]), rect(&shown[2]));
|
||||
}
|
||||
|
||||
@@ -2931,7 +2932,8 @@ fn ink_whose_strokes_reach_past_its_rect_is_drawn_through_them() {
|
||||
]);
|
||||
let shown = list(&display, 0);
|
||||
let rect = |d: &Dict| nums_of(d, b"Rect");
|
||||
assert_eq!(rect(&shown[0]), [21.25, 260.01, 331.5, 538.5], "the ink's /Rect takes in its strokes");
|
||||
// The smooth curve rises past its highest point (537) to 537.33; half the width (1.5) more.
|
||||
assert_rect_near(&rect(&shown[0]), [21.25, 260.01, 331.5, 538.825], "the ink's /Rect takes in its strokes");
|
||||
assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0]));
|
||||
assert!(ap_content(&display, &shown[0]).contains("260 502.75 m"), "the stroke is drawn");
|
||||
assert_eq!(rect(&shown[1]), [0.0, 0.0, 100.0, 100.0], "a /Rect that holds the points stays as it is");
|
||||
@@ -3014,10 +3016,102 @@ fn path_reach_stops_at_the_point_cap_and_is_saved_on_restyle() {
|
||||
set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap();
|
||||
let saved = reopen(&doc);
|
||||
let shown = list(&saved, 0);
|
||||
assert_eq!(rect(&shown[0]), [21.25, 260.01, 331.5, 538.5], "the saved /Rect takes in the strokes");
|
||||
assert_rect_near(&rect(&shown[0]), [21.25, 260.01, 331.5, 538.825], "the saved /Rect takes in the strokes");
|
||||
assert_eq!(ap_bbox(&saved, &shown[0]), rect(&shown[0]));
|
||||
}
|
||||
|
||||
/// A squiggle's peaks stand 1.5 above a quad's bottom edge however low the quad is, so over a quad
|
||||
/// 1 high they reach past it: the widened box takes them in, with half the line's width.
|
||||
#[test]
|
||||
fn squiggly_peaks_over_a_low_quad_are_inside_the_widened_box() {
|
||||
let (_, display) =
|
||||
displayed(&[b"<< /Type /Annot /Subtype /Squiggly /Rect [10 10 20 20] /C [0 1 0] /QuadPoints [100 301 200 301 100 300 200 300] >>"]);
|
||||
let shown = list(&display, 0);
|
||||
// Peak 300 + 1.5, half the 0.5 line width: 301.75; the sides reach 0.75 past the quad.
|
||||
assert_eq!(nums_of(&shown[0], b"Rect"), [10.0, 10.0, 200.75, 301.75]);
|
||||
assert_eq!(ap_bbox(&display, &shown[0]), nums_of(&shown[0], b"Rect"));
|
||||
}
|
||||
|
||||
/// A line's caption (`/Cap`, ISO 32000-2 Table 178) is drawn beside `/L`: the widened box takes
|
||||
/// in the caption's text as well as the line.
|
||||
#[test]
|
||||
fn a_line_caption_is_inside_the_widened_box() {
|
||||
let (_, display) = displayed(&[
|
||||
b"<< /Type /Annot /Subtype /Line /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /L [100 100 200 100] /Cap true /CP /Top /Contents (Caption) >>",
|
||||
]);
|
||||
let shown = list(&display, 0);
|
||||
let rect = nums_of(&shown[0], b"Rect");
|
||||
// The caption sits on top of the line: its 10-point text rises well past the stroke's 101.
|
||||
assert!(rect[3] > 108.0, "{rect:?}");
|
||||
assert_eq!(ap_bbox(&display, &shown[0]), rect);
|
||||
}
|
||||
|
||||
/// A line just above the crop box whose wide stroke reaches down onto the page is drawn as far as
|
||||
/// the page shows, not cut to its `/Rect`.
|
||||
#[test]
|
||||
fn a_stroke_that_reaches_onto_the_page_from_points_off_it_widens_the_box() {
|
||||
let doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /CropBox [0 0 300 400] /Annots [4 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Line /Rect [10 10 20 20] /BS << /W 6 >> /C [0 0 1] /L [100 401 200 401] >>",
|
||||
]);
|
||||
let display = with_missing_appearances(&doc).expect("the line needs an appearance");
|
||||
let shown = list(&display, 0);
|
||||
// The stroke spans 398 to 404 across and 97 to 203 along; the page shows up to 400.
|
||||
assert_eq!(nums_of(&shown[0], b"Rect"), [10.0, 10.0, 203.0, 400.0]);
|
||||
}
|
||||
|
||||
/// A cached display appearance was drawn for its page's crop box: once the crop box grows, the
|
||||
/// next refresh draws the annotation again for the larger region.
|
||||
#[test]
|
||||
fn a_cached_appearance_is_redrawn_for_a_changed_crop_box() {
|
||||
let mut doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /CropBox [0 0 300 400] /Annots [4 0 R] >>",
|
||||
b"<< /Type /Annot /Subtype /Highlight /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 380 500 380 100 360 500 360] >>",
|
||||
]);
|
||||
let mut cache = AppearanceCache::default();
|
||||
let display = with_missing_appearances_cached(&doc, &mut cache).expect("drawn");
|
||||
assert_eq!(nums_of(&list(&display, 0)[0], b"Rect"), [10.0, 10.0, 300.0, 380.0]);
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"CropBox".to_vec(), Object::Array([0, 0, 612, 792].map(Object::Int).to_vec()))).unwrap();
|
||||
let display = with_missing_appearances_cached(&doc, &mut cache).expect("drawn");
|
||||
let shown = list(&display, 0);
|
||||
assert_eq!(nums_of(&shown[0], b"Rect"), [10.0, 10.0, 500.0, 380.0], "drawn for the larger crop box");
|
||||
assert_eq!(ap_bbox(&display, &shown[0]), nums_of(&shown[0], b"Rect"));
|
||||
}
|
||||
|
||||
/// Redrawing a comment drawn through its points finds its page through `/P` when that page
|
||||
/// lists it, reading that one page's `/Annots` rather than every page's: here the last of 300.
|
||||
#[test]
|
||||
fn restyling_a_comment_drawn_through_its_points_reads_only_its_page() {
|
||||
let pages = 300;
|
||||
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
|
||||
let kids: Vec<String> = (0..pages).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
|
||||
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 612 792] >>", kids.join(" ")));
|
||||
for i in 0..pages {
|
||||
let (page, annot) = (3 + 2 * i, 4 + 2 * i);
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{annot} 0 R] >>"));
|
||||
objs.push(format!(
|
||||
"<< /Type /Annot /Subtype /Highlight /P {page} 0 R /Rect [10 10 20 20] /C [1 1 0] /QuadPoints [100 120 200 120 100 100 200 100] >>"
|
||||
));
|
||||
}
|
||||
let bytes: Vec<&[u8]> = objs.iter().map(|o| o.as_bytes()).collect();
|
||||
let mut doc = document_of(&bytes);
|
||||
let last = pages - 1;
|
||||
ANNOTS_ENTRIES_COPIED.with(|c| c.set(0));
|
||||
set_style(&mut doc, last, 0, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap();
|
||||
let copied = ANNOTS_ENTRIES_COPIED.with(std::cell::Cell::get);
|
||||
assert!(copied < 20, "{copied} annotation entries copied for one restyle");
|
||||
assert_eq!(nums_of(&list(&doc, last)[0], b"Rect"), [10.0, 10.0, 200.0, 120.0], "drawn for its page");
|
||||
}
|
||||
|
||||
/// `got` is `want` to within 0.001 in every coordinate.
|
||||
fn assert_rect_near(got: &[f64], want: [f64; 4], what: &str) {
|
||||
assert!(got.len() == 4 && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-3), "{what}: {got:?}");
|
||||
}
|
||||
|
||||
fn nums_of(d: &Dict, key: &[u8]) -> Vec<f64> {
|
||||
d.get(key).and_then(|a| a.as_array()).map(|a| a.iter().filter_map(|o| o.as_f64()).collect()).unwrap_or_default()
|
||||
}
|
||||
Reference in new issue
Block a user