/Nachtrag 09.03.2016: Eine lauffähige Version des „Tika Page Extractors“ kann heruntergeladen werden: Download. Der Quellcode steht auf Github zur Verfügung./
Volltextsuche ist eine relativ häufige Anforderung in der Programmierung. Häufig sollen dabei beliebige Dokumente durchsuchbar gemacht werden, also z.B. PDF-Dateien oder Office-Dokumente (Word, Excel, Open-/LibreOffice, RTF, was auch immer). Apache Tika ist dabei häufig das Tool der Wahl. Tika beherrscht über 1.000 verschiedene Formate, kann Metadaten extrahieren, die Sprache eines Dokuments erraten und wird auch von Suchservern wie Solr oder ElasticSearch eingesetzt.
Der Volltext wird bei der Extraktion komplett als Text zurückgegeben. In einem meiner letzten Projekte wollte ich jedoch mehr: Der Text sollte pro Seite zurückgegeben werden, um bei der Suche bestimmen zu können, auf welchen Seiten der gesuchte Text gefunden wurde. Die Lösung war das Erstellen einer eigenen ContentHandler-Klasse, welche die Aufgabe übernimmt.
Wichtig dabei ist jedoch, dass dies nur bei PDF-Dateien funktioniert! Andere Office-Formate enthalten in der Regel keine Informationen zu den Seiten. Die hier vorgestellte Klasse arbeitet also nur mit PDFs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
import org.apache.tika.sax.ToTextContentHandler; import org.xml.sax.Attributes; import org.xml.sax.SAXException; import java.util.ArrayList; import java.util.List; public class PageContentHandler extends ToTextContentHandler { final static private String pageTag = "div"; final static private String pageClass = "page"; /** * StringBuilder of current page */ private StringBuilder builder; /** * page list - setting the initial capacity to 500 will enhance speed by a tiny bit up to 500 bits, but will require * some RAM */ private List<String> pages = new ArrayList<>(500); /** * flag telling to compress text information by stripping whitespace? */ private final boolean compress; /** * Default constructor */ public PageContentHandler() { this.compress = true; } /** * Constructor * @param compress text information by stripping whitespace? */ public PageContentHandler(boolean compress) { this.compress = compress; } @Override public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException { if (pageTag.endsWith(qName) && pageClass.equals(atts.getValue("class"))) startPage(); } @Override public void endElement(String uri, String localName, String qName) throws SAXException { if (pageTag.endsWith(qName)) endPage(); } @Override public void characters(char[] ch, int start, int length) throws SAXException { // append data if (length > 0 && builder != null) { builder.append(ch); } } protected void startPage() throws SAXException { builder = new StringBuilder(); } protected void endPage() throws SAXException { String page = builder.toString(); // if compression has been turned on, compact whitespace and trim string if (compress) page = page.replaceAll("\\s+", " ").trim(); // add to page list at end of page list pages.add(page); } /** * @return all extracted pages */ public List<String> getPages() { return pages; } } |
Tika erstellt beim Extrahieren von PDFs Seitentags und zeichnet diese als div
aus. Diese können wir in der obigen Klasse (einem SAX-Parser) abfangen und eine Liste der Seiten bauen. Inspiriert wurde die obige Klasse von einer JRuby-Implementierung. Statt einer HashMap verwende ich jedoch eine Liste, die ebenfalls recht zuverlässig die Seiten wiedergeben und geringfügig schneller sein sollte (meine Tests zeigten einen Faktor von ca. 5-10% bei 300 Seiten). Als Option kann man dem Konstruktor einen Parameter mitgeben, der bestimmt, ob die extrahierte Information komprimiert werden soll oder nicht. Voreingestellt ist die Kompression, die Zeilenumbrüche, Tabulatoren und doppelte Leerzeichen (also Whitespace) entfernt. Diese sind in der Volltextsuche in der Regel nicht relevant.
Zum Testen kann man folgende Klasse verwenden:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import org.apache.tika.metadata.Metadata; import org.apache.tika.parser.AutoDetectParser; import org.apache.tika.parser.ParseContext; import org.apache.tika.parser.Parser; import java.io.FileInputStream; public class Tikal { public static void main(String[] args){ Parser parser = new AutoDetectParser(); PageContentHandler contentHandler = new PageContentHandler(true); try { FileInputStream fis = new FileInputStream(args[0]); long startTime = System.currentTimeMillis(); parser.parse(fis, contentHandler, new Metadata(), new ParseContext()); long stopTime = System.currentTimeMillis(); long elapsedTime = stopTime - startTime; System.out.println(elapsedTime); } catch (Exception e) { e.printStackTrace(); } for (String page : contentHandler.getPages()) System.out.println(page); } } |
Es wird ein Kommandozeilenparameter erwartet, der Dateiname der zu extrahierenden Datei. Dieser wird in Tika bzw. dessen Parser eingelesen und extrahiert. Die Zeit wird dabei gemessen und bei Erfolg die Seiten ausgegeben. Die ArrayList ermöglicht auch ein effektives Zugreifen auf einzelne Seiten innerhalb der Liste.
Alternativen
Es gibt auch einige Alternativen, die ich vorstellen möche:
- Möglich ist eine Kombination von PDFTK und pdftotext. PDFTK splittet PDF-Dateien dabei in einzelne Dokumente auf, die pdftotext dann in Text umwandeln kann.
- Das Github-Projekt pdf-extract verwendet beispielsweise diese Methode. Dabei handelt es sich um einen NodeJS-Wrapper, der auch noch OCR beherrscht (mit Hilfe von tesseract). Ich verwende für OCR übrigens mein eigenes Sandy-Skript.
- Die oben erwähnte JRuby-Implementierung ermöglicht ebenfalls eine Extraktion pro Seite – nur eben in JRuby.
Wer weitere Alternativen weiß, ich freue mich auf Hinweise und Kommentare.