Files
pdfcraft/crates/edit/src/lib.rs
T

1049 lines
48 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.
//! pdfcraft-edit — page content editing (L4). Today: page marks, Acrobat's Header & footer,
//! Watermark, Background and Bates numbering (execution plan M7.6).
//!
//! **How marks are stored.** Each mark is one content stream appended to (or, for content behind
//! the page, inserted after the backgrounds at the start of) the page's `/Contents`, drawn in
//! display space (after `/Rotate`) and wrapped in a marked-content artifact (§14.8.2.2) so
//! readers and screen readers skip it:
//! `/Artifact <</Type /Pagination /Subtype /Header /PCMark /HeaderFooter>> BDC … EMC`. The
//! `/PCMark` entry (also in the stream dictionary) names the kind of mark, which is how Update and
//! Remove find them again without decompressing page content. When
//! anything is drawn on top, the page's original content is wrapped in `q … Q` (two tiny streams)
//! so its graphics state cannot reach the mark. The closing stream also ends known unfinished
//! marked-content sequences before the new mark.
//!
//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`; headers and
//! footers may use standard Times (`/PCTimes`) or Courier (`/PCCour`) instead (#3754).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum EditError {
/// Holds the 0-based index; the message names the 1-based page a user counts (#1550).
#[error("page {} does not exist", .0.saturating_add(1))]
NoSuchPage(usize),
#[error("{0}")]
Invalid(String),
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
/// The kinds of page marks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MarkKind {
HeaderFooter,
Watermark,
Background,
}
impl MarkKind {
fn tag(self) -> &'static str {
match self {
MarkKind::HeaderFooter => "HeaderFooter",
MarkKind::Watermark => "Watermark",
MarkKind::Background => "Background",
}
}
}
pub type Rgb = [f64; 3];
/// 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>>`, 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.
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.
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>`).
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.
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()
}
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() }
}
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}"),
"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.
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))
}
fn page_list(doc: &Document) -> Vec<pdfcraft_model::Page> {
pdfcraft_model::pages(doc)
}
fn check(pages: &[usize], count: usize) -> Result<(), EditError> {
match pages.iter().find(|p| **p >= count) {
Some(p) => Err(EditError::NoSuchPage(*p)),
None => Ok(()),
}
}
/// The page's `/Contents` as a list of stream references (inline content is promoted).
fn contents(doc: &mut Document, page: &pdfcraft_model::Page) -> Result<Vec<Object>, EditError> {
let obj = doc.get(page.obj);
let d = obj.as_dict().cloned().unwrap_or_default();
Ok(match d.get(b"Contents").cloned() {
None => Vec::new(),
Some(Object::Ref(r)) => match doc.get(r).as_array() {
Some(a) => a.clone(),
None => vec![Object::Ref(r)],
},
Some(Object::Array(a)) => a,
Some(other) => vec![Object::Ref(doc.add(other))],
})
}
/// Add the font (and an opacity state) to the page's own resources.
fn add_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Option<f64>, content: Option<&[u8]>) -> Result<(), EditError> {
let mut faces: Vec<(Vec<u8>, &str)> = Vec::new();
for (name, base) in [(&b"PCHelv"[..], "Helvetica"), (b"PCTimes", "Times-Roman"), (b"PCCour", "Courier")] {
// Helvetica always (marks use it); the others only when the content names them.
if name != b"PCHelv" && !content.is_some_and(|c| c.windows(name.len() + 1).any(|w| w[0] == b'/' && &w[1..] == name)) {
continue;
}
faces.push((name.to_vec(), base));
}
add_page_resources(doc, page, opacity, &faces)
}
/// The fonts a page draws with: its own resources' or the ones it inherits.
fn page_fonts(doc: &Document, page: &pdfcraft_model::Page) -> Dict {
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default()
}
/// A name in `fonts` for the standard face `base` that can't take over a font the page already
/// draws with: `preferred` when it is free or already names exactly the dictionary
/// [`add_page_resources`] writes for that face, otherwise the first such `preferred_1`,
/// `preferred_2`… (one of the first `fonts.len() + 1` always is). A font with anything more
/// (`/ToUnicode`, `/Widths`…) is someone else's, even in the same face.
fn free_font_name(doc: &Document, fonts: &Dict, preferred: &str, base: &str) -> String {
let usable = |name: &str| match fonts.get(name.as_bytes()) {
None => true,
Some(font) => doc.resolve(font).as_dict().is_some_and(|d| {
d.len() == 4
&& d.name(b"Type") == Some(b"Font")
&& d.name(b"Subtype") == Some(b"Type1")
&& d.name(b"BaseFont") == Some(base.as_bytes())
&& d.name(b"Encoding") == Some(b"WinAnsiEncoding")
}),
};
if usable(preferred) {
return preferred.to_string();
}
(1..=fonts.len().saturating_add(1)).map(|k| format!("{preferred}_{k}")).find(|name| usable(name)).unwrap_or_else(|| preferred.to_string())
}
/// Add the standard `faces` (resource name, base font) and an opacity state to the page's own
/// resources.
fn add_page_resources(doc: &mut Document, page: &pdfcraft_model::Page, opacity: Option<f64>, faces: &[(Vec<u8>, &str)]) -> Result<(), EditError> {
let mut res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
for (name, base) in faces {
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(base));
font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding"));
fonts.set(name.clone(), Object::Dict(font));
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
if let Some(o) = opacity {
let mut gs = res.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let mut s = Dict::new();
s.set(b"Type".to_vec(), Object::name("ExtGState"));
s.set(b"CA".to_vec(), Object::Real(o));
s.set(b"ca".to_vec(), Object::Real(o));
let name = format!("PCGS{}", (o * 100.0).round() as i64);
gs.set(name.into_bytes(), Object::Dict(s));
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
}
// The page gets its own (possibly copied) resources; shared dictionaries stay untouched.
doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
Ok(())
}
const WRAP_OPEN: &[u8] = b"q %PdfCraft\n";
/// Starts with an end of line, so a comment left open at the end of the content before it
/// can't swallow the `Q`. Older files have the close without it.
const WRAP_CLOSE: &[u8] = b"\nQ %PdfCraft\n";
const OLD_WRAP_CLOSE: &[u8] = b"Q %PdfCraft\n";
/// Whether `bytes` are a wrapper's close: canonical `EMC` closures, then the existing
/// graphics closures. Legacy closes without the leading newline remain valid on their own.
fn is_wrap_close(bytes: &[u8]) -> bool {
let mut rest = bytes;
while let Some(shorter) = rest.strip_prefix(b"\nEMC".as_slice()) {
rest = shorter;
}
let legacy = rest.len() == bytes.len();
loop {
if rest == WRAP_CLOSE || (legacy && rest == OLD_WRAP_CLOSE) {
return true;
}
match rest.strip_prefix(b"\nQ".as_slice()) {
Some(shorter) => rest = shorter,
None => return false,
}
}
}
/// A `Wrap`-tagged stream's role from its exact bytes: `Some(true)` for an open, `Some(false)`
/// for a close.
fn wrapper_role(doc: &Document, o: &Object) -> Option<bool> {
if stream_tag(doc, o).as_deref() != Some("Wrap") {
return None;
}
let Object::Stream(stream) = &*doc.resolve(o) else { return None };
// Wrappers are a few bytes; one that decodes to more isn't PdfCraft's.
let bytes = stream.decoded_within(64 * 1024).ok()?;
if bytes == WRAP_OPEN { Some(true) } else { is_wrap_close(&bytes).then_some(false) }
}
#[cfg(test)]
fn stream_bytes(doc: &Document, o: &Object) -> Option<Vec<u8>> {
match &*doc.resolve(o) {
Object::Stream(s) => s.decoded().ok(),
_ => None,
}
}
/// The `/PCMark` tag in a content stream's dictionary: a mark kind, or `Wrap` for the q/Q
/// wrapper. Read from the dictionary, so finding marks never decompresses page content.
fn stream_tag(doc: &Document, o: &Object) -> Option<String> {
let obj = doc.resolve(o);
obj.as_dict()?.name(b"PCMark").map(|n| String::from_utf8_lossy(n).into_owned())
}
fn tagged(tag: &str) -> Dict {
let mut d = Dict::new();
d.set(b"PCMark".to_vec(), Object::name(tag));
d
}
/// What placing a mark on top needs, from the page's actual operators (not the `/PCMark` tags,
/// which another writer may keep, copy or forge).
enum Isolation {
/// The content parses cleanly with balanced `q`/`Q` and closed text objects and paths.
/// `suffix`: the stream from which the content leaves graphics state set (or a comment open)
/// that a mark on top must not inherit, so it needs a wrapper from there to the end.
/// `wrappers`: PdfCraft wrapper pairs (open, close) as stream indexes.
/// `marked`: unfinished marked-content sequences to close before a new mark.
Clean { suffix: Option<usize>, wrappers: Vec<(usize, usize)>, marked: usize },
/// The content could not be read that precisely (it doesn't decode, is too large, or has
/// stray bytes, unbalanced `q`/`Q` or an unfinished text object or path). `unclosed` is
/// the count of `q` left open at the end, when known. `marked` counts known unfinished
/// marked-content sequences independently of graphics malformation, or zero when unknown.
Unknown { unclosed: usize, marked: usize },
/// Marked content nests past [`ISOLATION_MAX_DEPTH`]: how many sequences a mark on top would
/// have to close (some may be hidden optional content) is unknown.
TooDeep,
}
const ISOLATION_MAX_STREAMS: usize = 100_000;
const ISOLATION_MAX_BYTES: usize = 128 * 1024 * 1024;
const ISOLATION_MAX_OPS: usize = 1_000_000;
const ISOLATION_MAX_DEPTH: usize = 10_000;
fn isolation(doc: &Document, list: &[Object]) -> Isolation {
let unknown = Isolation::Unknown { unclosed: 0, marked: 0 };
if list.len() > ISOLATION_MAX_STREAMS {
return unknown;
}
let mut remaining = ISOLATION_MAX_BYTES;
let mut data = Vec::with_capacity(list.len());
for object in list {
let resolved = doc.resolve(object);
let Object::Stream(stream) = &*resolved else { return unknown };
let Ok(bytes) = stream.decoded_within(remaining) else { return unknown };
let Some(left) = remaining.checked_sub(bytes.len().saturating_add(1)) else { return unknown };
remaining = left;
data.push(bytes);
}
let pieces = pdfcraft_content::Pieces::join(&data);
let parsed = pieces.parse();
if parsed.skipped != 0 || parsed.ops.len() > ISOLATION_MAX_OPS {
return unknown;
}
let edges = pieces.comment_edges(&parsed);
let (mut text_open, mut path_open, mut dirty, mut malformed) = (false, false, false, false);
let mut stack: Vec<usize> = Vec::new();
let (mut marked, mut marked_known) = (0usize, true);
let mut split = 0;
let mut previous_last = None;
let mut wrappers = Vec::new();
for op in &parsed.ops {
let (first, last) = pieces.pieces_of(op);
// A wrapper can only go in before a whole stream, outside graphics and marked-content
// scopes, between operators, where no comment would swallow the wrapper's `q`.
if stack.is_empty()
&& marked_known
&& marked == 0
&& !text_open
&& !path_open
&& !dirty
&& previous_last.is_none_or(|previous| first > previous)
&& !edges.get(first).is_some_and(|edge| edge.0)
{
split = first;
}
previous_last = Some(last);
// Match the reader's tolerant operands: BMC uses the last name, BDC the last
// name/object pair, and EMC ignores surplus operands. Invalid begins are ignored.
let begin_marked = match op.op.as_slice() {
b"BMC" => op.operands.last().is_some_and(|o| matches!(o, Object::Name(_))),
b"BDC" => op.operands.len().checked_sub(2).and_then(|i| op.operands.get(i)).is_some_and(|o| matches!(o, Object::Name(_))),
_ => false,
};
if marked_known {
if begin_marked {
if marked < ISOLATION_MAX_DEPTH {
marked += 1;
} else {
// Once the budget is exceeded, later EMCs cannot restore a known depth.
marked_known = false;
marked = 0;
}
} else if op.is("EMC") {
marked = marked.saturating_sub(1);
}
}
match op.op.as_slice() {
b"BT" if !text_open => text_open = true,
b"ET" if text_open => text_open = false,
b"BT" | b"ET" => malformed = true,
b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*" => path_open = true,
b"n" | b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" => path_open = false,
_ => {}
}
match op.op.as_slice() {
b"q" if op.operands.is_empty() && stack.len() < ISOLATION_MAX_DEPTH => stack.push(first),
b"Q" if op.operands.is_empty() => match stack.pop() {
Some(open) => {
// Only a standalone PdfCraft wrapper pair may later be removed again.
let wrap = |i: usize| list.get(i).is_some_and(|o| stream_tag(doc, o).as_deref() == Some("Wrap"));
if wrap(open)
&& wrap(first)
&& data.get(open).is_some_and(|d| d == WRAP_OPEN)
&& data.get(first).is_some_and(|d| is_wrap_close(d))
{
wrappers.push((open, first));
}
}
None => malformed = true,
},
b"q" | b"Q" => malformed = true,
_ if stack.is_empty() => dirty = true,
_ => {}
}
}
if !marked_known {
return Isolation::TooDeep;
}
if malformed || text_open || path_open || !stack.is_empty() {
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() }, marked };
}
let comment_open = edges.last().is_some_and(|edge| edge.1);
Isolation::Clean { suffix: (dirty || comment_open || marked != 0).then_some(split), wrappers, marked }
}
/// Put a mark's content on a page: behind (with the behind-marks at the start) or on top
/// (appended, after wrapping the original content in q/Q).
fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
place_tagged(doc, page, kind.tag(), content, behind).map(drop)
}
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
/// content before leaves set (a `cm`, a clip, text state…) must not reach the mark, including
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark; when
/// their number is unknown, nothing is placed on top. Returns the new content stream.
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<ObjRef, EditError> {
let mut list = contents(doc, page)?;
// The behind-marks at the start: backgrounds in the order added, then watermarks.
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
let behind_marks = list.iter().take_while(|o| stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).count();
let wrap = if behind {
None
} else {
match isolation(doc, &list) {
Isolation::Clean { suffix, marked, .. } => suffix.map(|split| (split, 0, marked)),
// Content that can't be read precisely is wrapped whole, as before: after the
// behind-marks at the start. A `q` it leaves open is closed at its end, where
// closing it changes nothing already drawn.
Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)),
// Content left inside an optional-content sequence it cannot close could be hidden.
Isolation::TooDeep => return Err(too_deep_on_top()),
}
};
let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content)));
let mark = Object::Ref(mark_ref);
if behind {
// Right after the backgrounds already there, so a new background is painted over the
// older ones (#3591) and a watermark over all of them. Not after the watermarks: on a
// page with no content, one on top is at the start too and can't be told apart, so a
// newer watermark behind goes under the older ones.
let at = list.iter().take(behind_marks).rposition(|o| stream_tag(doc, o).as_deref() == Some(MarkKind::Background.tag())).map_or(0, |i| i + 1);
list.insert(at, mark);
} else {
if let Some((split, unclosed, marked)) = wrap {
let mut close_bytes = b"\nEMC".repeat(marked);
close_bytes.extend_from_slice(&b"\nQ".repeat(unclosed));
close_bytes.extend_from_slice(WRAP_CLOSE);
let open = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), WRAP_OPEN.to_vec()))));
let close = Object::Ref(doc.add(Object::Stream(Stream::from_raw(tagged("Wrap"), close_bytes))));
list.insert(split.min(list.len()), open);
list.push(close);
}
list.push(mark);
}
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
Ok(mark_ref)
}
fn too_deep_on_top() -> EditError {
EditError::Invalid("the page's marked content is nested too deeply to add anything on top of it".into())
}
/// The refusal [`place_tagged`] gives for a mark on top of `page`, checked before an edit changes
/// anything (removing old marks, adding resources) so a refused edit leaves the document as it was.
fn check_on_top(doc: &Document, page: &pdfcraft_model::Page) -> Result<(), EditError> {
let obj = doc.get(page.obj);
let list = match obj.as_dict().and_then(|d| d.get(b"Contents")) {
None => return Ok(()),
Some(Object::Ref(r)) => doc.get(*r).as_array().cloned().unwrap_or_else(|| vec![Object::Ref(*r)]),
Some(Object::Array(a)) => a.clone(),
Some(other) => vec![other.clone()],
};
match isolation(doc, &list) {
Isolation::TooDeep => Err(too_deep_on_top()),
_ => Ok(()),
}
}
/// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original
/// content and nothing else (a header's shrink). A page without a wrapper has no original content
/// on it and is left alone.
fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>) -> Result<(), EditError> {
let mut list = contents(doc, page)?;
let Some(open) = list.iter().position(|o| stream_tag(doc, o).as_deref() == Some("Wrap")) else { return Ok(()) };
let stream = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
list.insert(open + 1, stream);
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
Ok(())
}
/// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica
/// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes
/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes.
/// Returns the new content stream.
pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec<u8>) -> Result<ObjRef, EditError> {
let all = page_list(doc);
check(&[page], all.len())?;
check_on_top(doc, &all[page])?;
add_resources(doc, &all[page], None, Some(&content))?;
let p = page_list(doc).swap_remove(page);
place_tagged(doc, &p, tag, content, false)
}
fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String {
format!(
"q\n/Artifact <</Type /Pagination /Subtype /{subtype} /PCMark /{}>> BDC\n{} {} {} {} {} {} cm\n",
kind.tag(),
matrix_number(matrix[0]),
matrix_number(matrix[1]),
matrix_number(matrix[2]),
matrix_number(matrix[3]),
matrix_number(matrix[4]),
matrix_number(matrix[5])
)
}
const END: &str = "EMC\nQ\n";
/// The refusal for page text with `c`, which nothing here can draw.
pub(crate) fn undrawable(c: char) -> EditError {
EditError::Invalid(undrawable_message(c))
}
/// The wording of the refusal for text with `c`, which the standard fonts can't draw.
pub fn undrawable_message(c: char) -> String {
format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c))
}
fn text_op(x: f64, y: f64, text: &str) -> Vec<u8> {
let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes();
v.extend(literal(&win_ansi(text)));
v.extend_from_slice(b" Tj\n");
v
}
/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on
/// those pages are removed first (Acrobat's Replace Existing).
pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
if hf.text.iter().all(|t| t.trim().is_empty()) {
return Err(EditError::Invalid("type the header or footer text first".into()));
}
if let Some(c) = hf.first_undrawable() {
return Err(undrawable(c));
}
if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) {
return Err(EditError::Invalid("invalid font size or margins".into()));
}
// Page numbers count up from start_number, so the last selected page needs the most room.
// Refuse the whole run before drawing anything: headers applied to some pages and then a
// failed page number would leave the document half changed.
let last_number = u64::from(hf.start_number)
.checked_add(pages.len().saturating_sub(1) as u64)
.ok_or_else(|| EditError::Invalid("the header page numbers are too large".into()))?;
if last_number > u64::from(u32::MAX) {
return Err(EditError::Invalid(format!(
"the header start number {} leaves no room for {} pages (page numbers stop at {})",
hf.start_number,
pages.len(),
u32::MAX
)));
}
// The same goes for Bates numbers: the last selected page must still fit in a u64.
let bates0 = hf.text.iter().find_map(|t| bates_start(t)).unwrap_or(1);
if bates0.checked_add(pages.len().saturating_sub(1) as u64).is_none() {
return Err(EditError::Invalid(format!(
"the Bates start number {bates0} leaves no room for {} pages (Bates numbers stop at {})",
pages.len(),
u64::MAX
)));
}
// Shrinking needs room on every page: refuse before anything is drawn or replaced.
let shrinks = pages
.iter()
.map(|&i| hf.shrink.then(|| shrink_matrix(hf, all[i].display_size(doc), all[i].view_matrix(doc))).transpose())
.collect::<Result<Vec<_>, _>>()?;
for &i in pages {
check_on_top(doc, &all[i])?;
}
if replace {
remove_marks(doc, pages, MarkKind::HeaderFooter)?;
}
let all = page_list(doc);
let count = all.len();
for (k, &i) in pages.iter().enumerate() {
let page = &all[i];
let (w, h) = page.display_size(doc);
let number =
u32::try_from(u64::from(hf.start_number) + k as u64).map_err(|_| EditError::Invalid("the header page numbers are too large".into()))?;
let bates = bates0.checked_add(k as u64).ok_or_else(|| EditError::Invalid("the Bates numbers are too large".into()))?;
let size = hf.font_size;
let [top, bottom, left, right] = hf.margins;
// The page resource the face is added under: the usual name, unless the page already
// draws another font by it, which must not change under the header.
let preferred = match hf.font {
added::Family::Helvetica => "PCHelv",
added::Family::Times => "PCTimes",
added::Family::Courier => "PCCour",
};
let base = hf.font.base_font(false, false);
let resource = free_font_name(doc, &page_fonts(doc, page), preferred, base);
let mut body: Vec<u8> = format!("BT\n/{resource} {} Tf\n{}\n", n(size), rgb(hf.color)).into_bytes();
let mut underlines = String::new();
for (slot, template) in hf.text.iter().enumerate() {
if template.trim().is_empty() {
continue;
}
let lines: Vec<String> = expand(template, number, count, bates, cx).lines().map(str::to_owned).collect();
let header = slot < 3;
let line_h = size * 1.2;
for (li, line) in lines.iter().enumerate() {
// Centred and right-aligned text is placed by its width in the chosen face.
let tw = hf.font.width(line, size, false, false);
let x = match slot % 3 {
0 => left,
1 => (w - tw) / 2.0,
_ => w - right - tw,
};
// Headers hang below the top margin; footers sit on the bottom margin.
let y = if header { h - top - size * 0.8 - li as f64 * line_h } else { bottom + (lines.len() - 1 - li) as f64 * line_h };
body.extend(text_op(x, y, line));
if hf.underline {
underlines.push_str(&format!("{} {} {} {} re f\n", n(x), n(y - size * 0.15), n(tw), n((size * 0.06).max(0.4))));
}
}
}
body.extend_from_slice(b"ET\n");
if !underlines.is_empty() {
body.extend(format!("{}\n{underlines}", rgb(hf.color)).bytes());
}
let mut content = begin(MarkKind::HeaderFooter, "Header", page.view_matrix(doc)).into_bytes();
content.extend(body);
content.extend_from_slice(END.as_bytes());
// A name already there is that very font (see `free_font_name`): left as it is.
if !page_fonts(doc, page).contains(resource.as_bytes()) {
add_page_resources(doc, page, None, &[(resource.into_bytes(), base)])?;
}
let page = &page_list(doc)[i];
place(doc, page, MarkKind::HeaderFooter, content, false)?;
if let Some(Some(m)) = shrinks.get(k) {
let cm = format!("{} cm\n", m.map(matrix_number).join(" "));
let page = &page_list(doc)[i];
place_inside_wrapper(doc, page, MarkKind::HeaderFooter.tag(), cm.into_bytes())?;
}
}
Ok(())
}
/// Add a text watermark to `pages`.
pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
let text = wm.text.trim();
if text.is_empty() && wm.source.is_none() {
return Err(EditError::Invalid("type the watermark text first".into()));
}
if let Some(c) = wm.first_undrawable() {
return Err(undrawable(c));
}
if !(wm.opacity.is_finite()
&& wm.rotation.is_finite()
&& wm.font_size.is_finite()
&& wm.font_size >= 0.0
&& wm.offset.iter().all(|o| o.is_finite()))
{
return Err(EditError::Invalid("invalid watermark settings".into()));
}
if !wm.behind {
for &i in pages {
check_on_top(doc, &all[i])?;
}
}
if replace {
remove_marks(doc, pages, MarkKind::Watermark)?;
}
let opacity = wm.opacity.clamp(0.0, 1.0);
let lines: Vec<&str> = text.lines().collect();
for &i in pages {
let page = page_list(doc)[i].clone();
let (w, h) = page.display_size(doc);
if let Some(src) = &wm.source {
let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc));
content.push_str(&format!("/PCGS{} gs\n", (opacity * 100.0).round() as i64));
content.push_str(&picture(src, (w, h), wm.scale, wm.rotation, wm.offset));
content.push_str(END);
add_resources(doc, &page, Some(opacity), None)?;
add_picture_resource(doc, &page_list(doc)[i].clone(), src)?;
let page = &page_list(doc)[i];
place(doc, page, MarkKind::Watermark, content.into_bytes(), wm.behind)?;
continue;
}
let widest = lines.iter().map(|l| helvetica_width(l, 1.0)).fold(0.0, f64::max).max(0.01);
let size = if wm.font_size > 0.0 { wm.font_size } else { ((w * w + h * h).sqrt() * 0.5 / widest).clamp(6.0, 300.0) };
let (s, c) = wm.rotation.to_radians().sin_cos();
let (cx, cy) = (w / 2.0 + wm.offset[0], h / 2.0 + wm.offset[1]);
let mut content = begin(MarkKind::Watermark, "Watermark", page.view_matrix(doc)).into_bytes();
content.extend(
format!(
"/PCGS{} gs\n{} {} {} {} {} {} cm\nBT\n/PCHelv {} Tf\n{}\n",
(opacity * 100.0).round() as i64,
n(c),
n(s),
n(-s),
n(c),
n(cx),
n(cy),
n(size),
rgb(wm.color)
)
.bytes(),
);
let line_h = size * 1.15;
let total = line_h * (lines.len() as f64 - 1.0);
for (li, line) in lines.iter().enumerate() {
let tw = helvetica_width(line, size);
content.extend(text_op(-tw / 2.0, total / 2.0 - li as f64 * line_h - size * 0.35, line));
}
content.extend_from_slice(b"ET\n");
content.extend_from_slice(END.as_bytes());
add_resources(doc, &page, Some(opacity), None)?;
let page = &page_list(doc)[i];
place(doc, page, MarkKind::Watermark, content, wm.behind)?;
}
Ok(())
}
/// Add a solid-colour background behind the content of `pages`.
pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> {
let all = page_list(doc);
check(pages, all.len())?;
if replace {
remove_marks(doc, pages, MarkKind::Background)?;
}
let opacity = if bg.opacity.is_finite() { bg.opacity.clamp(0.0, 1.0) } else { 1.0 };
for &i in pages {
let page = page_list(doc)[i].clone();
let (w, h) = page.display_size(doc);
let mut content = begin(MarkKind::Background, "Background", page.view_matrix(doc));
match &bg.source {
Some(src) => {
content.push_str(&format!("/PCGS{} gs\n", (opacity * 100.0).round() as i64));
content.push_str(&picture(src, (w, h), bg.scale, 0.0, [0.0; 2]));
}
None => content.push_str(&format!("/PCGS{} gs\n{}\n0 0 {} {} re f\n", (opacity * 100.0).round() as i64, rgb(bg.color), n(w), n(h))),
}
content.push_str(END);
add_resources(doc, &page, Some(opacity), None)?;
if let Some(src) = &bg.source {
add_picture_resource(doc, &page_list(doc)[i].clone(), src)?;
}
let page = &page_list(doc)[i];
place(doc, page, MarkKind::Background, content.into_bytes(), true)?;
}
Ok(())
}
/// Remove marks of `kind` from `pages`; returns how many were removed. Wrappers left with
/// nothing after them go too, restoring the original content list; a wrapper that content
/// after it still relies on (another tool's appended stream) stays.
pub fn remove_marks(doc: &mut Document, pages: &[usize], kind: MarkKind) -> Result<usize, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
let mut removed = 0;
for &i in pages {
let page = &all[i];
let list = contents(doc, page)?;
let before = list.len();
let mut kept: Vec<Object> = list.into_iter().filter(|o| stream_tag(doc, o).as_deref() != Some(kind.tag())).collect();
removed += before - kept.len();
// Strip only wrapper pairs whose close ends the list (after stripping the pairs after
// it): nothing comes after them to need the state they restore. Pairs come from the
// operators; for content that can't be read that precisely, from the wrapper streams
// alone, so updating a mark there doesn't nest another wrapper each time.
if before != kept.len() {
let wrappers = match isolation(doc, &kept) {
Isolation::Clean { wrappers, .. } => wrappers,
Isolation::Unknown { .. } | Isolation::TooDeep => {
let mut opens = Vec::new();
let mut pairs = Vec::new();
for (i, o) in kept.iter().enumerate() {
match wrapper_role(doc, o) {
Some(true) => opens.push(i),
Some(false) => pairs.extend(opens.pop().map(|open| (open, i))),
None => {}
}
}
pairs
}
};
let pairs: std::collections::HashMap<usize, usize> = wrappers.into_iter().map(|(open, close)| (close, open)).collect();
let mut discarded = std::collections::HashSet::new();
let mut end = kept.len();
while let Some(close) = end.checked_sub(1) {
if discarded.contains(&close) {
end = close;
} else if let Some(&open) = pairs.get(&close) {
discarded.insert(open);
discarded.insert(close);
end = close;
} else {
break;
}
}
kept = kept.into_iter().enumerate().filter(|(i, _)| !discarded.contains(i)).map(|(_, o)| o).collect();
}
if before != kept.len() {
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept)))?;
}
}
Ok(removed)
}
/// Which kinds of marks the document has (for enabling Update/Remove).
pub fn marks_present(doc: &Document) -> Vec<MarkKind> {
added::scan_markers(doc).marks
}
mod background;
pub use background::page_background;
pub mod objects;
pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, editable_objects, move_objects};
mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
pub mod bidi;
pub mod indic;
mod linebreak;
pub mod text;
pub use text::{
BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping,
clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines,
};
pub mod added;
pub use added::{
Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers,
update_content,
};
#[cfg(test)]
mod marked_isolation_tests;
#[cfg(test)]
mod tests;