1use std::collections::{HashMap, VecDeque};
55use std::sync::Mutex;
56use std::time::{Duration, Instant};
57
58use serde::{Deserialize, Serialize};
59
60use crate::inspect::{content_stream_ids, walk_page_graph};
61use crate::model::{
62 document_sha256, effective_pdf_edit_commands, sha256_hex, PdfEditCommand, PdfEditError,
63};
64
65const TOKEN_TTL: Duration = Duration::from_secs(5 * 60);
67
68const MAX_TOKENS_PER_SOURCE: usize = 2;
71
72#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
80#[serde(rename_all = "camelCase")]
81pub struct ExpectedTextCheck {
82 pub page: u32,
83 pub text: String,
84}
85
86#[derive(Clone, Debug, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct PreparedPdfSaveMeta {
91 pub token: String,
92 pub candidate_sha256: String,
93 pub candidate_bytes: Vec<u8>,
94 pub affected_pages: Vec<u32>,
95 pub expected_text: Vec<ExpectedTextCheck>,
96}
97
98#[derive(Clone, Debug, Serialize)]
100#[serde(rename_all = "camelCase")]
101pub struct PdfCommitResult {
102 pub target_path: String,
103 pub document_sha256: String,
104}
105
106#[derive(Debug, Clone, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct PreparePdfSaveRequestMeta {
110 pub source_path: String,
111 pub expected_source_sha256: String,
112 pub annotation_pdf_base64: String,
113 pub source_commands: Vec<PdfEditCommand>,
114}
115
116struct SaveTokenRecord {
121 source_path: String,
122 source_sha256: String,
127 candidate_bytes: Vec<u8>,
128 created_at: Instant,
129}
130
131#[derive(Default)]
132struct TokenStoreInner {
133 tokens: HashMap<String, SaveTokenRecord>,
134 by_source: HashMap<String, VecDeque<String>>,
137}
138
139#[derive(Default)]
143pub struct PdfSaveTokenStore {
144 inner: Mutex<TokenStoreInner>,
145}
146
147impl PdfSaveTokenStore {
148 fn insert(&self, source_path: &str, source_sha256: &str, candidate_bytes: Vec<u8>) -> String {
151 let token = random_save_token();
152 let mut inner = self.inner.lock().expect("token store mutex poisoned");
153
154 let mut evicted = Vec::new();
155 {
156 let queue = inner.by_source.entry(source_path.to_string()).or_default();
157 while queue.len() >= MAX_TOKENS_PER_SOURCE {
158 match queue.pop_front() {
159 Some(oldest) => evicted.push(oldest),
160 None => break,
161 }
162 }
163 }
164 for oldest in evicted {
165 inner.tokens.remove(&oldest);
166 }
167 inner
168 .by_source
169 .entry(source_path.to_string())
170 .or_default()
171 .push_back(token.clone());
172 inner.tokens.insert(
173 token.clone(),
174 SaveTokenRecord {
175 source_path: source_path.to_string(),
176 source_sha256: source_sha256.to_string(),
177 candidate_bytes,
178 created_at: Instant::now(),
179 },
180 );
181 token
182 }
183
184 #[cfg(test)]
188 fn peek(&self, token: &str) -> Result<(), PdfEditError> {
189 let inner = self.inner.lock().expect("token store mutex poisoned");
190 let record = inner.tokens.get(token).ok_or(PdfEditError::WriteConflict)?;
191 if record.created_at.elapsed() > TOKEN_TTL {
192 return Err(PdfEditError::WriteConflict);
193 }
194 Ok(())
195 }
196
197 fn consume(&self, token: &str) -> Result<SaveTokenRecord, PdfEditError> {
202 let mut inner = self.inner.lock().expect("token store mutex poisoned");
203 let Some(record) = inner.tokens.remove(token) else {
204 return Err(PdfEditError::WriteConflict);
205 };
206 if let Some(queue) = inner.by_source.get_mut(&record.source_path) {
207 queue.retain(|t| t != token);
208 }
209 if record.created_at.elapsed() > TOKEN_TTL {
210 return Err(PdfEditError::WriteConflict);
211 }
212 Ok(record)
213 }
214
215 pub(crate) fn source_path_of(&self, token: &str) -> Option<String> {
219 let inner = self.inner.lock().expect("token store mutex poisoned");
220 inner
221 .tokens
222 .get(token)
223 .map(|record| record.source_path.clone())
224 }
225
226 pub(crate) fn source_paths(&self) -> Vec<String> {
228 let inner = self.inner.lock().expect("token store mutex poisoned");
229 inner
230 .by_source
231 .iter()
232 .filter(|(_, queue)| !queue.is_empty())
233 .map(|(path, _)| path.clone())
234 .collect()
235 }
236
237 pub fn invalidate_source(&self, source_path: &str) {
241 let mut inner = self.inner.lock().expect("token store mutex poisoned");
242 if let Some(queue) = inner.by_source.remove(source_path) {
243 for token in queue {
244 inner.tokens.remove(&token);
245 }
246 }
247 }
248
249 #[cfg(test)]
250 fn live_token_count(&self, source_path: &str) -> usize {
251 let inner = self.inner.lock().expect("token store mutex poisoned");
252 inner
253 .by_source
254 .get(source_path)
255 .map(|q| q.len())
256 .unwrap_or(0)
257 }
258}
259
260fn random_save_token() -> String {
263 let mut bytes = [0_u8; 16];
264 getrandom::fill(&mut bytes).expect("OS RNG unavailable");
265 let mut token = String::with_capacity(32);
266 for byte in bytes {
267 use std::fmt::Write;
268 let _ = write!(&mut token, "{byte:02x}");
269 }
270 token
271}
272
273pub fn stage_prepared_save(
291 store: &PdfSaveTokenStore,
292 source_path: &str,
293 expected_source_sha256: &str,
294 actual_source_sha256: &str,
295 candidate_bytes: Vec<u8>,
296 affected_pages: Vec<u32>,
297 expected_text: Vec<ExpectedTextCheck>,
298) -> Result<PreparedPdfSaveMeta, PdfEditError> {
299 stage_checked(
300 store,
301 source_path,
302 expected_source_sha256,
303 actual_source_sha256,
304 None,
305 candidate_bytes,
306 affected_pages,
307 expected_text,
308 )
309}
310
311#[allow(clippy::too_many_arguments)]
319pub fn stage_prepared_save_against_base(
320 store: &PdfSaveTokenStore,
321 source_path: &str,
322 expected_source_sha256: &str,
323 actual_source_sha256: &str,
324 base_bytes: &[u8],
325 candidate_bytes: Vec<u8>,
326 affected_pages: Vec<u32>,
327 expected_text: Vec<ExpectedTextCheck>,
328) -> Result<PreparedPdfSaveMeta, PdfEditError> {
329 stage_checked(
330 store,
331 source_path,
332 expected_source_sha256,
333 actual_source_sha256,
334 Some(base_bytes),
335 candidate_bytes,
336 affected_pages,
337 expected_text,
338 )
339}
340
341#[allow(clippy::too_many_arguments)]
342fn stage_checked(
343 store: &PdfSaveTokenStore,
344 source_path: &str,
345 expected_source_sha256: &str,
346 actual_source_sha256: &str,
347 base_bytes: Option<&[u8]>,
348 candidate_bytes: Vec<u8>,
349 affected_pages: Vec<u32>,
350 expected_text: Vec<ExpectedTextCheck>,
351) -> Result<PreparedPdfSaveMeta, PdfEditError> {
352 if expected_source_sha256 != actual_source_sha256 {
353 return Err(PdfEditError::StaleSource);
354 }
355
356 structurally_verify_candidate_against(&candidate_bytes, &expected_text, base_bytes)?;
357
358 let candidate_sha256 = sha256_hex(&candidate_bytes);
359 let returned_candidate_bytes = candidate_bytes.clone();
360 let token = store.insert(source_path, actual_source_sha256, candidate_bytes);
361
362 Ok(PreparedPdfSaveMeta {
363 token,
364 candidate_sha256,
365 candidate_bytes: returned_candidate_bytes,
366 affected_pages,
367 expected_text,
368 })
369}
370
371#[cfg(test)]
375fn structurally_verify_candidate(
376 candidate_bytes: &[u8],
377 expected_text: &[ExpectedTextCheck],
378) -> Result<(), PdfEditError> {
379 structurally_verify_candidate_against(candidate_bytes, expected_text, None)
380}
381
382fn structurally_verify_candidate_against(
385 candidate_bytes: &[u8],
386 expected_text: &[ExpectedTextCheck],
387 base_bytes: Option<&[u8]>,
388) -> Result<(), PdfEditError> {
389 let doc =
390 crate::inspect::load_pdf(candidate_bytes).map_err(|_| PdfEditError::VerificationFailed)?;
391
392 let pages = doc.get_pages();
393 if pages.is_empty() {
394 return Err(PdfEditError::VerificationFailed);
395 }
396
397 for page_id in pages.values() {
398 let page_obj = doc
399 .get_object(*page_id)
400 .map_err(|_| PdfEditError::VerificationFailed)?;
401 let page_dict = page_obj
402 .as_dict()
403 .map_err(|_| PdfEditError::VerificationFailed)?;
404 if let Ok(lopdf::Object::Reference(id)) = page_dict.get(b"Resources") {
405 doc.get_object(*id)
406 .map_err(|_| PdfEditError::VerificationFailed)?;
407 }
408 if let Ok(contents) = page_dict.get(b"Contents") {
409 if !matches!(
411 contents,
412 lopdf::Object::Reference(_) | lopdf::Object::Array(_)
413 ) {
414 return Err(PdfEditError::VerificationFailed);
415 }
416 let ids =
417 content_stream_ids(&doc, contents).map_err(|_| PdfEditError::VerificationFailed)?;
418 for id in ids {
419 let obj = doc
420 .get_object(id)
421 .map_err(|_| PdfEditError::VerificationFailed)?;
422 obj.as_stream()
423 .map_err(|_| PdfEditError::VerificationFailed)?;
424 }
425 }
426 }
427
428 if let Some(base_bytes) = base_bytes {
429 let base =
430 crate::inspect::load_pdf(base_bytes).map_err(|_| PdfEditError::VerificationFailed)?;
431 let before = crate::inspect::resource_fingerprint(&base)
432 .map_err(|_| PdfEditError::VerificationFailed)?;
433 let after = crate::inspect::resource_fingerprint(&doc)
434 .map_err(|_| PdfEditError::VerificationFailed)?;
435 if !crate::inspect::resources_preserved(&before, &after) {
436 return Err(PdfEditError::VerificationFailed);
437 }
438 }
439
440 let candidate_sha256 = document_sha256(candidate_bytes);
441 for check in expected_text {
442 let graph = walk_page_graph(&doc, &candidate_sha256, check.page)
443 .map_err(|_| PdfEditError::VerificationFailed)?;
444 let found = graph
445 .islands
446 .iter()
447 .any(|island| island.text.contains(&check.text));
448 if !found {
449 return Err(PdfEditError::VerificationFailed);
450 }
451 }
452
453 Ok(())
454}
455
456pub fn select_compose_base<'a>(
462 source: &'a [u8],
463 annotation_pdf: &'a [u8],
464) -> Result<&'a [u8], PdfEditError> {
465 if annotation_pdf.len() <= 1 {
466 return Ok(source);
467 }
468 if annotation_pdf.len() > source.len() && annotation_pdf.starts_with(source) {
469 return Ok(annotation_pdf);
470 }
471 Err(PdfEditError::AnnotationSerializationFailed)
472}
473
474pub fn expected_text_checks(commands: &[PdfEditCommand]) -> Vec<ExpectedTextCheck> {
478 effective_pdf_edit_commands(commands)
479 .into_iter()
480 .map(|command| match command {
481 PdfEditCommand::ReplaceSourceText { locator, after, .. } => ExpectedTextCheck {
482 page: locator.page,
483 text: after.text.clone(),
484 },
485 PdfEditCommand::AddText { page, text, .. } => ExpectedTextCheck {
486 page: *page,
487 text: text.clone(),
488 },
489 PdfEditCommand::CorrectOcrLine { page, text, .. } => ExpectedTextCheck {
490 page: *page,
491 text: text.clone(),
492 },
493 })
494 .collect()
495}
496
497pub fn resolve_commit<F>(
514 store: &PdfSaveTokenStore,
515 token: &str,
516 target_path: Option<String>,
517 read_current_source_for_recheck: F,
518) -> Result<(String, Vec<u8>, PdfCommitResult), PdfEditError>
519where
520 F: FnOnce(&str) -> Result<Vec<u8>, PdfEditError>,
521{
522 resolve_commit_with(
523 store,
524 token,
525 target_path,
526 |source, target| source == target,
527 read_current_source_for_recheck,
528 )
529}
530
531pub fn resolve_commit_with<F, S>(
537 store: &PdfSaveTokenStore,
538 token: &str,
539 target_path: Option<String>,
540 same_file: S,
541 read_current_source_for_recheck: F,
542) -> Result<(String, Vec<u8>, PdfCommitResult), PdfEditError>
543where
544 F: FnOnce(&str) -> Result<Vec<u8>, PdfEditError>,
545 S: FnOnce(&str, &str) -> bool,
546{
547 let record = store.consume(token)?;
548 let target = target_path.unwrap_or_else(|| record.source_path.clone());
549
550 if same_file(&record.source_path, &target) {
551 let current_bytes = read_current_source_for_recheck(&record.source_path)?;
552 let current_sha256 = document_sha256(¤t_bytes);
553 if current_sha256 != record.source_sha256 {
554 return Err(PdfEditError::WriteConflict);
555 }
556 }
557
558 let document_sha256_value = document_sha256(&record.candidate_bytes);
559 let result = PdfCommitResult {
560 target_path: target.clone(),
561 document_sha256: document_sha256_value,
562 };
563 Ok((record.source_path, record.candidate_bytes, result))
564}
565
566#[cfg(test)]
571mod tests {
572 use super::*;
573
574 use crate::test_fixtures::build_one_page_pdf_bytes as build_pdf;
579
580 fn hello_pdf() -> Vec<u8> {
581 build_pdf(b"BT /F1 12 Tf 72 700 Td (Hello) Tj ET")
582 }
583
584 #[test]
585 fn structural_recheck_accepts_wellformed_candidate_with_matching_text() {
586 let bytes = hello_pdf();
587 let checks = vec![ExpectedTextCheck {
588 page: 1,
589 text: "Hello".to_string(),
590 }];
591 assert!(structurally_verify_candidate(&bytes, &checks).is_ok());
592 }
593
594 #[test]
595 fn structural_recheck_accepts_contents_that_is_an_indirect_array_of_streams() {
596 let doc = crate::test_fixtures::custom_page_pdf(
597 |d| vec![("F1", crate::test_fixtures::helvetica_font(d))],
598 &[b"BT /F1 12 Tf 72 700 Td ", b"(Hello) Tj ET"],
599 true,
600 );
601 let bytes = crate::test_fixtures::pdf_bytes(&doc);
602 let checks = vec![ExpectedTextCheck {
603 page: 1,
604 text: "Hello".to_string(),
605 }];
606 assert!(structurally_verify_candidate(&bytes, &checks).is_ok());
607 }
608
609 #[test]
610 fn structural_recheck_rejects_missing_expected_text() {
611 let bytes = hello_pdf();
612 let checks = vec![ExpectedTextCheck {
613 page: 1,
614 text: "Goodbye".to_string(),
615 }];
616 let err = structurally_verify_candidate(&bytes, &checks).unwrap_err();
617 assert!(matches!(err, PdfEditError::VerificationFailed));
618 }
619
620 #[test]
621 fn structural_recheck_rejects_unparseable_candidate() {
622 let bytes = b"not a pdf at all".to_vec();
623 let err = structurally_verify_candidate(&bytes, &[]).unwrap_err();
624 assert!(matches!(err, PdfEditError::VerificationFailed));
625 }
626
627 #[test]
628 fn stage_prepared_save_rejects_stale_source_before_touching_candidate() {
629 let store = PdfSaveTokenStore::default();
630 let bytes = hello_pdf();
631 let err = stage_prepared_save(
632 &store,
633 "/doc.pdf",
634 "sha-expected",
635 "sha-actual-different",
636 bytes,
637 vec![1],
638 vec![],
639 )
640 .unwrap_err();
641 assert!(matches!(err, PdfEditError::StaleSource));
642 assert_eq!(store.live_token_count("/doc.pdf"), 0);
643 }
644
645 #[test]
646 fn stage_prepared_save_issues_token_for_valid_candidate() {
647 let store = PdfSaveTokenStore::default();
648 let bytes = hello_pdf();
649 let checks = vec![ExpectedTextCheck {
650 page: 1,
651 text: "Hello".to_string(),
652 }];
653 let meta =
654 stage_prepared_save(&store, "/doc.pdf", "sha-x", "sha-x", bytes, vec![1], checks)
655 .expect("valid candidate stages");
656 assert_eq!(meta.token.len(), 32);
657 assert_eq!(meta.affected_pages, vec![1]);
658 assert_eq!(store.live_token_count("/doc.pdf"), 1);
659 }
660
661 #[test]
662 fn stage_prepared_save_rejects_verification_failure_without_issuing_token() {
663 let store = PdfSaveTokenStore::default();
664 let bytes = hello_pdf();
665 let checks = vec![ExpectedTextCheck {
666 page: 1,
667 text: "Not Present".to_string(),
668 }];
669 let err = stage_prepared_save(&store, "/doc.pdf", "sha-x", "sha-x", bytes, vec![1], checks)
670 .unwrap_err();
671 assert!(matches!(err, PdfEditError::VerificationFailed));
672 assert_eq!(store.live_token_count("/doc.pdf"), 0);
673 }
674
675 #[test]
676 fn store_evicts_oldest_token_beyond_two_per_source() {
677 let store = PdfSaveTokenStore::default();
678 let t1 = store.insert("/doc.pdf", "sha", hello_pdf());
679 let t2 = store.insert("/doc.pdf", "sha", hello_pdf());
680 let t3 = store.insert("/doc.pdf", "sha", hello_pdf());
681
682 assert_eq!(store.live_token_count("/doc.pdf"), 2);
683 assert!(store.peek(&t1).is_err(), "oldest token must be evicted");
684 assert!(store.peek(&t2).is_ok());
685 assert!(store.peek(&t3).is_ok());
686 }
687
688 #[test]
689 fn peek_unknown_token_is_write_conflict() {
690 let store = PdfSaveTokenStore::default();
691 let err = store.peek("does-not-exist").unwrap_err();
692 assert!(matches!(err, PdfEditError::WriteConflict));
693 }
694
695 #[test]
696 fn commit_consumes_token_so_replay_fails() {
697 let store = PdfSaveTokenStore::default();
698 let sha = document_sha256(&hello_pdf());
699 let token = store.insert("/doc.pdf", &sha, hello_pdf());
700
701 let first = resolve_commit(&store, &token, None, |_| Ok(hello_pdf()));
702 assert!(first.is_ok());
703
704 let replay = resolve_commit(&store, &token, None, |_| Ok(hello_pdf()));
705 let err = replay.unwrap_err();
706 assert!(matches!(err, PdfEditError::WriteConflict));
707 }
708
709 #[test]
710 fn commit_rejects_expired_token() {
711 let store = PdfSaveTokenStore::default();
712 let token = random_save_token();
713 {
714 let mut inner = store.inner.lock().unwrap();
715 inner
716 .by_source
717 .entry("/doc.pdf".to_string())
718 .or_default()
719 .push_back(token.clone());
720 inner.tokens.insert(
721 token.clone(),
722 SaveTokenRecord {
723 source_path: "/doc.pdf".to_string(),
724 source_sha256: document_sha256(&hello_pdf()),
725 candidate_bytes: hello_pdf(),
726 created_at: Instant::now() - TOKEN_TTL - Duration::from_secs(1),
727 },
728 );
729 }
730
731 let err = resolve_commit(&store, &token, None, |_| Ok(hello_pdf())).unwrap_err();
732 assert!(matches!(err, PdfEditError::WriteConflict));
733 assert!(store.peek(&token).is_err());
735 }
736
737 #[test]
738 fn commit_rejects_source_changed_since_prepare_for_same_path_save() {
739 let store = PdfSaveTokenStore::default();
740 let prepare_time_sha = document_sha256(&hello_pdf());
741 let token = store.insert("/doc.pdf", &prepare_time_sha, hello_pdf());
742 let different_current_bytes = build_pdf(b"BT /F1 12 Tf 72 700 Td (Changed) Tj ET");
743
744 let err = resolve_commit(
745 &store,
746 &token,
747 None, |_| Ok(different_current_bytes.clone()),
749 )
750 .unwrap_err();
751 assert!(matches!(err, PdfEditError::WriteConflict));
752 }
753
754 #[test]
755 fn commit_allows_same_path_save_when_source_unchanged() {
756 let store = PdfSaveTokenStore::default();
757 let source_bytes = hello_pdf();
758 let prepare_time_sha = document_sha256(&source_bytes);
759 let token = store.insert("/doc.pdf", &prepare_time_sha, source_bytes.clone());
760
761 let (source_path, bytes, result) =
762 resolve_commit(&store, &token, None, |_| Ok(source_bytes.clone()))
763 .expect("unchanged source commits cleanly");
764 assert_eq!(source_path, "/doc.pdf");
765 assert_eq!(result.target_path, "/doc.pdf");
766 assert_eq!(bytes, source_bytes);
767 }
768
769 #[test]
770 fn commit_save_as_does_not_recheck_source_sha() {
771 let store = PdfSaveTokenStore::default();
775 let sha = document_sha256(&hello_pdf());
776 let token = store.insert("/doc.pdf", &sha, hello_pdf());
777
778 let (source_path, _bytes, result) =
779 resolve_commit(&store, &token, Some("/doc-copy.pdf".to_string()), |_| {
780 panic!("same-path recheck must not run for Save As")
781 })
782 .expect("Save As commits without a same-path recheck");
783 assert_eq!(source_path, "/doc.pdf");
784 assert_eq!(result.target_path, "/doc-copy.pdf");
785 }
786
787 #[test]
788 fn invalidate_source_drops_every_remaining_token_for_that_path() {
789 let store = PdfSaveTokenStore::default();
790 let sha = document_sha256(&hello_pdf());
791 let t1 = store.insert("/doc.pdf", &sha, hello_pdf());
792 assert!(store.peek(&t1).is_ok());
793
794 store.invalidate_source("/doc.pdf");
795
796 assert!(store.peek(&t1).is_err());
797 assert_eq!(store.live_token_count("/doc.pdf"), 0);
798 }
799
800 #[test]
801 fn select_compose_base_uses_source_for_sentinel_annotation_bytes() {
802 let source = hello_pdf();
803 let sentinel = vec![0u8];
804 let base = select_compose_base(&source, &sentinel).expect("sentinel selects source");
805 assert_eq!(base, source.as_slice());
806 }
807
808 #[test]
809 fn select_compose_base_uses_annotation_bytes_when_incremental_update() {
810 let source = hello_pdf();
811 let mut annotation_pdf = source.clone();
812 annotation_pdf.extend_from_slice(b"\n% appended annotation objects + new xref\n");
813 let base =
814 select_compose_base(&source, &annotation_pdf).expect("incremental update selects it");
815 assert_eq!(base, annotation_pdf.as_slice());
816 }
817
818 #[test]
819 fn select_compose_base_rejects_annotation_bytes_not_extending_source() {
820 let source = hello_pdf();
821 let unrelated = build_pdf(b"BT /F1 12 Tf 72 700 Td (Different) Tj ET");
822 let err = select_compose_base(&source, &unrelated).unwrap_err();
823 assert!(matches!(err, PdfEditError::AnnotationSerializationFailed));
824 }
825
826 #[test]
827 fn expected_text_checks_derives_one_entry_per_command() {
828 use crate::model::{
829 PdfColor, PdfPoint, PdfRect, PdfSourceLocator, PdfTextAppearance, PdfTextEditState,
830 };
831
832 let appearance = PdfTextAppearance {
833 font_id: "f".to_string(),
834 family: "F".to_string(),
835 size_pt: 12.0,
836 color: PdfColor {
837 r: 0.0,
838 g: 0.0,
839 b: 0.0,
840 alpha: 1.0,
841 },
842 };
843 let commands = vec![
844 PdfEditCommand::ReplaceSourceText {
845 id: "e1".to_string(),
846 locator: PdfSourceLocator {
847 document_sha256: "sha".to_string(),
848 page: 2,
849 spans: vec![],
850 island_digest: "digest".to_string(),
851 },
852 before: PdfTextEditState {
853 text: "old".to_string(),
854 appearance: appearance.clone(),
855 },
856 after: PdfTextEditState {
857 text: "new text".to_string(),
858 appearance: appearance.clone(),
859 },
860 available_bounds: PdfRect {
861 x: 0.0,
862 y: 0.0,
863 width: 10.0,
864 height: 10.0,
865 },
866 },
867 PdfEditCommand::AddText {
868 id: "a1".to_string(),
869 page: 3,
870 anchor: PdfPoint { x: 1.0, y: 1.0 },
871 text: "added text".to_string(),
872 appearance,
873 },
874 ];
875
876 let checks = expected_text_checks(&commands);
877 assert_eq!(checks.len(), 2);
878 assert_eq!(
879 checks[0],
880 ExpectedTextCheck {
881 page: 2,
882 text: "new text".to_string()
883 }
884 );
885 assert_eq!(
886 checks[1],
887 ExpectedTextCheck {
888 page: 3,
889 text: "added text".to_string()
890 }
891 );
892 }
893
894 #[test]
895 fn expected_text_checks_last_ocr_correction_wins_by_line_id() {
896 use crate::model::{PdfColor, PdfOcrBackgroundProof, PdfRect, PdfTextAppearance};
897 let appearance = PdfTextAppearance {
898 font_id: "f".to_string(),
899 family: "F".to_string(),
900 size_pt: 12.0,
901 color: PdfColor {
902 r: 0.0,
903 g: 0.0,
904 b: 0.0,
905 alpha: 1.0,
906 },
907 };
908 let correction = |id: &str, text: &str| PdfEditCommand::CorrectOcrLine {
909 id: id.to_string(),
910 page: 4,
911 line_id: "line-1".to_string(),
912 rect: PdfRect {
913 x: 1.0,
914 y: 1.0,
915 width: 100.0,
916 height: 20.0,
917 },
918 text: text.to_string(),
919 appearance: appearance.clone(),
920 cover_color: PdfColor {
921 r: 1.0,
922 g: 1.0,
923 b: 1.0,
924 alpha: 1.0,
925 },
926 background_proof: PdfOcrBackgroundProof::NearUniform,
927 };
928 let checks = expected_text_checks(&[correction("old", "old"), correction("new", "new")]);
929 assert_eq!(
930 checks,
931 vec![ExpectedTextCheck {
932 page: 4,
933 text: "new".to_string()
934 }]
935 );
936 }
937 fn inherited_source() -> Vec<u8> {
943 crate::test_fixtures::pdf_bytes(&crate::test_fixtures::inherited_resources_pdf(
944 b"BT /F1 12 Tf 72 700 Td (Existing) Tj ET /Im1 Do",
945 ))
946 }
947
948 fn candidate_that_hides_the_inherited_resources(source: &[u8]) -> Vec<u8> {
950 let mut doc = lopdf::Document::load_mem(source).expect("source loads");
951 let page_id = doc.get_pages()[&1];
952 let some_object = *doc.objects.keys().next().expect("an object");
953 if let Ok(lopdf::Object::Dictionary(page)) = doc.get_object_mut(page_id) {
954 page.set(
955 "Resources",
956 lopdf::dictionary! { "Font" => lopdf::dictionary! { "VREdit1" => some_object } },
957 );
958 }
959 crate::test_fixtures::pdf_bytes(&doc)
960 }
961
962 #[test]
963 fn finding2_staging_against_the_base_refuses_a_page_that_lost_its_inherited_resources() {
964 let source = inherited_source();
965 let sha = document_sha256(&source);
966 let hidden = candidate_that_hides_the_inherited_resources(&source);
967 let store = PdfSaveTokenStore::default();
968
969 let err = stage_prepared_save_against_base(
970 &store,
971 "/doc.pdf",
972 &sha,
973 &sha,
974 &source,
975 hidden.clone(),
976 vec![1],
977 vec![],
978 )
979 .unwrap_err();
980 assert!(matches!(err, PdfEditError::VerificationFailed));
981 assert_eq!(store.live_token_count("/doc.pdf"), 0, "no token for it");
982
983 stage_prepared_save(&store, "/doc.pdf", &sha, &sha, hidden, vec![1], vec![])
986 .expect("structurally fine, which is all the digest-only path can check");
987 }
988
989 #[test]
990 fn finding2_staging_against_the_base_accepts_a_candidate_that_changes_no_resource() {
991 let source = inherited_source();
992 let sha = document_sha256(&source);
993 let store = PdfSaveTokenStore::default();
994 stage_prepared_save_against_base(
995 &store,
996 "/doc.pdf",
997 &sha,
998 &sha,
999 &source,
1000 source.clone(),
1001 vec![],
1002 vec![],
1003 )
1004 .expect("a candidate identical to its base");
1005 }
1006
1007 #[test]
1008 fn finding2_an_unparseable_base_is_a_verification_failure() {
1009 let store = PdfSaveTokenStore::default();
1010 let err = stage_prepared_save_against_base(
1011 &store,
1012 "/doc.pdf",
1013 "sha",
1014 "sha",
1015 b"not a pdf",
1016 hello_pdf(),
1017 vec![],
1018 vec![],
1019 )
1020 .unwrap_err();
1021 assert!(matches!(err, PdfEditError::VerificationFailed));
1022 }
1023
1024 #[test]
1025 fn finding6_the_recheck_runs_when_the_comparison_says_the_target_is_the_source() {
1026 let store = PdfSaveTokenStore::default();
1027 let sha = document_sha256(&hello_pdf());
1028 let changed = build_pdf(b"BT /F1 12 Tf 72 700 Td (Changed) Tj ET");
1029
1030 let token = store.insert("/tmp/doc.pdf", &sha, hello_pdf());
1032 resolve_commit(&store, &token, Some("/tmp/./doc.pdf".to_string()), |_| {
1033 panic!("string comparison skips the recheck for an alias")
1034 })
1035 .expect("the alias gets past string equality");
1036
1037 let token = store.insert("/tmp/doc.pdf", &sha, hello_pdf());
1039 let err = resolve_commit_with(
1040 &store,
1041 &token,
1042 Some("/tmp/./doc.pdf".to_string()),
1043 |source, target| {
1044 assert_eq!((source, target), ("/tmp/doc.pdf", "/tmp/./doc.pdf"));
1045 true
1046 },
1047 |_| Ok(changed.clone()),
1048 )
1049 .unwrap_err();
1050 assert!(matches!(err, PdfEditError::WriteConflict));
1051
1052 let token = store.insert("/tmp/doc.pdf", &sha, hello_pdf());
1054 resolve_commit_with(
1055 &store,
1056 &token,
1057 Some("/tmp/other.pdf".to_string()),
1058 |_, _| false,
1059 |_| panic!("a different file is not rechecked"),
1060 )
1061 .expect("Save As");
1062 }
1063
1064 #[test]
1065 fn finding5_the_store_reports_a_tokens_source_and_every_live_source() {
1066 let store = PdfSaveTokenStore::default();
1067 let t1 = store.insert("/a.pdf", "sha", hello_pdf());
1068 store.insert("/b.pdf", "sha", hello_pdf());
1069 assert_eq!(store.source_path_of(&t1).as_deref(), Some("/a.pdf"));
1070 assert_eq!(store.source_path_of("unknown"), None);
1071 let mut sources = store.source_paths();
1072 sources.sort();
1073 assert_eq!(sources, vec!["/a.pdf".to_string(), "/b.pdf".to_string()]);
1074 resolve_commit(&store, &t1, Some("/c.pdf".to_string()), |_| Ok(vec![])).expect("save as");
1075 assert_eq!(store.source_path_of(&t1), None, "consumed");
1076 }
1077}