1use lopdf::content::{Content, Operation};
80use lopdf::{dictionary, Dictionary, Document, Object, ObjectId, Stream, StringFormat};
81use serde::Deserialize;
82
83use crate::font::{build_to_unicode_cmap, subset_for_pdf};
84use crate::inspect::{simple_font_identity_bytes, walk_page_graph};
85use crate::model::{
86 document_sha256, effective_pdf_edit_commands, ComposedCandidate, Matrix, PdfColor,
87 PdfEditCommand, PdfEditError, PdfFontResolver, PdfOcrBackgroundProof, PdfPoint, PdfRect,
88 PdfSourceLocator, PdfSourceSpan, PdfTextAppearance, PdfTextEditState, ResolvedPdfFont,
89};
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97enum WriteStrategy {
98 ExactOperandPatch,
99 LocalTextObjectRewrite,
100}
101
102fn select_replace_strategy(
103 locator: &PdfSourceLocator,
104 before: &PdfTextEditState,
105 after: &PdfTextEditState,
106) -> WriteStrategy {
107 let same_font_encoding = before.appearance == after.appearance;
108 let one_operand = locator.spans.len() == 1;
109 let ascii_only = after.text.is_ascii();
110
111 if same_font_encoding && one_operand && ascii_only {
112 WriteStrategy::ExactOperandPatch
113 } else {
114 WriteStrategy::LocalTextObjectRewrite
115 }
116}
117
118pub fn compose_candidate(
126 source: &[u8],
127 commands: &[PdfEditCommand],
128 fonts: &dyn PdfFontResolver,
129) -> Result<ComposedCandidate, PdfEditError> {
130 let current_sha = document_sha256(source);
131 compose_candidate_on_base(¤t_sha, source, commands, fonts)
132}
133
134pub fn compose_candidate_on_base(
141 source_sha: &str,
142 base: &[u8],
143 commands: &[PdfEditCommand],
144 fonts: &dyn PdfFontResolver,
145) -> Result<ComposedCandidate, PdfEditError> {
146 if commands.is_empty() {
147 return Ok(ComposedCandidate {
148 bytes: base.to_vec(),
149 affected_pages: Vec::new(),
150 });
151 }
152
153 let mut doc = Document::load_mem(base).map_err(|_| PdfEditError::MalformedContent)?;
154 if doc.is_encrypted() {
155 return Err(PdfEditError::EncryptedDocument);
156 }
157
158 let mut affected_pages: Vec<u32> = Vec::new();
159
160 for command in effective_pdf_edit_commands(commands) {
161 let page = match command {
162 PdfEditCommand::ReplaceSourceText {
163 locator,
164 before,
165 after,
166 available_bounds,
167 ..
168 } => {
169 if locator.document_sha256 != source_sha {
170 return Err(PdfEditError::StaleSource);
171 }
172 apply_replace_source_text(
173 &mut doc,
174 source_sha,
175 locator,
176 before,
177 after,
178 *available_bounds,
179 fonts,
180 )?
181 }
182 PdfEditCommand::AddText {
183 page,
184 anchor,
185 text,
186 appearance,
187 ..
188 } => apply_add_text(&mut doc, *page, *anchor, text, appearance, fonts)?,
189 PdfEditCommand::CorrectOcrLine {
190 page,
191 rect,
192 text,
193 appearance,
194 cover_color,
195 background_proof,
196 ..
197 } => apply_correct_ocr_text(
198 &mut doc,
199 *page,
200 *rect,
201 text,
202 appearance,
203 *cover_color,
204 *background_proof,
205 fonts,
206 )?,
207 };
208 if !affected_pages.contains(&page) {
209 affected_pages.push(page);
210 }
211 }
212
213 doc.compress();
214 let mut bytes = Vec::new();
215 doc.save_to(&mut bytes)
216 .map_err(|_| PdfEditError::MalformedContent)?;
217
218 affected_pages.sort_unstable();
219 Ok(ComposedCandidate {
220 bytes,
221 affected_pages,
222 })
223}
224
225#[allow(clippy::too_many_arguments)]
230fn apply_replace_source_text(
231 doc: &mut Document,
232 current_sha: &str,
233 locator: &PdfSourceLocator,
234 before: &PdfTextEditState,
235 after: &PdfTextEditState,
236 available_bounds: PdfRect,
237 fonts: &dyn PdfFontResolver,
238) -> Result<u32, PdfEditError> {
239 if locator.spans.is_empty() {
240 return Err(PdfEditError::UnsupportedEncoding);
241 }
242
243 let graph = walk_page_graph(doc, current_sha, locator.page)?;
247 let island = graph
248 .islands
249 .iter()
250 .find(|isl| isl.locator.island_digest == locator.island_digest)
251 .ok_or(PdfEditError::SourceDigestMismatch)?;
252 if !island.editable {
253 return Err(PdfEditError::UnsupportedEncoding);
254 }
255 for span in &locator.spans {
256 let matches = island.locator.spans.iter().any(|current| {
257 current.stream_ref == span.stream_ref
258 && current.contents_index == span.contents_index
259 && current.operator_index == span.operator_index
260 && current.operand_index == span.operand_index
261 && current.glyph_start == span.glyph_start
262 && current.glyph_end == span.glyph_end
263 && current.expected_digest == span.expected_digest
264 });
265 if !matches {
266 return Err(PdfEditError::SourceDigestMismatch);
267 }
268 }
269
270 let stream_ref = locator.spans[0].stream_ref;
273 let contents_index = locator.spans[0].contents_index;
274 if !locator
275 .spans
276 .iter()
277 .all(|s| s.stream_ref == stream_ref && s.contents_index == contents_index)
278 {
279 return Err(PdfEditError::UnsupportedEncoding);
280 }
281 let stream_id: ObjectId = (stream_ref.0, stream_ref.1);
282 let page_id = pages_lookup(doc, locator.page)?;
283
284 match select_replace_strategy(locator, before, after) {
285 WriteStrategy::ExactOperandPatch => {
286 apply_exact_operand_patch(
287 doc,
288 page_id,
289 stream_id,
290 &locator.spans[0],
291 after,
292 available_bounds,
293 )?;
294 }
295 WriteStrategy::LocalTextObjectRewrite => {
296 apply_local_text_object_rewrite(
297 doc,
298 page_id,
299 stream_id,
300 &locator.spans,
301 after,
302 available_bounds,
303 fonts,
304 )?;
305 }
306 }
307
308 Ok(locator.page)
309}
310
311fn pages_lookup(doc: &Document, page: u32) -> Result<ObjectId, PdfEditError> {
312 doc.get_pages()
313 .get(&page)
314 .copied()
315 .ok_or(PdfEditError::MalformedContent)
316}
317
318fn apply_exact_operand_patch(
323 doc: &mut Document,
324 page_id: ObjectId,
325 stream_id: ObjectId,
326 span: &PdfSourceSpan,
327 after: &PdfTextEditState,
328 available_bounds: PdfRect,
329) -> Result<(), PdfEditError> {
330 validate_layout_conservative(&after.text, after.appearance.size_pt, available_bounds)?;
331
332 let content = decode_stream_content(doc, stream_id)?;
333 let resource_name = active_font_resource_name(&content, span.operator_index)
334 .ok_or(PdfEditError::UnsupportedEncoding)?;
335 let replacement = simple_font_identity_bytes(doc, page_id, &resource_name, &after.text)
343 .ok_or(PdfEditError::UnsupportedEncoding)?;
344
345 let mut content = content;
346 splice_operand_bytes(
347 &mut content,
348 span.operator_index,
349 span.operand_index,
350 span.glyph_start,
351 span.glyph_end,
352 &replacement,
353 )?;
354 write_stream_content(doc, stream_id, &content)
355}
356
357fn active_font_resource_name(content: &Content, operator_index: u32) -> Option<Vec<u8>> {
364 let mut name: Option<Vec<u8>> = None;
365 for op in content.operations.iter().take(operator_index as usize + 1) {
366 if op.operator == "Tf" {
367 if let Some(Object::Name(n)) = op.operands.first() {
368 name = Some(n.clone());
369 }
370 }
371 }
372 name
373}
374
375fn validate_layout_conservative(
378 text: &str,
379 size_pt: f64,
380 available: PdfRect,
381) -> Result<(), PdfEditError> {
382 let width = text.chars().count() as f64 * size_pt * 0.5;
383 let run_bounds = PdfRect {
384 x: available.x,
385 y: available.y,
386 width,
387 height: available.height,
388 };
389 if !available.contains(run_bounds) {
390 return Err(PdfEditError::TextOverflow);
391 }
392 Ok(())
393}
394
395#[allow(clippy::too_many_arguments)]
400fn apply_local_text_object_rewrite(
401 doc: &mut Document,
402 page_id: ObjectId,
403 stream_id: ObjectId,
404 spans: &[PdfSourceSpan],
405 after: &PdfTextEditState,
406 available_bounds: PdfRect,
407 fonts: &dyn PdfFontResolver,
408) -> Result<(), PdfEditError> {
409 let resolved = fonts.resolve(&after.appearance.font_id)?;
410 fonts.glyphs_for_text(&resolved, &after.text)?;
412
413 let anchor = PdfPoint {
414 x: available_bounds.x,
415 y: available_bounds.y,
416 };
417 let advance_1000 = measure_advance_1000(&resolved, &after.text);
418 validate_layout_measured(
419 advance_1000,
420 after.appearance.size_pt,
421 anchor,
422 available_bounds,
423 )?;
424
425 let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, &after.text)?;
426 let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
427
428 let mut content = decode_stream_content(doc, stream_id)?;
429 let reset_ctm = inverse_matrix(content_ctm(&content.operations));
430
431 let mut ordered: Vec<&PdfSourceSpan> = spans.iter().collect();
434 ordered.sort_by(|a, b| {
435 b.operator_index
436 .cmp(&a.operator_index)
437 .then(b.operand_index.cmp(&a.operand_index))
438 .then(b.glyph_start.cmp(&a.glyph_start))
439 });
440 for span in ordered {
441 splice_operand_bytes(
442 &mut content,
443 span.operator_index,
444 span.operand_index,
445 span.glyph_start,
446 span.glyph_end,
447 &[],
448 )?;
449 }
450
451 content.operations.extend(build_text_object_operations(
454 &resource_name,
455 &after.appearance,
456 anchor,
457 &cid_codes,
458 reset_ctm,
459 ));
460
461 write_stream_content(doc, stream_id, &content)
462}
463
464fn validate_layout_measured(
465 advance_1000: f64,
466 size_pt: f64,
467 anchor: PdfPoint,
468 available: PdfRect,
469) -> Result<(), PdfEditError> {
470 let width = (advance_1000 / 1000.0 * size_pt).max(0.0);
477 let run_bounds = PdfRect {
478 x: anchor.x,
479 y: available.y,
480 width,
481 height: available.height,
482 };
483 if !available.contains(run_bounds) {
484 return Err(PdfEditError::TextOverflow);
485 }
486 Ok(())
487}
488
489fn measure_advance_1000(resolved: &ResolvedPdfFont, text: &str) -> f64 {
490 let Ok(face) = ttf_parser::Face::parse(&resolved.bytes, resolved.face_index) else {
491 return text.chars().count() as f64 * 500.0;
492 };
493 let units_per_em = face.units_per_em() as f64;
494 if units_per_em <= 0.0 {
495 return text.chars().count() as f64 * 500.0;
496 }
497 text.chars()
498 .filter_map(|c| face.glyph_index(c))
499 .map(|gid| face.glyph_hor_advance(gid).unwrap_or(0) as f64 * 1000.0 / units_per_em)
500 .sum()
501}
502
503fn apply_add_text(
508 doc: &mut Document,
509 page: u32,
510 anchor: PdfPoint,
511 text: &str,
512 appearance: &PdfTextAppearance,
513 fonts: &dyn PdfFontResolver,
514) -> Result<u32, PdfEditError> {
515 if doc.is_encrypted() {
516 return Err(PdfEditError::EncryptedDocument);
517 }
518 let page_id = pages_lookup(doc, page)?;
519
520 let resolved = fonts.resolve(&appearance.font_id)?;
521 fonts.glyphs_for_text(&resolved, text)?;
522
523 let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, text)?;
524 let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
525 let reset_ctm = inverse_matrix(page_content_ctm(doc, page_id)?);
526
527 let mut content = Content::decode(&[]).map_err(|_| PdfEditError::MalformedContent)?;
528 content.operations =
529 build_text_object_operations(&resource_name, appearance, anchor, &cid_codes, reset_ctm);
530 let encoded = content
531 .encode()
532 .map_err(|_| PdfEditError::MalformedContent)?;
533 let new_stream_id = doc.add_object(Stream::new(dictionary! {}, encoded));
534
535 append_content_stream(doc, page_id, new_stream_id)?;
536
537 Ok(page)
538}
539
540#[allow(clippy::too_many_arguments)]
543fn apply_correct_ocr_text(
544 doc: &mut Document,
545 page: u32,
546 rect: PdfRect,
547 text: &str,
548 appearance: &PdfTextAppearance,
549 cover_color: PdfColor,
550 background_proof: PdfOcrBackgroundProof,
551 fonts: &dyn PdfFontResolver,
552) -> Result<u32, PdfEditError> {
553 let page_id = pages_lookup(doc, page)?;
554 let page_bounds = page_bounds(doc, page_id)?;
555 validate_ocr_correction(
556 page_bounds,
557 rect,
558 text,
559 appearance,
560 cover_color,
561 background_proof,
562 page_rotation(doc, page_id)?,
563 )?;
564
565 let resolved = fonts.resolve(&appearance.font_id)?;
566 fonts.glyphs_for_text(&resolved, text)?;
567 let width = measure_advance_1000(&resolved, text) / 1000.0 * appearance.size_pt;
568 if width > rect.width || appearance.size_pt > rect.height {
569 return Err(PdfEditError::TextOverflow);
570 }
571 let (font_obj_id, cid_codes) = embed_type0_font(doc, &resolved, text)?;
572 let resource_name = add_font_resource(doc, page_id, font_obj_id)?;
573 let reset_ctm = inverse_matrix(page_content_ctm(doc, page_id)?);
574
575 let mut content = Content::decode(&[]).map_err(|_| PdfEditError::MalformedContent)?;
576 content.operations = build_ocr_correction_operations(
577 &resource_name,
578 appearance,
579 rect,
580 cover_color,
581 &cid_codes,
582 reset_ctm,
583 );
584 let encoded = content
585 .encode()
586 .map_err(|_| PdfEditError::MalformedContent)?;
587 let new_stream_id = doc.add_object(Stream::new(dictionary! {}, encoded));
588 append_content_stream(doc, page_id, new_stream_id)?;
589 Ok(page)
590}
591
592fn validate_ocr_correction(
593 page_bounds: PdfRect,
594 rect: PdfRect,
595 text: &str,
596 appearance: &PdfTextAppearance,
597 cover_color: PdfColor,
598 background_proof: PdfOcrBackgroundProof,
599 rotation: i64,
600) -> Result<(), PdfEditError> {
601 if rotation != 0 {
602 return Err(PdfEditError::UnsupportedTransform);
603 }
604 if !matches!(background_proof, PdfOcrBackgroundProof::NearUniform)
605 || !is_finite_color(cover_color)
606 || cover_color.alpha < 1.0
607 || !is_finite_color(appearance.color)
608 || !appearance.size_pt.is_finite()
609 || appearance.size_pt <= 0.0
610 || text.is_empty()
611 || !is_finite_rect(page_bounds)
612 || page_bounds.width <= 0.0
613 || page_bounds.height <= 0.0
614 || !is_finite_rect(rect)
615 || rect.width <= 0.0
616 || rect.height <= 0.0
617 || !page_bounds.contains(rect)
618 {
619 return Err(PdfEditError::TextOverflow);
620 }
621 Ok(())
622}
623
624fn is_finite_rect(rect: PdfRect) -> bool {
625 rect.x.is_finite() && rect.y.is_finite() && rect.width.is_finite() && rect.height.is_finite()
626}
627
628fn is_finite_color(color: PdfColor) -> bool {
629 color.r.is_finite()
630 && color.g.is_finite()
631 && color.b.is_finite()
632 && color.alpha.is_finite()
633 && (0.0..=1.0).contains(&color.r)
634 && (0.0..=1.0).contains(&color.g)
635 && (0.0..=1.0).contains(&color.b)
636 && (0.0..=1.0).contains(&color.alpha)
637}
638
639fn page_bounds(doc: &Document, page_id: ObjectId) -> Result<PdfRect, PdfEditError> {
640 let values = inherited_page_array(doc, page_id, b"CropBox")
641 .or_else(|| inherited_page_array(doc, page_id, b"MediaBox"))
642 .ok_or(PdfEditError::MalformedContent)?;
643 if values.len() != 4 {
644 return Err(PdfEditError::MalformedContent);
645 }
646 let x0 = values[0];
647 let y0 = values[1];
648 let width = values[2] - x0;
649 let height = values[3] - y0;
650 Ok(PdfRect {
651 x: x0,
652 y: y0,
653 width,
654 height,
655 })
656}
657
658fn page_rotation(doc: &Document, page_id: ObjectId) -> Result<i64, PdfEditError> {
659 Ok(inherited_page_number(doc, page_id, b"Rotate").unwrap_or(0))
660}
661
662fn inherited_page_array(doc: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<Vec<f64>> {
663 for _ in 0..64 {
664 let dict = doc.get_object(page_id).ok()?.as_dict().ok()?;
665 if let Ok(value) = dict.get(key) {
666 let value = match value {
667 Object::Reference(id) => doc.get_object(*id).ok()?,
668 value => value,
669 };
670 let array = value.as_array().ok()?;
671 return array.iter().map(object_number).collect();
672 }
673 page_id = match dict.get(b"Parent").ok()? {
674 Object::Reference(id) => *id,
675 _ => return None,
676 };
677 }
678 None
679}
680
681fn object_number(value: &Object) -> Option<f64> {
682 match value {
683 Object::Integer(value) => Some(*value as f64),
684 Object::Real(value) => Some(*value as f64),
685 _ => None,
686 }
687}
688
689fn inherited_page_number(doc: &Document, mut page_id: ObjectId, key: &[u8]) -> Option<i64> {
690 for _ in 0..64 {
691 let dict = doc.get_object(page_id).ok()?.as_dict().ok()?;
692 if let Ok(value) = dict.get(key) {
693 let value = match value {
694 Object::Reference(id) => doc.get_object(*id).ok()?,
695 value => value,
696 };
697 return value.as_i64().ok();
698 }
699 page_id = match dict.get(b"Parent").ok()? {
700 Object::Reference(id) => *id,
701 _ => return None,
702 };
703 }
704 None
705}
706
707fn append_content_stream(
710 doc: &mut Document,
711 page_id: ObjectId,
712 new_stream_id: ObjectId,
713) -> Result<(), PdfEditError> {
714 let page_obj = doc
715 .get_object(page_id)
716 .map_err(|_| PdfEditError::MalformedContent)?;
717 let page_dict = page_obj
718 .as_dict()
719 .map_err(|_| PdfEditError::MalformedContent)?;
720
721 let mut ids: Vec<ObjectId> = match page_dict.get(b"Contents") {
722 Ok(Object::Reference(id)) => vec![*id],
723 Ok(Object::Array(items)) => items
724 .iter()
725 .filter_map(|o| match o {
726 Object::Reference(id) => Some(*id),
727 _ => None,
728 })
729 .collect(),
730 _ => Vec::new(),
731 };
732 ids.push(new_stream_id);
733
734 let array = Object::Array(ids.into_iter().map(Object::Reference).collect());
735 if let Ok(Object::Dictionary(dict)) = doc.get_object_mut(page_id) {
736 dict.set("Contents", array);
737 }
738 Ok(())
739}
740
741fn embed_type0_font(
757 doc: &mut Document,
758 resolved: &ResolvedPdfFont,
759 text: &str,
760) -> Result<(ObjectId, Vec<u16>), PdfEditError> {
761 let subset = subset_for_pdf(&resolved.bytes, resolved.face_index, text)?;
762
763 let face = ttf_parser::Face::parse(&resolved.bytes, resolved.face_index)
764 .map_err(|_| PdfEditError::UnsupportedEncoding)?;
765 let mut seen_old_gids: Vec<u16> = Vec::new();
766 let mut cid_codes: Vec<u16> = Vec::with_capacity(text.chars().count());
767 for c in text.chars() {
768 let old_gid = face.glyph_index(c).ok_or(PdfEditError::MissingGlyph)?.0;
769 let new_gid = match seen_old_gids.iter().position(|&g| g == old_gid) {
770 Some(pos) => (pos + 1) as u16,
771 None => {
772 seen_old_gids.push(old_gid);
773 seen_old_gids.len() as u16
774 }
775 };
776 cid_codes.push(new_gid);
777 }
778
779 let font_file_id = doc.add_object(Stream::new(
780 dictionary! { "Length1" => subset.font_program.len() as i64 },
781 subset.font_program.clone(),
782 ));
783
784 let mut widths_sorted = subset.widths.clone();
785 widths_sorted.sort_by_key(|w| w.cid);
786 let mut w_array: Vec<Object> = Vec::with_capacity(widths_sorted.len() * 2);
787 for w in &widths_sorted {
788 w_array.push(Object::Integer(w.cid as i64));
789 w_array.push(Object::Array(vec![Object::Integer(
790 w.width_1000.round() as i64
791 )]));
792 }
793
794 let base_name: Vec<u8> = resolved.family.replace(' ', "").into_bytes();
795
796 let descriptor_id = doc.add_object(dictionary! {
797 "Type" => "FontDescriptor",
798 "FontName" => Object::Name(base_name.clone()),
799 "Flags" => 4,
800 "FontBBox" => Object::Array(vec![Object::Integer(0), Object::Integer(0), Object::Integer(1000), Object::Integer(1000)]),
801 "ItalicAngle" => 0,
802 "Ascent" => 900,
803 "Descent" => -200,
804 "CapHeight" => 700,
805 "StemV" => 80,
806 "FontFile2" => font_file_id,
807 });
808
809 let descendant_id = doc.add_object(dictionary! {
810 "Type" => "Font",
811 "Subtype" => "CIDFontType2",
812 "BaseFont" => Object::Name(base_name.clone()),
813 "CIDSystemInfo" => dictionary! {
814 "Registry" => Object::String(b"Adobe".to_vec(), StringFormat::Literal),
815 "Ordering" => Object::String(b"Identity".to_vec(), StringFormat::Literal),
816 "Supplement" => 0,
817 },
818 "FontDescriptor" => descriptor_id,
819 "DW" => 1000,
820 "W" => Object::Array(w_array),
821 "CIDToGIDMap" => "Identity",
822 });
823
824 let to_unicode_bytes = build_to_unicode_cmap(&subset.to_unicode).into_bytes();
825 let to_unicode_id = doc.add_object(Stream::new(dictionary! {}, to_unicode_bytes));
826
827 let type0_id = doc.add_object(dictionary! {
828 "Type" => "Font",
829 "Subtype" => "Type0",
830 "BaseFont" => Object::Name(base_name),
831 "Encoding" => "Identity-H",
832 "DescendantFonts" => Object::Array(vec![Object::Reference(descendant_id)]),
833 "ToUnicode" => to_unicode_id,
834 });
835
836 Ok((type0_id, cid_codes))
837}
838
839fn add_font_resource(
845 doc: &mut Document,
846 page_id: ObjectId,
847 font_obj_id: ObjectId,
848) -> Result<Vec<u8>, PdfEditError> {
849 let page_obj = doc
850 .get_object(page_id)
851 .map_err(|_| PdfEditError::MalformedContent)?;
852 let page_dict = page_obj
853 .as_dict()
854 .map_err(|_| PdfEditError::MalformedContent)?;
855
856 let existing_resources = dict_entry_clone(doc, page_dict, b"Resources");
857 let mut fonts_dict = dict_entry_clone(doc, &existing_resources, b"Font");
858
859 let mut counter = 1u32;
860 let mut name = format!("VREdit{counter}");
861 while fonts_dict.has(name.as_bytes()) {
862 counter += 1;
863 name = format!("VREdit{counter}");
864 }
865 fonts_dict.set(name.as_str(), Object::Reference(font_obj_id));
866
867 let mut new_resources = existing_resources;
868 new_resources.set("Font", Object::Dictionary(fonts_dict));
869
870 if let Ok(Object::Dictionary(page_dict_mut)) = doc.get_object_mut(page_id) {
871 page_dict_mut.set("Resources", Object::Dictionary(new_resources));
872 }
873
874 Ok(name.into_bytes())
875}
876
877fn dict_entry_clone(doc: &Document, dict: &Dictionary, key: &[u8]) -> Dictionary {
882 match dict.get(key) {
883 Ok(Object::Reference(id)) => doc
884 .get_object(*id)
885 .ok()
886 .and_then(|o| o.as_dict().ok())
887 .cloned()
888 .unwrap_or_else(|| dictionary! {}),
889 Ok(Object::Dictionary(d)) => d.clone(),
890 _ => dictionary! {},
891 }
892}
893
894fn matrix_from_operands(operands: &[Object]) -> Option<Matrix> {
895 let values: Vec<f64> = operands.iter().filter_map(object_number).collect();
896 match values.as_slice() {
897 [a, b, c, d, e, f] => Some(Matrix {
898 a: *a,
899 b: *b,
900 c: *c,
901 d: *d,
902 e: *e,
903 f: *f,
904 }),
905 _ => None,
906 }
907}
908
909fn content_ctm(operations: &[Operation]) -> Matrix {
910 let mut ctm = Matrix::IDENTITY;
911 let mut stack = Vec::new();
912 for operation in operations {
913 match operation.operator.as_str() {
914 "q" => stack.push(ctm),
915 "Q" => {
916 if let Some(saved) = stack.pop() {
917 ctm = saved;
918 }
919 }
920 "cm" => {
921 if let Some(matrix) = matrix_from_operands(&operation.operands) {
922 ctm = matrix.then(ctm);
923 }
924 }
925 _ => {}
926 }
927 }
928 ctm
929}
930
931fn page_content_ctm(doc: &Document, page_id: ObjectId) -> Result<Matrix, PdfEditError> {
932 let page = doc
933 .get_object(page_id)
934 .map_err(|_| PdfEditError::MalformedContent)?
935 .as_dict()
936 .map_err(|_| PdfEditError::MalformedContent)?;
937 let contents = page
938 .get(b"Contents")
939 .map_err(|_| PdfEditError::MalformedContent)?;
940 let ids: Vec<ObjectId> = match contents {
941 Object::Reference(id) => vec![*id],
942 Object::Array(items) => items
943 .iter()
944 .filter_map(|item| match item {
945 Object::Reference(id) => Some(*id),
946 _ => None,
947 })
948 .collect(),
949 _ => Vec::new(),
950 };
951 let mut ctm = Matrix::IDENTITY;
952 let mut stack = Vec::new();
953 for id in ids {
954 let stream = doc
955 .get_object(id)
956 .map_err(|_| PdfEditError::MalformedContent)?
957 .as_stream()
958 .map_err(|_| PdfEditError::MalformedContent)?;
959 let bytes = stream
960 .decompressed_content()
961 .map_err(|_| PdfEditError::MalformedContent)?;
962 let content = Content::decode(&bytes).map_err(|_| PdfEditError::MalformedContent)?;
963 for operation in content.operations {
964 match operation.operator.as_str() {
965 "q" => stack.push(ctm),
966 "Q" => {
967 if let Some(saved) = stack.pop() {
968 ctm = saved;
969 }
970 }
971 "cm" => {
972 if let Some(matrix) = matrix_from_operands(&operation.operands) {
973 ctm = matrix.then(ctm);
974 }
975 }
976 _ => {}
977 }
978 }
979 }
980 Ok(ctm)
981}
982
983fn inverse_matrix(matrix: Matrix) -> Option<Matrix> {
984 if (matrix.a - 1.0).abs() < f64::EPSILON
985 && matrix.b.abs() < f64::EPSILON
986 && matrix.c.abs() < f64::EPSILON
987 && (matrix.d - 1.0).abs() < f64::EPSILON
988 && matrix.e.abs() < f64::EPSILON
989 && matrix.f.abs() < f64::EPSILON
990 {
991 return None;
992 }
993 let determinant = matrix.a * matrix.d - matrix.b * matrix.c;
994 if !determinant.is_finite() || determinant.abs() < f64::EPSILON {
995 return None;
996 }
997 let a = matrix.d / determinant;
998 let b = -matrix.b / determinant;
999 let c = -matrix.c / determinant;
1000 let d = matrix.a / determinant;
1001 Some(Matrix {
1002 a,
1003 b,
1004 c,
1005 d,
1006 e: -(matrix.e * a + matrix.f * c),
1007 f: -(matrix.e * b + matrix.f * d),
1008 })
1009}
1010
1011fn matrix_operation(matrix: Matrix) -> Operation {
1012 Operation::new(
1013 "cm",
1014 vec![
1015 Object::Real(matrix.a as f32),
1016 Object::Real(matrix.b as f32),
1017 Object::Real(matrix.c as f32),
1018 Object::Real(matrix.d as f32),
1019 Object::Real(matrix.e as f32),
1020 Object::Real(matrix.f as f32),
1021 ],
1022 )
1023}
1024
1025fn build_text_object_operations(
1029 resource_name: &[u8],
1030 appearance: &PdfTextAppearance,
1031 anchor: PdfPoint,
1032 cid_codes: &[u16],
1033 reset_ctm: Option<Matrix>,
1034) -> Vec<Operation> {
1035 let mut hex_bytes = Vec::with_capacity(cid_codes.len() * 2);
1036 for code in cid_codes {
1037 hex_bytes.extend_from_slice(&code.to_be_bytes());
1038 }
1039
1040 let mut operations = vec![Operation::new("q", vec![])];
1041 if let Some(matrix) = reset_ctm {
1042 operations.push(matrix_operation(matrix));
1043 }
1044 operations.extend([
1045 Operation::new("BT", vec![]),
1046 Operation::new(
1047 "Tf",
1048 vec![
1049 Object::Name(resource_name.to_vec()),
1050 Object::Real(appearance.size_pt as f32),
1051 ],
1052 ),
1053 Operation::new(
1054 "rg",
1055 vec![
1056 Object::Real(appearance.color.r as f32),
1057 Object::Real(appearance.color.g as f32),
1058 Object::Real(appearance.color.b as f32),
1059 ],
1060 ),
1061 Operation::new(
1062 "Tm",
1063 vec![
1064 Object::Integer(1),
1065 Object::Integer(0),
1066 Object::Integer(0),
1067 Object::Integer(1),
1068 Object::Real(anchor.x as f32),
1069 Object::Real(anchor.y as f32),
1070 ],
1071 ),
1072 Operation::new(
1073 "Tj",
1074 vec![Object::String(hex_bytes, StringFormat::Hexadecimal)],
1075 ),
1076 Operation::new("ET", vec![]),
1077 Operation::new("Q", vec![]),
1078 ]);
1079 operations
1080}
1081
1082fn build_ocr_correction_operations(
1085 resource_name: &[u8],
1086 appearance: &PdfTextAppearance,
1087 rect: PdfRect,
1088 cover_color: PdfColor,
1089 cid_codes: &[u16],
1090 reset_ctm: Option<Matrix>,
1091) -> Vec<Operation> {
1092 let mut operations = vec![Operation::new("q", vec![])];
1093 if let Some(matrix) = reset_ctm {
1094 operations.push(matrix_operation(matrix));
1095 }
1096 operations.extend([
1097 Operation::new(
1098 "rg",
1099 vec![
1100 Object::Real(cover_color.r as f32),
1101 Object::Real(cover_color.g as f32),
1102 Object::Real(cover_color.b as f32),
1103 ],
1104 ),
1105 Operation::new(
1106 "re",
1107 vec![
1108 Object::Real(rect.x as f32),
1109 Object::Real(rect.y as f32),
1110 Object::Real(rect.width as f32),
1111 Object::Real(rect.height as f32),
1112 ],
1113 ),
1114 Operation::new("f", vec![]),
1115 Operation::new("Q", vec![]),
1116 ]);
1117 operations.extend(build_text_object_operations(
1118 resource_name,
1119 appearance,
1120 PdfPoint {
1121 x: rect.x,
1122 y: rect.y + rect.height * 0.18,
1123 },
1124 cid_codes,
1125 reset_ctm,
1126 ));
1127 operations
1128}
1129
1130fn decode_stream_content(doc: &Document, id: ObjectId) -> Result<Content, PdfEditError> {
1135 let obj = doc
1136 .get_object(id)
1137 .map_err(|_| PdfEditError::MalformedContent)?;
1138 let stream = obj
1139 .as_stream()
1140 .map_err(|_| PdfEditError::MalformedContent)?;
1141 let bytes = stream
1142 .decompressed_content()
1143 .map_err(|_| PdfEditError::MalformedContent)?;
1144 Content::decode(&bytes).map_err(|_| PdfEditError::MalformedContent)
1145}
1146
1147fn write_stream_content(
1150 doc: &mut Document,
1151 id: ObjectId,
1152 content: &Content,
1153) -> Result<(), PdfEditError> {
1154 let encoded = content
1155 .encode()
1156 .map_err(|_| PdfEditError::MalformedContent)?;
1157 doc.objects
1158 .insert(id, Stream::new(dictionary! {}, encoded).into());
1159 Ok(())
1160}
1161
1162fn splice_operand_bytes(
1168 content: &mut Content,
1169 operator_index: u32,
1170 operand_index: u32,
1171 glyph_start: u32,
1172 glyph_end: u32,
1173 replacement: &[u8],
1174) -> Result<(), PdfEditError> {
1175 let op = content
1176 .operations
1177 .get_mut(operator_index as usize)
1178 .ok_or(PdfEditError::SourceDigestMismatch)?;
1179
1180 match op.operator.as_str() {
1181 "Tj" | "'" => {
1182 let operand = op
1183 .operands
1184 .get_mut(0)
1185 .ok_or(PdfEditError::SourceDigestMismatch)?;
1186 splice_string_object(operand, glyph_start, glyph_end, replacement)
1187 }
1188 "\"" => {
1189 let operand = op
1190 .operands
1191 .get_mut(2)
1192 .ok_or(PdfEditError::SourceDigestMismatch)?;
1193 splice_string_object(operand, glyph_start, glyph_end, replacement)
1194 }
1195 "TJ" => {
1196 let Some(Object::Array(items)) = op.operands.get_mut(0) else {
1197 return Err(PdfEditError::SourceDigestMismatch);
1198 };
1199 let item = items
1200 .get_mut(operand_index as usize)
1201 .ok_or(PdfEditError::SourceDigestMismatch)?;
1202 splice_string_object(item, glyph_start, glyph_end, replacement)
1203 }
1204 _ => Err(PdfEditError::SourceDigestMismatch),
1205 }
1206}
1207
1208fn splice_string_object(
1209 obj: &mut Object,
1210 start: u32,
1211 end: u32,
1212 replacement: &[u8],
1213) -> Result<(), PdfEditError> {
1214 let Object::String(bytes, _fmt) = obj else {
1215 return Err(PdfEditError::SourceDigestMismatch);
1216 };
1217 let (start, end) = (start as usize, end as usize);
1218 if start > end || end > bytes.len() {
1219 return Err(PdfEditError::SourceDigestMismatch);
1220 }
1221 let mut new_bytes = Vec::with_capacity(bytes.len() - (end - start) + replacement.len());
1222 new_bytes.extend_from_slice(&bytes[..start]);
1223 new_bytes.extend_from_slice(replacement);
1224 new_bytes.extend_from_slice(&bytes[end..]);
1225 *bytes = new_bytes;
1226 Ok(())
1227}
1228
1229#[derive(Debug, Deserialize)]
1238#[serde(rename_all = "camelCase")]
1239pub struct ComposePdfPreviewRequest {
1240 pub source_path: String,
1241 pub expected_source_sha256: String,
1242 pub source_commands: Vec<PdfEditCommand>,
1243}
1244
1245#[cfg(test)]
1250mod tests {
1251 use super::*;
1252 use lopdf::{dictionary, Object as LObject};
1253
1254 use crate::model::{GlyphId, PdfColor, PdfEditCommand, PdfOcrBackgroundProof, PdfPageGraph};
1255
1256 use crate::test_fixtures::{build_one_page_pdf as build_pdf, pdf_bytes as doc_bytes};
1261
1262 fn locate_island(
1263 doc: &Document,
1264 sha: &str,
1265 page: u32,
1266 text: &str,
1267 ) -> crate::model::PdfTextIsland {
1268 let graph = walk_page_graph(doc, sha, page).expect("inspect fixture page");
1269 graph
1270 .islands
1271 .into_iter()
1272 .find(|isl| isl.text == text)
1273 .unwrap_or_else(|| panic!("no island with text {text:?}"))
1274 }
1275
1276 struct FailingFontResolver;
1277 impl PdfFontResolver for FailingFontResolver {
1278 fn resolve(&self, _id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
1279 Err(PdfEditError::UnsupportedEncoding)
1280 }
1281 fn glyphs_for_text(
1282 &self,
1283 _font: &ResolvedPdfFont,
1284 _text: &str,
1285 ) -> Result<Vec<GlyphId>, PdfEditError> {
1286 Err(PdfEditError::UnsupportedEncoding)
1287 }
1288 }
1289
1290 fn installable_font() -> Vec<u8> {
1291 crate::test_fixtures::build_test_ttf(
1293 0,
1294 &[('A', 1), ('B', 2), ('C', 3), ('H', 4), ('i', 5)],
1295 600,
1296 )
1297 }
1298
1299 struct TestFontResolver {
1300 id: &'static str,
1301 bytes: Vec<u8>,
1302 }
1303
1304 impl PdfFontResolver for TestFontResolver {
1305 fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError> {
1306 if id != self.id {
1307 return Err(PdfEditError::UnsupportedEncoding);
1308 }
1309 Ok(ResolvedPdfFont {
1310 id: self.id.to_string(),
1311 family: "Test Font".to_string(),
1312 bytes: self.bytes.clone(),
1313 face_index: 0,
1314 embeddable: true,
1315 subsettable: true,
1316 })
1317 }
1318
1319 fn glyphs_for_text(
1320 &self,
1321 font: &ResolvedPdfFont,
1322 text: &str,
1323 ) -> Result<Vec<GlyphId>, PdfEditError> {
1324 crate::font::glyph_ids_for_text(&font.bytes, font.face_index, text)
1327 }
1328 }
1329
1330 fn appearance(font_id: &str, size_pt: f64) -> PdfTextAppearance {
1331 PdfTextAppearance {
1332 font_id: font_id.to_string(),
1333 family: font_id.to_string(),
1334 size_pt,
1335 color: PdfColor {
1336 r: 0.0,
1337 g: 0.0,
1338 b: 0.0,
1339 alpha: 1.0,
1340 },
1341 }
1342 }
1343
1344 fn test_resolver() -> TestFontResolver {
1347 TestFontResolver {
1348 id: "test-font",
1349 bytes: installable_font(),
1350 }
1351 }
1352
1353 fn replace_cmd(
1358 id: &str,
1359 island: &crate::model::PdfTextIsland,
1360 after_text: &str,
1361 after_appearance: PdfTextAppearance,
1362 available_bounds: PdfRect,
1363 ) -> PdfEditCommand {
1364 PdfEditCommand::ReplaceSourceText {
1365 id: id.to_string(),
1366 locator: island.locator.clone(),
1367 before: PdfTextEditState {
1368 text: island.text.clone(),
1369 appearance: island.appearance.clone(),
1370 },
1371 after: PdfTextEditState {
1372 text: after_text.to_string(),
1373 appearance: after_appearance,
1374 },
1375 available_bounds,
1376 }
1377 }
1378
1379 fn reinspect(candidate: &ComposedCandidate, page: u32) -> (Document, PdfPageGraph) {
1383 let out = Document::load_mem(&candidate.bytes).expect("reopen candidate");
1384 let graph =
1385 walk_page_graph(&out, &document_sha256(&candidate.bytes), page).expect("reinspect");
1386 (out, graph)
1387 }
1388
1389 #[test]
1394 fn exact_operand_patch_replaces_single_tj_text_in_place() {
1395 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1396 let source = doc_bytes(&doc);
1397 let sha = document_sha256(&source);
1398 let island = locate_island(&doc, &sha, 1, "Hello");
1399
1400 let command = replace_cmd(
1401 "edit-1",
1402 &island,
1403 "Howdy",
1404 island.appearance.clone(),
1405 PdfRect {
1406 x: 72.0,
1407 y: 695.0,
1408 width: 400.0,
1409 height: 20.0,
1410 },
1411 );
1412
1413 let candidate = compose_candidate(&source, &[command], &FailingFontResolver)
1414 .expect("exact patch composes");
1415 assert_eq!(candidate.affected_pages, vec![1]);
1416
1417 let (_, graph) = reinspect(&candidate, 1);
1418 assert!(graph.islands.iter().any(|isl| isl.text == "Howdy"));
1419 assert!(!graph.islands.iter().any(|isl| isl.text == "Hello"));
1420 }
1421
1422 #[test]
1423 fn corrected_source_text_survives_native_staging_verification() {
1424 let source = include_bytes!("../tests/fixtures/source-correction-2page.pdf");
1425 let doc = Document::load_mem(source).expect("fixture");
1426 let sha = document_sha256(source);
1427 let island = locate_island(&doc, &sha, 1, "ORIGINAL SOURCE TEXT");
1428 let command = replace_cmd(
1429 "correction",
1430 &island,
1431 "SOURCE TEST 930",
1432 island.appearance.clone(),
1433 PdfRect {
1434 x: 72.0,
1435 y: 717.0,
1436 width: 146.028,
1437 height: 12.0,
1438 },
1439 );
1440 for _ in 0..32 {
1442 assert_eq!(
1443 simple_font_identity_bytes(
1444 &doc,
1445 *doc.get_pages().get(&1).expect("page"),
1446 b"F1",
1447 "SOURCE TEST 930"
1448 ),
1449 Some(b"SOURCE TEST 930".to_vec())
1450 );
1451 let candidate =
1452 compose_candidate(source, std::slice::from_ref(&command), &FailingFontResolver)
1453 .expect("compose");
1454 let (_, graph) = reinspect(&candidate, 1);
1455 assert!(graph
1456 .islands
1457 .iter()
1458 .any(|island| island.text.contains("SOURCE TEST 930")));
1459 assert!(graph
1460 .islands
1461 .iter()
1462 .any(|island| island.text == "SIBLING SOURCE TEXT"));
1463 let store = crate::save::PdfSaveTokenStore::default();
1464 crate::save::stage_prepared_save(
1465 &store,
1466 "/fixture.pdf",
1467 &sha,
1468 &sha,
1469 candidate.bytes,
1470 candidate.affected_pages,
1471 crate::save::expected_text_checks(std::slice::from_ref(&command)),
1472 )
1473 .expect("verified stage");
1474 }
1475 }
1476
1477 #[test]
1478 fn exact_operand_patch_rejects_overflow_before_mutating() {
1479 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hi) Tj ET");
1480 let source = doc_bytes(&doc);
1481 let sha = document_sha256(&source);
1482 let island = locate_island(&doc, &sha, 1, "Hi");
1483
1484 let command = replace_cmd(
1486 "edit-overflow",
1487 &island,
1488 "This replacement text is far too long",
1489 island.appearance.clone(),
1490 PdfRect {
1491 x: 72.0,
1492 y: 695.0,
1493 width: 10.0,
1494 height: 10.0,
1495 },
1496 );
1497
1498 let err = compose_candidate(&source, &[command], &FailingFontResolver)
1499 .expect_err("overflow must reject");
1500 assert!(matches!(err, PdfEditError::TextOverflow));
1501 }
1502
1503 #[test]
1508 fn compose_candidate_on_base_preserves_annotations_while_editing_text() {
1509 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1510 let source = doc_bytes(&doc);
1511 let sha = document_sha256(&source);
1512 let island = locate_island(&doc, &sha, 1, "Hello");
1513
1514 let mut annotated = doc.clone();
1519 let page_id = *annotated.get_pages().get(&1).expect("page 1 exists");
1520 let annot_id = annotated.add_object(dictionary! {
1521 "Type" => "Annot",
1522 "Subtype" => "FreeText",
1523 "Rect" => LObject::Array(vec![
1524 LObject::Real(10.0),
1525 LObject::Real(10.0),
1526 LObject::Real(100.0),
1527 LObject::Real(30.0),
1528 ]),
1529 "Contents" => LObject::String(b"note".to_vec(), lopdf::StringFormat::Literal),
1530 });
1531 if let Ok(LObject::Dictionary(page_dict)) = annotated.get_object_mut(page_id) {
1532 page_dict.set("Annots", LObject::Array(vec![LObject::Reference(annot_id)]));
1533 }
1534 let base = doc_bytes(&annotated);
1535
1536 let command = replace_cmd(
1537 "edit-1",
1538 &island,
1539 "Howdy",
1540 island.appearance.clone(),
1541 PdfRect {
1542 x: 72.0,
1543 y: 695.0,
1544 width: 400.0,
1545 height: 20.0,
1546 },
1547 );
1548
1549 let candidate = compose_candidate_on_base(&sha, &base, &[command], &FailingFontResolver)
1550 .expect("compose on annotated base");
1551
1552 let (out, graph) = reinspect(&candidate, 1);
1553 let out_page_id = *out.get_pages().get(&1).expect("page 1 exists in candidate");
1554 let out_page_dict = out
1555 .get_object(out_page_id)
1556 .expect("page object")
1557 .as_dict()
1558 .expect("page dict");
1559 assert!(
1560 out_page_dict.has(b"Annots"),
1561 "FreeText annotation must survive text composition"
1562 );
1563
1564 assert!(graph.islands.iter().any(|isl| isl.text == "Howdy"));
1565 assert!(!graph.islands.iter().any(|isl| isl.text == "Hello"));
1566 }
1567
1568 #[test]
1569 fn compose_candidate_on_base_with_no_commands_returns_base_verbatim() {
1570 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1571 let base = doc_bytes(&doc);
1572 let sha = document_sha256(&base);
1573 let candidate = compose_candidate_on_base(&sha, &base, &[], &FailingFontResolver)
1574 .expect("empty commands compose trivially");
1575 assert_eq!(candidate.bytes, base);
1576 assert!(candidate.affected_pages.is_empty());
1577 }
1578
1579 #[test]
1584 fn stale_document_sha256_is_rejected() {
1585 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1586 let source = doc_bytes(&doc);
1587 let sha = document_sha256(&source);
1588 let mut island = locate_island(&doc, &sha, 1, "Hello");
1589 island.locator.document_sha256 =
1590 "0000000000000000000000000000000000000000000000000000000000000000".to_string();
1591
1592 let command = replace_cmd(
1593 "edit-stale",
1594 &island,
1595 "Howdy",
1596 island.appearance.clone(),
1597 PdfRect {
1598 x: 72.0,
1599 y: 695.0,
1600 width: 400.0,
1601 height: 20.0,
1602 },
1603 );
1604
1605 let err = compose_candidate(&source, &[command], &FailingFontResolver)
1606 .expect_err("stale source must reject");
1607 assert!(matches!(err, PdfEditError::StaleSource));
1608 }
1609
1610 #[test]
1611 fn tampered_island_digest_with_valid_document_sha_is_source_digest_mismatch() {
1612 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1613 let source = doc_bytes(&doc);
1614 let sha = document_sha256(&source);
1615 let mut island = locate_island(&doc, &sha, 1, "Hello");
1616
1617 island.locator.island_digest = format!("{}-tampered", island.locator.island_digest);
1621
1622 let command = replace_cmd(
1623 "edit-tampered",
1624 &island,
1625 "Howdy",
1626 island.appearance.clone(),
1627 PdfRect {
1628 x: 72.0,
1629 y: 695.0,
1630 width: 400.0,
1631 height: 20.0,
1632 },
1633 );
1634
1635 let err = compose_candidate(&source, &[command], &FailingFontResolver)
1636 .expect_err("tampered digest must reject");
1637 assert!(matches!(err, PdfEditError::SourceDigestMismatch));
1638 }
1639
1640 #[test]
1645 fn local_rewrite_replaces_adjacent_tj_operators_via_embedded_font_round_trip() {
1646 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (CAR YOU) Tj 55 0 Td ( LIST.) Tj ET");
1648 let source = doc_bytes(&doc);
1649 let sha = document_sha256(&source);
1650 let island = locate_island(&doc, &sha, 1, "CAR YOU LIST.");
1651 assert!(island.locator.spans.len() > 1);
1654
1655 let resolver = test_resolver();
1656 let command = replace_cmd(
1657 "edit-split",
1658 &island,
1659 "Hi",
1660 appearance("test-font", 20.0),
1661 PdfRect {
1662 x: 72.0,
1663 y: 690.0,
1664 width: 400.0,
1665 height: 40.0,
1666 },
1667 );
1668
1669 let candidate =
1670 compose_candidate(&source, &[command], &resolver).expect("local rewrite composes");
1671 let (_, graph) = reinspect(&candidate, 1);
1672
1673 assert!(!graph.islands.iter().any(|isl| isl.text == "CAR YOU LIST."));
1676 assert!(graph
1677 .islands
1678 .iter()
1679 .any(|isl| isl.text == "Hi" && isl.editable));
1680 }
1681
1682 #[test]
1683 fn local_rewrite_used_when_appearance_changes_even_for_single_span() {
1684 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1685 let source = doc_bytes(&doc);
1686 let sha = document_sha256(&source);
1687 let island = locate_island(&doc, &sha, 1, "Hello");
1688 assert_eq!(island.locator.spans.len(), 1);
1689
1690 let resolver = test_resolver();
1691 let command = replace_cmd(
1694 "edit-appearance",
1695 &island,
1696 "Hi",
1697 appearance("test-font", 24.0),
1698 PdfRect {
1699 x: 72.0,
1700 y: 690.0,
1701 width: 400.0,
1702 height: 40.0,
1703 },
1704 );
1705
1706 let candidate =
1707 compose_candidate(&source, &[command], &resolver).expect("compose succeeds");
1708 let (_, graph) = reinspect(&candidate, 1);
1709 assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
1710 }
1711
1712 #[test]
1717 fn add_text_appends_isolated_stream_without_touching_existing_content() {
1718 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1719 let source = doc_bytes(&doc);
1720
1721 let resolver = test_resolver();
1722 let command = PdfEditCommand::AddText {
1723 id: "add-1".to_string(),
1724 page: 1,
1725 anchor: PdfPoint { x: 100.0, y: 100.0 },
1726 text: "Hi".to_string(),
1727 appearance: appearance("test-font", 18.0),
1728 };
1729
1730 let candidate =
1731 compose_candidate(&source, &[command], &resolver).expect("add text composes");
1732 assert_eq!(candidate.affected_pages, vec![1]);
1733
1734 let (_, graph) = reinspect(&candidate, 1);
1735
1736 assert!(graph.islands.iter().any(|isl| isl.text == "Existing"));
1737 assert!(graph.islands.iter().any(|isl| isl.text == "Hi"));
1738 }
1739
1740 #[test]
1741 fn add_text_missing_glyph_rejects_before_any_mutation() {
1742 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1743 let source = doc_bytes(&doc);
1744 let resolver = test_resolver();
1746 let command = PdfEditCommand::AddText {
1747 id: "add-missing".to_string(),
1748 page: 1,
1749 anchor: PdfPoint { x: 100.0, y: 100.0 },
1750 text: "Zzz".to_string(),
1751 appearance: appearance("test-font", 18.0),
1752 };
1753
1754 let err = compose_candidate(&source, &[command], &resolver)
1755 .expect_err("missing glyph must reject");
1756 assert!(matches!(err, PdfEditError::MissingGlyph));
1757 }
1758
1759 #[test]
1760 fn add_text_unknown_font_id_rejects_with_unsupported_encoding() {
1761 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1762 let source = doc_bytes(&doc);
1763 let command = PdfEditCommand::AddText {
1764 id: "add-unknown-font".to_string(),
1765 page: 1,
1766 anchor: PdfPoint { x: 100.0, y: 100.0 },
1767 text: "Hi".to_string(),
1768 appearance: appearance("does-not-exist", 18.0),
1769 };
1770
1771 let err = compose_candidate(&source, &[command], &FailingFontResolver)
1772 .expect_err("unknown font id must reject");
1773 assert!(matches!(err, PdfEditError::UnsupportedEncoding));
1774 }
1775
1776 fn ocr_command(id: &str, line_id: &str, text: &str, rect: PdfRect) -> PdfEditCommand {
1777 PdfEditCommand::CorrectOcrLine {
1778 id: id.to_string(),
1779 page: 1,
1780 line_id: line_id.to_string(),
1781 rect,
1782 text: text.to_string(),
1783 appearance: appearance("test-font", 18.0),
1784 cover_color: PdfColor {
1785 r: 1.0,
1786 g: 1.0,
1787 b: 1.0,
1788 alpha: 1.0,
1789 },
1790 background_proof: PdfOcrBackgroundProof::NearUniform,
1791 }
1792 }
1793
1794 #[test]
1795 fn correct_ocr_text_appends_cover_and_type0_text_stream() {
1796 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1797 let source = doc_bytes(&doc);
1798 let resolver = test_resolver();
1799 let command = ocr_command(
1800 "ocr-1",
1801 "line-1",
1802 "Hi",
1803 PdfRect {
1804 x: 100.0,
1805 y: 100.0,
1806 width: 100.0,
1807 height: 30.0,
1808 },
1809 );
1810 let candidate = compose_candidate(&source, &[command], &resolver).expect("compose OCR");
1811 let (out, graph) = reinspect(&candidate, 1);
1812 let page_id = out.get_pages()[&1];
1813 let page = out
1814 .get_object(page_id)
1815 .expect("page")
1816 .as_dict()
1817 .expect("dict");
1818 let contents = page
1819 .get(b"Contents")
1820 .expect("contents")
1821 .as_array()
1822 .expect("array");
1823 let stream_id = contents
1824 .last()
1825 .expect("new stream")
1826 .as_reference()
1827 .expect("ref");
1828 let stream = out
1829 .get_object(stream_id)
1830 .expect("stream")
1831 .as_stream()
1832 .expect("stream");
1833 let content = Content::decode(&stream.decompressed_content().expect("decode stream"))
1834 .expect("content operations");
1835 let operators: Vec<&str> = content
1836 .operations
1837 .iter()
1838 .map(|operation| operation.operator.as_str())
1839 .collect();
1840 assert_eq!(&operators[..5], &["q", "rg", "re", "f", "Q"]);
1841 assert!(operators.windows(2).any(|window| window == ["BT", "Tf"]));
1842
1843 assert!(graph.islands.iter().any(|island| island.text == "Hi"));
1844 }
1845
1846 #[test]
1847 fn correct_ocr_text_duplicate_line_id_last_wins() {
1848 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1849 let source = doc_bytes(&doc);
1850 let resolver = test_resolver();
1851 let rect = PdfRect {
1852 x: 100.0,
1853 y: 100.0,
1854 width: 100.0,
1855 height: 30.0,
1856 };
1857 let commands = vec![
1858 ocr_command("ocr-old", "line-1", "Hi", rect),
1859 ocr_command("ocr-new", "line-1", "Hi", rect),
1860 ];
1861 let candidate = compose_candidate(&source, &commands, &resolver).expect("compose OCR");
1862 let (_, graph) = reinspect(&candidate, 1);
1863 assert_eq!(
1864 graph
1865 .islands
1866 .iter()
1867 .filter(|island| island.text == "Hi")
1868 .count(),
1869 1
1870 );
1871 }
1872
1873 #[test]
1874 fn correct_ocr_text_rejects_rotation_and_out_of_bounds_rect() {
1875 let mut rotated = build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET");
1876 let page_id = rotated.get_pages()[&1];
1877 rotated
1878 .get_object_mut(page_id)
1879 .expect("page")
1880 .as_dict_mut()
1881 .expect("dict")
1882 .set("Rotate", 90);
1883 let rotated_source = doc_bytes(&rotated);
1884 let resolver = test_resolver();
1885 let rect = PdfRect {
1886 x: 100.0,
1887 y: 100.0,
1888 width: 100.0,
1889 height: 30.0,
1890 };
1891 assert!(matches!(
1892 compose_candidate(
1893 &rotated_source,
1894 &[ocr_command("r", "line", "Hi", rect)],
1895 &resolver
1896 ),
1897 Err(PdfEditError::UnsupportedTransform)
1898 ));
1899
1900 let source = doc_bytes(&build_pdf(b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET"));
1901 let bad_rect = PdfRect {
1902 x: 600.0,
1903 y: 100.0,
1904 width: 100.0,
1905 height: 30.0,
1906 };
1907 assert!(matches!(
1908 compose_candidate(
1909 &source,
1910 &[ocr_command("b", "line", "Hi", bad_rect)],
1911 &resolver
1912 ),
1913 Err(PdfEditError::TextOverflow)
1914 ));
1915 }
1916
1917 #[test]
1918 fn empty_command_list_returns_source_bytes_unchanged() {
1919 let doc = build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET");
1920 let source = doc_bytes(&doc);
1921 let candidate = compose_candidate(&source, &[], &FailingFontResolver)
1922 .expect("empty batch always succeeds");
1923 assert_eq!(candidate.bytes, source);
1924 assert!(candidate.affected_pages.is_empty());
1925 }
1926}