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.
This commit is contained in:
James R. Barlow
2026-06-30 00:01:42 -07:00
parent a13d27bfb5
commit efe83e8c54
6 changed files with 300 additions and 8 deletions
+23
View File
@@ -36,6 +36,7 @@ from ocrmypdf.exceptions import (
DpiError,
EncryptedPdfError,
InputFileError,
NonEmbeddedFontsError,
PriorOcrFoundError,
TaggedPDFError,
UnsupportedImageFormatError,
@@ -43,6 +44,7 @@ from ocrmypdf.exceptions import (
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,
)
@@ -977,6 +979,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:
@@ -1089,6 +1097,21 @@ def try_auto_pdfa(input_pdf: Path, context: PdfContext) -> tuple[Path, str]:
"""
from ocrmypdf._exec import verapdf
# 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')
# If verapdf available, try speculative conversion with validation
if verapdf.available():
result = try_speculative_pdfa(input_pdf, context)
+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: