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, generate_pdfa_ps,
speculative_pdfa_conversion, 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 from ocrmypdf.pluginspec import GhostscriptRasterDevice, OrientationConfidence
try: try:
@@ -508,6 +508,49 @@ def calculate_raster_dpi(page_context: PageContext):
return canvas_dpi, page_dpi 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( def rasterize(
input_file: Path, input_file: Path,
page_context: PageContext, page_context: PageContext,
@@ -529,39 +572,13 @@ def rasterize(
Returns: Returns:
Path: The output PNG file path. Path: The output PNG file path.
""" """
colorspaces = [
GhostscriptRasterDevice.PNGMONO,
GhostscriptRasterDevice.PNGGRAY,
GhostscriptRasterDevice.PNG256,
GhostscriptRasterDevice.PNG16M,
]
device_idx = 0
if remove_vectors is None: if remove_vectors is None:
remove_vectors = page_context.options.remove_vectors remove_vectors = page_context.options.remove_vectors
output_file = page_context.get_path(f'rasterize{output_tag}.png') output_file = page_context.get_path(f'rasterize{output_tag}.png')
pageinfo = page_context.pageinfo pageinfo = page_context.pageinfo
def at_least(colorspace): device = _select_raster_device(pageinfo)
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]
log.debug( log.debug(
f"Rasterize with {device}, rotation {correction}, mediabox {pageinfo.mediabox}" f"Rasterize with {device}, rotation {correction}, mediabox {pageinfo.mediabox}"
+39
View File
@@ -6,6 +6,7 @@ from __future__ import annotations
import warnings import warnings
from unittest.mock import Mock from unittest.mock import Mock
import pikepdf
import pytest import pytest
from PIL import Image from PIL import Image
from reportlab.lib.units import inch from reportlab.lib.units import inch
@@ -13,8 +14,10 @@ from reportlab.lib.utils import ImageReader
from reportlab.pdfgen.canvas import Canvas from reportlab.pdfgen.canvas import Canvas
from ocrmypdf import _pipeline, pdfinfo from ocrmypdf import _pipeline, pdfinfo
from ocrmypdf._pipeline import _select_raster_device
from ocrmypdf.helpers import Resolution from ocrmypdf.helpers import Resolution
from ocrmypdf.pdfinfo import Encoding from ocrmypdf.pdfinfo import Encoding
from ocrmypdf.pluginspec import GhostscriptRasterDevice
warnings.filterwarnings( warnings.filterwarnings(
"ignore", category=DeprecationWarning, module="reportlab.lib.rl_safe_eval" "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 = Mock()
pageinfo.images = [Mock(enc=enc) for enc in encodings] pageinfo.images = [Mock(enc=enc) for enc in encodings]
assert _pipeline.should_visible_page_image_use_jpg(pageinfo) == expected 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