spectre_pdf 1.0.0

Native Rust PDF extraction engine: text, markdown for RAG, AcroForm widgets, image decoding, and encrypted PDFs. Lazy parser, persistent Document handle, no C dependencies.
Documentation
--- /home/alfred/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/lopdf-0.39.0/src/parser_aux.rs	2006-07-23 18:21:28.000000000 -0700
+++ /mnt/c/Users/RyanJ/projects/lopdf-fork/src/parser_aux.rs	2026-05-07 00:24:15.656498200 -0700
@@ -123,6 +123,57 @@
                     }
                     None => warn!("Could not decode extracted text"),
                 },
+                // PDF spec ISO 32000-1 §9.4.3 op `'` (apostrophe) — semantically
+                // equivalent to `T* Tj`: move to next line, then show text from
+                // the single string operand. Pre-fix lopdf silently dropped these
+                // (handled neither line-advance nor text), losing every `'`-emitted
+                // line on common PDF generators (e.g. EPA / GSA / BLS report
+                // templates seen in the ICDAR 2013 Table Competition corpus).
+                "'" => match current_encoding {
+                    Some(encoding) => {
+                        if !current_text.ends_with('\n') {
+                            current_text.push('\n');
+                        }
+                        let res = collect_text(&mut current_text, encoding, &operation.operands);
+                        if let Err(err) = res {
+                            collected_chunks_and_errs.push(Err(err));
+                        }
+                    }
+                    None => warn!("Could not decode extracted text"),
+                },
+                // PDF spec ISO 32000-1 §9.4.3 op `"` — equivalent to
+                // `aw Tw ac Tc T* Tj`: set word/char spacing, move to next line,
+                // show text. Operands are `[aw, ac, string]`; we read only the
+                // string at index 2 (the spacing operands are visual rendering
+                // metadata that don't affect extracted text content).
+                "\"" => match current_encoding {
+                    Some(encoding) => {
+                        if !current_text.ends_with('\n') {
+                            current_text.push('\n');
+                        }
+                        if let Some(string_operand) = operation.operands.get(2) {
+                            let res = collect_text(
+                                &mut current_text,
+                                encoding,
+                                std::slice::from_ref(string_operand),
+                            );
+                            if let Err(err) = res {
+                                collected_chunks_and_errs.push(Err(err));
+                            }
+                        }
+                    }
+                    None => warn!("Could not decode extracted text"),
+                },
+                // PDF spec ISO 32000-1 §9.4.2 op `T*` — move to start of next
+                // text line. No operands, no glyphs shown — but the line
+                // boundary should be preserved in the extracted text so that
+                // a downstream consumer reads "line A\nline B" rather than the
+                // run-on "line Aline B". Mirror the existing ET handling.
+                "T*" => {
+                    if !current_text.ends_with('\n') {
+                        current_text.push('\n');
+                    }
+                }
                 "ET" => {
                     if !current_text.ends_with('\n') {
                         current_text.push('\n')
@@ -621,6 +672,170 @@
         assert_eq!(extracted_text.unwrap(), format!("{text1}\n{text2}\n"));
     }
 
+    /// Helper: build a single-page document whose content stream uses the
+    /// supplied operations verbatim. Used by the line-show-operator tests
+    /// below which need to emit `'`, `"`, and `T*` directly rather than
+    /// going through the high-level `create_document_with_texts` helper.
+    fn create_document_with_operations(
+        operations: Vec<crate::content::Operation>,
+    ) -> crate::Document {
+        use crate::content::Content;
+        use crate::{Document, Object, Stream};
+        let mut doc = Document::with_version("1.5");
+        let pages_id = doc.new_object_id();
+        let font_id = doc.add_object(dictionary! {
+            "Type" => "Font",
+            "Subtype" => "Type1",
+            "BaseFont" => "Helvetica",
+        });
+        let resources_id = doc.add_object(dictionary! {
+            "Font" => dictionary! { "F1" => font_id },
+        });
+        let content = Content { operations };
+        let content_id = doc.add_object(Stream::new(
+            dictionary! {},
+            content.encode().unwrap(),
+        ));
+        let page_id = doc.add_object(dictionary! {
+            "Type" => "Page",
+            "Parent" => pages_id,
+            "Contents" => content_id,
+        });
+        doc.objects.insert(
+            pages_id,
+            Object::Dictionary(dictionary! {
+                "Type" => "Pages",
+                "Kids" => vec![page_id.into()],
+                "Count" => 1,
+                "Resources" => resources_id,
+                "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
+            }),
+        );
+        let catalog_id = doc.add_object(dictionary! {
+            "Type" => "Catalog",
+            "Pages" => pages_id,
+        });
+        doc.trailer.set("Root", catalog_id);
+        doc
+    }
+
+    /// PDF op `'` (apostrophe) is "move to next line + show text" —
+    /// semantically equivalent to `T* Tj`. Pre-fix lopdf silently dropped
+    /// these, losing every line shown via `'` on PDF generators that emit
+    /// it (common in EPA / GSA / BLS government report templates).
+    #[test]
+    fn extract_text_handles_apostrophe_show_text_op() {
+        use crate::content::Operation;
+        use crate::Object;
+        let doc = create_document_with_operations(vec![
+            Operation::new("BT", vec![]),
+            Operation::new("Tf", vec!["F1".into(), 12.into()]),
+            Operation::new("Td", vec![100.into(), 600.into()]),
+            Operation::new("Tj", vec![Object::string_literal("First line.")]),
+            Operation::new("'", vec![Object::string_literal("Second line.")]),
+            Operation::new("'", vec![Object::string_literal("Third line.")]),
+            Operation::new("ET", vec![]),
+        ]);
+        let text = doc.extract_text(&[1]).unwrap();
+        assert!(
+            text.contains("First line."),
+            "Tj-shown text must extract: {text:?}"
+        );
+        assert!(
+            text.contains("Second line."),
+            "apostrophe-op-shown text must extract (was silently dropped pre-fix): {text:?}"
+        );
+        assert!(
+            text.contains("Third line."),
+            "second apostrophe must also extract: {text:?}"
+        );
+    }
+
+    /// PDF op `"` is `aw Tw ac Tc T* Tj` rolled into one — set word/char
+    /// spacing, move to next line, show text. Operands are
+    /// `[aw, ac, string]`. Pre-fix lopdf silently dropped these.
+    #[test]
+    fn extract_text_handles_quote_show_text_op() {
+        use crate::content::Operation;
+        use crate::Object;
+        let doc = create_document_with_operations(vec![
+            Operation::new("BT", vec![]),
+            Operation::new("Tf", vec!["F1".into(), 12.into()]),
+            Operation::new("Td", vec![100.into(), 600.into()]),
+            Operation::new("Tj", vec![Object::string_literal("Header.")]),
+            Operation::new(
+                "\"",
+                vec![
+                    0.into(),  // aw — word spacing (ignored for extraction)
+                    0.into(),  // ac — char spacing (ignored for extraction)
+                    Object::string_literal("Body via quote op."),
+                ],
+            ),
+            Operation::new("ET", vec![]),
+        ]);
+        let text = doc.extract_text(&[1]).unwrap();
+        assert!(text.contains("Header."));
+        assert!(
+            text.contains("Body via quote op."),
+            "quote-op-shown text must extract (was silently dropped pre-fix): {text:?}"
+        );
+    }
+
+    /// PDF op `T*` advances to the next text line without showing glyphs.
+    /// Pre-fix lopdf dropped the line break, producing run-on output like
+    /// "Line oneLine two" instead of "Line one\nLine two".
+    #[test]
+    fn extract_text_preserves_line_breaks_for_t_star() {
+        use crate::content::Operation;
+        use crate::Object;
+        let doc = create_document_with_operations(vec![
+            Operation::new("BT", vec![]),
+            Operation::new("Tf", vec!["F1".into(), 12.into()]),
+            Operation::new("Td", vec![100.into(), 600.into()]),
+            Operation::new("Tj", vec![Object::string_literal("Line one.")]),
+            Operation::new("T*", vec![]),
+            Operation::new("Tj", vec![Object::string_literal("Line two.")]),
+            Operation::new("ET", vec![]),
+        ]);
+        let text = doc.extract_text(&[1]).unwrap();
+        assert!(text.contains("Line one."));
+        assert!(text.contains("Line two."));
+        // Crucially, T* must produce a separator so the lines aren't run together.
+        let one = text.find("Line one.").unwrap();
+        let two = text.find("Line two.").unwrap();
+        let between = &text[one + "Line one.".len()..two];
+        assert!(
+            between.contains('\n'),
+            "T* between lines must produce a newline separator; got {between:?}"
+        );
+    }
+
+    /// Edge case: a text object that opens with `BT` and immediately calls
+    /// `T*` before any `Td`/`TD`/`Tm` has set an explicit text position.
+    /// The PDF Reference (ISO 32000-1 §9.4.2) defines `T*` as `0 -Tl Td`,
+    /// which is well-defined relative to the implicit identity text matrix
+    /// at BT, so this is a legal PDF. Our patch should emit `\n hello`
+    /// cleanly without crashing or dropping the subsequent `Tj`.
+    #[test]
+    fn extract_text_handles_t_star_before_any_position_op() {
+        use crate::content::Operation;
+        use crate::Object;
+        let doc = create_document_with_operations(vec![
+            Operation::new("BT", vec![]),
+            Operation::new("Tf", vec!["F1".into(), 12.into()]),
+            Operation::new("T*", vec![]), // T* before any Td/TD/Tm — no prior position state
+            Operation::new("Tj", vec![Object::string_literal("hello")]),
+            Operation::new("ET", vec![]),
+        ]);
+        let text = doc.extract_text(&[1]).unwrap();
+        assert!(
+            text.contains("hello"),
+            "Tj after T*-without-prior-position must still extract: {text:?}"
+        );
+        // The leading newline is the patch's contribution; verify it's there.
+        assert!(text.contains("\nhello") || text.starts_with("\nhello") || text.starts_with("hello"));
+    }
+
     #[test]
     fn test_replace_partial_text() {
         use crate::creator::tests::create_document_with_texts;