#![allow(
clippy::manual_midpoint,
clippy::cast_possible_truncation,
clippy::many_single_char_names
)]
use kurbo::Rect;
use pdfrum_common::{DiagKind, Diagnostics, Severity};
use pdfrum_object::{Array, Dict, Resolve, names as obj_names};
use crate::annot::quad;
use crate::ap::border;
use crate::ap::emit::{Content, Float, PaintOp, color_op, color_with_default, paint_operator};
use crate::color::Color;
use crate::geom;
use crate::names;
const GS_SPACE: &str = "/GS gs ";
#[derive(Debug, Clone, PartialEq)]
pub struct Generated {
pub stream: Vec<u8>,
pub rect_override: Option<Rect>,
pub is_text_markup: bool,
pub blend_multiply: bool,
pub font_resources: Option<Dict>,
}
impl Generated {
fn plain(stream: Content) -> Generated {
Generated {
stream: stream.into_bytes(),
rect_override: None,
is_text_markup: false,
blend_multiply: false,
font_resources: None,
}
}
fn markup(stream: Content) -> Generated {
Generated {
is_text_markup: true,
..Generated::plain(stream)
}
}
}
#[must_use]
pub fn highlight<R: Resolve>(dict: &Dict, r: &R) -> Generated {
let mut out = Content::new();
out.raw(GS_SPACE);
out.raw(&color_with_default(
dict.array(names::C, r).as_ref(),
Color::Rgb(1.0, 1.0, 0.0),
PaintOp::Fill,
));
if let Some(quads) = dict.array(names::QUAD_POINTS, r) {
for index in 0..quad::quad_point_count(Some(&quads)) {
let rect = geom::normalize(quad::rect_from_quad_points_array(&quads, index));
let (l, b, right, t) = corners(rect);
out.point(l, t, Float::G6);
out.raw("m ");
out.point(right, t, Float::G6);
out.raw("l ");
out.point(right, b, Float::G6);
out.raw("l ");
out.point(l, b, Float::G6);
out.raw("l h f\n");
}
}
Generated {
blend_multiply: true,
..Generated::markup(out)
}
}
#[must_use]
pub fn underline<R: Resolve>(dict: &Dict, r: &R) -> Generated {
let mut out = Content::new();
out.raw(GS_SPACE);
out.raw(&stroke_default_black(dict, r));
if let Some(quads) = dict.array(names::QUAD_POINTS, r) {
out.raw("1 w ");
for index in 0..quad::quad_point_count(Some(&quads)) {
let rect = geom::normalize(quad::rect_from_quad_points_array(&quads, index));
let (l, b, right, _) = corners(rect);
out.point(l, b + 1.0, Float::G6);
out.raw("m ");
out.point(right, b + 1.0, Float::G6);
out.raw("l S\n");
}
}
Generated::markup(out)
}
#[must_use]
pub fn strike_out<R: Resolve>(dict: &Dict, r: &R) -> Generated {
let mut out = Content::new();
out.raw(GS_SPACE);
out.raw(&stroke_default_black(dict, r));
if let Some(quads) = dict.array(names::QUAD_POINTS, r) {
for index in 0..quad::quad_point_count(Some(&quads)) {
let rect = geom::normalize(quad::rect_from_quad_points_array(&quads, index));
let (l, b, right, t) = corners(rect);
let y = (t + b) / 2.0;
out.raw("1 w ");
out.point(l, y, Float::G6);
out.raw("m ");
out.point(right, y, Float::G6);
out.raw("l S\n");
}
}
Generated::markup(out)
}
#[must_use]
pub fn squiggly<R: Resolve>(dict: &Dict, r: &R) -> Generated {
const DELTA: f32 = 2.0;
let mut out = Content::new();
out.raw(GS_SPACE);
out.raw(&stroke_default_black(dict, r));
if let Some(quads) = dict.array(names::QUAD_POINTS, r) {
out.raw("1 w ");
for index in 0..quad::quad_point_count(Some(&quads)) {
let rect = geom::normalize(quad::rect_from_quad_points_array(&quads, index));
let (l, bottom, right, _) = corners(rect);
let top = bottom + DELTA;
out.point(l, top, Float::G6);
out.raw("m ");
let mut x = l + DELTA;
let mut upwards = false;
while x < right {
out.point(x, if upwards { top } else { bottom }, Float::G6);
out.raw("l ");
x += DELTA;
upwards = !upwards;
}
let remainder = right - (x - DELTA);
let y = if upwards {
bottom + remainder
} else {
top - remainder
};
out.point(right, y, Float::G6);
out.raw("l ");
out.raw("S\n");
}
}
Generated::markup(out)
}
#[must_use]
pub fn ink<R: Resolve>(dict: &Dict, r: &R, diags: &mut Diagnostics) -> Option<Generated> {
let ink_list = dict.array(names::INK_LIST, r).filter(|a| !a.is_empty())?;
let width = border::border_width(dict, r);
if width <= 0.0 {
return None;
}
let mut out = Content::new();
out.raw(GS_SPACE);
out.raw(&stroke_default_black(dict, r));
out.num(width, Float::G6);
out.raw("w ");
out.raw(&border::dash_pattern_string(dict, r));
for index in 0..ink_list.len() {
let Some(points) = ink_list.array_at(index, r).filter(|a| a.len() >= 2) else {
diags.record(Severity::Recovered, DiagKind::InkPathDropped, None);
continue;
};
out.point(
points.number_at_or_zero(0),
points.number_at_or_zero(1),
Float::G6,
);
out.raw("m ");
let mut at = 0;
while at + 1 < points.len() {
out.point(
points.number_at_or_zero(at),
points.number_at_or_zero(at + 1),
Float::G6,
);
out.raw("l ");
at += 2;
}
out.raw("S\n");
}
let rect = dict.rect(obj_names::RECT, r);
Some(Generated {
rect_override: Some(geom::inflate(rect, width / 2.0, width / 2.0)),
..Generated::plain(out)
})
}
#[must_use]
pub fn square<R: Resolve>(dict: &Dict, r: &R) -> Generated {
let (mut out, rect, stroke, fill) = shape_preamble(dict, r);
out.rect(rect, Float::G6);
out.raw("re ");
out.raw(paint_operator(stroke, fill));
out.raw("\n");
Generated::plain(out)
}
#[must_use]
pub fn circle<R: Resolve>(dict: &Dict, r: &R) -> Generated {
const K: f64 = 0.5523;
let (mut out, rect, stroke, fill) = shape_preamble(dict, r);
let (left, bottom, right, top) = corners(rect);
let mid_x = (left + right) / 2.0;
let mid_y = (top + bottom) / 2.0;
let dx = (K * f64::from(geom::width(rect)) / 2.0) as f32;
let dy = (K * f64::from(geom::height(rect)) / 2.0) as f32;
out.point(mid_x, top, Float::G6);
out.raw("m\n");
for (points, op) in [
(
[(mid_x + dx, top), (right, mid_y + dy), (right, mid_y)],
"c\n",
),
(
[(right, mid_y - dy), (mid_x + dx, bottom), (mid_x, bottom)],
"c\n",
),
(
[(mid_x - dx, bottom), (left, mid_y - dy), (left, mid_y)],
"c\n",
),
([(left, mid_y + dy), (mid_x - dx, top), (mid_x, top)], "c\n"),
] {
for (x, y) in points {
out.point(x, y, Float::G6);
}
out.raw(op);
}
out.raw(paint_operator(stroke, fill));
out.raw("\n");
Generated::plain(out)
}
#[must_use]
pub fn text<R: Resolve>(dict: &Dict, r: &R) -> Generated {
const NOTE: f32 = 20.0;
let rect = dict.rect(obj_names::RECT, r);
let (left, bottom) = (geom::left(rect), geom::bottom(rect));
let note = geom::rect(left, bottom, left + NOTE, bottom + NOTE);
let mut out = Content::new();
out.raw(GS_SPACE);
out.raw(&text_symbol(note));
Generated {
rect_override: Some(note),
..Generated::plain(out)
}
}
#[must_use]
pub fn line<R: Resolve>(dict: &Dict, r: &R) -> Option<Generated> {
let endpoints = dict.array(names::L, r)?;
if endpoints.len() < 4 {
return None;
}
let x1 = endpoints.number_at(0)?;
let y1 = endpoints.number_at(1)?;
let x2 = endpoints.number_at(2)?;
let y2 = endpoints.number_at(3)?;
let mut out = Content::new();
out.raw(GS_SPACE);
out.raw(&stroke_default_black(dict, r));
let fill = line_ending_fill(dict, r);
let width = border::border_width(dict, r);
if width <= 0.0 {
return None;
}
out.num(width, Float::G6);
out.raw("w ");
out.raw(&border::dash_pattern_string(dict, r));
out.point(x1, y1, Float::G6);
out.raw("m\n");
out.point(x2, y2, Float::G6);
out.raw("l\n");
out.raw("S\n");
let (start_style, end_style) = line_ending_styles(dict, r);
let size = width * 3.0;
draw_line_ending(&mut out, x1, y1, x2, y2, true, start_style, size, &fill);
draw_line_ending(&mut out, x1, y1, x2, y2, false, end_style, size, &fill);
let pad = (width / 2.0).max(size);
let rect = dict.rect(obj_names::RECT, r);
Some(Generated {
rect_override: Some(geom::inflate(rect, pad, pad)),
..Generated::plain(out)
})
}
fn line_ending_styles<R: Resolve>(dict: &Dict, r: &R) -> (LineEnd, LineEnd) {
let Some(le) = dict.array(names::LE, r) else {
return (LineEnd::None, LineEnd::None);
};
let start = le
.name_at(0)
.map_or(LineEnd::None, |n| LineEnd::from_bytes(n.as_bytes()));
let end = le
.name_at(1)
.map_or(LineEnd::None, |n| LineEnd::from_bytes(n.as_bytes()));
(start, end)
}
const CIRCLE_K: f32 = 0.552_284_8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineEnd {
None,
Square,
Circle,
Diamond,
OpenArrow,
ClosedArrow,
Butt,
ROpenArrow,
RClosedArrow,
Slash,
}
impl LineEnd {
fn from_bytes(bytes: &[u8]) -> Self {
match bytes {
b"Square" => Self::Square,
b"Circle" => Self::Circle,
b"Diamond" => Self::Diamond,
b"OpenArrow" => Self::OpenArrow,
b"ClosedArrow" => Self::ClosedArrow,
b"Butt" => Self::Butt,
b"ROpenArrow" => Self::ROpenArrow,
b"RClosedArrow" => Self::RClosedArrow,
b"Slash" => Self::Slash,
_ => Self::None,
}
}
}
#[allow(
clippy::too_many_arguments,
clippy::too_many_lines,
reason = "geometry + style + paint for one ending"
)]
fn draw_line_ending(
out: &mut Content,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
at_start: bool,
style: LineEnd,
size: f32,
fill: &str,
) {
if matches!(style, LineEnd::None) {
return;
}
let (px, py) = if at_start { (x1, y1) } else { (x2, y2) };
let dx = x2 - x1;
let dy = y2 - y1;
let len = (dx * dx + dy * dy).sqrt();
if len <= f32::EPSILON {
return;
}
let (mut ux, mut uy) = (dx / len, dy / len);
if at_start {
ux = -ux;
uy = -uy;
}
if matches!(style, LineEnd::ROpenArrow | LineEnd::RClosedArrow) {
ux = -ux;
uy = -uy;
}
let (nx, ny) = (-uy, ux);
let half = size * 0.5;
match style {
LineEnd::None => {}
LineEnd::OpenArrow | LineEnd::ROpenArrow => {
let bx = px - size * ux;
let by = py - size * uy;
out.point(bx + half * nx, by + half * ny, Float::G6);
out.raw("m\n");
out.point(px, py, Float::G6);
out.raw("l\n");
out.point(bx - half * nx, by - half * ny, Float::G6);
out.raw("l\n");
out.raw("S\n");
}
LineEnd::ClosedArrow | LineEnd::RClosedArrow => {
let bx = px - size * ux;
let by = py - size * uy;
if !fill.is_empty() {
out.raw(fill);
}
out.point(px, py, Float::G6);
out.raw("m\n");
out.point(bx + half * nx, by + half * ny, Float::G6);
out.raw("l\n");
out.point(bx - half * nx, by - half * ny, Float::G6);
out.raw("l\n");
out.raw("b\n");
}
LineEnd::Square => {
let cx = px - half * ux;
let cy = py - half * uy;
if !fill.is_empty() {
out.raw(fill);
}
let corners = [
(cx + half * (-ux + nx), cy + half * (-uy + ny)),
(cx + half * (ux + nx), cy + half * (uy + ny)),
(cx + half * (ux - nx), cy + half * (uy - ny)),
(cx + half * (-ux - nx), cy + half * (-uy - ny)),
];
out.point(corners[0].0, corners[0].1, Float::G6);
out.raw("m\n");
for &(x, y) in &corners[1..] {
out.point(x, y, Float::G6);
out.raw("l\n");
}
out.raw("b\n");
}
LineEnd::Diamond => {
if !fill.is_empty() {
out.raw(fill);
}
let tips = [
(px + half * ux, py + half * uy),
(px + half * nx, py + half * ny),
(px - half * ux, py - half * uy),
(px - half * nx, py - half * ny),
];
out.point(tips[0].0, tips[0].1, Float::G6);
out.raw("m\n");
for &(x, y) in &tips[1..] {
out.point(x, y, Float::G6);
out.raw("l\n");
}
out.raw("b\n");
}
LineEnd::Circle => {
if !fill.is_empty() {
out.raw(fill);
}
let (cx, cy, rad) = (px, py, half);
out.point(cx + rad, cy, Float::G6);
out.raw("m\n");
out.point(cx + rad, cy + CIRCLE_K * rad, Float::G6);
out.point(cx + CIRCLE_K * rad, cy + rad, Float::G6);
out.point(cx, cy + rad, Float::G6);
out.raw("c\n");
out.point(cx - CIRCLE_K * rad, cy + rad, Float::G6);
out.point(cx - rad, cy + CIRCLE_K * rad, Float::G6);
out.point(cx - rad, cy, Float::G6);
out.raw("c\n");
out.point(cx - rad, cy - CIRCLE_K * rad, Float::G6);
out.point(cx - CIRCLE_K * rad, cy - rad, Float::G6);
out.point(cx, cy - rad, Float::G6);
out.raw("c\n");
out.point(cx + CIRCLE_K * rad, cy - rad, Float::G6);
out.point(cx + rad, cy - CIRCLE_K * rad, Float::G6);
out.point(cx + rad, cy, Float::G6);
out.raw("c\n");
out.raw("b\n");
}
LineEnd::Butt => {
out.point(px + half * nx, py + half * ny, Float::G6);
out.raw("m\n");
out.point(px - half * nx, py - half * ny, Float::G6);
out.raw("l\n");
out.raw("S\n");
}
LineEnd::Slash => {
let angle = core::f32::consts::FRAC_PI_3;
let (sx, sy) = (angle.cos(), angle.sin());
let rx = sx * ux - sy * uy;
let ry = sy * ux + sx * uy;
out.point(px + half * rx, py + half * ry, Float::G6);
out.raw("m\n");
out.point(px - half * rx, py - half * ry, Float::G6);
out.raw("l\n");
out.raw("S\n");
}
}
}
fn line_ending_fill<R: Resolve>(dict: &Dict, r: &R) -> String {
match dict.array(names::IC, r) {
Some(arr) if !arr.is_empty() => {
color_with_default(Some(&arr), Color::Transparent, PaintOp::Fill)
}
_ => String::new(),
}
}
#[must_use]
pub fn link<R: Resolve>(dict: &Dict, r: &R) -> Generated {
let mut out = Content::new();
out.raw(GS_SPACE);
let rect = geom::normalize(dict.rect(obj_names::RECT, r));
let bs = dict.dict(names::BS, r);
let mut info = border::border_style_info(bs.as_ref(), r);
if bs.is_none() && dict.array(obj_names::BORDER, r).is_none() {
info.width = info.width.max(1.0);
}
let color = match dict.array(names::C, r) {
Some(arr) if arr.len() >= 3 => Color::Rgb(
arr.number_at_or_zero(0),
arr.number_at_or_zero(1),
arr.number_at_or_zero(2),
),
_ => Color::Rgb(0.0, 0.0, 1.0),
};
let path = border::border_path(rect, info, color);
if path.is_empty() {
let stroke = color_op(color, PaintOp::Stroke);
out.raw(&stroke);
let width = info.width.max(1.0);
out.num(width, Float::G6);
out.raw("w ");
let inset = geom::deflate(rect, width / 2.0, width / 2.0);
out.rect(inset, Float::G6);
out.raw(
"re s
",
);
} else {
out.raw(&path);
}
Generated::plain(out)
}
#[must_use]
pub fn caret<R: Resolve>(dict: &Dict, r: &R) -> Generated {
let mut out = Content::new();
out.raw(GS_SPACE);
out.raw(&stroke_default_black(dict, r));
let width = border::border_width(dict, r).max(1.0);
out.num(width, Float::G6);
out.raw("w\n");
let rect = geom::normalize(dict.rect(obj_names::RECT, r));
let (left, bottom, right, top) = corners(rect);
let mid_x = (left + right) / 2.0;
out.point(left, bottom, Float::G6);
out.raw("m\n");
out.point(mid_x, top, Float::G6);
out.raw("l\n");
out.point(right, bottom, Float::G6);
out.raw("l\n");
out.raw("S\n");
Generated::plain(out)
}
#[must_use]
pub fn text_symbol(rect: Rect) -> String {
const TIP: f32 = 4.0;
let mut out = Content::new();
out.raw(&color_op(Color::Rgb(1.0, 1.0, 0.0), PaintOp::Fill));
out.raw(&color_op(Color::Rgb(0.0, 0.0, 0.0), PaintOp::Stroke));
out.raw("1 w\n");
let outer = geom::translate(geom::deflate(rect, 0.5, 0.5), 0.0, 0.0);
let (ol, ob, or_, ot) = (
geom::left(outer),
geom::bottom(outer) + TIP,
geom::right(outer),
geom::top(outer),
);
let (fl, fb) = (ol + TIP, ob);
let (fr, ft) = (fl + TIP, fb - TIP);
let fold_mid = (fl + fr) / 2.0;
for (x, y, op) in [
(ol, ob, "m\n"),
(ol, ot, "l\n"),
(or_, ot, "l\n"),
(or_, ob, "l\n"),
(fr, fb, "l\n"),
(fold_mid, ft, "l\n"),
(fl, fb, "l\n"),
(ol, ob, "l\n"),
] {
out.point(x, y, Float::Shortest);
out.raw(op);
}
let (line_left, line_right) = (ol + 2.0, or_ - 2.0);
let step = (ot - ob) / 4.0;
let mut y = ot;
for _ in 0..3 {
y -= step;
out.point(line_left, y, Float::Shortest);
out.raw("m\n");
out.point(line_right, y, Float::Shortest);
out.raw("l\n");
}
out.raw("B*\n");
out.as_str().to_owned()
}
fn shape_preamble<R: Resolve>(dict: &Dict, r: &R) -> (Content, Rect, bool, bool) {
let mut out = Content::new();
out.raw(GS_SPACE);
let interior = dict.array(names::IC, r);
out.raw(&color_with_default(
interior.as_ref(),
Color::Transparent,
PaintOp::Fill,
));
out.raw(&stroke_default_black(dict, r));
let width = border::border_width(dict, r);
let stroke = width > 0.0;
if stroke {
out.num(width, Float::G6);
out.raw("w ");
out.raw(&border::dash_pattern_string(dict, r));
}
let mut rect = geom::normalize(dict.rect(obj_names::RECT, r));
if stroke {
rect = geom::deflate(rect, width / 2.0, width / 2.0);
}
let fill = interior.is_some_and(|array: Array| !array.is_empty());
(out, rect, stroke, fill)
}
fn stroke_default_black<R: Resolve>(dict: &Dict, r: &R) -> String {
color_with_default(
dict.array(names::C, r).as_ref(),
Color::Rgb(0.0, 0.0, 0.0),
PaintOp::Stroke,
)
}
fn corners(rect: Rect) -> (f32, f32, f32, f32) {
(
geom::left(rect),
geom::bottom(rect),
geom::right(rect),
geom::top(rect),
)
}
#[cfg(test)]
mod tests {
use super::{
caret, circle, highlight, ink, line, link, square, squiggly, strike_out, text, text_symbol,
underline,
};
use crate::geom;
use pdfrum_common::{DiagKind, Diagnostics};
use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
fn dict(pairs: &[(&str, Object)]) -> Dict {
Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
)
}
fn numbers(values: &[f32]) -> Object {
Object::Array(Array::of(values.iter().copied().map(Object::from)))
}
fn one_quad() -> Object {
numbers(&[10.0, 20.0, 30.0, 20.0, 10.0, 10.0, 30.0, 10.0])
}
fn stream(generated: &super::Generated) -> String {
String::from_utf8_lossy(&generated.stream).into_owned()
}
#[test]
fn a_highlight_fills_each_quadrilateral_and_asks_for_multiply() {
let annot = dict(&[("QuadPoints", one_quad())]);
let got = highlight(&annot, &NoResolve);
assert!(got.blend_multiply);
assert!(got.is_text_markup);
assert_eq!(
stream(&got),
"/GS gs 1 1 0 rg\n10 20 m 30 20 l 30 10 l 10 10 l h f\n"
);
}
#[test]
fn a_highlights_default_colour_is_yellow_and_an_empty_array_beats_it() {
let with_empty = dict(&[
("C", Object::Array(Array::new())),
("QuadPoints", one_quad()),
]);
let got = stream(&highlight(&with_empty, &NoResolve));
assert!(got.starts_with("/GS gs 10 20 m"), "{got}");
}
#[test]
fn an_underline_writes_its_width_once_and_a_strikeout_writes_it_per_quad() {
let two_quads = numbers(&[
10.0, 20.0, 30.0, 20.0, 10.0, 10.0, 30.0, 10.0, 10.0, 40.0, 30.0, 40.0, 10.0, 30.0,
30.0, 30.0,
]);
let annot = dict(&[("QuadPoints", two_quads)]);
assert_eq!(
stream(&underline(&annot, &NoResolve))
.matches("1 w ")
.count(),
1
);
assert_eq!(
stream(&strike_out(&annot, &NoResolve))
.matches("1 w ")
.count(),
2
);
}
#[test]
fn an_underline_sits_one_unit_above_the_bottom_edge() {
let annot = dict(&[("QuadPoints", one_quad())]);
assert_eq!(
stream(&underline(&annot, &NoResolve)),
"/GS gs 0 0 0 RG\n1 w 10 11 m 30 11 l S\n"
);
}
#[test]
fn a_strikeout_runs_through_the_vertical_middle() {
let annot = dict(&[("QuadPoints", one_quad())]);
assert_eq!(
stream(&strike_out(&annot, &NoResolve)),
"/GS gs 0 0 0 RG\n1 w 10 15 m 30 15 l S\n"
);
}
#[test]
fn a_squiggly_alternates_every_two_units() {
let annot = dict(&[(
"QuadPoints",
numbers(&[10.0, 20.0, 16.0, 20.0, 10.0, 10.0, 16.0, 10.0]),
)]);
assert_eq!(
stream(&squiggly(&annot, &NoResolve)),
"/GS gs 0 0 0 RG\n1 w 10 12 m 12 10 l 14 12 l 16 10 l S\n"
);
}
#[test]
fn a_quadrilateral_narrower_than_one_step_draws_only_its_ends() {
let annot = dict(&[(
"QuadPoints",
numbers(&[10.0, 20.0, 11.0, 20.0, 10.0, 10.0, 11.0, 10.0]),
)]);
let got = stream(&squiggly(&annot, &NoResolve));
assert_eq!(got, "/GS gs 0 0 0 RG\n1 w 10 12 m 11 11 l S\n");
}
#[test]
fn ink_repeats_its_first_point_and_drops_an_odd_trailing_coordinate() {
let annot = dict(&[
(
"InkList",
Object::Array(Array::of([numbers(&[1.0, 2.0, 3.0, 4.0, 5.0])])),
),
("Rect", numbers(&[0.0, 0.0, 10.0, 10.0])),
]);
let mut diags = Diagnostics::default();
let got = ink(&annot, &NoResolve, &mut diags).expect("has an ink list");
assert_eq!(stream(&got), "/GS gs 0 0 0 RG\n1 w 1 2 m 1 2 l 3 4 l S\n");
assert_eq!(got.rect_override, Some(geom::rect(-0.5, -0.5, 10.5, 10.5)));
}
#[test]
fn ink_declines_entirely_without_a_usable_list_or_a_positive_width() {
let mut diags = Diagnostics::default();
assert!(ink(&Dict::new(), &NoResolve, &mut diags).is_none());
assert!(
ink(
&dict(&[("InkList", Object::Array(Array::new()))]),
&NoResolve,
&mut diags
)
.is_none()
);
let zero_width = dict(&[
(
"InkList",
Object::Array(Array::of([numbers(&[1.0, 2.0, 3.0, 4.0])])),
),
("Border", numbers(&[0.0, 0.0, 0.0])),
]);
assert!(ink(&zero_width, &NoResolve, &mut diags).is_none());
}
#[test]
fn a_too_short_ink_sub_array_is_dropped_and_recorded() {
let annot = dict(&[
(
"InkList",
Object::Array(Array::of([
numbers(&[1.0, 2.0, 3.0, 4.0]),
numbers(&[9.0]),
Object::Int(7),
])),
),
("Rect", numbers(&[0.0, 0.0, 10.0, 10.0])),
]);
let mut diags = Diagnostics::default();
let got = ink(&annot, &NoResolve, &mut diags).expect("has an ink list");
assert_eq!(stream(&got), "/GS gs 0 0 0 RG\n1 w 1 2 m 1 2 l 3 4 l S\n");
assert!(diags.contains(&DiagKind::InkPathDropped));
assert_eq!(diags.recorded(), 2, "one per dropped sub-array");
}
#[test]
fn a_square_deflates_by_half_its_border_and_names_its_paint_operator() {
let annot = dict(&[("Rect", numbers(&[0.0, 0.0, 10.0, 10.0]))]);
assert_eq!(
stream(&square(&annot, &NoResolve)),
"/GS gs 0 0 0 RG\n1 w 0.5 0.5 9 9 re s\n"
);
let filled = dict(&[
("Rect", numbers(&[0.0, 0.0, 10.0, 10.0])),
("IC", numbers(&[0.0, 0.0, 1.0])),
]);
let got = stream(&square(&filled, &NoResolve));
assert!(got.contains("0 0 1 rg\n"), "{got}");
assert!(got.ends_with("re b\n"), "{got}");
}
#[test]
fn a_present_but_empty_interior_colour_leaves_the_square_unfilled() {
let annot = dict(&[
("Rect", numbers(&[0.0, 0.0, 10.0, 10.0])),
("IC", Object::Array(Array::new())),
]);
assert!(stream(&square(&annot, &NoResolve)).ends_with("re s\n"));
}
#[test]
fn a_circle_draws_four_bezier_arcs() {
let annot = dict(&[("Rect", numbers(&[0.0, 0.0, 10.0, 10.0]))]);
let got = stream(&circle(&annot, &NoResolve));
assert_eq!(got.matches(" c\n").count(), 4);
assert!(got.contains("5 9.5 m\n"), "{got}");
assert!(got.ends_with("s\n"), "{got}");
}
#[test]
fn a_sticky_note_replaces_its_rectangle_with_a_twenty_unit_box() {
let annot = dict(&[("Rect", numbers(&[234.372, 340.046, 400.0, 500.0]))]);
let got = text(&annot, &NoResolve);
assert_eq!(
got.rect_override,
Some(geom::rect(234.372, 340.046, 254.372, 360.046))
);
assert!(stream(&got).starts_with("/GS gs 1 1 0 rg\n0 0 0 RG\n1 w\n"));
}
#[test]
fn the_note_icon_folds_a_corner_and_rules_three_lines() {
let got = text_symbol(geom::rect(0.0, 0.0, 20.0, 20.0));
assert_eq!(got.matches(" m\n").count(), 4);
assert_eq!(got.matches(" l\n").count(), 10);
assert!(got.ends_with("B*\n"), "{got}");
}
#[test]
fn a_line_strokes_between_its_endpoints() {
let annot = dict(&[
("Rect", numbers(&[0.0, 0.0, 100.0, 100.0])),
("L", numbers(&[10.0, 10.0, 90.0, 90.0])),
(
"BS",
Object::Dict(Dict::from_pairs([(Name::from("W"), Object::from(2.0_f32))])),
),
]);
let got = line(&annot, &NoResolve).expect("line");
let s = stream(&got);
assert!(s.contains("10 10 m\n"), "{s}");
assert!(s.contains("90 90 l\n"), "{s}");
assert!(s.ends_with("S\n"), "{s}");
}
#[test]
fn a_line_declines_without_endpoints() {
let annot = dict(&[("Rect", numbers(&[0.0, 0.0, 10.0, 10.0]))]);
assert!(line(&annot, &NoResolve).is_none());
}
#[test]
fn a_link_draws_a_solid_border_donut() {
let annot = dict(&[("Rect", numbers(&[0.0, 0.0, 50.0, 12.0]))]);
let s = stream(&link(&annot, &NoResolve));
assert!(s.contains("0 0 1 rg"), "{s}");
assert!(s.contains("re f*"), "{s}");
assert!(s.contains("0 0 50 12 re"), "{s}");
assert!(s.contains("1 1 48 10 re"), "{s}");
}
#[test]
fn a_link_with_underline_bs_strokes_the_bottom_edge() {
let annot = dict(&[
("Rect", numbers(&[0.0, 0.0, 50.0, 12.0])),
(
"BS",
Object::Dict(Dict::from_pairs([
(Name::from("W"), Object::from(1.0_f32)),
(Name::from("S"), Object::Name(Name::from("U"))),
])),
),
]);
let s = stream(&link(&annot, &NoResolve));
assert!(
s.contains("0 0 1 RG") || s.contains("0 0 1 RG\n") || s.contains("0 0 1 RG "),
"{s}"
);
assert!(
s.contains(" S\n") || s.ends_with("S\n") || s.contains("S\n"),
"{s}"
);
}
#[test]
fn a_caret_draws_an_inverted_vee() {
let annot = dict(&[("Rect", numbers(&[0.0, 0.0, 10.0, 20.0]))]);
let s = stream(&caret(&annot, &NoResolve));
assert_eq!(s.matches(" m\n").count(), 1);
assert_eq!(s.matches(" l\n").count(), 2);
assert!(s.ends_with("S\n"), "{s}");
}
}
#[cfg(test)]
mod line_ending_tests {
use super::line;
use crate::names as doc_names;
use pdfrum_object::{Array, Dict, Name, NoResolve, Object, names};
fn line_dict(le: Option<[&str; 2]>) -> Dict {
let mut dict = Dict::from_pairs([
(
names::L.clone(),
Object::Array(Array::of([
Object::Real(0.0),
Object::Real(0.0),
Object::Real(100.0),
Object::Real(0.0),
])),
),
(
names::RECT.clone(),
Object::Array(Array::of([
Object::Real(0.0),
Object::Real(-10.0),
Object::Real(100.0),
Object::Real(10.0),
])),
),
(
names::C.clone(),
Object::Array(Array::of([
Object::Real(0.0),
Object::Real(0.0),
Object::Real(0.0),
])),
),
(
doc_names::BS.clone(),
Object::Dict(Dict::from_pairs([(names::W.clone(), Object::Real(2.0))])),
),
]);
if let Some([a, b]) = le {
dict.insert(
doc_names::LE.clone(),
Object::Array(Array::of([
Object::Name(Name::from(a)),
Object::Name(Name::from(b)),
])),
);
}
dict
}
#[test]
fn line_without_le_is_stroke_only() {
let generated = line(&line_dict(None), &NoResolve).expect("line");
let s = String::from_utf8(generated.stream).expect("utf8");
assert!(s.contains("S\n"), "{s}");
assert!(
!s.contains("\nb\n") && !s.contains(" b\n"),
"no fill close without LE: {s}"
);
}
#[test]
fn line_draws_closed_arrow_ops() {
let generated = line(&line_dict(Some(["None", "ClosedArrow"])), &NoResolve).expect("line");
let s = String::from_utf8(generated.stream).expect("utf8");
assert!(
s.contains('b') || s.contains("b\n"),
"closed arrow close: {s}"
);
assert!(s.matches("m\n").count() >= 2 || s.contains("m\n"), "{s}");
assert!(!s.contains(" rg") && !s.contains("rg\n"), "no IC fill: {s}");
}
#[test]
fn line_closed_arrow_uses_ic_fill() {
let mut d = line_dict(Some(["None", "ClosedArrow"]));
d.insert(
names::IC.clone(),
Object::Array(Array::of([
Object::Real(1.0),
Object::Real(0.0),
Object::Real(0.0),
])),
);
let generated = line(&d, &NoResolve).expect("line");
let s = String::from_utf8(generated.stream).expect("utf8");
assert!(s.contains("1 0 0 rg") || s.contains("1 0 0 rg\n"), "{s}");
assert!(s.contains('b') || s.contains("b\n"), "{s}");
}
#[test]
fn line_ending_size_tracks_stroke_width() {
let generated = line(&line_dict(Some(["OpenArrow", "None"])), &NoResolve).expect("line");
let s = String::from_utf8(generated.stream).expect("utf8");
assert!(s.contains("6 -3 m\n") || s.contains("6 -3 m"), "{s}");
assert!(s.contains("6 3 l\n") || s.contains("6 3 l"), "{s}");
}
#[test]
fn line_draws_open_arrow_stroke() {
let generated = line(&line_dict(Some(["OpenArrow", "None"])), &NoResolve).expect("line");
let s = String::from_utf8(generated.stream).expect("utf8");
assert!(s.matches("S\n").count() >= 2, "open arrow strokes: {s}");
}
}