#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# © 2013-16: jbarlow83 from Github (https://github.com/jbarlow83)
#
# This file is part of OCRmyPDF.
#
# OCRmyPDF is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# OCRmyPDF is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with OCRmyPDF.  If not, see <http://www.gnu.org/licenses/>.
#
# Python FFI wrapper for Leptonica library

import argparse
import logging
import os
import sys
import threading
import warnings
from collections import deque
from collections.abc import Sequence
from contextlib import suppress
from ctypes.util import find_library
from functools import lru_cache
from io import BytesIO, UnsupportedOperation
from os import fspath
from tempfile import TemporaryFile

from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.lib._leptonica import ffi
from ocrmypdf.subprocess import shim_paths_with_program_files

# pylint: disable=protected-access

logger = logging.getLogger(__name__)

if os.name == 'nt':
    libname = 'liblept-5'
    os.environ['PATH'] = shim_paths_with_program_files()
else:
    libname = 'lept'
_libpath = find_library(libname)
if not _libpath:
    raise MissingDependencyError(
        """
        ---------------------------------------------------------------------
        This error normally occurs when ocrmypdf can't find the Leptonica
        library, which is usually installed with Tesseract OCR. It could be that
        Tesseract is not installed properly, we can't find the installation
        on your system PATH environment variable.

        The library we are looking for is usually called:
            liblept-5.dll   (Windows)
            liblept*.dylib  (macOS)
            liblept*.so     (Linux/BSD)

        Please review our installation procedures to find a solution:
            https://ocrmypdf.readthedocs.io/en/latest/installation.html
        ---------------------------------------------------------------------
        """
    )
try:
    lept = ffi.dlopen(_libpath)
    lept.setMsgSeverity(lept.L_SEVERITY_WARNING)
except ffi.error as e:
    raise MissingDependencyError(
        f"Leptonica library found at {_libpath}, but we could not access it"
    ) from e


class _LeptonicaErrorTrap_Redirect:
    """
    Context manager to trap errors reported by Leptonica.

    Leptonica's error return codes don't provide much informatino about what
    went wrong. Leptonica does, however, write more detailed errors to stderr
    (provided this is not disabled at compile time). The Leptonica source
    code is very consistent in its use of macros to generate errors.

    This context manager redirects stderr to a temporary file which is then
    read and parsed for error messages.  As a side benefit, debug messages
    from Leptonica are also suppressed.

    """

    leptonica_lock = threading.Lock()

    def __init__(self):
        self.tmpfile = None
        self.copy_of_stderr = -1
        self.no_stderr = False

    def __enter__(self):
        self.tmpfile = TemporaryFile()

        # Save the old stderr, and redirect stderr to temporary file
        self.leptonica_lock.acquire()
        try:
            with suppress(AttributeError):
                sys.stderr.flush()
            self.copy_of_stderr = os.dup(sys.stderr.fileno())
            os.dup2(self.tmpfile.fileno(), sys.stderr.fileno(), inheritable=False)
        except AttributeError:
            # We are in some unusual context where our Python process does not
            # have a sys.stderr. Leptonica still expects to write to file
            # descriptor 2, so we are going to ensure it is redirected.
            self.copy_of_stderr = None
            self.no_stderr = True
            os.dup2(self.tmpfile.fileno(), 2, inheritable=False)
        except UnsupportedOperation:
            self.copy_of_stderr = None
        except Exception:
            self.leptonica_lock.release()
            raise
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        # Restore old stderr
        with suppress(AttributeError):
            sys.stderr.flush()

        if self.copy_of_stderr is not None:
            os.dup2(self.copy_of_stderr, sys.stderr.fileno())
            os.close(self.copy_of_stderr)
        if self.no_stderr:
            os.close(2)

        # Get data from tmpfile
        self.tmpfile.seek(0)  # Cursor will be at end, so move back to beginning
        leptonica_output = self.tmpfile.read().decode(errors='replace')
        self.tmpfile.close()
        self.leptonica_lock.release()

        # If there are Python errors, record them
        if exc_type:
            logger.warning(leptonica_output)

        # If there are Leptonica errors, wrap them in Python excpetions
        if 'Error' in leptonica_output:
            if 'image file not found' in leptonica_output:
                raise FileNotFoundError()
            if 'pixWrite: stream not opened' in leptonica_output:
                raise LeptonicaIOError()
            if 'index not valid' in leptonica_output:
                raise IndexError()
            raise LeptonicaError(leptonica_output)

        return False


tls = threading.local()
tls.trap = None


@ffi.callback("void(char *)")
def _stderr_handler(cstr):
    msg = ffi.string(cstr).decode(errors='replace')
    if msg.startswith("Error"):
        logger.error(msg)
    elif msg.startswith("Warning"):
        logger.warning(msg)
    else:
        logger.debug(msg)
    if tls.trap is not None:
        tls.trap.append(msg)
    return


class _LeptonicaErrorTrap_Queue:
    def __init__(self):
        self.queue = deque()

    def __enter__(self):
        self.queue.clear()
        tls.trap = self.queue

    def __exit__(self, exc_type, exc_value, traceback):
        tls.trap = None
        output = ''.join(self.queue)
        self.queue.clear()

        # If there are Python errors, record them
        if exc_type:
            logger.warning(output)

        if 'Error' in output:
            if 'image file not found' in output:
                raise FileNotFoundError()
            if 'pixWrite: stream not opened' in output:
                raise LeptonicaIOError()
            if 'index not valid' in output:
                raise IndexError()
            raise LeptonicaError(output)
        return False


try:
    lept.leptSetStderrHandler(_stderr_handler)
except ffi.error:
    # Pre-1.79 Leptonica does not have leptSetStderrHandler
    _LeptonicaErrorTrap = _LeptonicaErrorTrap_Redirect
else:
    # 1.79 have this new symbol
    _LeptonicaErrorTrap = _LeptonicaErrorTrap_Queue


class LeptonicaError(Exception):
    pass


class LeptonicaIOError(LeptonicaError):
    pass


class LeptonicaObject:
    """General wrapper for Leptonica objects

    When Leptonica returns an object, we bundled it in a wrapper class, which
    manages its memory. The wrapper class assumes that it will be calling some
    sort of lept.thingDestroy() function when the instance is deleted. Most
    Leptonica objects are reference counted, and destroy decrements the
    refcount.

    Most of the time, when Leptonica returns something, we wrap and it the job
    is done. When wrapping objects that came from a Leptonica container, like
    a PIXA returning PIX, the subclass must clone the object before passing it
    here, to maintain the reference count.

    CFFI ensures that the destroy function is called at garbage collection time
    so we do not need to mess with __del__.
    """

    cdata_destroy = lambda cdata: None
    LEPTONICA_TYPENAME = ''

    def __init__(self, cdata):
        if not cdata:
            raise ValueError('Tried to wrap a NULL ' + self.LEPTONICA_TYPENAME)
        self._cdata = ffi.gc(cdata, self._destroy)

    @classmethod
    def _destroy(cls, cdata):
        """Destroy some cdata"""
        # Leptonica API uses double-pointers for its destroy APIs to prevent
        # dangling pointers. This means we need to put our single pointer,
        # cdata, in a temporary CDATA**.
        pp = ffi.new('{} **'.format(cls.LEPTONICA_TYPENAME), cdata)
        cls.cdata_destroy(pp)


class Pix(LeptonicaObject):
    """
    Wrapper around leptonica's PIX object.

    Leptonica uses referencing counting on PIX objects. Also, many Leptonica
    functions return the original object with an increased reference count
    if the operation had no effect (for example, image skew was found to be 0).
    This has complications for memory management in Python. Whenever Leptonica
    returns a PIX object (new or old), we wrap it in this class, which
    registers it with the FFI garbage collector. pixDestroy() decrements the
    reference count and only destroys when the last reference is removed.

    Leptonica's reference counting is not threadsafe. This class can be used
    in a threadsafe manner if a Python threading.Lock protects the data.

    This class treats Pix objects as immutable.  All methods return new
    modified objects.  This allows convenient chaining:

    >>>   Pix.open('filename.jpg').scale((0.5, 0.5)).deskew().show()
    """

    LEPTONICA_TYPENAME = "PIX"
    cdata_destroy = lept.pixDestroy

    def __repr__(self):
        if self._cdata:
            s = "<leptonica.Pix image size={0}x{1} depth={2}{4} at 0x{3:x}>"
            return s.format(
                self._cdata.w,
                self._cdata.h,
                self._cdata.d,
                int(ffi.cast('intptr_t', self._cdata)),
                '(colormapped)' if self._cdata.colormap else '',
            )
        else:
            return "<leptonica.Pix image NULL>"

    def _repr_png_(self):
        """iPython display hook

        returns png version of image
        """

        data = ffi.new('l_uint8 **')
        size = ffi.new('size_t *')

        err = lept.pixWriteMemPng(data, size, self._cdata, 0)
        if err != 0:
            raise LeptonicaIOError("pixWriteMemPng")

        char_data = ffi.cast('char *', data[0])
        return ffi.buffer(char_data, size[0])[:]

    def __getstate__(self):
        data = ffi.new('l_uint32 **')
        size = ffi.new('size_t *')

        err = lept.pixSerializeToMemory(self._cdata, data, size)
        if err != 0:
            raise LeptonicaIOError("pixSerializeToMemory")

        char_data = ffi.cast('char *', data[0])

        # Copy from C bytes to python bytes()
        data_bytes = ffi.buffer(char_data, size[0])[:]

        # Can now free C bytes
        lept.lept_free(char_data)
        return dict(data=data_bytes)

    def __setstate__(self, state):
        cdata_bytes = ffi.new('char[]', state['data'])
        cdata_uint32 = ffi.cast('l_uint32 *', cdata_bytes)

        pix = lept.pixDeserializeFromMemory(cdata_uint32, len(state['data']))
        Pix.__init__(self, pix)

    def __eq__(self, other):
        if not isinstance(other, Pix):
            return NotImplemented
        same = ffi.new('l_int32 *', 0)
        with _LeptonicaErrorTrap():
            err = lept.pixEqual(self._cdata, other._cdata, same)
            if err:
                raise TypeError()
        return bool(same[0])

    @property
    def width(self):
        return self._cdata.w

    @property
    def height(self):
        return self._cdata.h

    @property
    def depth(self):
        return self._cdata.d

    @property
    def size(self):
        return (self._cdata.w, self._cdata.h)

    @property
    def info(self):
        return {'dpi': (self._cdata.xres, self._cdata.yres)}

    @property
    def mode(self):
        "Return mode like PIL.Image"
        if self.depth == 1:
            return '1'
        elif self.depth >= 16:
            return 'RGB'
        elif not self._cdata.colormap:
            return 'L'
        else:
            return 'P'

    @classmethod
    def read(cls, path):
        warnings.warn('Use Pix.open() instead', DeprecationWarning)
        return cls.open(path)

    @classmethod
    def open(cls, path):
        """Load an image file into a PIX object.

        Leptonica can load TIFF, PNM (PBM, PGM, PPM), PNG, and JPEG.  If
        loading fails then the object will wrap a C null pointer.
        """
        with open(path, 'rb') as py_file:
            data = py_file.read()
            buffer = ffi.from_buffer(data)
            with _LeptonicaErrorTrap():
                return cls(lept.pixReadMem(buffer, len(buffer)))

    def write_implied_format(self, path, jpeg_quality=0, jpeg_progressive=0):
        """Write pix to the filename, with the extension indicating format.

        jpeg_quality -- quality (iff JPEG; 1 - 100, 0 for default)
        jpeg_progressive -- (iff JPEG; 0 for baseline seq., 1 for progressive)
        """
        lept_format = lept.getImpliedFileFormat(os.fsencode(path))
        with open(path, 'wb') as py_file:
            data = ffi.new('l_uint8 **pdata')
            size = ffi.new('size_t *psize')
            with _LeptonicaErrorTrap():
                if lept_format == lept.L_JPEG_ENCODE:
                    lept.pixWriteMemJpeg(
                        data, size, self._cdata, jpeg_quality, jpeg_progressive
                    )
                else:
                    lept.pixWriteMem(data, size, self._cdata, lept_format)
            buffer = ffi.buffer(data[0], size[0])
            py_file.write(buffer)

    @classmethod
    def frompil(cls, pillow_image):
        """Create a copy of a PIL.Image from this Pix"""
        bio = BytesIO()
        pillow_image.save(bio, format='png', compress_level=1)
        py_buffer = bio.getbuffer()
        c_buffer = ffi.from_buffer(py_buffer)
        with _LeptonicaErrorTrap():
            pix = Pix(lept.pixReadMem(c_buffer, len(c_buffer)))
            return pix

    def topil(self):
        """Returns a PIL.Image version of this Pix"""
        from PIL import Image  # pylint: disable=import-outside-toplevel

        # Leptonica manages data in words, so it implicitly does an endian
        # swap.  Tell Pillow about this when it reads the data.
        pix = self
        if sys.byteorder == 'little':
            if self.mode == 'RGB':
                raw_mode = 'XBGR'
            elif self.mode == 'RGBA':
                raw_mode = 'ABGR'
            elif self.mode == '1':
                raw_mode = '1;I'
                pix = Pix(lept.pixEndianByteSwapNew(pix._cdata))
            else:
                raw_mode = self.mode
                pix = Pix(lept.pixEndianByteSwapNew(pix._cdata))
        else:
            raw_mode = self.mode  # no endian swap needed

        size = (pix._cdata.w, pix._cdata.h)
        bytecount = pix._cdata.wpl * 4 * pix._cdata.h
        buf = ffi.buffer(pix._cdata.data, bytecount)
        stride = pix._cdata.wpl * 4

        im = Image.frombytes(self.mode, size, buf, 'raw', raw_mode, stride)

        return im

    def show(self):
        return self.topil().show()

    def deskew(self, reduction_factor=0):
        """Returns the deskewed pix object.

        A clone of the original is returned when the algorithm cannot find a
        skew angle with sufficient confidence.

        reduction_factor -- amount to downsample (0 for default) when searching
            for skew angle
        """
        with _LeptonicaErrorTrap():
            return Pix(lept.pixDeskew(self._cdata, reduction_factor))

    def scale(self, scale_xy):
        "Returns the pix object rescaled according to the proportions given."
        with _LeptonicaErrorTrap():
            return Pix(lept.pixScale(self._cdata, scale_xy[0], scale_xy[1]))

    def rotate180(self):
        with _LeptonicaErrorTrap():
            return Pix(lept.pixRotate180(ffi.NULL, self._cdata))

    def rotate_orth(self, quads):
        "Orthographic rotation, quads: 0-3, number of clockwise rotations"
        with _LeptonicaErrorTrap():
            return Pix(lept.pixRotateOrth(self._cdata, quads))

    def find_skew(self):
        """Returns a tuple (deskew angle in degrees, confidence value).

        Returns (None, None) if no angle is available.
        """
        with _LeptonicaErrorTrap():
            angle = ffi.new('float *', 0.0)
            confidence = ffi.new('float *', 0.0)
            result = lept.pixFindSkew(self._cdata, angle, confidence)
            if result == 0:
                return (angle[0], confidence[0])
            else:
                return (None, None)

    def convert_rgb_to_luminance(self):
        with _LeptonicaErrorTrap():
            gray_pix = lept.pixConvertRGBToLuminance(self._cdata)
            if gray_pix:
                return Pix(gray_pix)
            return None

    def remove_colormap(self, removal_type):
        """Remove a palette (colormap); if no colormap, returns a copy of this
        image

            removal_type - any of lept.REMOVE_CMAP_*

        """
        with _LeptonicaErrorTrap():
            return Pix(
                lept.pixRemoveColormapGeneral(self._cdata, removal_type, lept.L_COPY)
            )

    def otsu_adaptive_threshold(
        self, tile_size=(300, 300), kernel_size=(4, 4), scorefract=0.1
    ):
        with _LeptonicaErrorTrap():
            sx, sy = tile_size
            smoothx, smoothy = kernel_size
            p_pix = ffi.new('PIX **')

            pix = Pix(lept.pixConvertTo8(self._cdata, 0))
            result = lept.pixOtsuAdaptiveThreshold(
                pix._cdata, sx, sy, smoothx, smoothy, scorefract, ffi.NULL, p_pix
            )
            if result == 0:
                return Pix(p_pix[0])
            else:
                return None

    def otsu_threshold_on_background_norm(
        self,
        mask=None,
        tile_size=(10, 15),
        thresh=100,
        mincount=50,
        bgval=255,
        kernel_size=(2, 2),
        scorefract=0.1,
    ):
        with _LeptonicaErrorTrap():
            sx, sy = tile_size
            smoothx, smoothy = kernel_size
            mask = ffi.NULL
            if isinstance(mask, Pix):
                mask = mask._cdata

            pix = Pix(lept.pixConvertTo8(self._cdata, 0))
            thresh_pix = lept.pixOtsuThreshOnBackgroundNorm(
                pix._cdata,
                mask,
                sx,
                sy,
                thresh,
                mincount,
                bgval,
                smoothx,
                smoothy,
                scorefract,
                ffi.NULL,
            )
            return Pix(thresh_pix)

    def masked_threshold_on_background_norm(
        self,
        mask=None,
        tile_size=(10, 15),
        thresh=100,
        mincount=50,
        kernel_size=(2, 2),
        scorefract=0.1,
    ):
        with _LeptonicaErrorTrap():
            sx, sy = tile_size
            smoothx, smoothy = kernel_size
            mask = ffi.NULL
            if isinstance(mask, Pix):
                mask = mask._cdata

            pix = Pix(lept.pixConvertTo8(self._cdata, 0))
            thresh_pix = lept.pixMaskedThreshOnBackgroundNorm(
                pix._cdata,
                mask,
                sx,
                sy,
                thresh,
                mincount,
                smoothx,
                smoothy,
                scorefract,
                ffi.NULL,
            )
            return Pix(thresh_pix)

    def crop_to_foreground(self, threshold=128, mindist=70, erasedist=30, showmorph=0):
        if get_leptonica_version() < 'leptonica-1.76':
            # Leptonica 1.76 changed the API for pixFindPageForeground; we don't
            # support the old version
            raise LeptonicaError("Not available in this version of Leptonica")
        with _LeptonicaErrorTrap():
            cropbox = Box(
                lept.pixFindPageForeground(
                    self._cdata, threshold, mindist, erasedist, showmorph, ffi.NULL
                )
            )

            cropped_pix = lept.pixClipRectangle(self._cdata, cropbox._cdata, ffi.NULL)

            return Pix(cropped_pix)

    def clean_background_to_white(
        self, mask=None, grayscale=None, gamma=1.0, black=0, white=255
    ):
        with _LeptonicaErrorTrap():
            return Pix(
                lept.pixCleanBackgroundToWhite(
                    self._cdata,
                    mask or ffi.NULL,
                    grayscale or ffi.NULL,
                    gamma,
                    black,
                    white,
                )
            )

    def gamma_trc(self, gamma=1.0, minval=0, maxval=255):
        with _LeptonicaErrorTrap():
            return Pix(lept.pixGammaTRC(ffi.NULL, self._cdata, gamma, minval, maxval))

    def background_norm(
        self,
        mask=None,
        grayscale=None,
        tile_size=(10, 15),
        fg_threshold=60,
        min_count=40,
        bg_val=200,
        smooth_kernel=(2, 1),
    ):
        # Background norm doesn't work on color mapped Pix, so remove colormap
        target_pix = self.remove_colormap(lept.REMOVE_CMAP_BASED_ON_SRC)
        with _LeptonicaErrorTrap():
            return Pix(
                lept.pixBackgroundNorm(
                    target_pix._cdata,
                    mask or ffi.NULL,
                    grayscale or ffi.NULL,
                    tile_size[0],
                    tile_size[1],
                    fg_threshold,
                    min_count,
                    bg_val,
                    smooth_kernel[0],
                    smooth_kernel[1],
                )
            )

    @staticmethod
    @lru_cache(maxsize=1)
    def make_pixel_sum_tab8():
        return lept.makePixelSumTab8()

    @staticmethod
    def correlation_binary(pix1, pix2):
        if get_leptonica_version() < 'leptonica-1.72':
            # Older versions of Leptonica (pre-1.72) have a buggy
            # implementation of pixCorrelationBinary that overflows on larger
            # images.  Ubuntu 14.04/trusty has 1.70. Ubuntu PPA
            # ppa:alex-p/tesseract-ocr has leptonlib 1.75.
            raise LeptonicaError("Leptonica version is too old")

        correlation = ffi.new('float *', 0.0)
        result = lept.pixCorrelationBinary(pix1._cdata, pix2._cdata, correlation)
        if result != 0:
            raise LeptonicaError("Correlation failed")
        return correlation[0]

    def generate_pdf_ci_data(self, type_, quality):
        "Convert to PDF data, with transcoding"
        p_compdata = ffi.new('L_COMP_DATA **')
        result = lept.pixGenerateCIData(self._cdata, type_, quality, 0, p_compdata)
        if result != 0:
            raise LeptonicaError("Generate PDF data failed")
        return CompressedData(p_compdata[0])

    def invert(self):
        return Pix(lept.pixInvert(ffi.NULL, self._cdata))

    def locate_barcodes(self):
        try:
            with _LeptonicaErrorTrap():
                pix = Pix(lept.pixConvertTo8(self._cdata, 0))
                pixa_candidates = PixArray(lept.pixExtractBarcodes(pix._cdata, 0))
                if not pixa_candidates:
                    return
                sarray = StringArray(
                    lept.pixReadBarcodes(
                        pixa_candidates._cdata,
                        lept.L_BF_ANY,
                        lept.L_USE_WIDTHS,
                        ffi.NULL,
                        0,
                    )
                )
        except (LeptonicaError, ValueError, IndexError):
            return
        finally:
            leptonica_junk = ('junkpixt.png', 'junkpixt')
            for junk in leptonica_junk:
                with suppress(FileNotFoundError):
                    os.unlink(junk)  # leptonica may produce this

        for n, s in enumerate(sarray):
            decoded = s.decode()
            if decoded.strip() == '':
                continue
            box = pixa_candidates.get_box(n)
            left, top = box.x, box.y
            right, bottom = box.x + box.w, box.y + box.h
            yield (decoded, (left, top, right, bottom))

    def despeckle(self, size):
        if size == 2:
            speckle2 = """
                oooo
                oC o
                o  o
                oooo
                """
            sel1 = Sel.from_selstr(speckle2, 'speckle2')
            sel2 = Sel.create_brick(2, 2, 0, 0, lept.SEL_HIT)
        elif size == 3:
            speckle3 = """
                ooooo
                oC  o
                o   o
                o   o
                ooooo
                """
            sel1 = Sel.from_selstr(speckle3, 'speckle3')
            sel2 = Sel.create_brick(3, 3, 0, 0, lept.SEL_HIT)
        else:
            raise ValueError(size)

        pixhmt = Pix(lept.pixHMT(ffi.NULL, self._cdata, sel1._cdata))
        pixdilated = Pix(lept.pixDilate(ffi.NULL, pixhmt._cdata, sel2._cdata))

        pixsub = Pix(lept.pixSubtract(ffi.NULL, self._cdata, pixdilated._cdata))
        return pixsub


class CompressedData(LeptonicaObject):
    """Wrapper for L_COMP_DATA - abstract compressed image data"""

    LEPTONICA_TYPENAME = 'L_COMP_DATA'
    cdata_destroy = lept.l_CIDataDestroy

    @classmethod
    def open(cls, path, jpeg_quality=75):
        "Open compressed data, without transcoding"
        filename = fspath(path)

        p_compdata = ffi.new('L_COMP_DATA **')
        result = lept.l_generateCIDataForPdf(
            os.fsencode(filename), ffi.NULL, jpeg_quality, p_compdata
        )
        if result != 0:
            raise LeptonicaError("CompressedData.open")
        return CompressedData(p_compdata[0])

    def __len__(self):
        return self._cdata.nbytescomp

    def read(self):
        buf = ffi.buffer(self._cdata.datacomp, self._cdata.nbytescomp)
        return bytes(buf)

    def __getattr__(self, name):
        if hasattr(self._cdata, name):
            return getattr(self._cdata, name)
        raise AttributeError(name)

    def get_palette_pdf_string(self):
        "Returns palette pre-formatted for use in PDF"
        buflen = len('< ') + len(' rrggbb') * self._cdata.ncolors + len('>')
        buf = ffi.buffer(self._cdata.cmapdatahex, buflen)
        return bytes(buf)


class PixArray(LeptonicaObject, Sequence):
    """Wrapper around PIXA (array of PIX)"""

    LEPTONICA_TYPENAME = 'PIXA'
    cdata_destroy = lept.pixaDestroy

    def __len__(self):
        return self._cdata[0].n

    def __getitem__(self, n):
        with _LeptonicaErrorTrap():
            return Pix(lept.pixaGetPix(self._cdata, n, lept.L_CLONE))

    def get_box(self, n):
        with _LeptonicaErrorTrap():
            return Box(lept.pixaGetBox(self._cdata, n, lept.L_CLONE))


class Box(LeptonicaObject):
    """Wrapper around Leptonica's BOX objects (a pixel rectangle)

    Uses x, y, w, h coordinates.
    """

    LEPTONICA_TYPENAME = 'BOX'
    cdata_destroy = lept.boxDestroy

    def __repr__(self):
        if self._cdata:
            return '<leptonica.Box x={0} y={1} w={2} h={3}>'.format(
                self.x, self.y, self.w, self.h
            )
        return '<leptonica.Box NULL>'

    @property
    def x(self):
        return self._cdata.x

    @property
    def y(self):
        return self._cdata.y

    @property
    def w(self):
        return self._cdata.w

    @property
    def h(self):
        return self._cdata.h


class BoxArray(LeptonicaObject, Sequence):
    """Wrapper around Leptonica's BOXA (Array of BOX) objects."""

    LEPTONICA_TYPENAME = 'BOXA'
    cdata_destroy = lept.boxaDestroy

    def __repr__(self):
        if not self._cdata:
            return '<BoxArray>'
        boxes = (repr(box) for box in self)
        return '<BoxArray [' + ', '.join(boxes) + ']>'

    def __len__(self):
        return self._cdata.n

    def __getitem__(self, n):
        if not isinstance(n, int):
            raise TypeError('list indices must be integers')
        if 0 <= n < len(self):
            return Box(lept.boxaGetBox(self._cdata, n, lept.L_CLONE))
        raise IndexError(n)


class StringArray(LeptonicaObject, Sequence):
    """Leptonica SARRAY/string array"""

    LEPTONICA_TYPENAME = 'SARRAY'
    cdata_destroy = lept.sarrayDestroy

    def __len__(self):
        return self._cdata.n

    def __getitem__(self, n):
        if 0 <= n < len(self):
            return ffi.string(self._cdata.array[n])
        raise IndexError(n)


class Sel(LeptonicaObject):
    """Leptonica 'sel'/selection element for hit-miss transform"""

    LEPTONICA_TYPENAME = 'SEL'
    cdata_destroy = lept.selDestroy

    @classmethod
    def from_selstr(cls, selstr, name):
        # TODO this will strip a horizontal line of don't care's
        lines = [line.strip() for line in selstr.split('\n') if line.strip()]
        h = len(lines)
        w = len(lines[0])
        lengths = set(len(line) for line in lines)
        if len(lengths) != 1:
            raise ValueError("All lines in selstr must be same length")

        repacked = ''.join(line.strip() for line in lines)
        buf_selstr = ffi.from_buffer(repacked.encode('ascii'))
        buf_name = ffi.from_buffer(name.encode('ascii'))
        sel = lept.selCreateFromString(buf_selstr, h, w, buf_name)
        return cls(sel)

    @classmethod
    def create_brick(cls, h, w, cy, cx, type_):
        sel = lept.selCreateBrick(h, w, cy, cx, type_)
        return cls(sel)

    def __repr__(self):
        selstr = ffi.gc(lept.selPrintToString(self._cdata), lept.lept_free)
        return '<Sel \n' + ffi.string(selstr).decode('ascii') + '\n>'


@lru_cache(maxsize=1)
def get_leptonica_version():
    """Get Leptonica version string.

    Caveat: Leptonica expects the caller to free this memory.  We don't,
    since that would involve binding to libc to access libc.free(),
    a pointless effort to reclaim 100 bytes of memory.

    Reminder that this returns "leptonica-1.xx" or "leptonica-1.yy.0".
    """
    return ffi.string(lept.getLeptonicaVersion()).decode()


def deskew(infile, outfile, dpi):
    try:
        pix_source = Pix.open(infile)
    except LeptonicaIOError:
        raise LeptonicaIOError("Failed to open file: %s" % infile)

    if dpi < 150:
        reduction_factor = 1  # Don't downsample too much if DPI is already low
    else:
        reduction_factor = 0  # Use default
    pix_deskewed = pix_source.deskew(reduction_factor)

    try:
        pix_deskewed.write_implied_format(outfile)
    except LeptonicaIOError:
        raise LeptonicaIOError("Failed to open destination file: %s" % outfile)


def remove_background(
    infile,
    outfile,
    tile_size=(40, 60),
    gamma=1.0,
    black_threshold=70,
    white_threshold=190,
):
    try:
        pix = Pix.open(infile)
    except LeptonicaIOError:
        raise LeptonicaIOError("Failed to open file: %s" % infile)

    pix = pix.background_norm(tile_size=tile_size).gamma_trc(
        gamma, black_threshold, white_threshold
    )

    try:
        pix.write_implied_format(outfile)
    except LeptonicaIOError:
        raise LeptonicaIOError("Failed to open destination file: %s" % outfile)


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description="Python wrapper to access Leptonica")

    subparsers = parser.add_subparsers(
        title='commands', description='supported operations'
    )

    parser_deskew = subparsers.add_parser('deskew')
    parser_deskew.add_argument(
        '-r',
        '--dpi',
        dest='dpi',
        action='store',
        type=int,
        default=300,
        help='input resolution',
    )
    parser_deskew.add_argument('infile', help='image to deskew')
    parser_deskew.add_argument('outfile', help='deskewed output image')
    parser_deskew.set_defaults(func=deskew)

    args = parser.parse_args()
    args.func(args)
