use lopdf::content::{Content, Operation};
use lopdf::{dictionary, Dictionary, Document, Object, ObjectId, Stream, StringFormat};
use serde::Deserialize;
use crate::font::{build_to_unicode_cmap, subset_for_pdf};
use crate::inspect::{
self, content_stream_ids, encode_middle_for_font, is_line_moving_show, walk_page_graph,
ContentBudget, ReencodeFailure,
};
use crate::model::{
document_sha256, effective_pdf_edit_commands, ComposedCandidate, GapNumber, GlyphSourceAddress,
Matrix, PdfColor, PdfEditCommand, PdfEditError, PdfFontResolver, PdfOcrBackgroundProof,
PdfPageGraph, PdfPoint, PdfRect, PdfSourceLocator, PdfSourceSpan, PdfTextAppearance,
PdfTextEditState, PdfTextIsland, ResolvedPdfFont, RunItem,
};
const OVERFLOW_EPSILON_PT: f64 = 0.05;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WriteStrategy {
ExactOperandPatch,
LocalTextObjectRewrite,
}
fn select_replace_strategy(
before: &PdfTextEditState,
after: &PdfTextEditState,
in_place_editable: bool,
) -> WriteStrategy {
let same_font_encoding = before.appearance == after.appearance;
if same_font_encoding && in_place_editable {
WriteStrategy::ExactOperandPatch
} else {
WriteStrategy::LocalTextObjectRewrite
}
}
pub fn compose_candidate(
source: &[u8],
commands: &[PdfEditCommand],
fonts: &dyn PdfFontResolver,
) -> Result<ComposedCandidate, PdfEditError> {
let current_sha = document_sha256(source);
compose_candidate_on_base(¤t_sha, source, commands, fonts)
}
pub fn compose_candidate_on_base(
source_sha: &str,
base: &[u8],
commands: &[PdfEditCommand],
fonts: &dyn PdfFontResolver,
) -> Result<ComposedCandidate, PdfEditError> {
if commands.is_empty() {
return Ok(ComposedCandidate {
bytes: base.to_vec(),
affected_pages: Vec::new(),
});
}
let mut doc = inspect::load_pdf(base).map_err(|_| PdfEditError::MalformedContent)?;
if doc.is_encrypted() {
return Err(PdfEditError::EncryptedDocument);
}
let resources_before = inspect::resource_fingerprint(&doc)?;
let mut affected_pages: Vec<u32> = Vec::new();
let effective = effective_pdf_edit_commands(commands);
let mut isolated: Vec<(usize, Isolated)> = Vec::new();
let mut appended: Vec<(usize, AppendedRun)> = Vec::new();
for slot in back_to_front_order(&effective) {
let command = effective[slot];
let (page, block) = match command {
PdfEditCommand::ReplaceSourceText {
locator,
before,
after,
available_bounds,
..
} => {
if locator.document_sha256 != source_sha {
return Err(PdfEditError::StaleSource);
}
let (page, block, run) = apply_replace_source_text(
&mut doc,
source_sha,
locator,
before,
after,
*available_bounds,
fonts,
)?;
appended.extend(run.map(|run| (slot, run)));
(page, block)
}
PdfEditCommand::AddText {
page,
anchor,
text,
appearance,
..
} => apply_add_text(&mut doc, *page, *anchor, text, appearance, fonts)?,
PdfEditCommand::CorrectOcrLine {
page,
rect,
text,
appearance,
cover_color,
background_proof,
..
} => apply_correct_ocr_text(
&mut doc,
*page,
*rect,
text,
appearance,
*cover_color,
*background_proof,
fonts,
)?,
};
if let Some(block) = block {
isolated.push((slot, block));
}
if !affected_pages.contains(&page) {
affected_pages.push(page);
}
}
let streams = install_isolated_content(&mut doc, isolated)?;
for (slot, run) in &mut appended {
run.stream = streams.get(slot).copied();
}
let appended: Vec<AppendedRun> = appended.into_iter().map(|(_, run)| run).collect();
verify_appended_runs(&doc, &appended)?;
if !inspect::resources_preserved(&resources_before, &inspect::resource_fingerprint(&doc)?) {
return Err(PdfEditError::VerificationFailed);
}
doc.compress();
let mut bytes = Vec::new();
doc.save_to(&mut bytes)
.map_err(|_| PdfEditError::MalformedContent)?;
affected_pages.sort_unstable();
Ok(ComposedCandidate {
bytes,
affected_pages,
})
}
type Isolated = (ObjectId, Vec<Operation>);
fn command_address(command: &PdfEditCommand) -> (u32, u32, u32, u32) {
match command {
PdfEditCommand::ReplaceSourceText { locator, .. } => locator
.spans
.iter()
.map(|span| {
(
span.contents_index,
span.operator_index,
span.operand_index,
span.glyph_start,
)
})
.min()
.unwrap_or_default(),
_ => (0, 0, 0, 0),
}
}
fn back_to_front_order(commands: &[&PdfEditCommand]) -> Vec<usize> {
let mut by_page: std::collections::BTreeMap<u32, Vec<usize>> =
std::collections::BTreeMap::new();
for (index, command) in commands.iter().enumerate() {
if let PdfEditCommand::ReplaceSourceText { locator, .. } = command {
by_page.entry(locator.page).or_default().push(index);
}
}
for list in by_page.values_mut() {
list.sort_by(|a, b| {
command_address(commands[*b])
.cmp(&command_address(commands[*a]))
.then(a.cmp(b))
});
}
let mut taken: std::collections::BTreeMap<u32, usize> = std::collections::BTreeMap::new();
commands
.iter()
.enumerate()
.map(|(index, command)| match command {
PdfEditCommand::ReplaceSourceText { locator, .. } => {
let next = taken.entry(locator.page).or_insert(0);
let chosen = by_page[&locator.page][*next];
*next += 1;
chosen
}
_ => index,
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn apply_replace_source_text(
doc: &mut Document,
current_sha: &str,
locator: &PdfSourceLocator,
before: &PdfTextEditState,
after: &PdfTextEditState,
available_bounds: PdfRect,
fonts: &dyn PdfFontResolver,
) -> Result<(u32, Option<Isolated>, Option<AppendedRun>), PdfEditError> {
if locator.spans.is_empty() {
return Err(PdfEditError::UnsupportedEncoding);
}
let graph = walk_page_graph(doc, current_sha, locator.page)?;
let island = graph
.islands
.iter()
.find(|isl| isl.locator.island_digest == locator.island_digest)
.ok_or(PdfEditError::SourceDigestMismatch)?;
if island.locator.spans.is_empty() {
return Err(PdfEditError::UnsupportedEncoding);
}
if locator.spans.len() != island.locator.spans.len()
|| !locator
.spans
.iter()
.zip(&island.locator.spans)
.all(|(given, current)| spans_equal(given, current))
{
return Err(PdfEditError::SourceDigestMismatch);
}
let spans: &[PdfSourceSpan] = &island.locator.spans;
let stream_ref = spans[0].stream_ref;
let contents_index = spans[0].contents_index;
if !spans
.iter()
.all(|s| s.stream_ref == stream_ref && s.contents_index == contents_index)
{
return Err(PdfEditError::UnsupportedEncoding);
}
let stream_id: ObjectId = (stream_ref.0, stream_ref.1);
let page_id = pages_lookup(doc, locator.page)?;
if before.appearance == after.appearance && !island.editable {
return Err(PdfEditError::UnsupportedEncoding);
}
isolate_shared_content_stream(doc, page_id, stream_id)?;
let (block, run) = match select_replace_strategy(before, after, island.editable) {
WriteStrategy::ExactOperandPatch => {
apply_exact_operand_patch(
doc,
page_id,
locator.page,
stream_id,
island,
after,
available_bounds,
)?;
(None, None)
}
WriteStrategy::LocalTextObjectRewrite => {
let (block, run) = apply_local_text_object_rewrite(
doc,
locator.page,
page_id,
stream_id,
island,
after,
available_bounds,
fonts,
)?;
(Some(block), run)
}
};
Ok((locator.page, block, run))
}
const MAX_ISOLATED_REFERENCES: usize = 1 << 20;
fn isolate_shared_content_stream(
doc: &mut Document,
page_id: ObjectId,
stream_id: ObjectId,
) -> Result<(), PdfEditError> {
let mut sharers: Vec<(ObjectId, Vec<ObjectId>)> = Vec::new();
let mut references = 0usize;
for other in doc.get_pages().into_values() {
if other == page_id {
continue;
}
let Ok(Object::Dictionary(dict)) = doc.get_object(other) else {
continue;
};
let Ok(contents) = dict.get(b"Contents") else {
continue;
};
let ids = content_stream_ids(doc, contents)?;
if ids.contains(&stream_id) {
references = references
.checked_add(ids.len())
.filter(|total| *total <= MAX_ISOLATED_REFERENCES)
.ok_or(PdfEditError::MalformedContent)?;
sharers.push((other, ids));
}
}
if sharers.is_empty() {
return Ok(());
}
let original = doc
.get_object(stream_id)
.map_err(|_| PdfEditError::MalformedContent)?
.clone();
let copy_id = doc.add_object(original);
for (other, ids) in sharers {
let array = Object::Array(
ids.into_iter()
.map(|id| Object::Reference(if id == stream_id { copy_id } else { id }))
.collect(),
);
if let Ok(Object::Dictionary(dict)) = doc.get_object_mut(other) {
dict.set("Contents", array);
}
}
Ok(())
}
fn spans_equal(a: &PdfSourceSpan, b: &PdfSourceSpan) -> bool {
a.stream_ref == b.stream_ref
&& a.contents_index == b.contents_index
&& a.operator_index == b.operator_index
&& a.operand_index == b.operand_index
&& a.glyph_start == b.glyph_start
&& a.glyph_end == b.glyph_end
&& a.expected_digest == b.expected_digest
}
fn pages_lookup(doc: &Document, page: u32) -> Result<ObjectId, PdfEditError> {
doc.get_pages()
.get(&page)
.copied()
.ok_or(PdfEditError::MalformedContent)
}
struct RunEdit {
first: usize,
end: usize,
middle: String,
}
fn plan_run_edit(run: &[RunItem], old: &[char], new: &[char]) -> Result<RunEdit, PdfEditError> {
let mut prefix = 0;
while prefix < old.len() && prefix < new.len() && old[prefix] == new[prefix] {
prefix += 1;
}
let mut suffix = 0;
while suffix < old.len() - prefix
&& suffix < new.len() - prefix
&& old[old.len() - 1 - suffix] == new[new.len() - 1 - suffix]
{
suffix += 1;
}
let old_end = old.len() - suffix;
let mut bounds: Vec<(usize, usize)> = Vec::with_capacity(run.len());
let mut at = 0;
for item in run {
let n = item.text.chars().count();
bounds.push((at, at + n));
at += n;
}
if at != old.len() {
return Err(PdfEditError::UnsupportedEncoding);
}
let is_glyph = |i: usize| run[i].glyph.is_some();
let (mut first, mut end);
if prefix < old_end {
first = bounds
.iter()
.position(|&(s, e)| s <= prefix && prefix < e)
.ok_or(PdfEditError::UnsupportedEncoding)?;
end = bounds
.iter()
.rposition(|&(s, _)| s < old_end)
.ok_or(PdfEditError::UnsupportedEncoding)?
+ 1;
} else {
let before = (prefix > 0)
.then(|| bounds.iter().position(|&(s, e)| s < prefix && prefix <= e))
.flatten()
.filter(|&i| is_glyph(i));
let after = bounds
.iter()
.position(|&(s, e)| s <= prefix && prefix < e)
.filter(|&i| is_glyph(i));
let anchor = before.or(after).ok_or(PdfEditError::UnsupportedEncoding)?;
first = anchor;
end = anchor + 1;
}
if !(first..end).any(is_glyph) {
if first > 0 && is_glyph(first - 1) {
first -= 1;
} else if end < run.len() && is_glyph(end) {
end += 1;
} else {
return Err(PdfEditError::UnsupportedEncoding);
}
}
let region_start = bounds[first].0;
let region_end = bounds[end - 1].1;
let middle: String = new[region_start..new.len() - (old.len() - region_end)]
.iter()
.collect();
Ok(RunEdit { first, end, middle })
}
fn gap_sum(item: &RunItem) -> Option<f64> {
item.gap_numbers
.as_ref()
.map(|numbers| numbers.iter().map(|n| n.value).sum())
}
fn number_object(value: f64) -> Object {
if value.fract() == 0.0 && value.abs() < 1e9 {
Object::Integer(value as i64)
} else {
Object::Real(value as f32)
}
}
fn zero_tj_number(content: &mut Content, number: &GapNumber) -> Result<(), PdfEditError> {
let op = content
.operations
.get_mut(number.operator_index as usize)
.ok_or(PdfEditError::SourceDigestMismatch)?;
if op.operator != "TJ" {
return Err(PdfEditError::SourceDigestMismatch);
}
let Some(Object::Array(items)) = op.operands.get_mut(0) else {
return Err(PdfEditError::SourceDigestMismatch);
};
let item = items
.get_mut(number.item_index as usize)
.ok_or(PdfEditError::SourceDigestMismatch)?;
*item = Object::Integer(0);
Ok(())
}
fn write_pieces(
content: &mut Content,
slot: &GlyphSourceAddress,
range: (u32, u32),
pieces: &[Vec<u8>],
gaps: &[f64],
) -> Result<(), PdfEditError> {
if gaps.is_empty() {
return splice_operand_bytes(
content,
slot.operator_index,
slot.operand_index,
range.0,
range.1,
&pieces[0],
);
}
let cannot_split = || PdfEditError::MissingGlyphs(vec![' ']);
let op = content
.operations
.get_mut(slot.operator_index as usize)
.ok_or(PdfEditError::SourceDigestMismatch)?;
let (bytes, format) = match (op.operator.as_str(), op.operands.first()) {
("Tj", Some(Object::String(bytes, format))) => (bytes.clone(), *format),
("TJ", Some(Object::Array(items))) => match items.get(slot.operand_index as usize) {
Some(Object::String(bytes, format)) => (bytes.clone(), *format),
_ => return Err(PdfEditError::SourceDigestMismatch),
},
_ => return Err(cannot_split()),
};
let (start, end) = (range.0 as usize, range.1 as usize);
if start > end || end > bytes.len() {
return Err(PdfEditError::SourceDigestMismatch);
}
let mut new_items: Vec<Object> = Vec::with_capacity(pieces.len() * 2);
for (k, piece) in pieces.iter().enumerate() {
let mut text: Vec<u8> = Vec::new();
if k == 0 {
text.extend_from_slice(&bytes[..start]);
}
text.extend_from_slice(piece);
if k + 1 == pieces.len() {
text.extend_from_slice(&bytes[end..]);
}
if k > 0 {
new_items.push(number_object(gaps[k - 1]));
}
new_items.push(Object::String(text, format));
}
if op.operator == "Tj" {
op.operator = "TJ".to_string();
op.operands = vec![Object::Array(new_items)];
} else if let Some(Object::Array(items)) = op.operands.get_mut(0) {
let at = slot.operand_index as usize;
items.splice(at..=at, new_items);
}
Ok(())
}
fn apply_exact_operand_patch(
doc: &mut Document,
page_id: ObjectId,
page: u32,
stream_id: ObjectId,
island: &PdfTextIsland,
after: &PdfTextEditState,
available_bounds: PdfRect,
) -> Result<(), PdfEditError> {
let layout = island.layout.ok_or(PdfEditError::UnsupportedEncoding)?;
let old: Vec<char> = island.text.chars().collect();
let new: Vec<char> = after.text.chars().collect();
if old == new {
return Ok(());
}
let plan = plan_run_edit(&island.run, &old, &new)?;
let region = &island.run[plan.first..plan.end];
let middle = encode_middle_for_font(doc, page_id, &island.appearance.font_id, &plan.middle)
.map_err(|failure| match failure {
ReencodeFailure::Blocked => PdfEditError::UnsupportedEncoding,
ReencodeFailure::MissingGlyphs(chars) => PdfEditError::MissingGlyphs(chars),
})?;
let size = layout.font_size;
let th = layout.horizontal_scale;
let gap_text = |value: f64| -(value / 1000.0) * size * th;
let mut gap_values: Vec<f64> = Vec::new();
if !middle.space_glyph {
if middle.pieces.len() > 1 && middle.pieces.iter().any(|piece| piece.char_count() == 0) {
return Err(PdfEditError::MissingGlyphs(vec![' ']));
}
let replaced: Vec<f64> = region.iter().filter_map(gap_sum).collect();
let reference = island.run.iter().find_map(gap_sum);
for k in 0..middle.pieces.len().saturating_sub(1) {
let value = replaced
.get(k)
.copied()
.or(reference)
.ok_or_else(|| PdfEditError::MissingGlyphs(vec![' ']))?;
gap_values.push(value);
}
}
let region_glyphs: Vec<&RunItem> = region.iter().filter(|i| i.glyph.is_some()).collect();
let code_len = |i: &&RunItem| {
i.glyph
.as_ref()
.map_or(0, |g| (g.glyph_end - g.glyph_start) as usize)
};
let positional = !region_glyphs.is_empty()
&& region_glyphs.len() == region.len()
&& region_glyphs.iter().all(|i| i.text.chars().count() == 1)
&& middle.pieces.len() == 1
&& gap_values.is_empty()
&& middle.pieces[0].char_count() == region_glyphs.len()
&& region_glyphs
.iter()
.all(|i| code_len(i) * region_glyphs.len() == middle.pieces[0].bytes.len());
let scale = layout.axis.0.hypot(layout.axis.1);
if !(scale.is_finite() && scale > 0.0) {
return Err(PdfEditError::UnsupportedEncoding);
}
let advance = |piece: &inspect::EncodedRun| {
piece.advance(size, layout.char_spacing, layout.word_spacing, th)
};
let mut new_region_text: f64 = middle.pieces.iter().map(advance).sum();
for (piece, value) in middle.pieces.iter().zip(&gap_values) {
new_region_text += layout.char_spacing * th + gap_text(*value);
if piece.ends_with_word_space() {
new_region_text += layout.word_spacing * th;
}
}
let island_len = island.run.last().map_or(0.0, |i| i.end) - island.run[0].start;
let old_region = region[region.len() - 1].end - region[0].start;
let region_reaches_island_end = plan.end == island.run.len();
let new_len_text = if positional {
let old_widths: f64 = region.iter().map(|i| i.end - i.start).sum();
let new_widths = middle.pieces[0].advance(size, 0.0, 0.0, th);
let mut tw_delta = 0.0;
for (k, item) in region_glyphs.iter().enumerate() {
if region_reaches_island_end && k + 1 == region_glyphs.len() {
continue;
}
let was_space = item.word_space;
let is_space = middle.pieces[0].word_space_at(k);
tw_delta += layout.word_spacing * th * (f64::from(is_space) - f64::from(was_space));
}
island_len / scale + new_widths - old_widths / scale + tw_delta
} else {
let trailing_tw = if region_reaches_island_end {
0.0
} else {
let was_space = region
.last()
.is_some_and(|i| i.glyph.is_some() && i.word_space);
let is_space = middle
.pieces
.last()
.is_some_and(|piece| piece.ends_with_word_space());
layout.word_spacing * th * (f64::from(is_space) - f64::from(was_space))
};
(island_len - old_region) / scale + new_region_text + trailing_tw
};
validate_layout_advance(new_len_text, layout.axis, available_bounds)?;
let mut content = decode_stream_content(doc, stream_id, &mut ContentBudget::new())?;
if positional {
let bytes = &middle.pieces[0].bytes;
let width = bytes.len() / region_glyphs.len();
for (item, chunk) in region_glyphs.iter().zip(bytes.chunks(width)) {
let g = item.glyph.as_ref().expect("filtered to glyphs");
splice_operand_bytes(
&mut content,
g.operator_index,
g.operand_index,
g.glyph_start,
g.glyph_end,
chunk,
)?;
}
write_stream_content(doc, stream_id, &content)?;
return verify_written(doc, page, island, &after.text, available_bounds);
}
for item in region {
for number in item.gap_numbers.iter().flatten() {
zero_tj_number(&mut content, number)?;
}
}
let glyphs: Vec<&GlyphSourceAddress> = region.iter().filter_map(|i| i.glyph.as_ref()).collect();
let slot = glyphs[0];
zero_numbers_between(&mut content, slot, glyphs[glyphs.len() - 1])?;
let same_operand = |g: &GlyphSourceAddress| {
g.operator_index == slot.operator_index && g.operand_index == slot.operand_index
};
for g in glyphs.iter().rev().filter(|g| !same_operand(g)) {
splice_operand_bytes(
&mut content,
g.operator_index,
g.operand_index,
g.glyph_start,
g.glyph_end,
&[],
)?;
}
let slot_end = glyphs
.iter()
.filter(|g| same_operand(g))
.map(|g| g.glyph_end)
.max()
.unwrap_or(slot.glyph_end);
let pieces: Vec<Vec<u8>> = middle.pieces.iter().map(|p| p.bytes.clone()).collect();
write_pieces(
&mut content,
slot,
(slot.glyph_start, slot_end),
&pieces,
&gap_values,
)?;
write_stream_content(doc, stream_id, &content)?;
verify_written(doc, page, island, &after.text, available_bounds)
}
fn zero_numbers_between(
content: &mut Content,
first: &GlyphSourceAddress,
last: &GlyphSourceAddress,
) -> Result<(), PdfEditError> {
if (last.operator_index, last.operand_index) < (first.operator_index, first.operand_index) {
return Err(PdfEditError::SourceDigestMismatch);
}
for op_index in first.operator_index..=last.operator_index {
let op = content
.operations
.get_mut(op_index as usize)
.ok_or(PdfEditError::SourceDigestMismatch)?;
if op.operator != "TJ" {
continue;
}
let Some(Object::Array(items)) = op.operands.get_mut(0) else {
continue;
};
let from = if op_index == first.operator_index {
first.operand_index as usize + 1
} else {
0
};
let to = if op_index == last.operator_index {
(last.operand_index as usize).min(items.len())
} else {
items.len()
};
for item in items.iter_mut().take(to).skip(from) {
if matches!(item, Object::Integer(_) | Object::Real(_)) {
*item = Object::Integer(0);
}
}
}
Ok(())
}
fn verify_written(
doc: &Document,
page: u32,
island: &PdfTextIsland,
wanted: &str,
available: PdfRect,
) -> Result<(), PdfEditError> {
if wanted.is_empty() {
return Ok(());
}
let first = island
.locator
.spans
.first()
.ok_or(PdfEditError::VerificationFailed)?;
let graph = walk_page_graph(doc, "", page).map_err(|_| PdfEditError::VerificationFailed)?;
let written = graph
.islands
.iter()
.find(|candidate| {
candidate.locator.spans.first().is_some_and(|span| {
span.stream_ref == first.stream_ref
&& span.contents_index == first.contents_index
&& span.operator_index == first.operator_index
&& span.operand_index == first.operand_index
&& span.glyph_start == first.glyph_start
})
})
.ok_or(PdfEditError::VerificationFailed)?;
let wanted_chars = wanted.chars().count();
let mut text = String::new();
let mut last_item = None;
for (index, item) in written.run.iter().enumerate() {
text.push_str(&item.text);
if text.chars().count() >= wanted_chars {
last_item = Some(index);
break;
}
}
let last_item = last_item.ok_or(PdfEditError::VerificationFailed)?;
if text != wanted {
return Err(PdfEditError::VerificationFailed);
}
let layout = written.layout.ok_or(PdfEditError::VerificationFailed)?;
let scale = layout.axis.0.hypot(layout.axis.1);
if !(scale.is_finite() && scale > 0.0) {
return Err(PdfEditError::VerificationFailed);
}
let extent = written.run[last_item].end - written.run[0].start;
validate_layout_advance(extent / scale, layout.axis, available)
}
fn validate_layout_advance(
advance: f64,
axis: (f64, f64),
available: PdfRect,
) -> Result<(), PdfEditError> {
let width = (advance * axis.0).abs();
let height = (advance * axis.1).abs();
if !(width.is_finite() && height.is_finite())
|| width > available.width + OVERFLOW_EPSILON_PT
|| height > available.height + OVERFLOW_EPSILON_PT
{
return Err(PdfEditError::TextOverflow);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn apply_local_text_object_rewrite(
doc: &mut Document,
page: u32,
page_id: ObjectId,
stream_id: ObjectId,
island: &PdfTextIsland,
after: &PdfTextEditState,
available_bounds: PdfRect,
fonts: &dyn PdfFontResolver,
) -> Result<(Isolated, Option<AppendedRun>), PdfEditError> {
let resolved = fonts.resolve(&after.appearance.font_id)?;
fonts.glyphs_for_text(&resolved, &after.text)?;
let layout = island.layout.ok_or(PdfEditError::UnsupportedEncoding)?;
let placement = layout.placement;
let finite = [
placement.a,
placement.b,
placement.c,
placement.d,
placement.e,
placement.f,
]
.iter()
.all(|value| value.is_finite());
let scale = layout.axis.0.hypot(layout.axis.1);
if !(finite && scale > 0.0 && after.appearance.size_pt.is_finite()) {
return Err(PdfEditError::UnsupportedTransform);
}
let advance_1000 = measure_advance_1000(&resolved, &after.text);
validate_layout_advance(
advance_1000 / 1000.0 * after.appearance.size_pt,
layout.axis,
available_bounds,
)?;
let removed = removed_glyphs(island)?;
let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, &after.text)?;
let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
let mut content = decode_stream_content(doc, stream_id, &mut ContentBudget::new())?;
remove_glyphs_keeping_pen(&mut content, removed)?;
write_stream_content(doc, stream_id, &content)?;
let operations =
build_text_object_operations(&resource_name, &after.appearance, placement, &cid_codes);
let run = (!after.text.is_empty()).then_some(AppendedRun {
page,
stream: None,
placement,
size: after.appearance.size_pt,
});
Ok(((page_id, operations), run))
}
struct AppendedRun {
page: u32,
stream: Option<ObjectId>,
placement: Matrix,
size: f64,
}
const APPENDED_ORIGIN_TOLERANCE_PT: f64 = 0.5;
const APPENDED_SIZE_TOLERANCE: f64 = 0.02;
fn verify_appended_runs(doc: &Document, runs: &[AppendedRun]) -> Result<(), PdfEditError> {
use std::collections::btree_map::Entry;
let mut graphs: std::collections::BTreeMap<u32, PdfPageGraph> =
std::collections::BTreeMap::new();
for run in runs {
let stream = run.stream.ok_or(PdfEditError::VerificationFailed)?;
let graph = match graphs.entry(run.page) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => entry.insert(
walk_page_graph(doc, "", run.page).map_err(|_| PdfEditError::VerificationFailed)?,
),
};
let written = graph
.islands
.iter()
.flat_map(|island| island.run.iter())
.find(|item| {
item.glyph
.is_some_and(|glyph| glyph.stream_ref == (stream.0, stream.1))
})
.and_then(|item| item.placement)
.ok_or(PdfEditError::VerificationFailed)?;
if !appended_run_placed(run, written) {
return Err(PdfEditError::VerificationFailed);
}
}
Ok(())
}
fn appended_run_placed(run: &AppendedRun, written: (Matrix, f64)) -> bool {
let (want, (have, have_size)) = (run.placement, written);
let origin = (have.e - want.e).hypot(have.f - want.f);
let em_x = |m: Matrix, size: f64| (m.a * size, m.b * size);
let em_y = |m: Matrix, size: f64| (m.c * size, m.d * size);
let near = |want: (f64, f64), have: (f64, f64)| {
(have.0 - want.0).hypot(have.1 - want.1) <= APPENDED_SIZE_TOLERANCE * want.0.hypot(want.1)
};
origin <= APPENDED_ORIGIN_TOLERANCE_PT
&& near(em_x(want, run.size), em_x(have, have_size))
&& near(em_y(want, run.size), em_y(have, have_size))
}
struct RemovedGlyph {
operator_index: u32,
operand_index: u32,
start: u32,
end: u32,
adjust: f64,
}
fn removed_glyphs(island: &PdfTextIsland) -> Result<Vec<RemovedGlyph>, PdfEditError> {
let mut removed = Vec::new();
for item in &island.run {
let Some(glyph) = &item.glyph else { continue };
removed.push(RemovedGlyph {
operator_index: glyph.operator_index,
operand_index: glyph.operand_index,
start: glyph.glyph_start,
end: glyph.glyph_end,
adjust: item.pen_adjust.ok_or(PdfEditError::UnsupportedEncoding)?,
});
}
Ok(removed)
}
fn remove_glyphs_keeping_pen(
content: &mut Content,
mut removed: Vec<RemovedGlyph>,
) -> Result<(), PdfEditError> {
removed.sort_by_key(|g| (g.operator_index, g.operand_index, g.start));
let mut end = removed.len();
while end > 0 {
let operator_index = removed[end - 1].operator_index;
let mut begin = end;
while begin > 0 && removed[begin - 1].operator_index == operator_index {
begin -= 1;
}
let glyphs = &removed[begin..end];
let op = content
.operations
.get_mut(operator_index as usize)
.ok_or(PdfEditError::SourceDigestMismatch)?;
let operator = op.operator.clone();
match operator.as_str() {
"TJ" => {
let Some(Object::Array(items)) = op.operands.get_mut(0) else {
return Err(PdfEditError::SourceDigestMismatch);
};
let mut hi = glyphs.len();
while hi > 0 {
let item_index = glyphs[hi - 1].operand_index;
let mut lo = hi;
while lo > 0 && glyphs[lo - 1].operand_index == item_index {
lo -= 1;
}
let replacement = match items.get(item_index as usize) {
Some(Object::String(bytes, format)) => {
pieces_around_removed(bytes, *format, &glyphs[lo..hi])?
}
_ => return Err(PdfEditError::SourceDigestMismatch),
};
let at = item_index as usize;
items.splice(at..=at, replacement).for_each(drop);
hi = lo;
}
}
"Tj" => {
let replacement = match op.operands.first() {
Some(Object::String(bytes, format)) => {
pieces_around_removed(bytes, *format, glyphs)?
}
_ => return Err(PdfEditError::SourceDigestMismatch),
};
op.operator = "TJ".to_string();
op.operands = vec![Object::Array(replacement)];
}
other if is_line_moving_show(other) => return Err(PdfEditError::UnsupportedEncoding),
_ => return Err(PdfEditError::SourceDigestMismatch),
}
end = begin;
}
Ok(())
}
fn pieces_around_removed(
bytes: &[u8],
format: StringFormat,
glyphs: &[RemovedGlyph],
) -> Result<Vec<Object>, PdfEditError> {
let mut out = Vec::with_capacity(glyphs.len() * 2 + 1);
let mut at = 0usize;
for glyph in glyphs {
let (start, end) = (glyph.start as usize, glyph.end as usize);
if start < at || end < start || end > bytes.len() {
return Err(PdfEditError::SourceDigestMismatch);
}
if start > at {
out.push(Object::String(bytes[at..start].to_vec(), format));
}
out.push(number_object(glyph.adjust));
at = end;
}
if at < bytes.len() {
out.push(Object::String(bytes[at..].to_vec(), format));
}
Ok(out)
}
fn measure_advance_1000(resolved: &ResolvedPdfFont, text: &str) -> f64 {
let Ok(face) = ttf_parser::Face::parse(&resolved.bytes, resolved.face_index) else {
return text.chars().count() as f64 * 500.0;
};
let units_per_em = face.units_per_em() as f64;
if units_per_em <= 0.0 {
return text.chars().count() as f64 * 500.0;
}
text.chars()
.filter_map(|c| face.glyph_index(c))
.map(|gid| face.glyph_hor_advance(gid).unwrap_or(0) as f64 * 1000.0 / units_per_em)
.sum()
}
fn apply_add_text(
doc: &mut Document,
page: u32,
anchor: PdfPoint,
text: &str,
appearance: &PdfTextAppearance,
fonts: &dyn PdfFontResolver,
) -> Result<(u32, Option<Isolated>), PdfEditError> {
if doc.is_encrypted() {
return Err(PdfEditError::EncryptedDocument);
}
let page_id = pages_lookup(doc, page)?;
let resolved = fonts.resolve(&appearance.font_id)?;
fonts.glyphs_for_text(&resolved, text)?;
let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, text)?;
let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
Ok((
page,
Some((
page_id,
build_text_object_operations(
&resource_name,
appearance,
Matrix::translation(anchor.x, anchor.y),
&cid_codes,
),
)),
))
}
#[allow(clippy::too_many_arguments)]
fn apply_correct_ocr_text(
doc: &mut Document,
page: u32,
rect: PdfRect,
text: &str,
appearance: &PdfTextAppearance,
cover_color: PdfColor,
background_proof: PdfOcrBackgroundProof,
fonts: &dyn PdfFontResolver,
) -> Result<(u32, Option<Isolated>), PdfEditError> {
let page_id = pages_lookup(doc, page)?;
let page_bounds = page_bounds(doc, page_id)?;
validate_ocr_correction(
page_bounds,
rect,
text,
appearance,
cover_color,
background_proof,
page_rotation(doc, page_id)?,
)?;
let resolved = fonts.resolve(&appearance.font_id)?;
fonts.glyphs_for_text(&resolved, text)?;
let width = measure_advance_1000(&resolved, text) / 1000.0 * appearance.size_pt;
if width > rect.width || appearance.size_pt > rect.height {
return Err(PdfEditError::TextOverflow);
}
let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, text)?;
let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
Ok((
page,
Some((
page_id,
build_ocr_correction_operations(
&resource_name,
appearance,
rect,
cover_color,
&cid_codes,
),
)),
))
}
fn validate_ocr_correction(
page_bounds: PdfRect,
rect: PdfRect,
text: &str,
appearance: &PdfTextAppearance,
cover_color: PdfColor,
background_proof: PdfOcrBackgroundProof,
rotation: i64,
) -> Result<(), PdfEditError> {
if rotation != 0 {
return Err(PdfEditError::UnsupportedTransform);
}
if !matches!(background_proof, PdfOcrBackgroundProof::NearUniform)
|| !is_finite_color(cover_color)
|| cover_color.alpha < 1.0
|| !is_finite_color(appearance.color)
|| !appearance.size_pt.is_finite()
|| appearance.size_pt <= 0.0
|| text.is_empty()
|| !is_finite_rect(page_bounds)
|| page_bounds.width <= 0.0
|| page_bounds.height <= 0.0
|| !is_finite_rect(rect)
|| rect.width <= 0.0
|| rect.height <= 0.0
|| !page_bounds.contains(rect)
{
return Err(PdfEditError::TextOverflow);
}
Ok(())
}
fn is_finite_rect(rect: PdfRect) -> bool {
rect.x.is_finite() && rect.y.is_finite() && rect.width.is_finite() && rect.height.is_finite()
}
fn is_finite_color(color: PdfColor) -> bool {
color.r.is_finite()
&& color.g.is_finite()
&& color.b.is_finite()
&& color.alpha.is_finite()
&& (0.0..=1.0).contains(&color.r)
&& (0.0..=1.0).contains(&color.g)
&& (0.0..=1.0).contains(&color.b)
&& (0.0..=1.0).contains(&color.alpha)
}
fn page_bounds(doc: &Document, page_id: ObjectId) -> Result<PdfRect, PdfEditError> {
let values = inherited_page_array(doc, page_id, b"CropBox")
.or_else(|| inherited_page_array(doc, page_id, b"MediaBox"))
.ok_or(PdfEditError::MalformedContent)?;
if values.len() != 4 {
return Err(PdfEditError::MalformedContent);
}
let x0 = values[0];
let y0 = values[1];
let width = values[2] - x0;
let height = values[3] - y0;
Ok(PdfRect {
x: x0,
y: y0,
width,
height,
})
}
fn page_rotation(doc: &Document, page_id: ObjectId) -> Result<i64, PdfEditError> {
Ok(inherited_page_number(doc, page_id, b"Rotate").unwrap_or(0))
}
fn inherited_page_array(doc: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<Vec<f64>> {
let mut seen = std::collections::HashSet::new();
for _ in 0..64 {
if !seen.insert(page_id) {
return None; }
let dict = doc.get_object(page_id).ok()?.as_dict().ok()?;
if let Ok(value) = dict.get(key) {
let value = match value {
Object::Reference(id) => doc.get_object(*id).ok()?,
value => value,
};
let array = value.as_array().ok()?;
return array.iter().map(object_number).collect();
}
page_id = match dict.get(b"Parent").ok()? {
Object::Reference(id) => *id,
_ => return None,
};
}
None
}
fn object_number(value: &Object) -> Option<f64> {
match value {
Object::Integer(value) => Some(*value as f64),
Object::Real(value) => Some(*value as f64),
_ => None,
}
}
fn inherited_page_number(doc: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<i64> {
let mut seen = std::collections::HashSet::new();
for _ in 0..64 {
if !seen.insert(page_id) {
return None; }
let dict = doc.get_object(page_id).ok()?.as_dict().ok()?;
if let Ok(value) = dict.get(key) {
let value = match value {
Object::Reference(id) => doc.get_object(*id).ok()?,
value => value,
};
return value.as_i64().ok();
}
page_id = match dict.get(b"Parent").ok()? {
Object::Reference(id) => *id,
_ => return None,
};
}
None
}
const MAX_STRAY_RESTORES: i64 = 1 << 16;
fn install_isolated_content(
doc: &mut Document,
mut blocks: Vec<(usize, Isolated)>,
) -> Result<std::collections::BTreeMap<usize, ObjectId>, PdfEditError> {
blocks.sort_by_key(|(slot, _)| *slot);
let mut by_page: std::collections::BTreeMap<ObjectId, Vec<(usize, Vec<Operation>)>> =
std::collections::BTreeMap::new();
for (slot, (page_id, operations)) in blocks {
by_page.entry(page_id).or_default().push((slot, operations));
}
let mut streams = std::collections::BTreeMap::new();
for (page_id, operations) in by_page {
streams.extend(isolate_page_content(doc, page_id, operations)?);
}
Ok(streams)
}
fn isolate_page_content(
doc: &mut Document,
page_id: ObjectId,
blocks: Vec<(usize, Vec<Operation>)>,
) -> Result<Vec<(usize, ObjectId)>, PdfEditError> {
let page_obj = doc
.get_object(page_id)
.map_err(|_| PdfEditError::MalformedContent)?;
let page_dict = page_obj
.as_dict()
.map_err(|_| PdfEditError::MalformedContent)?;
let ids: Vec<ObjectId> = match page_dict.get(b"Contents") {
Ok(contents) => content_stream_ids(doc, contents)?,
Err(_) => return Err(PdfEditError::MalformedContent),
};
let mut budget = ContentBudget::new();
let mut net: i64 = 0;
let mut lowest: i64 = 0;
for id in &ids {
for operation in budget.content_to_read(doc, *id)?.operations {
match operation.operator.as_str() {
"q" => net += 1,
"Q" => {
net -= 1;
lowest = lowest.min(net);
}
_ => {}
}
}
}
if -lowest > MAX_STRAY_RESTORES || net > MAX_STRAY_RESTORES {
return Err(PdfEditError::MalformedContent);
}
let opening = 1 - lowest; let closing = opening + net;
let repeated = |operator: &str, count: i64| -> Vec<Operation> {
(0..count)
.map(|_| Operation::new(operator, vec![]))
.collect()
};
let mut add_stream = |operations: Vec<Operation>| -> Result<ObjectId, PdfEditError> {
let mut content = Content::decode(&[]).map_err(|_| PdfEditError::MalformedContent)?;
content.operations = operations;
let encoded = content
.encode()
.map_err(|_| PdfEditError::MalformedContent)?;
Ok(doc.add_object(Stream::new(dictionary! {}, encoded)))
};
let prefix_id = add_stream(repeated("q", opening))?;
let suffix_id = add_stream(repeated("Q", closing))?;
let mut added = Vec::with_capacity(blocks.len());
let mut slots = Vec::with_capacity(blocks.len());
for (slot, operations) in blocks {
added.push(add_stream(operations)?);
slots.push(slot);
}
let installed: Vec<(usize, ObjectId)> = slots.into_iter().zip(added.iter().copied()).collect();
let mut all: Vec<ObjectId> = Vec::with_capacity(ids.len() + 2 + added.len());
all.push(prefix_id);
all.extend(ids);
all.push(suffix_id);
all.extend(added);
let array = Object::Array(all.into_iter().map(Object::Reference).collect());
if let Ok(Object::Dictionary(dict)) = doc.get_object_mut(page_id) {
dict.set("Contents", array);
}
Ok(installed)
}
fn embed_type0_font(
doc: &mut Document,
resolved: &ResolvedPdfFont,
text: &str,
) -> Result<(ObjectId, Vec<u16>), PdfEditError> {
let subset = subset_for_pdf(&resolved.bytes, resolved.face_index, text)?;
let face = ttf_parser::Face::parse(&resolved.bytes, resolved.face_index)
.map_err(|_| PdfEditError::UnsupportedEncoding)?;
let mut seen_old_gids: Vec<u16> = Vec::new();
let mut cid_codes: Vec<u16> = Vec::with_capacity(text.chars().count());
for c in text.chars() {
let old_gid = face.glyph_index(c).ok_or(PdfEditError::MissingGlyph)?.0;
let new_gid = match seen_old_gids.iter().position(|&g| g == old_gid) {
Some(pos) => (pos + 1) as u16,
None => {
seen_old_gids.push(old_gid);
seen_old_gids.len() as u16
}
};
cid_codes.push(new_gid);
}
let font_file_id = doc.add_object(Stream::new(
dictionary! { "Length1" => subset.font_program.len() as i64 },
subset.font_program.clone(),
));
let mut widths_sorted = subset.widths.clone();
widths_sorted.sort_by_key(|w| w.cid);
let mut w_array: Vec<Object> = Vec::with_capacity(widths_sorted.len() * 2);
for w in &widths_sorted {
w_array.push(Object::Integer(w.cid as i64));
w_array.push(Object::Array(vec![Object::Integer(
w.width_1000.round() as i64
)]));
}
let base_name: Vec<u8> = resolved.family.replace(' ', "").into_bytes();
let descriptor_id = doc.add_object(dictionary! {
"Type" => "FontDescriptor",
"FontName" => Object::Name(base_name.clone()),
"Flags" => 4,
"FontBBox" => Object::Array(vec![Object::Integer(0), Object::Integer(0), Object::Integer(1000), Object::Integer(1000)]),
"ItalicAngle" => 0,
"Ascent" => 900,
"Descent" => -200,
"CapHeight" => 700,
"StemV" => 80,
"FontFile2" => font_file_id,
});
let descendant_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "CIDFontType2",
"BaseFont" => Object::Name(base_name.clone()),
"CIDSystemInfo" => dictionary! {
"Registry" => Object::String(b"Adobe".to_vec(), StringFormat::Literal),
"Ordering" => Object::String(b"Identity".to_vec(), StringFormat::Literal),
"Supplement" => 0,
},
"FontDescriptor" => descriptor_id,
"DW" => 1000,
"W" => Object::Array(w_array),
"CIDToGIDMap" => "Identity",
});
let to_unicode_bytes = build_to_unicode_cmap(&subset.to_unicode).into_bytes();
let to_unicode_id = doc.add_object(Stream::new(dictionary! {}, to_unicode_bytes));
let type0_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type0",
"BaseFont" => Object::Name(base_name),
"Encoding" => "Identity-H",
"DescendantFonts" => Object::Array(vec![Object::Reference(descendant_id)]),
"ToUnicode" => to_unicode_id,
});
Ok((type0_id, cid_codes))
}
const MAX_RESOURCE_INHERIT_DEPTH: usize = 64;
fn effective_resources(doc: &Document, page_id: ObjectId) -> Result<Dictionary, PdfEditError> {
let mut seen = std::collections::HashSet::new();
let mut id = page_id;
for _ in 0..=MAX_RESOURCE_INHERIT_DEPTH {
if !seen.insert(id) {
return Err(PdfEditError::MalformedContent); }
let node = doc
.get_object(id)
.map_err(|_| PdfEditError::MalformedContent)?
.as_dict()
.map_err(|_| PdfEditError::MalformedContent)?;
if node.has(b"Resources") {
return Ok(dict_entry_clone(doc, node, b"Resources"));
}
match node.get(b"Parent") {
Ok(Object::Reference(parent)) => id = *parent,
_ => return Ok(dictionary! {}), }
}
Err(PdfEditError::MalformedContent) }
fn add_font_resource(
doc: &mut Document,
page_id: ObjectId,
font_obj_id: ObjectId,
) -> Result<Vec<u8>, PdfEditError> {
let existing_resources = effective_resources(doc, page_id)?;
let mut fonts_dict = dict_entry_clone(doc, &existing_resources, b"Font");
let mut counter = 1u32;
let mut name = format!("VREdit{counter}");
while fonts_dict.has(name.as_bytes()) {
counter += 1;
name = format!("VREdit{counter}");
}
fonts_dict.set(name.as_str(), Object::Reference(font_obj_id));
let mut new_resources = existing_resources;
new_resources.set("Font", Object::Dictionary(fonts_dict));
if let Ok(Object::Dictionary(page_dict_mut)) = doc.get_object_mut(page_id) {
page_dict_mut.set("Resources", Object::Dictionary(new_resources));
}
Ok(name.into_bytes())
}
fn dict_entry_clone(doc: &Document, dict: &Dictionary, key: &[u8]) -> Dictionary {
match dict.get(key) {
Ok(Object::Reference(id)) => doc
.get_object(*id)
.ok()
.and_then(|o| o.as_dict().ok())
.cloned()
.unwrap_or_else(|| dictionary! {}),
Ok(Object::Dictionary(d)) => d.clone(),
_ => dictionary! {},
}
}
fn build_text_object_operations(
resource_name: &[u8],
appearance: &PdfTextAppearance,
placement: Matrix,
cid_codes: &[u16],
) -> Vec<Operation> {
let mut hex_bytes = Vec::with_capacity(cid_codes.len() * 2);
for code in cid_codes {
hex_bytes.extend_from_slice(&code.to_be_bytes());
}
let mut operations = vec![Operation::new("q", vec![])];
operations.extend([
Operation::new("BT", vec![]),
Operation::new(
"Tf",
vec![
Object::Name(resource_name.to_vec()),
Object::Real(appearance.size_pt as f32),
],
),
Operation::new("Tr", vec![Object::Integer(0)]),
Operation::new("Tc", vec![Object::Integer(0)]),
Operation::new("Tw", vec![Object::Integer(0)]),
Operation::new("Tz", vec![Object::Integer(100)]),
Operation::new("Ts", vec![Object::Integer(0)]),
Operation::new(
"rg",
vec![
Object::Real(appearance.color.r as f32),
Object::Real(appearance.color.g as f32),
Object::Real(appearance.color.b as f32),
],
),
Operation::new(
"Tm",
[
placement.a,
placement.b,
placement.c,
placement.d,
placement.e,
placement.f,
]
.map(number_object)
.to_vec(),
),
Operation::new(
"Tj",
vec![Object::String(hex_bytes, StringFormat::Hexadecimal)],
),
Operation::new("ET", vec![]),
Operation::new("Q", vec![]),
]);
operations
}
fn build_ocr_correction_operations(
resource_name: &[u8],
appearance: &PdfTextAppearance,
rect: PdfRect,
cover_color: PdfColor,
cid_codes: &[u16],
) -> Vec<Operation> {
let mut operations = vec![Operation::new("q", vec![])];
operations.extend([
Operation::new(
"rg",
vec![
Object::Real(cover_color.r as f32),
Object::Real(cover_color.g as f32),
Object::Real(cover_color.b as f32),
],
),
Operation::new(
"re",
vec![
Object::Real(rect.x as f32),
Object::Real(rect.y as f32),
Object::Real(rect.width as f32),
Object::Real(rect.height as f32),
],
),
Operation::new("f", vec![]),
Operation::new("Q", vec![]),
]);
operations.extend(build_text_object_operations(
resource_name,
appearance,
Matrix::translation(rect.x, rect.y + rect.height * 0.18),
cid_codes,
));
operations
}
fn decode_stream_content(
doc: &Document,
id: ObjectId,
budget: &mut ContentBudget,
) -> Result<Content, PdfEditError> {
budget.content(doc, id, true)
}
fn write_stream_content(
doc: &mut Document,
id: ObjectId,
content: &Content,
) -> Result<(), PdfEditError> {
let encoded = content
.encode()
.map_err(|_| PdfEditError::MalformedContent)?;
doc.objects
.insert(id, Stream::new(dictionary! {}, encoded).into());
Ok(())
}
fn splice_operand_bytes(
content: &mut Content,
operator_index: u32,
operand_index: u32,
glyph_start: u32,
glyph_end: u32,
replacement: &[u8],
) -> Result<(), PdfEditError> {
let op = content
.operations
.get_mut(operator_index as usize)
.ok_or(PdfEditError::SourceDigestMismatch)?;
match op.operator.as_str() {
"Tj" | "'" => {
let operand = op
.operands
.get_mut(0)
.ok_or(PdfEditError::SourceDigestMismatch)?;
splice_string_object(operand, glyph_start, glyph_end, replacement)
}
"\"" => {
let operand = op
.operands
.get_mut(2)
.ok_or(PdfEditError::SourceDigestMismatch)?;
splice_string_object(operand, glyph_start, glyph_end, replacement)
}
"TJ" => {
let Some(Object::Array(items)) = op.operands.get_mut(0) else {
return Err(PdfEditError::SourceDigestMismatch);
};
let item = items
.get_mut(operand_index as usize)
.ok_or(PdfEditError::SourceDigestMismatch)?;
splice_string_object(item, glyph_start, glyph_end, replacement)
}
_ => Err(PdfEditError::SourceDigestMismatch),
}
}
fn splice_string_object(
obj: &mut Object,
start: u32,
end: u32,
replacement: &[u8],
) -> Result<(), PdfEditError> {
let Object::String(bytes, _fmt) = obj else {
return Err(PdfEditError::SourceDigestMismatch);
};
let (start, end) = (start as usize, end as usize);
if start > end || end > bytes.len() {
return Err(PdfEditError::SourceDigestMismatch);
}
let mut new_bytes = Vec::with_capacity(bytes.len() - (end - start) + replacement.len());
new_bytes.extend_from_slice(&bytes[..start]);
new_bytes.extend_from_slice(replacement);
new_bytes.extend_from_slice(&bytes[end..]);
*bytes = new_bytes;
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComposePdfPreviewRequest {
pub source_path: String,
pub expected_source_sha256: String,
pub source_commands: Vec<PdfEditCommand>,
}
#[cfg(test)]
mod tests {
use super::*;
use lopdf::{dictionary, Object as LObject};
use crate::inspect::simple_font_identity_bytes;
use crate::model::{GlyphId, PdfColor, PdfEditCommand, PdfOcrBackgroundProof, PdfPageGraph};
use crate::test_fixtures::{build_one_page_pdf as build_pdf, pdf_bytes as doc_bytes};
fn decode_stream_content(doc: &Document, id: ObjectId) -> Result<Content, PdfEditError> {
super::decode_stream_content(doc, id, &mut ContentBudget::new())
}
fn locate_island(
doc: &Document,
sha: &str,
page: u32,
text: &str,
) -> crate::model::PdfTextIsland {
let graph = walk_page_graph(doc, sha, page).expect("inspect fixture page");
graph
.islands
.into_iter()
.find(|isl| isl.text == text)
.unwrap_or_else(|| panic!("no island with text {text:?}"))
}
struct FailingFontResolver;
impl PdfFontResolver for FailingFontResolver {
fn resolve(&self, _id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
Err(PdfEditError::UnsupportedEncoding)
}
fn glyphs_for_text(
&self,
_font: &ResolvedPdfFont,
_text: &str,
) -> Result<Vec<GlyphId>, PdfEditError> {
Err(PdfEditError::UnsupportedEncoding)
}
}
fn installable_font() -> Vec<u8> {
crate::test_fixtures::build_test_ttf(
0,
&[('A', 1), ('B', 2), ('C', 3), ('H', 4), ('i', 5)],
600,
)
}
struct TestFontResolver {
id: &'static str,
bytes: Vec<u8>,
}
impl PdfFontResolver for TestFontResolver {
fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
if id != self.id {
return Err(PdfEditError::UnsupportedEncoding);
}
Ok(ResolvedPdfFont {
id: self.id.to_string(),
family: "Test Font".to_string(),
bytes: self.bytes.clone(),
face_index: 0,
embeddable: true,
subsettable: true,
})
}
fn glyphs_for_text(
&self,
font: &ResolvedPdfFont,
text: &str,
) -> Result<Vec<GlyphId>, PdfEditError> {
crate::font::glyph_ids_for_text(&font.bytes, font.face_index, text)
}
}
fn appearance(font_id: &str, size_pt: f64) -> PdfTextAppearance {
PdfTextAppearance {
font_id: font_id.to_string(),
family: font_id.to_string(),
size_pt,
color: PdfColor {
r: 0.0,
g: 0.0,
b: 0.0,
alpha: 1.0,
},
}
}
fn test_resolver() -> TestFontResolver {
TestFontResolver {
id: "test-font",
bytes: installable_font(),
}
}
fn replace_cmd(
id: &str,
island: &crate::model::PdfTextIsland,
after_text: &str,
after_appearance: PdfTextAppearance,
available_bounds: PdfRect,
) -> PdfEditCommand {
PdfEditCommand::ReplaceSourceText {
id: id.to_string(),
locator: island.locator.clone(),
before: PdfTextEditState {
text: island.text.clone(),
appearance: island.appearance.clone(),
},
after: PdfTextEditState {
text: after_text.to_string(),
appearance: after_appearance,
},
available_bounds,
}
}
fn reinspect(candidate: &ComposedCandidate, page: u32) -> (Document, PdfPageGraph) {
let out = Document::load_mem(&candidate.bytes).expect("reopen candidate");
let graph =
walk_page_graph(&out, &document_sha256(&candidate.bytes), page).expect("reinspect");
(out, graph)
}
#[test]
fn exact_operand_patch_replaces_single_tj_text_in_place() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "Hello");
let command = replace_cmd(
"edit-1",
&island,
"Howdy",
island.appearance.clone(),
PdfRect {
x: 72.0,
y: 695.0,
width: 400.0,
height: 20.0,
},
);
let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
.expect("exact patch composes");
assert_eq!(candidate.affected_pages, vec![1]);
let (_, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "Howdy"));
assert!(!graph.islands.iter().any(|isl| isl.text == "Hello"));
}
#[test]
fn corrected_source_text_survives_native_staging_verification() {
let source = include_bytes!("../tests/fixtures/source-correction-2page.pdf");
let doc = Document::load_mem(source).expect("fixture");
let sha = document_sha256(source);
let island = locate_island(&doc, &sha, 1, "ORIGINAL SOURCE TEXT");
let command = replace_cmd(
"correction",
&island,
"SOURCE TEST 930",
island.appearance.clone(),
PdfRect {
x: 72.0,
y: 717.0,
width: 146.028,
height: 12.0,
},
);
for _ in 0..32 {
assert_eq!(
simple_font_identity_bytes(
&doc,
*doc.get_pages().get(&1).expect("page"),
b"F1",
"SOURCE TEST 930"
),
Some(b"SOURCE TEST 930".to_vec())
);
let candidate =
compose_candidate(source, std::slice::from_ref(&command), &FailingFontResolver)
.expect("compose");
let (_, graph) = reinspect(&candidate, 1);
assert!(graph
.islands
.iter()
.any(|island| island.text.contains("SOURCE TEST 930")));
assert!(graph
.islands
.iter()
.any(|island| island.text == "SIBLING SOURCE TEXT"));
let store = crate::save::PdfSaveTokenStore::default();
crate::save::stage_prepared_save(
&store,
"/fixture.pdf",
&sha,
&sha,
candidate.bytes,
candidate.affected_pages,
crate::save::expected_text_checks(std::slice::from_ref(&command)),
)
.expect("verified stage");
}
}
#[test]
fn exact_operand_patch_rejects_overflow_before_mutating() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hi) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "Hi");
let command = replace_cmd(
"edit-overflow",
&island,
"This replacement text is far too long",
island.appearance.clone(),
PdfRect {
x: 72.0,
y: 695.0,
width: 10.0,
height: 10.0,
},
);
let err = compose_candidate(&source, &[command], &FailingFontResolver)
.expect_err("overflow must reject");
assert!(matches!(err, PdfEditError::TextOverflow));
}
#[test]
fn compose_candidate_on_base_preserves_annotations_while_editing_text() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "Hello");
let mut annotated = doc.clone();
let page_id = *annotated.get_pages().get(&1).expect("page 1 exists");
let annot_id = annotated.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "FreeText",
"Rect" => LObject::Array(vec![
LObject::Real(10.0),
LObject::Real(10.0),
LObject::Real(100.0),
LObject::Real(30.0),
]),
"Contents" => LObject::String(b"note".to_vec(), lopdf::StringFormat::Literal),
});
if let Ok(LObject::Dictionary(page_dict)) = annotated.get_object_mut(page_id) {
page_dict.set("Annots", LObject::Array(vec![LObject::Reference(annot_id)]));
}
let base = doc_bytes(&annotated);
let command = replace_cmd(
"edit-1",
&island,
"Howdy",
island.appearance.clone(),
PdfRect {
x: 72.0,
y: 695.0,
width: 400.0,
height: 20.0,
},
);
let candidate = compose_candidate_on_base(&sha, &base, &[command], &FailingFontResolver)
.expect("compose on annotated base");
let (out, graph) = reinspect(&candidate, 1);
let out_page_id = *out.get_pages().get(&1).expect("page 1 exists in candidate");
let out_page_dict = out
.get_object(out_page_id)
.expect("page object")
.as_dict()
.expect("page dict");
assert!(
out_page_dict.has(b"Annots"),
"FreeText annotation must survive text composition"
);
assert!(graph.islands.iter().any(|isl| isl.text == "Howdy"));
assert!(!graph.islands.iter().any(|isl| isl.text == "Hello"));
}
#[test]
fn compose_candidate_on_base_with_no_commands_returns_base_verbatim() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
let base = doc_bytes(&doc);
let sha = document_sha256(&base);
let candidate = compose_candidate_on_base(&sha, &base, &[], &FailingFontResolver)
.expect("empty commands compose trivially");
assert_eq!(candidate.bytes, base);
assert!(candidate.affected_pages.is_empty());
}
#[test]
fn stale_document_sha256_is_rejected() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let mut island = locate_island(&doc, &sha, 1, "Hello");
island.locator.document_sha256 =
"0000000000000000000000000000000000000000000000000000000000000000".to_string();
let command = replace_cmd(
"edit-stale",
&island,
"Howdy",
island.appearance.clone(),
PdfRect {
x: 72.0,
y: 695.0,
width: 400.0,
height: 20.0,
},
);
let err = compose_candidate(&source, &[command], &FailingFontResolver)
.expect_err("stale source must reject");
assert!(matches!(err, PdfEditError::StaleSource));
}
#[test]
fn tampered_island_digest_with_valid_document_sha_is_source_digest_mismatch() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let mut island = locate_island(&doc, &sha, 1, "Hello");
island.locator.island_digest = format!("{}-tampered", island.locator.island_digest);
let command = replace_cmd(
"edit-tampered",
&island,
"Howdy",
island.appearance.clone(),
PdfRect {
x: 72.0,
y: 695.0,
width: 400.0,
height: 20.0,
},
);
let err = compose_candidate(&source, &[command], &FailingFontResolver)
.expect_err("tampered digest must reject");
assert!(matches!(err, PdfEditError::SourceDigestMismatch));
}
#[test]
fn local_rewrite_replaces_adjacent_tj_operators_via_embedded_font_round_trip() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (CAR YOU) Tj 55 0 Td ( LIST.) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "CAR YOU LIST.");
assert!(island.locator.spans.len() > 1);
let resolver = test_resolver();
let command = replace_cmd(
"edit-split",
&island,
"Hi",
appearance("test-font", 20.0),
PdfRect {
x: 72.0,
y: 690.0,
width: 400.0,
height: 40.0,
},
);
let candidate =
compose_candidate(&source, &[command], &resolver).expect("local rewrite composes");
let (_, graph) = reinspect(&candidate, 1);
assert!(!graph.islands.iter().any(|isl| isl.text == "CAR YOU LIST."));
assert!(graph
.islands
.iter()
.any(|isl| isl.text == "Hi" && isl.editable));
}
#[test]
fn local_rewrite_used_when_appearance_changes_even_for_single_span() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "Hello");
assert_eq!(island.locator.spans.len(), 1);
let resolver = test_resolver();
let command = replace_cmd(
"edit-appearance",
&island,
"Hi",
appearance("test-font", 24.0),
PdfRect {
x: 72.0,
y: 690.0,
width: 400.0,
height: 40.0,
},
);
let candidate =
compose_candidate(&source, &[command], &resolver).expect("compose succeeds");
let (_, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
}
#[test]
fn add_text_appends_isolated_stream_without_touching_existing_content() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
let source = doc_bytes(&doc);
let resolver = test_resolver();
let command = PdfEditCommand::AddText {
id: "add-1".to_string(),
page: 1,
anchor: PdfPoint { x: 100.0, y: 100.0 },
text: "Hi".to_string(),
appearance: appearance("test-font", 18.0),
};
let candidate =
compose_candidate(&source, &[command], &resolver).expect("add text composes");
assert_eq!(candidate.affected_pages, vec![1]);
let (_, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
}
#[test]
fn add_text_missing_glyph_rejects_before_any_mutation() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
let source = doc_bytes(&doc);
let resolver = test_resolver();
let command = PdfEditCommand::AddText {
id: "add-missing".to_string(),
page: 1,
anchor: PdfPoint { x: 100.0, y: 100.0 },
text: "Zzz".to_string(),
appearance: appearance("test-font", 18.0),
};
let err = compose_candidate(&source, &[command], &resolver)
.expect_err("missing glyph must reject");
assert!(matches!(err, PdfEditError::MissingGlyph));
}
#[test]
fn add_text_unknown_font_id_rejects_with_unsupported_encoding() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
let source = doc_bytes(&doc);
let command = PdfEditCommand::AddText {
id: "add-unknown-font".to_string(),
page: 1,
anchor: PdfPoint { x: 100.0, y: 100.0 },
text: "Hi".to_string(),
appearance: appearance("does-not-exist", 18.0),
};
let err = compose_candidate(&source, &[command], &FailingFontResolver)
.expect_err("unknown font id must reject");
assert!(matches!(err, PdfEditError::UnsupportedEncoding));
}
fn ocr_command(id: &str, line_id: &str, text: &str, rect: PdfRect) -> PdfEditCommand {
PdfEditCommand::CorrectOcrLine {
id: id.to_string(),
page: 1,
line_id: line_id.to_string(),
rect,
text: text.to_string(),
appearance: appearance("test-font", 18.0),
cover_color: PdfColor {
r: 1.0,
g: 1.0,
b: 1.0,
alpha: 1.0,
},
background_proof: PdfOcrBackgroundProof::NearUniform,
}
}
#[test]
fn correct_ocr_text_appends_cover_and_type0_text_stream() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
let source = doc_bytes(&doc);
let resolver = test_resolver();
let command = ocr_command(
"ocr-1",
"line-1",
"Hi",
PdfRect {
x: 100.0,
y: 100.0,
width: 100.0,
height: 30.0,
},
);
let candidate = compose_candidate(&source, &[command], &resolver).expect("compose OCR");
let (out, graph) = reinspect(&candidate, 1);
let page_id = out.get_pages()[&1];
let page = out
.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict");
let contents = page
.get(b"Contents")
.expect("contents")
.as_array()
.expect("array");
let stream_id = contents
.last()
.expect("new stream")
.as_reference()
.expect("ref");
let stream = out
.get_object(stream_id)
.expect("stream")
.as_stream()
.expect("stream");
let content = Content::decode(&stream.decompressed_content().expect("decode stream"))
.expect("content operations");
let operators: Vec<&str> = content
.operations
.iter()
.map(|operation| operation.operator.as_str())
.collect();
assert_eq!(&operators[..5], &["q", "rg", "re", "f", "Q"]);
assert!(operators.windows(2).any(|window| window == ["BT", "Tf"]));
assert!(graph.islands.iter().any(|island| island.text == "Hi"));
}
#[test]
fn correct_ocr_text_duplicate_line_id_last_wins() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
let source = doc_bytes(&doc);
let resolver = test_resolver();
let rect = PdfRect {
x: 100.0,
y: 100.0,
width: 100.0,
height: 30.0,
};
let commands = vec![
ocr_command("ocr-old", "line-1", "Hi", rect),
ocr_command("ocr-new", "line-1", "Hi", rect),
];
let candidate = compose_candidate(&source, &commands, &resolver).expect("compose OCR");
let (_, graph) = reinspect(&candidate, 1);
assert_eq!(
graph
.islands
.iter()
.filter(|island| island.text == "Hi")
.count(),
1
);
}
#[test]
fn correct_ocr_text_rejects_rotation_and_out_of_bounds_rect() {
let mut rotated = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
let page_id = rotated.get_pages()[&1];
rotated
.get_object_mut(page_id)
.expect("page")
.as_dict_mut()
.expect("dict")
.set("Rotate", 90);
let rotated_source = doc_bytes(&rotated);
let resolver = test_resolver();
let rect = PdfRect {
x: 100.0,
y: 100.0,
width: 100.0,
height: 30.0,
};
assert!(matches!(
compose_candidate(
&rotated_source,
&[ocr_command("r", "line", "Hi", rect)],
&resolver
),
Err(PdfEditError::UnsupportedTransform)
));
let source = doc_bytes(&build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET"));
let bad_rect = PdfRect {
x: 600.0,
y: 100.0,
width: 100.0,
height: 30.0,
};
assert!(matches!(
compose_candidate(
&source,
&[ocr_command("b", "line", "Hi", bad_rect)],
&resolver
),
Err(PdfEditError::TextOverflow)
));
}
#[test]
fn empty_command_list_returns_source_bytes_unchanged() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
let source = doc_bytes(&doc);
let candidate = compose_candidate(&source, &[], &FailingFontResolver)
.expect("empty batch always succeeds");
assert_eq!(candidate.bytes, source);
assert!(candidate.affected_pages.is_empty());
}
use crate::test_fixtures::{
custom_page_pdf, differences_encoding, helvetica_font, identity_h_font, subset_type1_font,
};
fn island_box(island: &crate::model::PdfTextIsland) -> PdfRect {
let mut min_x = f64::MAX;
let mut min_y = f64::MAX;
let mut max_x = f64::MIN;
let mut max_y = f64::MIN;
for quad in &island.quads {
for corner in 0..4 {
min_x = min_x.min(quad[corner * 2]);
max_x = max_x.max(quad[corner * 2]);
min_y = min_y.min(quad[corner * 2 + 1]);
max_y = max_y.max(quad[corner * 2 + 1]);
}
}
PdfRect {
x: min_x,
y: min_y,
width: max_x - min_x,
height: max_y - min_y,
}
}
const PAGE_WIDE: PdfRect = PdfRect {
x: 0.0,
y: 0.0,
width: 612.0,
height: 792.0,
};
#[test]
fn same_glyph_swap_fits_the_islands_own_box() {
let source = include_bytes!("../tests/fixtures/proof-text-50page.pdf");
let doc = Document::load_mem(source).expect("fixture");
let sha = document_sha256(source);
let island = locate_island(&doc, &sha, 1, "Page 1: external link below");
assert!(island.editable, "{:?}", island.edit_blocker);
let own = island_box(&island);
assert!((own.width - 286.8).abs() < 0.01, "{}", own.width);
for (label, bounds) in [("own box", own), ("page wide", PAGE_WIDE)] {
let command = replace_cmd(
"swap",
&island,
"Page 1: xternale link below",
island.appearance.clone(),
bounds,
);
let candidate = compose_candidate(source, &[command], &FailingFontResolver)
.unwrap_or_else(|error| panic!("{label}: {error}"));
let (_, graph) = reinspect(&candidate, 1);
assert!(
graph
.islands
.iter()
.any(|isl| isl.text == "Page 1: xternale link below"),
"{label}"
);
}
let narrow = PdfRect {
width: own.width - 1.0,
..own
};
let command = replace_cmd(
"swap",
&island,
"Page 1: xternale link below",
island.appearance.clone(),
narrow,
);
assert!(matches!(
compose_candidate(source, &[command], &FailingFontResolver),
Err(PdfEditError::TextOverflow)
));
}
#[test]
fn narrower_replacement_never_overflows_its_own_box_under_character_and_word_spacing() {
let doc = build_pdf(b"BT /F1 12 Tf 1 Tc 3 Tw 72 700 Td (a b c) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "a b c");
let own = island_box(&island);
let command = replace_cmd("order", &island, "c b a", island.appearance.clone(), own);
compose_candidate(&source, &[command], &FailingFontResolver)
.expect("same advance fits its own box");
}
fn subset_with_differences(first_widths: &[i64]) -> Document {
let encoding = differences_encoding(
None,
true,
vec![
LObject::Integer(65),
LObject::Name(b"A".to_vec()),
LObject::Name(b"B".to_vec()),
],
);
custom_page_pdf(
|d| {
vec![(
"F1",
subset_type1_font(d, 65, first_widths, Some(encoding), None),
)]
},
&[b"BT /F1 12 Tf 72 700 Td (AB) Tj ET"],
false,
)
}
#[test]
fn missing_glyph_error_names_every_character_the_subset_lacks() {
let doc = subset_with_differences(&[500, 600, 700]);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "AB");
assert!(island.editable);
let command = replace_cmd(
"miss",
&island,
"ABC!C",
island.appearance.clone(),
PAGE_WIDE,
);
let error = compose_candidate(&source, &[command], &FailingFontResolver)
.expect_err("missing glyphs are refused");
assert_eq!(error.missing_chars(), Some(&['C', '!'][..]));
assert_eq!(error.code(), "missing_glyph");
assert!(error.to_string().contains("'C', '!'"), "{error}");
}
#[test]
fn graph_editable_and_compose_agree_for_every_island() {
let doc = custom_page_pdf(
|d| {
vec![
("F1", helvetica_font(d)),
("F2", subset_type1_font(d, 65, &[500, 600], None, None)),
]
},
&[b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET BT /F2 12 Tf 72 600 Td (AB) Tj ET"],
false,
);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let graph = walk_page_graph(&doc, &sha, 1).expect("graph");
assert_eq!(graph.islands.len(), 2);
for island in &graph.islands {
let command = replace_cmd(
"same",
island,
&island.text,
island.appearance.clone(),
island_box(island),
);
let outcome = compose_candidate(&source, &[command], &FailingFontResolver);
if island.editable {
assert!(outcome.is_ok(), "{}: {:?}", island.text, outcome.err());
} else {
assert!(
matches!(outcome, Err(PdfEditError::UnsupportedEncoding)),
"{}",
island.text
);
}
}
assert_eq!(
graph.islands.iter().filter(|i| i.editable).count(),
1,
"exactly the Helvetica island is editable"
);
}
#[test]
fn kerned_tj_replacement_writes_one_run_into_the_first_span() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td [(Hel) -20 (lo)] TJ ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "Hello");
assert_eq!(island.locator.spans.len(), 2);
let command = replace_cmd(
"kern",
&island,
"Help!",
island.appearance.clone(),
PAGE_WIDE,
);
let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
.expect("multi-operand island composes in place");
let (out, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "Help!"));
assert!(!graph.islands.iter().any(|isl| isl.text.contains("lo")));
let page_id = out.get_pages()[&1];
let stream_id = content_stream_ids(
&out,
out.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Contents")
.expect("contents"),
)
.expect("ids")[0];
let content = decode_stream_content(&out, stream_id).expect("content");
let tj = content
.operations
.iter()
.find(|op| op.operator == "TJ")
.expect("TJ kept");
let Some(Object::Array(items)) = tj.operands.first() else {
panic!("TJ array");
};
assert!(matches!(&items[0], Object::String(b, _) if b == b"Hel"));
assert!(matches!(&items[1], Object::Integer(-20)));
assert!(matches!(&items[2], Object::String(b, _) if b == b"p!"));
}
#[test]
fn adjacent_tj_operators_replace_in_place_when_the_font_can_encode() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj (World) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "HelloWorld");
assert_eq!(island.locator.spans.len(), 2);
let command = replace_cmd(
"two-tj",
&island,
"Hi there",
island.appearance.clone(),
PAGE_WIDE,
);
let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
.expect("two Tj operators compose in place");
let (_, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "Hi there"));
assert!(!graph.islands.iter().any(|isl| isl.text.contains("World")));
}
#[test]
fn identity_h_island_is_rewritten_with_two_byte_codes_and_refuses_unknown_characters() {
let doc = custom_page_pdf(
|d| {
vec![(
"F1",
identity_h_font(
d,
&[(1, 600), (2, 300), (3, 500)],
&[(1, 'H'), (2, 'i'), (3, '!')],
),
)]
},
&[b"BT /F1 12 Tf 72 700 Td <00010002> Tj ET"],
false,
);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "Hi");
assert!(island.editable);
let command = replace_cmd("cid", &island, "i!H", island.appearance.clone(), PAGE_WIDE);
let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
.expect("CID island composes in place");
let (_, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "i!H"));
let command = replace_cmd(
"cid-miss",
&island,
"Hx",
island.appearance.clone(),
PAGE_WIDE,
);
let error = compose_candidate(&source, &[command], &FailingFontResolver)
.expect_err("unknown character refused");
assert_eq!(error.missing_chars(), Some(&['x'][..]));
}
#[test]
fn add_text_on_an_indirect_array_contents_page_keeps_the_stream_list_flat() {
let doc = custom_page_pdf(
|d| vec![("F1", helvetica_font(d))],
&[b"BT /F1 12 Tf 72 700 Td ", b"(Existing) Tj ET"],
true,
);
let source = doc_bytes(&doc);
let command = PdfEditCommand::AddText {
id: "add".to_string(),
page: 1,
anchor: PdfPoint { x: 100.0, y: 100.0 },
text: "Hi".to_string(),
appearance: appearance("test-font", 18.0),
};
let candidate =
compose_candidate(&source, &[command], &test_resolver()).expect("add text composes");
let (out, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
let page_id = out.get_pages()[&1];
let contents = out
.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Contents")
.expect("contents")
.as_array()
.expect("a direct array now");
assert_eq!(contents.len(), 5);
for item in contents {
let id = item.as_reference().expect("reference");
out.get_object(id)
.expect("object")
.as_stream()
.expect("every entry is a stream, never the old array object");
}
}
#[test]
fn unparseable_stream_tail_is_refused_rather_than_truncated_on_write() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET (unterminated");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "Hello");
let command = replace_cmd(
"tail",
&island,
"Howdy",
island.appearance.clone(),
PAGE_WIDE,
);
assert!(matches!(
compose_candidate(&source, &[command], &FailingFontResolver),
Err(PdfEditError::MalformedContent)
));
}
use crate::test_fixtures::tex_style_pdf;
const KERNED: &[u8] = b"BT /F1 10 Tf 72 700 Td [(hello) -333 (big) -333 (world)] TJ ET";
fn lefts(island: &crate::model::PdfTextIsland) -> Vec<f64> {
island.quads.iter().map(|q| q[0]).collect()
}
fn tj_shape(candidate: &ComposedCandidate) -> Vec<String> {
let (out, _) = reinspect(candidate, 1);
let page_id = out.get_pages()[&1];
let ids = content_stream_ids(
&out,
out.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Contents")
.expect("contents"),
)
.expect("ids");
let content = decode_stream_content(&out, ids[0]).expect("content");
let tj = content
.operations
.iter()
.find(|op| op.operator == "TJ")
.expect("TJ");
let Some(Object::Array(items)) = tj.operands.first() else {
panic!("TJ array");
};
items
.iter()
.map(|item| match item {
Object::String(bytes, _) => String::from_utf8_lossy(bytes).into_owned(),
Object::Integer(n) => format!("<{n}>"),
Object::Real(n) => format!("<{n}>"),
other => panic!("{other:?}"),
})
.collect()
}
fn edit_text(
doc: &Document,
island: &crate::model::PdfTextIsland,
after: &str,
) -> Result<ComposedCandidate, PdfEditError> {
let command = replace_cmd(
"gap",
island,
after,
island.appearance.clone(),
island_box_wide(island),
);
compose_candidate(&doc_bytes(doc), &[command], &FailingFontResolver)
}
fn island_box_wide(island: &crate::model::PdfTextIsland) -> PdfRect {
let own = island_box(island);
PdfRect {
width: PAGE_WIDE.width - own.x,
..own
}
}
#[test]
fn tj_word_gaps_become_spaces_in_island_text_with_recorded_widths() {
let doc = tex_style_pdf(false, KERNED);
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "hello big world");
assert!(island.editable, "{:?}", island.edit_blocker);
assert_eq!(island.quads.len(), 13, "gaps are not glyphs");
let gaps: Vec<(usize, f64)> = island
.gaps
.iter()
.map(|g| (g.char_index, g.width_pt))
.collect();
assert_eq!(gaps.len(), 2);
assert_eq!((gaps[0].0, gaps[1].0), (5, 9));
assert!(
(gaps[0].1 - 3.33).abs() < 0.01 && (gaps[1].1 - 3.33).abs() < 0.01,
"{gaps:?}"
);
let json = serde_json::to_value(&island).expect("serializes");
assert_eq!(json["gaps"][0]["charIndex"], 5);
}
#[test]
fn small_kerns_letter_spacing_and_real_spaces_do_not_become_gaps() {
let doc = build_pdf(b"BT /F1 12 Tf 3 Tc 72 700 Td [(h) (e) (l) (l) (o)] TJ ET");
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "hello");
assert!(island.gaps.is_empty());
let doc = tex_style_pdf(true, b"BT /F1 10 Tf 72 700 Td [(hello ) -333 (big)] TJ ET");
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "hello big");
assert!(
island.gaps.is_empty(),
"a real space already marks the word break"
);
}
#[test]
fn word_rotation_keeps_every_gap_and_every_glyph_position() {
for space_glyph in [false, true] {
let doc = tex_style_pdf(space_glyph, KERNED);
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "hello big world");
let candidate = edit_text(&doc, &island, "hello big orldw").expect("composes");
let (_, graph) = reinspect(&candidate, 1);
let after = graph
.islands
.iter()
.find(|i| i.text == "hello big orldw")
.expect("island text equals the request, spaces included");
assert_eq!(after.gaps.len(), 2);
let (before_x, after_x) = (lefts(&island), lefts(after));
assert_eq!(before_x.len(), after_x.len());
for (b, a) in before_x.iter().zip(&after_x) {
assert!((b - a).abs() < 0.01, "glyph moved: {b} -> {a}");
}
assert_eq!(
tj_shape(&candidate),
["hello", "<-333>", "big", "<-333>", "orldw"]
);
}
}
#[test]
fn inserting_a_word_regenerates_a_gap_of_the_original_width() {
let doc = tex_style_pdf(false, KERNED);
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "hello big world");
let candidate = edit_text(&doc, &island, "hello a big world").expect("composes");
assert_eq!(
tj_shape(&candidate),
["hello", "<-333>", "a", "<-333>", "big", "<-333>", "world"]
);
let (_, graph) = reinspect(&candidate, 1);
let after = graph
.islands
.iter()
.find(|i| i.text == "hello a big world")
.expect("exact text");
let (b, a) = (lefts(&island), lefts(after));
for i in 0..5 {
assert!((b[i] - a[i]).abs() < 0.01, "hello moved");
}
let shift = 500.0 / 1000.0 * 10.0 + 3.33;
for i in 5..13 {
assert!((a[i + 1] - b[i] - shift).abs() < 0.01, "glyph {i}");
}
}
#[test]
fn removing_a_word_drops_its_gap_instead_of_leaving_two() {
let doc = tex_style_pdf(false, KERNED);
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "hello big world");
let candidate = edit_text(&doc, &island, "hello world").expect("composes");
assert_eq!(
tj_shape(&candidate),
["hello", "<-333>", "", "<0>", "world"]
);
let (_, graph) = reinspect(&candidate, 1);
let after = graph
.islands
.iter()
.find(|i| i.text == "hello world")
.expect("exact text");
let (b, a) = (lefts(&island), lefts(after));
let shift = 3.0 * 5.0 + 3.33;
for i in 0..5 {
assert!((b[i] - a[i]).abs() < 0.01, "hello moved");
}
for i in 8..13 {
assert!((b[i] - a[i - 3] - shift).abs() < 0.01, "world glyph {i}");
}
}
#[test]
fn a_real_space_glyph_is_used_when_the_font_has_one() {
let doc = tex_style_pdf(true, b"BT /F1 10 Tf 72 700 Td [(hello) -333 (big)] TJ ET");
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "hello big");
let candidate = edit_text(&doc, &island, "hello a big").expect("composes");
assert_eq!(tj_shape(&candidate), ["hello", "<-333>", "a big"]);
let (_, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|i| i.text == "hello a big"));
}
#[test]
fn a_new_space_with_no_glyph_and_no_reference_gap_is_refused_not_dropped() {
let doc = tex_style_pdf(false, b"BT /F1 10 Tf 72 700 Td (hello) Tj ET");
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "hello");
let error = edit_text(&doc, &island, "hello a").expect_err("refused");
assert_eq!(error.missing_chars(), Some(&[' '][..]));
}
#[test]
fn a_gap_made_by_a_position_move_is_a_positioned_run_and_not_editable() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj 35 0 Td (World) Tj ET");
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "Hello World");
assert_eq!(island.gaps.len(), 1);
assert!(!island.editable);
assert_eq!(
island.edit_blocker,
Some(crate::model::PdfEditBlocker::PositionedTextRun)
);
let error = edit_text(&doc, &island, "Hallo World").expect_err("refused");
assert!(
matches!(error, PdfEditError::UnsupportedEncoding),
"{error}"
);
}
#[test]
fn a_positioning_operator_between_two_show_operators_makes_the_run_positioned() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hel) Tj 18 0 Td (lo) Tj ET");
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "Hello");
assert!(!island.editable);
assert_eq!(
island.edit_blocker,
Some(crate::model::PdfEditBlocker::PositionedTextRun)
);
}
#[test]
fn same_length_edit_overwrites_glyphs_in_place_and_keeps_inner_kerns() {
let doc = tex_style_pdf(
false,
b"BT /F1 10 Tf 72 700 Td [(h) 40 (e) 40 (llo) -333 (big)] TJ ET",
);
let sha = document_sha256(&doc_bytes(&doc));
let island = locate_island(&doc, &sha, 1, "hello big");
let candidate = edit_text(&doc, &island, "elloh big").expect("composes");
assert_eq!(
tj_shape(&candidate),
["e", "<40>", "l", "<40>", "loh", "<-333>", "big"]
);
let (_, graph) = reinspect(&candidate, 1);
let after = graph
.islands
.iter()
.find(|i| i.text == "elloh big")
.expect("gap survives: exact text");
for (b, a) in lefts(&island).iter().zip(lefts(after)) {
assert!((b - a).abs() < 0.01);
}
}
fn rect(width: f64) -> PdfRect {
PdfRect {
x: 72.0,
y: 0.0,
width,
height: 800.0,
}
}
fn island_after(
candidate: &ComposedCandidate,
text: &str,
) -> (crate::model::PdfTextIsland, f64) {
let (_, graph) = reinspect(candidate, 1);
let island = graph
.islands
.into_iter()
.find(|i| i.text == text)
.unwrap_or_else(|| panic!("no island {text:?} in the candidate"));
let width = island_box(&island).width;
(island, width)
}
#[test]
fn finding2_array_cmap_edit_writes_the_codes_the_reader_sees() {
let doc = custom_page_pdf(
|d| {
vec![(
"F1",
crate::test_fixtures::identity_h_font_with_cmap(
d,
&[(1, 500), (2, 500), (3, 500)],
crate::test_fixtures::raw_to_unicode_cmap(
"1 beginbfrange\n<0001> <0003> [<0041> <005A> <0042>]\nendbfrange",
),
),
)]
},
&[b"BT /F1 12 Tf 72 700 Td <000100020003> Tj ET"],
false,
);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "AZB");
let command = replace_cmd("c", &island, "ACB", island.appearance.clone(), PAGE_WIDE);
let error = compose_candidate(&source, &[command], &FailingFontResolver)
.expect_err("C has no code in this font");
assert_eq!(error.missing_chars(), Some(&['C'][..]));
let command = replace_cmd("c", &island, "BZA", island.appearance.clone(), PAGE_WIDE);
let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
.expect("letters the font has");
let (_, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|i| i.text == "BZA"));
}
#[test]
fn finding4_flattened_edit_leaves_no_internal_kerns_and_fits_what_it_wrote() {
let doc = tex_style_pdf(
false,
b"BT /F1 10 Tf 72 700 Td [(h) -100 (e) -100 (llo) -333 (big)] TJ ET",
);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "hello big");
assert!((island_box(&island).width - 45.33).abs() < 0.01);
let command = replace_cmd(
"flat",
&island,
"aa big",
island.appearance.clone(),
rect(28.33),
);
let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
.expect("fits the width it predicted");
let (written, width) = island_after(&candidate, "aa big");
assert!(
(width - 28.33).abs() < 0.01,
"predicted 28.33 pt, page draws {width} pt"
);
assert_eq!(written.gaps.len(), 1);
let shape = tj_shape(&candidate);
assert!(shape.contains(&"<-333>".to_string()), "{shape:?}");
assert!(!shape.contains(&"<-100>".to_string()), "{shape:?}");
}
#[test]
fn finding4_flatten_across_two_operators_zeroes_the_kerns_between_them() {
let doc = tex_style_pdf(
false,
b"BT /F1 10 Tf 72 700 Td [(he) -100 (l)] TJ [(lo) -333 (big)] TJ ET",
);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "hello big");
let command = replace_cmd(
"flat2",
&island,
"aa big",
island.appearance.clone(),
rect(28.33),
);
let candidate =
compose_candidate(&source, &[command], &FailingFontResolver).expect("composes");
let (_, width) = island_after(&candidate, "aa big");
assert!((width - 28.33).abs() < 0.01, "{width}");
}
#[test]
fn finding5_same_length_edit_that_adds_a_space_accounts_for_word_spacing() {
let doc = tex_style_pdf(true, b"BT /F1 10 Tf 10 Tw 72 700 Td (axb) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "axb");
let own = island_box(&island);
assert!((own.width - 15.0).abs() < 0.01);
let command = replace_cmd("tw", &island, "a b", island.appearance.clone(), own);
assert!(matches!(
compose_candidate(&source, &[command], &FailingFontResolver),
Err(PdfEditError::TextOverflow)
));
let command = replace_cmd("tw", &island, "a b", island.appearance.clone(), rect(22.5));
let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
.expect("fits when given the width it needs");
let (_, width) = island_after(&candidate, "a b");
assert!((width - 22.5).abs() < 0.01, "{width}");
}
#[test]
fn finding5_removing_a_space_shrinks_by_word_spacing_too() {
let doc = tex_style_pdf(true, b"BT /F1 10 Tf 10 Tw 72 700 Td (a b) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "a b");
assert!((island_box(&island).width - 22.5).abs() < 0.01);
let command = replace_cmd("tw", &island, "axb", island.appearance.clone(), rect(15.0));
let candidate =
compose_candidate(&source, &[command], &FailingFontResolver).expect("narrower fits");
let (_, width) = island_after(&candidate, "axb");
assert!((width - 15.0).abs() < 0.01, "{width}");
}
#[test]
fn finding6_editing_one_page_leaves_a_page_that_shares_its_stream_alone() {
let mut doc = Document::with_version("1.7");
let pages_id = doc.new_object_id();
let font = helvetica_font(&mut doc);
let resources = doc.add_object(dictionary! { "Font" => dictionary! { "F1" => font } });
let shared = doc.add_object(Stream::new(
dictionary! {},
b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET".to_vec(),
));
let mut page_ids = Vec::new();
for _ in 0..2 {
page_ids.push(doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"Contents" => shared,
"Resources" => resources,
"MediaBox" => Object::Array(vec![
Object::Integer(0),
Object::Integer(0),
Object::Integer(612),
Object::Integer(792),
]),
}));
}
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => page_ids.iter().map(|id| Object::Reference(*id)).collect::<Vec<_>>(),
"Count" => 2,
}),
);
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "Hello");
let command = replace_cmd("p1", &island, "Howdy", island.appearance.clone(), PAGE_WIDE);
let candidate =
compose_candidate(&source, &[command], &FailingFontResolver).expect("composes");
assert_eq!(candidate.affected_pages, vec![1]);
let (_, page1) = reinspect(&candidate, 1);
let (_, page2) = reinspect(&candidate, 2);
assert!(page1.islands.iter().any(|i| i.text == "Howdy"));
assert!(!page1.islands.iter().any(|i| i.text == "Hello"));
assert!(
page2.islands.iter().any(|i| i.text == "Hello"),
"page 2 must still read Hello: {:?}",
page2.islands.iter().map(|i| &i.text).collect::<Vec<_>>()
);
assert!(!page2.islands.iter().any(|i| i.text == "Howdy"));
let island2 = {
let graph = walk_page_graph(&doc, &sha, 2).expect("page 2");
graph.islands.into_iter().next().expect("island")
};
let c1 = replace_cmd("p1", &island, "Howdy", island.appearance.clone(), PAGE_WIDE);
let c2 = replace_cmd("p2", &island2, "Hi", island2.appearance.clone(), PAGE_WIDE);
let outcome = compose_candidate(&source, &[c1, c2], &FailingFontResolver);
assert!(
matches!(outcome, Err(PdfEditError::SourceDigestMismatch)),
"{:?}",
outcome.map(|c| c.affected_pages)
);
}
#[test]
fn finding7_non_editable_island_with_unchanged_appearance_is_refused_by_the_composer() {
let doc = build_pdf(b"BT /F1 10 Tf 72 700 Td (A) Tj 6.67 0 Td (B) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "AB");
assert!(!island.editable);
assert_eq!(
island.edit_blocker,
Some(crate::model::PdfEditBlocker::PositionedTextRun)
);
let resolver = TestFontResolver {
id: "F1",
bytes: installable_font(),
};
assert!(resolver.resolve("F1").is_ok());
assert!(resolver
.glyphs_for_text(&resolver.resolve("F1").unwrap(), "AC")
.is_ok());
let command = replace_cmd("ac", &island, "AC", island.appearance.clone(), PAGE_WIDE);
let outcome = compose_candidate(&source, &[command], &resolver);
assert!(
matches!(outcome, Err(PdfEditError::UnsupportedEncoding)),
"{:?}",
outcome.map(|c| c.bytes.len())
);
}
#[test]
fn finding7_every_blocker_is_refused_before_strategy_selection() {
let doc = custom_page_pdf(
|d| {
vec![
("F1", helvetica_font(d)),
("F2", subset_type1_font(d, 65, &[500, 600], None, None)),
]
},
&[
b"BT /F2 12 Tf 72 600 Td (AB) Tj ET BT /F1 12 Tf 72 500 Td (Hel) Tj ET \
BT /F1 12 Tf 72 400 Td (Big) Tj /F1 20 Tf (Bad) Tj ET",
],
false,
);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let graph = walk_page_graph(&doc, &sha, 1).expect("graph");
let resolver = TestFontResolver {
id: "F1",
bytes: installable_font(),
};
let mut refused = 0;
for island in graph.islands.iter().filter(|i| !i.editable) {
let command = replace_cmd(
"same",
island,
&island.text,
island.appearance.clone(),
PAGE_WIDE,
);
assert!(
matches!(
compose_candidate(&source, &[command], &resolver),
Err(PdfEditError::UnsupportedEncoding)
),
"{:?} ({:?})",
island.text,
island.edit_blocker
);
refused += 1;
}
assert!(refused >= 2, "fixture must contain non-editable islands");
}
#[test]
fn finding7_a_changed_appearance_still_redraws_a_non_editable_island() {
let doc = build_pdf(b"BT /F1 10 Tf 72 700 Td (A) Tj 6.67 0 Td (B) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "AB");
assert!(!island.editable && island.substitution_editable);
let command = replace_cmd(
"sub",
&island,
"Hi",
appearance("test-font", 12.0),
PdfRect {
x: 72.0,
y: 690.0,
width: 200.0,
height: 30.0,
},
);
compose_candidate(&source, &[command], &test_resolver()).expect("substitution composes");
}
#[test]
fn finding8_repeated_spaces_in_a_space_less_font_are_refused_not_returned() {
let doc = tex_style_pdf(false, b"BT /F1 10 Tf 72 700 Td [(hello) -333 (big)] TJ ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "hello big");
for after in ["hello big", "hello big ", " hello big"] {
let outcome = edit_text(&doc, &island, after);
let error = outcome.expect_err(after);
assert_eq!(error.missing_chars(), Some(&[' '][..]), "{after:?}");
}
edit_text(&doc, &island, "hello bog").expect("unchanged spacing");
}
#[test]
fn composed_text_always_reads_back_as_requested_or_is_refused() {
let doc = tex_style_pdf(true, b"BT /F1 10 Tf 72 700 Td (ab) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "ab");
let outcome = verify_written(&doc, 1, &island, "zz", PAGE_WIDE);
assert!(matches!(outcome, Err(PdfEditError::VerificationFailed)));
verify_written(&doc, 1, &island, "ab", PAGE_WIDE).expect("matches what is there");
}
use crate::test_fixtures::{code_32_letter_font, code_5_space_font, inherited_resources_pdf};
fn add_hi(id: &str) -> PdfEditCommand {
PdfEditCommand::AddText {
id: id.to_string(),
page: 1,
anchor: PdfPoint { x: 100.0, y: 100.0 },
text: "Hi".to_string(),
appearance: appearance("test-font", 18.0),
}
}
fn appended_operations(doc: &Document, page: u32) -> Vec<Operation> {
let page_id = doc.get_pages()[&page];
let contents = doc
.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Contents")
.expect("contents");
let ids = content_stream_ids(doc, contents).expect("ids");
decode_stream_content(doc, *ids.last().expect("a stream"))
.expect("content")
.operations
}
#[test]
fn finding2_adding_a_font_to_a_page_that_inherits_its_resources_keeps_what_it_inherited() {
let doc = inherited_resources_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET /Im1 Do");
let source = doc_bytes(&doc);
let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
.expect("add text composes");
let (out, graph) = reinspect(&candidate, 1);
let existing = graph
.islands
.iter()
.find(|island| island.text == "Existing")
.expect("the inherited font still decodes the existing line");
assert!(existing.editable, "{:?}", existing.edit_blocker);
assert!(graph.islands.iter().any(|island| island.text == "Hi"));
let fingerprint = inspect::resource_fingerprint(&out).expect("fingerprint");
let names: Vec<String> = fingerprint[&1]
.keys()
.map(|(category, name)| {
format!(
"{}/{}",
String::from_utf8_lossy(category),
String::from_utf8_lossy(name)
)
})
.collect();
assert_eq!(names, ["Font/F1", "Font/VREdit1", "XObject/Im1"]);
let page_id = out.get_pages()[&1];
let parent = out
.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Parent")
.expect("parent")
.as_reference()
.expect("reference");
let inherited_fonts = out
.get_object(parent)
.expect("pages")
.as_dict()
.expect("dict")
.get(b"Resources")
.expect("resources")
.as_dict()
.expect("dict")
.get(b"Font")
.expect("fonts")
.as_dict()
.expect("dict");
assert_eq!(inherited_fonts.len(), 1);
assert!(inherited_fonts.has(b"F1"));
}
#[test]
fn finding2_a_second_added_text_extends_the_page_local_copy() {
let doc = inherited_resources_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
let source = doc_bytes(&doc);
let candidate = compose_candidate(&source, &[add_hi("a"), add_hi("b")], &test_resolver())
.expect("two adds compose");
let (out, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|island| island.text == "Existing"));
let fingerprint = inspect::resource_fingerprint(&out).expect("fingerprint");
let fonts: Vec<&[u8]> = fingerprint[&1]
.keys()
.filter(|(category, _)| category.as_slice() == b"Font")
.map(|(_, name)| name.as_slice())
.collect();
assert_eq!(fonts, [&b"F1"[..], &b"VREdit1"[..], &b"VREdit2"[..]]);
}
#[test]
fn finding2_an_unresolvable_resource_chain_is_refused_not_overwritten() {
let mut doc = inherited_resources_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
let page_id = doc.get_pages()[&1];
let pages_id = doc
.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Parent")
.expect("parent")
.as_reference()
.expect("reference");
if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
pages.remove(b"Resources");
pages.set("Parent", Object::Reference(page_id));
}
let outcome = compose_candidate(&doc_bytes(&doc), &[add_hi("a")], &test_resolver());
assert!(
matches!(outcome, Err(PdfEditError::MalformedContent)),
"{:?}",
outcome.map(|c| c.bytes.len())
);
}
#[test]
fn finding7_added_text_does_not_inherit_the_pages_trailing_text_state() {
let doc = build_pdf(b"BT /F1 10 Tf 3 Tr 5 Tc 7 Tw 50 Tz 4 Ts (hidden) Tj ET");
let source = doc_bytes(&doc);
let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
.expect("add text composes");
let (out, graph) = reinspect(&candidate, 1);
let added = graph
.islands
.iter()
.find(|island| island.text == "Hi")
.expect("added island");
let layout = added.layout.expect("layout");
assert_eq!(
(
layout.char_spacing,
layout.word_spacing,
layout.horizontal_scale
),
(0.0, 0.0, 1.0)
);
assert!(
(added.quads[0][1] - (100.0 - 0.25 * 18.0)).abs() < 1e-9,
"no inherited text rise: {}",
added.quads[0][1]
);
let ops = appended_operations(&out, 1);
let names: Vec<&str> = ops.iter().map(|op| op.operator.as_str()).collect();
assert_eq!(
names,
["q", "BT", "Tf", "Tr", "Tc", "Tw", "Tz", "Ts", "rg", "Tm", "Tj", "ET", "Q"]
);
let operand = |name: &str| {
ops.iter()
.find(|op| op.operator == name)
.map(|op| op.operands.clone())
};
assert_eq!(operand("Tr"), Some(vec![Object::Integer(0)]));
assert_eq!(operand("Tc"), Some(vec![Object::Integer(0)]));
assert_eq!(operand("Tw"), Some(vec![Object::Integer(0)]));
assert_eq!(operand("Tz"), Some(vec![Object::Integer(100)]));
assert_eq!(operand("Ts"), Some(vec![Object::Integer(0)]));
}
#[test]
fn finding7_the_ocr_correction_text_block_resets_the_same_state() {
let doc = build_pdf(b"BT /F1 10 Tf 3 Tr 5 Tc (hidden) Tj ET");
let source = doc_bytes(&doc);
let command = ocr_command(
"ocr",
"line-1",
"Hi",
PdfRect {
x: 100.0,
y: 100.0,
width: 100.0,
height: 30.0,
},
);
let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
let (out, _) = reinspect(&candidate, 1);
let ops = appended_operations(&out, 1);
let names: Vec<&str> = ops.iter().map(|op| op.operator.as_str()).collect();
assert_eq!(
names,
[
"q", "rg", "re", "f", "Q", "q", "BT", "Tf", "Tr", "Tc", "Tw", "Tz", "Ts", "rg", "Tm", "Tj", "ET", "Q"
]
);
}
fn nesting_before_last_stream(doc: &Document, page: u32) -> (i64, i64, usize) {
let page_id = doc.get_pages()[&page];
let contents = doc
.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Contents")
.expect("contents");
let ids = content_stream_ids(doc, contents).expect("ids");
let (mut depth, mut lowest) = (0_i64, 0_i64);
for id in &ids[..ids.len() - 1] {
for op in decode_stream_content(doc, *id).expect("content").operations {
match op.operator.as_str() {
"q" => depth += 1,
"Q" => {
depth -= 1;
lowest = lowest.min(depth);
}
_ => {}
}
}
}
(depth, lowest, ids.len())
}
#[test]
fn finding4_added_text_runs_outside_the_original_graphics_state() {
let cases: [(&str, &[u8]); 5] = [
(
"clip",
b"0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Existing) Tj ET",
),
("alpha", b"/GS0 gs BT /F1 12 Tf 72 700 Td (Existing) Tj ET"),
(
"open q",
b"q q 0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Existing) Tj ET",
),
("stray Q", b"Q Q BT /F1 12 Tf 72 700 Td (Existing) Tj ET"),
(
"balanced",
b"q 2 0 0 2 0 0 cm BT /F1 12 Tf 36 350 Td (Existing) Tj ET Q",
),
];
for (label, content) in cases {
let doc = build_pdf(content);
let source = doc_bytes(&doc);
let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
.unwrap_or_else(|e| panic!("{label}: {e:?}"));
let (out, graph) = reinspect(&candidate, 1);
assert!(
graph.islands.iter().any(|island| island.text == "Hi"),
"{label}"
);
let (depth, lowest, streams) = nesting_before_last_stream(&out, 1);
assert_eq!(depth, 0, "{label}: the added stream starts at depth zero");
assert_eq!(lowest, 0, "{label}: the wrapper is never popped early");
assert_eq!(streams, 4, "{label}: q wrapper, original, Q wrapper, added");
let names: Vec<String> = appended_operations(&out, 1)
.iter()
.map(|op| op.operator.clone())
.collect();
assert_eq!(
names,
["q", "BT", "Tf", "Tr", "Tc", "Tw", "Tz", "Ts", "rg", "Tm", "Tj", "ET", "Q"],
"{label}"
);
}
}
#[test]
fn finding4_the_original_content_stream_is_not_modified() {
let doc = build_pdf(b"0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
let page_id = doc.get_pages()[&1];
let original_ids = content_stream_ids(
&doc,
doc.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Contents")
.expect("contents"),
)
.expect("ids");
let original = decode_stream_content(&doc, original_ids[0]).expect("content");
let source = doc_bytes(&doc);
let candidate =
compose_candidate(&source, &[add_hi("a")], &test_resolver()).expect("composes");
let (out, _) = reinspect(&candidate, 1);
let kept = decode_stream_content(&out, original_ids[0]).expect("content");
assert_eq!(
kept.encode().expect("encode"),
original.encode().expect("encode")
);
}
#[test]
fn finding4_a_replacement_in_a_new_font_is_isolated_from_the_streams_clip() {
let doc = build_pdf(b"0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "Hello");
let command = replace_cmd(
"new-font",
&island,
"Hi",
appearance("test-font", 24.0),
PdfRect {
x: 72.0,
y: 690.0,
width: 400.0,
height: 40.0,
},
);
let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
let (out, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
let (depth, lowest, _) = nesting_before_last_stream(&out, 1);
assert_eq!((depth, lowest), (0, 0));
}
#[test]
fn finding5_a_locator_missing_one_of_the_islands_spans_is_refused() {
let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (AB) Tj (CD) Tj ET");
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "ABCD");
assert_eq!(island.locator.spans.len(), 2);
let bounds = PdfRect {
x: 72.0,
y: 690.0,
width: 400.0,
height: 40.0,
};
let changed_font = appearance("test-font", 24.0);
let mut partial = replace_cmd("partial", &island, "Hi", changed_font.clone(), bounds);
if let PdfEditCommand::ReplaceSourceText { locator, .. } = &mut partial {
locator.spans.truncate(1);
}
assert!(matches!(
compose_candidate(&source, &[partial], &test_resolver()),
Err(PdfEditError::SourceDigestMismatch)
));
let mut reordered = replace_cmd("reordered", &island, "Hi", changed_font.clone(), bounds);
if let PdfEditCommand::ReplaceSourceText { locator, .. } = &mut reordered {
locator.spans.reverse();
}
assert!(matches!(
compose_candidate(&source, &[reordered], &test_resolver()),
Err(PdfEditError::SourceDigestMismatch)
));
let mut repeated = replace_cmd("repeated", &island, "Hi", changed_font.clone(), bounds);
if let PdfEditCommand::ReplaceSourceText { locator, .. } = &mut repeated {
let first = locator.spans[0].clone();
locator.spans = vec![first.clone(), first];
}
assert!(matches!(
compose_candidate(&source, &[repeated], &test_resolver()),
Err(PdfEditError::SourceDigestMismatch)
));
let whole = replace_cmd("whole", &island, "Hi", changed_font, bounds);
let candidate =
compose_candidate(&source, &[whole], &test_resolver()).expect("complete locator");
let (_, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
assert!(!graph
.islands
.iter()
.any(|isl| isl.text.contains("AB") || isl.text.contains("CD")));
}
#[test]
fn finding4_replacing_with_code_32_pays_the_word_spacing_the_page_will_draw() {
let doc = custom_page_pdf(
|d| vec![("F1", code_32_letter_font(d))],
&[b"BT /F1 10 Tf 10 Tw 72 700 Td (aab) Tj ET"],
false,
);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "aab");
assert!(island.editable, "{:?}", island.edit_blocker);
let own = island_box(&island);
assert!((own.width - 15.0).abs() < 0.01, "{}", own.width);
let command = replace_cmd("tw", &island, "axb", island.appearance.clone(), own);
assert!(matches!(
compose_candidate(&source, &[command], &FailingFontResolver),
Err(PdfEditError::TextOverflow)
));
let command = replace_cmd("tw", &island, "axb", island.appearance.clone(), rect(25.0));
let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
.expect("fits the width it needs");
let (written, width) = island_after(&candidate, "axb");
assert!((width - 25.0).abs() < 0.01, "{width}");
assert!(
written.gaps.is_empty(),
"the Tw jump is not read back as a gap"
);
}
#[test]
fn finding4_removing_code_32_gives_back_its_word_spacing() {
let doc = custom_page_pdf(
|d| vec![("F1", code_32_letter_font(d))],
&[b"BT /F1 10 Tf 10 Tw 72 700 Td (a b) Tj ET"],
false,
);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "axb");
assert!((island_box(&island).width - 25.0).abs() < 0.01);
let command = replace_cmd("tw", &island, "aab", island.appearance.clone(), rect(15.0));
let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
.expect("15 pt is enough once the code-32 glyph is gone");
let (_, width) = island_after(&candidate, "aab");
assert!((width - 15.0).abs() < 0.01, "{width}");
}
#[test]
fn finding4_a_space_through_another_code_is_not_charged_word_spacing_in_the_fit() {
let doc = custom_page_pdf(
|d| vec![("F1", code_5_space_font(d))],
&[b"BT /F1 10 Tf 10 Tw 72 700 Td <610562> Tj ET"],
false,
);
let source = doc_bytes(&doc);
let sha = document_sha256(&source);
let island = locate_island(&doc, &sha, 1, "a b");
let own = island_box(&island);
assert!((own.width - 12.5).abs() < 0.01, "{}", own.width);
let command = replace_cmd("tw", &island, "aab", island.appearance.clone(), own);
assert!(
matches!(
compose_candidate(&source, &[command], &FailingFontResolver),
Err(PdfEditError::TextOverflow)
),
"the letter is 2.5 pt wider than the space it replaces, with no Tw to give back"
);
let command = replace_cmd("tw", &island, "aab", island.appearance.clone(), rect(15.0));
let candidate =
compose_candidate(&source, &[command], &FailingFontResolver).expect("fits 15 pt");
let (_, width) = island_after(&candidate, "aab");
assert!((width - 15.0).abs() < 0.01, "{width}");
}
#[test]
fn finding2_a_composed_add_text_passes_staging_against_its_inheriting_base() {
let source = doc_bytes(&inherited_resources_pdf(
b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET /Im1 Do",
));
let sha = document_sha256(&source);
let command = add_hi("a");
let candidate =
compose_candidate(&source, std::slice::from_ref(&command), &test_resolver())
.expect("composes");
let store = crate::save::PdfSaveTokenStore::default();
crate::save::stage_prepared_save_against_base(
&store,
"/doc.pdf",
&sha,
&sha,
&source,
candidate.bytes,
candidate.affected_pages,
crate::save::expected_text_checks(std::slice::from_ref(&command)),
)
.expect("an Add Text hides nothing the page resolved");
}
fn page_islands(bytes: &[u8]) -> Vec<crate::model::PdfTextIsland> {
let doc = Document::load_mem(bytes).expect("reopen");
walk_page_graph(&doc, &document_sha256(bytes), 1)
.expect("inspect")
.islands
}
fn changed_font_cmd(
id: &str,
island: &crate::model::PdfTextIsland,
text: &str,
x: f64,
) -> PdfEditCommand {
replace_cmd(
id,
island,
text,
appearance("test-font", 24.0),
PdfRect {
x,
y: 650.0,
width: 200.0,
height: 40.0,
},
)
}
fn add_text_cmd(id: &str, text: &str) -> PdfEditCommand {
PdfEditCommand::AddText {
id: id.to_string(),
page: 1,
anchor: PdfPoint { x: 100.0, y: 100.0 },
text: text.to_string(),
appearance: appearance("test-font", 18.0),
}
}
const THREE_COLOURS: &[u8] =
b"BT /F1 12 Tf 72 700 Td (AB) Tj 1 0 0 rg (CD) Tj 0 0 1 rg (AB) Tj ET";
#[test]
fn finding9_a_changed_font_replacement_keeps_the_neighbouring_quads_bit_for_bit() {
let source = doc_bytes(&build_pdf(THREE_COLOURS));
let before = page_islands(&source);
assert_eq!(
before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
["AB", "CD", "AB"]
);
let command = changed_font_cmd("first", &before[0], "Hi", 72.0);
let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
let after = page_islands(&candidate.bytes);
type SpanPlace = ((u32, u16), u32, u32, u32, u32, u32);
let red = |islands: &[crate::model::PdfTextIsland]| {
islands
.iter()
.find(|i| i.text == "CD")
.map(|i| {
let spans: Vec<SpanPlace> = i
.locator
.spans
.iter()
.map(|s| {
(
s.stream_ref,
s.contents_index,
s.operator_index,
s.operand_index,
s.glyph_start,
s.glyph_end,
)
})
.collect();
(i.quads.clone(), spans)
})
.expect("the red island survives")
};
let ((quads_before, spans_before), (quads_after, spans_after)) =
(red(&before), red(&after));
assert_eq!(quads_after, quads_before);
let moved_one_stream: Vec<_> = spans_before
.iter()
.map(|&(stream, index, operator, operand, start, end)| {
(stream, index + 1, operator, operand, start, end)
})
.collect();
assert_eq!(spans_after, moved_one_stream);
let blue = after
.iter()
.filter(|i| i.text == "AB")
.map(|i| i.quads.clone())
.collect::<Vec<_>>();
assert_eq!(blue, [before[2].quads.clone()]);
assert!(after.iter().any(|i| i.text == "Hi"));
assert!(!after
.iter()
.any(|i| i.text == "AB" && i.quads == before[0].quads));
}
#[test]
fn finding9_a_removed_string_inside_a_tj_keeps_the_pen_through_kerns_and_following_runs() {
let source = doc_bytes(&build_pdf(
b"BT /F1 12 Tf 72 700 Td [(AB) -300 (CD)] TJ 1 0 0 rg (EF) Tj ET",
));
let before = page_islands(&source);
let first = before
.iter()
.find(|i| i.text.contains("AB"))
.expect("first island");
let command = changed_font_cmd("tj", first, "Hi", 72.0);
let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
let after = page_islands(&candidate.bytes);
let ef = |islands: &[crate::model::PdfTextIsland]| {
islands
.iter()
.find(|i| i.text == "EF")
.map(|i| i.quads.clone())
.expect("EF survives")
};
assert_eq!(ef(&before), ef(&after));
}
fn glyph_origins(islands: &[crate::model::PdfTextIsland], glyph: &str) -> Vec<(f64, f64)> {
islands
.iter()
.flat_map(|island| island.run.iter())
.filter(|item| item.glyph.is_some() && item.text == glyph)
.filter_map(|item| item.placement)
.map(|(matrix, _)| (matrix.e, matrix.f))
.collect()
}
fn has_origin(origins: &[(f64, f64)], at: [f64; 4]) -> bool {
origins
.iter()
.any(|origin| (origin.0 - at[0]).hypot(origin.1 - at[1]) <= 0.5)
}
fn page_text(islands: &[crate::model::PdfTextIsland]) -> String {
islands
.iter()
.flat_map(|island| island.text.chars())
.filter(|c| *c != ' ')
.collect()
}
#[test]
fn finding12_add_text_and_two_replacements_on_one_page_all_resolve_in_one_batch() {
let source = doc_bytes(&build_pdf(THREE_COLOURS));
let before = page_islands(&source);
let commands = [
add_text_cmd("add", "HH"),
changed_font_cmd("one", &before[0], "Hi", 72.0),
changed_font_cmd("two", &before[2], "CA", 300.0),
];
let candidate = compose_candidate(&source, &commands, &test_resolver())
.expect("every command resolves");
assert_eq!(candidate.affected_pages, [1]);
let after = page_islands(&candidate.bytes);
let texts: Vec<&str> = after.iter().map(|i| i.text.as_str()).collect();
for wanted in ["CD", "HH"] {
assert!(texts.contains(&wanted), "{wanted}: {texts:?}");
}
let text = page_text(&after);
assert!(
text.contains("Hi") && text.contains("CA") && !text.contains("AB"),
"{texts:?}"
);
assert!(has_origin(&glyph_origins(&after, "H"), before[0].baseline));
assert!(has_origin(&glyph_origins(&after, "C"), before[2].baseline));
assert!(has_origin(
&glyph_origins(&after, "H"),
[100.0, 100.0, 0.0, 0.0]
));
let red = after.iter().find(|i| i.text == "CD").expect("CD");
assert_eq!(red.quads, before[1].quads);
let (out, _) = reinspect(&candidate, 1);
let page_id = out.get_pages()[&1];
let contents = out
.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Contents")
.expect("contents");
assert_eq!(content_stream_ids(&out, contents).expect("ids").len(), 6);
}
#[test]
fn finding12_replacements_apply_back_to_front_whatever_order_they_are_given_in() {
let source = doc_bytes(&build_pdf(THREE_COLOURS));
let before = page_islands(&source);
for order in [[0_usize, 2], [2, 0]] {
let commands: Vec<PdfEditCommand> = order
.iter()
.map(|&i| changed_font_cmd(&format!("c{i}"), &before[i], "Hi", 72.0 + i as f64))
.collect();
let candidate = compose_candidate(&source, &commands, &test_resolver())
.unwrap_or_else(|e| panic!("{order:?}: {e:?}"));
let after = page_islands(&candidate.bytes);
let text = page_text(&after);
assert_eq!(text.matches("Hi").count(), 2, "{order:?}: {text}");
assert!(!text.contains("AB"), "{order:?}: {text}");
let origins = glyph_origins(&after, "H");
assert_eq!(origins.len(), 2, "{order:?}");
assert!(has_origin(&origins, before[0].baseline), "{order:?}");
assert!(has_origin(&origins, before[2].baseline), "{order:?}");
let red = after.iter().find(|i| i.text == "CD").expect("CD");
assert_eq!(red.quads, before[1].quads, "{order:?}");
}
}
#[test]
fn finding12_two_islands_in_one_tj_array_both_resolve_when_the_first_is_split() {
let source = doc_bytes(&build_pdf(
b"BT /F1 12 Tf 72 700 Td [(AB) -2000 (CD)] TJ 1 0 0 rg (AB) Tj ET",
));
let before = page_islands(&source);
assert_eq!(
before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
["AB", "CD", "AB"]
);
let commands = [
changed_font_cmd("ab", &before[0], "Hi", 72.0),
changed_font_cmd("cd", &before[1], "CA", 300.0),
];
let candidate = compose_candidate(&source, &commands, &test_resolver())
.expect("both islands of the array resolve");
let after = page_islands(&candidate.bytes);
let texts: Vec<&str> = after.iter().map(|i| i.text.as_str()).collect();
let text = page_text(&after);
assert!(text.contains("Hi") && text.contains("CA"), "{texts:?}");
assert!(has_origin(&glyph_origins(&after, "H"), before[0].baseline));
assert!(has_origin(&glyph_origins(&after, "C"), before[1].baseline));
let tail = after
.iter()
.find(|i| i.text == "AB")
.map(|i| i.quads.clone())
.expect("the trailing AB");
assert_eq!(tail, before[2].quads);
}
#[test]
fn finding9_glyphs_shown_by_a_line_moving_operator_are_refused_not_left_with_the_pen_short() {
let source = doc_bytes(&build_pdf(
b"BT /F1 12 Tf 72 700 Td 14 TL (AB) ' 1 0 0 rg (CD) Tj ET",
));
let before = page_islands(&source);
let first = before.iter().find(|i| i.text == "AB").expect("AB");
let command = changed_font_cmd("quote", first, "Hi", 72.0);
assert!(matches!(
compose_candidate(&source, &[command], &test_resolver()),
Err(PdfEditError::UnsupportedEncoding)
));
}
#[test]
fn finding16_inspection_advertises_a_substitution_exactly_when_composition_admits_it() {
let cases: [(&[u8], bool); 4] = [
(b"BT /F1 12 Tf 72 700 Td (AB) Tj ET", true),
(b"BT /F1 12 Tf 72 700 Td [(AB)] TJ ET", true),
(b"BT /F1 12 Tf 72 700 Td 14 TL (AB) ' ET", false),
(b"BT /F1 12 Tf 72 700 Td 14 TL 0 0 (AB) \" ET", false),
];
for (content, advertised) in cases {
let label = String::from_utf8_lossy(content).into_owned();
let source = doc_bytes(&build_pdf(content));
let before = page_islands(&source);
let island = before
.iter()
.find(|i| i.text == "AB")
.unwrap_or_else(|| panic!("{label}: no AB island"));
assert_eq!(island.substitution_editable, advertised, "{label}");
let command = changed_font_cmd("substitute", island, "Hi", 72.0);
let composed = compose_candidate(&source, &[command], &test_resolver());
assert_eq!(
composed.is_ok(),
advertised,
"{label}: composition said {:?}",
composed.as_ref().err()
);
if !advertised {
assert!(
matches!(composed, Err(PdfEditError::UnsupportedEncoding)),
"{label}"
);
}
}
}
#[test]
fn finding12_an_island_listed_twice_in_a_batch_is_still_refused_the_second_time() {
let source = doc_bytes(&build_pdf(THREE_COLOURS));
let before = page_islands(&source);
let commands = [
changed_font_cmd("a", &before[0], "Hi", 72.0),
changed_font_cmd("b", &before[0], "CA", 300.0),
];
assert!(matches!(
compose_candidate(&source, &commands, &test_resolver()),
Err(PdfEditError::SourceDigestMismatch)
));
}
#[test]
fn finding10_a_page_sharing_a_stream_with_many_pages_costs_one_copy() {
let mut doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (AB) Tj ET");
let page_id = doc.get_pages()[&1];
let (contents, parent, resources) = {
let page = doc
.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict");
(
page.get(b"Contents").expect("contents").clone(),
page.get(b"Parent").expect("parent").clone(),
page.get(b"Resources").expect("resources").clone(),
)
};
let Object::Reference(pages_id) = parent.clone() else {
panic!("parent reference");
};
let mut kids = vec![Object::Reference(page_id)];
for _ in 0..3 {
let extra = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => parent.clone(),
"Contents" => contents.clone(),
"Resources" => resources.clone(),
"MediaBox" => Object::Array(vec![
Object::Integer(0),
Object::Integer(0),
Object::Integer(612),
Object::Integer(792),
]),
});
kids.push(Object::Reference(extra));
}
if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
pages.set("Kids", Object::Array(kids));
pages.set("Count", 4);
}
let source = doc_bytes(&doc);
let before = page_islands(&source);
let before_objects = Document::load_mem(&source).expect("reopen").objects.len();
let command = changed_font_cmd("shared", &before[0], "Hi", 72.0);
let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
let out = Document::load_mem(&candidate.bytes).expect("reopen candidate");
let mut streams: std::collections::BTreeSet<ObjectId> = Default::default();
for page in 2..=4 {
let id = out.get_pages()[&page];
let contents = out
.get_object(id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Contents")
.expect("contents");
streams.extend(content_stream_ids(&out, contents).expect("ids"));
}
assert_eq!(streams.len(), 1, "the three other pages share one copy");
assert!(out.objects.len() > before_objects);
}
#[test]
fn finding10_unclosed_q_operators_are_refused_not_expanded_into_a_wrapper() {
let content = format!(
"{}BT /F1 12 Tf 72 700 Td (Existing) Tj ET",
"q ".repeat(70_000)
);
let source = doc_bytes(&build_pdf(content.as_bytes()));
assert!(matches!(
compose_candidate(&source, &[add_hi("a")], &test_resolver()),
Err(PdfEditError::MalformedContent)
));
}
const CUT_INSIDE_AN_OPERATION: [&[u8]; 3] = [
b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET ",
b"q 1 0 0 1 0 0 cm Q /Artifact ",
b"<< /MCID 0 >> BDC EMC ",
];
fn page_cut_inside_an_operation() -> Document {
assert!(Content::decode_strict(CUT_INSIDE_AN_OPERATION[1]).is_err());
assert!(Content::decode_strict(&CUT_INSIDE_AN_OPERATION.concat()).is_ok());
custom_page_pdf(
|d| vec![("F1", helvetica_font(d))],
&CUT_INSIDE_AN_OPERATION,
false,
)
}
fn page_one_streams(doc: &Document) -> Vec<ObjectId> {
let page_id = doc.get_pages()[&1];
let contents = doc
.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Contents")
.expect("contents");
content_stream_ids(doc, contents).expect("ids")
}
fn stream_bytes(doc: &Document, id: ObjectId) -> Vec<u8> {
doc.get_object(id)
.expect("object")
.as_stream()
.expect("a stream")
.decompressed_content()
.expect("decodes")
}
fn nesting_before_the_last_stream_leniently(doc: &Document) -> (i64, i64) {
let ids = page_one_streams(doc);
let (mut depth, mut lowest) = (0_i64, 0_i64);
for id in &ids[..ids.len() - 1] {
let content = Content::decode(&stream_bytes(doc, *id)).expect("parses leniently");
for op in content.operations {
match op.operator.as_str() {
"q" => depth += 1,
"Q" => {
depth -= 1;
lowest = lowest.min(depth);
}
_ => {}
}
}
}
(depth, lowest)
}
#[test]
fn add_text_on_a_page_cut_inside_an_operation_composes_and_leaves_every_stream_alone() {
let doc = page_cut_inside_an_operation();
let original = page_one_streams(&doc);
let source = doc_bytes(&doc);
let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
.expect("a page is not malformed because its streams are cut inside an operation");
let (out, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
let ids = page_one_streams(&out);
assert_eq!(ids.len(), 6);
assert_eq!(&ids[1..4], &original[..]);
for (id, bytes) in ids[1..4].iter().zip(CUT_INSIDE_AN_OPERATION) {
assert_eq!(stream_bytes(&out, *id), bytes);
}
assert_eq!(nesting_before_the_last_stream_leniently(&out), (0, 0));
}
#[test]
fn a_changed_font_replacement_on_a_page_cut_inside_an_operation_composes() {
let doc = page_cut_inside_an_operation();
let source = doc_bytes(&doc);
let before = page_islands(&source);
assert_eq!(
before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
["Existing"]
);
let command = changed_font_cmd("redraw", &before[0], "Hi", 72.0);
let candidate = compose_candidate(&source, &[command], &test_resolver())
.expect("a page is not malformed because its streams are cut inside an operation");
let (out, graph) = reinspect(&candidate, 1);
assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
assert!(!graph.islands.iter().any(|isl| isl.text == "Existing"));
let ids = page_one_streams(&out);
assert_eq!(ids.len(), 6);
assert_eq!(stream_bytes(&out, ids[2]), CUT_INSIDE_AN_OPERATION[1]);
assert_eq!(stream_bytes(&out, ids[3]), CUT_INSIDE_AN_OPERATION[2]);
assert_eq!(nesting_before_the_last_stream_leniently(&out), (0, 0));
}
#[test]
fn add_text_still_refuses_a_stream_that_ends_in_anything_but_operands() {
for (label, tail) in [
("open string", &b"q (open"[..]),
("stray byte", &b"q \x01 Q"[..]),
] {
let streams: [&[u8]; 2] = [b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET ", tail];
let doc = custom_page_pdf(|d| vec![("F1", helvetica_font(d))], &streams, false);
let source = doc_bytes(&doc);
assert!(
matches!(
compose_candidate(&source, &[add_hi("a")], &test_resolver()),
Err(PdfEditError::MalformedContent)
),
"{label}"
);
}
}
fn cm_positioned_page(runs: &[(&str, &str, &str)]) -> Vec<u8> {
let mut content = String::from("0.1 0 0 0.1 0 0 cm\n1 0 0 1 0 7920 cm\n");
for (cm, rg, text) in runs {
content.push_str(&format!(
"q {cm} cm BT /F1 100 Tf {rg} rg 0 0 Td ({text}) Tj ET Q\n"
));
}
content.into_bytes()
}
fn three_cm_runs(middle_cm: &str, middle_text: &str) -> Vec<u8> {
cm_positioned_page(&[
("1 0 0 1 1000 -1000", "0 0 0", "AB"),
(middle_cm, "1 0 0", middle_text),
("1 0 0 1 3000 -1000", "0 0 1", "CA"),
])
}
struct ReplacedMiddle {
target: String,
replacement: String,
before: Vec<crate::model::PdfTextIsland>,
after: Vec<crate::model::PdfTextIsland>,
appended: Vec<Operation>,
bytes: Vec<u8>,
}
fn replace_middle_run_in_a_new_font(content: &[u8]) -> ReplacedMiddle {
replace_run_in_a_new_font(content, "BC", "Hi")
}
fn replace_run_in_a_new_font(
content: &[u8],
target: &str,
replacement: &str,
) -> ReplacedMiddle {
let source = doc_bytes(&build_pdf(content));
let before = page_islands(&source);
let found = before
.iter()
.find(|island| island.text == target)
.expect("the middle run is an island of its own");
let command = replace_cmd(
"middle",
found,
replacement,
appearance("test-font", 100.0),
PdfRect {
x: 190.0,
y: 680.0,
width: 200.0,
height: 40.0,
},
);
let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
let (out, graph) = reinspect(&candidate, 1);
ReplacedMiddle {
target: target.to_string(),
replacement: replacement.to_string(),
before,
after: graph.islands,
appended: appended_operations(&out, 1),
bytes: candidate.bytes,
}
}
fn operands_of(ops: &[Operation], operator: &str) -> Vec<f64> {
ops.iter()
.find(|op| op.operator == operator)
.unwrap_or_else(|| panic!("no {operator} in {ops:?}"))
.operands
.iter()
.filter_map(object_number)
.collect()
}
fn assert_numbers(label: &str, got: &[f64], want: &[f64]) {
assert_eq!(got.len(), want.len(), "{label}: {got:?} vs {want:?}");
for (got_value, want_value) in got.iter().zip(want) {
assert!(
(got_value - want_value).abs() <= 1e-4,
"{label}: {got:?} vs {want:?}"
);
}
}
fn device_size(island: &crate::model::PdfTextIsland) -> f64 {
let layout = island.layout.expect("layout");
layout.font_size * layout.placement.c.hypot(layout.placement.d)
}
fn island_named<'a>(
islands: &'a [crate::model::PdfTextIsland],
text: &str,
) -> &'a crate::model::PdfTextIsland {
islands
.iter()
.find(|island| island.text == text)
.unwrap_or_else(|| panic!("no island {text:?}"))
}
fn assert_replacement_sits_on_the_source_run(run: &ReplacedMiddle) {
let source = island_named(&run.before, &run.target);
let written = island_named(&run.after, &run.replacement);
let origin_error = (written.baseline[0] - source.baseline[0])
.hypot(written.baseline[1] - source.baseline[1]);
assert!(
origin_error <= 0.5,
"origin {:?} vs source {:?}",
&written.baseline[..2],
&source.baseline[..2]
);
let (have, want) = (device_size(written), device_size(source));
assert!(
(have - want).abs() <= 0.02 * want,
"device size {have} vs source {want}"
);
assert!(
(want - 10.0).abs() < 1e-6,
"the fixture's run is 10 pt: {want}"
);
for neighbour in ["AB", "CA"] {
assert_eq!(
island_named(&run.after, neighbour).quads,
island_named(&run.before, neighbour).quads,
"{neighbour}"
);
}
assert!(!run.after.iter().any(|island| island.text == run.target));
}
#[test]
fn a_changed_font_replacement_on_a_cm_positioned_page_carries_the_runs_placement() {
let run = replace_middle_run_in_a_new_font(&three_cm_runs("1 0 0 1 2000 -1000", "BC"));
assert_numbers(
"Tm",
&operands_of(&run.appended, "Tm"),
&[0.1, 0.0, 0.0, 0.1, 200.0, 692.0],
);
assert_numbers("Tf", &operands_of(&run.appended, "Tf"), &[100.0]);
assert_replacement_sits_on_the_source_run(&run);
}
#[test]
fn a_changed_font_replacement_keeps_the_rotation_of_a_cm_positioned_run() {
let run = replace_run_in_a_new_font(&three_cm_runs("0 1 -1 0 2000 -1000", "B"), "B", "H");
assert_numbers(
"Tm",
&operands_of(&run.appended, "Tm"),
&[0.0, 0.1, -0.1, 0.0, 200.0, 692.0],
);
assert_numbers("Tf", &operands_of(&run.appended, "Tf"), &[100.0]);
assert_replacement_sits_on_the_source_run(&run);
let placement = island_named(&run.after, "H")
.layout
.expect("layout")
.placement;
assert_numbers(
"read back",
&[placement.a, placement.b, placement.c, placement.d],
&[0.0, 0.1, -0.1, 0.0],
);
}
#[test]
fn a_changed_font_replacement_keeps_the_text_matrix_and_the_cm_together() {
let content = b"0.1 0 0 0.1 0 0 cm\n1 0 0 1 0 7920 cm\n\
q 1 0 0 1 1000 -1000 cm BT /F1 100 Tf 0 0 0 rg 0 0 Td (AB) Tj ET Q\n\
q 1 0 0 1 2000 -1000 cm BT /F1 100 Tf 1 0 0 rg 0 1 -1 0 30 40 Tm (B) Tj ET Q\n\
q 1 0 0 1 3000 -1000 cm BT /F1 100 Tf 0 0 1 rg 0 0 Td (CA) Tj ET Q\n";
let run = replace_run_in_a_new_font(content, "B", "H");
assert_numbers(
"Tm",
&operands_of(&run.appended, "Tm"),
&[0.0, 0.1, -0.1, 0.0, 203.0, 696.0],
);
assert_numbers("Tf", &operands_of(&run.appended, "Tf"), &[100.0]);
assert_replacement_sits_on_the_source_run(&run);
}
#[test]
fn add_text_on_a_page_with_a_leading_scale_stays_an_upright_point_in_page_space() {
let source = doc_bytes(&build_pdf(&three_cm_runs("1 0 0 1 2000 -1000", "BC")));
let command = PdfEditCommand::AddText {
id: "add".to_string(),
page: 1,
anchor: PdfPoint { x: 100.0, y: 100.0 },
text: "Hi".to_string(),
appearance: appearance("test-font", 18.0),
};
let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
let (out, graph) = reinspect(&candidate, 1);
let ops = appended_operations(&out, 1);
assert_numbers(
"Tm",
&operands_of(&ops, "Tm"),
&[1.0, 0.0, 0.0, 1.0, 100.0, 100.0],
);
assert_numbers("Tf", &operands_of(&ops, "Tf"), &[18.0]);
let added = island_named(&graph.islands, "Hi");
assert!(
(added.baseline[0] - 100.0).abs() < 1e-3 && (added.baseline[1] - 100.0).abs() < 1e-3
);
assert!((device_size(added) - 18.0).abs() < 1e-6);
}
#[test]
fn the_composer_read_back_refuses_a_replacement_drawn_away_from_the_source_run() {
let run = replace_middle_run_in_a_new_font(&three_cm_runs("1 0 0 1 2000 -1000", "BC"));
let placement = island_named(&run.before, "BC")
.layout
.expect("layout")
.placement;
let mut doc = Document::load_mem(&run.bytes).expect("reopen");
let stream = last_content_stream(&doc, 1);
let expected = |placement: Matrix, size: f64| AppendedRun {
page: 1,
stream: Some(stream),
placement,
size,
};
let shifted = |dx: f64, dy: f64| Matrix {
e: placement.e + dx,
f: placement.f + dy,
..placement
};
verify_appended_runs(&doc, &[expected(placement, 100.0)])
.expect("where the source run was");
verify_appended_runs(&doc, &[expected(shifted(0.3, -0.3), 100.0)]).expect("0.42 pt");
for (dx, dy) in [(1.0, 0.0), (0.0, -1.0), (-200.0, -692.0)] {
assert!(
matches!(
verify_appended_runs(&doc, &[expected(shifted(dx, dy), 100.0)]),
Err(PdfEditError::VerificationFailed)
),
"({dx}, {dy})"
);
}
verify_appended_runs(&doc, &[expected(placement, 101.0)]).expect("1 %");
for size in [103.0, 1000.0] {
assert!(matches!(
verify_appended_runs(&doc, &[expected(placement, size)]),
Err(PdfEditError::VerificationFailed)
));
}
let turned = Matrix {
a: 0.0,
b: 0.1,
c: -0.1,
d: 0.0,
..placement
};
assert!(matches!(
verify_appended_runs(&doc, &[expected(turned, 100.0)]),
Err(PdfEditError::VerificationFailed)
));
let last = last_content_stream(&doc, 1);
let mut content = decode_stream_content(&doc, last).expect("content");
for op in content
.operations
.iter_mut()
.filter(|op| op.operator == "Tm")
{
op.operands = [1_i64, 0, 0, 1, 0, 0].map(Object::Integer).to_vec();
}
write_stream_content(&mut doc, last, &content).expect("write");
let graph = walk_page_graph(&doc, "", 1).expect("inspect");
assert!(graph.islands.iter().any(|island| island.text == "Hi"));
assert!(matches!(
verify_appended_runs(&doc, &[expected(placement, 100.0)]),
Err(PdfEditError::VerificationFailed)
));
}
fn last_content_stream(doc: &Document, page: u32) -> ObjectId {
let page_id = doc.get_pages()[&page];
let contents = doc
.get_object(page_id)
.expect("page")
.as_dict()
.expect("dict")
.get(b"Contents")
.expect("contents");
*content_stream_ids(doc, contents)
.expect("ids")
.last()
.expect("a stream")
}
#[test]
fn a_replacement_that_joins_a_preceding_same_colour_run_is_still_read_back() {
let source = doc_bytes(&build_pdf(
b"BT /F1 12 Tf 0 g 120 700 Td (42.00) Tj 1 0 0 1 80 700 Tm (Total:) Tj ET",
));
let before = page_islands(&source);
let target = island_named(&before, "42.00");
let command = replace_cmd(
"price",
target,
"Hi",
appearance("test-font", 12.0),
PdfRect {
x: 120.0,
y: 690.0,
width: 100.0,
height: 30.0,
},
);
let candidate = compose_candidate(&source, &[command], &test_resolver())
.expect("a correct edit is not refused because it was grouped with its neighbour");
let after = page_islands(&candidate.bytes);
assert!(
after
.iter()
.any(|island| island.text.contains("Total:") && island.text.contains("Hi")),
"the fixture must put the replacement in the neighbour's island: {:?}",
after.iter().map(|i| i.text.as_str()).collect::<Vec<_>>()
);
let hi = after
.iter()
.flat_map(|island| island.run.iter())
.filter_map(|item| item.glyph.zip(item.placement))
.find(|(glyph, _)| glyph.stream_ref == last_stream_ref(&candidate.bytes))
.map(|(_, placement)| placement)
.expect("the appended run's first glyph");
assert_numbers(
"origin of the replacement",
&[hi.0.e, hi.0.f],
&[120.0, 700.0],
);
}
#[test]
fn two_adjacent_replacements_in_one_batch_are_both_read_back() {
let source = doc_bytes(&build_pdf(
b"BT /F1 12 Tf 0 g 100 700 Td (AB) Tj 1 0 0 1 150 700 Tm (CA) Tj ET",
));
let before = page_islands(&source);
assert_eq!(
before.len(),
2,
"{:?}",
before.iter().map(|i| &i.text).collect::<Vec<_>>()
);
let bounds = PdfRect {
x: 100.0,
y: 690.0,
width: 100.0,
height: 40.0,
};
let commands = [
replace_cmd(
"first",
island_named(&before, "AB"),
"Hi",
appearance("test-font", 24.0),
bounds,
),
replace_cmd(
"second",
island_named(&before, "CA"),
"Hi",
appearance("test-font", 24.0),
bounds,
),
];
let candidate = compose_candidate(&source, &commands, &test_resolver())
.expect("both replacements read back");
let after = page_islands(&candidate.bytes);
assert!(
after
.iter()
.any(|island| island.text.matches("Hi").count() == 2),
"the fixture must group the two replacements: {:?}",
after.iter().map(|i| i.text.as_str()).collect::<Vec<_>>()
);
}
#[test]
fn replacements_located_in_the_pristine_file_compose_in_one_call_in_either_order() {
let source = doc_bytes(&build_pdf(
b"BT /F1 12 Tf 0 g 100 700 Td (AB) Tj 1 0 0 1 150 700 Tm (CA) Tj \
1 0 0 rg 1 0 0 1 400 700 Tm (CD) Tj ET",
));
let before = page_islands(&source);
assert_eq!(
before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
["AB", "CA", "CD"]
);
let bounds = PdfRect {
x: 100.0,
y: 690.0,
width: 100.0,
height: 40.0,
};
let command = |id: &str, text: &str| {
replace_cmd(
id,
island_named(&before, text),
"Hi",
appearance("test-font", 24.0),
bounds,
)
};
for order in [["AB", "CA"], ["CA", "AB"]] {
let commands = [command("one", order[0]), command("two", order[1])];
let candidate = compose_candidate(&source, &commands, &test_resolver())
.unwrap_or_else(|e| panic!("{order:?}: {e:?}"));
let after = page_islands(&candidate.bytes);
let texts: Vec<&str> = after.iter().map(|i| i.text.as_str()).collect();
let text = page_text(&after);
assert_eq!(text.matches("Hi").count(), 2, "{order:?}: {texts:?}");
assert!(
!text.contains("AB") && !text.contains("CA"),
"{order:?}: {texts:?}"
);
let origins = glyph_origins(&after, "H");
assert_eq!(origins.len(), 2, "{order:?}");
assert!(has_origin(&origins, island_named(&before, "AB").baseline));
assert!(has_origin(&origins, island_named(&before, "CA").baseline));
assert_eq!(
island_named(&after, "CD").quads,
island_named(&before, "CD").quads,
"{order:?}"
);
if order[0] == "AB" {
assert!(
after.iter().any(|i| i.text.matches("Hi").count() == 2),
"{texts:?}"
);
}
}
}
fn last_stream_ref(bytes: &[u8]) -> (u32, u16) {
let doc = Document::load_mem(bytes).expect("reopen");
let id = last_content_stream(&doc, 1);
(id.0, id.1)
}
}