Home Blog Chesterton's fence, or: why Ghostscript came back for one job
Blog

Chesterton's fence, or: why Ghostscript came back for one job

Olivier Carrère 6 min read

A previous post described producing PDF/X-4 files directly from LuaLaTeX without a Ghostscript post-processing step, on the theory that compliance should be a property of the build rather than something bolted on afterward by a separate tool with its own failure modes. The same reasoning applied to a sibling pipeline: the print-ready post-processor for a planning poster, which recompresses images, sets TrimBox/BleedBox, and writes metadata using nothing but pikepdf, Pillow, and PyMuPDF - a deliberately “no Ghostscript” pipeline, spelled out as such in the script’s own docstring.

Then a print shop opened the file in Acrobat Pro on Windows, and every glyph on the page rendered stacked on top of the one before it.

What the print shop saw

The symptom was total: not a missing character or a kerning glitch, but entire lines collapsed into an unreadable smear of overlapping glyphs, as if every letter had been placed at the same x-position instead of advancing across the line. The print shop sent a screenshot from the machine where they’d caught it.

The confusing part was that the file was fine everywhere else. Preview, Chrome, pdftoppm - every Poppler- or Quartz-based renderer showed exactly what the build was supposed to produce. The PDF wasn’t corrupt: the fonts were correctly embedded, correctly subsetted, and carried valid /W glyph-width arrays. One specific renderer, on one specific platform, was refusing to advance the pen between glyphs - and that renderer happened to be the one the print shop actually used to open the file before running it.

Narrowing it down

Walking through the symptom - fine in every Poppler/Quartz viewer, broken only in Acrobat/Reader, widths present and valid in the file itself - pointed at a known, narrow interaction between Acrobat’s font rasterizer and the embedded CID font glyph tables that xdvipdfmx (the DVI-to-PDF driver behind XeLaTeX) produces. It’s not a bug in the recompression pipeline: none of the box-setting, metadata, or image code touches font programs at all. The only fix that has a chance of working is rebuilding the font programs from scratch - and the only tool on hand that can do that is exactly the one the pipeline had gone out of its way to avoid.

Chesterton’s fence

The script’s docstring called out “no Ghostscript” as a feature, and removing it earlier had been the right call - for recompressing images and editing boxes and metadata, a general-purpose PDF distiller is a bigger hammer than the job needs, and it “helps” by rewriting the file in ways that are hard to predict and hard to verify without a full preflight on the output. But there’s a difference between “we don’t need this tool for this job” and “this tool never does anything this pipeline needs.” The Acrobat bug is a case of the first kind of removal running into the second kind of need: nobody took Ghostscript out because it was covering for a font issue - it was removed because it wasn’t needed for the tasks the script actually performed at the time. The day a new requirement showed up - reconstructing a CID font program - it turned out Ghostscript was the only tool in reach that could meet it.

It’s the same shape as the fence in the field: reasoning about why it isn’t needed is only half the job. It’s also worth checking, before ruling a tool out for good, what happens the day a requirement shows up that only that tool can meet.

Putting back exactly one job

The fix doesn’t revert the earlier decision. It adds one narrow, clearly-scoped pass at the very end of the pipeline, after everything else - image recompression, box geometry, metadata - has already been done the “no Ghostscript” way:

def rebuild_fonts_with_ghostscript(pdf: Path) -> bool:
    """
    Re-distills the PDF through Ghostscript (pdfwrite) to rebuild the CID
    font programs that xdvipdfmx embedded. Works around an Acrobat/Reader-
    specific rendering bug (entire lines of text stacked glyph on glyph,
    advance widths not respected) that doesn't appear in Poppler- or
    Quartz-based viewers (Preview, Chrome...) - the input PDF isn't
    corrupt, only Acrobat's rendering of these particular CID fonts is.
    Ghostscript rebuilds the glyph tables from scratch, which fixes it in
    practice.

    No image-downsampling flags are touched: the images have already been
    carefully recompressed and sized by recompress_images() above, and a
    second Ghostscript pass has no business degrading them again.

    Returns True if Ghostscript ran (and the PDF was rewritten in place),
    False if it's absent (a warning is printed, the pipeline continues
    unchanged).
    """
    if not shutil.which('gs'):
        print('  [warning] gs (Ghostscript) not found - font rebuild skipped.')
        print('            Install with: brew install ghostscript')
        return False

    tmp = pdf.with_suffix('.gsfonts.pdf')
    subprocess.run([
        'gs', '-o', str(tmp), '-sDEVICE=pdfwrite',
        '-dCompatibilityLevel=1.4',
        '-dDownsampleColorImages=false', '-dDownsampleGrayImages=false',
        '-dDownsampleMonoImages=false',
        '-dAutoRotatePages=/None',
        str(pdf),
    ], check=True, stdout=subprocess.DEVNULL)
    tmp.rename(pdf)
    print('  Fonts rebuilt (Ghostscript) - Acrobat bug worked around.')
    return True

The -dDownsample*Images=false flags are doing the real work of keeping this a font fix and nothing more: without them, Ghostscript would happily re-touch every raster image in the file, undoing the resolution-aware recompression the earlier pure-Python pass already did carefully. -sDEVICE=pdfwrite is what forces the rebuild - it re-distills the whole document, which is a heavier operation than a targeted font patch, but there is no lighter tool that rebuilds a CID font program from scratch.

One side effect has to be handled explicitly: Ghostscript renumbers the MediaBox to start at (0, 0), so TrimBox, BleedBox, and the DocInfo/XMP metadata all have to be reapplied after the font-rebuild pass, not just once earlier in the pipeline:

print('\n[6] Rebuilding fonts (Ghostscript)...')
if rebuild_fonts_with_ghostscript(dest):
    # Ghostscript renumbers the MediaBox from (0, 0) - TrimBox/BleedBox/
    # metadata survive relative to that new origin, but they're reapplied
    # explicitly rather than trusting Ghostscript's own reconstruction.
    pdf_gs = pikepdf.open(str(dest), allow_overwriting_input=True)
    try:
        set_pdf_boxes(pdf_gs, final_trim_box, final_bleed_box)
        write_metadata(pdf_gs, metadata)
        pdf_gs.save(str(dest), force_version='1.4')
    finally:
        pdf_gs.close()

print('\n[7] Linearizing...')
linearize(dest)

And if gs isn’t installed, the pipeline doesn’t hard-fail - it prints a warning and produces the file exactly as it did before this change. Print-ready output over a mandatory dependency, same as the non-blocking preflight further down the same pipeline: a missing tool degrades the guarantee, it doesn’t stop the build.

What “don’t fix it” actually means here

The pipeline “worked” in every viewer available in the build environment. It looked done. Nothing in the automated checks - or in Preview, or in Chrome - gave any reason to doubt it. The failure only surfaced in one specific renderer, on one specific operating system, at the one point that actually mattered: the print shop, right before the file went to press.

The lesson isn’t “never remove a dependency.” It’s that “works” is only as strong as the environments it’s been checked against, and a build’s own preflight can’t catch a rendering bug in a proprietary viewer it doesn’t run. The preflight checks on this pipeline verify OutputIntent, boxes, color space, and resolution - properties of the file itself. “Does Acrobat on Windows render this correctly” isn’t a property of the file; it’s a property of a specific renderer’s interpretation of it, and the only way to catch it is to actually open the file in that renderer, which is what the print shop did.

Summing up

  1. Removing Ghostscript from the post-processing pipeline was the right call for the jobs it was doing - recompressing images and editing boxes and metadata don’t need a general-purpose PDF distiller, and skipping it kept the pipeline predictable and its output verifiable.
  2. The bug that showed up wasn’t in that pipeline. It was a narrow, Acrobat-specific CID font-table rendering issue in fonts embedded upstream by xdvipdfmx, invisible in every Poppler- or Quartz-based viewer and only caught because the print shop happened to open the file in the one renderer that exposed it.
  3. Putting Ghostscript back for exactly that one job - a final font-rebuild pass with image downsampling explicitly disabled, TrimBox/BleedBox/metadata reapplied afterward since Ghostscript resets the MediaBox origin - fixed the file in Acrobat without reopening the door to the problems removing Ghostscript had avoided in the first place.

External sources

Hero image: “Broken wooden fence in the field” by Ivan Radic, licensed under CC BY 2.0.

Follow on LinkedIn for more

Articles on docs-as-code, DITA XML, YAML, and AI-assisted documentation.

Follow