Merge branch 'fix/1688-mask-fill-color-device'
Promote rasterization device based on image-mask fill color so gray/colored stencil text is not destroyed by 1-bit dithering before OCR; default the 1-bit Ghostscript device to pngmonod. Fixes #1688.
This commit is contained in:
@@ -3,6 +3,22 @@
|
||||
|
||||
# v17
|
||||
|
||||
## v17.6.0
|
||||
|
||||
- Fixed a regression in OCR quality for PDFs that paint a 1-bit image mask
|
||||
(stencil) with a gray or colored fill color. Previously such pages were
|
||||
rasterized as 1-bit black-and-white before OCR, so Ghostscript dithered
|
||||
mid-tone text into an unreadable stipple and Tesseract failed to recognize
|
||||
it. The rasterizer now inspects the fill color used to paint a mask and
|
||||
promotes the page to grayscale or full color as needed, so the distinction
|
||||
is preserved for the OCR engine. This applies to both the Ghostscript and
|
||||
pypdfium rasterizers. {issue}`1688`
|
||||
- The default 1-bit raster device for Ghostscript is now ``pngmonod``
|
||||
(error-diffusion) instead of ``pngmono`` (ordered dithering). It produces
|
||||
better input for OCR on faint or anti-aliased scans at negligible cost and
|
||||
no change to output file size, since the rasterized image is an
|
||||
intermediate that is discarded after OCR.
|
||||
|
||||
## v17.5.0
|
||||
|
||||
- Added support for the ``end`` alias in ``--pages``, denoting the last page
|
||||
|
||||
+45
-28
@@ -44,7 +44,7 @@ from ocrmypdf.pdfa import (
|
||||
generate_pdfa_ps,
|
||||
speculative_pdfa_conversion,
|
||||
)
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, PageInfo, PdfInfo
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, FloatRect, Ink, PageInfo, PdfInfo
|
||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice, OrientationConfidence
|
||||
|
||||
try:
|
||||
@@ -508,6 +508,49 @@ def calculate_raster_dpi(page_context: PageContext):
|
||||
return canvas_dpi, page_dpi
|
||||
|
||||
|
||||
def _select_raster_device(pageinfo: PageInfo) -> GhostscriptRasterDevice:
|
||||
"""Choose the minimum raster device that preserves the page's color depth.
|
||||
|
||||
The device escalates from 1-bit mono through grayscale, indexed, and full
|
||||
color as required by the page's images, image masks, and vector content.
|
||||
Image masks are painted with the current fill color, so a mask painted in
|
||||
gray or color escalates the device even though the mask itself is 1-bit.
|
||||
"""
|
||||
colorspaces = [
|
||||
GhostscriptRasterDevice.PNGMONOD,
|
||||
GhostscriptRasterDevice.PNGGRAY,
|
||||
GhostscriptRasterDevice.PNG256,
|
||||
GhostscriptRasterDevice.PNG16M,
|
||||
]
|
||||
device_idx = 0
|
||||
|
||||
def at_least(colorspace):
|
||||
return max(device_idx, colorspaces.index(colorspace))
|
||||
|
||||
for image in pageinfo.images:
|
||||
if image.type_ == 'stencil':
|
||||
# The fill color used to paint the mask, not the 1-bit mask data,
|
||||
# determines the color depth OCR needs.
|
||||
if image.ink == Ink.color:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||
elif image.ink == Ink.gray:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
|
||||
continue
|
||||
if image.bpc > 1:
|
||||
if image.color == Colorspace.index:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG256)
|
||||
elif image.color == Colorspace.gray:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
|
||||
else:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||
|
||||
if pageinfo.has_vector:
|
||||
log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}")
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||
|
||||
return colorspaces[device_idx]
|
||||
|
||||
|
||||
def rasterize(
|
||||
input_file: Path,
|
||||
page_context: PageContext,
|
||||
@@ -529,39 +572,13 @@ def rasterize(
|
||||
Returns:
|
||||
Path: The output PNG file path.
|
||||
"""
|
||||
colorspaces = [
|
||||
GhostscriptRasterDevice.PNGMONO,
|
||||
GhostscriptRasterDevice.PNGGRAY,
|
||||
GhostscriptRasterDevice.PNG256,
|
||||
GhostscriptRasterDevice.PNG16M,
|
||||
]
|
||||
device_idx = 0
|
||||
|
||||
if remove_vectors is None:
|
||||
remove_vectors = page_context.options.remove_vectors
|
||||
|
||||
output_file = page_context.get_path(f'rasterize{output_tag}.png')
|
||||
pageinfo = page_context.pageinfo
|
||||
|
||||
def at_least(colorspace):
|
||||
return max(device_idx, colorspaces.index(colorspace))
|
||||
|
||||
for image in pageinfo.images:
|
||||
if image.type_ != 'image':
|
||||
continue # ignore masks
|
||||
if image.bpc > 1:
|
||||
if image.color == Colorspace.index:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG256)
|
||||
elif image.color == Colorspace.gray:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNGGRAY)
|
||||
else:
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||
|
||||
if pageinfo.has_vector:
|
||||
log.debug(f"Page has vector content, using {GhostscriptRasterDevice.PNG16M}")
|
||||
device_idx = at_least(GhostscriptRasterDevice.PNG16M)
|
||||
|
||||
device = colorspaces[device_idx]
|
||||
device = _select_raster_device(pageinfo)
|
||||
|
||||
log.debug(
|
||||
f"Rasterize with {device}, rotation {correction}, mediabox {pageinfo.mediabox}"
|
||||
|
||||
@@ -96,7 +96,12 @@ def _render_page_to_bitmap(
|
||||
# Render the page to a bitmap
|
||||
# The scale parameter controls the resolution
|
||||
# Render in grayscale for mono and gray devices (better input for 1-bit conversion)
|
||||
grayscale = raster_device.lower() in ('pngmono', 'pnggray', 'jpeggray')
|
||||
grayscale = raster_device.lower() in (
|
||||
'pngmono',
|
||||
'pngmonod',
|
||||
'pnggray',
|
||||
'jpeggray',
|
||||
)
|
||||
|
||||
# Default (use_cropbox=False) renders MediaBox for consistency with Ghostscript
|
||||
if not use_cropbox:
|
||||
@@ -157,8 +162,8 @@ def _process_image_for_output(
|
||||
# This ensures pypdfium output matches Ghostscript's native device output
|
||||
raster_device_lower = raster_device.lower()
|
||||
|
||||
if raster_device_lower == 'pngmono':
|
||||
# Convert to 1-bit black and white (matches Ghostscript pngmono device)
|
||||
if raster_device_lower in ('pngmono', 'pngmonod'):
|
||||
# Convert to 1-bit black and white (matches Ghostscript pngmono/pngmonod)
|
||||
if pil_image.mode != '1':
|
||||
if pil_image.mode not in ('L', '1'):
|
||||
pil_image = pil_image.convert('L')
|
||||
@@ -184,7 +189,15 @@ def _process_image_for_output(
|
||||
# pngalpha: keep RGBA as-is
|
||||
|
||||
# Determine output format based on raster_device
|
||||
png_devices = ('png', 'pngmono', 'pnggray', 'png256', 'png16m', 'pngalpha')
|
||||
png_devices = (
|
||||
'png',
|
||||
'pngmono',
|
||||
'pngmonod',
|
||||
'pnggray',
|
||||
'png256',
|
||||
'png16m',
|
||||
'pngalpha',
|
||||
)
|
||||
if raster_device_lower in png_devices:
|
||||
format_name = 'PNG'
|
||||
elif raster_device_lower in ('jpeg', 'jpeggray', 'jpg'):
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ocrmypdf.pdfinfo._types import Colorspace, Encoding, FloatRect
|
||||
from ocrmypdf.pdfinfo._types import Colorspace, Encoding, FloatRect, Ink
|
||||
from ocrmypdf.pdfinfo.info import PageInfo, PdfInfo
|
||||
|
||||
__all__ = ["Colorspace", "Encoding", "FloatRect", "PageInfo", "PdfInfo"]
|
||||
__all__ = ["Colorspace", "Encoding", "FloatRect", "Ink", "PageInfo", "PdfInfo"]
|
||||
|
||||
@@ -11,11 +11,11 @@ from math import hypot, inf, isclose
|
||||
from typing import NamedTuple
|
||||
from warnings import warn
|
||||
|
||||
from pikepdf import Matrix, Object, PdfInlineImage, parse_content_stream
|
||||
from pikepdf import Matrix, Name, Object, PdfInlineImage, parse_content_stream
|
||||
|
||||
from ocrmypdf.exceptions import InputFileError
|
||||
from ocrmypdf.helpers import Resolution
|
||||
from ocrmypdf.pdfinfo._types import UNIT_SQUARE
|
||||
from ocrmypdf.pdfinfo._types import UNIT_SQUARE, Ink
|
||||
|
||||
|
||||
class XobjectSettings(NamedTuple):
|
||||
@@ -24,6 +24,7 @@ class XobjectSettings(NamedTuple):
|
||||
name: str
|
||||
shorthand: tuple[float, float, float, float, float, float]
|
||||
stack_depth: int
|
||||
fill_ink: Ink
|
||||
|
||||
|
||||
class InlineSettings(NamedTuple):
|
||||
@@ -32,6 +33,7 @@ class InlineSettings(NamedTuple):
|
||||
iimage: PdfInlineImage
|
||||
shorthand: tuple[float, float, float, float, float, float]
|
||||
stack_depth: int
|
||||
fill_ink: Ink
|
||||
|
||||
|
||||
class ContentsInfo(NamedTuple):
|
||||
@@ -67,6 +69,60 @@ def _is_unit_square(shorthand):
|
||||
return all(isclose(a, b, rel_tol=1e-3) for a, b in pairwise)
|
||||
|
||||
|
||||
_INK_EPSILON = 1e-3
|
||||
|
||||
# Maps a fill-colorspace name (set by the `cs` operator) to a device color
|
||||
# family we can classify. Names not present here (Separation, ICCBased,
|
||||
# Indexed, DeviceN, Pattern, resource names like /CS0) are treated as color.
|
||||
_DEVICE_FILL_SPACE = {
|
||||
'/DeviceGray': 'gray',
|
||||
'/CalGray': 'gray',
|
||||
'/G': 'gray',
|
||||
'/DeviceRGB': 'rgb',
|
||||
'/CalRGB': 'rgb',
|
||||
'/RGB': 'rgb',
|
||||
'/DeviceCMYK': 'cmyk',
|
||||
'/CMYK': 'cmyk',
|
||||
}
|
||||
|
||||
|
||||
def _ink_from_components(space: str, comps: list[float]) -> Ink:
|
||||
"""Classify a device-color fill into mono/gray/color.
|
||||
|
||||
``space`` is one of 'gray', 'rgb', 'cmyk'. Any other value is treated
|
||||
conservatively as color, since we cannot prove it is achromatic.
|
||||
"""
|
||||
eps = _INK_EPSILON
|
||||
if space == 'gray' and len(comps) == 1:
|
||||
return Ink.mono if comps[0] <= eps else Ink.gray
|
||||
if space == 'rgb' and len(comps) == 3:
|
||||
r, g, b = comps
|
||||
if max(r, g, b) <= eps:
|
||||
return Ink.mono
|
||||
if abs(r - g) <= eps and abs(g - b) <= eps:
|
||||
return Ink.gray
|
||||
return Ink.color
|
||||
if space == 'cmyk' and len(comps) == 4:
|
||||
c, m, y, k = comps
|
||||
if c <= eps and m <= eps and y <= eps:
|
||||
return Ink.mono if k <= eps else Ink.gray
|
||||
return Ink.color
|
||||
return Ink.color # conservative-to-color
|
||||
|
||||
|
||||
def _operand_floats(operands) -> list[float] | None:
|
||||
"""Convert color operands to floats, or None if any is non-numeric.
|
||||
|
||||
Color operators in a malformed content stream may carry the wrong number
|
||||
of operands or a non-numeric operand (e.g. a Name). Returning None lets
|
||||
the caller keep the prior fill state instead of raising.
|
||||
"""
|
||||
try:
|
||||
return [float(o) for o in operands]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_stack(graphobjs):
|
||||
"""Convert runs of qQ's in the stack into single graphobjs."""
|
||||
for operands, operator in graphobjs:
|
||||
@@ -78,12 +134,15 @@ def _normalize_stack(graphobjs):
|
||||
yield (operands, operator)
|
||||
|
||||
|
||||
def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
def _interpret_contents(
|
||||
contentstream: Object, initial_shorthand=UNIT_SQUARE, initial_fill_ink=Ink.mono
|
||||
):
|
||||
"""Interpret the PDF content stream.
|
||||
|
||||
The stack represents the state of the PDF graphics stack. We are only
|
||||
interested in the current transformation matrix (CTM) so we only track
|
||||
this object; a full implementation would need to track many other items.
|
||||
The stack represents the state of the PDF graphics stack. We track the
|
||||
current transformation matrix (CTM) and the current fill color (so that
|
||||
image masks, which are painted with the fill color, can be classified);
|
||||
a full implementation would need to track many other items.
|
||||
|
||||
The CTM is initialized to the mapping from user space to device space.
|
||||
PDF units are 1/72". In a PDF viewer or printer this matrix is initialized
|
||||
@@ -102,10 +161,12 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
stack depth exceeds the spec limit and set a hard limit beyond this to
|
||||
bound our memory requirements. If the stack underflows behavior is
|
||||
undefined in the spec, but we just pretend nothing happened and leave the
|
||||
CTM unchanged.
|
||||
graphics state unchanged.
|
||||
"""
|
||||
stack = []
|
||||
ctm = Matrix(initial_shorthand)
|
||||
fill_ink = initial_fill_ink # PDF default fill color is black
|
||||
fill_space = '/DeviceGray' # current fill colorspace name (for sc/scn)
|
||||
xobject_settings: list[XobjectSettings] = []
|
||||
inline_images: list[InlineSettings] = []
|
||||
name_index = defaultdict(lambda: [])
|
||||
@@ -114,14 +175,15 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
vector_ops = set('S s f F f* B B* b b*'.split())
|
||||
text_showing_ops = set("""TJ Tj " '""".split())
|
||||
image_ops = set('BI ID EI q Q Do cm'.split())
|
||||
operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops)
|
||||
color_ops = set('g rg k cs sc scn'.split())
|
||||
operator_whitelist = ' '.join(vector_ops | text_showing_ops | image_ops | color_ops)
|
||||
|
||||
for n, graphobj in enumerate(
|
||||
_normalize_stack(parse_content_stream(contentstream, operator_whitelist))
|
||||
):
|
||||
operands, operator = graphobj
|
||||
if operator == 'q':
|
||||
stack.append(ctm)
|
||||
stack.append((ctm, fill_ink, fill_space))
|
||||
if len(stack) > 32: # See docstring
|
||||
if len(stack) > 128:
|
||||
raise RuntimeError(
|
||||
@@ -130,9 +192,9 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
warn("PDF graphics stack overflowed spec limit")
|
||||
elif operator == 'Q':
|
||||
try:
|
||||
ctm = stack.pop()
|
||||
ctm, fill_ink, fill_space = stack.pop()
|
||||
except IndexError:
|
||||
# Keeping the ctm the same seems to be the only sensible thing
|
||||
# Keeping the state the same seems to be the only sensible thing
|
||||
# to do. Just pretend nothing happened, keep calm and carry on.
|
||||
warn("PDF graphics stack underflowed - PDF may be malformed")
|
||||
elif operator == 'cm':
|
||||
@@ -143,17 +205,51 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
"PDF content stream is corrupt - this PDF is malformed. "
|
||||
"Use a PDF editor that is capable of visually inspecting the PDF."
|
||||
) from e
|
||||
elif operator == 'g':
|
||||
if vals := _operand_floats(operands):
|
||||
fill_ink = _ink_from_components('gray', vals)
|
||||
fill_space = '/DeviceGray'
|
||||
elif operator == 'rg':
|
||||
if vals := _operand_floats(operands):
|
||||
fill_ink = _ink_from_components('rgb', vals)
|
||||
fill_space = '/DeviceRGB'
|
||||
elif operator == 'k':
|
||||
if vals := _operand_floats(operands):
|
||||
fill_ink = _ink_from_components('cmyk', vals)
|
||||
fill_space = '/DeviceCMYK'
|
||||
elif operator == 'cs':
|
||||
# Selecting a colorspace resets the fill color to that space's
|
||||
# initial value, which is black for all device colorspaces.
|
||||
fill_ink = Ink.mono
|
||||
if operands:
|
||||
fill_space = str(operands[0])
|
||||
elif operator in ('sc', 'scn'):
|
||||
if any(isinstance(o, Name) for o in operands):
|
||||
fill_ink = Ink.color # pattern fill
|
||||
else:
|
||||
space = _DEVICE_FILL_SPACE.get(fill_space)
|
||||
vals = _operand_floats(operands)
|
||||
if space is None or vals is None:
|
||||
fill_ink = Ink.color # conservative for non-device space
|
||||
else:
|
||||
fill_ink = _ink_from_components(space, vals)
|
||||
elif operator == 'Do':
|
||||
image_name = operands[0]
|
||||
settings = XobjectSettings(
|
||||
name=image_name, shorthand=ctm.shorthand, stack_depth=len(stack)
|
||||
name=image_name,
|
||||
shorthand=ctm.shorthand,
|
||||
stack_depth=len(stack),
|
||||
fill_ink=fill_ink,
|
||||
)
|
||||
xobject_settings.append(settings)
|
||||
name_index[str(image_name)].append(settings)
|
||||
elif operator == 'INLINE IMAGE': # BI/ID/EI are grouped into this
|
||||
iimage = operands[0]
|
||||
inline = InlineSettings(
|
||||
iimage=iimage, shorthand=ctm.shorthand, stack_depth=len(stack)
|
||||
iimage=iimage,
|
||||
shorthand=ctm.shorthand,
|
||||
stack_depth=len(stack),
|
||||
fill_ink=fill_ink,
|
||||
)
|
||||
inline_images.append(inline)
|
||||
elif operator in vector_ops:
|
||||
|
||||
@@ -36,6 +36,7 @@ from ocrmypdf.pdfinfo._types import (
|
||||
UNIT_SQUARE,
|
||||
Colorspace,
|
||||
Encoding,
|
||||
Ink,
|
||||
)
|
||||
|
||||
logger = logging.getLogger()
|
||||
@@ -61,10 +62,12 @@ class ImageInfo:
|
||||
pdfimage: Object | None = None,
|
||||
inline: PdfInlineImage | None = None,
|
||||
shorthand=None,
|
||||
fill_ink: Ink | None = None,
|
||||
):
|
||||
"""Initialize an ImageInfo."""
|
||||
self._name = str(name)
|
||||
self._shorthand = shorthand
|
||||
self._fill_ink = fill_ink
|
||||
|
||||
pim: PdfInlineImage | PdfImage
|
||||
|
||||
@@ -175,6 +178,17 @@ class ImageInfo:
|
||||
"""Type of image, either 'image' or 'stencil'."""
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def ink(self) -> Ink | None:
|
||||
"""Fill-color classification for stencil masks, else None.
|
||||
|
||||
A stencil (image mask) is painted with the current fill color; this
|
||||
reports whether that color is mono/gray/color so the rasterizer can
|
||||
choose a device that does not discard the distinction. Non-stencil
|
||||
images return None.
|
||||
"""
|
||||
return self._fill_ink if self._type == 'stencil' else None
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Width of the image in pixels."""
|
||||
@@ -249,7 +263,10 @@ def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
||||
"""Find inline images in the contentstream."""
|
||||
for n, inline in enumerate(contentsinfo.inline_images):
|
||||
yield ImageInfo(
|
||||
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
|
||||
name=f'inline-{n:02d}',
|
||||
shorthand=inline.shorthand,
|
||||
inline=inline.iimage,
|
||||
fill_ink=inline.fill_ink,
|
||||
)
|
||||
|
||||
|
||||
@@ -300,7 +317,12 @@ def _find_regular_images(
|
||||
# these from our DPI calculation for the page.
|
||||
continue
|
||||
|
||||
yield ImageInfo(name=draw.name, pdfimage=pdfimage, shorthand=draw.shorthand)
|
||||
yield ImageInfo(
|
||||
name=draw.name,
|
||||
pdfimage=pdfimage,
|
||||
shorthand=draw.shorthand,
|
||||
fill_ink=draw.fill_ink,
|
||||
)
|
||||
|
||||
|
||||
def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo):
|
||||
@@ -330,13 +352,19 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
|
||||
# but in practice both Form XObjects and multiple drawing of the
|
||||
# same object are both very rare.
|
||||
ctm_shorthand = settings.shorthand
|
||||
# A Form XObject inherits the graphics state (including fill color)
|
||||
# in effect at the Do that draws it, so a mask painted with an
|
||||
# inherited gray/color fill must carry that classification inward.
|
||||
yield from _process_content_streams(
|
||||
pdf=pdf, container=form_xobject, shorthand=ctm_shorthand
|
||||
pdf=pdf,
|
||||
container=form_xobject,
|
||||
shorthand=ctm_shorthand,
|
||||
initial_fill_ink=settings.fill_ink,
|
||||
)
|
||||
|
||||
|
||||
def _process_content_streams(
|
||||
*, pdf: Pdf, container: Object, shorthand=None
|
||||
*, pdf: Pdf, container: Object, shorthand=None, initial_fill_ink=Ink.mono
|
||||
) -> Iterator[VectorMarker | TextMarker | ImageInfo]:
|
||||
"""Find all individual instances of images drawn in the container.
|
||||
|
||||
@@ -377,7 +405,7 @@ def _process_content_streams(
|
||||
else:
|
||||
return
|
||||
|
||||
contentsinfo = _interpret_contents(container, initial_shorthand)
|
||||
contentsinfo = _interpret_contents(container, initial_shorthand, initial_fill_ink)
|
||||
|
||||
if contentsinfo.found_vector:
|
||||
yield VectorMarker()
|
||||
|
||||
@@ -39,6 +39,20 @@ class Encoding(Enum):
|
||||
flate_jpeg = auto()
|
||||
|
||||
|
||||
class Ink(Enum):
|
||||
"""Classification of the fill color used to paint a stencil image mask.
|
||||
|
||||
A stencil (image mask) is painted with the current fill color, so the
|
||||
color depth needed to rasterize it for OCR depends on that fill color,
|
||||
not on the mask's 1-bit data.
|
||||
"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
mono = auto() # black (or no color information to preserve)
|
||||
gray = auto() # achromatic but not pure black
|
||||
color = auto() # chromatic, or a fill we cannot prove is achromatic
|
||||
|
||||
|
||||
FloatRect = tuple[float, float, float, float]
|
||||
|
||||
FRIENDLY_COLORSPACE: dict[str, Colorspace] = {
|
||||
|
||||
@@ -38,6 +38,7 @@ class GhostscriptRasterDevice(StrEnum):
|
||||
JPEGGRAY = 'jpeggray'
|
||||
JPEGCOLOR = 'jpeg'
|
||||
PNGMONO = 'pngmono'
|
||||
PNGMONOD = 'pngmonod'
|
||||
PNGGRAY = 'pnggray'
|
||||
PNG256 = 'png256'
|
||||
PNG16M = 'png16m'
|
||||
|
||||
+191
-2
@@ -18,8 +18,8 @@ from reportlab.pdfgen.canvas import Canvas
|
||||
from ocrmypdf import pdfinfo
|
||||
from ocrmypdf.exceptions import InputFileError
|
||||
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding
|
||||
from ocrmypdf.pdfinfo._contentstream import _interpret_contents
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, Ink
|
||||
from ocrmypdf.pdfinfo._contentstream import _ink_from_components, _interpret_contents
|
||||
from ocrmypdf.pdfinfo.layout import PDFPage
|
||||
|
||||
warnings.filterwarnings(
|
||||
@@ -290,3 +290,192 @@ def test_image_scale0(image_scale0):
|
||||
)
|
||||
assert not pi.pages[0]._images[0].dpi.is_finite
|
||||
assert pi.pages[0].dpi == Resolution(0, 0)
|
||||
|
||||
|
||||
def test_ink_enum_is_picklable():
|
||||
# ImageInfo crosses the worker-process boundary, so Ink must pickle.
|
||||
for member in (Ink.mono, Ink.gray, Ink.color):
|
||||
assert pickle.loads(pickle.dumps(member)) is member
|
||||
|
||||
|
||||
def test_pngmonod_device_exists():
|
||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
||||
|
||||
assert GhostscriptRasterDevice.PNGMONOD == 'pngmonod'
|
||||
# PNGMONO retained for compatibility / explicit use
|
||||
assert GhostscriptRasterDevice.PNGMONO == 'pngmono'
|
||||
|
||||
|
||||
def _ink_of_first_xobject(body: bytes):
|
||||
from ocrmypdf.pdfinfo._contentstream import _interpret_contents
|
||||
|
||||
p = pikepdf.Pdf.new()
|
||||
stream = pikepdf.Stream(p, body)
|
||||
info = _interpret_contents(stream)
|
||||
return info.xobject_settings[0].fill_ink
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body, expected",
|
||||
[
|
||||
(b"/Im0 Do", 'mono'), # default fill is black
|
||||
(b"0.263 0.263 0.263 rg /Im0 Do", 'gray'),
|
||||
(b"0.5 g /Im0 Do", 'gray'),
|
||||
(b"0 g /Im0 Do", 'mono'),
|
||||
(b"0.8 0.2 0.2 rg /Im0 Do", 'color'),
|
||||
(b"0 0 0 0.5 k /Im0 Do", 'gray'),
|
||||
(b"0.5 0.1 0 0 k /Im0 Do", 'color'),
|
||||
],
|
||||
)
|
||||
def test_fill_ink_tracked_per_draw(body, expected):
|
||||
assert _ink_of_first_xobject(body) is Ink[expected]
|
||||
|
||||
|
||||
def test_fill_ink_non_device_colorspace_is_color():
|
||||
# cs to a non-device colorspace then scn -> conservative color
|
||||
assert _ink_of_first_xobject(b"/CS0 cs 0.4 scn /Im0 Do") is Ink.color
|
||||
|
||||
|
||||
def test_fill_ink_pattern_scn_is_color():
|
||||
assert _ink_of_first_xobject(b"/Pattern cs /P0 scn /Im0 Do") is Ink.color
|
||||
|
||||
|
||||
def test_fill_ink_respects_graphics_stack():
|
||||
# Set red, save, set gray, restore -> red again at the Do
|
||||
assert _ink_of_first_xobject(b"0.8 0.1 0.1 rg q 0.5 g Q /Im0 Do") is Ink.color
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
b"g /Im0 Do", # g with no operand
|
||||
b"/Foo g /Im0 Do", # g with a non-numeric operand
|
||||
b"cs /Im0 Do", # cs with no operand
|
||||
b"0.5 /Foo k /Im0 Do", # k with a non-numeric operand
|
||||
b"/DeviceRGB cs /Foo 0.5 scn /Im0 Do", # scn with mixed bad operands
|
||||
],
|
||||
)
|
||||
def test_fill_ink_tolerates_malformed_color_operands(body):
|
||||
# Malformed color operators must not crash the interpreter; they leave the
|
||||
# fill state at its prior value (default mono) or fall back conservatively.
|
||||
assert _ink_of_first_xobject(body) in (Ink.mono, Ink.color)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"space, comps, expected",
|
||||
[
|
||||
('gray', [0.0], 'mono'),
|
||||
('gray', [0.263], 'gray'),
|
||||
('gray', [1.0], 'gray'), # white -> gray (harmless)
|
||||
('rgb', [0.0, 0.0, 0.0], 'mono'),
|
||||
('rgb', [0.263, 0.263, 0.263], 'gray'),
|
||||
('rgb', [0.8, 0.2, 0.2], 'color'),
|
||||
('rgb', [1.0, 1.0, 1.0], 'gray'),
|
||||
('cmyk', [0.0, 0.0, 0.0, 0.0], 'mono'), # white
|
||||
('cmyk', [0.0, 0.0, 0.0, 0.5], 'gray'),
|
||||
('cmyk', [0.5, 0.1, 0.0, 0.0], 'color'),
|
||||
('unknown', [0.5], 'color'), # conservative fallback
|
||||
],
|
||||
)
|
||||
def test_ink_from_components(space, comps, expected):
|
||||
assert _ink_from_components(space, comps) is Ink[expected]
|
||||
|
||||
|
||||
def _make_image_mask_pdf(path, content_fill: bytes):
|
||||
"""Build a 1-page PDF with one 8x8 image mask painted with content_fill.
|
||||
|
||||
content_fill is the color operator sequence emitted before drawing the
|
||||
mask, e.g. b"0.263 0.263 0.263 rg".
|
||||
"""
|
||||
pdf = pikepdf.Pdf.new()
|
||||
pdf.add_blank_page(page_size=(72, 72))
|
||||
# 8x8 1-bpc mask, each row padded to a byte (1 byte per row).
|
||||
mask_bytes = bytes([0x7E] * 8)
|
||||
mask = pikepdf.Stream(pdf, mask_bytes)
|
||||
mask.Type = pikepdf.Name.XObject
|
||||
mask.Subtype = pikepdf.Name.Image
|
||||
mask.Width = 8
|
||||
mask.Height = 8
|
||||
mask.ImageMask = True
|
||||
mask.BitsPerComponent = 1
|
||||
name = pdf.pages[0].add_resource(mask, pikepdf.Name.XObject)
|
||||
pdf.pages[0].Contents = pikepdf.Stream(
|
||||
pdf, b"q 72 0 0 72 0 0 cm %s %s Do Q" % (content_fill, bytes(name))
|
||||
)
|
||||
pdf.save(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mask_gray_pdf(outdir):
|
||||
return _make_image_mask_pdf(outdir / 'mask_gray.pdf', b"0.263 0.263 0.263 rg")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mask_rgb_pdf(outdir):
|
||||
return _make_image_mask_pdf(outdir / 'mask_rgb.pdf', b"0.8 0.2 0.2 rg")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mask_black_pdf(outdir):
|
||||
return _make_image_mask_pdf(outdir / 'mask_black.pdf', b"0 g")
|
||||
|
||||
|
||||
def test_imageinfo_ink_gray(mask_gray_pdf):
|
||||
image = pdfinfo.PdfInfo(mask_gray_pdf)[0].images[0]
|
||||
assert image.type_ == 'stencil'
|
||||
assert image.ink is Ink.gray
|
||||
|
||||
|
||||
def test_imageinfo_ink_color(mask_rgb_pdf):
|
||||
image = pdfinfo.PdfInfo(mask_rgb_pdf)[0].images[0]
|
||||
assert image.ink is Ink.color
|
||||
|
||||
|
||||
def test_imageinfo_ink_black(mask_black_pdf):
|
||||
image = pdfinfo.PdfInfo(mask_black_pdf)[0].images[0]
|
||||
assert image.ink is Ink.mono
|
||||
|
||||
|
||||
def test_imageinfo_ink_none_for_regular_image(eight_by_eight_regular_image):
|
||||
image = pdfinfo.PdfInfo(eight_by_eight_regular_image)[0].images[0]
|
||||
assert image.ink is None
|
||||
|
||||
|
||||
def test_fill_ink_cs_resets_color_to_black():
|
||||
# `cs` resets the fill color to the colorspace's initial value (black),
|
||||
# so a stale color set before `cs` must not leak to the drawn mask.
|
||||
assert _ink_of_first_xobject(b"0.8 0.2 0.2 rg /DeviceGray cs /Im0 Do") is Ink.mono
|
||||
|
||||
|
||||
def test_imageinfo_ink_inherited_in_form_xobject(outdir):
|
||||
# A mask drawn inside a Form XObject inherits the fill color set before the
|
||||
# Do that paints the form; the gray classification must reach the mask.
|
||||
pdf = pikepdf.Pdf.new()
|
||||
pdf.add_blank_page(page_size=(72, 72))
|
||||
|
||||
mask = pikepdf.Stream(pdf, bytes([0x7E] * 8))
|
||||
mask.Type = pikepdf.Name.XObject
|
||||
mask.Subtype = pikepdf.Name.Image
|
||||
mask.Width = 8
|
||||
mask.Height = 8
|
||||
mask.ImageMask = True
|
||||
mask.BitsPerComponent = 1
|
||||
|
||||
# Form draws the mask with no color of its own, inheriting the caller's.
|
||||
form = pikepdf.Stream(pdf, b"q 72 0 0 72 0 0 cm /Im0 Do Q")
|
||||
form.Type = pikepdf.Name.XObject
|
||||
form.Subtype = pikepdf.Name.Form
|
||||
form.BBox = [0, 0, 72, 72]
|
||||
form.Resources = pikepdf.Dictionary(XObject=pikepdf.Dictionary(Im0=mask))
|
||||
|
||||
fname = pdf.pages[0].add_resource(form, pikepdf.Name.XObject)
|
||||
pdf.pages[0].Contents = pikepdf.Stream(
|
||||
pdf, b"0.263 0.263 0.263 rg %s Do" % bytes(fname)
|
||||
)
|
||||
out = outdir / 'form_mask.pdf'
|
||||
pdf.save(out)
|
||||
|
||||
image = pdfinfo.PdfInfo(out)[0].images[0]
|
||||
assert image.type_ == 'stencil'
|
||||
assert image.ink is Ink.gray
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import warnings
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pikepdf
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from reportlab.lib.units import inch
|
||||
@@ -13,8 +14,10 @@ from reportlab.lib.utils import ImageReader
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
|
||||
from ocrmypdf import _pipeline, pdfinfo
|
||||
from ocrmypdf._pipeline import _select_raster_device
|
||||
from ocrmypdf.helpers import Resolution
|
||||
from ocrmypdf.pdfinfo import Encoding
|
||||
from ocrmypdf.pluginspec import GhostscriptRasterDevice
|
||||
|
||||
warnings.filterwarnings(
|
||||
"ignore", category=DeprecationWarning, module="reportlab.lib.rl_safe_eval"
|
||||
@@ -176,3 +179,39 @@ def test_should_visible_page_image_use_jpg(encodings, expected):
|
||||
pageinfo = Mock()
|
||||
pageinfo.images = [Mock(enc=enc) for enc in encodings]
|
||||
assert _pipeline.should_visible_page_image_use_jpg(pageinfo) == expected
|
||||
|
||||
|
||||
def _make_image_mask_pdf(path, content_fill: bytes):
|
||||
pdf = pikepdf.Pdf.new()
|
||||
pdf.add_blank_page(page_size=(72, 72))
|
||||
mask = pikepdf.Stream(pdf, bytes([0x7E] * 8))
|
||||
mask.Type = pikepdf.Name.XObject
|
||||
mask.Subtype = pikepdf.Name.Image
|
||||
mask.Width = 8
|
||||
mask.Height = 8
|
||||
mask.ImageMask = True
|
||||
mask.BitsPerComponent = 1
|
||||
name = pdf.pages[0].add_resource(mask, pikepdf.Name.XObject)
|
||||
pdf.pages[0].Contents = pikepdf.Stream(
|
||||
pdf, b"q 72 0 0 72 0 0 cm %s %s Do Q" % (content_fill, bytes(name))
|
||||
)
|
||||
pdf.save(path)
|
||||
return path
|
||||
|
||||
|
||||
def test_select_device_gray_mask(tmp_path):
|
||||
p = _make_image_mask_pdf(tmp_path / 'g.pdf', b"0.263 0.263 0.263 rg")
|
||||
pageinfo = pdfinfo.PdfInfo(p)[0]
|
||||
assert _select_raster_device(pageinfo) == GhostscriptRasterDevice.PNGGRAY
|
||||
|
||||
|
||||
def test_select_device_color_mask(tmp_path):
|
||||
p = _make_image_mask_pdf(tmp_path / 'c.pdf', b"0.8 0.2 0.2 rg")
|
||||
pageinfo = pdfinfo.PdfInfo(p)[0]
|
||||
assert _select_raster_device(pageinfo) == GhostscriptRasterDevice.PNG16M
|
||||
|
||||
|
||||
def test_select_device_black_mask_stays_mono(tmp_path):
|
||||
p = _make_image_mask_pdf(tmp_path / 'b.pdf', b"0 g")
|
||||
pageinfo = pdfinfo.PdfInfo(p)[0]
|
||||
assert _select_raster_device(pageinfo) == GhostscriptRasterDevice.PNGMONOD
|
||||
|
||||
@@ -213,6 +213,95 @@ class TestRasterizerHookDirect:
|
||||
assert result == img
|
||||
assert img.exists()
|
||||
|
||||
@pytest.mark.skipif(not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed")
|
||||
def test_pypdfium_pngmonod_produces_1bit(self, resources, tmp_path):
|
||||
"""Pngmonod is treated like pngmono by pypdfium: it yields a 1-bit PNG."""
|
||||
pm = get_plugin_manager([])
|
||||
options = OcrOptions(
|
||||
input_file=resources / 'graph.pdf',
|
||||
output_file=tmp_path / 'out.pdf',
|
||||
rasterizer='pypdfium',
|
||||
)
|
||||
|
||||
img = tmp_path / 'pngmonod_test.png'
|
||||
result = pm.rasterize_pdf_page(
|
||||
input_file=resources / 'graph.pdf',
|
||||
output_file=img,
|
||||
raster_device='pngmonod',
|
||||
raster_dpi=Resolution(50, 50),
|
||||
page_dpi=Resolution(50, 50),
|
||||
pageno=1,
|
||||
rotation=0,
|
||||
filter_vector=False,
|
||||
stop_on_soft_error=True,
|
||||
options=options,
|
||||
use_cropbox=False,
|
||||
)
|
||||
assert result == img
|
||||
with Image.open(img) as im:
|
||||
assert im.mode == '1'
|
||||
|
||||
|
||||
def _make_text_mask_pdf(path, fill: bytes):
|
||||
"""Build a letter page with a large text image mask painted with ``fill``.
|
||||
|
||||
The mask is a 1-bit stencil; ``fill`` is the color operator sequence that
|
||||
sets the paint color (e.g. ``b"0.263 0.263 0.263 rg"``). With a gray fill
|
||||
this reproduces issue #1688: the text is mid-gray, which is dithered into
|
||||
noise if rasterized to 1-bit but reads correctly once promoted to gray.
|
||||
"""
|
||||
from PIL import ImageDraw, ImageFont
|
||||
|
||||
w, h = 1700, 600
|
||||
im = Image.new('1', (w, h), 1) # 1 = white = "do not paint" under Decode [0 1]
|
||||
draw = ImageDraw.Draw(im)
|
||||
try:
|
||||
font = ImageFont.truetype("DejaVuSans-Bold.ttf", 220)
|
||||
except OSError:
|
||||
font = ImageFont.truetype(
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 220
|
||||
)
|
||||
draw.text((40, 120), "TESTING", fill=0, font=font)
|
||||
|
||||
packed = im.tobytes() # 1-bpc, rows byte-padded, MSB first
|
||||
pdf = pikepdf.Pdf.new()
|
||||
pdf.add_blank_page(page_size=(612, 792))
|
||||
mask = pikepdf.Stream(pdf, packed)
|
||||
mask.Type = pikepdf.Name.XObject
|
||||
mask.Subtype = pikepdf.Name.Image
|
||||
mask.Width = w
|
||||
mask.Height = h
|
||||
mask.ImageMask = True
|
||||
mask.BitsPerComponent = 1
|
||||
name = pdf.pages[0].add_resource(mask, pikepdf.Name.XObject)
|
||||
pdf.pages[0].Contents = pikepdf.Stream(
|
||||
pdf, b"q 560 0 0 200 26 500 cm %s %s Do Q" % (fill, bytes(name))
|
||||
)
|
||||
pdf.save(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rasterizer", ['ghostscript', 'pypdfium'])
|
||||
def test_gray_mask_ocrs_to_text(tmp_path, rasterizer):
|
||||
"""A gray-painted text mask OCRs to real text on both rasterizers (#1688)."""
|
||||
if rasterizer == 'pypdfium' and not PYPDFIUM_AVAILABLE:
|
||||
pytest.skip("pypdfium2 not installed")
|
||||
|
||||
src = _make_text_mask_pdf(tmp_path / 'mask.pdf', b"0.263 0.263 0.263 rg")
|
||||
out = tmp_path / 'out.pdf'
|
||||
sidecar = tmp_path / 'out.txt'
|
||||
check_ocrmypdf(
|
||||
src,
|
||||
out,
|
||||
'--rasterizer',
|
||||
rasterizer,
|
||||
'--sidecar',
|
||||
str(sidecar),
|
||||
'--oversample',
|
||||
'300',
|
||||
)
|
||||
assert 'TESTING' in sidecar.read_text().upper()
|
||||
|
||||
|
||||
def _create_gradient_image(width: int, height: int) -> Image.Image:
|
||||
"""Create an image with multiple gradients to detect rasterization errors.
|
||||
|
||||
Reference in New Issue
Block a user