1use lopdf::content::{Content, Operation};
89use lopdf::{dictionary, Dictionary, Document, Object, ObjectId, Stream, StringFormat};
90use serde::Deserialize;
91
92use crate::font::{build_to_unicode_cmap, subset_for_pdf};
93use crate::inspect::{
94 self, content_stream_ids, encode_middle_for_font, is_line_moving_show, walk_page_graph,
95 ContentBudget, ReencodeFailure,
96};
97use crate::model::{
98 document_sha256, effective_pdf_edit_commands, ComposedCandidate, GapNumber, GlyphSourceAddress,
99 Matrix, PdfColor, PdfEditCommand, PdfEditError, PdfFontResolver, PdfOcrBackgroundProof,
100 PdfPageGraph, PdfPoint, PdfRect, PdfSourceLocator, PdfSourceSpan, PdfTextAppearance,
101 PdfTextEditState, PdfTextIsland, ResolvedPdfFont, RunItem,
102};
103
104const OVERFLOW_EPSILON_PT: f64 = 0.05;
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117enum WriteStrategy {
118 ExactOperandPatch,
119 LocalTextObjectRewrite,
120}
121
122fn select_replace_strategy(
126 before: &PdfTextEditState,
127 after: &PdfTextEditState,
128 in_place_editable: bool,
129) -> WriteStrategy {
130 let same_font_encoding = before.appearance == after.appearance;
131
132 if same_font_encoding && in_place_editable {
133 WriteStrategy::ExactOperandPatch
134 } else {
135 WriteStrategy::LocalTextObjectRewrite
136 }
137}
138
139pub fn compose_candidate(
147 source: &[u8],
148 commands: &[PdfEditCommand],
149 fonts: &dyn PdfFontResolver,
150) -> Result<ComposedCandidate, PdfEditError> {
151 let current_sha = document_sha256(source);
152 compose_candidate_on_base(¤t_sha, source, commands, fonts)
153}
154
155pub fn compose_candidate_on_base(
162 source_sha: &str,
163 base: &[u8],
164 commands: &[PdfEditCommand],
165 fonts: &dyn PdfFontResolver,
166) -> Result<ComposedCandidate, PdfEditError> {
167 if commands.is_empty() {
168 return Ok(ComposedCandidate {
169 bytes: base.to_vec(),
170 affected_pages: Vec::new(),
171 });
172 }
173
174 let mut doc = inspect::load_pdf(base).map_err(|_| PdfEditError::MalformedContent)?;
175 if doc.is_encrypted() {
176 return Err(PdfEditError::EncryptedDocument);
177 }
178
179 let resources_before = inspect::resource_fingerprint(&doc)?;
181 let mut affected_pages: Vec<u32> = Vec::new();
182
183 let effective = effective_pdf_edit_commands(commands);
187 let mut isolated: Vec<(usize, Isolated)> = Vec::new();
188 let mut appended: Vec<(usize, AppendedRun)> = Vec::new();
189 for slot in back_to_front_order(&effective) {
190 let command = effective[slot];
191 let (page, block) = match command {
192 PdfEditCommand::ReplaceSourceText {
193 locator,
194 before,
195 after,
196 available_bounds,
197 ..
198 } => {
199 if locator.document_sha256 != source_sha {
200 return Err(PdfEditError::StaleSource);
201 }
202 let (page, block, run) = apply_replace_source_text(
203 &mut doc,
204 source_sha,
205 locator,
206 before,
207 after,
208 *available_bounds,
209 fonts,
210 )?;
211 appended.extend(run.map(|run| (slot, run)));
212 (page, block)
213 }
214 PdfEditCommand::AddText {
215 page,
216 anchor,
217 text,
218 appearance,
219 ..
220 } => apply_add_text(&mut doc, *page, *anchor, text, appearance, fonts)?,
221 PdfEditCommand::CorrectOcrLine {
222 page,
223 rect,
224 text,
225 appearance,
226 cover_color,
227 background_proof,
228 ..
229 } => apply_correct_ocr_text(
230 &mut doc,
231 *page,
232 *rect,
233 text,
234 appearance,
235 *cover_color,
236 *background_proof,
237 fonts,
238 )?,
239 };
240 if let Some(block) = block {
241 isolated.push((slot, block));
242 }
243 if !affected_pages.contains(&page) {
244 affected_pages.push(page);
245 }
246 }
247 let streams = install_isolated_content(&mut doc, isolated)?;
248 for (slot, run) in &mut appended {
251 run.stream = streams.get(slot).copied();
252 }
253 let appended: Vec<AppendedRun> = appended.into_iter().map(|(_, run)| run).collect();
254 verify_appended_runs(&doc, &appended)?;
255
256 if !inspect::resources_preserved(&resources_before, &inspect::resource_fingerprint(&doc)?) {
260 return Err(PdfEditError::VerificationFailed);
261 }
262
263 doc.compress();
264 let mut bytes = Vec::new();
265 doc.save_to(&mut bytes)
266 .map_err(|_| PdfEditError::MalformedContent)?;
267
268 affected_pages.sort_unstable();
269 Ok(ComposedCandidate {
270 bytes,
271 affected_pages,
272 })
273}
274
275type Isolated = (ObjectId, Vec<Operation>);
283
284fn command_address(command: &PdfEditCommand) -> (u32, u32, u32, u32) {
287 match command {
288 PdfEditCommand::ReplaceSourceText { locator, .. } => locator
289 .spans
290 .iter()
291 .map(|span| {
292 (
293 span.contents_index,
294 span.operator_index,
295 span.operand_index,
296 span.glyph_start,
297 )
298 })
299 .min()
300 .unwrap_or_default(),
301 _ => (0, 0, 0, 0),
302 }
303}
304
305fn back_to_front_order(commands: &[&PdfEditCommand]) -> Vec<usize> {
312 let mut by_page: std::collections::BTreeMap<u32, Vec<usize>> =
313 std::collections::BTreeMap::new();
314 for (index, command) in commands.iter().enumerate() {
315 if let PdfEditCommand::ReplaceSourceText { locator, .. } = command {
316 by_page.entry(locator.page).or_default().push(index);
317 }
318 }
319 for list in by_page.values_mut() {
320 list.sort_by(|a, b| {
321 command_address(commands[*b])
322 .cmp(&command_address(commands[*a]))
323 .then(a.cmp(b))
324 });
325 }
326 let mut taken: std::collections::BTreeMap<u32, usize> = std::collections::BTreeMap::new();
327 commands
328 .iter()
329 .enumerate()
330 .map(|(index, command)| match command {
331 PdfEditCommand::ReplaceSourceText { locator, .. } => {
332 let next = taken.entry(locator.page).or_insert(0);
333 let chosen = by_page[&locator.page][*next];
334 *next += 1;
335 chosen
336 }
337 _ => index,
338 })
339 .collect()
340}
341
342#[allow(clippy::too_many_arguments)]
343fn apply_replace_source_text(
344 doc: &mut Document,
345 current_sha: &str,
346 locator: &PdfSourceLocator,
347 before: &PdfTextEditState,
348 after: &PdfTextEditState,
349 available_bounds: PdfRect,
350 fonts: &dyn PdfFontResolver,
351) -> Result<(u32, Option<Isolated>, Option<AppendedRun>), PdfEditError> {
352 if locator.spans.is_empty() {
353 return Err(PdfEditError::UnsupportedEncoding);
354 }
355
356 let graph = walk_page_graph(doc, current_sha, locator.page)?;
360 let island = graph
361 .islands
362 .iter()
363 .find(|isl| isl.locator.island_digest == locator.island_digest)
364 .ok_or(PdfEditError::SourceDigestMismatch)?;
365 if island.locator.spans.is_empty() {
369 return Err(PdfEditError::UnsupportedEncoding);
370 }
371 if locator.spans.len() != island.locator.spans.len()
377 || !locator
378 .spans
379 .iter()
380 .zip(&island.locator.spans)
381 .all(|(given, current)| spans_equal(given, current))
382 {
383 return Err(PdfEditError::SourceDigestMismatch);
384 }
385 let spans: &[PdfSourceSpan] = &island.locator.spans;
386
387 let stream_ref = spans[0].stream_ref;
390 let contents_index = spans[0].contents_index;
391 if !spans
392 .iter()
393 .all(|s| s.stream_ref == stream_ref && s.contents_index == contents_index)
394 {
395 return Err(PdfEditError::UnsupportedEncoding);
396 }
397 let stream_id: ObjectId = (stream_ref.0, stream_ref.1);
398 let page_id = pages_lookup(doc, locator.page)?;
399
400 if before.appearance == after.appearance && !island.editable {
406 return Err(PdfEditError::UnsupportedEncoding);
407 }
408
409 isolate_shared_content_stream(doc, page_id, stream_id)?;
412
413 let (block, run) = match select_replace_strategy(before, after, island.editable) {
414 WriteStrategy::ExactOperandPatch => {
415 apply_exact_operand_patch(
416 doc,
417 page_id,
418 locator.page,
419 stream_id,
420 island,
421 after,
422 available_bounds,
423 )?;
424 (None, None)
425 }
426 WriteStrategy::LocalTextObjectRewrite => {
427 let (block, run) = apply_local_text_object_rewrite(
428 doc,
429 locator.page,
430 page_id,
431 stream_id,
432 island,
433 after,
434 available_bounds,
435 fonts,
436 )?;
437 (Some(block), run)
438 }
439 };
440
441 Ok((locator.page, block, run))
442}
443
444const MAX_ISOLATED_REFERENCES: usize = 1 << 20;
447
448fn isolate_shared_content_stream(
459 doc: &mut Document,
460 page_id: ObjectId,
461 stream_id: ObjectId,
462) -> Result<(), PdfEditError> {
463 let mut sharers: Vec<(ObjectId, Vec<ObjectId>)> = Vec::new();
464 let mut references = 0usize;
465 for other in doc.get_pages().into_values() {
466 if other == page_id {
467 continue;
468 }
469 let Ok(Object::Dictionary(dict)) = doc.get_object(other) else {
470 continue;
471 };
472 let Ok(contents) = dict.get(b"Contents") else {
473 continue;
474 };
475 let ids = content_stream_ids(doc, contents)?;
476 if ids.contains(&stream_id) {
477 references = references
478 .checked_add(ids.len())
479 .filter(|total| *total <= MAX_ISOLATED_REFERENCES)
480 .ok_or(PdfEditError::MalformedContent)?;
481 sharers.push((other, ids));
482 }
483 }
484 if sharers.is_empty() {
485 return Ok(());
486 }
487 let original = doc
488 .get_object(stream_id)
489 .map_err(|_| PdfEditError::MalformedContent)?
490 .clone();
491 let copy_id = doc.add_object(original);
492 for (other, ids) in sharers {
493 let array = Object::Array(
494 ids.into_iter()
495 .map(|id| Object::Reference(if id == stream_id { copy_id } else { id }))
496 .collect(),
497 );
498 if let Ok(Object::Dictionary(dict)) = doc.get_object_mut(other) {
499 dict.set("Contents", array);
500 }
501 }
502 Ok(())
503}
504
505fn spans_equal(a: &PdfSourceSpan, b: &PdfSourceSpan) -> bool {
508 a.stream_ref == b.stream_ref
509 && a.contents_index == b.contents_index
510 && a.operator_index == b.operator_index
511 && a.operand_index == b.operand_index
512 && a.glyph_start == b.glyph_start
513 && a.glyph_end == b.glyph_end
514 && a.expected_digest == b.expected_digest
515}
516
517fn pages_lookup(doc: &Document, page: u32) -> Result<ObjectId, PdfEditError> {
518 doc.get_pages()
519 .get(&page)
520 .copied()
521 .ok_or(PdfEditError::MalformedContent)
522}
523
524struct RunEdit {
530 first: usize,
532 end: usize,
533 middle: String,
534}
535
536fn plan_run_edit(run: &[RunItem], old: &[char], new: &[char]) -> Result<RunEdit, PdfEditError> {
539 let mut prefix = 0;
540 while prefix < old.len() && prefix < new.len() && old[prefix] == new[prefix] {
541 prefix += 1;
542 }
543 let mut suffix = 0;
544 while suffix < old.len() - prefix
545 && suffix < new.len() - prefix
546 && old[old.len() - 1 - suffix] == new[new.len() - 1 - suffix]
547 {
548 suffix += 1;
549 }
550 let old_end = old.len() - suffix;
551
552 let mut bounds: Vec<(usize, usize)> = Vec::with_capacity(run.len());
553 let mut at = 0;
554 for item in run {
555 let n = item.text.chars().count();
556 bounds.push((at, at + n));
557 at += n;
558 }
559 if at != old.len() {
560 return Err(PdfEditError::UnsupportedEncoding);
561 }
562 let is_glyph = |i: usize| run[i].glyph.is_some();
563
564 let (mut first, mut end);
565 if prefix < old_end {
566 first = bounds
567 .iter()
568 .position(|&(s, e)| s <= prefix && prefix < e)
569 .ok_or(PdfEditError::UnsupportedEncoding)?;
570 end = bounds
571 .iter()
572 .rposition(|&(s, _)| s < old_end)
573 .ok_or(PdfEditError::UnsupportedEncoding)?
574 + 1;
575 } else {
576 let before = (prefix > 0)
578 .then(|| bounds.iter().position(|&(s, e)| s < prefix && prefix <= e))
579 .flatten()
580 .filter(|&i| is_glyph(i));
581 let after = bounds
582 .iter()
583 .position(|&(s, e)| s <= prefix && prefix < e)
584 .filter(|&i| is_glyph(i));
585 let anchor = before.or(after).ok_or(PdfEditError::UnsupportedEncoding)?;
586 first = anchor;
587 end = anchor + 1;
588 }
589 if !(first..end).any(is_glyph) {
590 if first > 0 && is_glyph(first - 1) {
591 first -= 1;
592 } else if end < run.len() && is_glyph(end) {
593 end += 1;
594 } else {
595 return Err(PdfEditError::UnsupportedEncoding);
596 }
597 }
598 let region_start = bounds[first].0;
599 let region_end = bounds[end - 1].1;
600 let middle: String = new[region_start..new.len() - (old.len() - region_end)]
601 .iter()
602 .collect();
603 Ok(RunEdit { first, end, middle })
604}
605
606fn gap_sum(item: &RunItem) -> Option<f64> {
607 item.gap_numbers
608 .as_ref()
609 .map(|numbers| numbers.iter().map(|n| n.value).sum())
610}
611
612fn number_object(value: f64) -> Object {
613 if value.fract() == 0.0 && value.abs() < 1e9 {
614 Object::Integer(value as i64)
615 } else {
616 Object::Real(value as f32)
617 }
618}
619
620fn zero_tj_number(content: &mut Content, number: &GapNumber) -> Result<(), PdfEditError> {
622 let op = content
623 .operations
624 .get_mut(number.operator_index as usize)
625 .ok_or(PdfEditError::SourceDigestMismatch)?;
626 if op.operator != "TJ" {
627 return Err(PdfEditError::SourceDigestMismatch);
628 }
629 let Some(Object::Array(items)) = op.operands.get_mut(0) else {
630 return Err(PdfEditError::SourceDigestMismatch);
631 };
632 let item = items
633 .get_mut(number.item_index as usize)
634 .ok_or(PdfEditError::SourceDigestMismatch)?;
635 *item = Object::Integer(0);
636 Ok(())
637}
638
639fn write_pieces(
643 content: &mut Content,
644 slot: &GlyphSourceAddress,
645 range: (u32, u32),
646 pieces: &[Vec<u8>],
647 gaps: &[f64],
648) -> Result<(), PdfEditError> {
649 if gaps.is_empty() {
650 return splice_operand_bytes(
651 content,
652 slot.operator_index,
653 slot.operand_index,
654 range.0,
655 range.1,
656 &pieces[0],
657 );
658 }
659 let cannot_split = || PdfEditError::MissingGlyphs(vec![' ']);
661 let op = content
662 .operations
663 .get_mut(slot.operator_index as usize)
664 .ok_or(PdfEditError::SourceDigestMismatch)?;
665 let (bytes, format) = match (op.operator.as_str(), op.operands.first()) {
666 ("Tj", Some(Object::String(bytes, format))) => (bytes.clone(), *format),
667 ("TJ", Some(Object::Array(items))) => match items.get(slot.operand_index as usize) {
668 Some(Object::String(bytes, format)) => (bytes.clone(), *format),
669 _ => return Err(PdfEditError::SourceDigestMismatch),
670 },
671 _ => return Err(cannot_split()),
672 };
673 let (start, end) = (range.0 as usize, range.1 as usize);
674 if start > end || end > bytes.len() {
675 return Err(PdfEditError::SourceDigestMismatch);
676 }
677 let mut new_items: Vec<Object> = Vec::with_capacity(pieces.len() * 2);
678 for (k, piece) in pieces.iter().enumerate() {
679 let mut text: Vec<u8> = Vec::new();
680 if k == 0 {
681 text.extend_from_slice(&bytes[..start]);
682 }
683 text.extend_from_slice(piece);
684 if k + 1 == pieces.len() {
685 text.extend_from_slice(&bytes[end..]);
686 }
687 if k > 0 {
688 new_items.push(number_object(gaps[k - 1]));
689 }
690 new_items.push(Object::String(text, format));
691 }
692 if op.operator == "Tj" {
693 op.operator = "TJ".to_string();
694 op.operands = vec![Object::Array(new_items)];
695 } else if let Some(Object::Array(items)) = op.operands.get_mut(0) {
696 let at = slot.operand_index as usize;
697 items.splice(at..=at, new_items);
698 }
699 Ok(())
700}
701
702fn apply_exact_operand_patch(
703 doc: &mut Document,
704 page_id: ObjectId,
705 page: u32,
706 stream_id: ObjectId,
707 island: &PdfTextIsland,
708 after: &PdfTextEditState,
709 available_bounds: PdfRect,
710) -> Result<(), PdfEditError> {
711 let layout = island.layout.ok_or(PdfEditError::UnsupportedEncoding)?;
712 let old: Vec<char> = island.text.chars().collect();
713 let new: Vec<char> = after.text.chars().collect();
714 if old == new {
715 return Ok(());
716 }
717 let plan = plan_run_edit(&island.run, &old, &new)?;
718 let region = &island.run[plan.first..plan.end];
719
720 let middle = encode_middle_for_font(doc, page_id, &island.appearance.font_id, &plan.middle)
725 .map_err(|failure| match failure {
726 ReencodeFailure::Blocked => PdfEditError::UnsupportedEncoding,
727 ReencodeFailure::MissingGlyphs(chars) => PdfEditError::MissingGlyphs(chars),
728 })?;
729
730 let size = layout.font_size;
734 let th = layout.horizontal_scale;
735 let gap_text = |value: f64| -(value / 1000.0) * size * th;
736 let mut gap_values: Vec<f64> = Vec::new();
737 if !middle.space_glyph {
738 if middle.pieces.len() > 1 && middle.pieces.iter().any(|piece| piece.char_count() == 0) {
742 return Err(PdfEditError::MissingGlyphs(vec![' ']));
743 }
744 let replaced: Vec<f64> = region.iter().filter_map(gap_sum).collect();
745 let reference = island.run.iter().find_map(gap_sum);
746 for k in 0..middle.pieces.len().saturating_sub(1) {
747 let value = replaced
748 .get(k)
749 .copied()
750 .or(reference)
751 .ok_or_else(|| PdfEditError::MissingGlyphs(vec![' ']))?;
752 gap_values.push(value);
753 }
754 }
755
756 let region_glyphs: Vec<&RunItem> = region.iter().filter(|i| i.glyph.is_some()).collect();
761 let code_len = |i: &&RunItem| {
762 i.glyph
763 .as_ref()
764 .map_or(0, |g| (g.glyph_end - g.glyph_start) as usize)
765 };
766 let positional = !region_glyphs.is_empty()
767 && region_glyphs.len() == region.len()
768 && region_glyphs.iter().all(|i| i.text.chars().count() == 1)
769 && middle.pieces.len() == 1
770 && gap_values.is_empty()
771 && middle.pieces[0].char_count() == region_glyphs.len()
772 && region_glyphs
773 .iter()
774 .all(|i| code_len(i) * region_glyphs.len() == middle.pieces[0].bytes.len());
775
776 let scale = layout.axis.0.hypot(layout.axis.1);
778 if !(scale.is_finite() && scale > 0.0) {
779 return Err(PdfEditError::UnsupportedEncoding);
780 }
781 let advance = |piece: &inspect::EncodedRun| {
782 piece.advance(size, layout.char_spacing, layout.word_spacing, th)
783 };
784 let mut new_region_text: f64 = middle.pieces.iter().map(advance).sum();
785 for (piece, value) in middle.pieces.iter().zip(&gap_values) {
788 new_region_text += layout.char_spacing * th + gap_text(*value);
789 if piece.ends_with_word_space() {
790 new_region_text += layout.word_spacing * th;
791 }
792 }
793 let island_len = island.run.last().map_or(0.0, |i| i.end) - island.run[0].start;
794 let old_region = region[region.len() - 1].end - region[0].start;
795 let region_reaches_island_end = plan.end == island.run.len();
796 let new_len_text = if positional {
797 let old_widths: f64 = region.iter().map(|i| i.end - i.start).sum();
804 let new_widths = middle.pieces[0].advance(size, 0.0, 0.0, th);
805 let mut tw_delta = 0.0;
806 for (k, item) in region_glyphs.iter().enumerate() {
807 if region_reaches_island_end && k + 1 == region_glyphs.len() {
808 continue;
809 }
810 let was_space = item.word_space;
811 let is_space = middle.pieces[0].word_space_at(k);
812 tw_delta += layout.word_spacing * th * (f64::from(is_space) - f64::from(was_space));
813 }
814 island_len / scale + new_widths - old_widths / scale + tw_delta
815 } else {
816 let trailing_tw = if region_reaches_island_end {
819 0.0
820 } else {
821 let was_space = region
822 .last()
823 .is_some_and(|i| i.glyph.is_some() && i.word_space);
824 let is_space = middle
825 .pieces
826 .last()
827 .is_some_and(|piece| piece.ends_with_word_space());
828 layout.word_spacing * th * (f64::from(is_space) - f64::from(was_space))
829 };
830 (island_len - old_region) / scale + new_region_text + trailing_tw
831 };
832 validate_layout_advance(new_len_text, layout.axis, available_bounds)?;
833
834 let mut content = decode_stream_content(doc, stream_id, &mut ContentBudget::new())?;
835
836 if positional {
837 let bytes = &middle.pieces[0].bytes;
838 let width = bytes.len() / region_glyphs.len();
839 for (item, chunk) in region_glyphs.iter().zip(bytes.chunks(width)) {
840 let g = item.glyph.as_ref().expect("filtered to glyphs");
841 splice_operand_bytes(
842 &mut content,
843 g.operator_index,
844 g.operand_index,
845 g.glyph_start,
846 g.glyph_end,
847 chunk,
848 )?;
849 }
850 write_stream_content(doc, stream_id, &content)?;
851 return verify_written(doc, page, island, &after.text, available_bounds);
852 }
853
854 for item in region {
857 for number in item.gap_numbers.iter().flatten() {
858 zero_tj_number(&mut content, number)?;
859 }
860 }
861 let glyphs: Vec<&GlyphSourceAddress> = region.iter().filter_map(|i| i.glyph.as_ref()).collect();
864 let slot = glyphs[0];
865 zero_numbers_between(&mut content, slot, glyphs[glyphs.len() - 1])?;
870 let same_operand = |g: &GlyphSourceAddress| {
871 g.operator_index == slot.operator_index && g.operand_index == slot.operand_index
872 };
873 for g in glyphs.iter().rev().filter(|g| !same_operand(g)) {
874 splice_operand_bytes(
875 &mut content,
876 g.operator_index,
877 g.operand_index,
878 g.glyph_start,
879 g.glyph_end,
880 &[],
881 )?;
882 }
883 let slot_end = glyphs
885 .iter()
886 .filter(|g| same_operand(g))
887 .map(|g| g.glyph_end)
888 .max()
889 .unwrap_or(slot.glyph_end);
890 let pieces: Vec<Vec<u8>> = middle.pieces.iter().map(|p| p.bytes.clone()).collect();
891 write_pieces(
892 &mut content,
893 slot,
894 (slot.glyph_start, slot_end),
895 &pieces,
896 &gap_values,
897 )?;
898 write_stream_content(doc, stream_id, &content)?;
899 verify_written(doc, page, island, &after.text, available_bounds)
900}
901
902fn zero_numbers_between(
906 content: &mut Content,
907 first: &GlyphSourceAddress,
908 last: &GlyphSourceAddress,
909) -> Result<(), PdfEditError> {
910 if (last.operator_index, last.operand_index) < (first.operator_index, first.operand_index) {
911 return Err(PdfEditError::SourceDigestMismatch);
912 }
913 for op_index in first.operator_index..=last.operator_index {
914 let op = content
915 .operations
916 .get_mut(op_index as usize)
917 .ok_or(PdfEditError::SourceDigestMismatch)?;
918 if op.operator != "TJ" {
919 continue;
920 }
921 let Some(Object::Array(items)) = op.operands.get_mut(0) else {
922 continue;
923 };
924 let from = if op_index == first.operator_index {
925 first.operand_index as usize + 1
926 } else {
927 0
928 };
929 let to = if op_index == last.operator_index {
930 (last.operand_index as usize).min(items.len())
931 } else {
932 items.len()
933 };
934 for item in items.iter_mut().take(to).skip(from) {
935 if matches!(item, Object::Integer(_) | Object::Real(_)) {
936 *item = Object::Integer(0);
937 }
938 }
939 }
940 Ok(())
941}
942
943fn verify_written(
950 doc: &Document,
951 page: u32,
952 island: &PdfTextIsland,
953 wanted: &str,
954 available: PdfRect,
955) -> Result<(), PdfEditError> {
956 if wanted.is_empty() {
957 return Ok(());
958 }
959 let first = island
960 .locator
961 .spans
962 .first()
963 .ok_or(PdfEditError::VerificationFailed)?;
964 let graph = walk_page_graph(doc, "", page).map_err(|_| PdfEditError::VerificationFailed)?;
965 let written = graph
966 .islands
967 .iter()
968 .find(|candidate| {
969 candidate.locator.spans.first().is_some_and(|span| {
970 span.stream_ref == first.stream_ref
971 && span.contents_index == first.contents_index
972 && span.operator_index == first.operator_index
973 && span.operand_index == first.operand_index
974 && span.glyph_start == first.glyph_start
975 })
976 })
977 .ok_or(PdfEditError::VerificationFailed)?;
978
979 let wanted_chars = wanted.chars().count();
982 let mut text = String::new();
983 let mut last_item = None;
984 for (index, item) in written.run.iter().enumerate() {
985 text.push_str(&item.text);
986 if text.chars().count() >= wanted_chars {
987 last_item = Some(index);
988 break;
989 }
990 }
991 let last_item = last_item.ok_or(PdfEditError::VerificationFailed)?;
992 if text != wanted {
993 return Err(PdfEditError::VerificationFailed);
994 }
995 let layout = written.layout.ok_or(PdfEditError::VerificationFailed)?;
996 let scale = layout.axis.0.hypot(layout.axis.1);
997 if !(scale.is_finite() && scale > 0.0) {
998 return Err(PdfEditError::VerificationFailed);
999 }
1000 let extent = written.run[last_item].end - written.run[0].start;
1001 validate_layout_advance(extent / scale, layout.axis, available)
1002}
1003
1004fn validate_layout_advance(
1010 advance: f64,
1011 axis: (f64, f64),
1012 available: PdfRect,
1013) -> Result<(), PdfEditError> {
1014 let width = (advance * axis.0).abs();
1015 let height = (advance * axis.1).abs();
1016 if !(width.is_finite() && height.is_finite())
1017 || width > available.width + OVERFLOW_EPSILON_PT
1018 || height > available.height + OVERFLOW_EPSILON_PT
1019 {
1020 return Err(PdfEditError::TextOverflow);
1021 }
1022 Ok(())
1023}
1024
1025#[allow(clippy::too_many_arguments)]
1030fn apply_local_text_object_rewrite(
1031 doc: &mut Document,
1032 page: u32,
1033 page_id: ObjectId,
1034 stream_id: ObjectId,
1035 island: &PdfTextIsland,
1036 after: &PdfTextEditState,
1037 available_bounds: PdfRect,
1038 fonts: &dyn PdfFontResolver,
1039) -> Result<(Isolated, Option<AppendedRun>), PdfEditError> {
1040 let resolved = fonts.resolve(&after.appearance.font_id)?;
1041 fonts.glyphs_for_text(&resolved, &after.text)?;
1043
1044 let layout = island.layout.ok_or(PdfEditError::UnsupportedEncoding)?;
1048 let placement = layout.placement;
1049 let finite = [
1050 placement.a,
1051 placement.b,
1052 placement.c,
1053 placement.d,
1054 placement.e,
1055 placement.f,
1056 ]
1057 .iter()
1058 .all(|value| value.is_finite());
1059 let scale = layout.axis.0.hypot(layout.axis.1);
1060 if !(finite && scale > 0.0 && after.appearance.size_pt.is_finite()) {
1061 return Err(PdfEditError::UnsupportedTransform);
1062 }
1063
1064 let advance_1000 = measure_advance_1000(&resolved, &after.text);
1069 validate_layout_advance(
1070 advance_1000 / 1000.0 * after.appearance.size_pt,
1071 layout.axis,
1072 available_bounds,
1073 )?;
1074
1075 let removed = removed_glyphs(island)?;
1079
1080 let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, &after.text)?;
1081 let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
1082
1083 let mut content = decode_stream_content(doc, stream_id, &mut ContentBudget::new())?;
1084 remove_glyphs_keeping_pen(&mut content, removed)?;
1085 write_stream_content(doc, stream_id, &content)?;
1086
1087 let operations =
1092 build_text_object_operations(&resource_name, &after.appearance, placement, &cid_codes);
1093 let run = (!after.text.is_empty()).then_some(AppendedRun {
1095 page,
1096 stream: None,
1097 placement,
1098 size: after.appearance.size_pt,
1099 });
1100 Ok(((page_id, operations), run))
1101}
1102
1103struct AppendedRun {
1107 page: u32,
1108 stream: Option<ObjectId>,
1113 placement: Matrix,
1115 size: f64,
1117}
1118
1119const APPENDED_ORIGIN_TOLERANCE_PT: f64 = 0.5;
1121
1122const APPENDED_SIZE_TOLERANCE: f64 = 0.02;
1126
1127fn verify_appended_runs(doc: &Document, runs: &[AppendedRun]) -> Result<(), PdfEditError> {
1138 use std::collections::btree_map::Entry;
1139 let mut graphs: std::collections::BTreeMap<u32, PdfPageGraph> =
1140 std::collections::BTreeMap::new();
1141 for run in runs {
1142 let stream = run.stream.ok_or(PdfEditError::VerificationFailed)?;
1143 let graph = match graphs.entry(run.page) {
1144 Entry::Occupied(entry) => entry.into_mut(),
1145 Entry::Vacant(entry) => entry.insert(
1146 walk_page_graph(doc, "", run.page).map_err(|_| PdfEditError::VerificationFailed)?,
1147 ),
1148 };
1149 let written = graph
1150 .islands
1151 .iter()
1152 .flat_map(|island| island.run.iter())
1153 .find(|item| {
1154 item.glyph
1155 .is_some_and(|glyph| glyph.stream_ref == (stream.0, stream.1))
1156 })
1157 .and_then(|item| item.placement)
1158 .ok_or(PdfEditError::VerificationFailed)?;
1159 if !appended_run_placed(run, written) {
1160 return Err(PdfEditError::VerificationFailed);
1161 }
1162 }
1163 Ok(())
1164}
1165
1166fn appended_run_placed(run: &AppendedRun, written: (Matrix, f64)) -> bool {
1172 let (want, (have, have_size)) = (run.placement, written);
1173 let origin = (have.e - want.e).hypot(have.f - want.f);
1174 let em_x = |m: Matrix, size: f64| (m.a * size, m.b * size);
1175 let em_y = |m: Matrix, size: f64| (m.c * size, m.d * size);
1176 let near = |want: (f64, f64), have: (f64, f64)| {
1177 (have.0 - want.0).hypot(have.1 - want.1) <= APPENDED_SIZE_TOLERANCE * want.0.hypot(want.1)
1178 };
1179 origin <= APPENDED_ORIGIN_TOLERANCE_PT
1180 && near(em_x(want, run.size), em_x(have, have_size))
1181 && near(em_y(want, run.size), em_y(have, have_size))
1182}
1183
1184struct RemovedGlyph {
1187 operator_index: u32,
1188 operand_index: u32,
1189 start: u32,
1190 end: u32,
1191 adjust: f64,
1192}
1193
1194fn removed_glyphs(island: &PdfTextIsland) -> Result<Vec<RemovedGlyph>, PdfEditError> {
1197 let mut removed = Vec::new();
1198 for item in &island.run {
1199 let Some(glyph) = &item.glyph else { continue };
1200 removed.push(RemovedGlyph {
1201 operator_index: glyph.operator_index,
1202 operand_index: glyph.operand_index,
1203 start: glyph.glyph_start,
1204 end: glyph.glyph_end,
1205 adjust: item.pen_adjust.ok_or(PdfEditError::UnsupportedEncoding)?,
1206 });
1207 }
1208 Ok(removed)
1209}
1210
1211fn remove_glyphs_keeping_pen(
1220 content: &mut Content,
1221 mut removed: Vec<RemovedGlyph>,
1222) -> Result<(), PdfEditError> {
1223 removed.sort_by_key(|g| (g.operator_index, g.operand_index, g.start));
1224 let mut end = removed.len();
1225 while end > 0 {
1226 let operator_index = removed[end - 1].operator_index;
1227 let mut begin = end;
1228 while begin > 0 && removed[begin - 1].operator_index == operator_index {
1229 begin -= 1;
1230 }
1231 let glyphs = &removed[begin..end];
1232 let op = content
1233 .operations
1234 .get_mut(operator_index as usize)
1235 .ok_or(PdfEditError::SourceDigestMismatch)?;
1236 let operator = op.operator.clone();
1237 match operator.as_str() {
1238 "TJ" => {
1239 let Some(Object::Array(items)) = op.operands.get_mut(0) else {
1240 return Err(PdfEditError::SourceDigestMismatch);
1241 };
1242 let mut hi = glyphs.len();
1243 while hi > 0 {
1244 let item_index = glyphs[hi - 1].operand_index;
1245 let mut lo = hi;
1246 while lo > 0 && glyphs[lo - 1].operand_index == item_index {
1247 lo -= 1;
1248 }
1249 let replacement = match items.get(item_index as usize) {
1250 Some(Object::String(bytes, format)) => {
1251 pieces_around_removed(bytes, *format, &glyphs[lo..hi])?
1252 }
1253 _ => return Err(PdfEditError::SourceDigestMismatch),
1254 };
1255 let at = item_index as usize;
1256 items.splice(at..=at, replacement).for_each(drop);
1257 hi = lo;
1258 }
1259 }
1260 "Tj" => {
1261 let replacement = match op.operands.first() {
1262 Some(Object::String(bytes, format)) => {
1263 pieces_around_removed(bytes, *format, glyphs)?
1264 }
1265 _ => return Err(PdfEditError::SourceDigestMismatch),
1266 };
1267 op.operator = "TJ".to_string();
1268 op.operands = vec![Object::Array(replacement)];
1269 }
1270 other if is_line_moving_show(other) => return Err(PdfEditError::UnsupportedEncoding),
1271 _ => return Err(PdfEditError::SourceDigestMismatch),
1272 }
1273 end = begin;
1274 }
1275 Ok(())
1276}
1277
1278fn pieces_around_removed(
1281 bytes: &[u8],
1282 format: StringFormat,
1283 glyphs: &[RemovedGlyph],
1284) -> Result<Vec<Object>, PdfEditError> {
1285 let mut out = Vec::with_capacity(glyphs.len() * 2 + 1);
1286 let mut at = 0usize;
1287 for glyph in glyphs {
1288 let (start, end) = (glyph.start as usize, glyph.end as usize);
1289 if start < at || end < start || end > bytes.len() {
1290 return Err(PdfEditError::SourceDigestMismatch);
1291 }
1292 if start > at {
1293 out.push(Object::String(bytes[at..start].to_vec(), format));
1294 }
1295 out.push(number_object(glyph.adjust));
1296 at = end;
1297 }
1298 if at < bytes.len() {
1299 out.push(Object::String(bytes[at..].to_vec(), format));
1300 }
1301 Ok(out)
1302}
1303
1304fn measure_advance_1000(resolved: &ResolvedPdfFont, text: &str) -> f64 {
1305 let Ok(face) = ttf_parser::Face::parse(&resolved.bytes, resolved.face_index) else {
1306 return text.chars().count() as f64 * 500.0;
1307 };
1308 let units_per_em = face.units_per_em() as f64;
1309 if units_per_em <= 0.0 {
1310 return text.chars().count() as f64 * 500.0;
1311 }
1312 text.chars()
1313 .filter_map(|c| face.glyph_index(c))
1314 .map(|gid| face.glyph_hor_advance(gid).unwrap_or(0) as f64 * 1000.0 / units_per_em)
1315 .sum()
1316}
1317
1318fn apply_add_text(
1323 doc: &mut Document,
1324 page: u32,
1325 anchor: PdfPoint,
1326 text: &str,
1327 appearance: &PdfTextAppearance,
1328 fonts: &dyn PdfFontResolver,
1329) -> Result<(u32, Option<Isolated>), PdfEditError> {
1330 if doc.is_encrypted() {
1331 return Err(PdfEditError::EncryptedDocument);
1332 }
1333 let page_id = pages_lookup(doc, page)?;
1334
1335 let resolved = fonts.resolve(&appearance.font_id)?;
1336 fonts.glyphs_for_text(&resolved, text)?;
1337
1338 let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, text)?;
1339 let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
1340
1341 Ok((
1342 page,
1343 Some((
1344 page_id,
1345 build_text_object_operations(
1348 &resource_name,
1349 appearance,
1350 Matrix::translation(anchor.x, anchor.y),
1351 &cid_codes,
1352 ),
1353 )),
1354 ))
1355}
1356
1357#[allow(clippy::too_many_arguments)]
1360fn apply_correct_ocr_text(
1361 doc: &mut Document,
1362 page: u32,
1363 rect: PdfRect,
1364 text: &str,
1365 appearance: &PdfTextAppearance,
1366 cover_color: PdfColor,
1367 background_proof: PdfOcrBackgroundProof,
1368 fonts: &dyn PdfFontResolver,
1369) -> Result<(u32, Option<Isolated>), PdfEditError> {
1370 let page_id = pages_lookup(doc, page)?;
1371 let page_bounds = page_bounds(doc, page_id)?;
1372 validate_ocr_correction(
1373 page_bounds,
1374 rect,
1375 text,
1376 appearance,
1377 cover_color,
1378 background_proof,
1379 page_rotation(doc, page_id)?,
1380 )?;
1381
1382 let resolved = fonts.resolve(&appearance.font_id)?;
1383 fonts.glyphs_for_text(&resolved, text)?;
1384 let width = measure_advance_1000(&resolved, text) / 1000.0 * appearance.size_pt;
1385 if width > rect.width || appearance.size_pt > rect.height {
1386 return Err(PdfEditError::TextOverflow);
1387 }
1388 let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, text)?;
1389 let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
1390
1391 Ok((
1392 page,
1393 Some((
1394 page_id,
1395 build_ocr_correction_operations(
1396 &resource_name,
1397 appearance,
1398 rect,
1399 cover_color,
1400 &cid_codes,
1401 ),
1402 )),
1403 ))
1404}
1405
1406fn validate_ocr_correction(
1407 page_bounds: PdfRect,
1408 rect: PdfRect,
1409 text: &str,
1410 appearance: &PdfTextAppearance,
1411 cover_color: PdfColor,
1412 background_proof: PdfOcrBackgroundProof,
1413 rotation: i64,
1414) -> Result<(), PdfEditError> {
1415 if rotation != 0 {
1416 return Err(PdfEditError::UnsupportedTransform);
1417 }
1418 if !matches!(background_proof, PdfOcrBackgroundProof::NearUniform)
1419 || !is_finite_color(cover_color)
1420 || cover_color.alpha < 1.0
1421 || !is_finite_color(appearance.color)
1422 || !appearance.size_pt.is_finite()
1423 || appearance.size_pt <= 0.0
1424 || text.is_empty()
1425 || !is_finite_rect(page_bounds)
1426 || page_bounds.width <= 0.0
1427 || page_bounds.height <= 0.0
1428 || !is_finite_rect(rect)
1429 || rect.width <= 0.0
1430 || rect.height <= 0.0
1431 || !page_bounds.contains(rect)
1432 {
1433 return Err(PdfEditError::TextOverflow);
1434 }
1435 Ok(())
1436}
1437
1438fn is_finite_rect(rect: PdfRect) -> bool {
1439 rect.x.is_finite() && rect.y.is_finite() && rect.width.is_finite() && rect.height.is_finite()
1440}
1441
1442fn is_finite_color(color: PdfColor) -> bool {
1443 color.r.is_finite()
1444 && color.g.is_finite()
1445 && color.b.is_finite()
1446 && color.alpha.is_finite()
1447 && (0.0..=1.0).contains(&color.r)
1448 && (0.0..=1.0).contains(&color.g)
1449 && (0.0..=1.0).contains(&color.b)
1450 && (0.0..=1.0).contains(&color.alpha)
1451}
1452
1453fn page_bounds(doc: &Document, page_id: ObjectId) -> Result<PdfRect, PdfEditError> {
1454 let values = inherited_page_array(doc, page_id, b"CropBox")
1455 .or_else(|| inherited_page_array(doc, page_id, b"MediaBox"))
1456 .ok_or(PdfEditError::MalformedContent)?;
1457 if values.len() != 4 {
1458 return Err(PdfEditError::MalformedContent);
1459 }
1460 let x0 = values[0];
1461 let y0 = values[1];
1462 let width = values[2] - x0;
1463 let height = values[3] - y0;
1464 Ok(PdfRect {
1465 x: x0,
1466 y: y0,
1467 width,
1468 height,
1469 })
1470}
1471
1472fn page_rotation(doc: &Document, page_id: ObjectId) -> Result<i64, PdfEditError> {
1473 Ok(inherited_page_number(doc, page_id, b"Rotate").unwrap_or(0))
1474}
1475
1476fn inherited_page_array(doc: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<Vec<f64>> {
1477 let mut seen = std::collections::HashSet::new();
1478 for _ in 0..64 {
1479 if !seen.insert(page_id) {
1480 return None; }
1482 let dict = doc.get_object(page_id).ok()?.as_dict().ok()?;
1483 if let Ok(value) = dict.get(key) {
1484 let value = match value {
1485 Object::Reference(id) => doc.get_object(*id).ok()?,
1486 value => value,
1487 };
1488 let array = value.as_array().ok()?;
1489 return array.iter().map(object_number).collect();
1490 }
1491 page_id = match dict.get(b"Parent").ok()? {
1492 Object::Reference(id) => *id,
1493 _ => return None,
1494 };
1495 }
1496 None
1497}
1498
1499fn object_number(value: &Object) -> Option<f64> {
1500 match value {
1501 Object::Integer(value) => Some(*value as f64),
1502 Object::Real(value) => Some(*value as f64),
1503 _ => None,
1504 }
1505}
1506
1507fn inherited_page_number(doc: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<i64> {
1508 let mut seen = std::collections::HashSet::new();
1509 for _ in 0..64 {
1510 if !seen.insert(page_id) {
1511 return None; }
1513 let dict = doc.get_object(page_id).ok()?.as_dict().ok()?;
1514 if let Ok(value) = dict.get(key) {
1515 let value = match value {
1516 Object::Reference(id) => doc.get_object(*id).ok()?,
1517 value => value,
1518 };
1519 return value.as_i64().ok();
1520 }
1521 page_id = match dict.get(b"Parent").ok()? {
1522 Object::Reference(id) => *id,
1523 _ => return None,
1524 };
1525 }
1526 None
1527}
1528
1529const MAX_STRAY_RESTORES: i64 = 1 << 16;
1531
1532fn install_isolated_content(
1559 doc: &mut Document,
1560 mut blocks: Vec<(usize, Isolated)>,
1561) -> Result<std::collections::BTreeMap<usize, ObjectId>, PdfEditError> {
1562 blocks.sort_by_key(|(slot, _)| *slot);
1563 let mut by_page: std::collections::BTreeMap<ObjectId, Vec<(usize, Vec<Operation>)>> =
1564 std::collections::BTreeMap::new();
1565 for (slot, (page_id, operations)) in blocks {
1566 by_page.entry(page_id).or_default().push((slot, operations));
1567 }
1568 let mut streams = std::collections::BTreeMap::new();
1569 for (page_id, operations) in by_page {
1570 streams.extend(isolate_page_content(doc, page_id, operations)?);
1571 }
1572 Ok(streams)
1573}
1574
1575fn isolate_page_content(
1576 doc: &mut Document,
1577 page_id: ObjectId,
1578 blocks: Vec<(usize, Vec<Operation>)>,
1579) -> Result<Vec<(usize, ObjectId)>, PdfEditError> {
1580 let page_obj = doc
1581 .get_object(page_id)
1582 .map_err(|_| PdfEditError::MalformedContent)?;
1583 let page_dict = page_obj
1584 .as_dict()
1585 .map_err(|_| PdfEditError::MalformedContent)?;
1586
1587 let ids: Vec<ObjectId> = match page_dict.get(b"Contents") {
1590 Ok(contents) => content_stream_ids(doc, contents)?,
1591 Err(_) => return Err(PdfEditError::MalformedContent),
1592 };
1593
1594 let mut budget = ContentBudget::new();
1598 let mut net: i64 = 0;
1599 let mut lowest: i64 = 0;
1600 for id in &ids {
1601 for operation in budget.content_to_read(doc, *id)?.operations {
1604 match operation.operator.as_str() {
1605 "q" => net += 1,
1606 "Q" => {
1607 net -= 1;
1608 lowest = lowest.min(net);
1609 }
1610 _ => {}
1611 }
1612 }
1613 }
1614 if -lowest > MAX_STRAY_RESTORES || net > MAX_STRAY_RESTORES {
1617 return Err(PdfEditError::MalformedContent);
1618 }
1619 let opening = 1 - lowest; let closing = opening + net; let repeated = |operator: &str, count: i64| -> Vec<Operation> {
1623 (0..count)
1624 .map(|_| Operation::new(operator, vec![]))
1625 .collect()
1626 };
1627 let mut add_stream = |operations: Vec<Operation>| -> Result<ObjectId, PdfEditError> {
1628 let mut content = Content::decode(&[]).map_err(|_| PdfEditError::MalformedContent)?;
1629 content.operations = operations;
1630 let encoded = content
1631 .encode()
1632 .map_err(|_| PdfEditError::MalformedContent)?;
1633 Ok(doc.add_object(Stream::new(dictionary! {}, encoded)))
1634 };
1635 let prefix_id = add_stream(repeated("q", opening))?;
1636 let suffix_id = add_stream(repeated("Q", closing))?;
1637 let mut added = Vec::with_capacity(blocks.len());
1638 let mut slots = Vec::with_capacity(blocks.len());
1639 for (slot, operations) in blocks {
1640 added.push(add_stream(operations)?);
1641 slots.push(slot);
1642 }
1643 let installed: Vec<(usize, ObjectId)> = slots.into_iter().zip(added.iter().copied()).collect();
1644
1645 let mut all: Vec<ObjectId> = Vec::with_capacity(ids.len() + 2 + added.len());
1646 all.push(prefix_id);
1647 all.extend(ids);
1648 all.push(suffix_id);
1649 all.extend(added);
1650 let array = Object::Array(all.into_iter().map(Object::Reference).collect());
1651 if let Ok(Object::Dictionary(dict)) = doc.get_object_mut(page_id) {
1652 dict.set("Contents", array);
1653 }
1654 Ok(installed)
1655}
1656
1657fn embed_type0_font(
1673 doc: &mut Document,
1674 resolved: &ResolvedPdfFont,
1675 text: &str,
1676) -> Result<(ObjectId, Vec<u16>), PdfEditError> {
1677 let subset = subset_for_pdf(&resolved.bytes, resolved.face_index, text)?;
1678
1679 let face = ttf_parser::Face::parse(&resolved.bytes, resolved.face_index)
1680 .map_err(|_| PdfEditError::UnsupportedEncoding)?;
1681 let mut seen_old_gids: Vec<u16> = Vec::new();
1682 let mut cid_codes: Vec<u16> = Vec::with_capacity(text.chars().count());
1683 for c in text.chars() {
1684 let old_gid = face.glyph_index(c).ok_or(PdfEditError::MissingGlyph)?.0;
1685 let new_gid = match seen_old_gids.iter().position(|&g| g == old_gid) {
1686 Some(pos) => (pos + 1) as u16,
1687 None => {
1688 seen_old_gids.push(old_gid);
1689 seen_old_gids.len() as u16
1690 }
1691 };
1692 cid_codes.push(new_gid);
1693 }
1694
1695 let font_file_id = doc.add_object(Stream::new(
1696 dictionary! { "Length1" => subset.font_program.len() as i64 },
1697 subset.font_program.clone(),
1698 ));
1699
1700 let mut widths_sorted = subset.widths.clone();
1701 widths_sorted.sort_by_key(|w| w.cid);
1702 let mut w_array: Vec<Object> = Vec::with_capacity(widths_sorted.len() * 2);
1703 for w in &widths_sorted {
1704 w_array.push(Object::Integer(w.cid as i64));
1705 w_array.push(Object::Array(vec![Object::Integer(
1706 w.width_1000.round() as i64
1707 )]));
1708 }
1709
1710 let base_name: Vec<u8> = resolved.family.replace(' ', "").into_bytes();
1711
1712 let descriptor_id = doc.add_object(dictionary! {
1713 "Type" => "FontDescriptor",
1714 "FontName" => Object::Name(base_name.clone()),
1715 "Flags" => 4,
1716 "FontBBox" => Object::Array(vec![Object::Integer(0), Object::Integer(0), Object::Integer(1000), Object::Integer(1000)]),
1717 "ItalicAngle" => 0,
1718 "Ascent" => 900,
1719 "Descent" => -200,
1720 "CapHeight" => 700,
1721 "StemV" => 80,
1722 "FontFile2" => font_file_id,
1723 });
1724
1725 let descendant_id = doc.add_object(dictionary! {
1726 "Type" => "Font",
1727 "Subtype" => "CIDFontType2",
1728 "BaseFont" => Object::Name(base_name.clone()),
1729 "CIDSystemInfo" => dictionary! {
1730 "Registry" => Object::String(b"Adobe".to_vec(), StringFormat::Literal),
1731 "Ordering" => Object::String(b"Identity".to_vec(), StringFormat::Literal),
1732 "Supplement" => 0,
1733 },
1734 "FontDescriptor" => descriptor_id,
1735 "DW" => 1000,
1736 "W" => Object::Array(w_array),
1737 "CIDToGIDMap" => "Identity",
1738 });
1739
1740 let to_unicode_bytes = build_to_unicode_cmap(&subset.to_unicode).into_bytes();
1741 let to_unicode_id = doc.add_object(Stream::new(dictionary! {}, to_unicode_bytes));
1742
1743 let type0_id = doc.add_object(dictionary! {
1744 "Type" => "Font",
1745 "Subtype" => "Type0",
1746 "BaseFont" => Object::Name(base_name),
1747 "Encoding" => "Identity-H",
1748 "DescendantFonts" => Object::Array(vec![Object::Reference(descendant_id)]),
1749 "ToUnicode" => to_unicode_id,
1750 });
1751
1752 Ok((type0_id, cid_codes))
1753}
1754
1755const MAX_RESOURCE_INHERIT_DEPTH: usize = 64;
1758
1759fn effective_resources(doc: &Document, page_id: ObjectId) -> Result<Dictionary, PdfEditError> {
1766 let mut seen = std::collections::HashSet::new();
1767 let mut id = page_id;
1768 for _ in 0..=MAX_RESOURCE_INHERIT_DEPTH {
1769 if !seen.insert(id) {
1770 return Err(PdfEditError::MalformedContent); }
1772 let node = doc
1773 .get_object(id)
1774 .map_err(|_| PdfEditError::MalformedContent)?
1775 .as_dict()
1776 .map_err(|_| PdfEditError::MalformedContent)?;
1777 if node.has(b"Resources") {
1778 return Ok(dict_entry_clone(doc, node, b"Resources"));
1779 }
1780 match node.get(b"Parent") {
1781 Ok(Object::Reference(parent)) => id = *parent,
1782 _ => return Ok(dictionary! {}), }
1784 }
1785 Err(PdfEditError::MalformedContent) }
1787
1788fn add_font_resource(
1796 doc: &mut Document,
1797 page_id: ObjectId,
1798 font_obj_id: ObjectId,
1799) -> Result<Vec<u8>, PdfEditError> {
1800 let existing_resources = effective_resources(doc, page_id)?;
1801 let mut fonts_dict = dict_entry_clone(doc, &existing_resources, b"Font");
1802
1803 let mut counter = 1u32;
1804 let mut name = format!("VREdit{counter}");
1805 while fonts_dict.has(name.as_bytes()) {
1806 counter += 1;
1807 name = format!("VREdit{counter}");
1808 }
1809 fonts_dict.set(name.as_str(), Object::Reference(font_obj_id));
1810
1811 let mut new_resources = existing_resources;
1812 new_resources.set("Font", Object::Dictionary(fonts_dict));
1813
1814 if let Ok(Object::Dictionary(page_dict_mut)) = doc.get_object_mut(page_id) {
1815 page_dict_mut.set("Resources", Object::Dictionary(new_resources));
1816 }
1817
1818 Ok(name.into_bytes())
1819}
1820
1821fn dict_entry_clone(doc: &Document, dict: &Dictionary, key: &[u8]) -> Dictionary {
1826 match dict.get(key) {
1827 Ok(Object::Reference(id)) => doc
1828 .get_object(*id)
1829 .ok()
1830 .and_then(|o| o.as_dict().ok())
1831 .cloned()
1832 .unwrap_or_else(|| dictionary! {}),
1833 Ok(Object::Dictionary(d)) => d.clone(),
1834 _ => dictionary! {},
1835 }
1836}
1837
1838fn build_text_object_operations(
1866 resource_name: &[u8],
1867 appearance: &PdfTextAppearance,
1868 placement: Matrix,
1869 cid_codes: &[u16],
1870) -> Vec<Operation> {
1871 let mut hex_bytes = Vec::with_capacity(cid_codes.len() * 2);
1872 for code in cid_codes {
1873 hex_bytes.extend_from_slice(&code.to_be_bytes());
1874 }
1875
1876 let mut operations = vec![Operation::new("q", vec![])];
1877 operations.extend([
1878 Operation::new("BT", vec![]),
1879 Operation::new(
1880 "Tf",
1881 vec![
1882 Object::Name(resource_name.to_vec()),
1883 Object::Real(appearance.size_pt as f32),
1884 ],
1885 ),
1886 Operation::new("Tr", vec![Object::Integer(0)]),
1888 Operation::new("Tc", vec![Object::Integer(0)]),
1889 Operation::new("Tw", vec![Object::Integer(0)]),
1890 Operation::new("Tz", vec![Object::Integer(100)]),
1891 Operation::new("Ts", vec![Object::Integer(0)]),
1892 Operation::new(
1893 "rg",
1894 vec![
1895 Object::Real(appearance.color.r as f32),
1896 Object::Real(appearance.color.g as f32),
1897 Object::Real(appearance.color.b as f32),
1898 ],
1899 ),
1900 Operation::new(
1901 "Tm",
1902 [
1903 placement.a,
1904 placement.b,
1905 placement.c,
1906 placement.d,
1907 placement.e,
1908 placement.f,
1909 ]
1910 .map(number_object)
1911 .to_vec(),
1912 ),
1913 Operation::new(
1914 "Tj",
1915 vec![Object::String(hex_bytes, StringFormat::Hexadecimal)],
1916 ),
1917 Operation::new("ET", vec![]),
1918 Operation::new("Q", vec![]),
1919 ]);
1920 operations
1921}
1922
1923fn build_ocr_correction_operations(
1926 resource_name: &[u8],
1927 appearance: &PdfTextAppearance,
1928 rect: PdfRect,
1929 cover_color: PdfColor,
1930 cid_codes: &[u16],
1931) -> Vec<Operation> {
1932 let mut operations = vec![Operation::new("q", vec![])];
1933 operations.extend([
1934 Operation::new(
1935 "rg",
1936 vec![
1937 Object::Real(cover_color.r as f32),
1938 Object::Real(cover_color.g as f32),
1939 Object::Real(cover_color.b as f32),
1940 ],
1941 ),
1942 Operation::new(
1943 "re",
1944 vec![
1945 Object::Real(rect.x as f32),
1946 Object::Real(rect.y as f32),
1947 Object::Real(rect.width as f32),
1948 Object::Real(rect.height as f32),
1949 ],
1950 ),
1951 Operation::new("f", vec![]),
1952 Operation::new("Q", vec![]),
1953 ]);
1954 operations.extend(build_text_object_operations(
1957 resource_name,
1958 appearance,
1959 Matrix::translation(rect.x, rect.y + rect.height * 0.18),
1960 cid_codes,
1961 ));
1962 operations
1963}
1964
1965fn decode_stream_content(
1970 doc: &Document,
1971 id: ObjectId,
1972 budget: &mut ContentBudget,
1973) -> Result<Content, PdfEditError> {
1974 budget.content(doc, id, true)
1978}
1979
1980fn write_stream_content(
1983 doc: &mut Document,
1984 id: ObjectId,
1985 content: &Content,
1986) -> Result<(), PdfEditError> {
1987 let encoded = content
1988 .encode()
1989 .map_err(|_| PdfEditError::MalformedContent)?;
1990 doc.objects
1991 .insert(id, Stream::new(dictionary! {}, encoded).into());
1992 Ok(())
1993}
1994
1995fn splice_operand_bytes(
2001 content: &mut Content,
2002 operator_index: u32,
2003 operand_index: u32,
2004 glyph_start: u32,
2005 glyph_end: u32,
2006 replacement: &[u8],
2007) -> Result<(), PdfEditError> {
2008 let op = content
2009 .operations
2010 .get_mut(operator_index as usize)
2011 .ok_or(PdfEditError::SourceDigestMismatch)?;
2012
2013 match op.operator.as_str() {
2014 "Tj" | "'" => {
2015 let operand = op
2016 .operands
2017 .get_mut(0)
2018 .ok_or(PdfEditError::SourceDigestMismatch)?;
2019 splice_string_object(operand, glyph_start, glyph_end, replacement)
2020 }
2021 "\"" => {
2022 let operand = op
2023 .operands
2024 .get_mut(2)
2025 .ok_or(PdfEditError::SourceDigestMismatch)?;
2026 splice_string_object(operand, glyph_start, glyph_end, replacement)
2027 }
2028 "TJ" => {
2029 let Some(Object::Array(items)) = op.operands.get_mut(0) else {
2030 return Err(PdfEditError::SourceDigestMismatch);
2031 };
2032 let item = items
2033 .get_mut(operand_index as usize)
2034 .ok_or(PdfEditError::SourceDigestMismatch)?;
2035 splice_string_object(item, glyph_start, glyph_end, replacement)
2036 }
2037 _ => Err(PdfEditError::SourceDigestMismatch),
2038 }
2039}
2040
2041fn splice_string_object(
2042 obj: &mut Object,
2043 start: u32,
2044 end: u32,
2045 replacement: &[u8],
2046) -> Result<(), PdfEditError> {
2047 let Object::String(bytes, _fmt) = obj else {
2048 return Err(PdfEditError::SourceDigestMismatch);
2049 };
2050 let (start, end) = (start as usize, end as usize);
2051 if start > end || end > bytes.len() {
2052 return Err(PdfEditError::SourceDigestMismatch);
2053 }
2054 let mut new_bytes = Vec::with_capacity(bytes.len() - (end - start) + replacement.len());
2055 new_bytes.extend_from_slice(&bytes[..start]);
2056 new_bytes.extend_from_slice(replacement);
2057 new_bytes.extend_from_slice(&bytes[end..]);
2058 *bytes = new_bytes;
2059 Ok(())
2060}
2061
2062#[derive(Debug, Deserialize)]
2071#[serde(rename_all = "camelCase")]
2072pub struct ComposePdfPreviewRequest {
2073 pub source_path: String,
2074 pub expected_source_sha256: String,
2075 pub source_commands: Vec<PdfEditCommand>,
2076}
2077
2078#[cfg(test)]
2083mod tests {
2084 use super::*;
2085 use lopdf::{dictionary, Object as LObject};
2086
2087 use crate::inspect::simple_font_identity_bytes;
2088 use crate::model::{GlyphId, PdfColor, PdfEditCommand, PdfOcrBackgroundProof, PdfPageGraph};
2089
2090 use crate::test_fixtures::{build_one_page_pdf as build_pdf, pdf_bytes as doc_bytes};
2095
2096 fn decode_stream_content(doc: &Document, id: ObjectId) -> Result<Content, PdfEditError> {
2098 super::decode_stream_content(doc, id, &mut ContentBudget::new())
2099 }
2100
2101 fn locate_island(
2102 doc: &Document,
2103 sha: &str,
2104 page: u32,
2105 text: &str,
2106 ) -> crate::model::PdfTextIsland {
2107 let graph = walk_page_graph(doc, sha, page).expect("inspect fixture page");
2108 graph
2109 .islands
2110 .into_iter()
2111 .find(|isl| isl.text == text)
2112 .unwrap_or_else(|| panic!("no island with text {text:?}"))
2113 }
2114
2115 struct FailingFontResolver;
2116 impl PdfFontResolver for FailingFontResolver {
2117 fn resolve(&self, _id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
2118 Err(PdfEditError::UnsupportedEncoding)
2119 }
2120 fn glyphs_for_text(
2121 &self,
2122 _font: &ResolvedPdfFont,
2123 _text: &str,
2124 ) -> Result<Vec<GlyphId>, PdfEditError> {
2125 Err(PdfEditError::UnsupportedEncoding)
2126 }
2127 }
2128
2129 fn installable_font() -> Vec<u8> {
2130 crate::test_fixtures::build_test_ttf(
2132 0,
2133 &[('A', 1), ('B', 2), ('C', 3), ('H', 4), ('i', 5)],
2134 600,
2135 )
2136 }
2137
2138 struct TestFontResolver {
2139 id: &'static str,
2140 bytes: Vec<u8>,
2141 }
2142
2143 impl PdfFontResolver for TestFontResolver {
2144 fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
2145 if id != self.id {
2146 return Err(PdfEditError::UnsupportedEncoding);
2147 }
2148 Ok(ResolvedPdfFont {
2149 id: self.id.to_string(),
2150 family: "Test Font".to_string(),
2151 bytes: self.bytes.clone(),
2152 face_index: 0,
2153 embeddable: true,
2154 subsettable: true,
2155 })
2156 }
2157
2158 fn glyphs_for_text(
2159 &self,
2160 font: &ResolvedPdfFont,
2161 text: &str,
2162 ) -> Result<Vec<GlyphId>, PdfEditError> {
2163 crate::font::glyph_ids_for_text(&font.bytes, font.face_index, text)
2166 }
2167 }
2168
2169 fn appearance(font_id: &str, size_pt: f64) -> PdfTextAppearance {
2170 PdfTextAppearance {
2171 font_id: font_id.to_string(),
2172 family: font_id.to_string(),
2173 size_pt,
2174 color: PdfColor {
2175 r: 0.0,
2176 g: 0.0,
2177 b: 0.0,
2178 alpha: 1.0,
2179 },
2180 }
2181 }
2182
2183 fn test_resolver() -> TestFontResolver {
2186 TestFontResolver {
2187 id: "test-font",
2188 bytes: installable_font(),
2189 }
2190 }
2191
2192 fn replace_cmd(
2197 id: &str,
2198 island: &crate::model::PdfTextIsland,
2199 after_text: &str,
2200 after_appearance: PdfTextAppearance,
2201 available_bounds: PdfRect,
2202 ) -> PdfEditCommand {
2203 PdfEditCommand::ReplaceSourceText {
2204 id: id.to_string(),
2205 locator: island.locator.clone(),
2206 before: PdfTextEditState {
2207 text: island.text.clone(),
2208 appearance: island.appearance.clone(),
2209 },
2210 after: PdfTextEditState {
2211 text: after_text.to_string(),
2212 appearance: after_appearance,
2213 },
2214 available_bounds,
2215 }
2216 }
2217
2218 fn reinspect(candidate: &ComposedCandidate, page: u32) -> (Document, PdfPageGraph) {
2222 let out = Document::load_mem(&candidate.bytes).expect("reopen candidate");
2223 let graph =
2224 walk_page_graph(&out, &document_sha256(&candidate.bytes), page).expect("reinspect");
2225 (out, graph)
2226 }
2227
2228 #[test]
2233 fn exact_operand_patch_replaces_single_tj_text_in_place() {
2234 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2235 let source = doc_bytes(&doc);
2236 let sha = document_sha256(&source);
2237 let island = locate_island(&doc, &sha, 1, "Hello");
2238
2239 let command = replace_cmd(
2240 "edit-1",
2241 &island,
2242 "Howdy",
2243 island.appearance.clone(),
2244 PdfRect {
2245 x: 72.0,
2246 y: 695.0,
2247 width: 400.0,
2248 height: 20.0,
2249 },
2250 );
2251
2252 let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
2253 .expect("exact patch composes");
2254 assert_eq!(candidate.affected_pages, vec![1]);
2255
2256 let (_, graph) = reinspect(&candidate, 1);
2257 assert!(graph.islands.iter().any(|isl| isl.text == "Howdy"));
2258 assert!(!graph.islands.iter().any(|isl| isl.text == "Hello"));
2259 }
2260
2261 #[test]
2262 fn corrected_source_text_survives_native_staging_verification() {
2263 let source = include_bytes!("../tests/fixtures/source-correction-2page.pdf");
2264 let doc = Document::load_mem(source).expect("fixture");
2265 let sha = document_sha256(source);
2266 let island = locate_island(&doc, &sha, 1, "ORIGINAL SOURCE TEXT");
2267 let command = replace_cmd(
2268 "correction",
2269 &island,
2270 "SOURCE TEST 930",
2271 island.appearance.clone(),
2272 PdfRect {
2273 x: 72.0,
2274 y: 717.0,
2275 width: 146.028,
2276 height: 12.0,
2277 },
2278 );
2279 for _ in 0..32 {
2281 assert_eq!(
2282 simple_font_identity_bytes(
2283 &doc,
2284 *doc.get_pages().get(&1).expect("page"),
2285 b"F1",
2286 "SOURCE TEST 930"
2287 ),
2288 Some(b"SOURCE TEST 930".to_vec())
2289 );
2290 let candidate =
2291 compose_candidate(source, std::slice::from_ref(&command), &FailingFontResolver)
2292 .expect("compose");
2293 let (_, graph) = reinspect(&candidate, 1);
2294 assert!(graph
2295 .islands
2296 .iter()
2297 .any(|island| island.text.contains("SOURCE TEST 930")));
2298 assert!(graph
2299 .islands
2300 .iter()
2301 .any(|island| island.text == "SIBLING SOURCE TEXT"));
2302 let store = crate::save::PdfSaveTokenStore::default();
2303 crate::save::stage_prepared_save(
2304 &store,
2305 "/fixture.pdf",
2306 &sha,
2307 &sha,
2308 candidate.bytes,
2309 candidate.affected_pages,
2310 crate::save::expected_text_checks(std::slice::from_ref(&command)),
2311 )
2312 .expect("verified stage");
2313 }
2314 }
2315
2316 #[test]
2317 fn exact_operand_patch_rejects_overflow_before_mutating() {
2318 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hi) Tj ET");
2319 let source = doc_bytes(&doc);
2320 let sha = document_sha256(&source);
2321 let island = locate_island(&doc, &sha, 1, "Hi");
2322
2323 let command = replace_cmd(
2325 "edit-overflow",
2326 &island,
2327 "This replacement text is far too long",
2328 island.appearance.clone(),
2329 PdfRect {
2330 x: 72.0,
2331 y: 695.0,
2332 width: 10.0,
2333 height: 10.0,
2334 },
2335 );
2336
2337 let err = compose_candidate(&source, &[command], &FailingFontResolver)
2338 .expect_err("overflow must reject");
2339 assert!(matches!(err, PdfEditError::TextOverflow));
2340 }
2341
2342 #[test]
2347 fn compose_candidate_on_base_preserves_annotations_while_editing_text() {
2348 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2349 let source = doc_bytes(&doc);
2350 let sha = document_sha256(&source);
2351 let island = locate_island(&doc, &sha, 1, "Hello");
2352
2353 let mut annotated = doc.clone();
2358 let page_id = *annotated.get_pages().get(&1).expect("page 1 exists");
2359 let annot_id = annotated.add_object(dictionary! {
2360 "Type" => "Annot",
2361 "Subtype" => "FreeText",
2362 "Rect" => LObject::Array(vec![
2363 LObject::Real(10.0),
2364 LObject::Real(10.0),
2365 LObject::Real(100.0),
2366 LObject::Real(30.0),
2367 ]),
2368 "Contents" => LObject::String(b"note".to_vec(), lopdf::StringFormat::Literal),
2369 });
2370 if let Ok(LObject::Dictionary(page_dict)) = annotated.get_object_mut(page_id) {
2371 page_dict.set("Annots", LObject::Array(vec![LObject::Reference(annot_id)]));
2372 }
2373 let base = doc_bytes(&annotated);
2374
2375 let command = replace_cmd(
2376 "edit-1",
2377 &island,
2378 "Howdy",
2379 island.appearance.clone(),
2380 PdfRect {
2381 x: 72.0,
2382 y: 695.0,
2383 width: 400.0,
2384 height: 20.0,
2385 },
2386 );
2387
2388 let candidate = compose_candidate_on_base(&sha, &base, &[command], &FailingFontResolver)
2389 .expect("compose on annotated base");
2390
2391 let (out, graph) = reinspect(&candidate, 1);
2392 let out_page_id = *out.get_pages().get(&1).expect("page 1 exists in candidate");
2393 let out_page_dict = out
2394 .get_object(out_page_id)
2395 .expect("page object")
2396 .as_dict()
2397 .expect("page dict");
2398 assert!(
2399 out_page_dict.has(b"Annots"),
2400 "FreeText annotation must survive text composition"
2401 );
2402
2403 assert!(graph.islands.iter().any(|isl| isl.text == "Howdy"));
2404 assert!(!graph.islands.iter().any(|isl| isl.text == "Hello"));
2405 }
2406
2407 #[test]
2408 fn compose_candidate_on_base_with_no_commands_returns_base_verbatim() {
2409 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2410 let base = doc_bytes(&doc);
2411 let sha = document_sha256(&base);
2412 let candidate = compose_candidate_on_base(&sha, &base, &[], &FailingFontResolver)
2413 .expect("empty commands compose trivially");
2414 assert_eq!(candidate.bytes, base);
2415 assert!(candidate.affected_pages.is_empty());
2416 }
2417
2418 #[test]
2423 fn stale_document_sha256_is_rejected() {
2424 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2425 let source = doc_bytes(&doc);
2426 let sha = document_sha256(&source);
2427 let mut island = locate_island(&doc, &sha, 1, "Hello");
2428 island.locator.document_sha256 =
2429 "0000000000000000000000000000000000000000000000000000000000000000".to_string();
2430
2431 let command = replace_cmd(
2432 "edit-stale",
2433 &island,
2434 "Howdy",
2435 island.appearance.clone(),
2436 PdfRect {
2437 x: 72.0,
2438 y: 695.0,
2439 width: 400.0,
2440 height: 20.0,
2441 },
2442 );
2443
2444 let err = compose_candidate(&source, &[command], &FailingFontResolver)
2445 .expect_err("stale source must reject");
2446 assert!(matches!(err, PdfEditError::StaleSource));
2447 }
2448
2449 #[test]
2450 fn tampered_island_digest_with_valid_document_sha_is_source_digest_mismatch() {
2451 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2452 let source = doc_bytes(&doc);
2453 let sha = document_sha256(&source);
2454 let mut island = locate_island(&doc, &sha, 1, "Hello");
2455
2456 island.locator.island_digest = format!("{}-tampered", island.locator.island_digest);
2460
2461 let command = replace_cmd(
2462 "edit-tampered",
2463 &island,
2464 "Howdy",
2465 island.appearance.clone(),
2466 PdfRect {
2467 x: 72.0,
2468 y: 695.0,
2469 width: 400.0,
2470 height: 20.0,
2471 },
2472 );
2473
2474 let err = compose_candidate(&source, &[command], &FailingFontResolver)
2475 .expect_err("tampered digest must reject");
2476 assert!(matches!(err, PdfEditError::SourceDigestMismatch));
2477 }
2478
2479 #[test]
2484 fn local_rewrite_replaces_adjacent_tj_operators_via_embedded_font_round_trip() {
2485 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (CAR YOU) Tj 55 0 Td ( LIST.) Tj ET");
2487 let source = doc_bytes(&doc);
2488 let sha = document_sha256(&source);
2489 let island = locate_island(&doc, &sha, 1, "CAR YOU LIST.");
2490 assert!(island.locator.spans.len() > 1);
2493
2494 let resolver = test_resolver();
2495 let command = replace_cmd(
2496 "edit-split",
2497 &island,
2498 "Hi",
2499 appearance("test-font", 20.0),
2500 PdfRect {
2501 x: 72.0,
2502 y: 690.0,
2503 width: 400.0,
2504 height: 40.0,
2505 },
2506 );
2507
2508 let candidate =
2509 compose_candidate(&source, &[command], &resolver).expect("local rewrite composes");
2510 let (_, graph) = reinspect(&candidate, 1);
2511
2512 assert!(!graph.islands.iter().any(|isl| isl.text == "CAR YOU LIST."));
2515 assert!(graph
2516 .islands
2517 .iter()
2518 .any(|isl| isl.text == "Hi" && isl.editable));
2519 }
2520
2521 #[test]
2522 fn local_rewrite_used_when_appearance_changes_even_for_single_span() {
2523 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2524 let source = doc_bytes(&doc);
2525 let sha = document_sha256(&source);
2526 let island = locate_island(&doc, &sha, 1, "Hello");
2527 assert_eq!(island.locator.spans.len(), 1);
2528
2529 let resolver = test_resolver();
2530 let command = replace_cmd(
2533 "edit-appearance",
2534 &island,
2535 "Hi",
2536 appearance("test-font", 24.0),
2537 PdfRect {
2538 x: 72.0,
2539 y: 690.0,
2540 width: 400.0,
2541 height: 40.0,
2542 },
2543 );
2544
2545 let candidate =
2546 compose_candidate(&source, &[command], &resolver).expect("compose succeeds");
2547 let (_, graph) = reinspect(&candidate, 1);
2548 assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
2549 }
2550
2551 #[test]
2556 fn add_text_appends_isolated_stream_without_touching_existing_content() {
2557 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2558 let source = doc_bytes(&doc);
2559
2560 let resolver = test_resolver();
2561 let command = PdfEditCommand::AddText {
2562 id: "add-1".to_string(),
2563 page: 1,
2564 anchor: PdfPoint { x: 100.0, y: 100.0 },
2565 text: "Hi".to_string(),
2566 appearance: appearance("test-font", 18.0),
2567 };
2568
2569 let candidate =
2570 compose_candidate(&source, &[command], &resolver).expect("add text composes");
2571 assert_eq!(candidate.affected_pages, vec![1]);
2572
2573 let (_, graph) = reinspect(&candidate, 1);
2574
2575 assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
2576 assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
2577 }
2578
2579 #[test]
2580 fn add_text_missing_glyph_rejects_before_any_mutation() {
2581 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2582 let source = doc_bytes(&doc);
2583 let resolver = test_resolver();
2585 let command = PdfEditCommand::AddText {
2586 id: "add-missing".to_string(),
2587 page: 1,
2588 anchor: PdfPoint { x: 100.0, y: 100.0 },
2589 text: "Zzz".to_string(),
2590 appearance: appearance("test-font", 18.0),
2591 };
2592
2593 let err = compose_candidate(&source, &[command], &resolver)
2594 .expect_err("missing glyph must reject");
2595 assert!(matches!(err, PdfEditError::MissingGlyph));
2596 }
2597
2598 #[test]
2599 fn add_text_unknown_font_id_rejects_with_unsupported_encoding() {
2600 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2601 let source = doc_bytes(&doc);
2602 let command = PdfEditCommand::AddText {
2603 id: "add-unknown-font".to_string(),
2604 page: 1,
2605 anchor: PdfPoint { x: 100.0, y: 100.0 },
2606 text: "Hi".to_string(),
2607 appearance: appearance("does-not-exist", 18.0),
2608 };
2609
2610 let err = compose_candidate(&source, &[command], &FailingFontResolver)
2611 .expect_err("unknown font id must reject");
2612 assert!(matches!(err, PdfEditError::UnsupportedEncoding));
2613 }
2614
2615 fn ocr_command(id: &str, line_id: &str, text: &str, rect: PdfRect) -> PdfEditCommand {
2616 PdfEditCommand::CorrectOcrLine {
2617 id: id.to_string(),
2618 page: 1,
2619 line_id: line_id.to_string(),
2620 rect,
2621 text: text.to_string(),
2622 appearance: appearance("test-font", 18.0),
2623 cover_color: PdfColor {
2624 r: 1.0,
2625 g: 1.0,
2626 b: 1.0,
2627 alpha: 1.0,
2628 },
2629 background_proof: PdfOcrBackgroundProof::NearUniform,
2630 }
2631 }
2632
2633 #[test]
2634 fn correct_ocr_text_appends_cover_and_type0_text_stream() {
2635 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2636 let source = doc_bytes(&doc);
2637 let resolver = test_resolver();
2638 let command = ocr_command(
2639 "ocr-1",
2640 "line-1",
2641 "Hi",
2642 PdfRect {
2643 x: 100.0,
2644 y: 100.0,
2645 width: 100.0,
2646 height: 30.0,
2647 },
2648 );
2649 let candidate = compose_candidate(&source, &[command], &resolver).expect("compose OCR");
2650 let (out, graph) = reinspect(&candidate, 1);
2651 let page_id = out.get_pages()[&1];
2652 let page = out
2653 .get_object(page_id)
2654 .expect("page")
2655 .as_dict()
2656 .expect("dict");
2657 let contents = page
2658 .get(b"Contents")
2659 .expect("contents")
2660 .as_array()
2661 .expect("array");
2662 let stream_id = contents
2663 .last()
2664 .expect("new stream")
2665 .as_reference()
2666 .expect("ref");
2667 let stream = out
2668 .get_object(stream_id)
2669 .expect("stream")
2670 .as_stream()
2671 .expect("stream");
2672 let content = Content::decode(&stream.decompressed_content().expect("decode stream"))
2673 .expect("content operations");
2674 let operators: Vec<&str> = content
2675 .operations
2676 .iter()
2677 .map(|operation| operation.operator.as_str())
2678 .collect();
2679 assert_eq!(&operators[..5], &["q", "rg", "re", "f", "Q"]);
2680 assert!(operators.windows(2).any(|window| window == ["BT", "Tf"]));
2681
2682 assert!(graph.islands.iter().any(|island| island.text == "Hi"));
2683 }
2684
2685 #[test]
2686 fn correct_ocr_text_duplicate_line_id_last_wins() {
2687 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2688 let source = doc_bytes(&doc);
2689 let resolver = test_resolver();
2690 let rect = PdfRect {
2691 x: 100.0,
2692 y: 100.0,
2693 width: 100.0,
2694 height: 30.0,
2695 };
2696 let commands = vec![
2697 ocr_command("ocr-old", "line-1", "Hi", rect),
2698 ocr_command("ocr-new", "line-1", "Hi", rect),
2699 ];
2700 let candidate = compose_candidate(&source, &commands, &resolver).expect("compose OCR");
2701 let (_, graph) = reinspect(&candidate, 1);
2702 assert_eq!(
2703 graph
2704 .islands
2705 .iter()
2706 .filter(|island| island.text == "Hi")
2707 .count(),
2708 1
2709 );
2710 }
2711
2712 #[test]
2713 fn correct_ocr_text_rejects_rotation_and_out_of_bounds_rect() {
2714 let mut rotated = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
2715 let page_id = rotated.get_pages()[&1];
2716 rotated
2717 .get_object_mut(page_id)
2718 .expect("page")
2719 .as_dict_mut()
2720 .expect("dict")
2721 .set("Rotate", 90);
2722 let rotated_source = doc_bytes(&rotated);
2723 let resolver = test_resolver();
2724 let rect = PdfRect {
2725 x: 100.0,
2726 y: 100.0,
2727 width: 100.0,
2728 height: 30.0,
2729 };
2730 assert!(matches!(
2731 compose_candidate(
2732 &rotated_source,
2733 &[ocr_command("r", "line", "Hi", rect)],
2734 &resolver
2735 ),
2736 Err(PdfEditError::UnsupportedTransform)
2737 ));
2738
2739 let source = doc_bytes(&build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET"));
2740 let bad_rect = PdfRect {
2741 x: 600.0,
2742 y: 100.0,
2743 width: 100.0,
2744 height: 30.0,
2745 };
2746 assert!(matches!(
2747 compose_candidate(
2748 &source,
2749 &[ocr_command("b", "line", "Hi", bad_rect)],
2750 &resolver
2751 ),
2752 Err(PdfEditError::TextOverflow)
2753 ));
2754 }
2755
2756 #[test]
2757 fn empty_command_list_returns_source_bytes_unchanged() {
2758 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
2759 let source = doc_bytes(&doc);
2760 let candidate = compose_candidate(&source, &[], &FailingFontResolver)
2761 .expect("empty batch always succeeds");
2762 assert_eq!(candidate.bytes, source);
2763 assert!(candidate.affected_pages.is_empty());
2764 }
2765 use crate::test_fixtures::{
2770 custom_page_pdf, differences_encoding, helvetica_font, identity_h_font, subset_type1_font,
2771 };
2772
2773 fn island_box(island: &crate::model::PdfTextIsland) -> PdfRect {
2776 let mut min_x = f64::MAX;
2777 let mut min_y = f64::MAX;
2778 let mut max_x = f64::MIN;
2779 let mut max_y = f64::MIN;
2780 for quad in &island.quads {
2781 for corner in 0..4 {
2782 min_x = min_x.min(quad[corner * 2]);
2783 max_x = max_x.max(quad[corner * 2]);
2784 min_y = min_y.min(quad[corner * 2 + 1]);
2785 max_y = max_y.max(quad[corner * 2 + 1]);
2786 }
2787 }
2788 PdfRect {
2789 x: min_x,
2790 y: min_y,
2791 width: max_x - min_x,
2792 height: max_y - min_y,
2793 }
2794 }
2795
2796 const PAGE_WIDE: PdfRect = PdfRect {
2797 x: 0.0,
2798 y: 0.0,
2799 width: 612.0,
2800 height: 792.0,
2801 };
2802
2803 #[test]
2804 fn same_glyph_swap_fits_the_islands_own_box() {
2805 let source = include_bytes!("../tests/fixtures/proof-text-50page.pdf");
2810 let doc = Document::load_mem(source).expect("fixture");
2811 let sha = document_sha256(source);
2812 let island = locate_island(&doc, &sha, 1, "Page 1: external link below");
2813 assert!(island.editable, "{:?}", island.edit_blocker);
2814 let own = island_box(&island);
2815 assert!((own.width - 286.8).abs() < 0.01, "{}", own.width);
2816
2817 for (label, bounds) in [("own box", own), ("page wide", PAGE_WIDE)] {
2818 let command = replace_cmd(
2819 "swap",
2820 &island,
2821 "Page 1: xternale link below",
2822 island.appearance.clone(),
2823 bounds,
2824 );
2825 let candidate = compose_candidate(source, &[command], &FailingFontResolver)
2826 .unwrap_or_else(|error| panic!("{label}: {error}"));
2827 let (_, graph) = reinspect(&candidate, 1);
2828 assert!(
2829 graph
2830 .islands
2831 .iter()
2832 .any(|isl| isl.text == "Page 1: xternale link below"),
2833 "{label}"
2834 );
2835 }
2836
2837 let narrow = PdfRect {
2839 width: own.width - 1.0,
2840 ..own
2841 };
2842 let command = replace_cmd(
2843 "swap",
2844 &island,
2845 "Page 1: xternale link below",
2846 island.appearance.clone(),
2847 narrow,
2848 );
2849 assert!(matches!(
2850 compose_candidate(source, &[command], &FailingFontResolver),
2851 Err(PdfEditError::TextOverflow)
2852 ));
2853 }
2854
2855 #[test]
2856 fn narrower_replacement_never_overflows_its_own_box_under_character_and_word_spacing() {
2857 let doc = build_pdf(b"BT /F1 12 Tf 1 Tc 3 Tw 72 700 Td (a b c) Tj ET");
2858 let source = doc_bytes(&doc);
2859 let sha = document_sha256(&source);
2860 let island = locate_island(&doc, &sha, 1, "a b c");
2861 let own = island_box(&island);
2862 let command = replace_cmd("order", &island, "c b a", island.appearance.clone(), own);
2864 compose_candidate(&source, &[command], &FailingFontResolver)
2865 .expect("same advance fits its own box");
2866 }
2867
2868 fn subset_with_differences(first_widths: &[i64]) -> Document {
2869 let encoding = differences_encoding(
2870 None,
2871 true,
2872 vec![
2873 LObject::Integer(65),
2874 LObject::Name(b"A".to_vec()),
2875 LObject::Name(b"B".to_vec()),
2876 ],
2877 );
2878 custom_page_pdf(
2879 |d| {
2880 vec![(
2881 "F1",
2882 subset_type1_font(d, 65, first_widths, Some(encoding), None),
2883 )]
2884 },
2885 &[b"BT /F1 12 Tf 72 700 Td (AB) Tj ET"],
2886 false,
2887 )
2888 }
2889
2890 #[test]
2891 fn missing_glyph_error_names_every_character_the_subset_lacks() {
2892 let doc = subset_with_differences(&[500, 600, 700]);
2893 let source = doc_bytes(&doc);
2894 let sha = document_sha256(&source);
2895 let island = locate_island(&doc, &sha, 1, "AB");
2896 assert!(island.editable);
2897
2898 let command = replace_cmd(
2899 "miss",
2900 &island,
2901 "ABC!C",
2902 island.appearance.clone(),
2903 PAGE_WIDE,
2904 );
2905 let error = compose_candidate(&source, &[command], &FailingFontResolver)
2906 .expect_err("missing glyphs are refused");
2907 assert_eq!(error.missing_chars(), Some(&['C', '!'][..]));
2908 assert_eq!(error.code(), "missing_glyph");
2909 assert!(error.to_string().contains("'C', '!'"), "{error}");
2910 }
2911
2912 #[test]
2913 fn graph_editable_and_compose_agree_for_every_island() {
2914 let doc = custom_page_pdf(
2918 |d| {
2919 vec![
2920 ("F1", helvetica_font(d)),
2921 ("F2", subset_type1_font(d, 65, &[500, 600], None, None)),
2922 ]
2923 },
2924 &[b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET BT /F2 12 Tf 72 600 Td (AB) Tj ET"],
2925 false,
2926 );
2927 let source = doc_bytes(&doc);
2928 let sha = document_sha256(&source);
2929 let graph = walk_page_graph(&doc, &sha, 1).expect("graph");
2930 assert_eq!(graph.islands.len(), 2);
2931 for island in &graph.islands {
2932 let command = replace_cmd(
2933 "same",
2934 island,
2935 &island.text,
2936 island.appearance.clone(),
2937 island_box(island),
2938 );
2939 let outcome = compose_candidate(&source, &[command], &FailingFontResolver);
2940 if island.editable {
2941 assert!(outcome.is_ok(), "{}: {:?}", island.text, outcome.err());
2942 } else {
2943 assert!(
2944 matches!(outcome, Err(PdfEditError::UnsupportedEncoding)),
2945 "{}",
2946 island.text
2947 );
2948 }
2949 }
2950 assert_eq!(
2951 graph.islands.iter().filter(|i| i.editable).count(),
2952 1,
2953 "exactly the Helvetica island is editable"
2954 );
2955 }
2956
2957 #[test]
2958 fn kerned_tj_replacement_writes_one_run_into_the_first_span() {
2959 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td [(Hel) -20 (lo)] TJ ET");
2960 let source = doc_bytes(&doc);
2961 let sha = document_sha256(&source);
2962 let island = locate_island(&doc, &sha, 1, "Hello");
2963 assert_eq!(island.locator.spans.len(), 2);
2964
2965 let command = replace_cmd(
2966 "kern",
2967 &island,
2968 "Help!",
2969 island.appearance.clone(),
2970 PAGE_WIDE,
2971 );
2972 let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
2973 .expect("multi-operand island composes in place");
2974 let (out, graph) = reinspect(&candidate, 1);
2975 assert!(graph.islands.iter().any(|isl| isl.text == "Help!"));
2976 assert!(!graph.islands.iter().any(|isl| isl.text.contains("lo")));
2977
2978 let page_id = out.get_pages()[&1];
2980 let stream_id = content_stream_ids(
2981 &out,
2982 out.get_object(page_id)
2983 .expect("page")
2984 .as_dict()
2985 .expect("dict")
2986 .get(b"Contents")
2987 .expect("contents"),
2988 )
2989 .expect("ids")[0];
2990 let content = decode_stream_content(&out, stream_id).expect("content");
2991 let tj = content
2992 .operations
2993 .iter()
2994 .find(|op| op.operator == "TJ")
2995 .expect("TJ kept");
2996 let Some(Object::Array(items)) = tj.operands.first() else {
2997 panic!("TJ array");
2998 };
2999 assert!(matches!(&items[0], Object::String(b, _) if b == b"Hel"));
3001 assert!(matches!(&items[1], Object::Integer(-20)));
3002 assert!(matches!(&items[2], Object::String(b, _) if b == b"p!"));
3003 }
3004
3005 #[test]
3006 fn adjacent_tj_operators_replace_in_place_when_the_font_can_encode() {
3007 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj (World) Tj ET");
3008 let source = doc_bytes(&doc);
3009 let sha = document_sha256(&source);
3010 let island = locate_island(&doc, &sha, 1, "HelloWorld");
3011 assert_eq!(island.locator.spans.len(), 2);
3012 let command = replace_cmd(
3013 "two-tj",
3014 &island,
3015 "Hi there",
3016 island.appearance.clone(),
3017 PAGE_WIDE,
3018 );
3019 let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
3020 .expect("two Tj operators compose in place");
3021 let (_, graph) = reinspect(&candidate, 1);
3022 assert!(graph.islands.iter().any(|isl| isl.text == "Hi there"));
3023 assert!(!graph.islands.iter().any(|isl| isl.text.contains("World")));
3024 }
3025
3026 #[test]
3027 fn identity_h_island_is_rewritten_with_two_byte_codes_and_refuses_unknown_characters() {
3028 let doc = custom_page_pdf(
3029 |d| {
3030 vec![(
3031 "F1",
3032 identity_h_font(
3033 d,
3034 &[(1, 600), (2, 300), (3, 500)],
3035 &[(1, 'H'), (2, 'i'), (3, '!')],
3036 ),
3037 )]
3038 },
3039 &[b"BT /F1 12 Tf 72 700 Td <00010002> Tj ET"],
3040 false,
3041 );
3042 let source = doc_bytes(&doc);
3043 let sha = document_sha256(&source);
3044 let island = locate_island(&doc, &sha, 1, "Hi");
3045 assert!(island.editable);
3046
3047 let command = replace_cmd("cid", &island, "i!H", island.appearance.clone(), PAGE_WIDE);
3048 let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
3049 .expect("CID island composes in place");
3050 let (_, graph) = reinspect(&candidate, 1);
3051 assert!(graph.islands.iter().any(|isl| isl.text == "i!H"));
3052
3053 let command = replace_cmd(
3054 "cid-miss",
3055 &island,
3056 "Hx",
3057 island.appearance.clone(),
3058 PAGE_WIDE,
3059 );
3060 let error = compose_candidate(&source, &[command], &FailingFontResolver)
3061 .expect_err("unknown character refused");
3062 assert_eq!(error.missing_chars(), Some(&['x'][..]));
3063 }
3064
3065 #[test]
3066 fn add_text_on_an_indirect_array_contents_page_keeps_the_stream_list_flat() {
3067 let doc = custom_page_pdf(
3068 |d| vec![("F1", helvetica_font(d))],
3069 &[b"BT /F1 12 Tf 72 700 Td ", b"(Existing) Tj ET"],
3070 true,
3071 );
3072 let source = doc_bytes(&doc);
3073 let command = PdfEditCommand::AddText {
3074 id: "add".to_string(),
3075 page: 1,
3076 anchor: PdfPoint { x: 100.0, y: 100.0 },
3077 text: "Hi".to_string(),
3078 appearance: appearance("test-font", 18.0),
3079 };
3080 let candidate =
3081 compose_candidate(&source, &[command], &test_resolver()).expect("add text composes");
3082 let (out, graph) = reinspect(&candidate, 1);
3083 assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
3084 assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
3085
3086 let page_id = out.get_pages()[&1];
3087 let contents = out
3088 .get_object(page_id)
3089 .expect("page")
3090 .as_dict()
3091 .expect("dict")
3092 .get(b"Contents")
3093 .expect("contents")
3094 .as_array()
3095 .expect("a direct array now");
3096 assert_eq!(contents.len(), 5);
3098 for item in contents {
3099 let id = item.as_reference().expect("reference");
3100 out.get_object(id)
3101 .expect("object")
3102 .as_stream()
3103 .expect("every entry is a stream, never the old array object");
3104 }
3105 }
3106
3107 #[test]
3108 fn unparseable_stream_tail_is_refused_rather_than_truncated_on_write() {
3109 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET (unterminated");
3111 let source = doc_bytes(&doc);
3112 let sha = document_sha256(&source);
3113 let island = locate_island(&doc, &sha, 1, "Hello");
3114 let command = replace_cmd(
3115 "tail",
3116 &island,
3117 "Howdy",
3118 island.appearance.clone(),
3119 PAGE_WIDE,
3120 );
3121 assert!(matches!(
3122 compose_candidate(&source, &[command], &FailingFontResolver),
3123 Err(PdfEditError::MalformedContent)
3124 ));
3125 }
3126 use crate::test_fixtures::tex_style_pdf;
3131
3132 const KERNED: &[u8] = b"BT /F1 10 Tf 72 700 Td [(hello) -333 (big) -333 (world)] TJ ET";
3133
3134 fn lefts(island: &crate::model::PdfTextIsland) -> Vec<f64> {
3135 island.quads.iter().map(|q| q[0]).collect()
3136 }
3137
3138 fn tj_shape(candidate: &ComposedCandidate) -> Vec<String> {
3140 let (out, _) = reinspect(candidate, 1);
3141 let page_id = out.get_pages()[&1];
3142 let ids = content_stream_ids(
3143 &out,
3144 out.get_object(page_id)
3145 .expect("page")
3146 .as_dict()
3147 .expect("dict")
3148 .get(b"Contents")
3149 .expect("contents"),
3150 )
3151 .expect("ids");
3152 let content = decode_stream_content(&out, ids[0]).expect("content");
3153 let tj = content
3154 .operations
3155 .iter()
3156 .find(|op| op.operator == "TJ")
3157 .expect("TJ");
3158 let Some(Object::Array(items)) = tj.operands.first() else {
3159 panic!("TJ array");
3160 };
3161 items
3162 .iter()
3163 .map(|item| match item {
3164 Object::String(bytes, _) => String::from_utf8_lossy(bytes).into_owned(),
3165 Object::Integer(n) => format!("<{n}>"),
3166 Object::Real(n) => format!("<{n}>"),
3167 other => panic!("{other:?}"),
3168 })
3169 .collect()
3170 }
3171
3172 fn edit_text(
3173 doc: &Document,
3174 island: &crate::model::PdfTextIsland,
3175 after: &str,
3176 ) -> Result<ComposedCandidate, PdfEditError> {
3177 let command = replace_cmd(
3178 "gap",
3179 island,
3180 after,
3181 island.appearance.clone(),
3182 island_box_wide(island),
3183 );
3184 compose_candidate(&doc_bytes(doc), &[command], &FailingFontResolver)
3185 }
3186
3187 fn island_box_wide(island: &crate::model::PdfTextIsland) -> PdfRect {
3189 let own = island_box(island);
3190 PdfRect {
3191 width: PAGE_WIDE.width - own.x,
3192 ..own
3193 }
3194 }
3195
3196 #[test]
3197 fn tj_word_gaps_become_spaces_in_island_text_with_recorded_widths() {
3198 let doc = tex_style_pdf(false, KERNED);
3199 let sha = document_sha256(&doc_bytes(&doc));
3200 let island = locate_island(&doc, &sha, 1, "hello big world");
3201 assert!(island.editable, "{:?}", island.edit_blocker);
3202 assert_eq!(island.quads.len(), 13, "gaps are not glyphs");
3203 let gaps: Vec<(usize, f64)> = island
3204 .gaps
3205 .iter()
3206 .map(|g| (g.char_index, g.width_pt))
3207 .collect();
3208 assert_eq!(gaps.len(), 2);
3209 assert_eq!((gaps[0].0, gaps[1].0), (5, 9));
3210 assert!(
3211 (gaps[0].1 - 3.33).abs() < 0.01 && (gaps[1].1 - 3.33).abs() < 0.01,
3212 "{gaps:?}"
3213 );
3214 let json = serde_json::to_value(&island).expect("serializes");
3215 assert_eq!(json["gaps"][0]["charIndex"], 5);
3216 }
3217
3218 #[test]
3219 fn small_kerns_letter_spacing_and_real_spaces_do_not_become_gaps() {
3220 let doc = build_pdf(b"BT /F1 12 Tf 3 Tc 72 700 Td [(h) (e) (l) (l) (o)] TJ ET");
3221 let sha = document_sha256(&doc_bytes(&doc));
3222 let island = locate_island(&doc, &sha, 1, "hello");
3223 assert!(island.gaps.is_empty());
3224
3225 let doc = tex_style_pdf(true, b"BT /F1 10 Tf 72 700 Td [(hello ) -333 (big)] TJ ET");
3226 let sha = document_sha256(&doc_bytes(&doc));
3227 let island = locate_island(&doc, &sha, 1, "hello big");
3228 assert!(
3229 island.gaps.is_empty(),
3230 "a real space already marks the word break"
3231 );
3232 }
3233
3234 #[test]
3235 fn word_rotation_keeps_every_gap_and_every_glyph_position() {
3236 for space_glyph in [false, true] {
3237 let doc = tex_style_pdf(space_glyph, KERNED);
3238 let sha = document_sha256(&doc_bytes(&doc));
3239 let island = locate_island(&doc, &sha, 1, "hello big world");
3240 let candidate = edit_text(&doc, &island, "hello big orldw").expect("composes");
3241 let (_, graph) = reinspect(&candidate, 1);
3242 let after = graph
3243 .islands
3244 .iter()
3245 .find(|i| i.text == "hello big orldw")
3246 .expect("island text equals the request, spaces included");
3247 assert_eq!(after.gaps.len(), 2);
3248 let (before_x, after_x) = (lefts(&island), lefts(after));
3249 assert_eq!(before_x.len(), after_x.len());
3250 for (b, a) in before_x.iter().zip(&after_x) {
3251 assert!((b - a).abs() < 0.01, "glyph moved: {b} -> {a}");
3252 }
3253 assert_eq!(
3255 tj_shape(&candidate),
3256 ["hello", "<-333>", "big", "<-333>", "orldw"]
3257 );
3258 }
3259 }
3260
3261 #[test]
3262 fn inserting_a_word_regenerates_a_gap_of_the_original_width() {
3263 let doc = tex_style_pdf(false, KERNED);
3264 let sha = document_sha256(&doc_bytes(&doc));
3265 let island = locate_island(&doc, &sha, 1, "hello big world");
3266 let candidate = edit_text(&doc, &island, "hello a big world").expect("composes");
3267 assert_eq!(
3268 tj_shape(&candidate),
3269 ["hello", "<-333>", "a", "<-333>", "big", "<-333>", "world"]
3270 );
3271 let (_, graph) = reinspect(&candidate, 1);
3272 let after = graph
3273 .islands
3274 .iter()
3275 .find(|i| i.text == "hello a big world")
3276 .expect("exact text");
3277 let (b, a) = (lefts(&island), lefts(after));
3278 for i in 0..5 {
3279 assert!((b[i] - a[i]).abs() < 0.01, "hello moved");
3280 }
3281 let shift = 500.0 / 1000.0 * 10.0 + 3.33;
3283 for i in 5..13 {
3284 assert!((a[i + 1] - b[i] - shift).abs() < 0.01, "glyph {i}");
3285 }
3286 }
3287
3288 #[test]
3289 fn removing_a_word_drops_its_gap_instead_of_leaving_two() {
3290 let doc = tex_style_pdf(false, KERNED);
3291 let sha = document_sha256(&doc_bytes(&doc));
3292 let island = locate_island(&doc, &sha, 1, "hello big world");
3293 let candidate = edit_text(&doc, &island, "hello world").expect("composes");
3294 assert_eq!(
3295 tj_shape(&candidate),
3296 ["hello", "<-333>", "", "<0>", "world"]
3297 );
3298 let (_, graph) = reinspect(&candidate, 1);
3299 let after = graph
3300 .islands
3301 .iter()
3302 .find(|i| i.text == "hello world")
3303 .expect("exact text");
3304 let (b, a) = (lefts(&island), lefts(after));
3305 let shift = 3.0 * 5.0 + 3.33;
3306 for i in 0..5 {
3307 assert!((b[i] - a[i]).abs() < 0.01, "hello moved");
3308 }
3309 for i in 8..13 {
3310 assert!((b[i] - a[i - 3] - shift).abs() < 0.01, "world glyph {i}");
3311 }
3312 }
3313
3314 #[test]
3315 fn a_real_space_glyph_is_used_when_the_font_has_one() {
3316 let doc = tex_style_pdf(true, b"BT /F1 10 Tf 72 700 Td [(hello) -333 (big)] TJ ET");
3317 let sha = document_sha256(&doc_bytes(&doc));
3318 let island = locate_island(&doc, &sha, 1, "hello big");
3319 let candidate = edit_text(&doc, &island, "hello a big").expect("composes");
3320 assert_eq!(tj_shape(&candidate), ["hello", "<-333>", "a big"]);
3321 let (_, graph) = reinspect(&candidate, 1);
3322 assert!(graph.islands.iter().any(|i| i.text == "hello a big"));
3323 }
3324
3325 #[test]
3326 fn a_new_space_with_no_glyph_and_no_reference_gap_is_refused_not_dropped() {
3327 let doc = tex_style_pdf(false, b"BT /F1 10 Tf 72 700 Td (hello) Tj ET");
3328 let sha = document_sha256(&doc_bytes(&doc));
3329 let island = locate_island(&doc, &sha, 1, "hello");
3330 let error = edit_text(&doc, &island, "hello a").expect_err("refused");
3331 assert_eq!(error.missing_chars(), Some(&[' '][..]));
3332 }
3333
3334 #[test]
3335 fn a_gap_made_by_a_position_move_is_a_positioned_run_and_not_editable() {
3336 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj 35 0 Td (World) Tj ET");
3337 let sha = document_sha256(&doc_bytes(&doc));
3338 let island = locate_island(&doc, &sha, 1, "Hello World");
3339 assert_eq!(island.gaps.len(), 1);
3340 assert!(!island.editable);
3341 assert_eq!(
3342 island.edit_blocker,
3343 Some(crate::model::PdfEditBlocker::PositionedTextRun)
3344 );
3345 let error = edit_text(&doc, &island, "Hallo World").expect_err("refused");
3346 assert!(
3347 matches!(error, PdfEditError::UnsupportedEncoding),
3348 "{error}"
3349 );
3350 }
3351 #[test]
3352 fn a_positioning_operator_between_two_show_operators_makes_the_run_positioned() {
3353 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hel) Tj 18 0 Td (lo) Tj ET");
3356 let sha = document_sha256(&doc_bytes(&doc));
3357 let island = locate_island(&doc, &sha, 1, "Hello");
3358 assert!(!island.editable);
3359 assert_eq!(
3360 island.edit_blocker,
3361 Some(crate::model::PdfEditBlocker::PositionedTextRun)
3362 );
3363 }
3364
3365 #[test]
3366 fn same_length_edit_overwrites_glyphs_in_place_and_keeps_inner_kerns() {
3367 let doc = tex_style_pdf(
3368 false,
3369 b"BT /F1 10 Tf 72 700 Td [(h) 40 (e) 40 (llo) -333 (big)] TJ ET",
3370 );
3371 let sha = document_sha256(&doc_bytes(&doc));
3372 let island = locate_island(&doc, &sha, 1, "hello big");
3373 let candidate = edit_text(&doc, &island, "elloh big").expect("composes");
3374 assert_eq!(
3375 tj_shape(&candidate),
3376 ["e", "<40>", "l", "<40>", "loh", "<-333>", "big"]
3377 );
3378 let (_, graph) = reinspect(&candidate, 1);
3379 let after = graph
3380 .islands
3381 .iter()
3382 .find(|i| i.text == "elloh big")
3383 .expect("gap survives: exact text");
3384 for (b, a) in lefts(&island).iter().zip(lefts(after)) {
3385 assert!((b - a).abs() < 0.01);
3386 }
3387 }
3388
3389 fn rect(width: f64) -> PdfRect {
3394 PdfRect {
3395 x: 72.0,
3396 y: 0.0,
3397 width,
3398 height: 800.0,
3399 }
3400 }
3401
3402 fn island_after(
3404 candidate: &ComposedCandidate,
3405 text: &str,
3406 ) -> (crate::model::PdfTextIsland, f64) {
3407 let (_, graph) = reinspect(candidate, 1);
3408 let island = graph
3409 .islands
3410 .into_iter()
3411 .find(|i| i.text == text)
3412 .unwrap_or_else(|| panic!("no island {text:?} in the candidate"));
3413 let width = island_box(&island).width;
3414 (island, width)
3415 }
3416
3417 #[test]
3418 fn finding2_array_cmap_edit_writes_the_codes_the_reader_sees() {
3419 let doc = custom_page_pdf(
3421 |d| {
3422 vec![(
3423 "F1",
3424 crate::test_fixtures::identity_h_font_with_cmap(
3425 d,
3426 &[(1, 500), (2, 500), (3, 500)],
3427 crate::test_fixtures::raw_to_unicode_cmap(
3428 "1 beginbfrange\n<0001> <0003> [<0041> <005A> <0042>]\nendbfrange",
3429 ),
3430 ),
3431 )]
3432 },
3433 &[b"BT /F1 12 Tf 72 700 Td <000100020003> Tj ET"],
3434 false,
3435 );
3436 let source = doc_bytes(&doc);
3437 let sha = document_sha256(&source);
3438 let island = locate_island(&doc, &sha, 1, "AZB");
3439
3440 let command = replace_cmd("c", &island, "ACB", island.appearance.clone(), PAGE_WIDE);
3442 let error = compose_candidate(&source, &[command], &FailingFontResolver)
3443 .expect_err("C has no code in this font");
3444 assert_eq!(error.missing_chars(), Some(&['C'][..]));
3445
3446 let command = replace_cmd("c", &island, "BZA", island.appearance.clone(), PAGE_WIDE);
3447 let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
3448 .expect("letters the font has");
3449 let (_, graph) = reinspect(&candidate, 1);
3450 assert!(graph.islands.iter().any(|i| i.text == "BZA"));
3451 }
3452
3453 #[test]
3454 fn finding4_flattened_edit_leaves_no_internal_kerns_and_fits_what_it_wrote() {
3455 let doc = tex_style_pdf(
3458 false,
3459 b"BT /F1 10 Tf 72 700 Td [(h) -100 (e) -100 (llo) -333 (big)] TJ ET",
3460 );
3461 let source = doc_bytes(&doc);
3462 let sha = document_sha256(&source);
3463 let island = locate_island(&doc, &sha, 1, "hello big");
3464 assert!((island_box(&island).width - 45.33).abs() < 0.01);
3466
3467 let command = replace_cmd(
3468 "flat",
3469 &island,
3470 "aa big",
3471 island.appearance.clone(),
3472 rect(28.33),
3473 );
3474 let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
3475 .expect("fits the width it predicted");
3476 let (written, width) = island_after(&candidate, "aa big");
3477 assert!(
3478 (width - 28.33).abs() < 0.01,
3479 "predicted 28.33 pt, page draws {width} pt"
3480 );
3481 assert_eq!(written.gaps.len(), 1);
3482 let shape = tj_shape(&candidate);
3484 assert!(shape.contains(&"<-333>".to_string()), "{shape:?}");
3485 assert!(!shape.contains(&"<-100>".to_string()), "{shape:?}");
3486 }
3487
3488 #[test]
3489 fn finding4_flatten_across_two_operators_zeroes_the_kerns_between_them() {
3490 let doc = tex_style_pdf(
3491 false,
3492 b"BT /F1 10 Tf 72 700 Td [(he) -100 (l)] TJ [(lo) -333 (big)] TJ ET",
3493 );
3494 let source = doc_bytes(&doc);
3495 let sha = document_sha256(&source);
3496 let island = locate_island(&doc, &sha, 1, "hello big");
3497 let command = replace_cmd(
3498 "flat2",
3499 &island,
3500 "aa big",
3501 island.appearance.clone(),
3502 rect(28.33),
3503 );
3504 let candidate =
3505 compose_candidate(&source, &[command], &FailingFontResolver).expect("composes");
3506 let (_, width) = island_after(&candidate, "aa big");
3507 assert!((width - 28.33).abs() < 0.01, "{width}");
3508 }
3509
3510 #[test]
3511 fn finding5_same_length_edit_that_adds_a_space_accounts_for_word_spacing() {
3512 let doc = tex_style_pdf(true, b"BT /F1 10 Tf 10 Tw 72 700 Td (axb) Tj ET");
3515 let source = doc_bytes(&doc);
3516 let sha = document_sha256(&source);
3517 let island = locate_island(&doc, &sha, 1, "axb");
3518 let own = island_box(&island);
3519 assert!((own.width - 15.0).abs() < 0.01);
3520
3521 let command = replace_cmd("tw", &island, "a b", island.appearance.clone(), own);
3522 assert!(matches!(
3523 compose_candidate(&source, &[command], &FailingFontResolver),
3524 Err(PdfEditError::TextOverflow)
3525 ));
3526
3527 let command = replace_cmd("tw", &island, "a b", island.appearance.clone(), rect(22.5));
3528 let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
3529 .expect("fits when given the width it needs");
3530 let (_, width) = island_after(&candidate, "a b");
3531 assert!((width - 22.5).abs() < 0.01, "{width}");
3532 }
3533
3534 #[test]
3535 fn finding5_removing_a_space_shrinks_by_word_spacing_too() {
3536 let doc = tex_style_pdf(true, b"BT /F1 10 Tf 10 Tw 72 700 Td (a b) Tj ET");
3537 let source = doc_bytes(&doc);
3538 let sha = document_sha256(&source);
3539 let island = locate_island(&doc, &sha, 1, "a b");
3540 assert!((island_box(&island).width - 22.5).abs() < 0.01);
3541 let command = replace_cmd("tw", &island, "axb", island.appearance.clone(), rect(15.0));
3542 let candidate =
3543 compose_candidate(&source, &[command], &FailingFontResolver).expect("narrower fits");
3544 let (_, width) = island_after(&candidate, "axb");
3545 assert!((width - 15.0).abs() < 0.01, "{width}");
3546 }
3547
3548 #[test]
3549 fn finding6_editing_one_page_leaves_a_page_that_shares_its_stream_alone() {
3550 let mut doc = Document::with_version("1.7");
3551 let pages_id = doc.new_object_id();
3552 let font = helvetica_font(&mut doc);
3553 let resources = doc.add_object(dictionary! { "Font" => dictionary! { "F1" => font } });
3554 let shared = doc.add_object(Stream::new(
3555 dictionary! {},
3556 b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET".to_vec(),
3557 ));
3558 let mut page_ids = Vec::new();
3559 for _ in 0..2 {
3560 page_ids.push(doc.add_object(dictionary! {
3561 "Type" => "Page",
3562 "Parent" => pages_id,
3563 "Contents" => shared,
3564 "Resources" => resources,
3565 "MediaBox" => Object::Array(vec![
3566 Object::Integer(0),
3567 Object::Integer(0),
3568 Object::Integer(612),
3569 Object::Integer(792),
3570 ]),
3571 }));
3572 }
3573 doc.objects.insert(
3574 pages_id,
3575 Object::Dictionary(dictionary! {
3576 "Type" => "Pages",
3577 "Kids" => page_ids.iter().map(|id| Object::Reference(*id)).collect::<Vec<_>>(),
3578 "Count" => 2,
3579 }),
3580 );
3581 let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
3582 doc.trailer.set("Root", catalog);
3583
3584 let source = doc_bytes(&doc);
3585 let sha = document_sha256(&source);
3586 let island = locate_island(&doc, &sha, 1, "Hello");
3587 let command = replace_cmd("p1", &island, "Howdy", island.appearance.clone(), PAGE_WIDE);
3588 let candidate =
3589 compose_candidate(&source, &[command], &FailingFontResolver).expect("composes");
3590 assert_eq!(candidate.affected_pages, vec![1]);
3592
3593 let (_, page1) = reinspect(&candidate, 1);
3594 let (_, page2) = reinspect(&candidate, 2);
3595 assert!(page1.islands.iter().any(|i| i.text == "Howdy"));
3596 assert!(!page1.islands.iter().any(|i| i.text == "Hello"));
3597 assert!(
3598 page2.islands.iter().any(|i| i.text == "Hello"),
3599 "page 2 must still read Hello: {:?}",
3600 page2.islands.iter().map(|i| &i.text).collect::<Vec<_>>()
3601 );
3602 assert!(!page2.islands.iter().any(|i| i.text == "Howdy"));
3603
3604 let island2 = {
3607 let graph = walk_page_graph(&doc, &sha, 2).expect("page 2");
3608 graph.islands.into_iter().next().expect("island")
3609 };
3610 let c1 = replace_cmd("p1", &island, "Howdy", island.appearance.clone(), PAGE_WIDE);
3611 let c2 = replace_cmd("p2", &island2, "Hi", island2.appearance.clone(), PAGE_WIDE);
3612 let outcome = compose_candidate(&source, &[c1, c2], &FailingFontResolver);
3613 assert!(
3614 matches!(outcome, Err(PdfEditError::SourceDigestMismatch)),
3615 "{:?}",
3616 outcome.map(|c| c.affected_pages)
3617 );
3618 }
3619
3620 #[test]
3621 fn finding7_non_editable_island_with_unchanged_appearance_is_refused_by_the_composer() {
3622 let doc = build_pdf(b"BT /F1 10 Tf 72 700 Td (A) Tj 6.67 0 Td (B) Tj ET");
3625 let source = doc_bytes(&doc);
3626 let sha = document_sha256(&source);
3627 let island = locate_island(&doc, &sha, 1, "AB");
3628 assert!(!island.editable);
3629 assert_eq!(
3630 island.edit_blocker,
3631 Some(crate::model::PdfEditBlocker::PositionedTextRun)
3632 );
3633 let resolver = TestFontResolver {
3634 id: "F1",
3635 bytes: installable_font(),
3636 };
3637 assert!(resolver.resolve("F1").is_ok());
3639 assert!(resolver
3640 .glyphs_for_text(&resolver.resolve("F1").unwrap(), "AC")
3641 .is_ok());
3642
3643 let command = replace_cmd("ac", &island, "AC", island.appearance.clone(), PAGE_WIDE);
3644 let outcome = compose_candidate(&source, &[command], &resolver);
3645 assert!(
3646 matches!(outcome, Err(PdfEditError::UnsupportedEncoding)),
3647 "{:?}",
3648 outcome.map(|c| c.bytes.len())
3649 );
3650 }
3651
3652 #[test]
3653 fn finding7_every_blocker_is_refused_before_strategy_selection() {
3654 let doc = custom_page_pdf(
3656 |d| {
3657 vec![
3658 ("F1", helvetica_font(d)),
3659 ("F2", subset_type1_font(d, 65, &[500, 600], None, None)),
3660 ]
3661 },
3662 &[
3663 b"BT /F2 12 Tf 72 600 Td (AB) Tj ET BT /F1 12 Tf 72 500 Td (Hel) Tj ET \
3664 BT /F1 12 Tf 72 400 Td (Big) Tj /F1 20 Tf (Bad) Tj ET",
3665 ],
3666 false,
3667 );
3668 let source = doc_bytes(&doc);
3669 let sha = document_sha256(&source);
3670 let graph = walk_page_graph(&doc, &sha, 1).expect("graph");
3671 let resolver = TestFontResolver {
3672 id: "F1",
3673 bytes: installable_font(),
3674 };
3675 let mut refused = 0;
3676 for island in graph.islands.iter().filter(|i| !i.editable) {
3677 let command = replace_cmd(
3678 "same",
3679 island,
3680 &island.text,
3681 island.appearance.clone(),
3682 PAGE_WIDE,
3683 );
3684 assert!(
3685 matches!(
3686 compose_candidate(&source, &[command], &resolver),
3687 Err(PdfEditError::UnsupportedEncoding)
3688 ),
3689 "{:?} ({:?})",
3690 island.text,
3691 island.edit_blocker
3692 );
3693 refused += 1;
3694 }
3695 assert!(refused >= 2, "fixture must contain non-editable islands");
3696 }
3697
3698 #[test]
3699 fn finding7_a_changed_appearance_still_redraws_a_non_editable_island() {
3700 let doc = build_pdf(b"BT /F1 10 Tf 72 700 Td (A) Tj 6.67 0 Td (B) Tj ET");
3702 let source = doc_bytes(&doc);
3703 let sha = document_sha256(&source);
3704 let island = locate_island(&doc, &sha, 1, "AB");
3705 assert!(!island.editable && island.substitution_editable);
3706 let command = replace_cmd(
3707 "sub",
3708 &island,
3709 "Hi",
3710 appearance("test-font", 12.0),
3711 PdfRect {
3712 x: 72.0,
3713 y: 690.0,
3714 width: 200.0,
3715 height: 30.0,
3716 },
3717 );
3718 compose_candidate(&source, &[command], &test_resolver()).expect("substitution composes");
3719 }
3720
3721 #[test]
3722 fn finding8_repeated_spaces_in_a_space_less_font_are_refused_not_returned() {
3723 let doc = tex_style_pdf(false, b"BT /F1 10 Tf 72 700 Td [(hello) -333 (big)] TJ ET");
3724 let source = doc_bytes(&doc);
3725 let sha = document_sha256(&source);
3726 let island = locate_island(&doc, &sha, 1, "hello big");
3727 for after in ["hello big", "hello big ", " hello big"] {
3728 let outcome = edit_text(&doc, &island, after);
3729 let error = outcome.expect_err(after);
3730 assert_eq!(error.missing_chars(), Some(&[' '][..]), "{after:?}");
3731 }
3732 edit_text(&doc, &island, "hello bog").expect("unchanged spacing");
3734 }
3735
3736 #[test]
3737 fn composed_text_always_reads_back_as_requested_or_is_refused() {
3738 let doc = tex_style_pdf(true, b"BT /F1 10 Tf 72 700 Td (ab) Tj ET");
3740 let source = doc_bytes(&doc);
3741 let sha = document_sha256(&source);
3742 let island = locate_island(&doc, &sha, 1, "ab");
3743 let outcome = verify_written(&doc, 1, &island, "zz", PAGE_WIDE);
3745 assert!(matches!(outcome, Err(PdfEditError::VerificationFailed)));
3746 verify_written(&doc, 1, &island, "ab", PAGE_WIDE).expect("matches what is there");
3747 }
3748 use crate::test_fixtures::{code_32_letter_font, code_5_space_font, inherited_resources_pdf};
3753
3754 fn add_hi(id: &str) -> PdfEditCommand {
3755 PdfEditCommand::AddText {
3756 id: id.to_string(),
3757 page: 1,
3758 anchor: PdfPoint { x: 100.0, y: 100.0 },
3759 text: "Hi".to_string(),
3760 appearance: appearance("test-font", 18.0),
3761 }
3762 }
3763
3764 fn appended_operations(doc: &Document, page: u32) -> Vec<Operation> {
3766 let page_id = doc.get_pages()[&page];
3767 let contents = doc
3768 .get_object(page_id)
3769 .expect("page")
3770 .as_dict()
3771 .expect("dict")
3772 .get(b"Contents")
3773 .expect("contents");
3774 let ids = content_stream_ids(doc, contents).expect("ids");
3775 decode_stream_content(doc, *ids.last().expect("a stream"))
3776 .expect("content")
3777 .operations
3778 }
3779
3780 #[test]
3781 fn finding2_adding_a_font_to_a_page_that_inherits_its_resources_keeps_what_it_inherited() {
3782 let doc = inherited_resources_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET /Im1 Do");
3785 let source = doc_bytes(&doc);
3786 let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
3787 .expect("add text composes");
3788 let (out, graph) = reinspect(&candidate, 1);
3789
3790 let existing = graph
3791 .islands
3792 .iter()
3793 .find(|island| island.text == "Existing")
3794 .expect("the inherited font still decodes the existing line");
3795 assert!(existing.editable, "{:?}", existing.edit_blocker);
3796 assert!(graph.islands.iter().any(|island| island.text == "Hi"));
3797
3798 let fingerprint = inspect::resource_fingerprint(&out).expect("fingerprint");
3799 let names: Vec<String> = fingerprint[&1]
3800 .keys()
3801 .map(|(category, name)| {
3802 format!(
3803 "{}/{}",
3804 String::from_utf8_lossy(category),
3805 String::from_utf8_lossy(name)
3806 )
3807 })
3808 .collect();
3809 assert_eq!(names, ["Font/F1", "Font/VREdit1", "XObject/Im1"]);
3810
3811 let page_id = out.get_pages()[&1];
3813 let parent = out
3814 .get_object(page_id)
3815 .expect("page")
3816 .as_dict()
3817 .expect("dict")
3818 .get(b"Parent")
3819 .expect("parent")
3820 .as_reference()
3821 .expect("reference");
3822 let inherited_fonts = out
3823 .get_object(parent)
3824 .expect("pages")
3825 .as_dict()
3826 .expect("dict")
3827 .get(b"Resources")
3828 .expect("resources")
3829 .as_dict()
3830 .expect("dict")
3831 .get(b"Font")
3832 .expect("fonts")
3833 .as_dict()
3834 .expect("dict");
3835 assert_eq!(inherited_fonts.len(), 1);
3836 assert!(inherited_fonts.has(b"F1"));
3837 }
3838
3839 #[test]
3840 fn finding2_a_second_added_text_extends_the_page_local_copy() {
3841 let doc = inherited_resources_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
3842 let source = doc_bytes(&doc);
3843 let candidate = compose_candidate(&source, &[add_hi("a"), add_hi("b")], &test_resolver())
3844 .expect("two adds compose");
3845 let (out, graph) = reinspect(&candidate, 1);
3846 assert!(graph.islands.iter().any(|island| island.text == "Existing"));
3847 let fingerprint = inspect::resource_fingerprint(&out).expect("fingerprint");
3848 let fonts: Vec<&[u8]> = fingerprint[&1]
3849 .keys()
3850 .filter(|(category, _)| category.as_slice() == b"Font")
3851 .map(|(_, name)| name.as_slice())
3852 .collect();
3853 assert_eq!(fonts, [&b"F1"[..], &b"VREdit1"[..], &b"VREdit2"[..]]);
3854 }
3855
3856 #[test]
3857 fn finding2_an_unresolvable_resource_chain_is_refused_not_overwritten() {
3858 let mut doc = inherited_resources_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
3861 let page_id = doc.get_pages()[&1];
3862 let pages_id = doc
3863 .get_object(page_id)
3864 .expect("page")
3865 .as_dict()
3866 .expect("dict")
3867 .get(b"Parent")
3868 .expect("parent")
3869 .as_reference()
3870 .expect("reference");
3871 if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
3872 pages.remove(b"Resources");
3873 pages.set("Parent", Object::Reference(page_id));
3874 }
3875 let outcome = compose_candidate(&doc_bytes(&doc), &[add_hi("a")], &test_resolver());
3876 assert!(
3877 matches!(outcome, Err(PdfEditError::MalformedContent)),
3878 "{:?}",
3879 outcome.map(|c| c.bytes.len())
3880 );
3881 }
3882
3883 #[test]
3884 fn finding7_added_text_does_not_inherit_the_pages_trailing_text_state() {
3885 let doc = build_pdf(b"BT /F1 10 Tf 3 Tr 5 Tc 7 Tw 50 Tz 4 Ts (hidden) Tj ET");
3888 let source = doc_bytes(&doc);
3889 let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
3890 .expect("add text composes");
3891 let (out, graph) = reinspect(&candidate, 1);
3892
3893 let added = graph
3895 .islands
3896 .iter()
3897 .find(|island| island.text == "Hi")
3898 .expect("added island");
3899 let layout = added.layout.expect("layout");
3900 assert_eq!(
3901 (
3902 layout.char_spacing,
3903 layout.word_spacing,
3904 layout.horizontal_scale
3905 ),
3906 (0.0, 0.0, 1.0)
3907 );
3908 assert!(
3909 (added.quads[0][1] - (100.0 - 0.25 * 18.0)).abs() < 1e-9,
3910 "no inherited text rise: {}",
3911 added.quads[0][1]
3912 );
3913
3914 let ops = appended_operations(&out, 1);
3916 let names: Vec<&str> = ops.iter().map(|op| op.operator.as_str()).collect();
3917 assert_eq!(
3918 names,
3919 ["q", "BT", "Tf", "Tr", "Tc", "Tw", "Tz", "Ts", "rg", "Tm", "Tj", "ET", "Q"]
3920 );
3921 let operand = |name: &str| {
3922 ops.iter()
3923 .find(|op| op.operator == name)
3924 .map(|op| op.operands.clone())
3925 };
3926 assert_eq!(operand("Tr"), Some(vec![Object::Integer(0)]));
3927 assert_eq!(operand("Tc"), Some(vec![Object::Integer(0)]));
3928 assert_eq!(operand("Tw"), Some(vec![Object::Integer(0)]));
3929 assert_eq!(operand("Tz"), Some(vec![Object::Integer(100)]));
3930 assert_eq!(operand("Ts"), Some(vec![Object::Integer(0)]));
3931 }
3932
3933 #[test]
3934 fn finding7_the_ocr_correction_text_block_resets_the_same_state() {
3935 let doc = build_pdf(b"BT /F1 10 Tf 3 Tr 5 Tc (hidden) Tj ET");
3936 let source = doc_bytes(&doc);
3937 let command = ocr_command(
3938 "ocr",
3939 "line-1",
3940 "Hi",
3941 PdfRect {
3942 x: 100.0,
3943 y: 100.0,
3944 width: 100.0,
3945 height: 30.0,
3946 },
3947 );
3948 let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
3949 let (out, _) = reinspect(&candidate, 1);
3950 let ops = appended_operations(&out, 1);
3951 let names: Vec<&str> = ops.iter().map(|op| op.operator.as_str()).collect();
3952 assert_eq!(
3953 names,
3954 [
3955 "q", "rg", "re", "f", "Q", "q", "BT", "Tf", "Tr", "Tc", "Tw", "Tz", "Ts", "rg", "Tm", "Tj", "ET", "Q"
3957 ]
3958 );
3959 }
3960
3961 fn nesting_before_last_stream(doc: &Document, page: u32) -> (i64, i64, usize) {
3968 let page_id = doc.get_pages()[&page];
3969 let contents = doc
3970 .get_object(page_id)
3971 .expect("page")
3972 .as_dict()
3973 .expect("dict")
3974 .get(b"Contents")
3975 .expect("contents");
3976 let ids = content_stream_ids(doc, contents).expect("ids");
3977 let (mut depth, mut lowest) = (0_i64, 0_i64);
3978 for id in &ids[..ids.len() - 1] {
3979 for op in decode_stream_content(doc, *id).expect("content").operations {
3980 match op.operator.as_str() {
3981 "q" => depth += 1,
3982 "Q" => {
3983 depth -= 1;
3984 lowest = lowest.min(depth);
3985 }
3986 _ => {}
3987 }
3988 }
3989 }
3990 (depth, lowest, ids.len())
3991 }
3992
3993 #[test]
3994 fn finding4_added_text_runs_outside_the_original_graphics_state() {
3995 let cases: [(&str, &[u8]); 5] = [
3998 (
3999 "clip",
4000 b"0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Existing) Tj ET",
4001 ),
4002 ("alpha", b"/GS0 gs BT /F1 12 Tf 72 700 Td (Existing) Tj ET"),
4003 (
4004 "open q",
4005 b"q q 0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Existing) Tj ET",
4006 ),
4007 ("stray Q", b"Q Q BT /F1 12 Tf 72 700 Td (Existing) Tj ET"),
4008 (
4009 "balanced",
4010 b"q 2 0 0 2 0 0 cm BT /F1 12 Tf 36 350 Td (Existing) Tj ET Q",
4011 ),
4012 ];
4013 for (label, content) in cases {
4014 let doc = build_pdf(content);
4015 let source = doc_bytes(&doc);
4016 let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
4017 .unwrap_or_else(|e| panic!("{label}: {e:?}"));
4018 let (out, graph) = reinspect(&candidate, 1);
4019 assert!(
4020 graph.islands.iter().any(|island| island.text == "Hi"),
4021 "{label}"
4022 );
4023 let (depth, lowest, streams) = nesting_before_last_stream(&out, 1);
4024 assert_eq!(depth, 0, "{label}: the added stream starts at depth zero");
4025 assert_eq!(lowest, 0, "{label}: the wrapper is never popped early");
4026 assert_eq!(streams, 4, "{label}: q wrapper, original, Q wrapper, added");
4027 let names: Vec<String> = appended_operations(&out, 1)
4029 .iter()
4030 .map(|op| op.operator.clone())
4031 .collect();
4032 assert_eq!(
4033 names,
4034 ["q", "BT", "Tf", "Tr", "Tc", "Tw", "Tz", "Ts", "rg", "Tm", "Tj", "ET", "Q"],
4035 "{label}"
4036 );
4037 }
4038 }
4039
4040 #[test]
4041 fn finding4_the_original_content_stream_is_not_modified() {
4042 let doc = build_pdf(b"0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
4043 let page_id = doc.get_pages()[&1];
4044 let original_ids = content_stream_ids(
4045 &doc,
4046 doc.get_object(page_id)
4047 .expect("page")
4048 .as_dict()
4049 .expect("dict")
4050 .get(b"Contents")
4051 .expect("contents"),
4052 )
4053 .expect("ids");
4054 let original = decode_stream_content(&doc, original_ids[0]).expect("content");
4055 let source = doc_bytes(&doc);
4056 let candidate =
4057 compose_candidate(&source, &[add_hi("a")], &test_resolver()).expect("composes");
4058 let (out, _) = reinspect(&candidate, 1);
4059 let kept = decode_stream_content(&out, original_ids[0]).expect("content");
4060 assert_eq!(
4061 kept.encode().expect("encode"),
4062 original.encode().expect("encode")
4063 );
4064 }
4065
4066 #[test]
4067 fn finding4_a_replacement_in_a_new_font_is_isolated_from_the_streams_clip() {
4068 let doc = build_pdf(b"0 0 1 1 re W n BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
4069 let source = doc_bytes(&doc);
4070 let sha = document_sha256(&source);
4071 let island = locate_island(&doc, &sha, 1, "Hello");
4072 let command = replace_cmd(
4073 "new-font",
4074 &island,
4075 "Hi",
4076 appearance("test-font", 24.0),
4077 PdfRect {
4078 x: 72.0,
4079 y: 690.0,
4080 width: 400.0,
4081 height: 40.0,
4082 },
4083 );
4084 let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
4085 let (out, graph) = reinspect(&candidate, 1);
4086 assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
4087 let (depth, lowest, _) = nesting_before_last_stream(&out, 1);
4088 assert_eq!((depth, lowest), (0, 0));
4089 }
4090
4091 #[test]
4092 fn finding5_a_locator_missing_one_of_the_islands_spans_is_refused() {
4093 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (AB) Tj (CD) Tj ET");
4094 let source = doc_bytes(&doc);
4095 let sha = document_sha256(&source);
4096 let island = locate_island(&doc, &sha, 1, "ABCD");
4097 assert_eq!(island.locator.spans.len(), 2);
4098 let bounds = PdfRect {
4099 x: 72.0,
4100 y: 690.0,
4101 width: 400.0,
4102 height: 40.0,
4103 };
4104 let changed_font = appearance("test-font", 24.0);
4105
4106 let mut partial = replace_cmd("partial", &island, "Hi", changed_font.clone(), bounds);
4108 if let PdfEditCommand::ReplaceSourceText { locator, .. } = &mut partial {
4109 locator.spans.truncate(1);
4110 }
4111 assert!(matches!(
4112 compose_candidate(&source, &[partial], &test_resolver()),
4113 Err(PdfEditError::SourceDigestMismatch)
4114 ));
4115
4116 let mut reordered = replace_cmd("reordered", &island, "Hi", changed_font.clone(), bounds);
4118 if let PdfEditCommand::ReplaceSourceText { locator, .. } = &mut reordered {
4119 locator.spans.reverse();
4120 }
4121 assert!(matches!(
4122 compose_candidate(&source, &[reordered], &test_resolver()),
4123 Err(PdfEditError::SourceDigestMismatch)
4124 ));
4125 let mut repeated = replace_cmd("repeated", &island, "Hi", changed_font.clone(), bounds);
4126 if let PdfEditCommand::ReplaceSourceText { locator, .. } = &mut repeated {
4127 let first = locator.spans[0].clone();
4128 locator.spans = vec![first.clone(), first];
4129 }
4130 assert!(matches!(
4131 compose_candidate(&source, &[repeated], &test_resolver()),
4132 Err(PdfEditError::SourceDigestMismatch)
4133 ));
4134
4135 let whole = replace_cmd("whole", &island, "Hi", changed_font, bounds);
4137 let candidate =
4138 compose_candidate(&source, &[whole], &test_resolver()).expect("complete locator");
4139 let (_, graph) = reinspect(&candidate, 1);
4140 assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
4141 assert!(!graph
4142 .islands
4143 .iter()
4144 .any(|isl| isl.text.contains("AB") || isl.text.contains("CD")));
4145 }
4146
4147 #[test]
4148 fn finding4_replacing_with_code_32_pays_the_word_spacing_the_page_will_draw() {
4149 let doc = custom_page_pdf(
4151 |d| vec![("F1", code_32_letter_font(d))],
4152 &[b"BT /F1 10 Tf 10 Tw 72 700 Td (aab) Tj ET"],
4153 false,
4154 );
4155 let source = doc_bytes(&doc);
4156 let sha = document_sha256(&source);
4157 let island = locate_island(&doc, &sha, 1, "aab");
4158 assert!(island.editable, "{:?}", island.edit_blocker);
4159 let own = island_box(&island);
4160 assert!((own.width - 15.0).abs() < 0.01, "{}", own.width);
4161
4162 let command = replace_cmd("tw", &island, "axb", island.appearance.clone(), own);
4164 assert!(matches!(
4165 compose_candidate(&source, &[command], &FailingFontResolver),
4166 Err(PdfEditError::TextOverflow)
4167 ));
4168
4169 let command = replace_cmd("tw", &island, "axb", island.appearance.clone(), rect(25.0));
4171 let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
4172 .expect("fits the width it needs");
4173 let (written, width) = island_after(&candidate, "axb");
4174 assert!((width - 25.0).abs() < 0.01, "{width}");
4175 assert!(
4176 written.gaps.is_empty(),
4177 "the Tw jump is not read back as a gap"
4178 );
4179 }
4180
4181 #[test]
4182 fn finding4_removing_code_32_gives_back_its_word_spacing() {
4183 let doc = custom_page_pdf(
4184 |d| vec![("F1", code_32_letter_font(d))],
4185 &[b"BT /F1 10 Tf 10 Tw 72 700 Td (a b) Tj ET"],
4186 false,
4187 );
4188 let source = doc_bytes(&doc);
4189 let sha = document_sha256(&source);
4190 let island = locate_island(&doc, &sha, 1, "axb");
4191 assert!((island_box(&island).width - 25.0).abs() < 0.01);
4192 let command = replace_cmd("tw", &island, "aab", island.appearance.clone(), rect(15.0));
4193 let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
4194 .expect("15 pt is enough once the code-32 glyph is gone");
4195 let (_, width) = island_after(&candidate, "aab");
4196 assert!((width - 15.0).abs() < 0.01, "{width}");
4197 }
4198
4199 #[test]
4200 fn finding4_a_space_through_another_code_is_not_charged_word_spacing_in_the_fit() {
4201 let doc = custom_page_pdf(
4203 |d| vec![("F1", code_5_space_font(d))],
4204 &[b"BT /F1 10 Tf 10 Tw 72 700 Td <610562> Tj ET"],
4205 false,
4206 );
4207 let source = doc_bytes(&doc);
4208 let sha = document_sha256(&source);
4209 let island = locate_island(&doc, &sha, 1, "a b");
4210 let own = island_box(&island);
4211 assert!((own.width - 12.5).abs() < 0.01, "{}", own.width);
4212
4213 let command = replace_cmd("tw", &island, "aab", island.appearance.clone(), own);
4214 assert!(
4215 matches!(
4216 compose_candidate(&source, &[command], &FailingFontResolver),
4217 Err(PdfEditError::TextOverflow)
4218 ),
4219 "the letter is 2.5 pt wider than the space it replaces, with no Tw to give back"
4220 );
4221 let command = replace_cmd("tw", &island, "aab", island.appearance.clone(), rect(15.0));
4222 let candidate =
4223 compose_candidate(&source, &[command], &FailingFontResolver).expect("fits 15 pt");
4224 let (_, width) = island_after(&candidate, "aab");
4225 assert!((width - 15.0).abs() < 0.01, "{width}");
4226 }
4227 #[test]
4228 fn finding2_a_composed_add_text_passes_staging_against_its_inheriting_base() {
4229 let source = doc_bytes(&inherited_resources_pdf(
4231 b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET /Im1 Do",
4232 ));
4233 let sha = document_sha256(&source);
4234 let command = add_hi("a");
4235 let candidate =
4236 compose_candidate(&source, std::slice::from_ref(&command), &test_resolver())
4237 .expect("composes");
4238 let store = crate::save::PdfSaveTokenStore::default();
4239 crate::save::stage_prepared_save_against_base(
4240 &store,
4241 "/doc.pdf",
4242 &sha,
4243 &sha,
4244 &source,
4245 candidate.bytes,
4246 candidate.affected_pages,
4247 crate::save::expected_text_checks(std::slice::from_ref(&command)),
4248 )
4249 .expect("an Add Text hides nothing the page resolved");
4250 }
4251
4252 fn page_islands(bytes: &[u8]) -> Vec<crate::model::PdfTextIsland> {
4258 let doc = Document::load_mem(bytes).expect("reopen");
4259 walk_page_graph(&doc, &document_sha256(bytes), 1)
4260 .expect("inspect")
4261 .islands
4262 }
4263
4264 fn changed_font_cmd(
4265 id: &str,
4266 island: &crate::model::PdfTextIsland,
4267 text: &str,
4268 x: f64,
4269 ) -> PdfEditCommand {
4270 replace_cmd(
4271 id,
4272 island,
4273 text,
4274 appearance("test-font", 24.0),
4275 PdfRect {
4276 x,
4277 y: 650.0,
4278 width: 200.0,
4279 height: 40.0,
4280 },
4281 )
4282 }
4283
4284 fn add_text_cmd(id: &str, text: &str) -> PdfEditCommand {
4285 PdfEditCommand::AddText {
4286 id: id.to_string(),
4287 page: 1,
4288 anchor: PdfPoint { x: 100.0, y: 100.0 },
4289 text: text.to_string(),
4290 appearance: appearance("test-font", 18.0),
4291 }
4292 }
4293
4294 const THREE_COLOURS: &[u8] =
4296 b"BT /F1 12 Tf 72 700 Td (AB) Tj 1 0 0 rg (CD) Tj 0 0 1 rg (AB) Tj ET";
4297
4298 #[test]
4299 fn finding9_a_changed_font_replacement_keeps_the_neighbouring_quads_bit_for_bit() {
4300 let source = doc_bytes(&build_pdf(THREE_COLOURS));
4301 let before = page_islands(&source);
4302 assert_eq!(
4303 before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
4304 ["AB", "CD", "AB"]
4305 );
4306
4307 let command = changed_font_cmd("first", &before[0], "Hi", 72.0);
4308 let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
4309 let after = page_islands(&candidate.bytes);
4310
4311 type SpanPlace = ((u32, u16), u32, u32, u32, u32, u32);
4313 let red = |islands: &[crate::model::PdfTextIsland]| {
4315 islands
4316 .iter()
4317 .find(|i| i.text == "CD")
4318 .map(|i| {
4319 let spans: Vec<SpanPlace> = i
4320 .locator
4321 .spans
4322 .iter()
4323 .map(|s| {
4324 (
4325 s.stream_ref,
4326 s.contents_index,
4327 s.operator_index,
4328 s.operand_index,
4329 s.glyph_start,
4330 s.glyph_end,
4331 )
4332 })
4333 .collect();
4334 (i.quads.clone(), spans)
4335 })
4336 .expect("the red island survives")
4337 };
4338 let ((quads_before, spans_before), (quads_after, spans_after)) =
4339 (red(&before), red(&after));
4340 assert_eq!(quads_after, quads_before);
4344 let moved_one_stream: Vec<_> = spans_before
4349 .iter()
4350 .map(|&(stream, index, operator, operand, start, end)| {
4351 (stream, index + 1, operator, operand, start, end)
4352 })
4353 .collect();
4354 assert_eq!(spans_after, moved_one_stream);
4355 let blue = after
4356 .iter()
4357 .filter(|i| i.text == "AB")
4358 .map(|i| i.quads.clone())
4359 .collect::<Vec<_>>();
4360 assert_eq!(blue, [before[2].quads.clone()]);
4361 assert!(after.iter().any(|i| i.text == "Hi"));
4362 assert!(!after
4363 .iter()
4364 .any(|i| i.text == "AB" && i.quads == before[0].quads));
4365 }
4366
4367 #[test]
4368 fn finding9_a_removed_string_inside_a_tj_keeps_the_pen_through_kerns_and_following_runs() {
4369 let source = doc_bytes(&build_pdf(
4371 b"BT /F1 12 Tf 72 700 Td [(AB) -300 (CD)] TJ 1 0 0 rg (EF) Tj ET",
4372 ));
4373 let before = page_islands(&source);
4374 let first = before
4375 .iter()
4376 .find(|i| i.text.contains("AB"))
4377 .expect("first island");
4378 let command = changed_font_cmd("tj", first, "Hi", 72.0);
4379 let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
4380 let after = page_islands(&candidate.bytes);
4381 let ef = |islands: &[crate::model::PdfTextIsland]| {
4382 islands
4383 .iter()
4384 .find(|i| i.text == "EF")
4385 .map(|i| i.quads.clone())
4386 .expect("EF survives")
4387 };
4388 assert_eq!(ef(&before), ef(&after));
4389 }
4390
4391 fn glyph_origins(islands: &[crate::model::PdfTextIsland], glyph: &str) -> Vec<(f64, f64)> {
4399 islands
4400 .iter()
4401 .flat_map(|island| island.run.iter())
4402 .filter(|item| item.glyph.is_some() && item.text == glyph)
4403 .filter_map(|item| item.placement)
4404 .map(|(matrix, _)| (matrix.e, matrix.f))
4405 .collect()
4406 }
4407
4408 fn has_origin(origins: &[(f64, f64)], at: [f64; 4]) -> bool {
4410 origins
4411 .iter()
4412 .any(|origin| (origin.0 - at[0]).hypot(origin.1 - at[1]) <= 0.5)
4413 }
4414
4415 fn page_text(islands: &[crate::model::PdfTextIsland]) -> String {
4418 islands
4419 .iter()
4420 .flat_map(|island| island.text.chars())
4421 .filter(|c| *c != ' ')
4422 .collect()
4423 }
4424
4425 #[test]
4426 fn finding12_add_text_and_two_replacements_on_one_page_all_resolve_in_one_batch() {
4427 let source = doc_bytes(&build_pdf(THREE_COLOURS));
4428 let before = page_islands(&source);
4429 let commands = [
4432 add_text_cmd("add", "HH"),
4433 changed_font_cmd("one", &before[0], "Hi", 72.0),
4434 changed_font_cmd("two", &before[2], "CA", 300.0),
4435 ];
4436 let candidate = compose_candidate(&source, &commands, &test_resolver())
4437 .expect("every command resolves");
4438 assert_eq!(candidate.affected_pages, [1]);
4439 let after = page_islands(&candidate.bytes);
4440 let texts: Vec<&str> = after.iter().map(|i| i.text.as_str()).collect();
4441 for wanted in ["CD", "HH"] {
4444 assert!(texts.contains(&wanted), "{wanted}: {texts:?}");
4445 }
4446 let text = page_text(&after);
4447 assert!(
4448 text.contains("Hi") && text.contains("CA") && !text.contains("AB"),
4449 "{texts:?}"
4450 );
4451 assert!(has_origin(&glyph_origins(&after, "H"), before[0].baseline));
4453 assert!(has_origin(&glyph_origins(&after, "C"), before[2].baseline));
4454 assert!(has_origin(
4455 &glyph_origins(&after, "H"),
4456 [100.0, 100.0, 0.0, 0.0]
4457 ));
4458
4459 let red = after.iter().find(|i| i.text == "CD").expect("CD");
4461 assert_eq!(red.quads, before[1].quads);
4462
4463 let (out, _) = reinspect(&candidate, 1);
4465 let page_id = out.get_pages()[&1];
4466 let contents = out
4467 .get_object(page_id)
4468 .expect("page")
4469 .as_dict()
4470 .expect("dict")
4471 .get(b"Contents")
4472 .expect("contents");
4473 assert_eq!(content_stream_ids(&out, contents).expect("ids").len(), 6);
4474 }
4475
4476 #[test]
4477 fn finding12_replacements_apply_back_to_front_whatever_order_they_are_given_in() {
4478 let source = doc_bytes(&build_pdf(THREE_COLOURS));
4479 let before = page_islands(&source);
4480 for order in [[0_usize, 2], [2, 0]] {
4481 let commands: Vec<PdfEditCommand> = order
4482 .iter()
4483 .map(|&i| changed_font_cmd(&format!("c{i}"), &before[i], "Hi", 72.0 + i as f64))
4484 .collect();
4485 let candidate = compose_candidate(&source, &commands, &test_resolver())
4486 .unwrap_or_else(|e| panic!("{order:?}: {e:?}"));
4487 let after = page_islands(&candidate.bytes);
4488 let text = page_text(&after);
4493 assert_eq!(text.matches("Hi").count(), 2, "{order:?}: {text}");
4494 assert!(!text.contains("AB"), "{order:?}: {text}");
4495 let origins = glyph_origins(&after, "H");
4496 assert_eq!(origins.len(), 2, "{order:?}");
4497 assert!(has_origin(&origins, before[0].baseline), "{order:?}");
4498 assert!(has_origin(&origins, before[2].baseline), "{order:?}");
4499 let red = after.iter().find(|i| i.text == "CD").expect("CD");
4500 assert_eq!(red.quads, before[1].quads, "{order:?}");
4501 }
4502 }
4503
4504 #[test]
4505 fn finding12_two_islands_in_one_tj_array_both_resolve_when_the_first_is_split() {
4506 let source = doc_bytes(&build_pdf(
4509 b"BT /F1 12 Tf 72 700 Td [(AB) -2000 (CD)] TJ 1 0 0 rg (AB) Tj ET",
4510 ));
4511 let before = page_islands(&source);
4512 assert_eq!(
4513 before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
4514 ["AB", "CD", "AB"]
4515 );
4516 let commands = [
4517 changed_font_cmd("ab", &before[0], "Hi", 72.0),
4518 changed_font_cmd("cd", &before[1], "CA", 300.0),
4519 ];
4520 let candidate = compose_candidate(&source, &commands, &test_resolver())
4521 .expect("both islands of the array resolve");
4522 let after = page_islands(&candidate.bytes);
4523 let texts: Vec<&str> = after.iter().map(|i| i.text.as_str()).collect();
4524 let text = page_text(&after);
4527 assert!(text.contains("Hi") && text.contains("CA"), "{texts:?}");
4528 assert!(has_origin(&glyph_origins(&after, "H"), before[0].baseline));
4529 assert!(has_origin(&glyph_origins(&after, "C"), before[1].baseline));
4530 let tail = after
4532 .iter()
4533 .find(|i| i.text == "AB")
4534 .map(|i| i.quads.clone())
4535 .expect("the trailing AB");
4536 assert_eq!(tail, before[2].quads);
4537 }
4538
4539 #[test]
4540 fn finding9_glyphs_shown_by_a_line_moving_operator_are_refused_not_left_with_the_pen_short() {
4541 let source = doc_bytes(&build_pdf(
4542 b"BT /F1 12 Tf 72 700 Td 14 TL (AB) ' 1 0 0 rg (CD) Tj ET",
4543 ));
4544 let before = page_islands(&source);
4545 let first = before.iter().find(|i| i.text == "AB").expect("AB");
4546 let command = changed_font_cmd("quote", first, "Hi", 72.0);
4547 assert!(matches!(
4548 compose_candidate(&source, &[command], &test_resolver()),
4549 Err(PdfEditError::UnsupportedEncoding)
4550 ));
4551 }
4552
4553 #[test]
4554 fn finding16_inspection_advertises_a_substitution_exactly_when_composition_admits_it() {
4555 let cases: [(&[u8], bool); 4] = [
4559 (b"BT /F1 12 Tf 72 700 Td (AB) Tj ET", true),
4560 (b"BT /F1 12 Tf 72 700 Td [(AB)] TJ ET", true),
4561 (b"BT /F1 12 Tf 72 700 Td 14 TL (AB) ' ET", false),
4562 (b"BT /F1 12 Tf 72 700 Td 14 TL 0 0 (AB) \" ET", false),
4563 ];
4564 for (content, advertised) in cases {
4565 let label = String::from_utf8_lossy(content).into_owned();
4566 let source = doc_bytes(&build_pdf(content));
4567 let before = page_islands(&source);
4568 let island = before
4569 .iter()
4570 .find(|i| i.text == "AB")
4571 .unwrap_or_else(|| panic!("{label}: no AB island"));
4572 assert_eq!(island.substitution_editable, advertised, "{label}");
4573
4574 let command = changed_font_cmd("substitute", island, "Hi", 72.0);
4575 let composed = compose_candidate(&source, &[command], &test_resolver());
4576 assert_eq!(
4577 composed.is_ok(),
4578 advertised,
4579 "{label}: composition said {:?}",
4580 composed.as_ref().err()
4581 );
4582 if !advertised {
4583 assert!(
4584 matches!(composed, Err(PdfEditError::UnsupportedEncoding)),
4585 "{label}"
4586 );
4587 }
4588 }
4589 }
4590
4591 #[test]
4592 fn finding12_an_island_listed_twice_in_a_batch_is_still_refused_the_second_time() {
4593 let source = doc_bytes(&build_pdf(THREE_COLOURS));
4594 let before = page_islands(&source);
4595 let commands = [
4596 changed_font_cmd("a", &before[0], "Hi", 72.0),
4597 changed_font_cmd("b", &before[0], "CA", 300.0),
4598 ];
4599 assert!(matches!(
4600 compose_candidate(&source, &commands, &test_resolver()),
4601 Err(PdfEditError::SourceDigestMismatch)
4602 ));
4603 }
4604
4605 #[test]
4606 fn finding10_a_page_sharing_a_stream_with_many_pages_costs_one_copy() {
4607 let mut doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (AB) Tj ET");
4609 let page_id = doc.get_pages()[&1];
4610 let (contents, parent, resources) = {
4611 let page = doc
4612 .get_object(page_id)
4613 .expect("page")
4614 .as_dict()
4615 .expect("dict");
4616 (
4617 page.get(b"Contents").expect("contents").clone(),
4618 page.get(b"Parent").expect("parent").clone(),
4619 page.get(b"Resources").expect("resources").clone(),
4620 )
4621 };
4622 let Object::Reference(pages_id) = parent.clone() else {
4623 panic!("parent reference");
4624 };
4625 let mut kids = vec![Object::Reference(page_id)];
4626 for _ in 0..3 {
4627 let extra = doc.add_object(dictionary! {
4628 "Type" => "Page",
4629 "Parent" => parent.clone(),
4630 "Contents" => contents.clone(),
4631 "Resources" => resources.clone(),
4632 "MediaBox" => Object::Array(vec![
4633 Object::Integer(0),
4634 Object::Integer(0),
4635 Object::Integer(612),
4636 Object::Integer(792),
4637 ]),
4638 });
4639 kids.push(Object::Reference(extra));
4640 }
4641 if let Ok(Object::Dictionary(pages)) = doc.get_object_mut(pages_id) {
4642 pages.set("Kids", Object::Array(kids));
4643 pages.set("Count", 4);
4644 }
4645 let source = doc_bytes(&doc);
4646 let before = page_islands(&source);
4647 let before_objects = Document::load_mem(&source).expect("reopen").objects.len();
4648 let command = changed_font_cmd("shared", &before[0], "Hi", 72.0);
4649 let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
4650 let out = Document::load_mem(&candidate.bytes).expect("reopen candidate");
4651 let mut streams: std::collections::BTreeSet<ObjectId> = Default::default();
4652 for page in 2..=4 {
4653 let id = out.get_pages()[&page];
4654 let contents = out
4655 .get_object(id)
4656 .expect("page")
4657 .as_dict()
4658 .expect("dict")
4659 .get(b"Contents")
4660 .expect("contents");
4661 streams.extend(content_stream_ids(&out, contents).expect("ids"));
4662 }
4663 assert_eq!(streams.len(), 1, "the three other pages share one copy");
4664 assert!(out.objects.len() > before_objects);
4665 }
4666
4667 #[test]
4668 fn finding10_unclosed_q_operators_are_refused_not_expanded_into_a_wrapper() {
4669 let content = format!(
4672 "{}BT /F1 12 Tf 72 700 Td (Existing) Tj ET",
4673 "q ".repeat(70_000)
4674 );
4675 let source = doc_bytes(&build_pdf(content.as_bytes()));
4676 assert!(matches!(
4677 compose_candidate(&source, &[add_hi("a")], &test_resolver()),
4678 Err(PdfEditError::MalformedContent)
4679 ));
4680 }
4681
4682 const CUT_INSIDE_AN_OPERATION: [&[u8]; 3] = [
4692 b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET ",
4693 b"q 1 0 0 1 0 0 cm Q /Artifact ",
4694 b"<< /MCID 0 >> BDC EMC ",
4695 ];
4696
4697 fn page_cut_inside_an_operation() -> Document {
4698 assert!(Content::decode_strict(CUT_INSIDE_AN_OPERATION[1]).is_err());
4700 assert!(Content::decode_strict(&CUT_INSIDE_AN_OPERATION.concat()).is_ok());
4701 custom_page_pdf(
4702 |d| vec![("F1", helvetica_font(d))],
4703 &CUT_INSIDE_AN_OPERATION,
4704 false,
4705 )
4706 }
4707
4708 fn page_one_streams(doc: &Document) -> Vec<ObjectId> {
4710 let page_id = doc.get_pages()[&1];
4711 let contents = doc
4712 .get_object(page_id)
4713 .expect("page")
4714 .as_dict()
4715 .expect("dict")
4716 .get(b"Contents")
4717 .expect("contents");
4718 content_stream_ids(doc, contents).expect("ids")
4719 }
4720
4721 fn stream_bytes(doc: &Document, id: ObjectId) -> Vec<u8> {
4722 doc.get_object(id)
4723 .expect("object")
4724 .as_stream()
4725 .expect("a stream")
4726 .decompressed_content()
4727 .expect("decodes")
4728 }
4729
4730 fn nesting_before_the_last_stream_leniently(doc: &Document) -> (i64, i64) {
4733 let ids = page_one_streams(doc);
4734 let (mut depth, mut lowest) = (0_i64, 0_i64);
4735 for id in &ids[..ids.len() - 1] {
4736 let content = Content::decode(&stream_bytes(doc, *id)).expect("parses leniently");
4737 for op in content.operations {
4738 match op.operator.as_str() {
4739 "q" => depth += 1,
4740 "Q" => {
4741 depth -= 1;
4742 lowest = lowest.min(depth);
4743 }
4744 _ => {}
4745 }
4746 }
4747 }
4748 (depth, lowest)
4749 }
4750
4751 #[test]
4752 fn add_text_on_a_page_cut_inside_an_operation_composes_and_leaves_every_stream_alone() {
4753 let doc = page_cut_inside_an_operation();
4754 let original = page_one_streams(&doc);
4755 let source = doc_bytes(&doc);
4756 let candidate = compose_candidate(&source, &[add_hi("a")], &test_resolver())
4757 .expect("a page is not malformed because its streams are cut inside an operation");
4758 let (out, graph) = reinspect(&candidate, 1);
4759 assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
4760 assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
4761
4762 let ids = page_one_streams(&out);
4764 assert_eq!(ids.len(), 6);
4765 assert_eq!(&ids[1..4], &original[..]);
4766 for (id, bytes) in ids[1..4].iter().zip(CUT_INSIDE_AN_OPERATION) {
4767 assert_eq!(stream_bytes(&out, *id), bytes);
4768 }
4769 assert_eq!(nesting_before_the_last_stream_leniently(&out), (0, 0));
4770 }
4771
4772 #[test]
4773 fn a_changed_font_replacement_on_a_page_cut_inside_an_operation_composes() {
4774 let doc = page_cut_inside_an_operation();
4775 let source = doc_bytes(&doc);
4776 let before = page_islands(&source);
4777 assert_eq!(
4778 before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
4779 ["Existing"]
4780 );
4781 let command = changed_font_cmd("redraw", &before[0], "Hi", 72.0);
4782 let candidate = compose_candidate(&source, &[command], &test_resolver())
4783 .expect("a page is not malformed because its streams are cut inside an operation");
4784 let (out, graph) = reinspect(&candidate, 1);
4785 assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
4786 assert!(!graph.islands.iter().any(|isl| isl.text == "Existing"));
4787
4788 let ids = page_one_streams(&out);
4790 assert_eq!(ids.len(), 6);
4791 assert_eq!(stream_bytes(&out, ids[2]), CUT_INSIDE_AN_OPERATION[1]);
4792 assert_eq!(stream_bytes(&out, ids[3]), CUT_INSIDE_AN_OPERATION[2]);
4793 assert_eq!(nesting_before_the_last_stream_leniently(&out), (0, 0));
4794 }
4795
4796 #[test]
4797 fn add_text_still_refuses_a_stream_that_ends_in_anything_but_operands() {
4798 for (label, tail) in [
4801 ("open string", &b"q (open"[..]),
4802 ("stray byte", &b"q \x01 Q"[..]),
4803 ] {
4804 let streams: [&[u8]; 2] = [b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET ", tail];
4805 let doc = custom_page_pdf(|d| vec![("F1", helvetica_font(d))], &streams, false);
4806 let source = doc_bytes(&doc);
4807 assert!(
4808 matches!(
4809 compose_candidate(&source, &[add_hi("a")], &test_resolver()),
4810 Err(PdfEditError::MalformedContent)
4811 ),
4812 "{label}"
4813 );
4814 }
4815 }
4816
4817 fn cm_positioned_page(runs: &[(&str, &str, &str)]) -> Vec<u8> {
4826 let mut content = String::from("0.1 0 0 0.1 0 0 cm\n1 0 0 1 0 7920 cm\n");
4827 for (cm, rg, text) in runs {
4828 content.push_str(&format!(
4829 "q {cm} cm BT /F1 100 Tf {rg} rg 0 0 Td ({text}) Tj ET Q\n"
4830 ));
4831 }
4832 content.into_bytes()
4833 }
4834
4835 fn three_cm_runs(middle_cm: &str, middle_text: &str) -> Vec<u8> {
4837 cm_positioned_page(&[
4838 ("1 0 0 1 1000 -1000", "0 0 0", "AB"),
4839 (middle_cm, "1 0 0", middle_text),
4840 ("1 0 0 1 3000 -1000", "0 0 1", "CA"),
4841 ])
4842 }
4843
4844 struct ReplacedMiddle {
4845 target: String,
4846 replacement: String,
4847 before: Vec<crate::model::PdfTextIsland>,
4848 after: Vec<crate::model::PdfTextIsland>,
4849 appended: Vec<Operation>,
4850 bytes: Vec<u8>,
4851 }
4852
4853 fn replace_middle_run_in_a_new_font(content: &[u8]) -> ReplacedMiddle {
4856 replace_run_in_a_new_font(content, "BC", "Hi")
4857 }
4858
4859 fn replace_run_in_a_new_font(
4863 content: &[u8],
4864 target: &str,
4865 replacement: &str,
4866 ) -> ReplacedMiddle {
4867 let source = doc_bytes(&build_pdf(content));
4868 let before = page_islands(&source);
4869 let found = before
4870 .iter()
4871 .find(|island| island.text == target)
4872 .expect("the middle run is an island of its own");
4873 let command = replace_cmd(
4874 "middle",
4875 found,
4876 replacement,
4877 appearance("test-font", 100.0),
4878 PdfRect {
4879 x: 190.0,
4880 y: 680.0,
4881 width: 200.0,
4882 height: 40.0,
4883 },
4884 );
4885 let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
4886 let (out, graph) = reinspect(&candidate, 1);
4887 ReplacedMiddle {
4888 target: target.to_string(),
4889 replacement: replacement.to_string(),
4890 before,
4891 after: graph.islands,
4892 appended: appended_operations(&out, 1),
4893 bytes: candidate.bytes,
4894 }
4895 }
4896
4897 fn operands_of(ops: &[Operation], operator: &str) -> Vec<f64> {
4898 ops.iter()
4899 .find(|op| op.operator == operator)
4900 .unwrap_or_else(|| panic!("no {operator} in {ops:?}"))
4901 .operands
4902 .iter()
4903 .filter_map(object_number)
4904 .collect()
4905 }
4906
4907 fn assert_numbers(label: &str, got: &[f64], want: &[f64]) {
4908 assert_eq!(got.len(), want.len(), "{label}: {got:?} vs {want:?}");
4909 for (got_value, want_value) in got.iter().zip(want) {
4910 assert!(
4912 (got_value - want_value).abs() <= 1e-4,
4913 "{label}: {got:?} vs {want:?}"
4914 );
4915 }
4916 }
4917
4918 fn device_size(island: &crate::model::PdfTextIsland) -> f64 {
4921 let layout = island.layout.expect("layout");
4922 layout.font_size * layout.placement.c.hypot(layout.placement.d)
4923 }
4924
4925 fn island_named<'a>(
4926 islands: &'a [crate::model::PdfTextIsland],
4927 text: &str,
4928 ) -> &'a crate::model::PdfTextIsland {
4929 islands
4930 .iter()
4931 .find(|island| island.text == text)
4932 .unwrap_or_else(|| panic!("no island {text:?}"))
4933 }
4934
4935 fn assert_replacement_sits_on_the_source_run(run: &ReplacedMiddle) {
4937 let source = island_named(&run.before, &run.target);
4938 let written = island_named(&run.after, &run.replacement);
4939 let origin_error = (written.baseline[0] - source.baseline[0])
4942 .hypot(written.baseline[1] - source.baseline[1]);
4943 assert!(
4944 origin_error <= 0.5,
4945 "origin {:?} vs source {:?}",
4946 &written.baseline[..2],
4947 &source.baseline[..2]
4948 );
4949 let (have, want) = (device_size(written), device_size(source));
4950 assert!(
4951 (have - want).abs() <= 0.02 * want,
4952 "device size {have} vs source {want}"
4953 );
4954 assert!(
4955 (want - 10.0).abs() < 1e-6,
4956 "the fixture's run is 10 pt: {want}"
4957 );
4958 for neighbour in ["AB", "CA"] {
4960 assert_eq!(
4961 island_named(&run.after, neighbour).quads,
4962 island_named(&run.before, neighbour).quads,
4963 "{neighbour}"
4964 );
4965 }
4966 assert!(!run.after.iter().any(|island| island.text == run.target));
4967 }
4968
4969 #[test]
4970 fn a_changed_font_replacement_on_a_cm_positioned_page_carries_the_runs_placement() {
4971 let run = replace_middle_run_in_a_new_font(&three_cm_runs("1 0 0 1 2000 -1000", "BC"));
4972 assert_numbers(
4977 "Tm",
4978 &operands_of(&run.appended, "Tm"),
4979 &[0.1, 0.0, 0.0, 0.1, 200.0, 692.0],
4980 );
4981 assert_numbers("Tf", &operands_of(&run.appended, "Tf"), &[100.0]);
4983 assert_replacement_sits_on_the_source_run(&run);
4984 }
4985
4986 #[test]
4987 fn a_changed_font_replacement_keeps_the_rotation_of_a_cm_positioned_run() {
4988 let run = replace_run_in_a_new_font(&three_cm_runs("0 1 -1 0 2000 -1000", "B"), "B", "H");
4992 assert_numbers(
4993 "Tm",
4994 &operands_of(&run.appended, "Tm"),
4995 &[0.0, 0.1, -0.1, 0.0, 200.0, 692.0],
4996 );
4997 assert_numbers("Tf", &operands_of(&run.appended, "Tf"), &[100.0]);
4998 assert_replacement_sits_on_the_source_run(&run);
4999 let placement = island_named(&run.after, "H")
5001 .layout
5002 .expect("layout")
5003 .placement;
5004 assert_numbers(
5005 "read back",
5006 &[placement.a, placement.b, placement.c, placement.d],
5007 &[0.0, 0.1, -0.1, 0.0],
5008 );
5009 }
5010
5011 #[test]
5012 fn a_changed_font_replacement_keeps_the_text_matrix_and_the_cm_together() {
5013 let content = b"0.1 0 0 0.1 0 0 cm\n1 0 0 1 0 7920 cm\n\
5015 q 1 0 0 1 1000 -1000 cm BT /F1 100 Tf 0 0 0 rg 0 0 Td (AB) Tj ET Q\n\
5016 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\
5017 q 1 0 0 1 3000 -1000 cm BT /F1 100 Tf 0 0 1 rg 0 0 Td (CA) Tj ET Q\n";
5018 let run = replace_run_in_a_new_font(content, "B", "H");
5019 assert_numbers(
5022 "Tm",
5023 &operands_of(&run.appended, "Tm"),
5024 &[0.0, 0.1, -0.1, 0.0, 203.0, 696.0],
5025 );
5026 assert_numbers("Tf", &operands_of(&run.appended, "Tf"), &[100.0]);
5027 assert_replacement_sits_on_the_source_run(&run);
5028 }
5029
5030 #[test]
5031 fn add_text_on_a_page_with_a_leading_scale_stays_an_upright_point_in_page_space() {
5032 let source = doc_bytes(&build_pdf(&three_cm_runs("1 0 0 1 2000 -1000", "BC")));
5033 let command = PdfEditCommand::AddText {
5034 id: "add".to_string(),
5035 page: 1,
5036 anchor: PdfPoint { x: 100.0, y: 100.0 },
5037 text: "Hi".to_string(),
5038 appearance: appearance("test-font", 18.0),
5039 };
5040 let candidate = compose_candidate(&source, &[command], &test_resolver()).expect("composes");
5041 let (out, graph) = reinspect(&candidate, 1);
5042 let ops = appended_operations(&out, 1);
5043 assert_numbers(
5044 "Tm",
5045 &operands_of(&ops, "Tm"),
5046 &[1.0, 0.0, 0.0, 1.0, 100.0, 100.0],
5047 );
5048 assert_numbers("Tf", &operands_of(&ops, "Tf"), &[18.0]);
5049 let added = island_named(&graph.islands, "Hi");
5051 assert!(
5052 (added.baseline[0] - 100.0).abs() < 1e-3 && (added.baseline[1] - 100.0).abs() < 1e-3
5053 );
5054 assert!((device_size(added) - 18.0).abs() < 1e-6);
5055 }
5056
5057 #[test]
5058 fn the_composer_read_back_refuses_a_replacement_drawn_away_from_the_source_run() {
5059 let run = replace_middle_run_in_a_new_font(&three_cm_runs("1 0 0 1 2000 -1000", "BC"));
5060 let placement = island_named(&run.before, "BC")
5061 .layout
5062 .expect("layout")
5063 .placement;
5064 let mut doc = Document::load_mem(&run.bytes).expect("reopen");
5065 let stream = last_content_stream(&doc, 1);
5066 let expected = |placement: Matrix, size: f64| AppendedRun {
5067 page: 1,
5068 stream: Some(stream),
5069 placement,
5070 size,
5071 };
5072 let shifted = |dx: f64, dy: f64| Matrix {
5073 e: placement.e + dx,
5074 f: placement.f + dy,
5075 ..placement
5076 };
5077
5078 verify_appended_runs(&doc, &[expected(placement, 100.0)])
5079 .expect("where the source run was");
5080 verify_appended_runs(&doc, &[expected(shifted(0.3, -0.3), 100.0)]).expect("0.42 pt");
5082 for (dx, dy) in [(1.0, 0.0), (0.0, -1.0), (-200.0, -692.0)] {
5083 assert!(
5084 matches!(
5085 verify_appended_runs(&doc, &[expected(shifted(dx, dy), 100.0)]),
5086 Err(PdfEditError::VerificationFailed)
5087 ),
5088 "({dx}, {dy})"
5089 );
5090 }
5091 verify_appended_runs(&doc, &[expected(placement, 101.0)]).expect("1 %");
5093 for size in [103.0, 1000.0] {
5094 assert!(matches!(
5095 verify_appended_runs(&doc, &[expected(placement, size)]),
5096 Err(PdfEditError::VerificationFailed)
5097 ));
5098 }
5099 let turned = Matrix {
5101 a: 0.0,
5102 b: 0.1,
5103 c: -0.1,
5104 d: 0.0,
5105 ..placement
5106 };
5107 assert!(matches!(
5108 verify_appended_runs(&doc, &[expected(turned, 100.0)]),
5109 Err(PdfEditError::VerificationFailed)
5110 ));
5111
5112 let last = last_content_stream(&doc, 1);
5115 let mut content = decode_stream_content(&doc, last).expect("content");
5116 for op in content
5117 .operations
5118 .iter_mut()
5119 .filter(|op| op.operator == "Tm")
5120 {
5121 op.operands = [1_i64, 0, 0, 1, 0, 0].map(Object::Integer).to_vec();
5122 }
5123 write_stream_content(&mut doc, last, &content).expect("write");
5124 let graph = walk_page_graph(&doc, "", 1).expect("inspect");
5125 assert!(graph.islands.iter().any(|island| island.text == "Hi"));
5126 assert!(matches!(
5127 verify_appended_runs(&doc, &[expected(placement, 100.0)]),
5128 Err(PdfEditError::VerificationFailed)
5129 ));
5130 }
5131
5132 fn last_content_stream(doc: &Document, page: u32) -> ObjectId {
5133 let page_id = doc.get_pages()[&page];
5134 let contents = doc
5135 .get_object(page_id)
5136 .expect("page")
5137 .as_dict()
5138 .expect("dict")
5139 .get(b"Contents")
5140 .expect("contents");
5141 *content_stream_ids(doc, contents)
5142 .expect("ids")
5143 .last()
5144 .expect("a stream")
5145 }
5146
5147 #[test]
5152 fn a_replacement_that_joins_a_preceding_same_colour_run_is_still_read_back() {
5153 let source = doc_bytes(&build_pdf(
5158 b"BT /F1 12 Tf 0 g 120 700 Td (42.00) Tj 1 0 0 1 80 700 Tm (Total:) Tj ET",
5159 ));
5160 let before = page_islands(&source);
5161 let target = island_named(&before, "42.00");
5162 let command = replace_cmd(
5163 "price",
5164 target,
5165 "Hi",
5166 appearance("test-font", 12.0),
5167 PdfRect {
5168 x: 120.0,
5169 y: 690.0,
5170 width: 100.0,
5171 height: 30.0,
5172 },
5173 );
5174 let candidate = compose_candidate(&source, &[command], &test_resolver())
5175 .expect("a correct edit is not refused because it was grouped with its neighbour");
5176 let after = page_islands(&candidate.bytes);
5177 assert!(
5178 after
5179 .iter()
5180 .any(|island| island.text.contains("Total:") && island.text.contains("Hi")),
5181 "the fixture must put the replacement in the neighbour's island: {:?}",
5182 after.iter().map(|i| i.text.as_str()).collect::<Vec<_>>()
5183 );
5184 let hi = after
5185 .iter()
5186 .flat_map(|island| island.run.iter())
5187 .filter_map(|item| item.glyph.zip(item.placement))
5188 .find(|(glyph, _)| glyph.stream_ref == last_stream_ref(&candidate.bytes))
5189 .map(|(_, placement)| placement)
5190 .expect("the appended run's first glyph");
5191 assert_numbers(
5192 "origin of the replacement",
5193 &[hi.0.e, hi.0.f],
5194 &[120.0, 700.0],
5195 );
5196 }
5197
5198 #[test]
5199 fn two_adjacent_replacements_in_one_batch_are_both_read_back() {
5200 let source = doc_bytes(&build_pdf(
5204 b"BT /F1 12 Tf 0 g 100 700 Td (AB) Tj 1 0 0 1 150 700 Tm (CA) Tj ET",
5205 ));
5206 let before = page_islands(&source);
5207 assert_eq!(
5208 before.len(),
5209 2,
5210 "{:?}",
5211 before.iter().map(|i| &i.text).collect::<Vec<_>>()
5212 );
5213 let bounds = PdfRect {
5214 x: 100.0,
5215 y: 690.0,
5216 width: 100.0,
5217 height: 40.0,
5218 };
5219 let commands = [
5220 replace_cmd(
5221 "first",
5222 island_named(&before, "AB"),
5223 "Hi",
5224 appearance("test-font", 24.0),
5225 bounds,
5226 ),
5227 replace_cmd(
5228 "second",
5229 island_named(&before, "CA"),
5230 "Hi",
5231 appearance("test-font", 24.0),
5232 bounds,
5233 ),
5234 ];
5235 let candidate = compose_candidate(&source, &commands, &test_resolver())
5236 .expect("both replacements read back");
5237 let after = page_islands(&candidate.bytes);
5238 assert!(
5239 after
5240 .iter()
5241 .any(|island| island.text.matches("Hi").count() == 2),
5242 "the fixture must group the two replacements: {:?}",
5243 after.iter().map(|i| i.text.as_str()).collect::<Vec<_>>()
5244 );
5245 }
5246
5247 #[test]
5248 fn replacements_located_in_the_pristine_file_compose_in_one_call_in_either_order() {
5249 let source = doc_bytes(&build_pdf(
5256 b"BT /F1 12 Tf 0 g 100 700 Td (AB) Tj 1 0 0 1 150 700 Tm (CA) Tj \
5257 1 0 0 rg 1 0 0 1 400 700 Tm (CD) Tj ET",
5258 ));
5259 let before = page_islands(&source);
5260 assert_eq!(
5261 before.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
5262 ["AB", "CA", "CD"]
5263 );
5264 let bounds = PdfRect {
5265 x: 100.0,
5266 y: 690.0,
5267 width: 100.0,
5268 height: 40.0,
5269 };
5270 let command = |id: &str, text: &str| {
5271 replace_cmd(
5272 id,
5273 island_named(&before, text),
5274 "Hi",
5275 appearance("test-font", 24.0),
5276 bounds,
5277 )
5278 };
5279 for order in [["AB", "CA"], ["CA", "AB"]] {
5280 let commands = [command("one", order[0]), command("two", order[1])];
5281 let candidate = compose_candidate(&source, &commands, &test_resolver())
5282 .unwrap_or_else(|e| panic!("{order:?}: {e:?}"));
5283 let after = page_islands(&candidate.bytes);
5284 let texts: Vec<&str> = after.iter().map(|i| i.text.as_str()).collect();
5285
5286 let text = page_text(&after);
5288 assert_eq!(text.matches("Hi").count(), 2, "{order:?}: {texts:?}");
5289 assert!(
5290 !text.contains("AB") && !text.contains("CA"),
5291 "{order:?}: {texts:?}"
5292 );
5293 let origins = glyph_origins(&after, "H");
5294 assert_eq!(origins.len(), 2, "{order:?}");
5295 assert!(has_origin(&origins, island_named(&before, "AB").baseline));
5296 assert!(has_origin(&origins, island_named(&before, "CA").baseline));
5297
5298 assert_eq!(
5300 island_named(&after, "CD").quads,
5301 island_named(&before, "CD").quads,
5302 "{order:?}"
5303 );
5304 if order[0] == "AB" {
5307 assert!(
5308 after.iter().any(|i| i.text.matches("Hi").count() == 2),
5309 "{texts:?}"
5310 );
5311 }
5312 }
5313 }
5314
5315 fn last_stream_ref(bytes: &[u8]) -> (u32, u16) {
5317 let doc = Document::load_mem(bytes).expect("reopen");
5318 let id = last_content_stream(&doc, 1);
5319 (id.0, id.1)
5320 }
5321}