1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
18pub enum BorderStyle {
19 #[default]
21 Solid,
22 Dash,
24 Beveled,
26 Inset,
28 Underline,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Dash {
35 pub on: i64,
37 pub gap: i64,
39 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#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct BorderStyleInfo {
56 pub width: f32,
58 pub style: BorderStyle,
60 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#[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#[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 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 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#[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#[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#[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 assert_eq!(style_of("Dotted").style, BorderStyle::Dash);
319 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 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 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 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 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}