1use std::fmt;
29use std::rc::Rc;
30
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33
34#[derive(Debug, thiserror::Error)]
54pub enum PdfEditError {
55 #[error("PDF changed since edit started")]
56 StaleSource,
57 #[error("source operand changed")]
58 SourceDigestMismatch,
59 #[error("visible text has no unique editable source")]
60 #[allow(dead_code)] NoUniqueMatch,
62 #[error("text transform is unsupported")]
63 UnsupportedTransform,
64 #[error("font encoding is unsupported")]
65 UnsupportedEncoding,
66 #[error("selected font lacks a required glyph")]
67 MissingGlyph,
68 #[error("selected font lacks a required glyph for: {}", format_missing_chars(.0))]
73 MissingGlyphs(Vec<char>),
74 #[error("font license forbids embedding")]
75 FontEmbeddingDenied,
76 #[error("text exceeds safe bounds")]
77 TextOverflow,
78 #[error("PDF content stream is malformed")]
79 MalformedContent,
80 #[error("encrypted PDF cannot be edited")]
81 EncryptedDocument,
82 #[error("annotations could not be serialized")]
83 AnnotationSerializationFailed,
84 #[error("candidate verification failed")]
85 VerificationFailed,
86 #[error("target changed before save")]
87 WriteConflict,
88}
89
90fn format_missing_chars(chars: &[char]) -> String {
93 chars
94 .iter()
95 .map(|c| format!("{c:?}"))
96 .collect::<Vec<_>>()
97 .join(", ")
98}
99
100impl PdfEditError {
101 pub fn missing_chars(&self) -> Option<&[char]> {
103 match self {
104 PdfEditError::MissingGlyphs(chars) => Some(chars),
105 _ => None,
106 }
107 }
108
109 pub fn code(&self) -> &'static str {
112 match self {
113 PdfEditError::StaleSource => "stale_source",
114 PdfEditError::SourceDigestMismatch => "source_digest_mismatch",
115 PdfEditError::NoUniqueMatch => "no_unique_match",
116 PdfEditError::UnsupportedTransform => "unsupported_transform",
117 PdfEditError::UnsupportedEncoding => "unsupported_encoding",
118 PdfEditError::MissingGlyph | PdfEditError::MissingGlyphs(_) => "missing_glyph",
119 PdfEditError::FontEmbeddingDenied => "font_embedding_denied",
120 PdfEditError::TextOverflow => "text_overflow",
121 PdfEditError::MalformedContent => "malformed_content",
122 PdfEditError::EncryptedDocument => "encrypted_document",
123 PdfEditError::AnnotationSerializationFailed => "annotation_serialization_failed",
124 PdfEditError::VerificationFailed => "verification_failed",
125 PdfEditError::WriteConflict => "write_conflict",
126 }
127 }
128}
129
130impl Serialize for PdfEditError {
131 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
132 where
133 S: serde::Serializer,
134 {
135 use serde::ser::SerializeStruct;
136 let mut state = serializer.serialize_struct("PdfEditError", 2)?;
137 state.serialize_field("code", self.code())?;
138 state.serialize_field("message", &self.to_string())?;
139 state.end()
140 }
141}
142
143#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
150#[serde(rename_all = "camelCase")]
151pub struct PdfPoint {
152 pub x: f64,
153 pub y: f64,
154}
155
156#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
160#[serde(rename_all = "camelCase")]
161pub struct PdfRect {
162 pub x: f64,
163 pub y: f64,
164 pub width: f64,
165 pub height: f64,
166}
167
168impl PdfRect {
169 pub fn contains(&self, other: PdfRect) -> bool {
171 other.x >= self.x
172 && other.y >= self.y
173 && other.x + other.width <= self.x + self.width
174 && other.y + other.height <= self.y + self.height
175 }
176
177 pub fn intersects(&self, other: PdfRect) -> bool {
179 self.x < other.x + other.width
180 && other.x < self.x + self.width
181 && self.y < other.y + other.height
182 && other.y < self.y + self.height
183 }
184}
185
186#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
188#[serde(rename_all = "camelCase")]
189pub struct PdfColor {
190 pub r: f64,
191 pub g: f64,
192 pub b: f64,
193 pub alpha: f64,
194}
195
196#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
199#[serde(rename_all = "camelCase")]
200pub struct PdfTextAppearance {
201 pub font_id: String,
202 pub family: String,
203 pub size_pt: f64,
204 pub color: PdfColor,
205}
206
207#[derive(Serialize)]
208#[serde(rename_all = "camelCase")]
209#[allow(dead_code)] pub struct PdfDocumentRevision {
211 pub document_sha256: String,
212 pub page_count: u32,
213 pub pages: Vec<PdfPageGraph>,
214}
215
216#[derive(Serialize)]
217#[serde(rename_all = "camelCase")]
218pub struct PdfPageGraph {
219 pub page: u32,
220 pub document_sha256: String,
222 pub media_box: [f64; 4],
223 pub crop_box: [f64; 4],
224 pub rotation: i32,
225 pub islands: Vec<PdfTextIsland>,
226}
227
228#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
233#[serde(rename_all = "snake_case")]
234pub enum PdfEditBlocker {
235 UnsupportedFontType,
238 NoReversibleEncoding,
243 MissingMetrics,
246 MixedTextState,
249 UnmappedGlyphs,
251 SpansAcrossStreams,
253 PositionedTextRun,
257}
258
259#[derive(Clone, Copy, Debug, Serialize, PartialEq)]
262#[serde(rename_all = "camelCase")]
263pub struct PdfTextGap {
264 pub char_index: usize,
267 pub width_pt: f64,
270}
271
272#[derive(Clone, Debug)]
275#[allow(dead_code)] pub(crate) struct RunItem {
277 pub glyph: Option<GlyphSourceAddress>,
279 pub text: String,
281 pub start: f64,
283 pub end: f64,
284 pub gap_numbers: Option<Vec<GapNumber>>,
287 pub beyond_tc: f64,
290 pub word_space: bool,
293 pub pen_adjust: Option<f64>,
297 pub placement: Option<(Matrix, f64)>,
302}
303
304#[derive(Clone, Copy, Debug, PartialEq)]
306#[allow(dead_code)]
307pub(crate) struct GapNumber {
308 pub operator_index: u32,
309 pub item_index: u32,
311 pub value: f64,
312}
313
314#[derive(Serialize)]
315#[serde(rename_all = "camelCase")]
316pub struct PdfTextIsland {
317 pub id: String,
318 pub text: String,
319 pub quads: Vec<[f64; 8]>,
320 pub baseline: [f64; 4],
321 pub appearance: PdfTextAppearance,
322 pub locator: PdfSourceLocator,
323 pub editable: bool,
328 pub rejection: Option<PdfEditError>,
330 pub edit_blocker: Option<PdfEditBlocker>,
332 pub substitution_editable: bool,
338 #[serde(skip)]
340 #[allow(dead_code)] pub(crate) layout: Option<IslandLayout>,
342 pub gaps: Vec<PdfTextGap>,
344 #[serde(skip)]
345 #[allow(dead_code)] pub(crate) run: Vec<RunItem>,
347}
348
349#[derive(Clone, Copy, Debug, PartialEq)]
351#[allow(dead_code)] pub(crate) struct IslandLayout {
353 pub axis: (f64, f64),
356 pub placement: Matrix,
363 pub font_size: f64,
365 pub char_spacing: f64,
366 pub word_spacing: f64,
367 pub horizontal_scale: f64,
369}
370
371#[derive(Clone, Debug, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373pub struct PdfSourceLocator {
374 pub document_sha256: String,
375 pub page: u32,
376 pub spans: Vec<PdfSourceSpan>,
377 pub island_digest: String,
378}
379
380#[derive(Clone, Debug, Serialize, Deserialize)]
381#[serde(rename_all = "camelCase")]
382pub struct PdfSourceSpan {
383 pub stream_ref: (u32, u16),
384 pub contents_index: u32,
385 pub operator_index: u32,
386 pub operand_index: u32,
387 pub glyph_start: u32,
388 pub glyph_end: u32,
389 pub expected_text: String,
390 pub expected_digest: String,
391}
392
393#[derive(Clone, Copy, Debug, PartialEq)]
405pub struct Matrix {
406 pub a: f64,
407 pub b: f64,
408 pub c: f64,
409 pub d: f64,
410 pub e: f64,
411 pub f: f64,
412}
413
414impl Matrix {
415 pub const IDENTITY: Matrix = Matrix {
416 a: 1.0,
417 b: 0.0,
418 c: 0.0,
419 d: 1.0,
420 e: 0.0,
421 f: 0.0,
422 };
423
424 pub fn translation(tx: f64, ty: f64) -> Matrix {
425 Matrix {
426 a: 1.0,
427 b: 0.0,
428 c: 0.0,
429 d: 1.0,
430 e: tx,
431 f: ty,
432 }
433 }
434
435 pub fn then(self, other: Matrix) -> Matrix {
437 Matrix {
438 a: self.a * other.a + self.b * other.c,
439 b: self.a * other.b + self.b * other.d,
440 c: self.c * other.a + self.d * other.c,
441 d: self.c * other.b + self.d * other.d,
442 e: self.e * other.a + self.f * other.c + other.e,
443 f: self.e * other.b + self.f * other.d + other.f,
444 }
445 }
446
447 pub fn transform(self, point: (f64, f64)) -> (f64, f64) {
449 (
450 point.0 * self.a + point.1 * self.c + self.e,
451 point.0 * self.b + point.1 * self.d + self.f,
452 )
453 }
454
455 pub fn origin(self) -> (f64, f64) {
457 (self.e, self.f)
458 }
459
460 pub fn translate_text(&mut self, tx: f64, ty: f64) {
462 *self = Matrix::translation(tx, ty).then(*self);
463 }
464}
465
466#[derive(Clone, Debug)]
473pub struct DecodedGlyph {
474 pub cid: u32,
475 pub unicode: String,
476 pub source_range: (u32, u32),
477 pub bbox: Option<[f64; 4]>,
478}
479
480pub trait TextStateFont: fmt::Debug {
485 fn id(&self) -> &str;
488
489 fn decode_show_operand(
491 &self,
492 operand: &lopdf::Object,
493 ) -> Result<Vec<DecodedGlyph>, PdfEditError>;
494
495 fn advance(&self, cid: u32, font_size: f64) -> f64;
498}
499
500#[derive(Clone, Copy, Debug, PartialEq, Eq)]
504pub enum TextRenderMode {
505 Fill,
506 Stroke,
507 FillStroke,
508 Invisible,
509 FillClip,
510 StrokeClip,
511 FillStrokeClip,
512 Clip,
513}
514
515impl TextRenderMode {
516 pub fn from_operand(value: i64) -> TextRenderMode {
519 match value {
520 0 => TextRenderMode::Fill,
521 1 => TextRenderMode::Stroke,
522 2 => TextRenderMode::FillStroke,
523 4 => TextRenderMode::FillClip,
524 5 => TextRenderMode::StrokeClip,
525 6 => TextRenderMode::FillStrokeClip,
526 7 => TextRenderMode::Clip,
527 _ => TextRenderMode::Invisible,
528 }
529 }
530}
531
532#[derive(Clone, Debug, PartialEq)]
535pub struct PaintState {
536 pub render_mode: TextRenderMode,
537 pub fill_color: Option<[f64; 4]>,
538 pub stroke_color: Option<[f64; 4]>,
539}
540
541#[derive(Clone, Copy, Debug, PartialEq, Eq)]
545pub enum WritingDirection {
546 LeftToRight,
547 #[allow(dead_code)]
548 RightToLeft,
550 #[allow(dead_code)]
551 Vertical,
552}
553
554#[derive(Clone, Debug)]
558pub struct SourceAddress {
559 pub stream_ref: (u32, u16),
560 pub contents_index: u32,
561 pub operator_index: u32,
562 pub operand_index: u32,
563}
564
565impl SourceAddress {
566 pub fn with_glyph(&self, glyph_range: (u32, u32)) -> GlyphSourceAddress {
570 GlyphSourceAddress {
571 stream_ref: self.stream_ref,
572 contents_index: self.contents_index,
573 operator_index: self.operator_index,
574 operand_index: self.operand_index,
575 glyph_start: glyph_range.0,
576 glyph_end: glyph_range.1,
577 }
578 }
579}
580
581#[derive(Clone, Copy, Debug)]
585pub struct GlyphSourceAddress {
586 pub stream_ref: (u32, u16),
587 pub contents_index: u32,
588 pub operator_index: u32,
589 pub operand_index: u32,
590 pub glyph_start: u32,
591 pub glyph_end: u32,
592}
593
594#[derive(Clone, Debug)]
598pub struct PositionedGlyph {
599 pub page: u32,
600 pub unicode: String,
601 #[allow(dead_code)] pub cid: u32,
603 pub origin: (f64, f64),
605 pub quad: [f64; 8],
607 pub source: GlyphSourceAddress,
608 pub font_id: String,
609 pub font_size: f64,
610 pub pen_advance: f64,
614 pub text_matrix: Matrix,
615 pub ctm: Matrix,
616 pub writing_direction: WritingDirection,
617 pub paint: PaintState,
618}
619
620#[derive(Clone, Debug)]
624pub struct TextState {
625 pub ctm: Matrix,
626 pub text_matrix: Matrix,
627 pub text_line_matrix: Matrix,
628 pub font: Option<Rc<dyn TextStateFont>>,
629 pub font_size: f64,
631 pub char_spacing: f64,
633 pub word_spacing: f64,
635 pub horizontal_scale: f64,
637 pub leading: f64,
639 pub text_rise: f64,
641 pub render_mode: TextRenderMode,
642}
643
644impl TextState {
645 pub fn new() -> TextState {
646 TextState {
647 ctm: Matrix::IDENTITY,
648 text_matrix: Matrix::IDENTITY,
649 text_line_matrix: Matrix::IDENTITY,
650 font: None,
651 font_size: 0.0,
652 char_spacing: 0.0,
653 word_spacing: 0.0,
654 horizontal_scale: 1.0,
655 leading: 0.0,
656 text_rise: 0.0,
657 render_mode: TextRenderMode::Fill,
658 }
659 }
660
661 pub fn begin_text(&mut self) {
663 self.text_matrix = Matrix::IDENTITY;
664 self.text_line_matrix = Matrix::IDENTITY;
665 }
666
667 pub fn set_tm(&mut self, m: Matrix) {
669 self.text_matrix = m;
670 self.text_line_matrix = m;
671 }
672
673 pub fn move_line(&mut self, tx: f64, ty: f64) {
675 self.text_line_matrix = Matrix::translation(tx, ty).then(self.text_line_matrix);
676 self.text_matrix = self.text_line_matrix;
677 }
678
679 pub fn next_line(&mut self) {
681 self.move_line(0.0, -self.leading);
682 }
683
684 pub fn origin(&self) -> (f64, f64) {
687 self.ctm.transform(self.text_matrix.origin())
688 }
689
690 pub fn glyph_quad(
696 &self,
697 glyph: &DecodedGlyph,
698 text_matrix: Matrix,
699 ) -> Result<[f64; 8], PdfEditError> {
700 let font = self.font.as_ref().ok_or(PdfEditError::MalformedContent)?;
701 let (x_min, y_min, x_max, y_max) = match glyph.bbox {
702 Some([x0, y0, x1, y1]) => (
703 x0 / 1000.0 * self.font_size,
704 y0 / 1000.0 * self.font_size,
705 x1 / 1000.0 * self.font_size,
706 y1 / 1000.0 * self.font_size,
707 ),
708 None => {
709 let advance = font.advance(glyph.cid, self.font_size);
710 (0.0, self.font_size * -0.25, advance, self.font_size * 0.75)
711 }
712 };
713
714 let corners = [
715 (x_min, y_min),
716 (x_max, y_min),
717 (x_max, y_max),
718 (x_min, y_max),
719 ];
720 let mut quad = [0.0; 8];
721 for (i, (x, y)) in corners.iter().enumerate() {
722 let scaled = (x * self.horizontal_scale, y + self.text_rise);
723 let (tx, ty) = text_matrix.transform(scaled);
724 let (dx, dy) = self.ctm.transform((tx, ty));
725 quad[i * 2] = dx;
726 quad[i * 2 + 1] = dy;
727 }
728 Ok(quad)
729 }
730}
731
732impl Default for TextState {
733 fn default() -> Self {
734 TextState::new()
735 }
736}
737
738#[derive(Clone, Debug)]
744#[allow(dead_code)]
749pub struct PaintedBounds {
750 pub page: u32,
751 pub bounds: PdfRect,
752 pub unrelated: bool,
753}
754
755#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
762#[serde(rename_all = "camelCase")]
763pub struct PdfTextEditState {
764 pub text: String,
765 pub appearance: PdfTextAppearance,
766}
767
768#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
771#[serde(rename_all = "camelCase")]
772pub enum PdfOcrBackgroundProof {
773 NearUniform,
774}
775
776#[derive(Clone, Debug, Serialize, Deserialize)]
783#[serde(
784 tag = "kind",
785 rename_all = "camelCase",
786 rename_all_fields = "camelCase"
787)]
788#[allow(clippy::large_enum_variant)]
792pub enum PdfEditCommand {
793 ReplaceSourceText {
794 id: String,
795 locator: PdfSourceLocator,
796 before: PdfTextEditState,
797 after: PdfTextEditState,
798 available_bounds: PdfRect,
799 },
800 AddText {
801 id: String,
802 page: u32,
803 anchor: PdfPoint,
804 text: String,
805 appearance: PdfTextAppearance,
806 },
807 #[serde(rename = "correctOcrText")]
808 CorrectOcrLine {
809 id: String,
810 page: u32,
811 line_id: String,
812 rect: PdfRect,
813 text: String,
814 appearance: PdfTextAppearance,
815 cover_color: PdfColor,
816 background_proof: PdfOcrBackgroundProof,
817 },
818}
819
820#[cfg(any(feature = "edit", test))]
823pub(crate) fn effective_pdf_edit_commands(commands: &[PdfEditCommand]) -> Vec<&PdfEditCommand> {
824 use std::collections::HashMap;
825
826 let mut last_correction: HashMap<&str, usize> = HashMap::new();
827 for (index, command) in commands.iter().enumerate() {
828 if let PdfEditCommand::CorrectOcrLine { line_id, .. } = command {
829 last_correction.insert(line_id.as_str(), index);
830 }
831 }
832
833 commands
834 .iter()
835 .enumerate()
836 .filter_map(|(index, command)| match command {
837 PdfEditCommand::CorrectOcrLine { line_id, .. }
838 if last_correction.get(line_id.as_str()) != Some(&index) =>
839 {
840 None
841 }
842 _ => Some(command),
843 })
844 .collect()
845}
846
847#[derive(Clone, Debug)]
853pub struct ComposedCandidate {
854 pub bytes: Vec<u8>,
855 pub affected_pages: Vec<u32>,
856}
857
858#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
865#[serde(rename_all = "camelCase")]
866pub enum PdfFontSource {
867 Bundled,
868 System,
869}
870
871#[derive(Clone, Debug, Serialize)]
872#[serde(rename_all = "camelCase")]
873pub struct PdfFontCatalogEntry {
874 pub id: String,
875 pub family: String,
876 pub postscript_name: Option<String>,
877 pub styles: Vec<String>,
878 pub embeddable: bool,
879 pub subsettable: bool,
880 pub source: PdfFontSource,
881}
882
883#[derive(Clone, Debug)]
887pub struct ResolvedPdfFont {
888 #[allow(dead_code)] pub id: String,
890 pub family: String,
891 pub bytes: Vec<u8>,
893 pub face_index: u32,
895 #[allow(dead_code)] pub embeddable: bool,
897 #[allow(dead_code)]
898 pub subsettable: bool,
899}
900
901#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
905pub struct GlyphId(pub u16);
906
907pub trait PdfFontResolver {
912 fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError>;
913 fn glyphs_for_text(
914 &self,
915 font: &ResolvedPdfFont,
916 text: &str,
917 ) -> Result<Vec<GlyphId>, PdfEditError>;
918}
919
920pub fn sha256_hex(bytes: &[u8]) -> String {
927 let digest: [u8; 32] = Sha256::digest(bytes).into();
928 let mut out = String::with_capacity(digest.len() * 2);
929 for byte in digest {
930 out.push_str(&format!("{byte:02x}"));
931 }
932 out
933}
934
935pub fn document_sha256(source_bytes: &[u8]) -> String {
939 sha256_hex(source_bytes)
940}
941
942pub fn expected_text_digest(expected_text: &str) -> String {
947 sha256_hex(expected_text.as_bytes())
948}
949
950#[allow(clippy::too_many_arguments)]
957pub fn compute_island_digest(
958 spans: &[PdfSourceSpan],
959 decoded_text: &str,
960 font_id: &str,
961 text_matrix: &Matrix,
962 ctm: &Matrix,
963 paint: &PaintState,
964 quads: &[[f64; 8]],
965) -> String {
966 let mut buf = String::new();
967 for span in spans {
968 buf.push_str(&format!(
969 "span:{}.{}/{}/{}/{}-{}|",
970 span.stream_ref.0,
971 span.stream_ref.1,
972 span.contents_index,
973 span.operator_index,
974 span.operand_index,
975 span.glyph_start
976 ));
977 buf.push_str(&format!("{}\n", span.glyph_end));
978 }
979 buf.push_str("text:");
980 buf.push_str(decoded_text);
981 buf.push_str("\nfont:");
982 buf.push_str(font_id);
983 buf.push_str(&format!(
984 "\ntm:{},{},{},{},{},{}",
985 text_matrix.a, text_matrix.b, text_matrix.c, text_matrix.d, text_matrix.e, text_matrix.f
986 ));
987 buf.push_str(&format!(
988 "\nctm:{},{},{},{},{},{}",
989 ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f
990 ));
991 buf.push_str(&format!(
992 "\npaint:{:?}/{:?}/{:?}",
993 paint.render_mode, paint.fill_color, paint.stroke_color
994 ));
995 for quad in quads {
996 buf.push_str("\nquad:");
997 for value in quad {
998 buf.push_str(&format!("{value},"));
999 }
1000 }
1001 sha256_hex(buf.as_bytes())
1002}
1003
1004#[cfg(test)]
1009mod tests {
1010 use super::*;
1011
1012 #[test]
1013 fn sha256_hex_of_empty_input_is_64_hex_chars_and_stable() {
1014 let a = sha256_hex(b"");
1017 let b = sha256_hex(b"");
1018 assert_eq!(a, b);
1019 assert_eq!(a.len(), 64);
1020 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
1021 }
1022
1023 #[test]
1024 fn sha256_hex_is_deterministic_and_64_hex_chars() {
1025 let a = sha256_hex(b"viewright pdf editor");
1026 let b = sha256_hex(b"viewright pdf editor");
1027 assert_eq!(a, b);
1028 assert_eq!(a.len(), 64);
1029 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
1030 }
1031
1032 #[test]
1033 fn sha256_hex_differs_for_different_input() {
1034 assert_ne!(
1035 sha256_hex(b"CAR YOU LIST."),
1036 sha256_hex(b"CAR YOU LIST, YOU WIN.")
1037 );
1038 }
1039
1040 #[test]
1041 fn document_sha256_matches_sha256_hex() {
1042 let bytes = b"%PDF-1.7 fixture bytes";
1043 assert_eq!(document_sha256(bytes), sha256_hex(bytes));
1044 }
1045
1046 #[test]
1047 fn expected_text_digest_is_stable_and_distinct() {
1048 let a = expected_text_digest("CAR YOU LIST.");
1049 let b = expected_text_digest("CAR YOU LIST.");
1050 let c = expected_text_digest("CAR YOU LIST, YOU WIN.");
1051 assert_eq!(a, b);
1052 assert_ne!(a, c);
1053 }
1054
1055 fn sample_span(operator_index: u32, glyph_start: u32, glyph_end: u32) -> PdfSourceSpan {
1056 PdfSourceSpan {
1057 stream_ref: (7, 0),
1058 contents_index: 0,
1059 operator_index,
1060 operand_index: 0,
1061 glyph_start,
1062 glyph_end,
1063 expected_text: "CAR".to_string(),
1064 expected_digest: expected_text_digest("CAR"),
1065 }
1066 }
1067
1068 #[test]
1069 fn island_digest_is_stable_for_identical_inputs() {
1070 let spans = vec![sample_span(0, 0, 3)];
1071 let paint = PaintState {
1072 render_mode: TextRenderMode::Fill,
1073 fill_color: Some([0.0, 0.0, 0.0, 1.0]),
1074 stroke_color: None,
1075 };
1076 let tm = Matrix::IDENTITY;
1077 let ctm = Matrix::IDENTITY;
1078 let quads = vec![[0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0]];
1079
1080 let a = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
1081 let b = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
1082 assert_eq!(a, b);
1083 assert_eq!(a.len(), 64);
1084 }
1085
1086 #[test]
1087 fn island_digest_distinguishes_repeated_text_by_span_position() {
1088 let paint = PaintState {
1091 render_mode: TextRenderMode::Fill,
1092 fill_color: None,
1093 stroke_color: None,
1094 };
1095 let tm = Matrix::IDENTITY;
1096 let ctm = Matrix::IDENTITY;
1097 let quads = vec![[0.0; 8]];
1098
1099 let first = compute_island_digest(
1100 &[sample_span(0, 0, 3)],
1101 "CAR",
1102 "F1",
1103 &tm,
1104 &ctm,
1105 &paint,
1106 &quads,
1107 );
1108 let second = compute_island_digest(
1109 &[sample_span(5, 0, 3)],
1110 "CAR",
1111 "F1",
1112 &tm,
1113 &ctm,
1114 &paint,
1115 &quads,
1116 );
1117 assert_ne!(first, second);
1118 }
1119
1120 #[test]
1121 fn island_digest_distinguishes_by_matrix() {
1122 let spans = vec![sample_span(0, 0, 3)];
1123 let paint = PaintState {
1124 render_mode: TextRenderMode::Fill,
1125 fill_color: None,
1126 stroke_color: None,
1127 };
1128 let ctm = Matrix::IDENTITY;
1129 let quads = vec![[0.0; 8]];
1130
1131 let a = compute_island_digest(&spans, "CAR", "F1", &Matrix::IDENTITY, &ctm, &paint, &quads);
1132 let b = compute_island_digest(
1133 &spans,
1134 "CAR",
1135 "F1",
1136 &Matrix::translation(12.0, 0.0),
1137 &ctm,
1138 &paint,
1139 &quads,
1140 );
1141 assert_ne!(a, b);
1142 }
1143
1144 #[test]
1145 fn matrix_identity_transform_is_noop() {
1146 let (x, y) = Matrix::IDENTITY.transform((3.0, 4.0));
1147 assert_eq!((x, y), (3.0, 4.0));
1148 }
1149
1150 #[test]
1151 fn matrix_translation_then_composes_in_order() {
1152 let m = Matrix::translation(10.0, 0.0).then(Matrix::translation(0.0, 5.0));
1153 assert_eq!(m.transform((0.0, 0.0)), (10.0, 5.0));
1154 }
1155
1156 #[test]
1157 fn text_state_move_line_updates_origin() {
1158 let mut state = TextState::new();
1159 state.begin_text();
1160 state.move_line(12.0, -20.0);
1161 assert_eq!(state.text_matrix.origin(), (12.0, -20.0));
1162 assert_eq!(state.origin(), (12.0, -20.0));
1163 }
1164
1165 #[test]
1166 fn text_state_next_line_uses_leading() {
1167 let mut state = TextState::new();
1168 state.leading = 14.0;
1169 state.next_line();
1170 assert_eq!(state.text_matrix.origin(), (0.0, -14.0));
1171 }
1172
1173 #[test]
1174 fn pdf_rect_contains_and_intersects() {
1175 let outer = PdfRect {
1176 x: 0.0,
1177 y: 0.0,
1178 width: 100.0,
1179 height: 50.0,
1180 };
1181 let inner = PdfRect {
1182 x: 10.0,
1183 y: 10.0,
1184 width: 20.0,
1185 height: 10.0,
1186 };
1187 let outside = PdfRect {
1188 x: 200.0,
1189 y: 200.0,
1190 width: 10.0,
1191 height: 10.0,
1192 };
1193 assert!(outer.contains(inner));
1194 assert!(!outer.contains(outside));
1195 assert!(outer.intersects(inner));
1196 assert!(!outer.intersects(outside));
1197 }
1198
1199 #[test]
1200 fn text_render_mode_from_operand_covers_spec_range() {
1201 assert_eq!(TextRenderMode::from_operand(0), TextRenderMode::Fill);
1202 assert_eq!(TextRenderMode::from_operand(1), TextRenderMode::Stroke);
1203 assert_eq!(TextRenderMode::from_operand(2), TextRenderMode::FillStroke);
1204 assert_eq!(TextRenderMode::from_operand(7), TextRenderMode::Clip);
1205 assert_eq!(TextRenderMode::from_operand(99), TextRenderMode::Invisible);
1206 }
1207
1208 #[test]
1209 fn pdf_source_locator_round_trips_through_json() {
1210 let locator = PdfSourceLocator {
1211 document_sha256: document_sha256(b"fixture"),
1212 page: 1,
1213 spans: vec![sample_span(0, 0, 3)],
1214 island_digest: sha256_hex(b"island"),
1215 };
1216 let json = serde_json::to_string(&locator).expect("serialize");
1217 assert!(json.contains("\"documentSha256\""));
1218 assert!(json.contains("\"islandDigest\""));
1219 let round_tripped: PdfSourceLocator = serde_json::from_str(&json).expect("deserialize");
1220 assert_eq!(round_tripped.page, locator.page);
1221 assert_eq!(round_tripped.spans.len(), locator.spans.len());
1222 assert_eq!(round_tripped.island_digest, locator.island_digest);
1223 }
1224
1225 fn sample_appearance() -> PdfTextAppearance {
1226 PdfTextAppearance {
1227 font_id: "source-serif-4".to_string(),
1228 family: "Source Serif 4".to_string(),
1229 size_pt: 34.0,
1230 color: PdfColor {
1231 r: 0.0,
1232 g: 0.0,
1233 b: 0.0,
1234 alpha: 1.0,
1235 },
1236 }
1237 }
1238
1239 #[test]
1240 fn pdf_edit_command_replace_source_text_round_trips() {
1241 let command = PdfEditCommand::ReplaceSourceText {
1242 id: "edit-1".to_string(),
1243 locator: PdfSourceLocator {
1244 document_sha256: document_sha256(b"fixture"),
1245 page: 1,
1246 spans: vec![sample_span(0, 0, 3)],
1247 island_digest: sha256_hex(b"island"),
1248 },
1249 before: PdfTextEditState {
1250 text: "CAR YOU LIST.".to_string(),
1251 appearance: sample_appearance(),
1252 },
1253 after: PdfTextEditState {
1254 text: "CAR YOU LIST, YOU WIN.".to_string(),
1255 appearance: sample_appearance(),
1256 },
1257 available_bounds: PdfRect {
1258 x: 0.0,
1259 y: 0.0,
1260 width: 200.0,
1261 height: 40.0,
1262 },
1263 };
1264
1265 let json = serde_json::to_string(&command).expect("serialize");
1266 assert!(json.contains("\"kind\":\"replaceSourceText\""));
1267 assert!(json.contains("\"availableBounds\""));
1268
1269 let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1270 match round_tripped {
1271 PdfEditCommand::ReplaceSourceText { id, after, .. } => {
1272 assert_eq!(id, "edit-1");
1273 assert_eq!(after.text, "CAR YOU LIST, YOU WIN.");
1274 }
1275 PdfEditCommand::AddText { .. } => panic!("expected ReplaceSourceText"),
1276 PdfEditCommand::CorrectOcrLine { .. } => panic!("expected ReplaceSourceText"),
1277 }
1278 }
1279
1280 #[test]
1281 fn pdf_edit_command_add_text_round_trips() {
1282 let command = PdfEditCommand::AddText {
1283 id: "add-1".to_string(),
1284 page: 2,
1285 anchor: PdfPoint { x: 72.0, y: 144.0 },
1286 text: "New line".to_string(),
1287 appearance: sample_appearance(),
1288 };
1289
1290 let json = serde_json::to_string(&command).expect("serialize");
1291 assert!(json.contains("\"kind\":\"addText\""));
1292
1293 let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1294 match round_tripped {
1295 PdfEditCommand::AddText { id, page, text, .. } => {
1296 assert_eq!(id, "add-1");
1297 assert_eq!(page, 2);
1298 assert_eq!(text, "New line");
1299 }
1300 PdfEditCommand::ReplaceSourceText { .. } => panic!("expected AddText"),
1301 PdfEditCommand::CorrectOcrLine { .. } => panic!("expected AddText"),
1302 }
1303 }
1304
1305 #[test]
1306 fn pdf_edit_command_correct_ocr_text_round_trips() {
1307 let command = PdfEditCommand::CorrectOcrLine {
1308 id: "ocr-1".to_string(),
1309 page: 1,
1310 line_id: "line-7".to_string(),
1311 rect: PdfRect {
1312 x: 72.0,
1313 y: 700.0,
1314 width: 120.0,
1315 height: 18.0,
1316 },
1317 text: "Corrected".to_string(),
1318 appearance: sample_appearance(),
1319 cover_color: PdfColor {
1320 r: 1.0,
1321 g: 1.0,
1322 b: 1.0,
1323 alpha: 1.0,
1324 },
1325 background_proof: PdfOcrBackgroundProof::NearUniform,
1326 };
1327 let json = serde_json::to_string(&command).expect("serialize");
1328 assert!(json.contains("\"kind\":\"correctOcrText\""));
1329 assert!(json.contains("\"lineId\":\"line-7\""));
1330 assert!(json.contains("\"backgroundProof\":\"nearUniform\""));
1331 let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1332 assert!(matches!(
1333 round_tripped,
1334 PdfEditCommand::CorrectOcrLine {
1335 background_proof: PdfOcrBackgroundProof::NearUniform,
1336 ..
1337 }
1338 ));
1339 let invalid = json.replace("nearUniform", "gradient");
1340 assert!(serde_json::from_str::<PdfEditCommand>(&invalid).is_err());
1341 }
1342
1343 #[test]
1344 fn effective_commands_last_ocr_correction_wins() {
1345 let appearance = sample_appearance();
1346 let correction = |id: &str, text: &str| PdfEditCommand::CorrectOcrLine {
1347 id: id.to_string(),
1348 page: 1,
1349 line_id: "line-1".to_string(),
1350 rect: PdfRect {
1351 x: 0.0,
1352 y: 0.0,
1353 width: 100.0,
1354 height: 20.0,
1355 },
1356 text: text.to_string(),
1357 appearance: appearance.clone(),
1358 cover_color: PdfColor {
1359 r: 1.0,
1360 g: 1.0,
1361 b: 1.0,
1362 alpha: 1.0,
1363 },
1364 background_proof: PdfOcrBackgroundProof::NearUniform,
1365 };
1366 let commands = vec![correction("old", "old"), correction("new", "new")];
1367 let active = effective_pdf_edit_commands(&commands);
1368 assert_eq!(active.len(), 1);
1369 assert!(matches!(active[0], PdfEditCommand::CorrectOcrLine { text, .. } if text == "new"));
1370 }
1371
1372 #[test]
1373 fn pdf_font_source_round_trips() {
1374 for (source, wire) in [
1375 (PdfFontSource::Bundled, "\"bundled\""),
1376 (PdfFontSource::System, "\"system\""),
1377 ] {
1378 let json = serde_json::to_string(&source).expect("serialize");
1379 assert_eq!(json, wire, "frontend expects a string source discriminator");
1380 let round_tripped: PdfFontSource = serde_json::from_str(&json).expect("deserialize");
1381 assert_eq!(round_tripped, source);
1382 }
1383 }
1384
1385 #[test]
1386 fn pdf_font_catalog_entry_serializes_camel_case() {
1387 let entry = PdfFontCatalogEntry {
1388 id: "source-serif-4".to_string(),
1389 family: "Source Serif 4".to_string(),
1390 postscript_name: Some("SourceSerif4-Regular".to_string()),
1391 styles: vec!["Regular".to_string(), "Bold".to_string()],
1392 embeddable: true,
1393 subsettable: true,
1394 source: PdfFontSource::Bundled,
1395 };
1396 let json = serde_json::to_string(&entry).expect("serialize");
1397 assert!(json.contains("\"postscriptName\""));
1398 assert!(json.contains("\"subsettable\""));
1399 assert_eq!(
1400 serde_json::from_str::<serde_json::Value>(&json).unwrap()["source"],
1401 "bundled"
1402 );
1403 }
1404
1405 #[test]
1406 fn pdf_edit_error_serializes_as_code_message_envelope() {
1407 let json = serde_json::to_string(&PdfEditError::StaleSource).expect("serialize");
1410 assert_eq!(
1411 json,
1412 r#"{"code":"stale_source","message":"PDF changed since edit started"}"#
1413 );
1414 assert_eq!(
1415 PdfEditError::TextOverflow.to_string(),
1416 "text exceeds safe bounds"
1417 );
1418 assert_eq!(PdfEditError::TextOverflow.code(), "text_overflow");
1419 }
1420
1421 #[derive(Debug)]
1423 struct FixedAdvanceFont {
1424 advance: f64,
1425 }
1426
1427 impl TextStateFont for FixedAdvanceFont {
1428 fn id(&self) -> &str {
1429 "fixed"
1430 }
1431
1432 fn decode_show_operand(
1433 &self,
1434 _operand: &lopdf::Object,
1435 ) -> Result<Vec<DecodedGlyph>, PdfEditError> {
1436 Ok(Vec::new())
1437 }
1438
1439 fn advance(&self, _cid: u32, _font_size: f64) -> f64 {
1440 self.advance
1441 }
1442 }
1443
1444 #[test]
1445 fn glyph_quad_without_bbox_uses_advance_fallback() {
1446 let mut state = TextState::new();
1447 state.font = Some(Rc::new(FixedAdvanceFont { advance: 10.0 }));
1448 state.font_size = 12.0;
1449
1450 let glyph = DecodedGlyph {
1451 cid: 1,
1452 unicode: "A".to_string(),
1453 source_range: (0, 1),
1454 bbox: None,
1455 };
1456
1457 let quad = state
1458 .glyph_quad(&glyph, Matrix::IDENTITY)
1459 .expect("font is set");
1460 assert_eq!((quad[0], quad[1]), (0.0, state.font_size * -0.25));
1462 assert_eq!((quad[2], quad[3]), (10.0, state.font_size * -0.25));
1464 }
1465
1466 #[test]
1467 fn glyph_quad_without_font_is_malformed_content() {
1468 let state = TextState::new();
1469 let glyph = DecodedGlyph {
1470 cid: 1,
1471 unicode: "A".to_string(),
1472 source_range: (0, 1),
1473 bbox: None,
1474 };
1475 let err = state
1476 .glyph_quad(&glyph, Matrix::IDENTITY)
1477 .expect_err("no font set");
1478 assert!(matches!(err, PdfEditError::MalformedContent));
1479 }
1480
1481 #[test]
1482 fn source_address_with_glyph_narrows_correctly() {
1483 let address = SourceAddress {
1484 stream_ref: (7, 0),
1485 contents_index: 1,
1486 operator_index: 2,
1487 operand_index: 0,
1488 };
1489 let glyph_address = address.with_glyph((3, 6));
1490 assert_eq!(glyph_address.stream_ref, (7, 0));
1491 assert_eq!(glyph_address.contents_index, 1);
1492 assert_eq!(glyph_address.operator_index, 2);
1493 assert_eq!(glyph_address.glyph_start, 3);
1494 assert_eq!(glyph_address.glyph_end, 6);
1495 }
1496}