diff --git a/src/ocrmypdf/_pipeline.py b/src/ocrmypdf/_pipeline.py index 607806af..bb4426d8 100644 --- a/src/ocrmypdf/_pipeline.py +++ b/src/ocrmypdf/_pipeline.py @@ -773,7 +773,20 @@ def generate_postscript_stub( options = context.get_options() pdf = pikepdf.open(input_file) pdfmark = get_pdfmark(pdf, options) - generate_pdfa_ps(output_file, pdfmark) + + ascii_docinfo = False + if ghostscript.version() >= '9.24': + ascii_docinfo = True + try: + for v in pdfmark.values(): + v.encode('ascii', errors='strict') + except UnicodeEncodeError: + log.warning( + "Ghostscript 9.24 does not support Unicode strings in metadata." + " These will be converted to ASCII if possible." + ) + + generate_pdfa_ps(output_file, pdfmark, ascii_docinfo=ascii_docinfo) def metadata_fixup( diff --git a/src/ocrmypdf/pdfa.py b/src/ocrmypdf/pdfa.py index 9b2e2ee7..5cac0e6c 100644 --- a/src/ocrmypdf/pdfa.py +++ b/src/ocrmypdf/pdfa.py @@ -15,7 +15,21 @@ # You should have received a copy of the GNU General Public License # along with OCRmyPDF. If not, see . -# Generate a PDFA_def.ps file for Ghostscript >= 9.14 +""" +Generate a PDFMARK file for Ghostscript >= 9.14, for PDF/A conversion + +pdfmark is an extension to the Postscript language that describes some PDF +features like bookmarks and annotations. It was originally specified Adobe +Distiller, for Postscript to PDF conversion: +https://www.adobe.com/content/dam/acom/en/devnet/acrobat/pdfs/pdfmark_reference.pdf + +Ghostscript uses pdfmark for PDF to PDF/A conversion as well. To use Ghostscript +to create a PDF/A, we need to create a pdfmark file with the necessary metadata. + +This takes care of the many version-specific bugs and pecularities in +Ghostscript's handling of pdfmark. + +""" from binascii import hexlify from datetime import datetime @@ -78,7 +92,8 @@ def def encode_text_string(s: str) -> str: - '''Encode text string to hex string for use in a PDF + """ + Encode text string to hex string for use in a PDF From PDF 32000-1:2008 a string object may be included in hexademical form if it is enclosed in angle brackets. For general Unicode the string should @@ -86,7 +101,7 @@ def encode_text_string(s: str) -> str: ASCII strings could be encoded as PdfDocEncoding literals provided that certain Postscript sequences are escaped. But it's far simpler to encode everything as UTF-16. - ''' + """ # Sometimes lazy C programmers leave their NULs at the end of strings they # insert into PDFs @@ -102,8 +117,27 @@ def encode_text_string(s: str) -> str: return ascii_hex_str +def _encode_ascii(s: str) -> str: + """ + Aggressively strip non-ASCII and PDF escape sequences + + Ghostscript 9.24+ lost support for UTF-16BE in pdfmark files for reasons + given in GhostPDL commit e997c683. Our temporary workaround is use ASCII + and drop all non-ASCII characters. A slightly improved alternative would + be to implement PdfDocEncoding in pikepdf and encode to that, or handle + metadata there. + """ + trans = str.maketrans({ + '(': '', + ')': '', + '\\': '', + }) + return s.translate(trans).encode('ascii', errors='replace').decode() + + def encode_pdf_date(d: datetime) -> str: - """Encode Python datetime object as PDF date string + """ + Encode Python datetime object as PDF date string From Adobe pdfmark manual: (D:YYYYMMDDHHmmSSOHH'mm') @@ -137,6 +171,13 @@ def encode_pdf_date(d: datetime) -> str: def decode_pdf_date(s: str) -> datetime: + """ + Decode a pdfmark date to a Python datetime object + + A pdfmark date is a string in a paritcular format. See the pdfmark + Reference for the specification. + + """ if s.startswith('D:'): s = s[2:] @@ -153,11 +194,13 @@ def decode_pdf_date(s: str) -> datetime: def _get_pdfmark_dates(pdfmark): - """Encode dates for pdfmark Postscript. The best way to deal with a - missing date entry is set it to null, because if the key is omitted - Ghostscript will set it to now - we do not want to erase the fact that - the value was unknown. Setting to an empty string breaks Ghostscript - 9.22 as reported here: + """ + Encode dates in the expected format for pdfmark Postscript + + The best way to deal with amissing date entry is set it to null, because if + the key is omitted Ghostscript will set it to now - we do not want to erase + the fact that the value was unknown. Setting to an empty string breaks + Ghostscript 9.22 as reported here: https://bugs.ghostscript.com/show_bug.cgi?id=699182 """ @@ -178,7 +221,7 @@ def _get_pdfmark_dates(pdfmark): yield ' {} null'.format(key) -def _get_pdfa_def(icc_profile, icc_identifier, pdfmark): +def _get_pdfa_def(icc_profile, icc_identifier, pdfmark, ascii_docinfo=False): """ Create a Postscript pdfmark file for Ghostscript. @@ -188,6 +231,9 @@ def _get_pdfa_def(icc_profile, icc_identifier, pdfmark): :param icc_profile: filename of the ICC profile to include in pdfmark :param icc_identifier: ICC identifier such as 'sRGB' :param pdfmark: a dictionary containing keys to include the pdfmark + :param ascii_docinfo: if True, the docinfo block must be encoded in pure + ASCII and may not contain UTF-16BE-BOM-hex encoded strings, as + required for Ghostscript 9.24+ :returns: a string containing the entire pdfmark @@ -198,14 +244,19 @@ def _get_pdfa_def(icc_profile, icc_identifier, pdfmark): # https://bugs.ghostscript.com/show_bug.cgi?id=697684 # Work around this by only adding keys that have a nontrivial value docinfo_keys = ('/Title', '/Author', '/Subject', '/Creator', '/Keywords') - docinfo_line_template = ' {key} <{value}>' def docinfo_gen(): + if not ascii_docinfo: + docinfo_line_template = ' {key} <{value}>' + encode = encode_text_string + else: + docinfo_line_template = ' {key} ({value})' + encode = _encode_ascii yield from _get_pdfmark_dates(pdfmark) for key in docinfo_keys: if key in pdfmark and pdfmark[key].strip() != '': line = docinfo_line_template.format( - key=key, value=encode_text_string(pdfmark[key])) + key=key, value=encode(pdfmark[key])) yield line docinfo = '\n'.join(docinfo_gen()) @@ -216,7 +267,7 @@ def _get_pdfa_def(icc_profile, icc_identifier, pdfmark): return result -def generate_pdfa_ps(target_filename, pdfmark, icc='sRGB'): +def generate_pdfa_ps(target_filename, pdfmark, icc='sRGB', ascii_docinfo=False): if icc == 'sRGB': icc_profile = SRGB_ICC_PROFILE else: diff --git a/tests/test_metadata.py b/tests/test_metadata.py index ac110081..58b56976 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -32,6 +32,7 @@ from ocrmypdf.pdfa import ( file_claims_pdfa, encode_pdf_date, decode_pdf_date, generate_pdfa_ps, SRGB_ICC_PROFILE ) +from ocrmypdf.exec import ghostscript try: import fitz @@ -89,8 +90,11 @@ def test_override_metadata(spoof_tesseract_noop, output_type, resources, before = pikepdf.open(input_file) after = pikepdf.open(outpdf) - assert after.metadata.Title == german - assert after.metadata.Author == chinese + if ghostscript.version() >= '9.24': + pytest.xfail('Ghostscript 9.24+ does not support Unicode DOCINFO') + + assert after.metadata.Title == german, after.metadata + assert after.metadata.Author == chinese, after.metadata assert after.metadata.get('/Keywords', '') == '' before_date = decode_pdf_date(str(before.metadata.CreationDate))