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("font license forbids embedding")]
69 FontEmbeddingDenied,
70 #[error("text exceeds safe bounds")]
71 TextOverflow,
72 #[error("PDF content stream is malformed")]
73 MalformedContent,
74 #[error("encrypted PDF cannot be edited")]
75 EncryptedDocument,
76 #[error("annotations could not be serialized")]
77 AnnotationSerializationFailed,
78 #[error("candidate verification failed")]
79 VerificationFailed,
80 #[error("target changed before save")]
81 WriteConflict,
82}
83
84impl PdfEditError {
85 pub fn code(&self) -> &'static str {
88 match self {
89 PdfEditError::StaleSource => "stale_source",
90 PdfEditError::SourceDigestMismatch => "source_digest_mismatch",
91 PdfEditError::NoUniqueMatch => "no_unique_match",
92 PdfEditError::UnsupportedTransform => "unsupported_transform",
93 PdfEditError::UnsupportedEncoding => "unsupported_encoding",
94 PdfEditError::MissingGlyph => "missing_glyph",
95 PdfEditError::FontEmbeddingDenied => "font_embedding_denied",
96 PdfEditError::TextOverflow => "text_overflow",
97 PdfEditError::MalformedContent => "malformed_content",
98 PdfEditError::EncryptedDocument => "encrypted_document",
99 PdfEditError::AnnotationSerializationFailed => "annotation_serialization_failed",
100 PdfEditError::VerificationFailed => "verification_failed",
101 PdfEditError::WriteConflict => "write_conflict",
102 }
103 }
104}
105
106impl Serialize for PdfEditError {
107 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
108 where
109 S: serde::Serializer,
110 {
111 use serde::ser::SerializeStruct;
112 let mut state = serializer.serialize_struct("PdfEditError", 2)?;
113 state.serialize_field("code", self.code())?;
114 state.serialize_field("message", &self.to_string())?;
115 state.end()
116 }
117}
118
119#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
126#[serde(rename_all = "camelCase")]
127pub struct PdfPoint {
128 pub x: f64,
129 pub y: f64,
130}
131
132#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
136#[serde(rename_all = "camelCase")]
137pub struct PdfRect {
138 pub x: f64,
139 pub y: f64,
140 pub width: f64,
141 pub height: f64,
142}
143
144impl PdfRect {
145 pub fn contains(&self, other: PdfRect) -> bool {
147 other.x >= self.x
148 && other.y >= self.y
149 && other.x + other.width <= self.x + self.width
150 && other.y + other.height <= self.y + self.height
151 }
152
153 pub fn intersects(&self, other: PdfRect) -> bool {
155 self.x < other.x + other.width
156 && other.x < self.x + self.width
157 && self.y < other.y + other.height
158 && other.y < self.y + self.height
159 }
160}
161
162#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
164#[serde(rename_all = "camelCase")]
165pub struct PdfColor {
166 pub r: f64,
167 pub g: f64,
168 pub b: f64,
169 pub alpha: f64,
170}
171
172#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
175#[serde(rename_all = "camelCase")]
176pub struct PdfTextAppearance {
177 pub font_id: String,
178 pub family: String,
179 pub size_pt: f64,
180 pub color: PdfColor,
181}
182
183#[derive(Serialize)]
184#[serde(rename_all = "camelCase")]
185#[allow(dead_code)] pub struct PdfDocumentRevision {
187 pub document_sha256: String,
188 pub page_count: u32,
189 pub pages: Vec<PdfPageGraph>,
190}
191
192#[derive(Serialize)]
193#[serde(rename_all = "camelCase")]
194pub struct PdfPageGraph {
195 pub page: u32,
196 pub document_sha256: String,
198 pub media_box: [f64; 4],
199 pub crop_box: [f64; 4],
200 pub rotation: i32,
201 pub islands: Vec<PdfTextIsland>,
202}
203
204#[derive(Serialize)]
205#[serde(rename_all = "camelCase")]
206pub struct PdfTextIsland {
207 pub id: String,
208 pub text: String,
209 pub quads: Vec<[f64; 8]>,
210 pub baseline: [f64; 4],
211 pub appearance: PdfTextAppearance,
212 pub locator: PdfSourceLocator,
213 pub editable: bool,
214 pub rejection: Option<PdfEditError>,
215}
216
217#[derive(Clone, Debug, Serialize, Deserialize)]
218#[serde(rename_all = "camelCase")]
219pub struct PdfSourceLocator {
220 pub document_sha256: String,
221 pub page: u32,
222 pub spans: Vec<PdfSourceSpan>,
223 pub island_digest: String,
224}
225
226#[derive(Clone, Debug, Serialize, Deserialize)]
227#[serde(rename_all = "camelCase")]
228pub struct PdfSourceSpan {
229 pub stream_ref: (u32, u16),
230 pub contents_index: u32,
231 pub operator_index: u32,
232 pub operand_index: u32,
233 pub glyph_start: u32,
234 pub glyph_end: u32,
235 pub expected_text: String,
236 pub expected_digest: String,
237}
238
239#[derive(Clone, Copy, Debug, PartialEq)]
251pub struct Matrix {
252 pub a: f64,
253 pub b: f64,
254 pub c: f64,
255 pub d: f64,
256 pub e: f64,
257 pub f: f64,
258}
259
260impl Matrix {
261 pub const IDENTITY: Matrix = Matrix {
262 a: 1.0,
263 b: 0.0,
264 c: 0.0,
265 d: 1.0,
266 e: 0.0,
267 f: 0.0,
268 };
269
270 pub fn translation(tx: f64, ty: f64) -> Matrix {
271 Matrix {
272 a: 1.0,
273 b: 0.0,
274 c: 0.0,
275 d: 1.0,
276 e: tx,
277 f: ty,
278 }
279 }
280
281 pub fn then(self, other: Matrix) -> Matrix {
283 Matrix {
284 a: self.a * other.a + self.b * other.c,
285 b: self.a * other.b + self.b * other.d,
286 c: self.c * other.a + self.d * other.c,
287 d: self.c * other.b + self.d * other.d,
288 e: self.e * other.a + self.f * other.c + other.e,
289 f: self.e * other.b + self.f * other.d + other.f,
290 }
291 }
292
293 pub fn transform(self, point: (f64, f64)) -> (f64, f64) {
295 (
296 point.0 * self.a + point.1 * self.c + self.e,
297 point.0 * self.b + point.1 * self.d + self.f,
298 )
299 }
300
301 pub fn origin(self) -> (f64, f64) {
303 (self.e, self.f)
304 }
305
306 pub fn translate_text(&mut self, tx: f64, ty: f64) {
308 *self = Matrix::translation(tx, ty).then(*self);
309 }
310}
311
312#[derive(Clone, Debug)]
319pub struct DecodedGlyph {
320 pub cid: u32,
321 pub unicode: String,
322 pub source_range: (u32, u32),
323 pub bbox: Option<[f64; 4]>,
324}
325
326pub trait TextStateFont: fmt::Debug {
331 fn id(&self) -> &str;
334
335 fn decode_show_operand(
337 &self,
338 operand: &lopdf::Object,
339 ) -> Result<Vec<DecodedGlyph>, PdfEditError>;
340
341 fn advance(&self, cid: u32, font_size: f64) -> f64;
344}
345
346#[derive(Clone, Copy, Debug, PartialEq, Eq)]
350pub enum TextRenderMode {
351 Fill,
352 Stroke,
353 FillStroke,
354 Invisible,
355 FillClip,
356 StrokeClip,
357 FillStrokeClip,
358 Clip,
359}
360
361impl TextRenderMode {
362 pub fn from_operand(value: i64) -> TextRenderMode {
365 match value {
366 0 => TextRenderMode::Fill,
367 1 => TextRenderMode::Stroke,
368 2 => TextRenderMode::FillStroke,
369 4 => TextRenderMode::FillClip,
370 5 => TextRenderMode::StrokeClip,
371 6 => TextRenderMode::FillStrokeClip,
372 7 => TextRenderMode::Clip,
373 _ => TextRenderMode::Invisible,
374 }
375 }
376}
377
378#[derive(Clone, Debug, PartialEq)]
381pub struct PaintState {
382 pub render_mode: TextRenderMode,
383 pub fill_color: Option<[f64; 4]>,
384 pub stroke_color: Option<[f64; 4]>,
385}
386
387#[derive(Clone, Copy, Debug, PartialEq, Eq)]
391pub enum WritingDirection {
392 LeftToRight,
393 #[allow(dead_code)]
394 RightToLeft,
396 #[allow(dead_code)]
397 Vertical,
398}
399
400#[derive(Clone, Debug)]
404pub struct SourceAddress {
405 pub stream_ref: (u32, u16),
406 pub contents_index: u32,
407 pub operator_index: u32,
408 pub operand_index: u32,
409}
410
411impl SourceAddress {
412 pub fn with_glyph(&self, glyph_range: (u32, u32)) -> GlyphSourceAddress {
416 GlyphSourceAddress {
417 stream_ref: self.stream_ref,
418 contents_index: self.contents_index,
419 operator_index: self.operator_index,
420 operand_index: self.operand_index,
421 glyph_start: glyph_range.0,
422 glyph_end: glyph_range.1,
423 }
424 }
425}
426
427#[derive(Clone, Copy, Debug)]
431pub struct GlyphSourceAddress {
432 pub stream_ref: (u32, u16),
433 pub contents_index: u32,
434 pub operator_index: u32,
435 pub operand_index: u32,
436 pub glyph_start: u32,
437 pub glyph_end: u32,
438}
439
440#[derive(Clone, Debug)]
444pub struct PositionedGlyph {
445 pub page: u32,
446 pub unicode: String,
447 #[allow(dead_code)] pub cid: u32,
449 pub origin: (f64, f64),
451 pub quad: [f64; 8],
453 pub source: GlyphSourceAddress,
454 pub font_id: String,
455 pub font_size: f64,
456 pub text_matrix: Matrix,
457 pub ctm: Matrix,
458 pub writing_direction: WritingDirection,
459 pub paint: PaintState,
460}
461
462#[derive(Clone, Debug)]
466pub struct TextState {
467 pub ctm: Matrix,
468 pub text_matrix: Matrix,
469 pub text_line_matrix: Matrix,
470 pub font: Option<Rc<dyn TextStateFont>>,
471 pub font_size: f64,
473 pub char_spacing: f64,
475 pub word_spacing: f64,
477 pub horizontal_scale: f64,
479 pub leading: f64,
481 pub text_rise: f64,
483 pub render_mode: TextRenderMode,
484}
485
486impl TextState {
487 pub fn new() -> TextState {
488 TextState {
489 ctm: Matrix::IDENTITY,
490 text_matrix: Matrix::IDENTITY,
491 text_line_matrix: Matrix::IDENTITY,
492 font: None,
493 font_size: 0.0,
494 char_spacing: 0.0,
495 word_spacing: 0.0,
496 horizontal_scale: 1.0,
497 leading: 0.0,
498 text_rise: 0.0,
499 render_mode: TextRenderMode::Fill,
500 }
501 }
502
503 pub fn begin_text(&mut self) {
505 self.text_matrix = Matrix::IDENTITY;
506 self.text_line_matrix = Matrix::IDENTITY;
507 }
508
509 pub fn set_tm(&mut self, m: Matrix) {
511 self.text_matrix = m;
512 self.text_line_matrix = m;
513 }
514
515 pub fn move_line(&mut self, tx: f64, ty: f64) {
517 self.text_line_matrix = Matrix::translation(tx, ty).then(self.text_line_matrix);
518 self.text_matrix = self.text_line_matrix;
519 }
520
521 pub fn next_line(&mut self) {
523 self.move_line(0.0, -self.leading);
524 }
525
526 pub fn origin(&self) -> (f64, f64) {
529 self.ctm.transform(self.text_matrix.origin())
530 }
531
532 pub fn glyph_quad(
538 &self,
539 glyph: &DecodedGlyph,
540 text_matrix: Matrix,
541 ) -> Result<[f64; 8], PdfEditError> {
542 let font = self.font.as_ref().ok_or(PdfEditError::MalformedContent)?;
543 let (x_min, y_min, x_max, y_max) = match glyph.bbox {
544 Some([x0, y0, x1, y1]) => (
545 x0 / 1000.0 * self.font_size,
546 y0 / 1000.0 * self.font_size,
547 x1 / 1000.0 * self.font_size,
548 y1 / 1000.0 * self.font_size,
549 ),
550 None => {
551 let advance = font.advance(glyph.cid, self.font_size);
552 (0.0, self.font_size * -0.25, advance, self.font_size * 0.75)
553 }
554 };
555
556 let corners = [
557 (x_min, y_min),
558 (x_max, y_min),
559 (x_max, y_max),
560 (x_min, y_max),
561 ];
562 let mut quad = [0.0; 8];
563 for (i, (x, y)) in corners.iter().enumerate() {
564 let scaled = (x * self.horizontal_scale, y + self.text_rise);
565 let (tx, ty) = text_matrix.transform(scaled);
566 let (dx, dy) = self.ctm.transform((tx, ty));
567 quad[i * 2] = dx;
568 quad[i * 2 + 1] = dy;
569 }
570 Ok(quad)
571 }
572}
573
574impl Default for TextState {
575 fn default() -> Self {
576 TextState::new()
577 }
578}
579
580#[derive(Clone, Debug)]
586#[allow(dead_code)]
591pub struct PaintedBounds {
592 pub page: u32,
593 pub bounds: PdfRect,
594 pub unrelated: bool,
595}
596
597#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
604#[serde(rename_all = "camelCase")]
605pub struct PdfTextEditState {
606 pub text: String,
607 pub appearance: PdfTextAppearance,
608}
609
610#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
613#[serde(rename_all = "camelCase")]
614pub enum PdfOcrBackgroundProof {
615 NearUniform,
616}
617
618#[derive(Clone, Debug, Serialize, Deserialize)]
625#[serde(
626 tag = "kind",
627 rename_all = "camelCase",
628 rename_all_fields = "camelCase"
629)]
630#[allow(clippy::large_enum_variant)]
634pub enum PdfEditCommand {
635 ReplaceSourceText {
636 id: String,
637 locator: PdfSourceLocator,
638 before: PdfTextEditState,
639 after: PdfTextEditState,
640 available_bounds: PdfRect,
641 },
642 AddText {
643 id: String,
644 page: u32,
645 anchor: PdfPoint,
646 text: String,
647 appearance: PdfTextAppearance,
648 },
649 #[serde(rename = "correctOcrText")]
650 CorrectOcrLine {
651 id: String,
652 page: u32,
653 line_id: String,
654 rect: PdfRect,
655 text: String,
656 appearance: PdfTextAppearance,
657 cover_color: PdfColor,
658 background_proof: PdfOcrBackgroundProof,
659 },
660}
661
662pub(crate) fn effective_pdf_edit_commands(commands: &[PdfEditCommand]) -> Vec<&PdfEditCommand> {
665 use std::collections::HashMap;
666
667 let mut last_correction: HashMap<&str, usize> = HashMap::new();
668 for (index, command) in commands.iter().enumerate() {
669 if let PdfEditCommand::CorrectOcrLine { line_id, .. } = command {
670 last_correction.insert(line_id.as_str(), index);
671 }
672 }
673
674 commands
675 .iter()
676 .enumerate()
677 .filter_map(|(index, command)| match command {
678 PdfEditCommand::CorrectOcrLine { line_id, .. }
679 if last_correction.get(line_id.as_str()) != Some(&index) =>
680 {
681 None
682 }
683 _ => Some(command),
684 })
685 .collect()
686}
687
688#[derive(Clone, Debug)]
694pub struct ComposedCandidate {
695 pub bytes: Vec<u8>,
696 pub affected_pages: Vec<u32>,
697}
698
699#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
706#[serde(rename_all = "camelCase")]
707pub enum PdfFontSource {
708 Bundled,
709 System,
710}
711
712#[derive(Clone, Debug, Serialize)]
713#[serde(rename_all = "camelCase")]
714pub struct PdfFontCatalogEntry {
715 pub id: String,
716 pub family: String,
717 pub postscript_name: Option<String>,
718 pub styles: Vec<String>,
719 pub embeddable: bool,
720 pub subsettable: bool,
721 pub source: PdfFontSource,
722}
723
724#[derive(Clone, Debug)]
728pub struct ResolvedPdfFont {
729 #[allow(dead_code)] pub id: String,
731 pub family: String,
732 pub bytes: Vec<u8>,
734 pub face_index: u32,
736 #[allow(dead_code)] pub embeddable: bool,
738 #[allow(dead_code)]
739 pub subsettable: bool,
740}
741
742#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
746pub struct GlyphId(pub u16);
747
748pub trait PdfFontResolver {
753 fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError>;
754 fn glyphs_for_text(
755 &self,
756 font: &ResolvedPdfFont,
757 text: &str,
758 ) -> Result<Vec<GlyphId>, PdfEditError>;
759}
760
761pub fn sha256_hex(bytes: &[u8]) -> String {
768 let digest: [u8; 32] = Sha256::digest(bytes).into();
769 let mut out = String::with_capacity(digest.len() * 2);
770 for byte in digest {
771 out.push_str(&format!("{byte:02x}"));
772 }
773 out
774}
775
776pub fn document_sha256(source_bytes: &[u8]) -> String {
780 sha256_hex(source_bytes)
781}
782
783pub fn expected_text_digest(expected_text: &str) -> String {
788 sha256_hex(expected_text.as_bytes())
789}
790
791#[allow(clippy::too_many_arguments)]
798pub fn compute_island_digest(
799 spans: &[PdfSourceSpan],
800 decoded_text: &str,
801 font_id: &str,
802 text_matrix: &Matrix,
803 ctm: &Matrix,
804 paint: &PaintState,
805 quads: &[[f64; 8]],
806) -> String {
807 let mut buf = String::new();
808 for span in spans {
809 buf.push_str(&format!(
810 "span:{}.{}/{}/{}/{}-{}|",
811 span.stream_ref.0,
812 span.stream_ref.1,
813 span.contents_index,
814 span.operator_index,
815 span.operand_index,
816 span.glyph_start
817 ));
818 buf.push_str(&format!("{}\n", span.glyph_end));
819 }
820 buf.push_str("text:");
821 buf.push_str(decoded_text);
822 buf.push_str("\nfont:");
823 buf.push_str(font_id);
824 buf.push_str(&format!(
825 "\ntm:{},{},{},{},{},{}",
826 text_matrix.a, text_matrix.b, text_matrix.c, text_matrix.d, text_matrix.e, text_matrix.f
827 ));
828 buf.push_str(&format!(
829 "\nctm:{},{},{},{},{},{}",
830 ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f
831 ));
832 buf.push_str(&format!(
833 "\npaint:{:?}/{:?}/{:?}",
834 paint.render_mode, paint.fill_color, paint.stroke_color
835 ));
836 for quad in quads {
837 buf.push_str("\nquad:");
838 for value in quad {
839 buf.push_str(&format!("{value},"));
840 }
841 }
842 sha256_hex(buf.as_bytes())
843}
844
845#[cfg(test)]
850mod tests {
851 use super::*;
852
853 #[test]
854 fn sha256_hex_of_empty_input_is_64_hex_chars_and_stable() {
855 let a = sha256_hex(b"");
858 let b = sha256_hex(b"");
859 assert_eq!(a, b);
860 assert_eq!(a.len(), 64);
861 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
862 }
863
864 #[test]
865 fn sha256_hex_is_deterministic_and_64_hex_chars() {
866 let a = sha256_hex(b"viewright pdf editor");
867 let b = sha256_hex(b"viewright pdf editor");
868 assert_eq!(a, b);
869 assert_eq!(a.len(), 64);
870 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
871 }
872
873 #[test]
874 fn sha256_hex_differs_for_different_input() {
875 assert_ne!(
876 sha256_hex(b"CAR YOU LIST."),
877 sha256_hex(b"CAR YOU LIST, YOU WIN.")
878 );
879 }
880
881 #[test]
882 fn document_sha256_matches_sha256_hex() {
883 let bytes = b"%PDF-1.7 fixture bytes";
884 assert_eq!(document_sha256(bytes), sha256_hex(bytes));
885 }
886
887 #[test]
888 fn expected_text_digest_is_stable_and_distinct() {
889 let a = expected_text_digest("CAR YOU LIST.");
890 let b = expected_text_digest("CAR YOU LIST.");
891 let c = expected_text_digest("CAR YOU LIST, YOU WIN.");
892 assert_eq!(a, b);
893 assert_ne!(a, c);
894 }
895
896 fn sample_span(operator_index: u32, glyph_start: u32, glyph_end: u32) -> PdfSourceSpan {
897 PdfSourceSpan {
898 stream_ref: (7, 0),
899 contents_index: 0,
900 operator_index,
901 operand_index: 0,
902 glyph_start,
903 glyph_end,
904 expected_text: "CAR".to_string(),
905 expected_digest: expected_text_digest("CAR"),
906 }
907 }
908
909 #[test]
910 fn island_digest_is_stable_for_identical_inputs() {
911 let spans = vec![sample_span(0, 0, 3)];
912 let paint = PaintState {
913 render_mode: TextRenderMode::Fill,
914 fill_color: Some([0.0, 0.0, 0.0, 1.0]),
915 stroke_color: None,
916 };
917 let tm = Matrix::IDENTITY;
918 let ctm = Matrix::IDENTITY;
919 let quads = vec![[0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0]];
920
921 let a = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
922 let b = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
923 assert_eq!(a, b);
924 assert_eq!(a.len(), 64);
925 }
926
927 #[test]
928 fn island_digest_distinguishes_repeated_text_by_span_position() {
929 let paint = PaintState {
932 render_mode: TextRenderMode::Fill,
933 fill_color: None,
934 stroke_color: None,
935 };
936 let tm = Matrix::IDENTITY;
937 let ctm = Matrix::IDENTITY;
938 let quads = vec![[0.0; 8]];
939
940 let first = compute_island_digest(
941 &[sample_span(0, 0, 3)],
942 "CAR",
943 "F1",
944 &tm,
945 &ctm,
946 &paint,
947 &quads,
948 );
949 let second = compute_island_digest(
950 &[sample_span(5, 0, 3)],
951 "CAR",
952 "F1",
953 &tm,
954 &ctm,
955 &paint,
956 &quads,
957 );
958 assert_ne!(first, second);
959 }
960
961 #[test]
962 fn island_digest_distinguishes_by_matrix() {
963 let spans = vec![sample_span(0, 0, 3)];
964 let paint = PaintState {
965 render_mode: TextRenderMode::Fill,
966 fill_color: None,
967 stroke_color: None,
968 };
969 let ctm = Matrix::IDENTITY;
970 let quads = vec![[0.0; 8]];
971
972 let a = compute_island_digest(&spans, "CAR", "F1", &Matrix::IDENTITY, &ctm, &paint, &quads);
973 let b = compute_island_digest(
974 &spans,
975 "CAR",
976 "F1",
977 &Matrix::translation(12.0, 0.0),
978 &ctm,
979 &paint,
980 &quads,
981 );
982 assert_ne!(a, b);
983 }
984
985 #[test]
986 fn matrix_identity_transform_is_noop() {
987 let (x, y) = Matrix::IDENTITY.transform((3.0, 4.0));
988 assert_eq!((x, y), (3.0, 4.0));
989 }
990
991 #[test]
992 fn matrix_translation_then_composes_in_order() {
993 let m = Matrix::translation(10.0, 0.0).then(Matrix::translation(0.0, 5.0));
994 assert_eq!(m.transform((0.0, 0.0)), (10.0, 5.0));
995 }
996
997 #[test]
998 fn text_state_move_line_updates_origin() {
999 let mut state = TextState::new();
1000 state.begin_text();
1001 state.move_line(12.0, -20.0);
1002 assert_eq!(state.text_matrix.origin(), (12.0, -20.0));
1003 assert_eq!(state.origin(), (12.0, -20.0));
1004 }
1005
1006 #[test]
1007 fn text_state_next_line_uses_leading() {
1008 let mut state = TextState::new();
1009 state.leading = 14.0;
1010 state.next_line();
1011 assert_eq!(state.text_matrix.origin(), (0.0, -14.0));
1012 }
1013
1014 #[test]
1015 fn pdf_rect_contains_and_intersects() {
1016 let outer = PdfRect {
1017 x: 0.0,
1018 y: 0.0,
1019 width: 100.0,
1020 height: 50.0,
1021 };
1022 let inner = PdfRect {
1023 x: 10.0,
1024 y: 10.0,
1025 width: 20.0,
1026 height: 10.0,
1027 };
1028 let outside = PdfRect {
1029 x: 200.0,
1030 y: 200.0,
1031 width: 10.0,
1032 height: 10.0,
1033 };
1034 assert!(outer.contains(inner));
1035 assert!(!outer.contains(outside));
1036 assert!(outer.intersects(inner));
1037 assert!(!outer.intersects(outside));
1038 }
1039
1040 #[test]
1041 fn text_render_mode_from_operand_covers_spec_range() {
1042 assert_eq!(TextRenderMode::from_operand(0), TextRenderMode::Fill);
1043 assert_eq!(TextRenderMode::from_operand(1), TextRenderMode::Stroke);
1044 assert_eq!(TextRenderMode::from_operand(2), TextRenderMode::FillStroke);
1045 assert_eq!(TextRenderMode::from_operand(7), TextRenderMode::Clip);
1046 assert_eq!(TextRenderMode::from_operand(99), TextRenderMode::Invisible);
1047 }
1048
1049 #[test]
1050 fn pdf_source_locator_round_trips_through_json() {
1051 let locator = PdfSourceLocator {
1052 document_sha256: document_sha256(b"fixture"),
1053 page: 1,
1054 spans: vec![sample_span(0, 0, 3)],
1055 island_digest: sha256_hex(b"island"),
1056 };
1057 let json = serde_json::to_string(&locator).expect("serialize");
1058 assert!(json.contains("\"documentSha256\""));
1059 assert!(json.contains("\"islandDigest\""));
1060 let round_tripped: PdfSourceLocator = serde_json::from_str(&json).expect("deserialize");
1061 assert_eq!(round_tripped.page, locator.page);
1062 assert_eq!(round_tripped.spans.len(), locator.spans.len());
1063 assert_eq!(round_tripped.island_digest, locator.island_digest);
1064 }
1065
1066 fn sample_appearance() -> PdfTextAppearance {
1067 PdfTextAppearance {
1068 font_id: "source-serif-4".to_string(),
1069 family: "Source Serif 4".to_string(),
1070 size_pt: 34.0,
1071 color: PdfColor {
1072 r: 0.0,
1073 g: 0.0,
1074 b: 0.0,
1075 alpha: 1.0,
1076 },
1077 }
1078 }
1079
1080 #[test]
1081 fn pdf_edit_command_replace_source_text_round_trips() {
1082 let command = PdfEditCommand::ReplaceSourceText {
1083 id: "edit-1".to_string(),
1084 locator: PdfSourceLocator {
1085 document_sha256: document_sha256(b"fixture"),
1086 page: 1,
1087 spans: vec![sample_span(0, 0, 3)],
1088 island_digest: sha256_hex(b"island"),
1089 },
1090 before: PdfTextEditState {
1091 text: "CAR YOU LIST.".to_string(),
1092 appearance: sample_appearance(),
1093 },
1094 after: PdfTextEditState {
1095 text: "CAR YOU LIST, YOU WIN.".to_string(),
1096 appearance: sample_appearance(),
1097 },
1098 available_bounds: PdfRect {
1099 x: 0.0,
1100 y: 0.0,
1101 width: 200.0,
1102 height: 40.0,
1103 },
1104 };
1105
1106 let json = serde_json::to_string(&command).expect("serialize");
1107 assert!(json.contains("\"kind\":\"replaceSourceText\""));
1108 assert!(json.contains("\"availableBounds\""));
1109
1110 let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1111 match round_tripped {
1112 PdfEditCommand::ReplaceSourceText { id, after, .. } => {
1113 assert_eq!(id, "edit-1");
1114 assert_eq!(after.text, "CAR YOU LIST, YOU WIN.");
1115 }
1116 PdfEditCommand::AddText { .. } => panic!("expected ReplaceSourceText"),
1117 PdfEditCommand::CorrectOcrLine { .. } => panic!("expected ReplaceSourceText"),
1118 }
1119 }
1120
1121 #[test]
1122 fn pdf_edit_command_add_text_round_trips() {
1123 let command = PdfEditCommand::AddText {
1124 id: "add-1".to_string(),
1125 page: 2,
1126 anchor: PdfPoint { x: 72.0, y: 144.0 },
1127 text: "New line".to_string(),
1128 appearance: sample_appearance(),
1129 };
1130
1131 let json = serde_json::to_string(&command).expect("serialize");
1132 assert!(json.contains("\"kind\":\"addText\""));
1133
1134 let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1135 match round_tripped {
1136 PdfEditCommand::AddText { id, page, text, .. } => {
1137 assert_eq!(id, "add-1");
1138 assert_eq!(page, 2);
1139 assert_eq!(text, "New line");
1140 }
1141 PdfEditCommand::ReplaceSourceText { .. } => panic!("expected AddText"),
1142 PdfEditCommand::CorrectOcrLine { .. } => panic!("expected AddText"),
1143 }
1144 }
1145
1146 #[test]
1147 fn pdf_edit_command_correct_ocr_text_round_trips() {
1148 let command = PdfEditCommand::CorrectOcrLine {
1149 id: "ocr-1".to_string(),
1150 page: 1,
1151 line_id: "line-7".to_string(),
1152 rect: PdfRect {
1153 x: 72.0,
1154 y: 700.0,
1155 width: 120.0,
1156 height: 18.0,
1157 },
1158 text: "Corrected".to_string(),
1159 appearance: sample_appearance(),
1160 cover_color: PdfColor {
1161 r: 1.0,
1162 g: 1.0,
1163 b: 1.0,
1164 alpha: 1.0,
1165 },
1166 background_proof: PdfOcrBackgroundProof::NearUniform,
1167 };
1168 let json = serde_json::to_string(&command).expect("serialize");
1169 assert!(json.contains("\"kind\":\"correctOcrText\""));
1170 assert!(json.contains("\"lineId\":\"line-7\""));
1171 assert!(json.contains("\"backgroundProof\":\"nearUniform\""));
1172 let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1173 assert!(matches!(
1174 round_tripped,
1175 PdfEditCommand::CorrectOcrLine {
1176 background_proof: PdfOcrBackgroundProof::NearUniform,
1177 ..
1178 }
1179 ));
1180 let invalid = json.replace("nearUniform", "gradient");
1181 assert!(serde_json::from_str::<PdfEditCommand>(&invalid).is_err());
1182 }
1183
1184 #[test]
1185 fn effective_commands_last_ocr_correction_wins() {
1186 let appearance = sample_appearance();
1187 let correction = |id: &str, text: &str| PdfEditCommand::CorrectOcrLine {
1188 id: id.to_string(),
1189 page: 1,
1190 line_id: "line-1".to_string(),
1191 rect: PdfRect {
1192 x: 0.0,
1193 y: 0.0,
1194 width: 100.0,
1195 height: 20.0,
1196 },
1197 text: text.to_string(),
1198 appearance: appearance.clone(),
1199 cover_color: PdfColor {
1200 r: 1.0,
1201 g: 1.0,
1202 b: 1.0,
1203 alpha: 1.0,
1204 },
1205 background_proof: PdfOcrBackgroundProof::NearUniform,
1206 };
1207 let commands = vec![correction("old", "old"), correction("new", "new")];
1208 let active = effective_pdf_edit_commands(&commands);
1209 assert_eq!(active.len(), 1);
1210 assert!(matches!(active[0], PdfEditCommand::CorrectOcrLine { text, .. } if text == "new"));
1211 }
1212
1213 #[test]
1214 fn pdf_font_source_round_trips() {
1215 for (source, wire) in [
1216 (PdfFontSource::Bundled, "\"bundled\""),
1217 (PdfFontSource::System, "\"system\""),
1218 ] {
1219 let json = serde_json::to_string(&source).expect("serialize");
1220 assert_eq!(json, wire, "frontend expects a string source discriminator");
1221 let round_tripped: PdfFontSource = serde_json::from_str(&json).expect("deserialize");
1222 assert_eq!(round_tripped, source);
1223 }
1224 }
1225
1226 #[test]
1227 fn pdf_font_catalog_entry_serializes_camel_case() {
1228 let entry = PdfFontCatalogEntry {
1229 id: "source-serif-4".to_string(),
1230 family: "Source Serif 4".to_string(),
1231 postscript_name: Some("SourceSerif4-Regular".to_string()),
1232 styles: vec!["Regular".to_string(), "Bold".to_string()],
1233 embeddable: true,
1234 subsettable: true,
1235 source: PdfFontSource::Bundled,
1236 };
1237 let json = serde_json::to_string(&entry).expect("serialize");
1238 assert!(json.contains("\"postscriptName\""));
1239 assert!(json.contains("\"subsettable\""));
1240 assert_eq!(
1241 serde_json::from_str::<serde_json::Value>(&json).unwrap()["source"],
1242 "bundled"
1243 );
1244 }
1245
1246 #[test]
1247 fn pdf_edit_error_serializes_as_code_message_envelope() {
1248 let json = serde_json::to_string(&PdfEditError::StaleSource).expect("serialize");
1251 assert_eq!(
1252 json,
1253 r#"{"code":"stale_source","message":"PDF changed since edit started"}"#
1254 );
1255 assert_eq!(
1256 PdfEditError::TextOverflow.to_string(),
1257 "text exceeds safe bounds"
1258 );
1259 assert_eq!(PdfEditError::TextOverflow.code(), "text_overflow");
1260 }
1261
1262 #[derive(Debug)]
1264 struct FixedAdvanceFont {
1265 advance: f64,
1266 }
1267
1268 impl TextStateFont for FixedAdvanceFont {
1269 fn id(&self) -> &str {
1270 "fixed"
1271 }
1272
1273 fn decode_show_operand(
1274 &self,
1275 _operand: &lopdf::Object,
1276 ) -> Result<Vec<DecodedGlyph>, PdfEditError> {
1277 Ok(Vec::new())
1278 }
1279
1280 fn advance(&self, _cid: u32, _font_size: f64) -> f64 {
1281 self.advance
1282 }
1283 }
1284
1285 #[test]
1286 fn glyph_quad_without_bbox_uses_advance_fallback() {
1287 let mut state = TextState::new();
1288 state.font = Some(Rc::new(FixedAdvanceFont { advance: 10.0 }));
1289 state.font_size = 12.0;
1290
1291 let glyph = DecodedGlyph {
1292 cid: 1,
1293 unicode: "A".to_string(),
1294 source_range: (0, 1),
1295 bbox: None,
1296 };
1297
1298 let quad = state
1299 .glyph_quad(&glyph, Matrix::IDENTITY)
1300 .expect("font is set");
1301 assert_eq!((quad[0], quad[1]), (0.0, state.font_size * -0.25));
1303 assert_eq!((quad[2], quad[3]), (10.0, state.font_size * -0.25));
1305 }
1306
1307 #[test]
1308 fn glyph_quad_without_font_is_malformed_content() {
1309 let state = TextState::new();
1310 let glyph = DecodedGlyph {
1311 cid: 1,
1312 unicode: "A".to_string(),
1313 source_range: (0, 1),
1314 bbox: None,
1315 };
1316 let err = state
1317 .glyph_quad(&glyph, Matrix::IDENTITY)
1318 .expect_err("no font set");
1319 assert!(matches!(err, PdfEditError::MalformedContent));
1320 }
1321
1322 #[test]
1323 fn source_address_with_glyph_narrows_correctly() {
1324 let address = SourceAddress {
1325 stream_ref: (7, 0),
1326 contents_index: 1,
1327 operator_index: 2,
1328 operand_index: 0,
1329 };
1330 let glyph_address = address.with_glyph((3, 6));
1331 assert_eq!(glyph_address.stream_ref, (7, 0));
1332 assert_eq!(glyph_address.contents_index, 1);
1333 assert_eq!(glyph_address.operator_index, 2);
1334 assert_eq!(glyph_address.glyph_start, 3);
1335 assert_eq!(glyph_address.glyph_end, 6);
1336 }
1337}