@@ -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;