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
+139 -2
View File
@@ -7,10 +7,147 @@ 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)
@pytest.mark.parametrize('optimize', (0, 3))