Files
pdfcraft/crates/annot/src/appearance.rs
T

1234 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Appearance streams (§12.5.5) for the comment types PdfCraft creates, execution plan M5.2.
//!
//! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary alone, 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, indirect geometry), so callers never replace an appearance with a worse one.
//!
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard
//! Helvetica font with WinAnsi encoding; line breaking uses [`text_width`], an approximation of
//! Helvetica's proportions by character class (no font program or metrics file is bundled).
use pdfcraft_cos::{Dict, Object, PdfString, Stream};
use pdfcraft_fonts::{first_undrawable, literal, win_ansi};
pub use pdfcraft_fonts::{helvetica_width as text_width, wrap};
use crate::{NOTE_SIZE, Rgb, n};
/// Length of an arrowhead for a line of width `w`.
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).
pub fn cloud_radius(w: f64) -> f64 {
6.0 + 1.5 * w.max(0.0)
}
/// One of the ten `/LE` names (ISO 32000-2 Table 217). Unknown names are not drawn.
pub(crate) fn known_ending(kind: &[u8]) -> bool {
std::str::from_utf8(kind).ok().and_then(crate::LineEnding::parse).is_some()
}
/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`.
///
/// Returns false for an unknown style or a non-finite direction, so the caller keeps the
/// previous appearance instead of writing `NaN`. `None` and a zero-length direction draw nothing.
fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) -> bool {
if kind == b"None" {
return true;
}
if !known_ending(kind) {
return false;
}
let (dx, dy) = (from.0 - tip.0, from.1 - tip.1);
if !dx.is_finite() || !dy.is_finite() {
return false;
}
let len = dx.hypot(dy);
if !len.is_finite() {
return false;
}
if len == 0.0 {
return true;
}
let s = arrow_size(w);
if !s.is_finite() {
return false;
}
let (ix, iy) = (dx / len, dy / len);
match kind {
b"OpenArrow" | b"ClosedArrow" | b"ROpenArrow" | b"RClosedArrow" => {
// Open and closed arrows keep their original geometry. A reverse arrow uses the same
// head, pointing back along the line.
let (ux, uy) = if kind.starts_with(b"R") { (-ix, -iy) } else { (ix, iy) };
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos));
let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos));
let op = if kind.ends_with(b"ClosedArrow") { "h B" } else { "S" };
c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1)));
}
b"Square" | b"Circle" | b"Diamond" => {
let h = s / 2.0;
if kind == b"Square" {
c.push_str(&format!("{} {} {} {} re h B\n", n(tip.0 - h), n(tip.1 - h), n(s), n(s)));
} else if kind == b"Circle" {
c.push_str(&ellipse(tip.0 - h, tip.1 - h, tip.0 + h, tip.1 + h));
c.push_str("B\n");
} else {
let (px, py) = (-iy, ix);
let v = |along: f64, across: f64| (tip.0 + ix * along + px * across, tip.1 + iy * along + py * across);
let (a, b, d, e) = (v(h, 0.0), v(0.0, h), v(-h, 0.0), v(0.0, -h));
c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h B\n", n(a.0), n(a.1), n(b.0), n(b.1), n(d.0), n(d.1), n(e.0), n(e.1)));
}
}
_ => {
// Butt is perpendicular to the line. Slash is that stroke turned 45 degrees.
let (px, py) = (-iy, ix);
let (sx, sy) = if kind == b"Slash" {
let q = std::f64::consts::FRAC_1_SQRT_2;
(px * q - py * q, px * q + py * q)
} else {
(px, py)
};
let h = s / 2.0;
c.push_str(&format!("{} {} m {} {} l S\n", n(tip.0 - sx * h), n(tip.1 - sy * h), n(tip.0 + sx * h), n(tip.1 + sy * h)));
}
}
true
}
/// The cubic Bézier segments (first control point, second control point, end) of a smooth curve
/// through every point of `pts`, from the first: a Catmull-Rom spline, so the curve leaves each
/// point parallel to the chord between its neighbours (an end uses itself as the missing
/// neighbour). Ink is drawn so, as Acrobat draws it, rather than as straight segments. The curve
/// stays within its points and these control points.
pub fn smooth_segments(pts: &[(f64, f64)]) -> Vec<[(f64, f64); 3]> {
let Some(&last) = pts.last() else { return Vec::new() };
let at = |i: usize| pts.get(i).copied().unwrap_or(last);
(0..pts.len().saturating_sub(1))
.map(|i| {
let (p0, p1, p2, p3) = (at(i.saturating_sub(1)), at(i), at(i + 1), at(i + 2));
[(p1.0 + (p2.0 - p0.0) / 6.0, p1.1 + (p2.1 - p0.1) / 6.0), (p2.0 - (p3.0 - p1.0) / 6.0, p2.1 - (p3.1 - p1.1) / 6.0), p2]
})
.collect()
}
/// `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()
}
/// 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 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 {
let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum();
// Outward normal of an edge: to the right of travel for counter-clockwise outlines.
let sign = if area >= 0.0 { 1.0 } else { -1.0 };
let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum();
// Each edge rounds to at least one bump, so the total stays within the budget plus one per edge.
let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64));
let mut c = String::new();
for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
let len = dx.hypot(dy);
if i == 0 {
c.push_str(&format!("{} {} m\n", n(a.0), n(a.1)));
}
if len < 1e-6 {
continue;
}
let k = ((len / (2.0 * r)).round() as usize).clamp(1, 10_000);
let step = len / k as f64;
let (ux, uy) = (dx / len, dy / len);
let (nx, ny) = (sign * uy, -sign * ux);
let h = 4.0 / 3.0 * step / 2.0;
for j in 0..k {
let p0 = (a.0 + ux * step * j as f64, a.1 + uy * step * j as f64);
let p1 = (a.0 + ux * step * (j + 1) as f64, a.1 + uy * step * (j + 1) as f64);
c.push_str(&format!("{} {} {} {} {} {} c\n", n(p0.0 + nx * h), n(p0.1 + ny * h), n(p1.0 + nx * h), n(p1.1 + ny * h), n(p1.0), n(p1.1)));
}
}
c.push_str("h\n");
c
}
fn nums(d: &Dict, key: &[u8]) -> Option<Vec<f64>> {
d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect()
}
/// `/C`-style colour arrays: none (transparent), gray, RGB or CMYK.
fn color(d: &Dict, key: &[u8]) -> Option<Option<Rgb>> {
let c = match d.get(key) {
None => return Some(None),
Some(o) => o.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<Vec<f64>>>()?,
};
Some(match c.as_slice() {
[] => None,
[g] => Some([*g; 3]),
[r, g, b] => Some([*r, *g, *b]),
[c, m, y, k] => Some([(1.0 - c) * (1.0 - k), (1.0 - m) * (1.0 - k), (1.0 - y) * (1.0 - k)]),
_ => return None,
})
}
fn interior_color(d: &Dict, fallback: Option<Rgb>) -> Option<Rgb> {
color(d, b"IC").flatten().or(fallback)
}
/// The border width (ISO 32000-2 §12.5.4): `/BS /W` when there is a border style dictionary, which
/// makes `/Border` ignored (Table 166), else the third element of `/Border [hr vr w [dash]]`, read
/// whether or not the optional fourth element, a dash array, follows. 1 when neither says, and
/// when the number given is negative or not finite.
pub(crate) fn border_width(d: &Dict) -> f64 {
let width = match d.get(b"BS").and_then(Object::as_dict) {
Some(bs) => bs.get(b"W").and_then(Object::as_f64),
None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(2)).and_then(Object::as_f64),
};
width.filter(|w| w.is_finite() && *w >= 0.0).unwrap_or(1.0)
}
/// Elements a dash array may have before it is drawn solid instead: the lengths are written into
/// the appearance as they are, so a file can't make it arbitrarily large.
const MAX_DASH: usize = 64;
/// A dash array (§8.4.3.6) as drawn: every element finite and non-negative, not all zero (that
/// pattern is invalid) and at most [`MAX_DASH`] long. `None` for anything else, which is drawn solid.
fn dash_array(o: &Object) -> Option<Vec<f64>> {
let a = o.as_array().filter(|a| !a.is_empty() && a.len() <= MAX_DASH)?;
let v: Vec<f64> = a.iter().map(Object::as_f64).collect::<Option<_>>()?;
(v.iter().all(|x| x.is_finite() && *x >= 0.0) && v.iter().any(|x| *x > 0.0)).then_some(v)
}
/// Dash pattern operator for a dashed border, if any: `/BS /S /D` with its `/D` (default `[3]`,
/// Table 168), or, without `/BS`, the dash array that may follow the width in `/Border` (Table 166).
fn dash(d: &Dict) -> String {
let arr = match d.get(b"BS").and_then(Object::as_dict) {
Some(bs) if bs.name(b"S") != Some(b"D") => return String::new(),
Some(bs) => match bs.get(b"D") {
None => Some(vec![3.0]),
Some(o) => dash_array(o),
},
None => d.get(b"Border").and_then(Object::as_array).and_then(|b| b.get(3)).and_then(dash_array),
};
let Some(arr) = arr else { return String::new() };
format!("[{}] 0 d\n", arr.iter().map(|x| n(*x)).collect::<Vec<_>>().join(" "))
}
fn rg(c: Rgb) -> String {
format!("{} {} {} rg\n", n(c[0]), n(c[1]), n(c[2]))
}
fn rg_stroke(c: Rgb) -> String {
format!("{} {} {} RG\n", n(c[0]), n(c[1]), n(c[2]))
}
/// `/DA` of a text box: (text colour, font size). Defaults to black 12 pt.
pub fn parse_da(d: &Dict) -> (Rgb, f64) {
let da = d.get(b"DA").and_then(|o| o.as_string()).map(|s| String::from_utf8_lossy(&s.bytes).into_owned()).unwrap_or_default();
let toks: Vec<&str> = da.split_whitespace().collect();
let mut col = [0.0; 3];
let mut size = 12.0;
let num = |i: usize| toks.get(i).and_then(|t| t.parse::<f64>().ok());
for (i, t) in toks.iter().enumerate() {
match *t {
"rg" if i >= 3 => {
if let (Some(r), Some(g), Some(b)) = (num(i - 3), num(i - 2), num(i - 1)) {
col = [r, g, b];
}
}
"g" if i >= 1 => {
if let Some(g) = num(i - 1) {
col = [g; 3];
}
}
"Tf" if i >= 1 => {
if let Some(s) = num(i - 1).filter(|s| *s > 0.0) {
size = s;
}
}
_ => {}
}
}
(col.map(|x| x.clamp(0.0, 1.0)), size.min(400.0))
}
fn ext_gstate(opacity: f64, multiply: bool) -> Option<Dict> {
if opacity >= 1.0 && !multiply {
return None;
}
let mut gs = Dict::new();
gs.set(b"Type".to_vec(), Object::name("ExtGState"));
if multiply {
gs.set(b"BM".to_vec(), Object::name("Multiply"));
}
gs.set(b"CA".to_vec(), Object::Real(opacity));
gs.set(b"ca".to_vec(), Object::Real(opacity));
let mut res = Dict::new();
res.set(b"GS0".to_vec(), Object::Dict(gs));
Some(res)
}
fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream {
let mut d = Dict::new();
d.set(b"Type".to_vec(), Object::name("XObject"));
d.set(b"Subtype".to_vec(), Object::name("Form"));
d.set(b"FormType".to_vec(), Object::Int(1));
d.set(b"BBox".to_vec(), Object::Array(bbox.iter().map(|x| Object::Real(*x)).collect()));
d.set(b"Resources".to_vec(), Object::Dict(resources));
Stream::flate(d, content)
}
/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written
/// when the signature is added to a turned page): 0, 90, 180 or 270.
pub(crate) fn picture_turn(d: &Dict) -> i64 {
d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// The page `/Rotate` a rubber stamp's label is turned back by (`/PCStampRotate`).
fn stamp_turn(d: &Dict) -> i64 {
d.get(b"PCStampRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// The page `/Rotate` a text box's or Fill & Sign text's lines are turned back by
/// (`/PCTextRotate`, written when it is added to a turned page).
fn text_turn(d: &Dict) -> i64 {
d.get(b"PCTextRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's
/// "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of
/// it can be drawn.
pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text);
let (what, text) = match d.name(b"Subtype")? {
b"FreeText" => ("the comment's text", text(b"Contents")?),
b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?),
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?),
// Drawn in Helvetica when the redaction is applied: refused when it is marked.
b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?),
_ => return None,
};
Some((what, first_undrawable(&text)?))
}
/// `/Font` resources naming `/Helv`: standard Helvetica with WinAnsiEncoding.
fn helvetica_resource() -> Object {
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type1"));
font.set(b"BaseFont".to_vec(), Object::name("Helvetica"));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
let mut fonts = Dict::new();
fonts.set(b"Helv".to_vec(), Object::Dict(font));
Object::Dict(fonts)
}
/// Where a caret and its paragraph symbol go within `rect`: the box the caret is drawn in, and for
/// `/Sy /P` the symbol's baseline origin and font size (ISO 32000-2 Table 183).
///
/// The caret lies within `/Rect` less `/RD` (left, top, right, bottom), when `/RD` is four finite,
/// non-negative numbers that leave a box; otherwise within `/Rect`. The ¶ sits to the right of the
/// caret, its height a little over half the caret's, in the room `/RD` leaves on the right; when
/// that is too narrow, the caret gives up to half its width and the symbol shrinks to what is left,
/// so both stay inside `/Rect` (the form's `/BBox`).
fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) {
let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0));
let mut caret = match rd {
Some(rd) => [rect[0] + rd[0], rect[1] + rd[3], rect[2] - rd[2], rect[3] - rd[1]],
None => rect,
};
if !(caret[2] > caret[0] && caret[3] > caret[1]) {
caret = rect;
}
if d.name(b"Sy") != Some(b"P") {
return (caret, None);
}
let size = (caret[3] - caret[1]) * 0.6;
let (gap, glyph) = (size * 0.1, text_width("\u{b6}", size));
let need = gap + glyph;
let room = rect[2] - caret[2];
if room < need {
caret[2] -= (need - room).min((caret[2] - caret[0]) * 0.5);
}
let room = rect[2] - caret[2];
let scale = if need > room { room / need } else { 1.0 };
let size = size * scale;
if !(size.is_finite() && size > 0.0) {
return (caret, None);
}
// The pilcrow descends about a sixth of the size below its baseline.
(caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size)))
}
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and
/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`).
pub fn build(d: &Dict) -> Option<Stream> {
// Imported measurement appearances may include leaders, captions and formatting that
// this builder cannot reproduce. Preserve them rather than silently losing detail.
if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") {
return None;
}
let subtype = d.name(b"Subtype")?.to_vec();
let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?;
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
// 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" {
return None;
}
let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0);
let stroke = color(d, b"C")?;
let w = border_width(d);
let mut res = Dict::new();
let mut c = String::new();
let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly");
if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") {
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
c.push_str("/GS0 gs\n");
}
match subtype.as_slice() {
b"Text" => {
let col = stroke.unwrap_or([1.0, 0.82, 0.0]);
let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "Note".into());
c.push_str(&note_icon(&icon, col));
return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res));
}
b"FileAttachment" => {
let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into());
c.push_str(&attach_icon(&icon, col));
return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res));
}
_ if markup => {
let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
let col = stroke?;
for quad in q.as_chunks::<8>().0.iter() {
let p = |i: usize| (quad[2 * i], quad[2 * i + 1]);
let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3));
let h = (p1.0 - p3.0).hypot(p1.1 - p3.1);
// A point a fraction `t` of the way from the bottom edge to the top edge.
let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t);
match subtype.as_slice() {
b"Highlight" => {
c.push_str(&rg(col));
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 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 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);
c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw)));
for i in 0..=steps {
let t = i as f64 / steps as f64;
let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t);
let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 };
let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) };
let (x, y) = (base.0 + ux * up, base.1 + uy * up);
c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" }));
}
c.push_str("S\n");
}
}
}
}
b"Redact" => {
// While marked: the outline of each area (the fill comes when applied).
let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
let col = stroke.unwrap_or([0.89, 0.13, 0.13]);
c.push_str(&format!("{}1 w\n", rg_stroke(col)));
for quad in q.as_chunks::<8>().0.iter() {
c.push_str(&format!(
"{} {} m {} {} l {} {} l {} {} l h S\n",
n(quad[0]),
n(quad[1]),
n(quad[2]),
n(quad[3]),
n(quad[6]),
n(quad[7]),
n(quad[4]),
n(quad[5])
));
}
}
b"Square" | b"Circle" => {
let fill = interior_color(d, None);
if stroke.is_none() && fill.is_none() {
return Some(form(rect, c.as_bytes(), res));
}
let inset = w / 2.0;
let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[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" {
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));
}
c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) {
(true, true) => "B\n",
(true, false) => "f\n",
(false, true) => "S\n",
(false, false) => "n\n",
});
}
b"Line" => {
let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?;
let col = stroke?;
let ends: Vec<Vec<u8>> = match d.get(b"LE") {
None => vec![b"None".to_vec(), b"None".to_vec()],
Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::<Option<_>>()?,
};
if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) {
return None;
}
let fill = interior_color(d, Some(col)).unwrap_or(col);
c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d)));
c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3])));
for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) {
if !line_end(&mut c, end, tip, from, w) {
return None;
}
}
}
b"Polygon" | b"PolyLine" => {
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();
let col = stroke;
let closed = subtype == b"Polygon";
let fill = if closed { interior_color(d, None) } else { None };
let ends = if subtype == b"PolyLine" {
match d.get(b"LE") {
None => None,
Some(o) => {
let names: Vec<Vec<u8>> =
o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::<Option<_>>()?;
if names.iter().any(|e| !known_ending(e)) {
return None;
}
Some(names)
}
}
} else {
None
};
if let Some(f) = fill {
c.push_str(&rg(f));
}
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)));
} 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" }));
}
if closed {
c.push_str("h\n");
}
}
c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) {
(true, true) => "B\n",
(true, false) => "f\n",
(false, true) => "S\n",
(false, false) => "n\n",
});
if let Some(ends) = ends {
let col = col?;
let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None };
let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None };
let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None };
let fill = interior_color(d, Some(col)).unwrap_or(col);
c.push_str(&format!("[] 0 d\n{}", rg(fill)));
if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) {
return None;
}
}
}
b"Caret" => {
// A filled caret: two curved flanks meeting at the top centre.
let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
let (caret, pilcrow) = caret_layout(d, rect);
let [x0, y0, x1, y1] = caret;
let (cx, h) = ((x0 + x1) / 2.0, y1 - y0);
c.push_str(&format!(
"{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n",
rg(col),
n(x0),
n(y0),
n(cx - (x1 - x0) * 0.1),
n(y0 + h * 0.2),
n(cx),
n(y1 - h * 0.25),
n(cx),
n(y1),
n(cx),
n(y1 - h * 0.25),
n(cx + (x1 - x0) * 0.1),
n(y0 + h * 0.2),
n(x1),
n(y0)
));
if let Some((x, y, size)) = pilcrow {
// `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour.
// 0xB6 is the pilcrow in WinAnsiEncoding.
let mut out = c.into_bytes();
out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes());
out.push(0xB6);
out.extend_from_slice(b") Tj ET\n");
res.set(b"Font".to_vec(), helvetica_resource());
return Some(form(rect, &out, res));
}
}
b"Ink" => {
let col = stroke?;
let list = d.get(b"InkList")?.as_array()?;
// `/BS` gives an ink stroke its width and dash pattern (Table 182).
c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d)));
for s in list {
let pts: Vec<f64> = s.as_array()?.iter().map(|o| o.as_f64()).collect::<Option<_>>()?;
let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
let Some(first) = pts.first() else { continue };
c.push_str(&format!("{} {} m\n", n(first.0), n(first.1)));
match pts.as_slice() {
[_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))),
[_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))),
_ => c.push_str(&smooth_curve(&pts)),
}
c.push_str("S\n");
}
}
b"Stamp" => {
// A typed signature: filled outlines normalised to the rectangle.
if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) {
let [x0, y0, x1, y1] = rect;
let (w, h) = (x1 - x0, y1 - y0);
c.push_str(&rg(stroke.unwrap_or([0.0; 3])));
for contour in outline {
let v: Vec<f64> = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default();
for (i, p) in v.as_chunks::<2>().0.iter().enumerate() {
c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" }));
}
if v.len() >= 6 {
c.push_str("h\n");
}
}
c.push_str("f\n");
return Some(form(rect, c.as_bytes(), res));
}
// A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills
// the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets
// the standard counter-rotation as its form `/Matrix`: viewers fit the turned
// bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed.
if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) {
let [x0, y0, x1, y1] = rect;
let (w, h) = (x1 - x0, y1 - y0);
let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true)));
let place = if image {
format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0))
} else {
let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?;
format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0))
};
c.push_str(&format!("q {place} /Pic Do Q\n"));
let mut xo = Dict::new();
xo.set(b"Pic".to_vec(), Object::Ref(pic));
res.set(b"XObject".to_vec(), Object::Dict(xo));
let mut stream = form(rect, c.as_bytes(), res);
let turn = picture_turn(d);
if image && turn != 0 {
// The page's view matrix without its offset: display axes in user space.
let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]);
let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0];
stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect()));
}
return Some(stream);
}
// Only PdfCraft's own Fill & Sign marks are drawn here.
let name = d.name(b"Name")?;
let col = stroke.unwrap_or([0.0; 3]);
// A mark or stamp placed on a turned page is drawn upright in its displayed box, then
// mapped into `/Rect` the way the page maps display space into user space. Viewers
// fit that box to `/Rect`; the page rotation then shows it the right way up.
let turn = stamp_turn(d);
let draw = if turn == 0 {
rect
} else {
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
if dw < 1.0 || dh < 1.0 {
return None;
}
[0.0, 0.0, dw, dh]
};
let turned = |mut stream: Stream| {
if turn != 0 {
let m = pdfcraft_model::view_matrix_for(turn, rect);
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
}
stream
};
let [x0, y0, x1, y1] = draw;
let (w, h) = (x1 - x0, y1 - y0);
let lw = w.min(h) * 0.12;
match name {
b"PCCheck" => c.push_str(&format!(
"{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n",
rg_stroke(col),
n(lw),
n(x0 + w * 0.15),
n(y0 + h * 0.5),
n(x0 + w * 0.4),
n(y0 + h * 0.2),
n(x0 + w * 0.88),
n(y0 + h * 0.85)
)),
b"PCCross" => c.push_str(&format!(
"{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n",
rg_stroke(col),
n(lw),
n(x0 + w * 0.18),
n(y0 + h * 0.18),
n(x1 - w * 0.18),
n(y1 - h * 0.18),
n(x0 + w * 0.18),
n(y1 - h * 0.18),
n(x1 - w * 0.18),
n(y0 + h * 0.18)
)),
b"PCDot" => {
let r = w.min(h) * 0.3;
c.push_str(&rg(col));
c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r));
c.push_str("f\n");
}
b"PCLine" => c.push_str(&format!(
"{}{} w 1 J\n{} {} m {} {} l S\n",
rg_stroke(col),
n(h.clamp(0.5, 2.0)),
n(x0),
n(y0 + h / 2.0),
n(x1),
n(y0 + h / 2.0)
)),
other => {
// Only stamps PdfCraft made: others keep their own artwork.
if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) {
return None;
}
let kind = crate::StampKind::from_name(other)?;
let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text);
let sign_here = kind.group() == crate::StampGroup::SignHere;
return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res)));
}
}
if turn != 0 {
return Some(turned(form(draw, c.as_bytes(), res)));
}
}
b"FreeText" => {
let (text_color, size) = parse_da(d);
let bg = stroke;
let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 };
// A callout: the leader line (arrowhead at its first point), and the text box inside
// `/Rect` by `/RD`.
// Text added to a turned page is drawn upright in its displayed box, which the form
// `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes.
let turn = if d.contains(b"CL") { 0 } else { text_turn(d) };
let (rect, matrix) = if turn == 0 {
(rect, None)
} else {
let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]);
let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) };
if dw < 1.0 || dh < 1.0 {
return None;
}
([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect)))
};
let full = rect;
let mut rect = rect;
if d.contains(b"CL") {
let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?;
let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?;
rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]];
if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return None;
}
let end = match d.get(b"LE") {
None => b"None".to_vec(),
Some(o) => o.as_name()?.to_vec(),
};
if !known_ending(&end) {
return None;
}
let lw = bw.max(0.5);
let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect();
if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) {
return None;
}
c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw)));
for (i, p) in pts.iter().enumerate() {
c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" }));
}
c.push_str("S\n");
if !line_end(&mut c, &end, pts[0], pts[1], lw) {
return None;
}
}
if let Some(bg) = bg {
c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1])));
}
if bw > 0.0 {
let h = bw / 2.0;
c.push_str(&format!(
"{}{} w\n{}{} {} {} {} re S\n[] 0 d\n",
rg_stroke(text_color),
n(bw),
dash(d),
n(rect[0] + h),
n(rect[1] + h),
n(rect[2] - rect[0] - bw),
n(rect[3] - rect[1] - bw)
));
}
let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default();
let pad = 2.0 + bw;
let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0);
let q = d.int(b"Q").unwrap_or(0);
c.push_str(&format!(
"{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
n(rect[0]),
n(rect[1]),
n(rect[2] - rect[0]),
n(rect[3] - rect[1]),
n(size),
rg(text_color)
));
let mut out = c.into_bytes();
let mut y = rect[3] - pad - size * 0.9;
for line in wrap(&text, size, width) {
if y < rect[1] - size {
break;
}
let lw = text_width(&line, size);
let x = match q {
1 => rect[0] + pad + (width - lw) / 2.0,
2 => rect[2] - pad - lw,
_ => rect[0] + pad,
};
out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes());
out.extend(literal(&win_ansi(&line)));
out.extend_from_slice(b" Tj\n");
y -= size * 1.2;
}
out.extend_from_slice(b"ET\n");
res.set(b"Font".to_vec(), helvetica_resource());
let mut stream = form(full, &out, res);
if let Some(m) = matrix {
stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect()));
}
return Some(stream);
}
_ => return None,
}
// PdfCraft measurement captions are kept separate from the comment's free-form text.
// A restyle regenerates the path and its value together.
let mut out = c.into_bytes();
if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) {
let value = value.to_text();
if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") {
let size = 10.0;
let x = (rect[0] + rect[2] - text_width(&value, size)) * 0.5;
let y = rect[3] - 12.0;
let col = stroke.unwrap_or([0.0, 0.47, 0.84]);
out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes());
// WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8.
out.extend(literal(&win_ansi(&value)));
out.extend_from_slice(b" Tj ET\n");
res.set(b"Font".to_vec(), helvetica_resource());
}
}
Some(form(rect, &out, res))
}
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
/// are compared (§7.3.5). Eight are stamps of PdfCraft's palette; the other six take a label of
/// their own and the palette's colour for their meaning.
fn standard_stamp(name: &[u8]) -> Option<(&'static str, Rgb)> {
use crate::StampKind::*;
let palette = [Approved, NotApproved, NotForPublicRelease, Confidential, Final, ForComment, Draft, ForPublicRelease];
if let Some(kind) = palette.into_iter().find(|k| k.name().as_bytes() == name) {
return Some((kind.label(), kind.color()));
}
let (label, like) = match name {
b"Experimental" => ("EXPERIMENTAL", Draft),
b"AsIs" => ("AS IS", Draft),
b"Expired" => ("EXPIRED", NotApproved),
b"Sold" => ("SOLD", Approved),
b"Departmental" => ("DEPARTMENTAL", Draft),
b"TopSecret" => ("TOP SECRET", Confidential),
_ => return None,
};
Some((label, like.color()))
}
/// The neutral grey of a stamp whose name has no drawing.
const PLACEHOLDER_GREY: Rgb = [0.45, 0.45, 0.45];
/// The display appearance of a rubber stamp another program wrote without one: the stamp its
/// `/Name` names (Draft when there is none, Table 184's default) in its `/C` colour or the
/// stamp's own, or for any other name a thin grey frame with both diagonals, so the stamp stays
/// visible and can be selected. The frame is grey whatever the `/C`: it stands in for a stamp
/// that cannot be drawn, so it takes none of that stamp's colour. The name is never drawn as text.
///
/// For display only ([`crate::with_missing_appearances`]): [`build`] declines these stamps, so
/// edits (restyling, flattening) never write this drawing into the file. `None` for any other
/// annotation, one of PdfCraft's own stamps, a dictionary with an `/AP` (a stamp's own artwork is
/// never replaced; the display copy passes the dictionary without an `/AP` that has no normal
/// appearance), or a `/Rect` that isn't finite or is under a point wide or high.
pub(crate) fn display_stamp(d: &Dict) -> Option<Stream> {
let own = matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) || d.contains(b"PCPicture") || d.contains(b"PCOutline");
if d.name(b"Subtype") != Some(b"Stamp") || own || d.contains(b"AP") {
return None;
}
let rect = nums(d, b"Rect")?;
let [a, b, c, e] = rect.as_slice() else { return None };
let rect = [a.min(*c), b.min(*e), a.max(*c), b.max(*e)];
if !rect.iter().all(|v| v.is_finite()) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return None;
}
let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0);
// A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp.
let stroke = color(d, b"C").flatten();
let mut res = Dict::new();
if let Some(gs) = ext_gstate(opacity, false) {
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
}
// A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default.
let stamp_intent = d.name(b"IT").is_none_or(|it| it == b"Stamp");
let name = d.name(b"Name").or(stamp_intent.then_some(b"Draft".as_slice()));
if let Some((label, col)) = name.and_then(standard_stamp) {
return Some(stamp(label, false, rect, stroke.unwrap_or(col), None, opacity, res));
}
let [x0, y0, x1, y1] = rect;
let (a0, b0, a1, b1) = (x0 + 0.5, y0 + 0.5, x1 - 0.5, y1 - 0.5);
let mut c = String::new();
if opacity < 1.0 {
c.push_str("/GS0 gs\n");
}
c.push_str(&format!(
"{}1 w\n{} {} {} {} re\n{} {} m {} {} l\n{} {} m {} {} l\nS\n",
// Grey whatever the `/C`: the placeholder is not the stamp, so it takes none of its colour.
rg_stroke(PLACEHOLDER_GREY),
n(a0),
n(b0),
n(a1 - a0),
n(b1 - b0),
n(a0),
n(b0),
n(a1),
n(b1),
n(a0),
n(b1),
n(a1),
n(b0)
));
Some(form(rect, c.as_bytes(), res))
}
/// A rubber stamp: a rounded frame (a pointed tag for sign-here stamps) with the label in bold
/// capitals, and the dynamic stamps' "By … at …" line.
fn stamp(label: &str, sign_here: bool, rect: [f64; 4], col: Rgb, by: Option<&str>, opacity: f64, mut res: Dict) -> Stream {
let [x0, y0, x1, y1] = rect;
let h = y1 - y0;
let mut c = String::new();
if opacity < 1.0 {
c.push_str("/GS0 gs\n");
}
let lw = (h * 0.07).clamp(1.0, 3.0);
let inset = lw / 2.0 + 0.5;
if sign_here {
// A tag pointing left, filled, with white text.
let tip = h * 0.45;
c.push_str(&format!(
"{}{}{} w 1 j\n{} {} m {} {} l {} {} l {} {} l {} {} l h B\n",
rg(col),
rg_stroke(col),
n(lw),
n(x0 + inset),
n(y0 + h / 2.0),
n(x0 + tip),
n(y1 - inset),
n(x1 - inset),
n(y1 - inset),
n(x1 - inset),
n(y0 + inset),
n(x0 + tip),
n(y0 + inset)
));
} else {
// A rounded frame with a light tint.
let r = (h * 0.2).min(8.0);
let k = 0.552_284_75 * r;
let (a0, b0, a1, b1) = (x0 + inset, y0 + inset, x1 - inset, y1 - inset);
let tint = col.map(|v| 1.0 - (1.0 - v) * 0.12);
c.push_str(&format!(
"{}{}{} w\n{} {} m {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c {} {} l {} {} {} {} {} {} c h B\n",
rg(tint),
rg_stroke(col),
n(lw),
n(a0 + r),
n(b0),
n(a1 - r),
n(b0),
n(a1 - r + k),
n(b0),
n(a1),
n(b0 + r - k),
n(a1),
n(b0 + r),
n(a1),
n(b1 - r),
n(a1),
n(b1 - r + k),
n(a1 - r + k),
n(b1),
n(a1 - r),
n(b1),
n(a0 + r),
n(b1),
n(a0 + r - k),
n(b1),
n(a0),
n(b1 - r + k),
n(a0),
n(b1 - r),
n(a0),
n(b0 + r),
n(a0),
n(b0 + r - k),
n(a0 + r - k),
n(b0),
n(a0 + r),
n(b0)
));
}
let (title_h, by_h) = if by.is_some() { (h * 0.5, h * 0.22) } else { (h * 0.52, 0.0) };
let text_area = if sign_here { (x0 + h * 0.45, x1 - lw * 2.0) } else { (x0 + lw * 2.0, x1 - lw * 2.0) };
let mut size = title_h;
let tw = text_width(label, size) * 1.12;
if tw > text_area.1 - text_area.0 - 4.0 {
size *= (text_area.1 - text_area.0 - 4.0) / tw;
}
let tw = text_width(label, size) * 1.12;
let text_col = if sign_here { [1.0, 1.0, 1.0] } else { col };
let ty = if by.is_some() { y0 + h * 0.48 } else { y0 + (h - size * 0.72) / 2.0 };
let mut out = c.into_bytes();
out.extend(
format!("BT\n{}/HelvB {} Tf\n1 0 0 1 {} {} Tm ", rg(text_col), n(size), n(text_area.0 + (text_area.1 - text_area.0 - tw) / 2.0), n(ty))
.bytes(),
);
out.extend(literal(&win_ansi(label)));
out.extend_from_slice(b" Tj\n");
if let Some(b) = by {
let mut s = by_h;
let bw = text_width(b, s);
if bw > text_area.1 - text_area.0 - 4.0 {
s *= (text_area.1 - text_area.0 - 4.0) / bw;
}
let bw = text_width(b, s);
out.extend(
format!("/Helv {} Tf\n1 0 0 1 {} {} Tm ", n(s), n(text_area.0 + (text_area.1 - text_area.0 - bw) / 2.0), n(y0 + h * 0.18)).bytes(),
);
out.extend(literal(&win_ansi(b)));
out.extend_from_slice(b" Tj\n");
}
out.extend_from_slice(b"ET\n");
let font = |base: &str| {
let mut f = Dict::new();
f.set(b"Type".to_vec(), Object::name("Font"));
f.set(b"Subtype".to_vec(), Object::name("Type1"));
f.set(b"BaseFont".to_vec(), Object::name(base));
f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
Object::Dict(f)
};
let mut fonts = Dict::new();
fonts.set(b"HelvB".to_vec(), font("Helvetica-Bold"));
fonts.set(b"Helv".to_vec(), font("Helvetica"));
res.set(b"Font".to_vec(), Object::Dict(fonts));
form(rect, &out, res)
}
/// An ellipse inscribed in a rectangle, as four Bézier arcs.
fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
let k = 0.552_284_75;
let (cx, cy, rx, ry) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, (x1 - x0) / 2.0, (y1 - y0) / 2.0);
let (ox, oy) = (rx * k, ry * k);
let mut s = format!("{} {} m\n", n(cx + rx), n(cy));
for [a, b, c, d, e, f] in [
[cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry],
[cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy],
[cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry],
[cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy],
] {
s.push_str(&format!("{} {} {} {} {} {} c\n", n(a), n(b), n(c), n(d), n(e), n(f)));
}
s.push_str("h\n");
s
}
/// PdfCraft's note icons, drawn in a 20 × 20 box: a speech bubble for `/Comment`, a page
/// with a folded corner for everything else, both filled with the note colour.
/// File attachment icons in a 20 × 20 box: PdfCraft's own drawings.
fn attach_icon(name: &str, col: Rgb) -> String {
let mut s = format!("{}{}1.2 w 1 j 1 J\n", rg(col), rg_stroke(col));
match name {
"Paperclip" => s.push_str("8 4 m 8 15 l 8 18 13 18 13 15 c 13 6 l 13 3 10 3 10 6 c 10 14 l S\n"),
"Graph" => s.push_str("3 3 m 3 17 l 3 3 m 17 3 l S\n5 3 3 6 re 9 3 3 10 re 13 3 3 13 re f\n"),
"Tag" => s.push_str("3 10 m 8 15 l 17 15 l 17 5 l 8 5 l h S\n7.3 10 m 7.3 10.7 6.7 11.3 6 11.3 c 5.3 11.3 4.7 10.7 4.7 10 c 4.7 9.3 5.3 8.7 6 8.7 c 6.7 8.7 7.3 9.3 7.3 10 c f\n"),
_ => {
// Push pin: a round head on a short needle.
s.push_str("10 2 m 10 9 l S\n6 9 m 14 9 l 14 11 l 12 12 l 12 16 l 13.5 17 l 13.5 18.5 l 6.5 18.5 l 6.5 17 l 8 16 l 8 12 l 6 11 l h f\n");
}
}
s
}
/// A closed circle path (four Bézier quarters) centred on (`x`, `y`).
fn circle_path(x: f64, y: f64, r: f64) -> String {
let k = r * 0.5523;
let p = |v: f64| n(v);
format!(
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h\n",
p(x + r),
p(y),
p(x + r),
p(y + k),
p(x + k),
p(y + r),
p(x),
p(y + r),
p(x - k),
p(y + r),
p(x - r),
p(y + k),
p(x - r),
p(y),
p(x - r),
p(y - k),
p(x - k),
p(y - r),
p(x),
p(y - r),
p(x + k),
p(y - r),
p(x + r),
p(y - k),
p(x + r),
p(y)
)
}
/// A note's icon in a 20 × 20 box: filled in `col`, outlined in dark grey. Each name of
/// [`crate::NoteIcon`] has its own shape, so the icon a note names is the one it shows (ISO
/// 32000-2 §12.5.6.4, Table 175); a name PdfCraft doesn't know draws as `Note`.
fn note_icon(name: &str, col: Rgb) -> String {
let mut s = format!("{}0.25 0.25 0.25 RG 0.8 w 1 j 1 J\n", rg(col));
match name {
"Comment" => {
s.push_str("3 18.5 m 17 18.5 l 18.5 18.5 18.5 17 18.5 17 c 18.5 8 l 18.5 6.5 17 6.5 17 6.5 c 9.5 6.5 l 5 2 l 5.5 6.5 l 3 6.5 l 1.5 6.5 1.5 8 1.5 8 c 1.5 17 l 1.5 18.5 3 18.5 3 18.5 c h B\n");
s.push_str("4.5 15 m 15.5 15 l 4.5 12.5 m 15.5 12.5 l 4.5 10 m 11.5 10 l S\n");
}
// A key lying flat: a round bow with a hole, a shaft and two bits.
"Key" => {
s.push_str(&circle_path(5.5, 10.0, 4.5));
s.push_str(&circle_path(5.0, 10.0, 1.5));
s.push_str(
"9.5 11.2 m 18.5 11.2 l 18.5 8.8 l 17 8.8 l 17 6 l 15 6 l 15 8.8 l 13.5 8.8 l 13.5 6.8 l 11.5 6.8 l 11.5 8.8 l 9.5 8.8 l h B*\n",
);
}
// A disc with a question mark.
"Help" => {
s.push_str(&circle_path(10.0, 10.0, 8.5));
s.push_str("B\n1.6 w 7 12.8 m 7 15.6 13 15.6 13 12.8 c 13 10.6 10 10.8 10 8.2 c S\n10 5 m 10 5.1 l S\n");
}
// An insertion caret.
"Insert" => s.push_str("1.5 3 m 10 18 l 18.5 3 l 14.5 3 l 10 11 l 5.5 3 l h B\n"),
// A caret over a bar: a new paragraph goes here.
"NewParagraph" => s.push_str("3.5 9 m 10 18.5 l 16.5 9 l h B\n3 2 14 4 re B\n"),
// A pilcrow: a filled bowl and two stems.
"Paragraph" => {
s.push_str("11 18.5 m 7 18.5 4 16.3 4 13.2 c 4 10.1 7 8 11 8 c h B\n");
s.push_str("11 18.5 m 16 18.5 l 16 1.5 l 14 1.5 l 14 16.5 l 13 16.5 l 13 1.5 l 11 1.5 l h B\n");
}
// A tick.
"Check" => s.push_str("1.5 10.5 m 4.5 13.5 l 8 9.5 l 16 18.5 l 18.5 15.5 l 8 3.5 l h B\n"),
// A ring.
"Circle" => {
s.push_str(&circle_path(10.0, 10.0, 8.5));
s.push_str(&circle_path(10.0, 10.0, 5.0));
s.push_str("B*\n");
}
// A thick X.
"Cross" => s.push_str("4 1.5 m 10 7.5 l 16 1.5 l 18.5 4 l 12.5 10 l 18.5 16 l 16 18.5 l 10 12.5 l 4 18.5 l 1.5 16 l 7.5 10 l 1.5 4 l h B\n"),
// A ring with sighting lines through it.
"CrossHairs" => {
s.push_str(&circle_path(10.0, 10.0, 7.0));
s.push_str(&circle_path(10.0, 10.0, 4.5));
s.push_str("B*\n1.2 w 10 0.5 m 10 19.5 l 0.5 10 m 19.5 10 l S\n");
}
// A five-pointed star.
"Star" => {
let points: Vec<String> = (0..10)
.map(|i| {
let r = if i % 2 == 0 { 9.5 } else { 3.9 };
let a = std::f64::consts::FRAC_PI_2 + f64::from(i) * std::f64::consts::PI / 5.0;
format!("{} {}", n(10.0 + r * a.cos()), n(9.5 + r * a.sin()))
})
.collect();
s.push_str(&format!("{} m {} l h B\n", points[0], points[1..].join(" l ")));
}
// Block arrows.
"RightArrow" => s.push_str("1.5 7 m 11 7 l 11 2.5 l 18.5 10 l 11 17.5 l 11 13 l 1.5 13 l h B\n"),
"UpArrow" => s.push_str("7 1.5 m 7 11 l 2.5 11 l 10 18.5 l 17.5 11 l 13 11 l 13 1.5 l h B\n"),
_ => {
s.push_str("3 19 m 13 19 l 17 15 l 17 1 l 3 1 l h B\n13 19 m 13 15 l 17 15 l S\n");
s.push_str("5.5 12 m 14.5 12 l 5.5 9 m 14.5 9 l 5.5 6 m 11.5 6 l S\n");
}
}
s
}