mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
299 lines
13 KiB
Rust
299 lines
13 KiB
Rust
use super::{Document, EditError, Object, Rgb, added};
|
||
|
||
/// The kinds of page marks.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum MarkKind {
|
||
HeaderFooter,
|
||
Watermark,
|
||
Background,
|
||
}
|
||
|
||
impl MarkKind {
|
||
pub(crate) fn tag(self) -> &'static str {
|
||
match self {
|
||
MarkKind::HeaderFooter => "HeaderFooter",
|
||
MarkKind::Watermark => "Watermark",
|
||
MarkKind::Background => "Background",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Header and footer settings (Acrobat's Add Header and Footer dialog).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct HeaderFooter {
|
||
/// Left, centre and right header text, then left, centre and right footer text. Tokens:
|
||
/// `<<1>>` page number, `<<n>>` page count, `<<1 of n>>`, `<<1/n>>`, `<<Page 1>>`,
|
||
/// `<<Page 1 of n>>`, dates `<<m/d/yyyy>>`, `<<mm/dd/yyyy>>`, `<<d/m/yyyy>>`, `<<yyyy-mm-dd>>`,
|
||
/// `<<yyyy/mm/dd>>`, `<<yyyy/m/d>>`, `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, the dotted day-first
|
||
/// dates of German-speaking and much of Central and Eastern Europe `<<dd.mm.yyyy>>`,
|
||
/// `<<d.m.yyyy>>`, `<<dd.mm.yy>>`, `<<d.m.yy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
|
||
/// (digits, start, prefix, suffix).
|
||
pub text: [String; 6],
|
||
/// The standard face the text is drawn in (#3754): Helvetica (the default), Times or Courier.
|
||
pub font: added::Family,
|
||
pub font_size: f64,
|
||
pub color: Rgb,
|
||
pub underline: bool,
|
||
/// Margins in points: top, bottom, left, right (the text's distance from the page edges).
|
||
pub margins: [f64; 4],
|
||
/// The number `<<1>>` shows on the first page of the range.
|
||
pub start_number: u32,
|
||
/// Shrink the page content so the header and footer don't cover it (Acrobat's Appearance
|
||
/// Options): the original content is scaled down evenly into the band the header and footer
|
||
/// leave free, centred across the page. Remove and Update take the scaling away again.
|
||
pub shrink: bool,
|
||
}
|
||
|
||
impl Default for HeaderFooter {
|
||
/// Acrobat's defaults: 8 pt black, margins 0.5 in top and bottom, 1 in left and right.
|
||
fn default() -> Self {
|
||
Self {
|
||
text: Default::default(),
|
||
font: added::Family::Helvetica,
|
||
font_size: 8.0,
|
||
color: [0.0; 3],
|
||
underline: false,
|
||
margins: [36.0, 36.0, 72.0, 72.0],
|
||
start_number: 1,
|
||
shrink: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl HeaderFooter {
|
||
/// The first character [`add_header_footer`] would refuse, because the standard fonts can't
|
||
/// draw it (#125). Tokens only add ASCII (Bates prefixes and suffixes are part of the
|
||
/// template), so checking the templates, split as `expand`'s output is, covers every page
|
||
/// (and a `\r` that would be drawn is refused too). The dialog asks this too, so its preview
|
||
/// never offers text that applying refuses (#2013).
|
||
pub fn first_undrawable(&self) -> Option<char> {
|
||
self.text.iter().flat_map(|t| t.lines()).find_map(pdfcraft_fonts::first_non_win_ansi)
|
||
}
|
||
|
||
/// The room the header and the footer take at the top and the bottom of a page, in points:
|
||
/// the margin, the lines of the tallest box, and a clearance of half the font size. A side
|
||
/// without text takes none. This is the room [`HeaderFooter::shrink`] keeps free.
|
||
pub fn bands(&self) -> (f64, f64) {
|
||
let size = self.font_size;
|
||
let line_h = size * 1.2;
|
||
let lines = |boxes: &[String]| boxes.iter().filter(|t| !t.trim().is_empty()).map(|t| t.lines().count().max(1)).max();
|
||
let (header, footer) = self.text.split_at(3);
|
||
// Header lines hang below the top margin (a full line each); the footer's last line sits
|
||
// on the bottom margin, so only its capitals (0.8 of the size) rise above it.
|
||
let top = lines(header).map_or(0.0, |n| self.margins[0] + size + (n - 1) as f64 * line_h + size / 2.0);
|
||
let bottom = lines(footer).map_or(0.0, |n| self.margins[1] + size * 0.8 + (n - 1) as f64 * line_h + size / 2.0);
|
||
(top, bottom)
|
||
}
|
||
}
|
||
|
||
/// The `cm` that shrinks a page's original content (user space) into the band `hf` leaves free,
|
||
/// for a page shown `size` (display space) with view matrix `view` (display to user space): an
|
||
/// even scale, centred across, the content's bottom on the footer's band.
|
||
pub(crate) fn shrink_matrix(hf: &HeaderFooter, size: (f64, f64), view: [f64; 6]) -> Result<[f64; 6], EditError> {
|
||
let (w, h) = size;
|
||
let (top, bottom) = hf.bands();
|
||
let free = h - top - bottom;
|
||
if !(free.is_finite() && free >= 1.0 && w.is_finite() && w > 0.0) {
|
||
return Err(EditError::Invalid("the header and footer margins leave no room for the page content to shrink into".into()));
|
||
}
|
||
let k = free / h;
|
||
let v = kurbo::Affine::new(view);
|
||
let det = v.determinant();
|
||
if !(det.is_finite() && det.abs() > 1e-12) {
|
||
return Err(EditError::Invalid("the page can't be shrunk: its view matrix is degenerate".into()));
|
||
}
|
||
let shrink = kurbo::Affine::new([k, 0.0, 0.0, k, (w - w * k) / 2.0, bottom]);
|
||
// User space to display space, shrink there, and back.
|
||
Ok((v * shrink * v.inverse()).as_coeffs())
|
||
}
|
||
|
||
/// A picture for a background or watermark (Acrobat: Source ▸ File): an XObject already in
|
||
/// the document, an image (drawn into a unit square) or a form (a page of a PDF).
|
||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||
pub struct MarkSource {
|
||
pub xobject: pdfcraft_cos::ObjRef,
|
||
/// Its natural size in points.
|
||
pub size: (f64, f64),
|
||
/// An image XObject (unit square); otherwise a form whose `/Matrix` maps it to `size`.
|
||
pub image: bool,
|
||
}
|
||
|
||
/// A watermark: text, or a picture from a file (`source`; the text is then ignored).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Watermark {
|
||
pub text: String,
|
||
pub source: Option<MarkSource>,
|
||
/// Pictures: size relative to the page (1.0 fits the page).
|
||
pub scale: f64,
|
||
/// 0 = fit: about half the page diagonal.
|
||
pub font_size: f64,
|
||
pub color: Rgb,
|
||
/// 0–1.
|
||
pub opacity: f64,
|
||
/// Degrees counter-clockwise (Acrobat's 45° runs from bottom-left to top-right).
|
||
pub rotation: f64,
|
||
/// Draw behind the page content instead of on top.
|
||
pub behind: bool,
|
||
/// Offset of the centre from the page centre, in points (right, up).
|
||
pub offset: [f64; 2],
|
||
}
|
||
|
||
impl Watermark {
|
||
/// The first character of a text watermark [`add_watermark`] would refuse, because the
|
||
/// standard fonts can't draw it (#125). A picture watermark draws no text.
|
||
pub fn first_undrawable(&self) -> Option<char> {
|
||
match self.source {
|
||
Some(_) => None,
|
||
None => self.text.trim().lines().find_map(pdfcraft_fonts::first_non_win_ansi),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Default for Watermark {
|
||
fn default() -> Self {
|
||
Self {
|
||
text: String::new(),
|
||
source: None,
|
||
scale: 0.5,
|
||
font_size: 0.0,
|
||
color: [0.6, 0.6, 0.6],
|
||
opacity: 0.5,
|
||
rotation: 45.0,
|
||
behind: false,
|
||
offset: [0.0; 2],
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A background behind the page content: a solid colour, or a picture from a file.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct Background {
|
||
pub color: Rgb,
|
||
pub opacity: f64,
|
||
pub source: Option<MarkSource>,
|
||
/// Pictures: size relative to the page (1.0 fits the page).
|
||
pub scale: f64,
|
||
}
|
||
|
||
impl Default for Background {
|
||
fn default() -> Self {
|
||
Self { color: [1.0, 1.0, 0.85], opacity: 1.0, source: None, scale: 1.0 }
|
||
}
|
||
}
|
||
|
||
/// Drawing a picture mark: fitted to the page at `scale`, centred (plus `offset`), turned by
|
||
/// `rotation` degrees. Returns the content and the resource name it uses.
|
||
pub(crate) fn picture(src: &MarkSource, page: (f64, f64), scale: f64, rotation: f64, offset: [f64; 2]) -> String {
|
||
let (w, h) = page;
|
||
let (sw, sh) = (src.size.0.max(0.01), src.size.1.max(0.01));
|
||
let k = (w / sw).min(h / sh) * if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||
let (dw, dh) = (sw * k, sh * k);
|
||
let (s, c) = rotation.to_radians().sin_cos();
|
||
let (cx, cy) = (w / 2.0 + offset[0], h / 2.0 + offset[1]);
|
||
// Centre, rotate, then place the picture's box (unit square for images).
|
||
let place = if src.image {
|
||
format!("{} 0 0 {} {} {} cm", n(dw), n(dh), n(-dw / 2.0), n(-dh / 2.0))
|
||
} else {
|
||
format!("{} 0 0 {} {} {} cm", n(k), n(k), n(-dw / 2.0), n(-dh / 2.0))
|
||
};
|
||
format!("{} {} {} {} {} {} cm\n{place}\n/PCPic{} Do\n", n(c), n(s), n(-s), n(c), n(cx), n(cy), src.xobject.num)
|
||
}
|
||
|
||
/// Register a mark picture in the page's own resources (`/PCPic<num>`).
|
||
pub(crate) fn add_picture_resource(doc: &mut Document, page: &pdfcraft_model::Page, src: &MarkSource) -> Result<(), EditError> {
|
||
let p = doc.get(page.obj).as_dict().cloned().unwrap_or_default();
|
||
let mut res = p.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||
let mut xo = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||
xo.set(format!("PCPic{}", src.xobject.num).into_bytes(), Object::Ref(src.xobject));
|
||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Values tokens need that come from outside the document.
|
||
#[derive(Clone, Debug, PartialEq, Default)]
|
||
pub struct Context {
|
||
/// Today's date: (year, month 1–12, day 1–31).
|
||
pub date: (i64, u32, u32),
|
||
}
|
||
|
||
// Model-derived transforms can contain tiny scale factors; retain their PDF decimal precision.
|
||
pub(crate) fn matrix_number(value: f64) -> String {
|
||
let mut bytes = Vec::new();
|
||
pdfcraft_cos::serialize(&Object::Real(value), &mut bytes);
|
||
String::from_utf8_lossy(&bytes).into_owned()
|
||
}
|
||
|
||
pub(crate) fn n(v: f64) -> String {
|
||
let s = format!("{:.3}", if v.abs() < 5e-4 { 0.0 } else { v });
|
||
let s = s.trim_end_matches('0').trim_end_matches('.');
|
||
if s == "-0" { "0".into() } else { s.into() }
|
||
}
|
||
|
||
pub(crate) fn rgb(c: Rgb) -> String {
|
||
let c = c.map(|x| x.clamp(0.0, 1.0));
|
||
format!("{} {} {} rg", n(c[0]), n(c[1]), n(c[2]))
|
||
}
|
||
|
||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||
|
||
/// Expand `<<…>>` tokens for page `index` (0-based within the range) of `count`.
|
||
pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &Context) -> String {
|
||
let (y, m, d) = cx.date;
|
||
let mut out = String::new();
|
||
let mut rest = template;
|
||
while let Some(start) = rest.find("<<") {
|
||
out.push_str(&rest[..start]);
|
||
let after = &rest[start + 2..];
|
||
let Some(end) = after.find(">>") else {
|
||
out.push_str(&rest[start..]);
|
||
return out;
|
||
};
|
||
let token = &after[..end];
|
||
let p = page_number;
|
||
let value = match token {
|
||
"1" => p.to_string(),
|
||
"n" => count.to_string(),
|
||
"1 of n" => format!("{p} of {count}"),
|
||
"1/n" => format!("{p}/{count}"),
|
||
"Page 1" => format!("Page {p}"),
|
||
"Page 1 of n" => format!("Page {p} of {count}"),
|
||
"m/d" => format!("{m}/{d}"),
|
||
"m/d/yy" => format!("{m}/{d}/{:02}", y.rem_euclid(100)),
|
||
"m/d/yyyy" => format!("{m}/{d}/{y}"),
|
||
"mm/dd/yy" => format!("{m:02}/{d:02}/{:02}", y.rem_euclid(100)),
|
||
"mm/dd/yyyy" => format!("{m:02}/{d:02}/{y}"),
|
||
"d/m/yy" => format!("{d}/{m}/{:02}", y.rem_euclid(100)),
|
||
"d/m/yyyy" => format!("{d}/{m}/{y}"),
|
||
"dd/mm/yyyy" => format!("{d:02}/{m:02}/{y}"),
|
||
"d.m.yy" => format!("{d}.{m}.{:02}", y.rem_euclid(100)),
|
||
"d.m.yyyy" => format!("{d}.{m}.{y}"),
|
||
"dd.mm.yy" => format!("{d:02}.{m:02}.{:02}", y.rem_euclid(100)),
|
||
"dd.mm.yyyy" => format!("{d:02}.{m:02}.{y}"),
|
||
"yyyy-mm-dd" => format!("{y}-{m:02}-{d:02}"),
|
||
"yyyy/mm/dd" => format!("{y}/{m:02}/{d:02}"),
|
||
"yyyy/m/d" => format!("{y}/{m}/{d}"),
|
||
"mmmm d, yyyy" => format!("{} {d}, {y}", MONTHS[(m.clamp(1, 12) - 1) as usize]),
|
||
t if t.starts_with("Bates Number") => {
|
||
// Bates Number#digits#start#prefix#suffix (start is applied by the caller).
|
||
let parts: Vec<&str> = t.split('#').collect();
|
||
let digits = parts.get(1).and_then(|d| d.parse::<usize>().ok()).unwrap_or(6).clamp(1, 15);
|
||
format!("{}{:0digits$}{}", parts.get(3).unwrap_or(&""), bates, parts.get(4).unwrap_or(&""))
|
||
}
|
||
_ => format!("<<{token}>>"),
|
||
};
|
||
out.push_str(&value);
|
||
rest = &after[end + 2..];
|
||
}
|
||
out.push_str(rest);
|
||
out
|
||
}
|
||
|
||
/// The Bates start number in a template (`<<Bates Number#digits#start#…>>`), if any.
|
||
pub(crate) fn bates_start(template: &str) -> Option<u64> {
|
||
let i = template.find("<<Bates Number")?;
|
||
let t = &template[i + 2..];
|
||
let t = &t[..t.find(">>")?];
|
||
t.split('#').nth(2).and_then(|s| s.parse().ok()).or(Some(1))
|
||
}
|