use std::collections::{BTreeMap, BTreeSet};
use pdfrum_common::kurbo::Affine;
use pdfrum_object::{Dict, Name, ObjRef, Object, Resolve};
use pdfrum_page::{Page, PageObject};
use crate::content::emit::{DEFAULT_GRAPHICS, GraphicsKey, ResourceNames, default_graphics};
use crate::content::marks::{emit_mark_diff, finish_marks};
use crate::content::num::write_matrix;
use crate::content::resource::ResourceTable;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Regenerated {
pub stream: Option<usize>,
pub bytes: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PageRewrite {
pub streams: Vec<Regenerated>,
pub resources: Dict,
}
#[must_use]
pub fn regenerate(page: &Page, resources: &Dict, r: &impl Resolve) -> Option<PageRewrite> {
let dirty = page.dirty_stream_set();
if dirty.is_empty() {
return None;
}
let mut table = ResourceTable::load(resources, r);
let default_gs = table.realize_dict("ExtGState", &default_graphics());
let mut buffers: BTreeMap<Option<usize>, StreamBuffer> = dirty
.iter()
.map(|stream| {
let mut buffer = StreamBuffer::default();
open_frame(
&mut buffer.bytes,
page.ctm_at_start_of_stream(*stream),
&default_gs,
);
(*stream, buffer)
})
.collect();
let mut used: BTreeMap<String, BTreeSet<Name>> = BTreeMap::new();
used.entry("ExtGState".to_owned())
.or_default()
.insert(default_gs.clone());
for object in &page.objects {
if !object.is_active() {
continue;
}
let writing = buffers.contains_key(&object.content_stream());
let names = realize_for(object, &mut table, &mut used, writing);
let Some(buffer) = buffers.get_mut(&object.content_stream()) else {
continue;
};
let marks = std::mem::take(&mut buffer.marks);
let mut body = String::new();
let open = emit_mark_diff(&mut body, &marks, object.marks(), &|_| None);
if crate::content::emit::emit_object(&mut body, object, &names) {
buffer.bytes.push_str(&body);
buffer.open_marks = open;
buffer.marks = object.marks().clone();
buffer.wrote_something = true;
} else {
buffer.marks = marks;
}
}
let streams = buffers
.into_iter()
.map(|(stream, buffer)| close_frame(page, stream, buffer))
.collect();
table.sweep(&used);
Some(PageRewrite {
streams,
resources: table.to_dict(resources),
})
}
#[derive(Debug, Default)]
struct StreamBuffer {
bytes: String,
marks: pdfrum_page::ContentMarks,
open_marks: usize,
wrote_something: bool,
}
fn open_frame(out: &mut String, inherited: Affine, default_gs: &Name) {
out.push_str("q\n");
if inherited != Affine::IDENTITY {
write_matrix(out, inherited.inverse());
out.push_str(" cm\n");
}
out.push_str(DEFAULT_GRAPHICS);
out.push('/');
out.push_str(&String::from_utf8_lossy(&pdfrum_object::name_encode(
default_gs.as_bytes(),
)));
out.push_str(" gs ");
}
fn close_frame(page: &Page, stream: Option<usize>, mut buffer: StreamBuffer) -> Regenerated {
let affects_ctm = stream_affects_ctm(page, stream);
if !buffer.wrote_something && !affects_ctm {
return Regenerated {
stream,
bytes: String::new(),
};
}
if buffer.wrote_something {
finish_marks(&mut buffer.bytes, buffer.open_marks);
}
buffer.bytes.push_str("Q\n");
if let Some(index) = stream.filter(|_| affects_ctm) {
let previous = previous_ctm(page, index);
let difference = previous.inverse() * page.ctm_at_end_of_stream(index);
if difference != Affine::IDENTITY {
write_matrix(&mut buffer.bytes, difference);
buffer.bytes.push_str(" cm\n");
}
}
Regenerated {
stream,
bytes: buffer.bytes,
}
}
fn previous_ctm(page: &Page, stream: usize) -> Affine {
if stream == 0 {
Affine::IDENTITY
} else {
page.ctm_at_end_of_stream(stream.saturating_sub(1))
}
}
fn stream_affects_ctm(page: &Page, stream: Option<usize>) -> bool {
let Some(stream) = stream else {
return false;
};
previous_ctm(page, stream) != page.ctm_at_end_of_stream(stream)
}
fn realize_for(
object: &PageObject,
table: &mut ResourceTable,
used: &mut BTreeMap<String, BTreeSet<Name>>,
writing: bool,
) -> ResourceNames {
let mut names = ResourceNames::default();
if let Some(key) = GraphicsKey::of(object.state()) {
let dict = key.to_dict();
let name = if writing {
Some(table.realize_dict("ExtGState", &dict))
} else {
table.name_of_dict("ExtGState", &dict)
};
names.ext_gstate = record(used, "ExtGState", name);
}
match object {
PageObject::Text(text) => {
if let Some(source) = text.object.font_source {
let name = named(table, "Font", source, writing);
names.font = record(used, "Font", name);
}
}
PageObject::Image(image) => {
if let Some(source) = image.object.source {
let name = named(table, "XObject", source, writing);
names.xobject = record(used, "XObject", name);
}
}
PageObject::Form(form) => {
if let Some(source) = form.object.source {
let name = named(table, "XObject", source, writing);
names.xobject = record(used, "XObject", name);
}
}
PageObject::Path(_) | PageObject::Shading(_) => {}
}
names
}
fn named(table: &mut ResourceTable, category: &str, source: ObjRef, writing: bool) -> Option<Name> {
if writing {
Some(table.realize(category, source))
} else {
table.name_of(category, source)
}
}
fn record(
used: &mut BTreeMap<String, BTreeSet<Name>>,
category: &str,
name: Option<Name>,
) -> Option<Name> {
let name = name?;
used.entry(category.to_owned())
.or_default()
.insert(name.clone());
Some(name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContentsShape {
Absent,
Single(ObjRef),
Array(Vec<ObjRef>),
}
impl ContentsShape {
#[must_use]
pub fn read(page_dict: &Dict, r: &impl Resolve) -> Self {
let Some(contents) = page_dict.raw(pdfrum_object::names::CONTENTS) else {
return Self::Absent;
};
let direct = match contents {
Object::Ref(reference) => match r.fetch(*reference) {
Ok(object) => (*object).clone(),
Err(_) => return Self::Absent,
},
other => other.clone(),
};
match direct {
Object::Stream(_) => match contents {
Object::Ref(reference) => Self::Single(*reference),
_ => Self::Absent,
},
Object::Array(array) => {
Self::Array(array.iter().filter_map(Object::as_ref_id).collect())
}
_ => Self::Absent,
}
}
#[must_use]
pub fn with_added(&self, added: ObjRef) -> (usize, Self) {
match self {
Self::Absent => (0, Self::Single(added)),
Self::Single(existing) => (1, Self::Array(vec![*existing, added])),
Self::Array(elements) => {
let mut next = elements.clone();
next.push(added);
(next.len().saturating_sub(1), Self::Array(next))
}
}
}
#[must_use]
pub fn with_removed(&self, removed: &BTreeSet<usize>) -> (Self, BTreeMap<usize, usize>) {
match self {
Self::Absent => (Self::Absent, BTreeMap::new()),
Self::Single(_) => {
let shape = if removed.contains(&0) {
Self::Absent
} else {
self.clone()
};
(shape, BTreeMap::new())
}
Self::Array(elements) => {
let mut mapping = BTreeMap::new();
let mut kept = Vec::new();
for (old, element) in elements.iter().enumerate() {
if removed.contains(&old) {
continue;
}
let new = kept.len();
kept.push(*element);
mapping.insert(old, new);
}
(Self::Array(kept), mapping)
}
}
}
#[must_use]
pub fn to_object(&self, array_ref: Option<ObjRef>) -> Option<Object> {
match self {
Self::Absent => None,
Self::Single(reference) => Some(Object::Ref(*reference)),
Self::Array(elements) => {
let array = pdfrum_object::Array::of(elements.iter().map(|e| Object::Ref(*e)));
Some(match array_ref {
Some(reference) => {
let _ = &array;
Object::Ref(reference)
}
None => Object::Array(array),
})
}
}
}
#[must_use]
pub fn elements(&self) -> Vec<ObjRef> {
match self {
Self::Absent => Vec::new(),
Self::Single(reference) => vec![*reference],
Self::Array(elements) => elements.clone(),
}
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::indexing_slicing,
reason = "test fixtures index collections whose length the fixture fixes"
)]
use super::{ContentsShape, Regenerated, regenerate};
use pdfrum_common::kurbo::{Affine, BezPath};
use pdfrum_object::{Dict, Name, NoResolve, ObjRef, Object};
use pdfrum_page::{Content, FillRule, Page, PageObject, PathObject};
use pdfrum_page::{ContentMarks, GraphicsState};
use std::collections::{BTreeMap, BTreeSet};
fn path(stream: usize, dirty: bool) -> PageObject {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((1.0, 0.0));
p.line_to((1.0, 1.0));
p.close_path();
PageObject::Path(Box::new(Content {
object: PathObject {
path: p,
matrix: Affine::IDENTITY,
fill_rule: FillRule::Winding,
stroke: false,
},
state: GraphicsState::default(),
marks: ContentMarks::new(),
content_stream: Some(stream),
dirty,
active: true,
}))
}
fn page_of(objects: Vec<PageObject>) -> Page {
Page {
objects,
..Page::empty()
}
}
#[test]
fn an_untouched_page_regenerates_nothing() {
let page = page_of(vec![path(0, false), path(0, false)]);
assert!(regenerate(&page, &Dict::new(), &NoResolve).is_none());
}
#[test]
fn a_dirty_stream_gets_the_whole_frame() {
let page = page_of(vec![path(0, true)]);
let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
assert_eq!(rewrite.streams.len(), 1);
let bytes = &rewrite.streams[0].bytes;
assert!(
bytes.starts_with("q\n0 0 0 RG 0 0 0 rg 1 w 0 J 0 j\n/FXE1 gs "),
"got {bytes}"
);
assert!(bytes.ends_with("Q\n"), "got {bytes}");
let Some(Object::Dict(gs)) = rewrite.resources.raw(&Name::from("ExtGState")) else {
panic!("no ExtGState");
};
assert!(gs.contains_key(&Name::from("FXE1")));
}
#[test]
fn only_dirty_streams_are_written() {
let page = page_of(vec![path(0, false), path(1, true)]);
let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
assert_eq!(rewrite.streams.len(), 1);
assert_eq!(rewrite.streams[0].stream, Some(1));
}
#[test]
fn a_clean_object_in_a_dirty_stream_is_still_written() {
let mut page = page_of(vec![path(0, false), path(0, true)]);
page.objects[0].mark_clean();
let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
let bytes = &rewrite.streams[0].bytes;
assert_eq!(bytes.matches(" f Q\n").count(), 2, "got {bytes}");
}
#[test]
fn an_inactive_object_is_left_out_of_its_regenerated_stream() {
let mut page = page_of(vec![path(0, false), path(0, false)]);
page.objects[1].set_active(false);
let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
let bytes = &rewrite.streams[0].bytes;
assert_eq!(bytes.matches(" f Q\n").count(), 1, "got {bytes}");
}
#[test]
fn a_stream_left_with_nothing_comes_back_empty() {
let mut page = page_of(vec![path(0, false)]);
page.objects[0].set_active(false);
let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
assert_eq!(
rewrite.streams,
vec![Regenerated {
stream: Some(0),
bytes: String::new()
}]
);
}
#[test]
fn a_removal_regenerates_the_stream_it_emptied() {
let mut page = page_of(vec![path(0, false), path(1, false)]);
assert!(page.remove_object(1).is_some());
let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
assert_eq!(rewrite.streams.len(), 1);
assert_eq!(rewrite.streams[0].stream, Some(1));
assert!(rewrite.streams[0].bytes.is_empty());
}
#[test]
fn an_inherited_transform_is_undone_and_restated() {
let mut page = page_of(vec![path(1, true)]);
page.stream_ctms.insert(0, Affine::scale(2.0));
page.stream_ctms.insert(1, Affine::scale(2.0));
let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
let bytes = &rewrite.streams[0].bytes;
assert!(bytes.starts_with("q\n.5 0 0 .5 0 0 cm\n"), "got {bytes}");
assert!(bytes.ends_with("Q\n"), "got {bytes}");
}
#[test]
fn a_stream_that_moves_the_transform_restates_the_move() {
let mut page = page_of(vec![path(0, true)]);
page.stream_ctms.insert(0, Affine::scale(3.0));
let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
let bytes = &rewrite.streams[0].bytes;
assert!(
bytes.starts_with("q\n"),
"no inverse: stream 0 inherits none"
);
assert!(bytes.ends_with("Q\n3 0 0 3 0 0 cm\n"), "got {bytes}");
}
#[test]
fn an_empty_stream_that_moves_the_transform_survives() {
let mut page = page_of(vec![path(1, true)]);
page.dirty_streams.insert(Some(0));
page.stream_ctms.insert(0, Affine::scale(2.0));
let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
let zero = rewrite
.streams
.iter()
.find(|s| s.stream == Some(0))
.expect("stream 0");
assert!(!zero.bytes.is_empty(), "it moves the transform");
assert!(
zero.bytes.ends_with("2 0 0 2 0 0 cm\n"),
"got {}",
zero.bytes
);
}
#[test]
fn a_streamless_object_is_written_first() {
let mut page = page_of(vec![path(0, true)]);
page.push_object(path(0, false));
let rewrite = regenerate(&page, &Dict::new(), &NoResolve).expect("dirty");
let order: Vec<Option<usize>> = rewrite.streams.iter().map(|s| s.stream).collect();
assert_eq!(order, vec![None, Some(0)]);
}
#[test]
fn nothing_gaining_a_stream_becomes_a_lone_stream_at_zero() {
let (index, shape) = ContentsShape::Absent.with_added(ObjRef::new(5, 0));
assert_eq!(index, 0);
assert_eq!(shape, ContentsShape::Single(ObjRef::new(5, 0)));
}
#[test]
fn a_lone_stream_gaining_a_second_becomes_an_array_and_the_new_one_is_one() {
let shape = ContentsShape::Single(ObjRef::new(4, 0));
let (index, next) = shape.with_added(ObjRef::new(9, 0));
assert_eq!(index, 1);
assert_eq!(
next,
ContentsShape::Array(vec![ObjRef::new(4, 0), ObjRef::new(9, 0)])
);
}
#[test]
fn an_array_gaining_one_appends_at_the_end() {
let shape = ContentsShape::Array(vec![ObjRef::new(1, 0), ObjRef::new(2, 0)]);
let (index, next) = shape.with_added(ObjRef::new(3, 0));
assert_eq!(index, 2);
assert_eq!(next.elements().len(), 3);
}
#[test]
fn a_lone_stream_removed_leaves_no_contents_key_at_all() {
let shape = ContentsShape::Single(ObjRef::new(4, 0));
let (next, mapping) = shape.with_removed(&[0].into_iter().collect());
assert_eq!(next, ContentsShape::Absent);
assert!(mapping.is_empty());
assert_eq!(next.to_object(None), None);
}
#[test]
fn removing_an_array_element_shifts_the_ones_after_it_down() {
let shape = ContentsShape::Array(vec![
ObjRef::new(1, 0),
ObjRef::new(2, 0),
ObjRef::new(3, 0),
]);
let (next, mapping) = shape.with_removed(&[1].into_iter().collect());
assert_eq!(
next,
ContentsShape::Array(vec![ObjRef::new(1, 0), ObjRef::new(3, 0)])
);
assert_eq!(mapping, BTreeMap::from([(0, 0), (2, 1)]));
}
#[test]
fn an_array_is_never_collapsed_back_to_a_bare_stream() {
let shape = ContentsShape::Array(vec![ObjRef::new(1, 0), ObjRef::new(2, 0)]);
let (next, _) = shape.with_removed(&[1].into_iter().collect());
assert!(matches!(next, ContentsShape::Array(ref e) if e.len() == 1));
let (empty, _) = next.with_removed(&[0].into_iter().collect());
assert_eq!(empty, ContentsShape::Array(Vec::new()));
}
#[test]
fn removing_several_elements_renumbers_the_survivors_in_one_pass() {
let shape = ContentsShape::Array((1..=5).map(|n| ObjRef::new(n, 0)).collect());
let removed: BTreeSet<usize> = [0, 3].into_iter().collect();
let (next, mapping) = shape.with_removed(&removed);
assert_eq!(next.elements().len(), 3);
assert_eq!(mapping, BTreeMap::from([(1, 0), (2, 1), (4, 2)]));
}
#[test]
fn a_page_with_no_contents_reads_as_absent() {
assert_eq!(
ContentsShape::read(&Dict::new(), &NoResolve),
ContentsShape::Absent
);
let odd = Dict::from_pairs([(pdfrum_object::names::CONTENTS.clone(), Object::Int(7))]);
assert_eq!(ContentsShape::read(&odd, &NoResolve), ContentsShape::Absent);
}
#[test]
fn an_inline_array_of_references_reads_as_an_array() {
let array = pdfrum_object::Array::of([
Object::Ref(ObjRef::new(2, 0)),
Object::Ref(ObjRef::new(3, 0)),
]);
let dict =
Dict::from_pairs([(pdfrum_object::names::CONTENTS.clone(), Object::Array(array))]);
assert_eq!(
ContentsShape::read(&dict, &NoResolve),
ContentsShape::Array(vec![ObjRef::new(2, 0), ObjRef::new(3, 0)])
);
}
}