mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-25 10:39:01 -06:00
place_tagged wrapped the page's content in q/Q only when no Wrap stream existed yet. Content appended after an earlier wrapper (by another tool, or an older OCR layer) that left a cm, a clip or text state set then moved, hid or distorted every later header, Bates number, watermark, added text, flattened annotation and redaction cover. Placing a mark on top now reads the page's actual operators instead of the /PCMark tags. When the content parses cleanly with balanced q/Q and closed text objects and paths, the stretch from the first stream that leaves state set is wrapped, once, at a stream boundary no comment runs into. Content that is already self-contained gets no new wrapper, so repeated marks don't pile up. Content that can't be read that precisely (stray bytes, unbalanced q/Q, an unfinished text object or path, too large) is wrapped whole as before, rather than refusing: redaction stamps its covers after removing the text and can't back out. A q that content leaves open is closed after it, where closing it changes nothing already drawn. The wrapper's close now starts with a newline, so a comment at the end of the content before it can't swallow the Q. Removing marks strips only wrapper pairs that end the list, keeping one that later content still relies on, and recognises the older close bytes too.
841 lines
36 KiB
Rust
841 lines
36 KiB
Rust
//! 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, prepended to) 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 can't leak into the mark.
|
||
//!
|
||
//! Text uses standard Helvetica (WinAnsi) added to the page resources as `/PCHelv`.
|
||
|
||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||
|
||
use pdfcraft_cos::{Dict, Document, 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>>`,
|
||
/// `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
|
||
/// (digits, start, prefix, suffix).
|
||
pub text: [String; 6],
|
||
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,
|
||
}
|
||
|
||
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_size: 8.0, color: [0.0; 3], underline: false, margins: [36.0, 36.0, 72.0, 72.0], start_number: 1 }
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
/// 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}"),
|
||
"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 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 [(&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;
|
||
}
|
||
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.to_vec(), 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: [`WRAP_CLOSE`] after a `Q` for each `q` the wrapped
|
||
/// content left open, or [`OLD_WRAP_CLOSE`].
|
||
fn is_wrap_close(bytes: &[u8]) -> bool {
|
||
let mut rest = bytes;
|
||
loop {
|
||
if rest == WRAP_CLOSE || 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.
|
||
Clean { suffix: Option<usize>, wrappers: Vec<(usize, 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.
|
||
Unknown { unclosed: usize },
|
||
}
|
||
|
||
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 };
|
||
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 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, at the top level, between operators,
|
||
// and where no comment runs into the stream (it would swallow the wrapper's `q`).
|
||
if stack.is_empty()
|
||
&& !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 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 malformed || text_open || path_open || !stack.is_empty() {
|
||
return Isolation::Unknown { unclosed: if malformed { 0 } else { stack.len() } };
|
||
}
|
||
let comment_open = edges.last().is_some_and(|edge| edge.1);
|
||
Isolation::Clean { suffix: (dirty || comment_open).then_some(split), wrappers }
|
||
}
|
||
|
||
/// Put a mark's content on a page: behind (prepended) 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)
|
||
}
|
||
|
||
/// 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.
|
||
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
|
||
let mut list = contents(doc, page)?;
|
||
let wrap = if behind {
|
||
None
|
||
} else {
|
||
match isolation(doc, &list) {
|
||
Isolation::Clean { suffix, .. } => suffix.map(|split| (split, 0)),
|
||
// 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 } => {
|
||
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
|
||
let split = list.iter().position(|o| !stream_tag(doc, o).is_some_and(|t| behind_kinds.contains(&t.as_str()))).unwrap_or(list.len());
|
||
(split < list.len()).then_some((split, unclosed))
|
||
}
|
||
}
|
||
};
|
||
let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
|
||
if behind {
|
||
list.insert(0, mark);
|
||
} else {
|
||
if let Some((split, unclosed)) = wrap {
|
||
let mut close_bytes = 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(())
|
||
}
|
||
|
||
/// 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.
|
||
pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec<u8>) -> Result<(), EditError> {
|
||
let all = page_list(doc);
|
||
check(&[page], all.len())?;
|
||
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
|
||
)));
|
||
}
|
||
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;
|
||
let mut body: Vec<u8> = format!("BT\n/PCHelv {} 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() {
|
||
let tw = helvetica_width(line, size);
|
||
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());
|
||
add_resources(doc, page, None, None)?;
|
||
let page = &page_list(doc)[i];
|
||
place(doc, page, MarkKind::HeaderFooter, content, false)?;
|
||
}
|
||
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 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 { .. } => {
|
||
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
|
||
}
|
||
|
||
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};
|
||
pub mod bidi;
|
||
pub mod indic;
|
||
mod linebreak;
|
||
pub mod text;
|
||
pub use text::{
|
||
BlockStyle, LineEdit, RangeStyle, TextBlock, TextLine, clear_block, reading_blocks, 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 tests;
|