Add functional "redo OCR" feature

Needs argument validation and some other changes. Needs testing
with mixed-content PDFs.

Only really works for pure invisible text at the moment.
This commit is contained in:
James R. Barlow
2018-10-19 00:02:19 -07:00
parent f7dbf94071
commit 16af753206
5 changed files with 116 additions and 26 deletions
+5 -1
View File
@@ -250,13 +250,17 @@ ocrsettings = parser.add_argument_group(
"Control how OCR is applied")
ocrsettings.add_argument(
'-f', '--force-ocr', action='store_true',
help="Rasterize any fonts or vector objects on each page, apply OCR, and "
help="Rasterize any text or vector objects on each page, apply OCR, and "
"save the rastered output (this rewrites the PDF)")
ocrsettings.add_argument(
'-s', '--skip-text', action='store_true',
help="Skip OCR on any pages that already contain text, but include the "
"page in final output; useful for PDFs that contain a mix of "
"images, text pages, and/or previously OCRed pages")
ocrsettings.add_argument(
'--redo-ocr', action='store_true',
help="Remove any invisible text, and apply OCR")
ocrsettings.add_argument(
'--skip-big', type=numeric(float, 0, 5000), metavar='MPixels',
help="Skip OCR on pages larger than the specified amount of megapixels, "
+9 -4
View File
@@ -243,7 +243,7 @@ def is_ocr_required(pageinfo, log, options):
if pageinfo.has_text:
msg = "{0:4d}: page already has text! {1}"
if not options.force_ocr and not options.skip_text:
if not options.force_ocr and not (options.skip_text or options.redo_ocr):
log.error(msg.format(page,
"aborting (use --force-ocr to force OCR)"))
raise PriorOcrFoundError()
@@ -251,6 +251,10 @@ def is_ocr_required(pageinfo, log, options):
log.info(msg.format(page,
"rasterizing text and running OCR anyway"))
ocr_required = True
elif options.redo_ocr and pageinfo.only_ocr_text:
log.info(msg.format(page,
"redoing OCR"))
ocr_required = True
elif options.skip_text:
log.info(msg.format(page,
"skipping all processing on this page"))
@@ -559,12 +563,13 @@ def select_ocr_image(
user."""
image = infiles[0]
if context.get_options().force_ocr:
options = context.get_options()
pageinfo = get_pageinfo(image, context)
if options.force_ocr or (options.redo_ocr and pageinfo.only_ocr_text):
re_symlink(image, output_file, log)
return
pageinfo = get_pageinfo(image, context)
with Image.open(image) as im:
from PIL import ImageColor
from PIL import ImageDraw
+38 -2
View File
@@ -43,8 +43,39 @@ def _update_page_resources(*, page, font, font_key, procset):
resources['/ProcSet'] = procset
def _strip_old_text(pdf, page):
stream = []
in_text_obj = False
page.page_contents_coalesce()
for operands, operator in pikepdf.parse_content_stream(page, ''):
if not in_text_obj:
if operator == pikepdf.Operator('BT'):
in_text_obj = True
else:
stream.append((operands, operator))
else:
if operator == pikepdf.Operator('ET'):
in_text_obj = False
def convert(op):
try:
return op.unparse()
except AttributeError:
return str(op).encode('ascii')
lines = []
for operands, operator in stream:
line = b' '.join(convert(op) for op in operands) + b' ' + operator.unparse()
lines.append(line)
content_stream = b'\n'.join(lines)
page.Contents = pikepdf.Stream(pdf, content_stream)
def _weave_layers_graft(
*, pdf_base, page_num, text, font, font_key, procset, rotation, log):
*, pdf_base, page_num, text, font, font_key, procset, rotation,
strip_old_text, log):
"""Insert the text layer from text page 0 on to pdf_base at page_num"""
log.debug("Grafting")
@@ -109,6 +140,9 @@ def _weave_layers_graft(
new_text_layer = pikepdf.Stream(pdf_base, pdf_text_contents)
if strip_old_text:
_strip_old_text(pdf_base, base_page)
base_page.page_contents_add(new_text_layer, prepend=True)
_update_page_resources(
@@ -336,10 +370,12 @@ def weave_layers(
if text and font:
# Graft the text layer onto this page, whether new or old
strip_old = (context.get_options().redo_ocr
and pdfinfo[page_num - 1].only_ocr_text)
_weave_layers_graft(
pdf_base=pdf_base, page_num=page_num, text=text, font=font,
font_key=font_key, rotation=text_misaligned, procset=procset,
log=log
strip_old_text=strip_old, log=log
)
# Correct the rotation if applicable
+54 -19
View File
@@ -107,6 +107,20 @@ ContentsInfo = namedtuple('ContentsInfo',
['xobject_settings', 'inline_images', 'found_text', 'found_vector'])
class VectorInfo:
def __init__(self):
pass
class TextInfo:
def __init__(self, invisible, visible):
self.invisible = invisible
self.visible = visible
def __bool__(self):
return self.invisible or self.visible
def _normalize_stack(graphobjs):
"""Convert runs of qQ's in the stack into single graphobjs"""
for operands, operator in graphobjs:
@@ -143,9 +157,14 @@ def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
xobject_settings = []
inline_images = []
found_text, found_vector = False, False
text_operators = set("""Tj " ' TJ""".split())
vector_operators = set('S s f F f* B B* b b*'.split())
operator_whitelist = """q Q Do cm TJ Tj " ' BI ID EI S s f F f* B B* b b*"""
found_invisible_text, found_visible_text = False, False
text_mode_ops = set("""BT ET Tr""".split())
text_showing_ops = set("""Tj " ' TJ""".split())
vector_ops = set('S s f F f* B B* b b*'.split())
image_ops = set('BI ID EI q Q Do cm'.split())
text_render_mode = 0
operator_whitelist = ' '.join(
text_mode_ops | text_showing_ops | vector_ops | image_ops)
for n, graphobj in enumerate(_normalize_stack(
pikepdf.parse_content_stream(contentstream, operator_whitelist))):
@@ -176,15 +195,25 @@ def _interpret_contents(contentstream, initial_shorthand=UNIT_SQUARE):
iimage=iimage, shorthand=ctm.shorthand,
stack_depth=len(stack))
inline_images.append(inline)
elif operator in text_operators:
elif operator in text_mode_ops:
if operator == 'BT':
text_render_mode = 0
elif operator == 'Tr':
text_render_mode = operands[0]
elif operator in text_showing_ops:
found_text = True
elif operator in vector_operators:
if text_render_mode == 3:
found_invisible_text = True
else:
found_visible_text = True
elif operator in vector_ops:
found_vector = True
return ContentsInfo(
xobject_settings=xobject_settings,
inline_images=inline_images,
found_text=found_text,
found_text=TextInfo(invisible=found_invisible_text,
visible=found_visible_text),
found_vector=found_vector)
@@ -252,11 +281,6 @@ def _get_dpi(ctm_shorthand, image_size):
return dpi_w, dpi_h
class VectorInfo:
def __init__(self):
pass
class ImageInfo:
DPI_PREC = Decimal('1.000')
@@ -444,11 +468,11 @@ def _find_form_xobject_images(pdf, container, contentsinfo):
# but in practice both Form XObjects and multiple drawing of the
# same object are both very rare.
ctm_shorthand = settings.shorthand
yield from _find_images(
yield from _process_content_streams(
pdf=pdf, container=form_xobject, shorthand=ctm_shorthand)
def _find_images(*, pdf, container, shorthand=None):
def _process_content_streams(*, pdf, container, shorthand=None):
"""Find all individual instances of images drawn in the container
Usually the container is a page, but it may also be a Form XObject.
@@ -491,6 +515,8 @@ def _find_images(*, pdf, container, shorthand=None):
if contentsinfo.found_vector:
yield VectorInfo()
if contentsinfo.found_text:
yield contentsinfo.found_text
yield from _find_inline_images(contentsinfo)
yield from _find_regular_images(container, contentsinfo)
yield from _find_form_xobject_images(pdf, container, contentsinfo)
@@ -591,15 +617,20 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile, xmltext):
pageinfo['rotate'] = 0
userunit_shorthand = (userunit, 0, 0, userunit, 0, 0)
pageinfo['images'] = [im for im in
_find_images(pdf=pdf, container=page,
shorthand=userunit_shorthand)]
contentsinfo = [ci for ci in
_process_content_streams(pdf=pdf, container=page,
shorthand=userunit_shorthand)]
if any(isinstance(im, VectorInfo) for im in pageinfo['images']):
pageinfo['has_vector'] = False
if any(isinstance(ci, VectorInfo) for ci in contentsinfo):
pageinfo['has_vector'] = True
pageinfo['images'] = [im for im in pageinfo['images']
if not isinstance(im, VectorInfo)]
textinfos = [ti for ti in contentsinfo if isinstance(ti, TextInfo)]
all_invisible = all(ti.invisible for ti in textinfos) and len(textinfos) > 0
pageinfo['only_ocr_text'] = all_invisible
pageinfo['images'] = [im for im in contentsinfo
if isinstance(im, ImageInfo)]
if pageinfo['images']:
xres = Decimal(max(image.xres for image in pageinfo['images']))
yres = Decimal(max(image.yres for image in pageinfo['images']))
@@ -666,6 +697,10 @@ class PageInfo:
def has_vector(self):
return self._pageinfo['has_vector']
@property
def only_ocr_text(self):
return self._pageinfo['only_ocr_text']
@property
def width_inches(self):
return self._pageinfo['width_inches']
+10
View File
@@ -175,3 +175,13 @@ def test_vector(resources):
filename = resources / 'vector.pdf'
pdf = pdfinfo.PdfInfo(filename)
assert pdf[0].has_vector
assert not pdf[0].only_ocr_text
assert not pdf[0].has_text
def test_ocr_detection(resources):
filename = resources / 'graph_ocred.pdf'
pdf = pdfinfo.PdfInfo(filename)
assert not pdf[0].has_vector
assert pdf[0].only_ocr_text
assert pdf[0].has_text