From 3957a0606c48ebe7ca760c3255f81c7afb2b63bb Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 26 Feb 2016 18:19:39 -0800 Subject: [PATCH 1/3] Compute image pixel density without performing rectangle intersection (+5 squashed commits) Squashed commits: [0e27904] Partially implement DPI calculation with rotation of the image Fixes test suite [a64f662] pageinfo: all tests pass [c5b811a] Fix typos [cdd2286] Can now find inline images for efficiently [60dde8d] First cut at implementing intelligent DPI detection based on content stream Broke many of the test cases --- ocrmypdf/pageinfo.py | 154 +++++++++++++++++++++++++++++++++-------- tests/test_pageinfo.py | 5 +- 2 files changed, 130 insertions(+), 29 deletions(-) diff --git a/ocrmypdf/pageinfo.py b/ocrmypdf/pageinfo.py index 340d9f22..b9f93dc0 100644 --- a/ocrmypdf/pageinfo.py +++ b/ocrmypdf/pageinfo.py @@ -6,7 +6,9 @@ from decimal import Decimal, getcontext import re import sys import PyPDF2 as pypdf +from collections import namedtuple +matrix_mult = pypdf.pdf.utils.matrixMultiply FRIENDLY_COLORSPACE = { '/DeviceGray': 'gray', @@ -38,32 +40,66 @@ FRIENDLY_COMP = { } -def _page_has_inline_images(page): - # PDF always uses \r\n for separator regardless of platform - # Really basic heuristic that might trigger the odd false positive - # This is only finds the first image and is not quite spec compliant - try: - contents = page.getContents() - data = contents.getData() - except AttributeError: - # If we can't access the contents or data (empty page?) then there - # are no inline images - return False +def _matrix_from_shorthand(shorthand): + """Convert from PDF matrix shorthand to full matrix - begin_image, image_data, end_image = False, False, False - for data in re.split(b'\s+', data): - if data == b'BI': - begin_image = True - elif data == b'ID': - image_data = True - elif data == b'EI': - end_image = True - if all((begin_image, image_data, end_image)): - return True - return False + PDF 1.7 spec defines a shorthand for describing the entries of a matrix + since the last column is always (0, 0, 1). + """ + + a, b, c, d, e, f = map(float, shorthand) + return ((a, b, 0), + (c, d, 0), + (e, f, 1)) -def _find_page_images(page, pageinfo): +def _shorthand_from_matrix(matrix): + """Convert from transformation matrix to PDF shorthand.""" + a, b = matrix[0][0], matrix[0][1] + c, d = matrix[1][0], matrix[1][1] + e, f = matrix[2][0], matrix[2][1] + return tuple(map(float, (a, b, c, d, e, f))) + + +def euclidean_distance(rowvec1, rowvec2): + return ((rowvec1[0] - rowvec2[0]) ** 2 + + (rowvec1[1] - rowvec2[1]) ** 2) ** 0.5 + + +ContentsInfo = namedtuple('ContentsInfo', + ['raster_settings', 'has_inline_images']) + +def _interpret_contents(contentstream): + operations = contentstream.operations + stack = [] + ctm = _matrix_from_shorthand((1, 0, 0, 1, 0, 0)) + image_raster_settings = [] + has_inline_images = False + + print(operations) + for op in operations: + operands, command = op + if command == b'q': + stack.append(ctm) + elif command == b'Q': + ctm = stack.pop() + elif command == b'cm': + ctm = matrix_mult( + ctm, _matrix_from_shorthand(operands)) + elif command == b'Do': + image_name = operands[0] + image_raster_settings.append( + (image_name, _shorthand_from_matrix(ctm))) + elif command == b'INLINE IMAGE': + # {'settings': {'/BPC': 8, '/H': 8, '/CS': '/RGB', '/F': ['/A85', '/Fl'], '/W': 8}, 'data': b'...'}, + has_inline_images = True + + return ContentsInfo( + raster_settings=image_raster_settings, + has_inline_images=has_inline_images) + + +def _find_page_images(page, pageinfo, contentsinfo): try: page['/Resources']['/XObject'] except KeyError: @@ -79,6 +115,7 @@ def _find_page_images(page, pageinfo): if pdfimage['/ImageMask']: continue image = {} + image['name'] = str(xobj) image['width'] = pdfimage['/Width'] image['height'] = pdfimage['/Height'] image['bpc'] = pdfimage['/BitsPerComponent'] @@ -98,8 +135,62 @@ def _find_page_images(page, pageinfo): image['color'] = 'jpx' if image['enc'] == 'jpx' else '?' image['comp'] = FRIENDLY_COMP.get(image['color'], '?') - image['dpi_w'] = image['width'] / pageinfo['width_inches'] - image['dpi_h'] = image['height'] / pageinfo['height_inches'] + image['dpi_w'] = image['dpi_h'] = 0 + + for raster in contentsinfo.raster_settings: + # Loop in case the same image is display multiple times on a page + if raster[0] != image['name']: + continue + shorthand = raster[1] + matrix = _matrix_from_shorthand(shorthand) + + # Corners of the image in untranslated square image space; last + # column is a dummy + corners = [[0, 0, 1], + [1, 0, 1], + [0, 1, 1], + [1, 1, 1]] + + # Rotate/translate/scale the corners into PDF coords (1/72") + # ordering of points may change, e.g. if rotation is 180 then + # the point (0, 0) may become the top right + # The row vectors can all be transformed together here by building + # a matrix of them + page_unit_corners = matrix_mult(corners, matrix) + print(matrix) + print(page_unit_corners) + + # Calculate the width and height of the rotated image + # the transformation matrix so the corner that was originally + # (1, 1) can be ignored + image_drawn_width = euclidean_distance( + page_unit_corners[0], page_unit_corners[1]) + image_drawn_height = euclidean_distance( + page_unit_corners[0], page_unit_corners[2]) + + print((image_drawn_width, image_drawn_height)) + + # The scale of the image is pixels per PDF unit (1/72") + scale_w = image['width'] / image_drawn_width + scale_h = image['height'] / image_drawn_height + + # DPI = scale * 72 + dpi_w = scale_w * 72.0 + dpi_h = scale_h * 72.0 + + print((dpi_w, dpi_h)) + + # If the image is drawn skewed or rotated analyzing its actual + # bounding box is a bit more of headache. This is allowed, but + # rare. + if shorthand[1] != 0 or shorthand[2] != 0: + print('image was rotated') + + # When image is used multiple times take the highest DPI it is + # rendered at + image['dpi_w'] = Decimal(max(dpi_w, image.get('dpi_w', 0))) + image['dpi_h'] = Decimal(max(dpi_h, image.get('dpi_h', 0))) + image['dpi'] = (image['dpi_w'] * image['dpi_h']) ** Decimal(0.5) yield image @@ -141,10 +232,19 @@ def _pdf_get_pageinfo(infile, pageno: int): pageinfo['width_inches'] = width_pt / Decimal(72.0) pageinfo['height_inches'] = height_pt / Decimal(72.0) - pageinfo['images'] = [im for im in _find_page_images(page, pageinfo)] + try: + contentstream = pypdf.pdf.ContentStream(page.getContents(), pdf) + except AttributeError as e: + return pageinfo + + contentsinfo = _interpret_contents(contentstream) + + + pageinfo['images'] = [im for im in _find_page_images( + page, pageinfo, contentsinfo)] # Look for inline images - if _page_has_inline_images(page): + if contentsinfo.has_inline_images: raise NotImplementedError( "Warning: input PDF contains inline images - not supported") diff --git a/tests/test_pageinfo.py b/tests/test_pageinfo.py index 8968c322..7615a268 100644 --- a/tests/test_pageinfo.py +++ b/tests/test_pageinfo.py @@ -103,8 +103,8 @@ def test_single_page_image(): assert pdfimage['bpc'] == 8 # DPI in a 1"x1" is the image width - assert pdfimage['dpi_w'] == 8 - assert pdfimage['dpi_h'] == 8 + assert abs(pdfimage['dpi_w'] - 8) < 1e-5 + assert abs(pdfimage['dpi_h'] - 8) < 1e-5 def test_single_page_inline_image(): @@ -131,4 +131,5 @@ def test_jpeg(): pdfimage = pdfinfo[0]['images'][0] assert pdfimage['enc'] == 'jpeg' + assert (pdfimage['dpi_w'] - 150) < 1e-5 From 5cc3adb39a552f2ef028ac43b9a6d3386596f0e7 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 26 Feb 2016 22:44:28 -0800 Subject: [PATCH 2/3] Add support for inline images --- ocrmypdf/pageinfo.py | 130 +++++++++++++++++++++++------------------ tests/test_pageinfo.py | 6 +- 2 files changed, 78 insertions(+), 58 deletions(-) diff --git a/ocrmypdf/pageinfo.py b/ocrmypdf/pageinfo.py index b9f93dc0..42befb68 100644 --- a/ocrmypdf/pageinfo.py +++ b/ocrmypdf/pageinfo.py @@ -67,14 +67,14 @@ def euclidean_distance(rowvec1, rowvec2): ContentsInfo = namedtuple('ContentsInfo', - ['raster_settings', 'has_inline_images']) + ['raster_settings', 'inline_images']) def _interpret_contents(contentstream): operations = contentstream.operations stack = [] ctm = _matrix_from_shorthand((1, 0, 0, 1, 0, 0)) image_raster_settings = [] - has_inline_images = False + inline_images = [] print(operations) for op in operations: @@ -91,21 +91,85 @@ def _interpret_contents(contentstream): image_raster_settings.append( (image_name, _shorthand_from_matrix(ctm))) elif command == b'INLINE IMAGE': - # {'settings': {'/BPC': 8, '/H': 8, '/CS': '/RGB', '/F': ['/A85', '/Fl'], '/W': 8}, 'data': b'...'}, - has_inline_images = True + settings = operands['settings'] + inline_images.append( + (settings, _shorthand_from_matrix(ctm))) return ContentsInfo( raster_settings=image_raster_settings, - has_inline_images=has_inline_images) + inline_images=inline_images) + + +def _get_dpi(ctm_shorthand, image_size): + matrix = _matrix_from_shorthand(ctm_shorthand) + + # Corners of the image in untranslated square image space; last + # column is a dummy + corners = [[0, 0, 1], + [1, 0, 1], + [0, 1, 1], + [1, 1, 1]] + + # Rotate/translate/scale the corners into PDF coords (1/72") + # ordering of points may change, e.g. if rotation is 180 then + # the point (0, 0) may become the top right + # The row vectors can all be transformed together here by building + # a matrix of them + page_unit_corners = matrix_mult(corners, matrix) + print(matrix) + print(page_unit_corners) + + # Calculate the width and height of the rotated image + # the transformation matrix so the corner that was originally + # (1, 1) can be ignored + image_drawn_width = euclidean_distance( + page_unit_corners[0], page_unit_corners[1]) + image_drawn_height = euclidean_distance( + page_unit_corners[0], page_unit_corners[2]) + + print((image_drawn_width, image_drawn_height)) + + # The scale of the image is pixels per PDF unit (1/72") + scale_w = image_size[0] / image_drawn_width + scale_h = image_size[1] / image_drawn_height + + # DPI = scale * 72 + dpi_w = scale_w * 72.0 + dpi_h = scale_h * 72.0 + + print((dpi_w, dpi_h)) + + # If the image is drawn skewed or rotated analyzing its actual + # bounding box is a bit more of headache. This is allowed, but + # rare. + if ctm_shorthand[1] != 0 or ctm_shorthand[2] != 0: + print('image was rotated') + + return (dpi_w, dpi_h) def _find_page_images(page, pageinfo, contentsinfo): + + for n, im in enumerate(contentsinfo.inline_images): + print(n) + settings, shorthand = im + image = {} + image['name'] = str('inline-%02d' % n) + image['width'] = settings['/W'] + image['height'] = settings['/H'] + image['bpc'] = settings['/BPC'] + image['color'] = FRIENDLY_COLORSPACE.get(settings['/CS'], '-') + image['comp'] = FRIENDLY_COMP.get(image['color'], '?') + + dpi_w, dpi_h = _get_dpi(shorthand, (image['width'], image['height'])) + image['dpi_w'], image['dpi_h'] = Decimal(dpi_w), Decimal(dpi_h) + yield image + + # Look for XObject (out of line images) try: page['/Resources']['/XObject'] except KeyError: return - - # Look for XObject (out of line images) for xobj in page['/Resources']['/XObject']: # PyPDF2 returns the keys as an iterator pdfimage = page['/Resources']['/XObject'][xobj] @@ -142,49 +206,9 @@ def _find_page_images(page, pageinfo, contentsinfo): if raster[0] != image['name']: continue shorthand = raster[1] - matrix = _matrix_from_shorthand(shorthand) - # Corners of the image in untranslated square image space; last - # column is a dummy - corners = [[0, 0, 1], - [1, 0, 1], - [0, 1, 1], - [1, 1, 1]] - - # Rotate/translate/scale the corners into PDF coords (1/72") - # ordering of points may change, e.g. if rotation is 180 then - # the point (0, 0) may become the top right - # The row vectors can all be transformed together here by building - # a matrix of them - page_unit_corners = matrix_mult(corners, matrix) - print(matrix) - print(page_unit_corners) - - # Calculate the width and height of the rotated image - # the transformation matrix so the corner that was originally - # (1, 1) can be ignored - image_drawn_width = euclidean_distance( - page_unit_corners[0], page_unit_corners[1]) - image_drawn_height = euclidean_distance( - page_unit_corners[0], page_unit_corners[2]) - - print((image_drawn_width, image_drawn_height)) - - # The scale of the image is pixels per PDF unit (1/72") - scale_w = image['width'] / image_drawn_width - scale_h = image['height'] / image_drawn_height - - # DPI = scale * 72 - dpi_w = scale_w * 72.0 - dpi_h = scale_h * 72.0 - - print((dpi_w, dpi_h)) - - # If the image is drawn skewed or rotated analyzing its actual - # bounding box is a bit more of headache. This is allowed, but - # rare. - if shorthand[1] != 0 or shorthand[2] != 0: - print('image was rotated') + dpi_w, dpi_h = _get_dpi( + shorthand, (image['width'], image['height'])) # When image is used multiple times take the highest DPI it is # rendered at @@ -238,16 +262,10 @@ def _pdf_get_pageinfo(infile, pageno: int): return pageinfo contentsinfo = _interpret_contents(contentstream) - - + print(contentsinfo) pageinfo['images'] = [im for im in _find_page_images( page, pageinfo, contentsinfo)] - # Look for inline images - if contentsinfo.has_inline_images: - raise NotImplementedError( - "Warning: input PDF contains inline images - not supported") - if pageinfo['images']: xres = max(image['dpi_w'] for image in pageinfo['images']) yres = max(image['dpi_h'] for image in pageinfo['images']) diff --git a/tests/test_pageinfo.py b/tests/test_pageinfo.py index 7615a268..dce67495 100644 --- a/tests/test_pageinfo.py +++ b/tests/test_pageinfo.py @@ -120,8 +120,10 @@ def test_single_page_inline_image(): pdf.showPage() pdf.save() - with pytest.raises(NotImplementedError): - pageinfo.pdf_get_all_pageinfo(filename) + pdfinfo = pageinfo.pdf_get_all_pageinfo(filename) + print(pdfinfo) + pdfimage = pdfinfo[0]['images'][0] + assert (pdfimage['dpi_w'] - 8) < 1e-5 def test_jpeg(): From 570bbe9a0532c9eadafdc7e23c62583147293f1b Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Fri, 26 Feb 2016 23:02:12 -0800 Subject: [PATCH 3/3] Add comments and remove debugging, improve inline handling Squashed commits: [bfff3c9] pageinfo, have a main() --- ocrmypdf/pageinfo.py | 79 +++++++++++++++++++++++++++++++++--------- tests/test_pageinfo.py | 2 ++ 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/ocrmypdf/pageinfo.py b/ocrmypdf/pageinfo.py index 42befb68..a3f05c31 100644 --- a/ocrmypdf/pageinfo.py +++ b/ocrmypdf/pageinfo.py @@ -21,7 +21,11 @@ FRIENDLY_COLORSPACE = { '/Indexed': 'index', '/Separation': 'sep', '/DeviceN': 'devn', - '/Pattern': '-' + '/Pattern': '-', + '/G': 'gray', # Abbreviations permitted in inline images + '/RGB': 'rgb', + '/CMYK': 'cmyk', + '/I': 'index', } FRIENDLY_ENCODING = { @@ -29,6 +33,8 @@ FRIENDLY_ENCODING = { '/DCTDecode': 'jpeg', '/JPXDecode': 'jpx', '/JBIG2Decode': 'jbig2', + '/CCF': 'ccitt', # Abbreviations permitted in inline images + '/DCT': 'jpeg' } FRIENDLY_COMP = { @@ -101,10 +107,45 @@ def _interpret_contents(contentstream): def _get_dpi(ctm_shorthand, image_size): + """Given the transformation matrix and image size, find the image DPI. + + PDFs do not include image resolution information within image data. + Instead, the PDF page content stream describes the location where the + image will be rasterized, and the effective resolution is the ratio of the + pixel size to raster target size. + + Normally a scanned PDF has the paper size set appropriately but this is + not guaranteed. The most common case is a cropped image will change the + page size (/CropBox) without altering the page content stream. That means + it is not sufficient to assume that the image fills the page, even though + that is the most common case. + + This code solves the general case where the image may be scaled (always), + cropped, translated (often), and rotated in place (occasionally) to an + arbitrary angle (rare). It will work as long as the image is a + parallelogram from the perspective of a rectilinear coordinate system. + It does not work for arbitrarily quadrilaterals that might be produced + by shearing, but by that point DPI becomes a linear gradient rather than + constant over the image. + + The transformation matrix describes the coordinate system at the time of + rendering. We transform the image corner locations into the coordinate + system and measure the width and height within the system, expressed in + PDF units. From there we can compare to the actual image dimensions. + + pdfimages -list does calculate the DPI in some way that is not completely + naive, but it does not the DPI of rotated images right, so cannot be + used anymore to validate this. Photoshop works, or using Acrobat to + rotate the image back to normal. + + It does not matter if the image is partially cropped, or even out of the + /MediaBox. + + """ matrix = _matrix_from_shorthand(ctm_shorthand) - # Corners of the image in untranslated square image space; last - # column is a dummy + # Corners of the image in untransformed unit space; last + # column is a dummy to assist matrix math corners = [[0, 0, 1], [1, 0, 1], [0, 1, 1], @@ -116,8 +157,6 @@ def _get_dpi(ctm_shorthand, image_size): # The row vectors can all be transformed together here by building # a matrix of them page_unit_corners = matrix_mult(corners, matrix) - print(matrix) - print(page_unit_corners) # Calculate the width and height of the rotated image # the transformation matrix so the corner that was originally @@ -127,7 +166,7 @@ def _get_dpi(ctm_shorthand, image_size): image_drawn_height = euclidean_distance( page_unit_corners[0], page_unit_corners[2]) - print((image_drawn_width, image_drawn_height)) + # print((image_drawn_width, image_drawn_height)) # The scale of the image is pixels per PDF unit (1/72") scale_w = image_size[0] / image_drawn_width @@ -137,14 +176,6 @@ def _get_dpi(ctm_shorthand, image_size): dpi_w = scale_w * 72.0 dpi_h = scale_h * 72.0 - print((dpi_w, dpi_h)) - - # If the image is drawn skewed or rotated analyzing its actual - # bounding box is a bit more of headache. This is allowed, but - # rare. - if ctm_shorthand[1] != 0 or ctm_shorthand[2] != 0: - print('image was rotated') - return (dpi_w, dpi_h) @@ -212,9 +243,11 @@ def _find_page_images(page, pageinfo, contentsinfo): # When image is used multiple times take the highest DPI it is # rendered at - image['dpi_w'] = Decimal(max(dpi_w, image.get('dpi_w', 0))) - image['dpi_h'] = Decimal(max(dpi_h, image.get('dpi_h', 0))) + image['dpi_w'] = max(dpi_w, image.get('dpi_w', 0)) + image['dpi_h'] = max(dpi_h, image.get('dpi_h', 0)) + image['dpi_w'] = Decimal(image['dpi_w']) + image['dpi_h'] = Decimal(image['dpi_h']) image['dpi'] = (image['dpi_w'] * image['dpi_h']) ** Decimal(0.5) yield image @@ -282,3 +315,17 @@ def pdf_get_all_pageinfo(infile): pdf = pypdf.PdfFileReader(infile) getcontext().prec = 6 return [_pdf_get_pageinfo(infile, n) for n in range(pdf.numPages)] + + +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument('infile') + args = parser.parse_args() + info = pdf_get_all_pageinfo(args.infile) + from pprint import pprint + pprint(info) + + +if __name__ == '__main__': + main() diff --git a/tests/test_pageinfo.py b/tests/test_pageinfo.py index dce67495..efff7da7 100644 --- a/tests/test_pageinfo.py +++ b/tests/test_pageinfo.py @@ -124,6 +124,8 @@ def test_single_page_inline_image(): print(pdfinfo) pdfimage = pdfinfo[0]['images'][0] assert (pdfimage['dpi_w'] - 8) < 1e-5 + assert pdfimage['color'] != '-' + assert pdfimage['width'] == 8 def test_jpeg():