6 minute read

Sometimes we have a PDF document which is entirely composed of images with text. The text in these images cannot be selected or searched. This is often the case if you have scanned documents and merged them into a PDF. Also, quite often, we find some old research paper online as PDF, where the text looks more or less good and is rendered with fonts, but it can neither be selected nor searched, because the font shape and characters do not match. What we want in both cases is to add an invisible layer of real text over the original PDF. This text should then be search-, select-, and copy-able. The script pdfAddOcrLayer.sh can create such a layer for you.

This can be done with a combination of Tesseract, OCRmyPDF, Ghostscript, and qpdf. The idea is that we first take the original PDF document and convert it to a series of images and then apply optical character recognition (OCR) to these images. Tesseract and OCRmyPDF can do this. The result is a new PDF document A where everything from the original document is converted to an image and an invisible text layer is stacked on top.

This is already good for most cases, but it will render any previously existing text or vector graphics as image as well. We therefore create a second new PDF document B from A where we delete all images and just keep the text. We also create a third new PDF document C from the original document where all text is vectorized to vector graphics. We then stack B on top of C. This gives us a new PDF document which is visually the same as the source document, but where all the text is obtained via OCR.

This has the downside that all fonts are dropped from the PDF. Basically, any originally existing text rendered via fonts now becomes geometrical/vector shapes. This probably makes the PDF document bigger, but visually it looks the same. On the top, we have an invisible layer of text that can be searched and copied. So for a PDF with existing font-based text, we have separated the visuals from the text. For a scanned document, this does not make any difference at all. We have exactly the same visual impression of the scanned document, but we can now search, select, and copy text freely.

Here I provide the little script pdfAddOcrLayer.sh, which wraps around Ghostscript and does this in the terminal. It takes as parameters

  • The path to the source PDF document.
  • OPTIONAL: The path to the destination document (default: _ocr is added to the source name).
  • OPTIONAL: The languages/scripts to be used (default: all languages/scripts available to Tesseract).

Here you can download this script and the complete collection of my personal scripts is available here.

#!/bin/bash -

# Add an OCR-Layer to a PDF document.
#
# The script expects the following parameters:
# 1. The path to a source document.
# 2. The optional path to the destination document.
# 3. Optionally: The languages to be used, e.g., eng, deu

# strict error handling
set -o pipefail  # trace ERR through pipes
set -o errtrace  # trace ERR through 'time command' and other functions
set -o nounset   # set -u : exit the script if you try to use an uninitialized variable
set -o errexit   # set -e : exit the script if any statement returns a non-true return value

if [ $# -lt 1 ]; then
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Add an OCR layer to a PDF document."
    echo "All fonts are dropped, all text is turned into vector graphics, and a new text layer based on OCR is put on top of the document."
    echo "Parameters:"
    echo " 1. path to source document"
    echo " 2. OPTIONAL: the path to the destination document (default: source + _ocr)"
    echo " 3. OPTIONAL: the languages/scrips to use, like deu, eng, ... (default: all)"
    exit 0
fi

if ! ( command -v tesseract &> /dev/null ); then
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): tesseract is not installed but needed."
    echo "You can install it via 'sudo apt-get install tesseract-ocr"
    echo "More languages can be installed via 'sudo apt-get install tesseract-ocr-deu tesseract-ocr-end tesseract-ocr-chi-sim ...'."
    exit 1
fi

if ! ( command -v ocrmypdf &> /dev/null ); then
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): ocrmypdf is not installed but needed."
    echo "You can install it via 'sudo apt-get install ocrmypdf'."
    exit 1
fi

if ! ( command -v gs &> /dev/null ); then
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): ghostscript (gs) is not installed but needed."
    echo "You can install it via 'sudo apt-get install ghostscript'."
    exit 1
fi

if ! ( command -v gs &> /dev/null ); then
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): qpdf is not installed but needed."
    echo "You can install it via 'sudo apt-get install qpdf'."
    exit 1
fi

srcDocument="$(realpath "$1")"
if [ -f "$srcDocument" ]; then
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Got source document '$srcDocument'."
else
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Source document $srcDocument' does not exist."
    exit 1
fi

dstDocument="${2:-}"
if [ -n "$dstDocument" ]; then
      dstDocument="$(realpath "$dstDocument")"
      echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Destination document is specified as '$dstDocument'."
else
    dstDocument="$(realpath "${srcDocument%.*}_ocr.pdf")"
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Using default destination document '$dstDocument'."
fi

langs="${3:-}"
if [ -n "$langs" ]; then
      echo "$(date +'%0Y-%0m-%0d %0R:%0S'): The languages to use are '$langs'."
else
    langs="$(tesseract --list-langs 2>&1)"
    langs="${langs#*:}"
    langs="${langs##+([[:space:]])}"
    langs="${langs%%+([[:space:]])}"
    langs="$(echo "$langs" | tr '\n' '+')"
    langs="${langs%%\+}"
    langs="${langs##\+}"
    langs="${langs//+osd/}"
    langs="${langs//osd+/}"
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Using all available languages, namely '$langs'."
fi

tempPdfA="$(mktemp --suffix=.pdf)"
echo "$(date +'%0Y-%0m-%0d %0R:%0S'): We first create the temporary pdf with the rasterized/OCRed images as '$tempPdfA'."
ocrmypdf -l "$langs" --output-type pdfa --no-tesseract-downsample-large-images --deskew --clean-final --continue-on-soft-render-error --optimize 1  --force-ocr "$srcDocument" "$tempPdfA"

tempPdfB="$(mktemp --suffix=.pdf)"
echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Now we remove all images from '$tempPdfA' and store it as '$tempPdfB'."
gs -dAutoRotatePages=/None \
   -dBATCH \
   -dCannotEmbedFontPolicy=/Error \
   -dCompatibilityLevel="1.7" \
   -dCompressFonts=true \
   -dCompressStreams=true \
   -dCreateJobTicket=false \
   -dDoThumbnails=false \
   -dEmbedAllFonts=true \
   -dFastWebView=false \
   -dFILTERIMAGE \
   -dFILTERVECTOR \
   -dHaveTransparency=true \
   -dNOPAUSE \
   -dNOPROMPT \
   -dOptimize=true \
   -dPDFSTOPONERROR=true \
   -dPDFSTOPONWARNING=true \
   -dPreserveCopyPage=false \
   -dPreserveEPSInfo=false \
   -dPreserveHalftoneInfo=false \
   -dPreserveOPIComments=false \
   -dPreserveOverprintSettings=false \
   -dPreserveSeparation=false \
   -dPreserveDeviceN=false \
   -dPreserveMarkedContent=false \
   -dPrinted=false \
   -dOmitInfoDate=true \
   -dOmitID=true \
   -dOmitXMP=true \
   -dQUIET \
   -dSAFER \
   -dSubsetFonts=true \
   -dUCRandBGInfo=/Remove \
   -dUNROLLFORMS \
   -sDEVICE=pdfwrite \
   -sOutputFile="$tempPdfB" \
   "$tempPdfA" \
   -q

echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Now we delete '$tempPdfA'."
rm "$tempPdfA"

tempPdfC="$(mktemp --suffix=.pdf)"
echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Now we remove all text from '$srcDocument' and store it as '$tempPdfC'."
gs -dAutoRotatePages=/None \
   -dBATCH \
   -dCompatibilityLevel="1.7" \
   -dCompressStreams=true \
   -dCreateJobTicket=false \
   -dDoThumbnails=false \
   -dFastWebView=false \
   -dHaveTransparency=true \
   -dNOPAUSE \
   -dNOPROMPT \
   -dNoOutputFonts \
   -dOptimize=true \
   -dPDFSTOPONERROR=true \
   -dPDFSTOPONWARNING=true \
   -dPreserveCopyPage=false \
   -dPreserveEPSInfo=false \
   -dPreserveHalftoneInfo=false \
   -dPreserveOPIComments=false \
   -dPreserveOverprintSettings=false \
   -dPreserveSeparation=false \
   -dPreserveDeviceN=false \
   -dPreserveMarkedContent=false \
   -dPrinted=false \
   -dOmitInfoDate=true \
   -dOmitID=true \
   -dOmitXMP=true \
   -dQUIET \
   -dSAFER \
   -dSubsetFonts=true \
   -dUCRandBGInfo=/Remove \
   -dUNROLLFORMS \
   -sDEVICE=pdfwrite \
   -sOutputFile="$tempPdfC" \
   "$srcDocument" \
   -q

echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Finally, we super-impose the text layer from '$tempPdfB' onto '$tempPdfC' and store it as '$dstDocument'."

qpdf "$tempPdfC" --overlay "$tempPdfB" -- "$dstDocument"

echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Now we delete '$tempPdfB'."
rm "$tempPdfB"

echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Now we delete '$tempPdfC'."
rm "$tempPdfC"

if [ -f "$dstDocument" ]; then
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Finished OCRing '$srcDocument' to '$dstDocument'."
else
    echo "$(date +'%0Y-%0m-%0d %0R:%0S'): Destination document '$dstDocument' was not created."
    exit 1
fi