Compare commits

...
15 Commits
Author SHA1 Message Date
sokaiandGitHub 5569d4db07 Minor Update to tesseract_ocr.py (#1711)
Changed tesseract (v5.5.2) help command for `--tesseract-pagesegmode` parameter to faster help access: `tesseract --help` → `tesseract --help-extra`
2026-07-03 16:21:22 -07:00
James R. Barlow 8de7b05fb9 Fix flaky tagged-PDF skip-text test under Ghostscript 10.x
The test asserted that --mode skip preserves the structure tree, but with
the default --output-type auto the output runs through Ghostscript PDF/A
conversion, which discards /StructTreeRoot on Ghostscript 10.x (9.x kept
it). This failed on macOS CI and locally while passing on the Ubuntu
runners' Ghostscript 9.55. Pin the test to --output-type pdf so it exercises
OCRmyPDF's own structure-tree handling without the version-dependent GS step,
and document the caveat in advanced.md.
2026-07-01 00:10:57 -07:00
James R. Barlow 72ce05768e Bump version: v17.8.0 2026-06-30 01:45:30 -07:00
James R. Barlow 3dc68778fc Fix stale auto-mode comment and harden GS-raises test 2026-06-30 01:19:00 -07:00
James R. Barlow 1aec92b919 docs: auto output type falls back to Ghostscript for PDF/A (#1561) 2026-06-30 01:12:08 -07:00
James R. Barlow 43d3448709 Make --output-type auto fall back to Ghostscript for PDF/A 2026-06-30 01:08:04 -07:00
James R. Barlow 7512b1042a Detect veraPDF when version line is preceded by JVM warnings 2026-06-30 01:03:02 -07:00
James R. Barlow efe83e8c54 Protect non-embedded CID text layers from PDF/A corruption (closes #1561)
Ghostscript's PDF/A conversion re-embeds non-embedded CID (CJK) fonts by
substituting a system font, which corrupts the character-to-Unicode
mapping and silently destroys an existing text layer -- commonly the OCR
layer Adobe Acrobat adds to scanned CJK documents.

Detect non-embedded CID/Type0 fonts before conversion: with
--output-type auto (the default) downgrade to a regular PDF and preserve
the text layer; with an explicit --output-type pdfa* stop with an error
rather than emit corrupted output. Simple non-embedded fonts (e.g. Latin)
are left alone -- Ghostscript substitutes them without corrupting the
text, and they are far too common to treat as conversion blockers.

Use --output-type pdf to keep the existing text layer, or --force-ocr to
rebuild it with embedded fonts.
2026-06-30 00:01:42 -07:00
James R. Barlow a13d27bfb5 Protect stdout from corruption when writing PDF to standard output
Writing the output PDF to stdout (ocrmypdf in.pdf -) previously relied on
an honor system: no in-process code -- third-party libraries, plugins, or
stray print() calls -- was supposed to write to stdout, enforced only
indirectly. A single accidental write to fd 1 would silently corrupt the
output PDF.

Enforce this at the OS level. At CLI startup, before plugins load or any
worker process/thread starts, save the real stdout via os.dup() and point
fd 1 at stderr, so stray writes are diverted to stderr while only the final
"produce the PDF" step writes to the preserved descriptor. Exposed as the
opt-in public API function configure_stdout_protection(), mirroring
configure_logging(); it is not enabled inside ocr() so in-process library
users keep their own stdout.

Also fix check_requested_output_file() to test the preserved real stdout
for tty-ness, since after the redirect sys.stdout reports stderr's status.

Fold unreleased v17.7.2 notes into v17.8.0.
2026-06-28 12:02:34 -07:00
James R. Barlow ea7ad7d683 Handle non-UTF-8 DocumentInfo keys in repair_docinfo_nuls (closes #1540)
Some PDFs use a /Name dictionary key in /DocumentInfo whose bytes are not
valid UTF-8/PDFDocEncoding, e.g. a Latin-1 /Saks#e5r. Older pikepdf raised
UnicodeDecodeError while iterating such a block, crashing the pipeline
during PDF/A conversion. repair_docinfo_nuls is documented to log and
continue on a malformed DocumentInfo block, so catch UnicodeDecodeError
alongside TypeError.

Add a mock-based unit test that drives the decode-error branch (current
pikepdf surrogate-escapes instead of raising) and an end-to-end test over
the reporter's file, committed as docinfo_latin1_key.pdf.
2026-06-27 01:50:58 -07:00
James R. Barlow 8b20bb3c5b docs: add "Edit on GitHub" link to ReadTheDocs pages (closes #1490)
ReadTheDocs stopped injecting the GitHub edit context when it migrated to
Addons, so the sphinx_rtd_theme "Edit on GitHub" breadcrumb link vanished,
leaving only the static "View page source" (_sources/*.txt) copy. Set the
html_context explicitly so each page links back to its source on GitHub.
2026-06-27 01:25:17 -07:00
James R. Barlow 320876a6d1 docs: document Tesseract configs/ requirement (closes #1567)
When TESSDATA_PREFIX points at a hand-assembled tessdata folder lacking
the configs/ subdirectory (e.g. files pulled from tessdata_best),
Tesseract prints "read_params_file: Can't open hocr/txt" and produces no
output. The runtime now surfaces a clear error (v17.5.0); add the
matching documentation: a new errors.md entry and a note on the
TESSDATA_PREFIX docs.
2026-06-27 00:32:22 -07:00
James R. Barlow dfbb4c9275 Add regression test for blank page with non-zero MediaBox origin (#1709)
A page whose MediaBox has a non-zero origin (e.g. from PDF Arranger crops)
was rendered blank by --force-ocr in v16.12.0, because fix_pagepdf_boxes
offset the CropBox with the wrong sign, pushing it entirely outside the
image-page MediaBox. The behavior was fixed in v17.0.0 but had no
end-to-end test: the existing box tests only assert that ocrmypdf runs,
which the blank-page bug passed (it exited 0 with a valid, empty PDF/A).

Add a test that renders the output and asserts the visible content
survives, parametrized over both the ghostscript and pypdfium rasterizers
since the bug reproduced regardless of rasterizer.
2026-06-22 14:57:52 -07:00
James R. Barlow d4f5c2d160 Merge remote-tracking branch 'origin/dependabot/github_actions/actions/checkout-7' 2026-06-22 14:11:53 -07:00
dependabot[bot]andGitHub 263d6034be build(deps): bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 10:42:29 +00:00
29 changed files with 970 additions and 59 deletions
+7 -7
View File
@@ -31,7 +31,7 @@ jobs:
PYTHON: ${{ matrix.python }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -107,7 +107,7 @@ jobs:
PYTHON: ${{ matrix.python }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install Homebrew deps
continue-on-error: true
@@ -169,7 +169,7 @@ jobs:
PYTHON: ${{ matrix.python }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -207,7 +207,7 @@ jobs:
name: Build sdist and wheels
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
@@ -233,7 +233,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
@@ -281,7 +281,7 @@ jobs:
- name: Set image name
run: echo "DOCKER_IMAGE_NAME=ocrmypdf" >> $GITHUB_ENV
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Login to Docker Hub
uses: docker/login-action@v4
@@ -327,7 +327,7 @@ jobs:
- name: Set image name
run: echo "DOCKER_IMAGE_NAME=ocrmypdf-alpine" >> $GITHUB_ENV
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Login to Docker Hub
uses: docker/login-action@v4
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
contents: write
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Download artifacts from draft release
env:
+53 -3
View File
@@ -135,6 +135,16 @@ OCRmyPDF cannot rebuild a structure tree to match newly recognized text. When
the structure tree no longer corresponds to the page content, so it is discarded.
`--mode skip` leaves text pages untouched, so their structural markup is preserved.
:::{note}
Preservation under `--mode skip` only holds when the output is not converted to
PDF/A. PDF/A conversion is performed by Ghostscript, and Ghostscript 10.x discards
the structure tree during conversion (Ghostscript 9.x preserved it). Because the
default `--output-type auto` may fall back to Ghostscript, use
`--output-type pdf` if you need to guarantee that a Tagged PDF's structural markup
survives. For best results, install veraPDF so that speculative PDF/A
conversion can sidestep this issue entirely in most real cases.
:::
### Time and image size limits
By default, OCRmyPDF permits tesseract to run for three minutes (180
@@ -201,6 +211,13 @@ include:
Overrides the path to Tesseract's data files. This can allow
simultaneous installation of the "best" and "fast" training data
sets. OCRmyPDF does not manage this environment variable.
If you point ``TESSDATA_PREFIX`` at a hand-assembled ``tessdata``
folder (for example, individual ``.traineddata`` files downloaded
from tessdata_best), make sure it also contains the ``configs/``
subdirectory with the ``hocr`` and ``txt`` files. OCRmyPDF requires
these; without them Tesseract produces no output. See
:ref:`Tesseract cannot open its config file <tesseract-config-missing>`.
```
```{eval-rst}
@@ -516,6 +533,36 @@ OCRmyPDF can produce PDF/A compliant output for long-term archival. The
| `pdf` | Standard PDF, no PDF/A conversion |
| `none` | No output file (useful with `--sidecar`) |
### Non-embedded fonts and PDF/A
:::{versionadded} 17.8.0
OCRmyPDF now refuses to corrupt non-embedded CID text layers during PDF/A
conversion.
:::
PDF/A requires every font to be embedded. If your input already has a text
layer that uses *non-embedded* CID fonts — most commonly a CJK
(Chinese-Japanese-Korean) OCR layer
produced by Adobe Acrobat, which relies on the reader's system fonts —
Ghostscript would have to substitute and re-embed a replacement font to make
the file PDF/A. For CID-keyed (CJK) fonts this routinely corrupts the
character-to-Unicode mapping, so the text silently becomes garbage or stops
being searchable even though the page still *looks* correct.
Rather than emit corrupted output, OCRmyPDF detects this situation and:
- with `--output-type auto` (the default), produces a regular PDF instead of
PDF/A, preserving the existing text layer exactly;
- with an explicit `--output-type pdfa` (or `pdfa-1`/`pdfa-2`/`pdfa-3`), stops
with an error.
This is a Ghostscript limitation that OCRmyPDF cannot repair, because a
non-embedded font cannot be made PDF/A-compliant without re-embedding it. To
keep the existing text layer, use `--output-type pdf`. To produce PDF/A anyway,
re-run OCR with `--force-ocr`, which discards the original text layer and
rebuilds it with embedded fonts. Text layers whose fonts are *already embedded*
are converted to PDF/A normally.
### Speculative PDF/A conversion
:::{versionadded} 17.0.0
@@ -529,9 +576,12 @@ fast "speculative" PDF/A conversion that avoids Ghostscript when possible:
3. If validation passes, Ghostscript is skipped entirely
4. If validation fails or verapdf is unavailable, falls back to Ghostscript
This approach is faster and avoids some Ghostscript limitations (such as
image transcoding), but only works for PDFs that are already "mostly"
PDF/A compliant.
This fast path avoids some Ghostscript limitations (such as image
transcoding) and is used whenever it can produce valid PDF/A. When it
cannot — for example when veraPDF is not installed, or the input needs real
conversion — `auto` falls back to Ghostscript so that it still produces
PDF/A by default, matching OCRmyPDF 16 and earlier. If even Ghostscript
cannot safely produce PDF/A, `auto` outputs a regular PDF instead of failing.
### PDF/A conversion flow
+14
View File
@@ -178,6 +178,20 @@ html_theme = 'sphinx_rtd_theme'
#
html_theme_options = {}
# ReadTheDocs used to inject the "Edit on GitHub" context automatically, but
# dropped it when it switched to Addons, so set it explicitly here. This makes
# sphinx_rtd_theme add an "Edit on GitHub" link to each page that points at the
# corresponding source file in the repository, replacing the static
# "View page source" (_sources/*.txt) link. See
# https://github.com/ocrmypdf/OCRmyPDF/issues/1490
html_context = {
'display_github': True,
'github_user': 'ocrmypdf',
'github_repo': 'OCRmyPDF',
'github_version': 'main',
'conf_py_path': '/docs/',
}
# Add any paths that contain custom themes here, relative to this directory.
# html_theme_path = []
+28
View File
@@ -49,3 +49,31 @@ pdftk input.pdf cat output output.pdf
Sometimes Acrobat can repair PDFs with its [Preflight
tool](https://helpx.adobe.com/acrobat/using/correcting-problem-areas-preflight-tool.html).
(tesseract-config-missing)=
## Tesseract cannot open its config file \'hocr\' or \'txt\'
:::{code}
ERROR - Tesseract cannot open its config file 'hocr'.
:::
OCRmyPDF asks Tesseract to produce `hocr` and `txt` output. Tesseract
reads the instructions for these output formats from configuration files
named `hocr` and `txt` that live in the `configs/` subdirectory of its
`tessdata` folder. If those files are missing, Tesseract prints
`read_params_file: Can't open hocr`, exits without error, and produces no
output.
This usually happens when a `tessdata` directory was assembled by hand --
for example, by downloading individual `.traineddata` files from
[tessdata_best](https://github.com/tesseract-ocr/tessdata_best) and
pointing `TESSDATA_PREFIX` at them -- because those repositories do not
include the `configs/` directory. A complete Tesseract installation from
your operating system\'s package manager includes it.
To fix this, ensure the `configs/hocr` and `configs/txt` files exist in
the `tessdata` directory that Tesseract is using. Copying the `configs/`
directory from a full Tesseract installation is sufficient. See
{envvar}`TESSDATA_PREFIX` for more on selecting an alternate `tessdata`
folder.
+45
View File
@@ -3,6 +3,51 @@
# v17
## v17.8.0
- `--output-type auto` (the default) again produces PDF/A whenever it can,
matching OCRmyPDF 16's "PDF/A by default" behavior. It first tries the fast
Ghostscript-free conversion (validated by veraPDF when available) and now
falls back to Ghostscript when that cannot produce PDF/A, only emitting a
regular PDF when even Ghostscript cannot safely convert (for example, an
input with non-embedded CID/CJK fonts, per {issue}`1561`). A consequence is
that the default path may once again invoke Ghostscript, which is slower and
may transcode images; use `--output-type pdf` to skip PDF/A conversion
entirely.
- Fixed detection of veraPDF 1.30.0 and newer: recent builds print JVM
warnings before their version string, which caused OCRmyPDF to report
veraPDF as unavailable and skip the fast PDF/A path.
- OCRmyPDF no longer silently corrupts a non-embedded CID (CJK) text layer when
producing PDF/A ({issue}`1561`). PDF/A requires all fonts to be embedded, so
Ghostscript substitutes and re-embeds non-embedded CID fonts — such as the OCR
text layer Adobe Acrobat adds to scanned CJK documents — which mangles the
text and destroys searchability. OCRmyPDF now detects non-embedded CID fonts
before conversion: with `--output-type auto` (the default) it produces a
regular PDF and preserves the existing text layer, and with an explicit
`--output-type pdfa*` it stops with an error rather than emit corrupted
output. Use `--output-type pdf` to keep the text layer, or `--force-ocr` to
rebuild it with embedded fonts.
- Writing the output PDF to standard output (`ocrmypdf input.pdf -`) is now
protected against corruption at the operating system level. Previously
OCRmyPDF relied on no in-process code — third-party libraries, plugins, or
stray `print()` calls — ever writing to stdout; a single accidental write
would silently corrupt the PDF. The command line program now saves the real
stdout at startup, before plugins are loaded or any worker process/thread is
started, and redirects file descriptor 1 to stderr, so that only OCRmyPDF's
final PDF output can reach stdout. A consequence is that a plugin which
intentionally prints to stdout will have that output redirected to stderr.
- Added the public API function {func}`ocrmypdf.configure_stdout_protection`,
which installs this same protection. Like {func}`ocrmypdf.configure_logging`,
it is optional and intended for callers that want command-line-like behavior;
applications that manage their own standard output should not call it.
- Fixed an uncaught `UnicodeDecodeError` when processing a PDF whose
`/DocumentInfo` dictionary contains a `/Name` key encoded in Latin-1 (or
another non-UTF-8 encoding), such as `/Saks#e5r`. `repair_docinfo_nuls` now
treats such a block as malformed, logs a message, and continues instead of
crashing the pipeline ({issue}`1540`). Current pikepdf releases tolerate these
keys by surrogate-escaping them, but older versions raised while iterating the
dictionary.
## v17.7.1
- Fixed a severe, Windows-specific performance regression in the "Scanning
+1 -1
View File
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
[project]
name = "ocrmypdf"
version = "17.7.1"
version = "17.8.0"
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
readme = "README.md"
license = "MPL-2.0"
+2
View File
@@ -19,6 +19,7 @@ from ocrmypdf._version import __version__
from ocrmypdf.api import (
Verbosity,
configure_logging,
configure_stdout_protection,
ocr,
)
from ocrmypdf.exceptions import (
@@ -53,6 +54,7 @@ __all__ = [
'BoundingBox',
'configure_debug_logging',
'configure_logging',
'configure_stdout_protection',
'DpiError',
'EncryptedPdfError',
'Executor',
+6 -1
View File
@@ -16,7 +16,7 @@ from contextlib import suppress
from ocrmypdf import __version__
from ocrmypdf._pipelines.ocr import run_pipeline_cli
from ocrmypdf._validation import check_options
from ocrmypdf.api import Verbosity, configure_logging
from ocrmypdf.api import Verbosity, configure_logging, configure_stdout_protection
from ocrmypdf.cli import get_options_and_plugins
from ocrmypdf.exceptions import (
BadArgsError,
@@ -39,6 +39,11 @@ def sigbus(*args):
def run(args=None):
"""Run the ocrmypdf command line interface."""
# Protect the real stdout before loading plugins or starting any worker
# processes/threads, so that only our final PDF output can reach it and
# stray writes from plugins or libraries are diverted to stderr.
configure_stdout_protection()
options, plugin_manager = get_options_and_plugins(args=args)
with suppress(AttributeError, PermissionError):
+6 -2
View File
@@ -88,8 +88,12 @@ def repair_docinfo_nuls(pdf):
if isinstance(v, str) and b'\x00' in bytes(v):
pdf.docinfo[k] = bytes(v).replace(b'\x00', b'')
modified = True
except TypeError:
# TypeError can also be raised if dictionary items are unexpected types
except (TypeError, UnicodeDecodeError):
# TypeError: DocumentInfo is not a dictionary, or its items are
# unexpected types.
# UnicodeDecodeError: a DocumentInfo key or value contains bytes that
# are not valid PDFDocEncoding/UTF-16, e.g. a Latin-1 /Name key such as
# /Saks#e5r. Older pikepdf raised while iterating such a block (#1540).
log.error("File contains a malformed DocumentInfo block - continuing anyway.")
return modified
+89 -19
View File
@@ -30,18 +30,23 @@ from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._metadata import repair_docinfo_nuls
from ocrmypdf._options import OcrOptions, ProcessingMode, TaggedPdfMode
from ocrmypdf._pageboxes import log_box_repairs, repair_page_boxes
from ocrmypdf._stdoutprotect import get_protected_stdout_fd
from ocrmypdf.exceptions import (
ColorConversionNeededError,
DigitalSignatureError,
DpiError,
EncryptedPdfError,
InputFileError,
NonEmbeddedFontsError,
PriorOcrFoundError,
SubprocessOutputError,
TaggedPDFError,
UnsupportedImageFormatError,
)
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
from ocrmypdf.pdfa import (
file_claims_pdfa,
find_nonembedded_cid_fonts,
generate_pdfa_ps,
speculative_pdfa_conversion,
)
@@ -976,6 +981,12 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -
# pikepdf can deal with this, but we make the world a better place by
# stamping them out as soon as possible.
with pikepdf.open(input_pdf) as pdf_file:
# Ghostscript would substitute and re-embed any non-embedded CID font to
# satisfy PDF/A, corrupting CJK text (e.g. an Acrobat OCR layer) in the
# process. Refuse rather than silently damage the user's text layer.
nonembedded = find_nonembedded_cid_fonts(pdf_file)
if nonembedded:
raise NonEmbeddedFontsError(nonembedded)
if repair_docinfo_nuls(pdf_file):
pdf_file.save(fix_docinfo_file)
else:
@@ -1069,14 +1080,46 @@ def try_speculative_pdfa(input_pdf: Path, context: PdfContext) -> Path | None:
return None
def _ghostscript_pdfa_fallback(input_pdf: Path, context: PdfContext) -> Path | None:
"""Best-effort PDF/A conversion via Ghostscript for 'auto' output type.
Returns the converted PDF/A path, or None if Ghostscript is unavailable,
fails, or cannot produce valid PDF/A. Never raises: 'auto' mode degrades to
a regular PDF instead of erroring or emitting corrupted output.
Args:
input_pdf: Path to the PDF to convert.
context: The PDF context.
"""
from ocrmypdf._exec import ghostscript
if not ghostscript.available():
return None
try:
ps_stub = generate_postscript_stub(context)
gs_out = convert_to_pdfa(input_pdf, ps_stub, context)
except (
SubprocessOutputError,
ColorConversionNeededError,
NonEmbeddedFontsError,
) as e:
log.info('Auto mode: Ghostscript could not produce PDF/A (%s)', e)
return None
if not file_claims_pdfa(gs_out)['pass']:
log.info('Auto mode: Ghostscript output is not valid PDF/A')
return None
return gs_out
def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]:
"""Best-effort PDF/A for 'auto' output type.
This function attempts to produce PDF/A without requiring Ghostscript:
1. If verapdf is available, tries speculative conversion with validation
2. Without verapdf, passes through as PDF/A if safe (input already PDF/A
or force-ocr was used)
3. Falls back to regular PDF if neither condition is met
Order of attempts, first success wins:
1. Non-embedded CID fonts -> regular PDF (Ghostscript would corrupt them).
2. Speculative conversion validated by verapdf (no Ghostscript).
3. Without verapdf, pass through if already PDF/A or rebuilt with force-ocr.
4. Ghostscript conversion (best-effort; failures fall through).
5. Regular PDF if none of the above produced PDF/A.
Args:
input_pdf: Path to the PDF to convert
@@ -1088,25 +1131,42 @@ def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]:
"""
from ocrmypdf._exec import verapdf
# If verapdf available, try speculative conversion with validation
# Non-embedded CID fonts cannot be made PDF/A without Ghostscript font
# substitution that corrupts CID/CJK text. Rather than risk an existing
# text layer, downgrade to a regular PDF (the same outcome as any other
# case where best-effort PDF/A is not achievable).
with pikepdf.open(input_pdf) as pdf_file:
nonembedded = find_nonembedded_cid_fonts(pdf_file)
if nonembedded:
log.info(
"Auto mode: input has non-embedded CID fonts (%s) that cannot be "
"converted to PDF/A without corrupting the text; outputting a "
"regular PDF. Use --output-type pdf to select this explicitly.",
', '.join(sorted(nonembedded)),
)
return (input_pdf, 'pdf')
# Cheap path: speculative conversion validated by verapdf (no Ghostscript).
if verapdf.available():
result = try_speculative_pdfa(input_pdf, context)
if result is not None:
return (result, 'pdfa')
# verapdf validation failed - fall through to regular PDF
log.info(
'Auto mode: speculative PDF/A validation failed, outputting regular PDF'
)
return (input_pdf, 'pdf')
# Without verapdf, check if we can pass through as PDF/A
if _is_safe_pdfa(input_pdf, context.options):
# Pass through as-is (no modifications needed)
log.info('Auto mode: speculative PDF/A validation failed')
elif _is_safe_pdfa(input_pdf, context.options):
# No verapdf, but the input is already PDF/A or was rebuilt with
# --force-ocr, so we can pass it through without Ghostscript.
log.info('Auto mode: passing through as PDF/A (input already compliant)')
return (input_pdf, 'pdfa')
# Fall through to regular PDF
log.info('Auto mode: no verapdf available and input is not PDF/A, outputting PDF')
# Fall back to Ghostscript to produce real PDF/A (v16 behavior). Best-effort:
# if Ghostscript is unavailable or cannot safely produce PDF/A, keep a
# regular PDF rather than error.
gs_out = _ghostscript_pdfa_fallback(input_pdf, context)
if gs_out is not None:
log.info('Auto mode: produced PDF/A via Ghostscript')
return (gs_out, 'pdfa')
log.info('Auto mode: could not produce PDF/A, outputting regular PDF')
return (input_pdf, 'pdf')
@@ -1278,8 +1338,18 @@ def copy_final(
log.debug('%s -> %s', input_file, output_file)
with input_file.open('rb') as input_stream:
if output_file == '-':
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
sys.stdout.flush()
fd = get_protected_stdout_fd()
if fd is not None:
# Stdout protection is active: write to the preserved real
# stdout. dup the saved fd so the with-block's close() does not
# close our long-lived descriptor.
with os.fdopen(os.dup(fd), 'wb') as stdout_stream:
copyfileobj(input_stream, stdout_stream)
stdout_stream.flush()
else:
# No protection installed (e.g. plain API use): legacy behavior.
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
sys.stdout.flush()
elif hasattr(output_file, 'writable'):
output_stream = cast(BinaryIO, output_file)
copyfileobj(input_stream, output_stream) # type: ignore[misc]
+1 -1
View File
@@ -489,7 +489,7 @@ def postprocess(
else:
pdf_out = pdf_file
if context.options.output_type == 'auto':
# Best effort PDF/A - never uses Ghostscript
# Best effort PDF/A - may use Ghostscript as a last resort
pdf_out, actual_type = try_auto_pdfa(pdf_out, context)
# Store actual output type for reporting
context.options.extra_attrs['_actual_output_type'] = actual_type
+83
View File
@@ -0,0 +1,83 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
"""Protect the real standard output from corruption by stray writes.
When OCRmyPDF writes its final PDF to standard output (``ocrmypdf in.pdf -``),
the bytes on stdout must be exactly the PDF and nothing else. Any accidental
write to file descriptor 1 anywhere in the process -- from a third-party
library, a plugin, or a stray ``print()`` -- would silently corrupt the output.
This module enforces that guarantee at the operating system level. It saves a
private duplicate of the real stdout and points file descriptor 1 at standard
error, so that anything that writes to stdout lands harmlessly on stderr. Only
OCRmyPDF's final "produce the PDF" step writes to the preserved real stdout, via
:func:`get_protected_stdout_fd`.
"""
from __future__ import annotations
import os
import sys
import threading
_lock = threading.Lock()
_saved_fd: int | None = None
_active = False
def protect_stdout() -> bool:
"""Redirect file descriptor 1 to stderr and preserve the real stdout.
After this call, any write to file descriptor 1 -- including ``print()`` and
writes from third-party C libraries -- is redirected to standard error and
cannot corrupt the real standard output. The real stdout is preserved on a
private file descriptor available from :func:`get_protected_stdout_fd`.
This mutates process-global state and affects the whole process. It must be
called once, early, before any plugins are loaded or any worker
process/thread is started, so that all of them inherit the redirected
descriptor.
Returns:
True if protection was installed (or was already active). False if
stdout is not backed by a real OS file descriptor -- for example under
a test harness that captures stdout -- in which case nothing is changed.
"""
global _saved_fd, _active
with _lock:
if _active:
return True
try:
fd1 = sys.stdout.fileno()
except (AttributeError, OSError, ValueError):
# stdout is not backed by a real file descriptor (e.g. captured by
# a test harness or replaced with an in-memory stream).
return False
try:
sys.stdout.flush()
saved = os.dup(fd1)
os.dup2(2, fd1) # point stdout at stderr
except OSError:
return False
_saved_fd = saved
_active = True
return True
def get_protected_stdout_fd() -> int | None:
"""Return the preserved real stdout file descriptor, or None if inactive."""
return _saved_fd if _active else None
def protected_stdout_isatty() -> bool | None:
"""Whether the preserved real stdout is a terminal.
Returns None if protection is not active, in which case the caller should
fall back to ``sys.stdout.isatty()``. When protection is active,
``sys.stdout`` reports the terminal status of stderr (its descriptor was
redirected), so this consults the saved real-stdout descriptor instead.
"""
if not _active or _saved_fd is None:
return None
return os.isatty(_saved_fd)
+8 -1
View File
@@ -19,6 +19,7 @@ from ocrmypdf._defaults import DEFAULT_ROTATE_PAGES_THRESHOLD
from ocrmypdf._exec import unpaper
from ocrmypdf._options import OcrOptions, ProcessingMode
from ocrmypdf._plugin_manager import OcrmypdfPluginManager
from ocrmypdf._stdoutprotect import protected_stdout_isatty
from ocrmypdf.exceptions import (
BadArgsError,
InputFileError,
@@ -231,7 +232,13 @@ def create_input_file(options: OcrOptions, work_folder: Path) -> tuple[Path, str
def check_requested_output_file(options: OcrOptions) -> None:
if options.output_file == '-':
if sys.stdout.isatty():
# When stdout protection is active, fd 1 has been redirected to stderr,
# so sys.stdout.isatty() would report stderr's status. Consult the
# preserved real stdout instead, falling back when protection is off.
is_tty = protected_stdout_isatty()
if is_tty is None:
is_tty = sys.stdout.isatty()
if is_tty:
raise BadArgsError(
"Output was set to stdout '-' but it looks like stdout "
"is connected to a terminal. Please redirect stdout to a "
+1 -1
View File
@@ -1,3 +1,3 @@
# SPDX-FileCopyrightText: 2022 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
__version__ = "17.7.1"
__version__ = "17.8.0"
+33
View File
@@ -56,6 +56,7 @@ from ocrmypdf._pipelines.hocr_to_ocr_pdf import run_hocr_to_ocr_pdf_pipeline
from ocrmypdf._pipelines.ocr import run_pipeline, run_pipeline_cli
from ocrmypdf._pipelines.pdf_to_hocr import run_hocr_pipeline
from ocrmypdf._plugin_manager import OcrmypdfPluginManager, get_plugin_manager
from ocrmypdf._stdoutprotect import protect_stdout
from ocrmypdf._validation import check_options
from ocrmypdf.cli import ArgumentParser, get_parser
from ocrmypdf.exceptions import ExitCode
@@ -233,6 +234,37 @@ def configure_logging(
return log
def configure_stdout_protection() -> bool:
"""Protect the process's real standard output from corruption.
When OCRmyPDF writes its final PDF to standard output (``output_file='-'``),
the bytes on stdout must be exactly the PDF and nothing else. By default
OCRmyPDF relies on no in-process code -- third party libraries, plugins, or
stray ``print()`` calls -- ever writing to stdout. This function makes that
guarantee real: it redirects file descriptor 1 to standard error and
preserves a private copy of the real stdout, so that any accidental write to
stdout lands harmlessly on stderr while OCRmyPDF still emits its final PDF to
the preserved descriptor.
This is the same protection the ``ocrmypdf`` command line program installs.
It is optional for API users and works like :func:`configure_logging`: call
it before :func:`ocr` if you want command-line-like behavior. It must be
called once, early -- before any plugins are loaded or any worker
process/thread is started -- so that they inherit the redirected descriptor.
Because it mutates process-global file descriptors and affects the entire
process, applications that manage their own standard output (for example,
a long-lived service that calls :func:`ocr` in-process) should **not** call
this function.
Returns:
True if protection was installed (or was already active). False if
stdout is not backed by a real operating system file descriptor, in
which case nothing is changed.
"""
return protect_stdout()
def _check_no_conflicting_ocr_params(
locals_dict: dict,
kwargs: dict,
@@ -965,6 +997,7 @@ __all__ = [
'Verbosity',
'check_options',
'configure_logging',
'configure_stdout_protection',
'create_options',
'get_parser',
'get_plugin_manager',
@@ -130,7 +130,7 @@ class TesseractOptions(BaseModel):
metavar='PSM',
choices=range(0, 14),
dest=f'{namespace}_pagesegmode',
help="Set Tesseract page segmentation mode (see tesseract --help).",
help="Set Tesseract page segmentation mode (see tesseract --help-extra).",
)
tess.add_argument(
+31
View File
@@ -139,6 +139,37 @@ class TaggedPDFError(InputFileError):
)
class NonEmbeddedFontsError(InputFileError):
"""Input has non-embedded CID fonts that PDF/A conversion would corrupt.
PDF/A requires all fonts to be embedded. Ghostscript substitutes and embeds
a replacement for non-embedded CID (CJK) fonts, which corrupts the
character-to-Unicode mapping and silently destroys an existing text layer
(commonly an Adobe Acrobat CJK OCR layer). OCRmyPDF refuses to produce such
output rather than damage the user's data
(see https://github.com/ocrmypdf/OCRmyPDF/issues/1561).
"""
def __init__(self, fonts: set[str]):
"""Build guidance naming the offending fonts."""
super().__init__()
font_list = ', '.join(sorted(fonts))
self.message = dedent(
f"""\
The input PDF contains non-embedded CID (character ID) fonts: {font_list}.
PDF/A requires all fonts to be embedded. Converting to PDF/A would
make Ghostscript substitute and embed replacement fonts, which
corrupts CID (e.g. CJK/Chinese-Japanese-Korean) text and silently
destroys an existing text layer such as one produced by Adobe Acrobat.
Use --output-type pdf to keep the existing text layer intact without
PDF/A conversion, or --force-ocr to discard the existing layer and
rebuild it with embedded fonts.
"""
)
class ColorConversionNeededError(BadArgsError):
"""PDF needs color conversion to a standard color space.
+67 -6
View File
@@ -137,6 +137,65 @@ def file_claims_pdfa(filename: Path):
return pdfa_dict
def _cid_font_is_embedded(type0_font: Dictionary) -> bool:
"""Return True if a Type0 font's CID descendant carries embedded glyphs."""
for descendant in type0_font.get(Name.DescendantFonts, []):
descriptor = descendant.get(Name.FontDescriptor, None)
if descriptor is not None and any(
key in descriptor for key in (Name.FontFile, Name.FontFile2, Name.FontFile3)
):
return True
return False
def find_nonembedded_cid_fonts(pdf: Pdf) -> set[str]:
"""Find CID-keyed (Type0) fonts that lack embedded glyph data.
PDF/A requires every font to be embedded. When Ghostscript converts a PDF
to PDF/A it must substitute and embed a replacement for any non-embedded
font. For CID-keyed fonts -- which is how CJK text is encoded, including the
OCR text layers produced by Adobe Acrobat -- this substitution routinely
corrupts the character-to-Unicode mapping, silently destroying the
searchable text. Detecting these fonts lets the caller refuse PDF/A
conversion rather than emit corrupted output.
Simple (non-CID) non-embedded fonts are not reported: Ghostscript
substitutes standard encodings for them without corrupting the text, and
they are far too common to treat as conversion blockers.
Args:
pdf: An open ``pikepdf.Pdf`` to scan.
Returns:
The set of ``BaseFont`` names of non-embedded CID fonts found.
"""
found: set[str] = set()
def scan_resources(resources, depth: int = 0) -> None:
if resources is None or depth > 10:
return
fonts = resources.get(Name.Font, None)
if fonts is not None:
for font in fonts.values():
try:
if font.get(Name.Subtype) != Name.Type0:
continue
if not _cid_font_is_embedded(font):
basefont = str(font.get(Name.BaseFont, '/(unnamed)'))
found.add(basefont.lstrip('/'))
except (AttributeError, TypeError, KeyError):
continue
xobjects = resources.get(Name.XObject, None)
if xobjects is not None:
for xobj in xobjects.values():
if xobj.get(Name.Subtype) == Name.Form and Name.Resources in xobj:
scan_resources(xobj[Name.Resources], depth + 1)
for page in pdf.pages:
scan_resources(page.get(Name.Resources, None))
return found
def _load_srgb_icc_profile() -> bytes:
"""Load the sRGB ICC profile from package data."""
return (package_files('ocrmypdf.data') / SRGB_ICC_PROFILE_NAME).read_bytes()
@@ -191,12 +250,14 @@ def add_srgb_output_intent(pdf: Pdf) -> None:
icc_stream[Name.N] = 3 # RGB has 3 components
# Create OutputIntent dictionary
output_intent = Dictionary({
'/Type': Name.OutputIntent,
'/S': Name('/GTS_PDFA1'),
'/OutputConditionIdentifier': 'sRGB',
'/DestOutputProfile': icc_stream,
})
output_intent = Dictionary(
{
'/Type': Name.OutputIntent,
'/S': Name('/GTS_PDFA1'),
'/OutputConditionIdentifier': 'sRGB',
'/DestOutputProfile': icc_stream,
}
)
# Add to catalog's OutputIntents array
if Name.OutputIntents not in pdf.Root:
+9 -3
View File
@@ -62,12 +62,18 @@ def get_version(
f"Could not find program '{program}' on the PATH"
) from e
match = re.match(regex, output.strip())
if not match:
# Some tools (e.g. veraPDF launched on a recent JDK) print warnings before
# the version line, so scan each line rather than only the start of output.
version = None
for line in output.splitlines():
match = re.match(regex, line.strip())
if match:
version = match.group(1)
break
if version is None:
raise MissingDependencyError(
f"The program '{program}' did not report its version. "
f"Message was:\n{output}"
)
version = match.group(1)
return version
+42
View File
@@ -0,0 +1,42 @@
# SPDX-FileCopyrightText: 2024 James R. Barlow
# SPDX-License-Identifier: MIT
"""Test plugin that deliberately writes garbage to stdout.
Used to verify that OCRmyPDF's stdout protection diverts stray writes (from
plugins or libraries) to stderr, so that a PDF written to stdout is never
corrupted. Pollutes at three points: plugin import (main process), the
``validate`` hook (main process), and the ``filter_ocr_image`` hook (worker
process/thread).
"""
from __future__ import annotations
import os
import sys
from ocrmypdf import hookimpl
POLLUTION = b'POLLUTION'
def _pollute(where: bytes) -> None:
# Write to file descriptor 1 directly (as a careless C library might) and
# via Python's sys.stdout (as a stray print() might).
os.write(1, POLLUTION + b'-fd1-' + where + b'\n')
print(POLLUTION.decode() + '-stdout-' + where.decode())
sys.stdout.flush()
# Pollute at import time, which happens while plugins are being loaded.
_pollute(b'import')
@hookimpl
def validate(pdfinfo, options):
_pollute(b'validate')
@hookimpl
def filter_ocr_image(page, image):
_pollute(b'filter_ocr_image')
return image
+3
View File
@@ -76,6 +76,9 @@ the copyright holder(s) and license(s) applicable to these resources.
* - missing_docinfo.pdf
- synthetic
- PDF file with no /DocumentInfo section
* - docinfo_latin1_key.pdf
- synthetic
- PDF whose /DocumentInfo dictionary has a /Name key with Latin-1 bytes (/Saks#e5r) that is not valid UTF-8
* - overlay.pdf
- synthetic
- PDF file generated by PDFPen pro that triggered content stream parse errors
+32
View File
@@ -0,0 +1,32 @@
%PDF-1.3
%¿÷¢þ
1 0 obj
<< /Pages 3 0 R /Type /Catalog >>
endobj
2 0 obj
<< /Author (Geomatikk AS) /Beskrivelse () /Creator (OCRmyPDF 16.10.0 / EasyOCR-PDF 1.7.2) /CreatorVersion (6.36.0.918) /Dokumentidplanreg () /Enhetsnavn () /Hyperlink (1) /Opprinnelse () /Producer (pikepdf 9.5.2) /RegistrationDate (N/A) /Saksansvarlig#20enhet () /Saksbehandler () /Saksnr () /Saks#e5r () /Status () >>
endobj
3 0 obj
<< /Count 1 /Kids [ 4 0 R ] /Type /Pages >>
endobj
4 0 obj
<< /Contents 5 0 R /MediaBox [ 0 0 612 792 ] /Parent 3 0 R /Resources << >> /Type /Page >>
endobj
5 0 obj
<< /Length 0 /Filter /FlateDecode >>
stream
endstream
endobj
xref
0 6
0000000000 65535 f
0000000015 00000 n
0000000064 00000 n
0000000398 00000 n
0000000457 00000 n
0000000563 00000 n
trailer << /Info 2 0 R /Root 1 0 R /Size 6 /ID [<c5231b8cfab9c82526c0da7475add5da><c5231b8cfab9c82526c0da7475add5da>] >>
startxref
633
%%EOF
+27 -6
View File
@@ -100,9 +100,9 @@ def test_redo_ocr(resources, outpdf):
out = check_ocrmypdf(in_, out, '--redo-ocr')
after = PdfInfo(out, detailed_analysis=True)
assert before[0].has_text and after[0].has_text
assert (
before[0].get_textareas() != after[0].get_textareas()
), "Expected text to be different after re-OCR"
assert before[0].get_textareas() != after[0].get_textareas(), (
"Expected text to be different after re-OCR"
)
def test_argsfile(resources, outdir):
@@ -768,9 +768,9 @@ def test_sidecar_pagecount(resources, outpdf):
# There should a formfeed between each pair of pages, so the count of
# formfeeds is the page count less one
assert (
ocr_text.count('\f') == num_pages - 1
), "Sidecar page count does not match PDF page count"
assert ocr_text.count('\f') == num_pages - 1, (
"Sidecar page count does not match PDF page count"
)
def test_sidecar_nonempty(resources, outpdf):
@@ -889,6 +889,27 @@ def test_version_check():
get_version('echo')
def test_get_version_skips_leading_warning_lines(monkeypatch):
"""VeraPDF 1.30.0 prints JVM warnings before its version line."""
from subprocess import CompletedProcess
import ocrmypdf.subprocess as sp
output = (
"WARNING: Final field flavour has been mutated reflectively\n"
"WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid this\n"
"veraPDF 1.30.0\n"
"Built: Wed Jun 03 13:29:00 PDT 2026\n"
)
def fake_run(args, **kwargs):
return CompletedProcess(args, 0, stdout=output, stderr="")
monkeypatch.setattr(sp, 'run', fake_run)
version = get_version('verapdf', regex=r'veraPDF (\d+(\.\d+)*)')
assert version == '1.30.0'
@pytest.mark.parametrize(
'threshold, optimize, output_type, expected',
[
+28 -1
View File
@@ -6,13 +6,14 @@ from __future__ import annotations
import datetime as dt
import warnings
from shutil import copyfile
from unittest.mock import MagicMock, patch
import pikepdf
import pytest
from pikepdf.models.metadata import decode_pdf_date
from ocrmypdf._jobcontext import PdfContext
from ocrmypdf._metadata import metadata_fixup
from ocrmypdf._metadata import metadata_fixup, repair_docinfo_nuls
from ocrmypdf._pipeline import convert_to_pdfa
from ocrmypdf.api import setup_plugin_infrastructure
from ocrmypdf.cli import get_options_and_plugins
@@ -43,6 +44,32 @@ def test_preserve_docinfo(output_type, resources, outpdf):
assert pdfa_info['output'] == output_type
def test_repair_docinfo_nuls_undecodable_key(caplog):
"""A DocumentInfo key with bytes that don't decode must not crash.
Some PDFs use a /Name dictionary key in DocumentInfo whose bytes are not
valid PDFDocEncoding/UTF-8 (e.g. Latin-1 ``/Saks#e5r``). Older pikepdf
raised UnicodeDecodeError while iterating such a dictionary. The repair
must log and continue rather than propagate the exception. See #1540.
"""
pdf = MagicMock()
pdf.docinfo.items.side_effect = UnicodeDecodeError(
'utf-8', b'Saks\xe5r', 4, 5, 'invalid continuation byte'
)
# Make isinstance(pdf.docinfo, Dictionary) succeed so we reach the loop.
with patch('ocrmypdf._metadata.Dictionary', MagicMock):
result = repair_docinfo_nuls(pdf)
assert result is False
assert 'malformed DocumentInfo' in caplog.text
def test_repair_docinfo_nuls_undecodable_key_real_file(resources):
"""Opening a real file with a Latin-1 DocumentInfo key must not crash."""
with pikepdf.open(resources / 'docinfo_latin1_key.pdf') as pdf:
# Should return without raising regardless of pikepdf's decode behavior.
repair_docinfo_nuls(pdf)
@pytest.mark.parametrize("output_type", ['pdfa', 'pdf'])
def test_override_metadata(output_type, resources, outpdf, caplog):
input_file = resources / 'c02-22.pdf'
+213 -2
View File
@@ -7,10 +7,221 @@ import os
import pikepdf
import pytest
from pikepdf import Name
from ocrmypdf.exceptions import MissingDependencyError
from ocrmypdf.exceptions import ExitCode, MissingDependencyError
from ocrmypdf.pdfa import file_claims_pdfa, find_nonembedded_cid_fonts
from .conftest import check_ocrmypdf
from .conftest import check_ocrmypdf, run_ocrmypdf_api
def _make_cid_font(
pdf: pikepdf.Pdf, *, embedded: bool, basefont: str
) -> pikepdf.Object:
"""Build a Type0/CID font object, optionally embedding glyph data."""
descriptor = pikepdf.Dictionary(
Type=Name.FontDescriptor, FontName=Name(basefont), Flags=4
)
if embedded:
# The actual bytes do not matter; only the presence of FontFile2 marks
# the CID font as embedded.
descriptor.FontFile2 = pdf.make_stream(b'\x00\x01\x00\x00 fake font program')
cidfont = pdf.make_indirect(
pikepdf.Dictionary(
Type=Name.Font,
Subtype=Name.CIDFontType2,
BaseFont=Name(basefont),
FontDescriptor=descriptor,
CIDSystemInfo=pikepdf.Dictionary(
Registry='Adobe', Ordering='Identity', Supplement=0
),
)
)
return pdf.make_indirect(
pikepdf.Dictionary(
Type=Name.Font,
Subtype=Name.Type0,
BaseFont=Name(basefont),
Encoding=Name.Identity_H,
DescendantFonts=pikepdf.Array([cidfont]),
)
)
def _write_cid_font_pdf(path, *, embedded: bool, basefont='/ABCDEF+TestCID'):
with pikepdf.new() as pdf:
page = pdf.add_blank_page()
font = _make_cid_font(pdf, embedded=embedded, basefont=basefont)
page.Resources = pikepdf.Dictionary(Font=pikepdf.Dictionary(F0=font))
pdf.save(path)
class TestFindNonembeddedCidFonts:
def test_blank_page_reports_nothing(self, tmp_path):
path = tmp_path / 'blank.pdf'
with pikepdf.new() as pdf:
pdf.add_blank_page()
pdf.save(path)
with pikepdf.open(path) as pdf:
assert find_nonembedded_cid_fonts(pdf) == set()
def test_detects_nonembedded_cid_font(self, tmp_path):
path = tmp_path / 'nonembedded.pdf'
_write_cid_font_pdf(path, embedded=False)
with pikepdf.open(path) as pdf:
assert find_nonembedded_cid_fonts(pdf) == {'ABCDEF+TestCID'}
def test_ignores_embedded_cid_font(self, tmp_path):
path = tmp_path / 'embedded.pdf'
_write_cid_font_pdf(path, embedded=True)
with pikepdf.open(path) as pdf:
assert find_nonembedded_cid_fonts(pdf) == set()
def test_detects_nonembedded_cid_font_in_form_xobject(self, tmp_path):
path = tmp_path / 'xobject.pdf'
with pikepdf.new() as pdf:
page = pdf.add_blank_page()
font = _make_cid_font(pdf, embedded=False, basefont='/ZZZ+Hidden')
form = pdf.make_stream(
b'',
Type=Name.XObject,
Subtype=Name.Form,
BBox=pikepdf.Array([0, 0, 1, 1]),
Resources=pikepdf.Dictionary(Font=pikepdf.Dictionary(F0=font)),
)
page.Resources = pikepdf.Dictionary(XObject=pikepdf.Dictionary(Fm0=form))
pdf.save(path)
with pikepdf.open(path) as pdf:
assert find_nonembedded_cid_fonts(pdf) == {'ZZZ+Hidden'}
@pytest.fixture
def nonembedded_cid_pdf(tmp_path):
"""A PDF with a real, non-embedded CID (CJK) text layer, as Acrobat produces."""
reportlab = pytest.importorskip('reportlab')
del reportlab
from reportlab.lib.pagesizes import letter
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
from reportlab.pdfgen import canvas
path = tmp_path / 'cjk_nonembedded.pdf'
pdfmetrics.registerFont(UnicodeCIDFont('STSong-Light')) # Adobe-GB1, not embedded
c = canvas.Canvas(str(path), pagesize=letter)
c.setFont('STSong-Light', 24)
c.drawString(60, 650, '你好世界')
c.showPage()
c.save()
# Sanity check that we built the structure under test.
with pikepdf.open(path) as pdf:
assert find_nonembedded_cid_fonts(pdf)
return path
def test_pdfa_rejects_nonembedded_cid_font(nonembedded_cid_pdf, outpdf):
"""Explicit PDF/A on a non-embedded CID layer must error, not corrupt it."""
exitcode = run_ocrmypdf_api(
nonembedded_cid_pdf,
outpdf,
'--plugin',
'tests/plugins/tesseract_noop.py',
'--skip-text',
'--output-type',
'pdfa',
)
assert exitcode == ExitCode.input_file
assert not outpdf.exists() or outpdf.stat().st_size == 0
def test_auto_downgrades_nonembedded_cid_font_to_pdf(nonembedded_cid_pdf, outpdf):
"""Auto mode preserves the text layer by outputting a regular PDF."""
check_ocrmypdf(
nonembedded_cid_pdf,
outpdf,
'--plugin',
'tests/plugins/tesseract_noop.py',
'--skip-text',
'--output-type',
'auto',
)
# Not PDF/A, and the original non-embedded layer survived untouched.
assert not file_claims_pdfa(outpdf)['pass']
with pikepdf.open(outpdf) as pdf:
assert find_nonembedded_cid_fonts(pdf)
def test_auto_falls_back_to_ghostscript_for_pdfa(resources, outpdf, monkeypatch):
"""Auto mode produces PDF/A via Ghostscript when the cheap path can't."""
# Force the speculative (veraPDF) path off so the fallback is exercised.
monkeypatch.setattr('ocrmypdf._exec.verapdf.available', lambda: False)
check_ocrmypdf(
resources / 'francais.pdf',
outpdf,
'--plugin',
'tests/plugins/tesseract_noop.py',
'--output-type',
'auto',
)
assert file_claims_pdfa(outpdf)['pass']
def test_auto_outputs_pdf_when_ghostscript_unavailable(resources, outpdf, monkeypatch):
"""With neither veraPDF nor Ghostscript, auto outputs a plain PDF."""
monkeypatch.setattr('ocrmypdf._exec.verapdf.available', lambda: False)
monkeypatch.setattr('ocrmypdf._exec.ghostscript.available', lambda: False)
check_ocrmypdf(
resources / 'francais.pdf',
outpdf,
'--plugin',
'tests/plugins/tesseract_noop.py',
'--output-type',
'auto',
)
assert not file_claims_pdfa(outpdf)['pass']
def test_auto_degrades_when_ghostscript_cannot_make_pdfa(
resources, outpdf, monkeypatch
):
"""If Ghostscript produces non-PDF/A output, auto keeps a plain PDF (no error)."""
monkeypatch.setattr('ocrmypdf._exec.verapdf.available', lambda: False)
exitcode = run_ocrmypdf_api(
resources / 'francais.pdf',
outpdf,
'--plugin',
'tests/plugins/tesseract_noop.py',
'--plugin',
'tests/plugins/gs_pdfa_failure.py',
'--output-type',
'auto',
)
assert exitcode == ExitCode.ok
assert outpdf.exists()
assert not file_claims_pdfa(outpdf)['pass']
def test_auto_degrades_when_ghostscript_raises(resources, outpdf, monkeypatch):
"""A Ghostscript conversion exception in auto mode degrades to plain PDF."""
from ocrmypdf.exceptions import ColorConversionNeededError
monkeypatch.setattr('ocrmypdf._exec.verapdf.available', lambda: False)
monkeypatch.setattr('ocrmypdf._exec.ghostscript.available', lambda: True)
def boom(*args, **kwargs):
raise ColorConversionNeededError()
monkeypatch.setattr('ocrmypdf._pipeline.convert_to_pdfa', boom)
exitcode = run_ocrmypdf_api(
resources / 'francais.pdf',
outpdf,
'--plugin',
'tests/plugins/tesseract_noop.py',
'--output-type',
'auto',
)
assert exitcode == ExitCode.ok
assert outpdf.exists()
assert not file_claims_pdfa(outpdf)['pass']
@pytest.mark.parametrize('optimize', (0, 3))
+107 -2
View File
@@ -12,9 +12,11 @@ import pikepdf
import pytest
from PIL import Image
from ocrmypdf._exec import ghostscript
from ocrmypdf._options import OcrOptions
from ocrmypdf._plugin_manager import get_plugin_manager
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution
from ocrmypdf.pluginspec import GhostscriptRasterDevice
from .conftest import check_ocrmypdf
@@ -507,6 +509,110 @@ class TestRasterizerWithNonStandardBoxes:
assert pdfium_size == (400, 500), f"pypdfium size: {pdfium_size}"
@pytest.fixture
def pdf_with_offset_mediabox_origin(tmp_path):
"""Create a single-page PDF whose MediaBox has a non-zero origin.
Tools that crop/rotate non-destructively (e.g. PDF Arranger) shift the
MediaBox origin rather than re-rendering content, producing a MediaBox like
``[0, 440, 600, 800]`` with the visible content still inside the box. This
fixture reproduces that shape with a full-page gradient so the visible
region is unambiguously non-blank. Regression fixture for issue #1709.
"""
# Full-page gradient so the entire MediaBox region carries content.
img = _create_gradient_image(600, 800)
img_bytes = BytesIO()
img.save(img_bytes, format='PNG')
img_bytes.seek(0)
pdf_bytes = BytesIO()
img2pdf.convert(
img_bytes.read(),
layout_fun=img2pdf.get_fixed_dpi_layout_fun((72, 72)),
outputstream=pdf_bytes,
**IMG2PDF_KWARGS,
)
pdf_bytes.seek(0)
pdf_path = tmp_path / 'offset_mediabox_origin.pdf'
with pikepdf.open(pdf_bytes) as pdf:
page = pdf.pages[0]
# Shift the lower-left y origin so the box is [0, 440, 600, 800]: a
# 600x360 visible region whose content lies entirely within the box.
page.MediaBox = pikepdf.Array([0, 440, 600, 800])
page.CropBox = pikepdf.Array([0, 440, 600, 800])
pdf.save(pdf_path)
return pdf_path
def _nonwhite_fraction(pdf_path, png_path) -> float:
"""Rasterize page 1 of pdf_path and return the fraction of non-white pixels."""
ghostscript.rasterize_pdf(
pdf_path,
png_path,
raster_device=GhostscriptRasterDevice.PNGGRAY,
raster_dpi=Resolution(72, 72),
pageno=1,
rotation=0,
)
with Image.open(png_path) as im:
gray = im.convert('L')
histogram = gray.histogram()
total = sum(histogram)
# Treat near-white (>= 250) as background; everything else is page content.
nonwhite = sum(histogram[:250])
return nonwhite / total
class TestOffsetMediaBoxOrigin:
"""Regression tests for issue #1709.
A non-zero MediaBox origin (e.g. from PDF Arranger crops) must not cause
--force-ocr to drop the page content and emit a blank page. Both rasterizers
are covered because the bug surfaced regardless of which one rendered.
"""
@pytest.mark.parametrize(
'rasterizer',
[
'ghostscript',
pytest.param(
'pypdfium',
marks=pytest.mark.skipif(
not PYPDFIUM_AVAILABLE, reason="pypdfium2 not installed"
),
),
],
)
def test_force_ocr_preserves_offset_origin_content(
self, pdf_with_offset_mediabox_origin, rasterizer, outpdf, tmp_path
):
"""--force-ocr must preserve content when the MediaBox origin is non-zero."""
# Sanity check: the input genuinely has content in its visible region.
input_fraction = _nonwhite_fraction(
pdf_with_offset_mediabox_origin, tmp_path / 'input.png'
)
assert input_fraction > 0.5, "test fixture should have a non-blank page"
check_ocrmypdf(
pdf_with_offset_mediabox_origin,
outpdf,
'--force-ocr',
'--rasterizer',
rasterizer,
'--plugin',
'tests/plugins/tesseract_noop.py',
)
# The output page must not be blank: the visible content survives.
output_fraction = _nonwhite_fraction(outpdf, tmp_path / 'output.png')
assert output_fraction > 0.5, (
f"output page is blank (non-white fraction {output_fraction:.3f}); "
"content was dropped for a non-zero MediaBox origin (issue #1709)"
)
class TestRasterizerWithRotationAndBoxes:
"""Test rasterizer + rotation + nonstandard boxes combinations."""
@@ -671,8 +777,7 @@ class TestRasterizerWithRotationAndBoxes:
expected = self._get_expected_size(rotation)
assert abs(gs_img.size[0] - expected[0]) <= 2, (
f"GS width at {rotation}°: {gs_img.size[0]}, "
f"expected {expected[0]}"
f"GS width at {rotation}°: {gs_img.size[0]}, expected {expected[0]}"
)
assert abs(gs_img.size[1] - expected[1]) <= 2, (
f"GS height at {rotation}°: {gs_img.size[1]}, "
+27
View File
@@ -48,6 +48,33 @@ def test_stdout(ocrmypdf_exec, resources, outpdf):
assert check_pdf(output_file)
def test_stdout_protected_from_pollution(ocrmypdf_exec, resources, outpdf):
if 'COV_CORE_DATAFILE' in os.environ:
pytest.skip("Coverage uses stdout")
input_file = str(resources / 'francais.pdf')
output_file = str(outpdf)
# A plugin deliberately writes garbage to stdout during the run. With stdout
# protection active, that garbage must be diverted to stderr and never reach
# the PDF we are writing to stdout.
with open(output_file, 'wb') as output_stream:
p_args = ocrmypdf_exec + [
input_file,
'-',
'--plugin',
'tests/plugins/tesseract_noop.py',
'--plugin',
'tests/plugins/stdout_polluter.py',
]
p = run(p_args, stdout=output_stream, stderr=PIPE, stdin=DEVNULL, check=True)
assert check_pdf(output_file), "PDF on stdout was corrupted"
with open(output_file, 'rb') as f:
assert b'POLLUTION' not in f.read(), "pollution leaked into the PDF"
assert b'POLLUTION' in p.stderr, "pollution was not diverted to stderr"
@pytest.mark.skipif(os.name == 'nt', reason='Windows does not support /dev/null')
def test_dev_null(resources):
if 'COV_CORE_DATAFILE' in os.environ:
+5 -1
View File
@@ -54,10 +54,14 @@ def test_tagged_pdf_mode_ignore_with_skip_text(resources, outpdf, caplog):
outpdf,
tagged_pdf_mode='ignore',
skip_text=True, # Tagged PDF has text, so skip pages with text
# output_type=pdf avoids the Ghostscript PDF/A step, whose treatment of
# the structure tree is version-dependent (Ghostscript >= 10 discards it,
# 9.x preserves it). We only want to assert OCRmyPDF's own behavior here.
output_type='pdf',
plugins=['tests/plugins/tesseract_noop.py'],
)
assert 'structural markup' in caplog.text
# skip-text leaves the text pages untouched, so the structure tree remains valid
# skip-text leaves the text pages untouched, so OCRmyPDF keeps the structure tree
with pikepdf.open(outpdf) as pdf:
assert Name.StructTreeRoot in pdf.Root