#!/usr/bin/env python3
"""TrueType + Type1-base-14 font-program embedding (file-remediation Stage 2,
Engine E2/E3 library) — Phases 1-2 of the font-embed engine
(docs/plan-font-embed-engine.md).

Fixes veraPDF PDF/UA-1 **7.21.4.1** ("font program not embedded") for the
Windows-core case: a `/TrueType` or `/Type1` font (Times/Arial/Courier-family,
by `/BaseFont`) whose FontDescriptor carries no `FontFile*`. Mechanism:
subset the matching bundled Liberation face (`standards/fonts/`, SIL OFL,
committed in-repo) to the font's used Unicode range via `fontTools.subset`,
then inject the subset bytes as a `/FontFile2` stream on the FontDescriptor
with `/Length1` = the full sfnt byte length. Glyph *advances* come from the
PDF's own `/Widths` array, not the embedded outline, so layout is unaffected;
only the glyph outlines are a metric-compatible clone. Raw pixel diff can't
tell that apart from a wrong substitution on text-dense pages (anti-aliasing
alone reads as ~10-15%), so the gate in remediate_pdf.py uses
`fidelity_gate.structural_diff` instead: fraction of pixels whose intensity
changes by more than `FONT_EMBED_DELTA` (ignores anti-aliasing, catches
structural breakage), bounded by `FONT_EMBED_STRUCT_PCT`.

P2: a non-embedded `/Type1` base-14 font (Times/Helvetica/Courier-family) is
embedded the same way, EXCEPT `/FontFile2` is only valid on a `/TrueType`
descriptor, so the font's `/Subtype` is flipped `/Type1` -> `/TrueType` after
injection (veraPDF requires the FontFile type match the subtype). Some Type1
base-14 fonts (e.g. `/Times-Roman`) carry neither a `/FontDescriptor` nor a
`/Widths` array at all — both are synthesized from the subset TrueType's own
metrics (OS/2 or hhea ascent/descent, post.italicAngle, hmtx advances) when
absent; an existing descriptor/Widths (already-tagged PDFs) is left as-is.
Symbol/dingbat Type1 fonts (`/Symbol`, `/ZapfDingbats`, unmapped families)
have no free bundleable clone and stay `skipped_no_substitute` (P3/opt-in).

Any font this module can't safely embed degrades gracefully — never a
crash: `skipped_type3` (Type3, always exempt from 7.21.4.1), `skipped_embedded`
(already has a FontFile*, including a Type0's descendant CIDFont), or
`skipped_no_substitute` (non-TrueType/non-Type1, a TrueType with no descriptor,
an unmapped family, or the mapped face isn't in the bundle — e.g.
Symbol/Wingdings/Webdings have no free clone).

Deferred to later phases (see plan Phases table):
  P3 — CFF/OTF -> FontFile3 + AMSFonts CM (LaTeX pixel-zero) + free Symbol.

`fix_degenerate_widths` (veraPDF PDF/UA-1 **7.21.5**): a different defect —
the font program is embedded, but every glyph's advance width, as declared
INSIDE the Type1 charstrings (the `hsbw`/`sbw` operator's `wx`), is a
uniform placeholder (typically 1000) that disagrees with the font
dictionary's `/Widths` array, which is what actually drives layout. Seen on
LibreOffice/OpenOffice Type1 exports (`BaseFont` matches the subsetter's
`[A-Z]AAAAA+` naming). `/Widths` is authoritative and is never touched.

This is fixed IN PLACE, not by substitution: `fontTools.t1Lib` decrypts the
Type1 program, and for each used glyph's charstring the `wx` operand of its
`hsbw`/`sbw` operator is patched to the matching `/Widths` value, then the
font is re-encrypted and re-embedded under the same `/FontFile` key — the
original outlines (Minion Pro, Adobe Garamond Pro, ...) are untouched, only
the advance-width metadata baked into the program changes. A first version
of this fix substituted a bundled Liberation TrueType face instead (the
same mechanism as P2 above); that cleared 7.21.5 but visibly changed glyph
shapes (Liberation forced into Garamond's advance widths reads cramped on
italic runs) and never reached exact-zero pixel fidelity. The in-place
rewrite is strictly better on every axis verified against it: pixel-diff
`worst_pct: 0.0` (vs ~2.4% for substitution), and it needs no Unicode/AGL
resolution or bundled substitute at all — the character-code -> glyph
mapping comes straight from the Type1 program's own parsed `/Encoding`
array (`t1Lib` exposes it as a 256-entry list; these fonts carry no PDF
`/Encoding` — the LibreOffice export relies on the font's built-in one).
Any font whose glyph program can't be safely patched (parse failure,
non-default `/FontMatrix`, a used code's glyph missing from the
CharStrings dict, no `hsbw`/`sbw` operator, two codes mapping to the same
glyph with disagreeing target widths, or the re-encoded output not
matching the expected Type1-writer byte layout) is skipped, never guessed
at — a wrong patch would render the wrong characters or corrupt the font
program, which is strictly worse than leaving 7.21.5 failing.

Usage:
    python pdf_font_embed.py --input FILE --output FILE [--json]
"""

from __future__ import annotations

import argparse
import io
import json
import logging
import os
import re
import sys
import tempfile
from pathlib import Path

import pikepdf
from fontTools import subset as ft_subset
from fontTools import t1Lib
from fontTools.ttLib import TTFont

# ponytail: fontTools logs a per-table "NOT subset; don't know how to
# subset; dropped" INFO/WARNING for tables (e.g. FFTM) that are safe to
# drop from a subset — expected noise, not a defect. Silence it.
logging.getLogger("fontTools.subset").setLevel(logging.ERROR)

_SUBSET_RE = re.compile(r"^/[A-Z]{6}\+")
_STYLE_SUFFIX_RE = re.compile(r"[-,]([A-Za-z]+)$")

# Family map (subset-prefix stripped, case-insensitive prefix match). Order
# matters: checked in sequence, first match wins.
_SUBSTITUTES = [
    (re.compile(r"^(times|timesnewroman)", re.I), "LiberationSerif"),
    (re.compile(r"^(arial|helvetica|arialmt)", re.I), "LiberationSans"),
    (re.compile(r"^(courier|couriernew)", re.I), "LiberationMono"),
]

# Used-glyph codec proxy per /Encoding (plan §"Used-glyph proxy"). Unlisted
# or dict /Encoding with no recognized /BaseEncoding falls back to cp1252
# (WinAnsi). Subsetting to a SUPERSET of the used glyphs just costs a few
# extra glyphs — harmless.
# KNOWN LIMITATION (adversarial review 2026-07-23): this ignores an /Encoding
# /Differences array. A font that remaps codes to glyphs the codec can't
# produce (e.g. Differences -> /bullet,/trademark) yields a codeset that is
# NOT a superset, so the subset physically lacks those glyphs. This is NOT
# silently shipped: veraPDF 7.21.4.1 also requires an embedded font define
# ALL referenced glyphs, so such a doc FAILS the gate -> `.rejected.pdf`,
# needs_manual (under-remediated, never corrupt). Follow-up: resolve
# /Differences glyph names -> Unicode (AGL) before subsetting to cover them.
_ENCODING_CODEC = {"/MacRomanEncoding": "mac_roman", "/WinAnsiEncoding": "cp1252"}


def get_fonts_dir() -> Path:
    return Path(__file__).resolve().parent.parent / "standards" / "fonts"


def _strip_subset_prefix(name: str) -> str:
    return _SUBSET_RE.sub("", name)


def _is_embedded(fd) -> bool:
    if fd is None:
        return False
    return any(k in fd for k in ("/FontFile", "/FontFile2", "/FontFile3"))


def _cid_descriptor(font):
    """The descendant CIDFont's FontDescriptor for a /Type0 font, or None."""
    descendants = font.get("/DescendantFonts")
    if not descendants:
        return None
    try:
        cid_font = descendants[0]
    except (IndexError, TypeError):
        return None
    return cid_font.get("/FontDescriptor")


def _base_family(base_font: str):
    name = _strip_subset_prefix(base_font or "").lstrip("/")
    for pattern, family in _SUBSTITUTES:
        if pattern.match(name):
            return family
    return None


def _style_variant(base_font: str, fd) -> str:
    """Regular/Bold/Italic/BoldItalic from the BaseFont's Adobe-convention
    style suffix (FamilyName-Suffix or FamilyName,Suffix), falling back to
    FontDescriptor /Flags (italic bit 0x40, ForceBold bit 0x40000) only when
    the name gives no signal."""
    name = _strip_subset_prefix(base_font or "").lstrip("/")
    m = _STYLE_SUFFIX_RE.search(name)
    suffix = (m.group(1) if m else name).lower()
    bold = "bold" in suffix
    italic = "italic" in suffix or "oblique" in suffix
    if not (bold or italic) and fd is not None:
        flags = int(fd.get("/Flags", 0) or 0)
        italic = bool(flags & 0x40)
        bold = bool(flags & 0x40000)
    if bold and italic:
        return "BoldItalic"
    if bold:
        return "Bold"
    if italic:
        return "Italic"
    return "Regular"


def _codec_for(font) -> str:
    enc = font.get("/Encoding")
    if isinstance(enc, pikepdf.Name):
        return _ENCODING_CODEC.get(str(enc), "cp1252")
    if isinstance(enc, pikepdf.Dictionary):
        base = enc.get("/BaseEncoding")
        if isinstance(base, pikepdf.Name):
            return _ENCODING_CODEC.get(str(base), "cp1252")
    return "cp1252"


def _used_codepoints(font) -> set[int]:
    """Used-glyph proxy: FirstChar..LastChar decoded under the resolved
    /Encoding (defaults to the full 0x20-0xFF range if absent)."""
    first = font.get("/FirstChar")
    last = font.get("/LastChar")
    lo = int(first) if first is not None else 0x20
    hi = int(last) if last is not None else 0xFF
    codec = _codec_for(font)
    codepoints = {0x20}  # space, always
    for code in range(max(0, lo), min(0xFF, hi) + 1):
        try:
            codepoints.add(ord(bytes([code]).decode(codec)))
        except (ValueError, UnicodeDecodeError):
            continue
    return codepoints


def _subset_font(ttf_path: Path, codepoints: set[int]) -> bytes:
    font = TTFont(str(ttf_path))
    options = ft_subset.Options()
    options.notdef_outline = True
    subsetter = ft_subset.Subsetter(options=options)
    subsetter.populate(unicodes=sorted(codepoints))
    subsetter.subset(font)
    buf = io.BytesIO()
    font.save(buf)
    return buf.getvalue()


def _make_descriptor(pdf, ttfont: TTFont, base_font: str, variant: str):
    """Synthesize a minimal indirect /FontDescriptor from the subset
    TrueType's own metrics (P2: Type1 base-14 fonts, e.g. `/Times-Roman`,
    that carry no descriptor at all). Metrics scaled to the PDF's 1000
    glyph-space units; /Widths drives layout, this is presentation-only."""
    head = ttfont["head"]
    scale = 1000 / head.unitsPerEm
    try:
        os2 = ttfont["OS/2"]
        ascent, descent = os2.sTypoAscender, os2.sTypoDescender
        cap_height = getattr(os2, "sCapHeight", 0) or ascent
    except KeyError:
        hhea = ttfont["hhea"]
        ascent, descent = hhea.ascent, hhea.descent
        cap_height = ascent
    flags = 0x20  # nonsymbolic
    if variant in ("Italic", "BoldItalic"):
        flags |= 0x40  # italic bit
    fd_dict = pikepdf.Dictionary({
        "/Type": pikepdf.Name("/FontDescriptor"),
        "/FontName": pikepdf.Name(base_font),
        "/Flags": flags,
        "/FontBBox": pikepdf.Array([round(v * scale) for v in
                                     (head.xMin, head.yMin, head.xMax, head.yMax)]),
        "/ItalicAngle": ttfont["post"].italicAngle,
        "/Ascent": round(ascent * scale),
        "/Descent": round(descent * scale),
        "/CapHeight": round(cap_height * scale),
        "/StemV": 80,  # ponytail: not derivable from the font; standard estimate
    })
    return pdf.make_indirect(fd_dict)


def _synthesize_widths(font, ttfont: TTFont, codec: str) -> None:
    """Synthesize /FirstChar, /LastChar, /Widths from the subset TrueType's
    hmtx (P2: Type1 base-14 fonts with no /Widths at all). Width 0 for any
    code the codec/cmap can't resolve — matches the used-glyph proxy's
    graceful-degrade stance (subsetting/metrics are optimizations, not a
    correctness requirement)."""
    first_char, last_char = font.get("/FirstChar"), font.get("/LastChar")
    first = int(first_char) if first_char is not None else 0x20
    last = int(last_char) if last_char is not None else 0xFF
    cmap = ttfont.getBestCmap()
    hmtx = ttfont["hmtx"]
    upm = ttfont["head"].unitsPerEm
    widths = []
    for code in range(first, last + 1):
        width = 0
        if 0 <= code <= 0xFF:
            try:
                glyph = cmap.get(ord(bytes([code]).decode(codec)))
                if glyph is not None:
                    width = round(hmtx.metrics[glyph][0] * 1000 / upm)
            except (ValueError, UnicodeDecodeError):
                pass
        widths.append(width)
    font["/FirstChar"] = first
    font["/LastChar"] = last
    font["/Widths"] = pikepdf.Array(widths)


def repair_missing_fonts(pdf):
    """Walk every page's /Resources /Font (recursing into Form-XObject
    resources, cycle-guarded — mirrors pdf_font_repair.repair_fonts),
    embedding a Liberation substitute for each non-embedded, substitutable
    /TrueType or /Type1 font (Type1 is flipped to /TrueType on embed).
    Returns {"embedded", "skipped_no_substitute", "skipped_type3",
    "skipped_embedded"}."""
    counts = {"embedded": 0, "skipped_no_substitute": 0,
              "skipped_type3": 0, "skipped_embedded": 0}
    fonts_dir = get_fonts_dir()
    seen_fonts = set()
    seen_forms = set()

    def handle_font(font):
        key = font.objgen if font.is_indirect else id(font)
        if key in seen_fonts:
            return
        seen_fonts.add(key)

        subtype = str(font.get("/Subtype", ""))

        if subtype == "/Type3":
            counts["skipped_type3"] += 1
            return

        if subtype == "/Type0":
            # P1 does not embed Type0/CID fonts; only report embedded-ness.
            if _is_embedded(_cid_descriptor(font)):
                counts["skipped_embedded"] += 1
            else:
                counts["skipped_no_substitute"] += 1
            return

        fd = font.get("/FontDescriptor")
        if _is_embedded(fd):
            counts["skipped_embedded"] += 1
            return

        if subtype not in ("/TrueType", "/Type1"):
            # MMType1/unknown (P3) — not handled.
            counts["skipped_no_substitute"] += 1
            return

        if subtype == "/TrueType" and fd is None:
            # TrueType needs an existing descriptor to attach FontFile2 to.
            # Type1 descriptors are synthesized below when absent (P2).
            counts["skipped_no_substitute"] += 1
            return

        base_font = str(font.get("/BaseFont", ""))
        family = _base_family(base_font)
        if family is None:
            # Includes /Symbol, /ZapfDingbats — no free bundleable clone.
            counts["skipped_no_substitute"] += 1
            return

        variant = _style_variant(base_font, fd)
        ttf_path = fonts_dir / f"{family}-{variant}.ttf"
        if not ttf_path.exists():
            counts["skipped_no_substitute"] += 1
            return

        try:
            data = _subset_font(ttf_path, _used_codepoints(font))
        except Exception:
            data = ttf_path.read_bytes()  # subset failure -> embed full font

        stream = pdf.make_stream(data)
        stream["/Length1"] = len(data)

        if subtype == "/Type1":
            # KNOWN LIMITATION (adversarial review 2026-07-23): the cycle guard
            # keys on the font-dict objgen, not the descriptor. If two /Type1
            # dicts SHARE one /FontDescriptor, font A embeds + flips to
            # /TrueType, then font B sees the now-embedded shared descriptor,
            # counts skipped_embedded, and is left /Type1 with a /FontFile2 in
            # its descriptor (inconsistent). Contained by the gate: veraPDF
            # 7.21.4.1 still fails for B -> doc bounced to needs_manual, never
            # shipped. Follow-up: flip every font sharing the descriptor.
            needs_descriptor = fd is None
            needs_widths = font.get("/Widths") is None
            if needs_descriptor or needs_widths:
                subset_ttf = TTFont(io.BytesIO(data))
            if needs_descriptor:
                fd = _make_descriptor(pdf, subset_ttf, base_font, variant)
                font["/FontDescriptor"] = fd
            if needs_widths:
                _synthesize_widths(font, subset_ttf, _codec_for(font))
            if font.get("/Encoding") is None:
                font["/Encoding"] = pikepdf.Name("/WinAnsiEncoding")
            # /FontFile2 is only valid on a /TrueType descriptor — veraPDF
            # requires the FontFile type match the subtype.
            font["/Subtype"] = pikepdf.Name("/TrueType")

        fd["/FontFile2"] = stream
        counts["embedded"] += 1

    def handle_resources(res):
        if res is None:
            return
        for font in (res.get("/Font") or {}).values():
            handle_font(font)
        for xo in (res.get("/XObject") or {}).values():
            if str(xo.get("/Subtype", "")) != "/Form":
                continue
            if xo.is_indirect:
                fkey = xo.objgen
                if fkey in seen_forms:
                    continue
                seen_forms.add(fkey)
            if xo.get("/Resources") is not None:
                handle_resources(xo.get("/Resources"))

    # KNOWN LIMITATION (adversarial review 2026-07-23): page.get("/Resources")
    # returns None when a page INHERITS /Resources from a /Pages tree node, so
    # fonts declared only on the parent are skipped (shared with the sibling
    # pdf_font_repair.repair_fonts, which walks the same way). Degrades safely:
    # the doc keeps failing veraPDF 7.21.4.1 -> needs_manual, never corrupt.
    # Follow-up: resolve inherited resources (walk /Parent) in both walkers.
    for page in pdf.pages:
        handle_resources(page.get("/Resources"))

    return counts


def _charstring_width_index(program) -> int | None:
    """Index of wx (the advance the font PROGRAM declares) within a
    decompiled Type1 charstring's hsbw (sbx wx hsbw) or sbw
    (sbx sby wx wy sbw) operator, or None. Returning the index (not just
    the value) lets a caller both read and patch program[idx] in place."""
    for i, tok in enumerate(program):
        if tok == "hsbw" and i >= 2:
            return i - 1
        if tok == "sbw" and i >= 4:
            return i - 3
    return None


def _parse_type1(data: bytes):
    """Parse a raw Type1 FontFile stream via fontTools.t1Lib (needs a real
    file path, hence the tempfile). None on any parse failure — callers
    treat that as "can't confirm", not "no defect"."""
    fh, path = tempfile.mkstemp(suffix=".pfa")
    try:
        with os.fdopen(fh, "wb") as f:
            f.write(data)
        t1 = t1Lib.T1Font(path)
        t1.parse()
        return t1
    except Exception:
        return None
    finally:
        os.unlink(path)


# fontTools.t1Lib.T1Font.createData() (the t1write-derived round-trip
# writer used by getData()) always emits this exact trailer — 8 lines of 64
# '0' characters, then cleartomark/%%EndResource/%%EOF, "\n"-joined, with a
# leading "\n" separating it from the eexec ciphertext — regardless of font
# content. Used below to split the regenerated bytes into PDF FontFile
# Length1 (cleartext)/Length2 (encrypted binary)/Length3 (trailer) without
# guessing at byte offsets inside the (opaque, re-encrypted) binary blob.
# _t1_lengths asserts this shape holds and raises otherwise, so a future
# fontTools change that alters the writer trips a hard skip, never a
# silently malformed FontFile.
_T1_TRAILER = b"\n" + b"\n".join([b"0" * 64] * 8 + [b"cleartomark", b"%%EndResource", b"%%EOF"])
_T1_EEXEC_MARKER = b"currentfile eexec "
_DEFAULT_FONT_MATRIX = [0.001, 0, 0, 0.001, 0, 0]


def _t1_lengths(data: bytes) -> tuple[int, int, int]:
    """(Length1, Length2, Length3) for a Type1 program written by
    t1Lib.T1Font.getData(). Raises ValueError if the marker/trailer aren't
    where t1write always puts them — see _T1_TRAILER comment."""
    idx = data.find(_T1_EEXEC_MARKER)
    if idx == -1:
        raise ValueError("no eexec marker in regenerated Type1 data")
    length1 = idx + len(_T1_EEXEC_MARKER)
    if not data.endswith(_T1_TRAILER):
        raise ValueError("regenerated Type1 data has an unexpected trailer shape")
    length3 = len(_T1_TRAILER)
    length2 = len(data) - length1 - length3
    return length1, length2, length3


def fix_degenerate_widths(pdf):
    """veraPDF 7.21.5: an embedded Type1 font whose PROGRAM declares a
    uniform placeholder glyph width (typically 1000, see module docstring)
    that disagrees with the font dictionary's /Widths — the value that
    actually drives layout. /Widths is never modified; the font PROGRAM is
    patched in place (original outlines preserved, see module docstring for
    why this replaced an earlier Liberation-substitution approach).

    Only acts on a font that is ALL of: embedded /Type1 with /FontFile
    (not /FontFile3 — CFF is out of scope here), subset-prefix named,
    parses via t1Lib, uses the default /FontMatrix (so charstring wx is
    directly comparable to /Widths), has a built-in /Encoding array (t1Lib
    parses this as a 256-entry glyph-name list) whose every used code names
    a glyph present in CharStrings, has an hsbw/sbw operator for every one
    of those glyphs, has no two codes mapping to the same glyph with
    disagreeing target widths, and whose program's widths actually
    disagree with /Widths. Any font failing one of those is left untouched
    and counted, never guessed at.

    Returns {"fixed": n, "skipped_uncertain_mapping": n}."""
    counts = {"fixed": 0, "skipped_uncertain_mapping": 0}
    seen_fonts = set()
    seen_forms = set()

    def handle_font(font):
        key = font.objgen if font.is_indirect else id(font)
        if key in seen_fonts:
            return
        seen_fonts.add(key)

        if str(font.get("/Subtype", "")) != "/Type1":
            return
        fd = font.get("/FontDescriptor")
        if fd is None or "/FontFile" not in fd:
            return  # not an embedded Type1 program (FontFile3/CFF, or unembedded)
        base_font = str(font.get("/BaseFont", ""))
        if not _SUBSET_RE.match(base_font):
            return

        widths = font.get("/Widths")
        first_char = font.get("/FirstChar")
        if widths is None or first_char is None:
            return
        first = int(first_char)
        pdf_widths = {first + i: int(w) for i, w in enumerate(widths)}

        ff = fd["/FontFile"]
        data = ff.read_bytes()
        t1 = _parse_type1(data)
        if t1 is None:
            counts["skipped_uncertain_mapping"] += 1
            return

        if t1.font.get("FontMatrix") != _DEFAULT_FONT_MATRIX:
            # non-default FontMatrix: charstring wx isn't directly
            # comparable to /Widths without extra scaling we don't trust.
            counts["skipped_uncertain_mapping"] += 1
            return
        encoding = t1.font.get("Encoding")
        cs_dict = t1.font.get("CharStrings")
        if not isinstance(encoding, list) or cs_dict is None:
            counts["skipped_uncertain_mapping"] += 1
            return

        # Every USED code (present in /Widths) must name a real glyph.
        # Skip the whole font on any unresolved code rather than patch a
        # subset and leave the rest silently wrong.
        code_glyphs = {}
        for code, target in pdf_widths.items():
            if code >= len(encoding):
                continue
            glyph_name = encoding[code]
            if not glyph_name or glyph_name == ".notdef":
                continue
            if glyph_name not in cs_dict:
                counts["skipped_uncertain_mapping"] += 1
                return
            code_glyphs[code] = (glyph_name, target)
        if not code_glyphs:
            counts["skipped_uncertain_mapping"] += 1
            return

        # A single charstring carries one wx. If two codes share a glyph
        # (via /Encoding) but /Widths wants DIFFERENT advances for them,
        # one can't be satisfied — skip rather than silently pick a winner.
        glyph_targets = {}
        for glyph_name, target in code_glyphs.values():
            if glyph_name in glyph_targets and glyph_targets[glyph_name] != target:
                counts["skipped_uncertain_mapping"] += 1
                return
            glyph_targets[glyph_name] = target

        width_indices = {}
        disagrees = False
        for glyph_name, target in glyph_targets.items():
            cs = cs_dict[glyph_name]
            cs.decompile()
            idx = _charstring_width_index(cs.program)
            if idx is None:
                counts["skipped_uncertain_mapping"] += 1
                return
            width_indices[glyph_name] = idx
            if cs.program[idx] != target:
                disagrees = True
        if not disagrees:
            return  # program already agrees with /Widths — nothing to fix

        for glyph_name, target in glyph_targets.items():
            cs_dict[glyph_name].program[width_indices[glyph_name]] = target

        try:
            new_data = t1.getData()
            length1, length2, length3 = _t1_lengths(new_data)
        except Exception:
            counts["skipped_uncertain_mapping"] += 1
            return

        stream = pdf.make_stream(new_data)
        stream["/Length1"] = length1
        stream["/Length2"] = length2
        stream["/Length3"] = length3
        del fd["/FontFile"]
        fd["/FontFile"] = stream
        counts["fixed"] += 1

    def handle_resources(res):
        if res is None:
            return
        for font in (res.get("/Font") or {}).values():
            handle_font(font)
        for xo in (res.get("/XObject") or {}).values():
            if str(xo.get("/Subtype", "")) != "/Form":
                continue
            if xo.is_indirect:
                fkey = xo.objgen
                if fkey in seen_forms:
                    continue
                seen_forms.add(fkey)
            if xo.get("/Resources") is not None:
                handle_resources(xo.get("/Resources"))

    for page in pdf.pages:
        handle_resources(page.get("/Resources"))

    return counts


def main():
    parser = argparse.ArgumentParser(
        description="TrueType + Type1-base-14 font-program embedding (veraPDF 7.21.4.1)")
    parser.add_argument("--input", required=True)
    parser.add_argument("--output", required=True)
    parser.add_argument("--json", action="store_true")
    args = parser.parse_args()

    try:
        pdf = pikepdf.open(args.input)
        counts = repair_missing_fonts(pdf)
        pdf.save(args.output)
        pdf.close()
    except Exception as exc:
        if args.json:
            print(json.dumps({"error": str(exc)}), file=sys.stderr)
        else:
            print(f"ERROR: {exc}", file=sys.stderr)
        sys.exit(2)

    if args.json:
        print(json.dumps(counts, indent=2))
    else:
        print(f"embedded={counts['embedded']} "
              f"skipped_no_substitute={counts['skipped_no_substitute']} "
              f"skipped_type3={counts['skipped_type3']} "
              f"skipped_embedded={counts['skipped_embedded']}")


if __name__ == "__main__":
    main()
