Use Ghostscript for text region detection
Ghostscript txtwrite seems to be quite effective at the task. Eliminates dependency on fitz
This commit is contained in:
@@ -257,7 +257,7 @@ setup(
|
||||
'ruffus == 2.6.3', # pinned - ocrmypdf implements a 2.6.3 workaround
|
||||
],
|
||||
extras_require={
|
||||
'fitz': ['PyMuPDF >= 1.12.5, != 1.13.3'],
|
||||
'fitz': [], # Backward compatibility
|
||||
},
|
||||
tests_require=tests_require,
|
||||
entry_points={
|
||||
|
||||
@@ -48,24 +48,64 @@ def _gs_error_reported(stream):
|
||||
return re.search(r'error', stream, flags=re.IGNORECASE)
|
||||
|
||||
|
||||
def extract_text(input_file, pageno=1):
|
||||
"""
|
||||
Use the txtwrite device to get text layout information out
|
||||
|
||||
For details on options of -dTextFormat see
|
||||
https://www.ghostscript.com/doc/current/VectorDevices.htm#TXT
|
||||
|
||||
Format is like
|
||||
<page>
|
||||
<line>
|
||||
<span bbox="left top right bottom" font="..." size="...">
|
||||
<char bbox="...." c="X"/>
|
||||
|
||||
:return: XML-ish text representation in bytes
|
||||
|
||||
"""
|
||||
|
||||
args_gs = [
|
||||
'gs',
|
||||
'-dQUIET',
|
||||
'-dSAFER',
|
||||
'-dBATCH',
|
||||
'-dNOPAUSE',
|
||||
'-sDEVICE=txtwrite',
|
||||
'-dTextFormat=0',
|
||||
'-dFirstPage=%i' % pageno,
|
||||
'-dLastPage=%i' % pageno,
|
||||
'-o', '-',
|
||||
input_file
|
||||
]
|
||||
|
||||
p = run(args_gs, stdout=PIPE, stderr=PIPE)
|
||||
if p.returncode != 0:
|
||||
raise SubprocessOutputError(
|
||||
'Ghostscript text extraction failed\n%s\n%s\n%s',
|
||||
input_file, p.stdout.decode(), p.stderr.decode())
|
||||
|
||||
return p.stdout
|
||||
|
||||
|
||||
def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
pageno=1, page_dpi=None, rotation=None):
|
||||
"""
|
||||
Rasterize one page of a PDF at resolution (xres, yres) in canvas units.
|
||||
|
||||
The image is sized to match the integer pixels dimensions implied by
|
||||
|
||||
The image is sized to match the integer pixels dimensions implied by
|
||||
(xres, yres) even if those numbers are noninteger. The image's DPI will
|
||||
be overridden with the values in page_dpi.
|
||||
|
||||
|
||||
:param input_file: pathlike
|
||||
:param output_file: pathlike
|
||||
:param xres: resolution at which to rasterize page
|
||||
:param yres:
|
||||
:param raster_device:
|
||||
:param log:
|
||||
:param yres:
|
||||
:param raster_device:
|
||||
:param log:
|
||||
:param pageno: page number to rasterize (beginning at page 1)
|
||||
:param page_dpi: resolution tuple (x, y) overriding output image DPI
|
||||
:return:
|
||||
:param page_dpi: resolution tuple (x, y) overriding output image DPI
|
||||
:return:
|
||||
"""
|
||||
res = xres, yres
|
||||
int_res = round(xres), round(yres)
|
||||
@@ -90,7 +130,7 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
'-f',
|
||||
fspath(input_file)
|
||||
]
|
||||
|
||||
|
||||
log.debug(args_gs)
|
||||
p = run(args_gs, stdout=PIPE, stderr=STDOUT,
|
||||
universal_newlines=True)
|
||||
@@ -120,7 +160,7 @@ def rasterize_pdf(input_file, output_file, xres, yres, raster_device, log,
|
||||
|
||||
if rotation is not None:
|
||||
log.debug("Rotating output by %i", rotation)
|
||||
# rotation is a clockwise angle and Image.ROTATE_* is
|
||||
# rotation is a clockwise angle and Image.ROTATE_* is
|
||||
# counterclockwise so this cancels out the rotation
|
||||
if rotation == 90:
|
||||
im = im.transpose(Image.ROTATE_90)
|
||||
@@ -156,8 +196,8 @@ def generate_pdfa(pdf_pages, output_file, compression, log,
|
||||
"-dAutoFilterGrayImages=true",
|
||||
]
|
||||
|
||||
# Older versions of Ghostscript expect a leading slash in
|
||||
# sColorConversionStrategy, newer ones should not have it. See Ghostscript
|
||||
# Older versions of Ghostscript expect a leading slash in
|
||||
# sColorConversionStrategy, newer ones should not have it. See Ghostscript
|
||||
# git commit fe1c025d.
|
||||
strategy = 'RGB' if version() >= '9.19' else '/RGB'
|
||||
|
||||
|
||||
@@ -16,13 +16,3 @@
|
||||
# along with OCRmyPDF. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""Bindings to external libraries"""
|
||||
|
||||
import os as _os
|
||||
|
||||
try:
|
||||
import fitz
|
||||
except ImportError:
|
||||
fitz = None
|
||||
|
||||
if _os.environ.get('_OCRMYPDF_NO_FITZ'):
|
||||
fitz = None
|
||||
+54
-67
@@ -28,7 +28,7 @@ from pathlib import Path
|
||||
from enum import Enum
|
||||
from contextlib import contextmanager
|
||||
|
||||
from .lib import fitz
|
||||
from .exec import ghostscript
|
||||
from .helpers import universal_open, fspath
|
||||
|
||||
from pikepdf import PdfMatrix
|
||||
@@ -503,70 +503,68 @@ def _find_images(*, pdf, container, shorthand=None):
|
||||
yield from _find_form_xobject_images(pdf, container, contentsinfo)
|
||||
|
||||
|
||||
def _naive_find_text(*, pdf, page):
|
||||
if not(page.get('/Type') == '/Page' and '/Contents' in page):
|
||||
# Not a page, or has no /Contents => no text
|
||||
return False
|
||||
|
||||
# First we check the main content stream
|
||||
contentsinfo = _interpret_contents(page, UNIT_SQUARE)
|
||||
if contentsinfo.found_text:
|
||||
return True
|
||||
|
||||
# Then see if there is a Form XObject with with a content stream
|
||||
# that might have text. For full completeness we should recursively
|
||||
# search nested Form XObjects, as we do with images. But that is
|
||||
# rare.
|
||||
if '/Resources' in page:
|
||||
resources = page['/Resources']
|
||||
if '/XObject' in resources:
|
||||
xobjs = resources['/XObject'].as_dict()
|
||||
for xobj in xobjs:
|
||||
candidate = xobjs[xobj]
|
||||
if candidate['/Subtype'] != '/Form':
|
||||
continue
|
||||
form_xobject = candidate
|
||||
# Content stream is attached to Form XObject dictionary
|
||||
sub_contentsinfo = _interpret_contents(
|
||||
form_xobject, UNIT_SQUARE)
|
||||
if sub_contentsinfo.found_text:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _page_get_textblocks(infile, pageno):
|
||||
"Smarter text detection"
|
||||
"""Smarter text detection"""
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
doc = fitz.Document(infile)
|
||||
if fitz.version[0] >= '1.13.0':
|
||||
text = doc[pageno].getText('dict')
|
||||
else:
|
||||
import json
|
||||
textjson = doc[pageno].getText('json')
|
||||
text = json.loads(textjson)
|
||||
if not text:
|
||||
return
|
||||
gstext = ghostscript.extract_text(infile, pageno+1)
|
||||
root = ET.fromstring(gstext)
|
||||
|
||||
text['blocks'] = [blk for blk in text['blocks'] if blk['type'] == 0]
|
||||
return text
|
||||
def blocks():
|
||||
for span in root.findall('.//span'):
|
||||
bbox_str = span.attrib['bbox']
|
||||
font_size = span.attrib['size']
|
||||
pts = [int(pt) for pt in bbox_str.split()]
|
||||
pts[1] = pts[1] - int(float(font_size) + 0.5)
|
||||
bbox = tuple(pts)
|
||||
yield bbox
|
||||
|
||||
def joined_blocks():
|
||||
prev = None
|
||||
for bbox in blocks():
|
||||
if prev is None:
|
||||
prev = bbox
|
||||
if bbox[1] == prev[1] and bbox[3] == prev[3]:
|
||||
gap = prev[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
if gap < height:
|
||||
# Join boxes
|
||||
prev = (prev[0], prev[1], bbox[2], bbox[3])
|
||||
continue
|
||||
# yield previously joined bboxes and start anew
|
||||
yield prev
|
||||
prev = bbox
|
||||
if prev is not None:
|
||||
yield prev
|
||||
|
||||
return [block for block in joined_blocks()]
|
||||
|
||||
|
||||
def _page_has_text(text):
|
||||
"Smarter text detection that ignores text in margins"
|
||||
def _page_has_text(text_blocks, page_width, page_height):
|
||||
"""Smarter text detection that ignores text in margins"""
|
||||
|
||||
pw, ph = text['width'], text['height']
|
||||
pw, ph = float(page_width), float(page_height)
|
||||
|
||||
margin_ratio = 0.125
|
||||
interior_bbox = fitz.Rect(
|
||||
interior_bbox = (
|
||||
margin_ratio * pw, margin_ratio * ph,
|
||||
(1 - margin_ratio) * pw, (1 - margin_ratio) * ph
|
||||
)
|
||||
|
||||
def rects_intersect(a, b):
|
||||
"""
|
||||
Where (a,b) are 4-tuple rects (left-0, top-1, right-2, bottom-3)
|
||||
https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other
|
||||
Negative signs to account for our coordinates being in the fourth quadrant
|
||||
and the formula assuming the first
|
||||
"""
|
||||
return a[0] < b[2] and a[2] > b[0] and -a[1] > -b[3] and -a[3] < -b[1]
|
||||
|
||||
has_text = False
|
||||
for block in text['blocks']:
|
||||
bbox = fitz.Rect(block['bbox'])
|
||||
if bbox & interior_bbox:
|
||||
for bbox in text_blocks:
|
||||
if rects_intersect(bbox, interior_bbox):
|
||||
has_text = True
|
||||
break
|
||||
return has_text
|
||||
|
||||
|
||||
@@ -577,16 +575,15 @@ def _pdf_get_pageinfo(pdf, pageno: int, infile):
|
||||
|
||||
page = pdf.pages[pageno]
|
||||
|
||||
if fitz:
|
||||
pageinfo['textinfo'] = _page_get_textblocks(str(infile), pageno)
|
||||
pageinfo['has_text'] = _page_has_text(pageinfo['textinfo'])
|
||||
else:
|
||||
pageinfo['has_text'] = _naive_find_text(pdf=pdf, page=page)
|
||||
pageinfo['textinfo'] = _page_get_textblocks(str(infile), pageno)
|
||||
|
||||
mediabox = [Decimal(d) for d in page.MediaBox.as_list()]
|
||||
width_pt = mediabox[2] - mediabox[0]
|
||||
height_pt = mediabox[3] - mediabox[1]
|
||||
|
||||
pageinfo['has_text'] = _page_has_text(
|
||||
pageinfo['textinfo'], width_pt, height_pt)
|
||||
|
||||
userunit = page.get('/UserUnit', Decimal(1.0))
|
||||
if not isinstance(userunit, Decimal):
|
||||
userunit = Decimal(userunit)
|
||||
@@ -666,9 +663,7 @@ class PageInfo:
|
||||
return self._pageinfo['images']
|
||||
|
||||
def get_textareas(self):
|
||||
if not fitz:
|
||||
raise NotImplementedError("no impl without fitz")
|
||||
yield from self._pageinfo['textinfo']['blocks']
|
||||
yield from self._pageinfo['textinfo']
|
||||
|
||||
@property
|
||||
def xres(self):
|
||||
@@ -706,10 +701,6 @@ class PdfInfo:
|
||||
def __init__(self, infile):
|
||||
self._infile = infile
|
||||
self._pages = _pdf_get_all_pageinfo(infile)
|
||||
if fitz:
|
||||
self._toc = fitz.Document(fspath(infile)).getToC()
|
||||
else:
|
||||
self._toc = []
|
||||
|
||||
@property
|
||||
def pages(self):
|
||||
@@ -730,10 +721,6 @@ class PdfInfo:
|
||||
raise NotImplementedError("can't get filename from stream")
|
||||
return self._infile
|
||||
|
||||
@property
|
||||
def table_of_contents(self):
|
||||
return self._toc
|
||||
|
||||
def __getitem__(self, item):
|
||||
return self._pages[item]
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ from .pdfinfo import PdfInfo, Encoding, Colorspace
|
||||
from .pdfa import generate_pdfa_ps, encode_pdf_date
|
||||
from .helpers import re_symlink, is_iterable_notstr, page_number, flatten_groups
|
||||
from .exec import ghostscript, tesseract, qpdf
|
||||
from .lib import fitz
|
||||
from .exceptions import PdfMergeFailedError, UnsupportedImageFormatError, \
|
||||
DpiError, PriorOcrFoundError, InputFileError
|
||||
from . import leptonica
|
||||
@@ -526,7 +525,7 @@ def select_ocr_image(
|
||||
user."""
|
||||
|
||||
image = infiles[0]
|
||||
if not fitz or context.get_options().force_ocr:
|
||||
if context.get_options().force_ocr:
|
||||
re_symlink(image, output_file, log)
|
||||
return
|
||||
|
||||
@@ -537,16 +536,16 @@ def select_ocr_image(
|
||||
from PIL import ImageDraw
|
||||
from decimal import Decimal
|
||||
white = ImageColor.getcolor('#ffffff', im.mode)
|
||||
#pink = ImageColor.getcolor('#ff0080', im.mode)
|
||||
draw = ImageDraw.ImageDraw(im)
|
||||
|
||||
xres, yres = im.info['dpi']
|
||||
log.debug('resolution %r %r', xres, yres)
|
||||
for textarea in pageinfo.get_textareas():
|
||||
# Calculate resolution based on the image size and page dimensions
|
||||
# without regard whatever resolution is in pageinfo (may differ or
|
||||
# be None)
|
||||
bbox = textarea['bbox']
|
||||
log.debug('resolution %r %r', xres, yres)
|
||||
|
||||
bbox = textarea
|
||||
pixcoords = [Decimal(bbox[0]) / Decimal(72) * xres,
|
||||
Decimal(bbox[1]) / Decimal(72) * yres,
|
||||
Decimal(bbox[2]) / Decimal(72) * xres,
|
||||
@@ -554,6 +553,7 @@ def select_ocr_image(
|
||||
pixcoords = [int(c) for c in pixcoords]
|
||||
log.debug('blanking %r', pixcoords)
|
||||
draw.rectangle(pixcoords, fill=white)
|
||||
#draw.rectangle(pixcoords, outline=pink)
|
||||
|
||||
del draw
|
||||
|
||||
|
||||
Reference in New Issue
Block a user