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
662#[cfg(any(feature = "edit", test))]
665pub(crate) fn effective_pdf_edit_commands(commands: &[PdfEditCommand]) -> Vec<&PdfEditCommand> {
666 use std::collections::HashMap;
667
668 let mut last_correction: HashMap<&str, usize> = HashMap::new();
669 for (index, command) in commands.iter().enumerate() {
670 if let PdfEditCommand::CorrectOcrLine { line_id, .. } = command {
671 last_correction.insert(line_id.as_str(), index);
672 }
673 }
674
675 commands
676 .iter()
677 .enumerate()
678 .filter_map(|(index, command)| match command {
679 PdfEditCommand::CorrectOcrLine { line_id, .. }
680 if last_correction.get(line_id.as_str()) != Some(&index) =>
681 {
682 None
683 }
684 _ => Some(command),
685 })
686 .collect()
687}
688
689#[derive(Clone, Debug)]
695pub struct ComposedCandidate {
696 pub bytes: Vec<u8>,
697 pub affected_pages: Vec<u32>,
698}
699
700#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
707#[serde(rename_all = "camelCase")]
708pub enum PdfFontSource {
709 Bundled,
710 System,
711}
712
713#[derive(Clone, Debug, Serialize)]
714#[serde(rename_all = "camelCase")]
715pub struct PdfFontCatalogEntry {
716 pub id: String,
717 pub family: String,
718 pub postscript_name: Option<String>,
719 pub styles: Vec<String>,
720 pub embeddable: bool,
721 pub subsettable: bool,
722 pub source: PdfFontSource,
723}
724
725#[derive(Clone, Debug)]
729pub struct ResolvedPdfFont {
730 #[allow(dead_code)] pub id: String,
732 pub family: String,
733 pub bytes: Vec<u8>,
735 pub face_index: u32,
737 #[allow(dead_code)] pub embeddable: bool,
739 #[allow(dead_code)]
740 pub subsettable: bool,
741}
742
743#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
747pub struct GlyphId(pub u16);
748
749pub trait PdfFontResolver {
754 fn resolve(&self, id: &str) -> Result<ResolvedPdfFont, PdfEditError>;
755 fn glyphs_for_text(
756 &self,
757 font: &ResolvedPdfFont,
758 text: &str,
759 ) -> Result<Vec<GlyphId>, PdfEditError>;
760}
761
762pub fn sha256_hex(bytes: &[u8]) -> String {
769 let digest: [u8; 32] = Sha256::digest(bytes).into();
770 let mut out = String::with_capacity(digest.len() * 2);
771 for byte in digest {
772 out.push_str(&format!("{byte:02x}"));
773 }
774 out
775}
776
777pub fn document_sha256(source_bytes: &[u8]) -> String {
781 sha256_hex(source_bytes)
782}
783
784pub fn expected_text_digest(expected_text: &str) -> String {
789 sha256_hex(expected_text.as_bytes())
790}
791
792#[allow(clippy::too_many_arguments)]
799pub fn compute_island_digest(
800 spans: &[PdfSourceSpan],
801 decoded_text: &str,
802 font_id: &str,
803 text_matrix: &Matrix,
804 ctm: &Matrix,
805 paint: &PaintState,
806 quads: &[[f64; 8]],
807) -> String {
808 let mut buf = String::new();
809 for span in spans {
810 buf.push_str(&format!(
811 "span:{}.{}/{}/{}/{}-{}|",
812 span.stream_ref.0,
813 span.stream_ref.1,
814 span.contents_index,
815 span.operator_index,
816 span.operand_index,
817 span.glyph_start
818 ));
819 buf.push_str(&format!("{}\n", span.glyph_end));
820 }
821 buf.push_str("text:");
822 buf.push_str(decoded_text);
823 buf.push_str("\nfont:");
824 buf.push_str(font_id);
825 buf.push_str(&format!(
826 "\ntm:{},{},{},{},{},{}",
827 text_matrix.a, text_matrix.b, text_matrix.c, text_matrix.d, text_matrix.e, text_matrix.f
828 ));
829 buf.push_str(&format!(
830 "\nctm:{},{},{},{},{},{}",
831 ctm.a, ctm.b, ctm.c, ctm.d, ctm.e, ctm.f
832 ));
833 buf.push_str(&format!(
834 "\npaint:{:?}/{:?}/{:?}",
835 paint.render_mode, paint.fill_color, paint.stroke_color
836 ));
837 for quad in quads {
838 buf.push_str("\nquad:");
839 for value in quad {
840 buf.push_str(&format!("{value},"));
841 }
842 }
843 sha256_hex(buf.as_bytes())
844}
845
846#[cfg(test)]
851mod tests {
852 use super::*;
853
854 #[test]
855 fn sha256_hex_of_empty_input_is_64_hex_chars_and_stable() {
856 let a = sha256_hex(b"");
859 let b = sha256_hex(b"");
860 assert_eq!(a, b);
861 assert_eq!(a.len(), 64);
862 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
863 }
864
865 #[test]
866 fn sha256_hex_is_deterministic_and_64_hex_chars() {
867 let a = sha256_hex(b"viewright pdf editor");
868 let b = sha256_hex(b"viewright pdf editor");
869 assert_eq!(a, b);
870 assert_eq!(a.len(), 64);
871 assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
872 }
873
874 #[test]
875 fn sha256_hex_differs_for_different_input() {
876 assert_ne!(
877 sha256_hex(b"CAR YOU LIST."),
878 sha256_hex(b"CAR YOU LIST, YOU WIN.")
879 );
880 }
881
882 #[test]
883 fn document_sha256_matches_sha256_hex() {
884 let bytes = b"%PDF-1.7 fixture bytes";
885 assert_eq!(document_sha256(bytes), sha256_hex(bytes));
886 }
887
888 #[test]
889 fn expected_text_digest_is_stable_and_distinct() {
890 let a = expected_text_digest("CAR YOU LIST.");
891 let b = expected_text_digest("CAR YOU LIST.");
892 let c = expected_text_digest("CAR YOU LIST, YOU WIN.");
893 assert_eq!(a, b);
894 assert_ne!(a, c);
895 }
896
897 fn sample_span(operator_index: u32, glyph_start: u32, glyph_end: u32) -> PdfSourceSpan {
898 PdfSourceSpan {
899 stream_ref: (7, 0),
900 contents_index: 0,
901 operator_index,
902 operand_index: 0,
903 glyph_start,
904 glyph_end,
905 expected_text: "CAR".to_string(),
906 expected_digest: expected_text_digest("CAR"),
907 }
908 }
909
910 #[test]
911 fn island_digest_is_stable_for_identical_inputs() {
912 let spans = vec![sample_span(0, 0, 3)];
913 let paint = PaintState {
914 render_mode: TextRenderMode::Fill,
915 fill_color: Some([0.0, 0.0, 0.0, 1.0]),
916 stroke_color: None,
917 };
918 let tm = Matrix::IDENTITY;
919 let ctm = Matrix::IDENTITY;
920 let quads = vec![[0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0]];
921
922 let a = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
923 let b = compute_island_digest(&spans, "CAR", "F1", &tm, &ctm, &paint, &quads);
924 assert_eq!(a, b);
925 assert_eq!(a.len(), 64);
926 }
927
928 #[test]
929 fn island_digest_distinguishes_repeated_text_by_span_position() {
930 let paint = PaintState {
933 render_mode: TextRenderMode::Fill,
934 fill_color: None,
935 stroke_color: None,
936 };
937 let tm = Matrix::IDENTITY;
938 let ctm = Matrix::IDENTITY;
939 let quads = vec![[0.0; 8]];
940
941 let first = compute_island_digest(
942 &[sample_span(0, 0, 3)],
943 "CAR",
944 "F1",
945 &tm,
946 &ctm,
947 &paint,
948 &quads,
949 );
950 let second = compute_island_digest(
951 &[sample_span(5, 0, 3)],
952 "CAR",
953 "F1",
954 &tm,
955 &ctm,
956 &paint,
957 &quads,
958 );
959 assert_ne!(first, second);
960 }
961
962 #[test]
963 fn island_digest_distinguishes_by_matrix() {
964 let spans = vec![sample_span(0, 0, 3)];
965 let paint = PaintState {
966 render_mode: TextRenderMode::Fill,
967 fill_color: None,
968 stroke_color: None,
969 };
970 let ctm = Matrix::IDENTITY;
971 let quads = vec![[0.0; 8]];
972
973 let a = compute_island_digest(&spans, "CAR", "F1", &Matrix::IDENTITY, &ctm, &paint, &quads);
974 let b = compute_island_digest(
975 &spans,
976 "CAR",
977 "F1",
978 &Matrix::translation(12.0, 0.0),
979 &ctm,
980 &paint,
981 &quads,
982 );
983 assert_ne!(a, b);
984 }
985
986 #[test]
987 fn matrix_identity_transform_is_noop() {
988 let (x, y) = Matrix::IDENTITY.transform((3.0, 4.0));
989 assert_eq!((x, y), (3.0, 4.0));
990 }
991
992 #[test]
993 fn matrix_translation_then_composes_in_order() {
994 let m = Matrix::translation(10.0, 0.0).then(Matrix::translation(0.0, 5.0));
995 assert_eq!(m.transform((0.0, 0.0)), (10.0, 5.0));
996 }
997
998 #[test]
999 fn text_state_move_line_updates_origin() {
1000 let mut state = TextState::new();
1001 state.begin_text();
1002 state.move_line(12.0, -20.0);
1003 assert_eq!(state.text_matrix.origin(), (12.0, -20.0));
1004 assert_eq!(state.origin(), (12.0, -20.0));
1005 }
1006
1007 #[test]
1008 fn text_state_next_line_uses_leading() {
1009 let mut state = TextState::new();
1010 state.leading = 14.0;
1011 state.next_line();
1012 assert_eq!(state.text_matrix.origin(), (0.0, -14.0));
1013 }
1014
1015 #[test]
1016 fn pdf_rect_contains_and_intersects() {
1017 let outer = PdfRect {
1018 x: 0.0,
1019 y: 0.0,
1020 width: 100.0,
1021 height: 50.0,
1022 };
1023 let inner = PdfRect {
1024 x: 10.0,
1025 y: 10.0,
1026 width: 20.0,
1027 height: 10.0,
1028 };
1029 let outside = PdfRect {
1030 x: 200.0,
1031 y: 200.0,
1032 width: 10.0,
1033 height: 10.0,
1034 };
1035 assert!(outer.contains(inner));
1036 assert!(!outer.contains(outside));
1037 assert!(outer.intersects(inner));
1038 assert!(!outer.intersects(outside));
1039 }
1040
1041 #[test]
1042 fn text_render_mode_from_operand_covers_spec_range() {
1043 assert_eq!(TextRenderMode::from_operand(0), TextRenderMode::Fill);
1044 assert_eq!(TextRenderMode::from_operand(1), TextRenderMode::Stroke);
1045 assert_eq!(TextRenderMode::from_operand(2), TextRenderMode::FillStroke);
1046 assert_eq!(TextRenderMode::from_operand(7), TextRenderMode::Clip);
1047 assert_eq!(TextRenderMode::from_operand(99), TextRenderMode::Invisible);
1048 }
1049
1050 #[test]
1051 fn pdf_source_locator_round_trips_through_json() {
1052 let locator = PdfSourceLocator {
1053 document_sha256: document_sha256(b"fixture"),
1054 page: 1,
1055 spans: vec![sample_span(0, 0, 3)],
1056 island_digest: sha256_hex(b"island"),
1057 };
1058 let json = serde_json::to_string(&locator).expect("serialize");
1059 assert!(json.contains("\"documentSha256\""));
1060 assert!(json.contains("\"islandDigest\""));
1061 let round_tripped: PdfSourceLocator = serde_json::from_str(&json).expect("deserialize");
1062 assert_eq!(round_tripped.page, locator.page);
1063 assert_eq!(round_tripped.spans.len(), locator.spans.len());
1064 assert_eq!(round_tripped.island_digest, locator.island_digest);
1065 }
1066
1067 fn sample_appearance() -> PdfTextAppearance {
1068 PdfTextAppearance {
1069 font_id: "source-serif-4".to_string(),
1070 family: "Source Serif 4".to_string(),
1071 size_pt: 34.0,
1072 color: PdfColor {
1073 r: 0.0,
1074 g: 0.0,
1075 b: 0.0,
1076 alpha: 1.0,
1077 },
1078 }
1079 }
1080
1081 #[test]
1082 fn pdf_edit_command_replace_source_text_round_trips() {
1083 let command = PdfEditCommand::ReplaceSourceText {
1084 id: "edit-1".to_string(),
1085 locator: PdfSourceLocator {
1086 document_sha256: document_sha256(b"fixture"),
1087 page: 1,
1088 spans: vec![sample_span(0, 0, 3)],
1089 island_digest: sha256_hex(b"island"),
1090 },
1091 before: PdfTextEditState {
1092 text: "CAR YOU LIST.".to_string(),
1093 appearance: sample_appearance(),
1094 },
1095 after: PdfTextEditState {
1096 text: "CAR YOU LIST, YOU WIN.".to_string(),
1097 appearance: sample_appearance(),
1098 },
1099 available_bounds: PdfRect {
1100 x: 0.0,
1101 y: 0.0,
1102 width: 200.0,
1103 height: 40.0,
1104 },
1105 };
1106
1107 let json = serde_json::to_string(&command).expect("serialize");
1108 assert!(json.contains("\"kind\":\"replaceSourceText\""));
1109 assert!(json.contains("\"availableBounds\""));
1110
1111 let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1112 match round_tripped {
1113 PdfEditCommand::ReplaceSourceText { id, after, .. } => {
1114 assert_eq!(id, "edit-1");
1115 assert_eq!(after.text, "CAR YOU LIST, YOU WIN.");
1116 }
1117 PdfEditCommand::AddText { .. } => panic!("expected ReplaceSourceText"),
1118 PdfEditCommand::CorrectOcrLine { .. } => panic!("expected ReplaceSourceText"),
1119 }
1120 }
1121
1122 #[test]
1123 fn pdf_edit_command_add_text_round_trips() {
1124 let command = PdfEditCommand::AddText {
1125 id: "add-1".to_string(),
1126 page: 2,
1127 anchor: PdfPoint { x: 72.0, y: 144.0 },
1128 text: "New line".to_string(),
1129 appearance: sample_appearance(),
1130 };
1131
1132 let json = serde_json::to_string(&command).expect("serialize");
1133 assert!(json.contains("\"kind\":\"addText\""));
1134
1135 let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1136 match round_tripped {
1137 PdfEditCommand::AddText { id, page, text, .. } => {
1138 assert_eq!(id, "add-1");
1139 assert_eq!(page, 2);
1140 assert_eq!(text, "New line");
1141 }
1142 PdfEditCommand::ReplaceSourceText { .. } => panic!("expected AddText"),
1143 PdfEditCommand::CorrectOcrLine { .. } => panic!("expected AddText"),
1144 }
1145 }
1146
1147 #[test]
1148 fn pdf_edit_command_correct_ocr_text_round_trips() {
1149 let command = PdfEditCommand::CorrectOcrLine {
1150 id: "ocr-1".to_string(),
1151 page: 1,
1152 line_id: "line-7".to_string(),
1153 rect: PdfRect {
1154 x: 72.0,
1155 y: 700.0,
1156 width: 120.0,
1157 height: 18.0,
1158 },
1159 text: "Corrected".to_string(),
1160 appearance: sample_appearance(),
1161 cover_color: PdfColor {
1162 r: 1.0,
1163 g: 1.0,
1164 b: 1.0,
1165 alpha: 1.0,
1166 },
1167 background_proof: PdfOcrBackgroundProof::NearUniform,
1168 };
1169 let json = serde_json::to_string(&command).expect("serialize");
1170 assert!(json.contains("\"kind\":\"correctOcrText\""));
1171 assert!(json.contains("\"lineId\":\"line-7\""));
1172 assert!(json.contains("\"backgroundProof\":\"nearUniform\""));
1173 let round_tripped: PdfEditCommand = serde_json::from_str(&json).expect("deserialize");
1174 assert!(matches!(
1175 round_tripped,
1176 PdfEditCommand::CorrectOcrLine {
1177 background_proof: PdfOcrBackgroundProof::NearUniform,
1178 ..
1179 }
1180 ));
1181 let invalid = json.replace("nearUniform", "gradient");
1182 assert!(serde_json::from_str::<PdfEditCommand>(&invalid).is_err());
1183 }
1184
1185 #[test]
1186 fn effective_commands_last_ocr_correction_wins() {
1187 let appearance = sample_appearance();
1188 let correction = |id: &str, text: &str| PdfEditCommand::CorrectOcrLine {
1189 id: id.to_string(),
1190 page: 1,
1191 line_id: "line-1".to_string(),
1192 rect: PdfRect {
1193 x: 0.0,
1194 y: 0.0,
1195 width: 100.0,
1196 height: 20.0,
1197 },
1198 text: text.to_string(),
1199 appearance: appearance.clone(),
1200 cover_color: PdfColor {
1201 r: 1.0,
1202 g: 1.0,
1203 b: 1.0,
1204 alpha: 1.0,
1205 },
1206 background_proof: PdfOcrBackgroundProof::NearUniform,
1207 };
1208 let commands = vec![correction("old", "old"), correction("new", "new")];
1209 let active = effective_pdf_edit_commands(&commands);
1210 assert_eq!(active.len(), 1);
1211 assert!(matches!(active[0], PdfEditCommand::CorrectOcrLine { text, .. } if text == "new"));
1212 }
1213
1214 #[test]
1215 fn pdf_font_source_round_trips() {
1216 for (source, wire) in [
1217 (PdfFontSource::Bundled, "\"bundled\""),
1218 (PdfFontSource::System, "\"system\""),
1219 ] {
1220 let json = serde_json::to_string(&source).expect("serialize");
1221 assert_eq!(json, wire, "frontend expects a string source discriminator");
1222 let round_tripped: PdfFontSource = serde_json::from_str(&json).expect("deserialize");
1223 assert_eq!(round_tripped, source);
1224 }
1225 }
1226
1227 #[test]
1228 fn pdf_font_catalog_entry_serializes_camel_case() {
1229 let entry = PdfFontCatalogEntry {
1230 id: "source-serif-4".to_string(),
1231 family: "Source Serif 4".to_string(),
1232 postscript_name: Some("SourceSerif4-Regular".to_string()),
1233 styles: vec!["Regular".to_string(), "Bold".to_string()],
1234 embeddable: true,
1235 subsettable: true,
1236 source: PdfFontSource::Bundled,
1237 };
1238 let json = serde_json::to_string(&entry).expect("serialize");
1239 assert!(json.contains("\"postscriptName\""));
1240 assert!(json.contains("\"subsettable\""));
1241 assert_eq!(
1242 serde_json::from_str::<serde_json::Value>(&json).unwrap()["source"],
1243 "bundled"
1244 );
1245 }
1246
1247 #[test]
1248 fn pdf_edit_error_serializes_as_code_message_envelope() {
1249 let json = serde_json::to_string(&PdfEditError::StaleSource).expect("serialize");
1252 assert_eq!(
1253 json,
1254 r#"{"code":"stale_source","message":"PDF changed since edit started"}"#
1255 );
1256 assert_eq!(
1257 PdfEditError::TextOverflow.to_string(),
1258 "text exceeds safe bounds"
1259 );
1260 assert_eq!(PdfEditError::TextOverflow.code(), "text_overflow");
1261 }
1262
1263 #[derive(Debug)]
1265 struct FixedAdvanceFont {
1266 advance: f64,
1267 }
1268
1269 impl TextStateFont for FixedAdvanceFont {
1270 fn id(&self) -> &str {
1271 "fixed"
1272 }
1273
1274 fn decode_show_operand(
1275 &self,
1276 _operand: &lopdf::Object,
1277 ) -> Result<Vec<DecodedGlyph>, PdfEditError> {
1278 Ok(Vec::new())
1279 }
1280
1281 fn advance(&self, _cid: u32, _font_size: f64) -> f64 {
1282 self.advance
1283 }
1284 }
1285
1286 #[test]
1287 fn glyph_quad_without_bbox_uses_advance_fallback() {
1288 let mut state = TextState::new();
1289 state.font = Some(Rc::new(FixedAdvanceFont { advance: 10.0 }));
1290 state.font_size = 12.0;
1291
1292 let glyph = DecodedGlyph {
1293 cid: 1,
1294 unicode: "A".to_string(),
1295 source_range: (0, 1),
1296 bbox: None,
1297 };
1298
1299 let quad = state
1300 .glyph_quad(&glyph, Matrix::IDENTITY)
1301 .expect("font is set");
1302 assert_eq!((quad[0], quad[1]), (0.0, state.font_size * -0.25));
1304 assert_eq!((quad[2], quad[3]), (10.0, state.font_size * -0.25));
1306 }
1307
1308 #[test]
1309 fn glyph_quad_without_font_is_malformed_content() {
1310 let state = TextState::new();
1311 let glyph = DecodedGlyph {
1312 cid: 1,
1313 unicode: "A".to_string(),
1314 source_range: (0, 1),
1315 bbox: None,
1316 };
1317 let err = state
1318 .glyph_quad(&glyph, Matrix::IDENTITY)
1319 .expect_err("no font set");
1320 assert!(matches!(err, PdfEditError::MalformedContent));
1321 }
1322
1323 #[test]
1324 fn source_address_with_glyph_narrows_correctly() {
1325 let address = SourceAddress {
1326 stream_ref: (7, 0),
1327 contents_index: 1,
1328 operator_index: 2,
1329 operand_index: 0,
1330 };
1331 let glyph_address = address.with_glyph((3, 6));
1332 assert_eq!(glyph_address.stream_ref, (7, 0));
1333 assert_eq!(glyph_address.contents_index, 1);
1334 assert_eq!(glyph_address.operator_index, 2);
1335 assert_eq!(glyph_address.glyph_start, 3);
1336 assert_eq!(glyph_address.glyph_end, 6);
1337 }
1338}