Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5569d4db07 | ||
|
|
8de7b05fb9 | ||
|
|
72ce05768e | ||
|
|
3dc68778fc | ||
|
|
1aec92b919 | ||
|
|
43d3448709 | ||
|
|
7512b1042a | ||
|
|
efe83e8c54 | ||
|
|
a13d27bfb5 | ||
|
|
ea7ad7d683 | ||
|
|
8b20bb3c5b | ||
|
|
320876a6d1 | ||
|
|
dfbb4c9275 | ||
|
|
d4f5c2d160 | ||
|
|
263d6034be |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,3 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
__version__ = "17.7.1"
|
||||
__version__ = "17.8.0"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]}, "
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user