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:
James R. Barlow
2026-06-05 11:41:18 -07:00
11 changed files with 556 additions and 54 deletions
+16
View File
@@ -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
View File
@@ -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}"
+17 -4
View File
@@ -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'):
+2 -2
View File
@@ -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"]
+109 -13
View File
@@ -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:
+33 -5
View File
@@ -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()
+14
View File
@@ -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] = {
+1
View File
@@ -38,6 +38,7 @@ class GhostscriptRasterDevice(StrEnum):
JPEGGRAY = 'jpeggray'
JPEGCOLOR = 'jpeg'
PNGMONO = 'pngmono'
PNGMONOD = 'pngmonod'
PNGGRAY = 'pnggray'
PNG256 = 'png256'
PNG16M = 'png16m'
+191 -2
View File
@@ -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
+39
View File
@@ -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
+89
View File
@@ -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.