Skip to main content

pdfrum_doc/ap/
border.rs

1//! Border styles (ISO 32000-1 §12.5.4) and the paths that draw them.
2//!
3//! Two things surprise here. The style is chosen from the **first byte** of
4//! `/S` alone, so `/S /Dotted` is a dash and `/S /Squiggly` is solid; and the
5//! beveled and inset styles **double the stated width** before anyone else
6//! sees it, which then propagates into how far the body rectangle is inset.
7
8use kurbo::Rect;
9use pdfrum_object::{Array, Dict, Resolve, names as obj_names};
10
11use crate::ap::emit::{Content, Float, PaintOp, color_op};
12use crate::color::Color;
13use crate::geom;
14use crate::names;
15
16/// How a border is drawn.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum BorderStyle {
19    /// One solid line.
20    #[default]
21    Solid,
22    /// A dashed line.
23    Dash,
24    /// A raised bevel.
25    Beveled,
26    /// A recessed bevel.
27    Inset,
28    /// A line along the bottom edge only.
29    Underline,
30}
31
32/// A dash pattern's three numbers.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Dash {
35    /// On length.
36    pub on: i64,
37    /// Off length.
38    pub gap: i64,
39    /// Phase offset.
40    pub phase: i64,
41}
42
43impl Default for Dash {
44    fn default() -> Self {
45        Dash {
46            on: 3,
47            gap: 0,
48            phase: 0,
49        }
50    }
51}
52
53/// A resolved border style.
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct BorderStyleInfo {
56    /// The line width — already **doubled** for a beveled or inset style.
57    pub width: f32,
58    /// Which style.
59    pub style: BorderStyle,
60    /// The dash pattern.
61    pub dash: Dash,
62}
63
64impl Default for BorderStyleInfo {
65    fn default() -> Self {
66        BorderStyleInfo {
67            width: 1.0,
68            style: BorderStyle::Solid,
69            dash: Dash::default(),
70        }
71    }
72}
73
74/// Reads a `/BS` dictionary.
75///
76/// `/S` is matched on its first byte only, and an unrecognized one leaves the
77/// style solid. A beveled or inset style then doubles the width in place.
78#[must_use]
79pub fn border_style_info<R: Resolve>(bs: Option<&Dict>, r: &R) -> BorderStyleInfo {
80    let mut info = BorderStyleInfo::default();
81    let Some(bs) = bs else {
82        return info;
83    };
84    if bs.contains_key(names::W) {
85        info.width = bs.number(names::W, r).unwrap_or(0.0);
86    }
87    match bs
88        .byte_string(names::S, r)
89        .as_deref()
90        .and_then(<[u8]>::first)
91    {
92        Some(b'S') => info.style = BorderStyle::Solid,
93        Some(b'D') => info.style = BorderStyle::Dash,
94        Some(b'B') => {
95            info.style = BorderStyle::Beveled;
96            info.width *= 2.0;
97        }
98        Some(b'I') => {
99            info.style = BorderStyle::Inset;
100            info.width *= 2.0;
101        }
102        Some(b'U') => info.style = BorderStyle::Underline,
103        _ => {}
104    }
105    if let Some(pattern) = bs.array(names::D, r) {
106        info.dash = Dash {
107            on: pattern.int_at(0).unwrap_or(0),
108            gap: pattern.int_at(1).unwrap_or(0),
109            phase: pattern.int_at(2).unwrap_or(0),
110        };
111    }
112    info
113}
114
115/// The border path for one rectangle, or nothing at all.
116///
117/// A width at or below zero draws nothing. Each style has its own gate on the
118/// colour: solid and the bevels need a fill colour, dash and underline need a
119/// stroke colour, and a transparent one means the whole path is skipped —
120/// except in the bevels, whose two grey wedges are drawn unconditionally.
121#[must_use]
122pub fn border_path(rect: Rect, info: BorderStyleInfo, color: Color) -> String {
123    if info.width <= 0.0 {
124        return String::new();
125    }
126    let (left, bottom, right, top) = (
127        geom::left(rect),
128        geom::bottom(rect),
129        geom::right(rect),
130        geom::top(rect),
131    );
132    let (width, half) = (info.width, info.width / 2.0);
133    let mut out = Content::new();
134
135    match info.style {
136        BorderStyle::Solid => {
137            let fill = color_op(color, PaintOp::Fill);
138            if fill.is_empty() {
139                return String::new();
140            }
141            // An even-odd donut: the outer rectangle minus one inset by the
142            // full width.
143            out.raw(&fill);
144            out.rect(rect, Float::Shortest);
145            out.raw("re\n");
146            out.rect(geom::deflate(rect, width, width), Float::Shortest);
147            out.raw("re f*\n");
148        }
149        BorderStyle::Dash => {
150            let stroke = color_op(color, PaintOp::Stroke);
151            if stroke.is_empty() {
152                return String::new();
153            }
154            out.raw(&stroke);
155            out.num(width, Float::Shortest);
156            out.raw(&format!(
157                "w [{} {}] {} d\n",
158                info.dash.on, info.dash.gap, info.dash.phase
159            ));
160            out.point(left + half, bottom + half, Float::Shortest);
161            out.raw("m\n");
162            out.point(left + half, top - half, Float::Shortest);
163            out.raw("l\n");
164            out.point(right - half, top - half, Float::Shortest);
165            out.raw("l\n");
166            out.point(right - half, bottom + half, Float::Shortest);
167            out.raw("l\n");
168            out.point(left + half, bottom + half, Float::Shortest);
169            out.raw("l S\n");
170        }
171        BorderStyle::Beveled | BorderStyle::Inset => {
172            let beveled = info.style == BorderStyle::Beveled;
173            let (top_left, bottom_right) = if beveled { (1.0, 0.5) } else { (0.5, 0.75) };
174
175            out.raw(&color_op(Color::Gray(top_left), PaintOp::Fill));
176            for (x, y, op) in [
177                (left + half, bottom + half, "m\n"),
178                (left + half, top - half, "l\n"),
179                (right - half, top - half, "l\n"),
180                (right - width, top - width, "l\n"),
181                (left + width, top - width, "l\n"),
182                (left + width, bottom + width, "l f\n"),
183            ] {
184                out.point(x, y, Float::Shortest);
185                out.raw(op);
186            }
187
188            out.raw(&color_op(Color::Gray(bottom_right), PaintOp::Fill));
189            for (x, y, op) in [
190                (right - half, top - half, "m\n"),
191                (right - half, bottom + half, "l\n"),
192                (left + half, bottom + half, "l\n"),
193                (left + width, bottom + width, "l\n"),
194                (right - width, bottom + width, "l\n"),
195                (right - width, top - width, "l f\n"),
196            ] {
197                out.point(x, y, Float::Shortest);
198                out.raw(op);
199            }
200
201            let fill = color_op(color, PaintOp::Fill);
202            if !fill.is_empty() {
203                out.raw(&fill);
204                out.rect(rect, Float::Shortest);
205                out.raw("re\n");
206                // Note the **half** width here, unlike the solid style's
207                // full-width inset.
208                out.rect(geom::deflate(rect, half, half), Float::Shortest);
209                out.raw("re f*\n");
210            }
211        }
212        BorderStyle::Underline => {
213            let stroke = color_op(color, PaintOp::Stroke);
214            if stroke.is_empty() {
215                return String::new();
216            }
217            out.raw(&stroke);
218            out.num(width, Float::Shortest);
219            out.raw("w\n");
220            out.point(left, bottom + half, Float::Shortest);
221            out.raw("m\n");
222            out.point(right, bottom + half, Float::Shortest);
223            out.raw("l S\n");
224        }
225    }
226    out.as_str().to_owned()
227}
228
229/// The **annotation-level** border width, which is a different lookup from
230/// [`border_style_info`]'s.
231///
232/// `/BS /W` when `/BS` exists *and carries the key*; else `/Border[2]` when
233/// `/Border` has **more than two** elements; else one.
234#[must_use]
235pub fn border_width<R: Resolve>(dict: &Dict, r: &R) -> f32 {
236    if let Some(bs) = dict.dict(names::BS, r)
237        && bs.contains_key(names::W)
238    {
239        return bs.number(names::W, r).unwrap_or(0.0);
240    }
241    if let Some(border) = dict.array(obj_names::BORDER, r)
242        && border.len() > 2
243    {
244        return border.number_at_or_zero(2);
245    }
246    1.0
247}
248
249/// The dash array an annotation names.
250///
251/// `/BS /D` when `/BS /S` is exactly `D`; else `/Border[3]` when `/Border`
252/// has **exactly four** elements.
253#[must_use]
254pub fn dash_array<R: Resolve>(dict: &Dict, r: &R) -> Option<Array> {
255    if let Some(bs) = dict.dict(names::BS, r)
256        && bs.byte_string(names::S, r).as_deref() == Some(b"D")
257    {
258        return bs.array(names::D, r);
259    }
260    let border = dict.array(obj_names::BORDER, r)?;
261    if border.len() == 4 {
262        return border.array_at(3, r);
263    }
264    None
265}
266
267/// The `d` operator for an annotation's dash pattern, or nothing.
268///
269/// At most ten elements, each followed by a space — so the closing bracket is
270/// preceded by one — and the phase is always the literal zero, whatever the
271/// pattern's own third number says.
272#[must_use]
273pub fn dash_pattern_string<R: Resolve>(dict: &Dict, r: &R) -> String {
274    let Some(dashes) = dash_array(dict, r).filter(|a| !a.is_empty()) else {
275        return String::new();
276    };
277    let mut out = String::from("[");
278    for index in 0..dashes.len().min(10) {
279        out.push_str(&crate::ap::fmt::shortest(dashes.number_at_or_zero(index)));
280        out.push(' ');
281    }
282    out.push_str("] 0 d\n");
283    out
284}
285
286#[cfg(test)]
287mod tests {
288    use super::{
289        BorderStyle, BorderStyleInfo, Dash, border_path, border_style_info, border_width,
290        dash_pattern_string,
291    };
292    use crate::color::Color;
293    use crate::geom;
294    use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
295
296    fn dict(pairs: &[(&str, Object)]) -> Dict {
297        Dict::from_pairs(
298            pairs
299                .iter()
300                .map(|(k, v)| (Name::from(*k), v.clone()))
301                .collect::<Vec<_>>(),
302        )
303    }
304
305    fn style_of(spelling: &str) -> BorderStyleInfo {
306        let bs = dict(&[
307            ("W", Object::from(2.0_f32)),
308            ("S", Object::Name(Name::from(spelling))),
309        ]);
310        border_style_info(Some(&bs), &NoResolve)
311    }
312
313    #[test]
314    fn only_the_first_byte_of_the_style_name_is_examined() {
315        assert_eq!(style_of("Solid").style, BorderStyle::Solid);
316        assert_eq!(style_of("Dashed").style, BorderStyle::Dash);
317        // `Dotted` starts with a D, so it dashes.
318        assert_eq!(style_of("Dotted").style, BorderStyle::Dash);
319        // `Squiggly` starts with an S, so it is solid.
320        assert_eq!(style_of("Squiggly").style, BorderStyle::Solid);
321        assert_eq!(style_of("Underline").style, BorderStyle::Underline);
322    }
323
324    #[test]
325    fn the_bevelled_styles_double_the_stated_width() {
326        let width = |spelling: &str| style_of(spelling).width;
327        assert!((width("Beveled") - 4.0).abs() < f32::EPSILON);
328        assert!((width("Inset") - 4.0).abs() < f32::EPSILON);
329        assert!((width("Solid") - 2.0).abs() < f32::EPSILON);
330        assert!((width("Dashed") - 2.0).abs() < f32::EPSILON);
331    }
332
333    #[test]
334    fn an_absent_style_dictionary_is_a_solid_hairline() {
335        let info = border_style_info(None, &NoResolve);
336        assert!((info.width - 1.0).abs() < f32::EPSILON);
337        assert_eq!(info.style, BorderStyle::Solid);
338        assert_eq!(info.dash, Dash::default());
339    }
340
341    #[test]
342    fn a_present_but_unreadable_width_reads_as_zero() {
343        let bs = dict(&[("W", Object::Name(Name::from("thick")))]);
344        assert!(border_style_info(Some(&bs), &NoResolve).width.abs() < f32::EPSILON);
345    }
346
347    #[test]
348    fn a_width_at_or_below_zero_draws_nothing() {
349        let info = BorderStyleInfo {
350            width: 0.0,
351            ..BorderStyleInfo::default()
352        };
353        let rect = geom::rect(0.0, 0.0, 10.0, 10.0);
354        assert_eq!(border_path(rect, info, Color::Gray(0.0)), "");
355    }
356
357    #[test]
358    fn a_solid_border_is_an_even_odd_donut_inset_by_the_full_width() {
359        let rect = geom::rect(0.0, 0.0, 10.0, 10.0);
360        let info = BorderStyleInfo {
361            width: 2.0,
362            style: BorderStyle::Solid,
363            dash: Dash::default(),
364        };
365        assert_eq!(
366            border_path(rect, info, Color::Gray(0.0)),
367            "0 g\n0 0 10 10 re\n2 2 6 6 re f*\n"
368        );
369    }
370
371    #[test]
372    fn a_solid_border_with_no_colour_draws_nothing() {
373        let rect = geom::rect(0.0, 0.0, 10.0, 10.0);
374        assert_eq!(
375            border_path(rect, BorderStyleInfo::default(), Color::Transparent),
376            ""
377        );
378    }
379
380    #[test]
381    fn a_bevel_draws_its_two_wedges_even_with_no_border_colour() {
382        let rect = geom::rect(0.0, 0.0, 10.0, 10.0);
383        let info = BorderStyleInfo {
384            width: 2.0,
385            style: BorderStyle::Beveled,
386            dash: Dash::default(),
387        };
388        let path = border_path(rect, info, Color::Transparent);
389        // Both grey values are there; no donut follows.
390        assert!(path.starts_with("1 g\n"), "{path}");
391        assert!(path.contains("\n.5 g\n"), "{path}");
392        assert!(!path.contains("re f*"), "{path}");
393
394        // Inset uses the other two greys.
395        let inset = BorderStyleInfo {
396            style: BorderStyle::Inset,
397            ..info
398        };
399        let path = border_path(rect, inset, Color::Transparent);
400        assert!(path.starts_with(".5 g\n"), "{path}");
401        assert!(path.contains("\n.75 g\n"), "{path}");
402    }
403
404    #[test]
405    fn a_bevels_donut_insets_by_half_the_width_where_a_solids_uses_all_of_it() {
406        let rect = geom::rect(0.0, 0.0, 10.0, 10.0);
407        let info = BorderStyleInfo {
408            width: 2.0,
409            style: BorderStyle::Beveled,
410            dash: Dash::default(),
411        };
412        let path = border_path(rect, info, Color::Gray(0.0));
413        assert!(
414            path.ends_with("0 g\n0 0 10 10 re\n1 1 8 8 re f*\n"),
415            "{path}"
416        );
417    }
418
419    #[test]
420    fn a_dashed_border_writes_its_pattern_as_bare_integers() {
421        let rect = geom::rect(0.0, 0.0, 10.0, 10.0);
422        let info = BorderStyleInfo {
423            width: 2.0,
424            style: BorderStyle::Dash,
425            dash: Dash {
426                on: 3,
427                gap: 1,
428                phase: 0,
429            },
430        };
431        let path = border_path(rect, info, Color::Gray(0.0));
432        assert!(path.contains("2 w [3 1] 0 d\n"), "{path}");
433        assert!(path.ends_with("1 1 l S\n"), "{path}");
434    }
435
436    #[test]
437    fn an_underline_draws_only_the_bottom_edge() {
438        let rect = geom::rect(0.0, 0.0, 10.0, 10.0);
439        let info = BorderStyleInfo {
440            width: 2.0,
441            style: BorderStyle::Underline,
442            dash: Dash::default(),
443        };
444        assert_eq!(
445            border_path(rect, info, Color::Gray(0.0)),
446            "0 G\n2 w\n0 1 m\n10 1 l S\n"
447        );
448    }
449
450    #[test]
451    fn the_annotation_border_width_prefers_the_style_dictionarys_key() {
452        let both = dict(&[
453            ("BS", Object::Dict(dict(&[("W", Object::from(3.0_f32))]))),
454            (
455                "Border",
456                Object::Array(Array::of([0.0_f32, 0.0, 7.0].map(Object::from))),
457            ),
458        ]);
459        assert!((border_width(&both, &NoResolve) - 3.0).abs() < f32::EPSILON);
460
461        // A `/BS` without the key falls through to `/Border[2]`.
462        let fallthrough = dict(&[
463            ("BS", Object::Dict(Dict::new())),
464            (
465                "Border",
466                Object::Array(Array::of([0.0_f32, 0.0, 7.0].map(Object::from))),
467            ),
468        ]);
469        assert!((border_width(&fallthrough, &NoResolve) - 7.0).abs() < f32::EPSILON);
470
471        // A short `/Border` does not count.
472        let short = dict(&[(
473            "Border",
474            Object::Array(Array::of([0.0_f32, 0.0].map(Object::from))),
475        )]);
476        assert!((border_width(&short, &NoResolve) - 1.0).abs() < f32::EPSILON);
477        assert!((border_width(&Dict::new(), &NoResolve) - 1.0).abs() < f32::EPSILON);
478    }
479
480    #[test]
481    fn the_dash_operator_caps_at_ten_elements_and_forces_a_zero_phase() {
482        let many: Vec<Object> = (1..=12).map(Object::Int).collect();
483        let annot = dict(&[(
484            "BS",
485            Object::Dict(dict(&[
486                ("S", Object::Str(PdfString::literal(b"D"))),
487                ("D", Object::Array(Array::of(many))),
488            ])),
489        )]);
490        assert_eq!(
491            dash_pattern_string(&annot, &NoResolve),
492            "[1 2 3 4 5 6 7 8 9 10 ] 0 d\n"
493        );
494    }
495
496    #[test]
497    fn no_dash_array_writes_no_operator() {
498        assert_eq!(dash_pattern_string(&Dict::new(), &NoResolve), "");
499        let empty = dict(&[(
500            "BS",
501            Object::Dict(dict(&[
502                ("S", Object::Name(Name::from("D"))),
503                ("D", Object::Array(Array::new())),
504            ])),
505        )]);
506        assert_eq!(dash_pattern_string(&empty, &NoResolve), "");
507    }
508}