feat: promote raster device for color/gray image masks; default pngmonod (#1688)

This commit is contained in:
James R. Barlow
2026-06-05 11:40:49 -07:00
parent 6bc9499e68
commit 91b6a818f5
2 changed files with 84 additions and 28 deletions
+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}"
+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