#!/usr/bin/env python3
"""Normalize a PDF for USPTO e-filing (Patent Center / Trademark Center).

Patent Center rejects anything that is not exactly Letter (8.5 x 11") or
A4. Drawings exported from PowerPoint/Impress come out at slide size
(10 x 7.5"), Visio comes out at drawing size, and scanners often produce
Legal or an odd trimmed size -- all rejected.

This rebuilds each page onto a correctly sized portrait sheet, scaling the
existing content (vector stays vector -- nothing is rasterized) to sit
inside the drawing margins of 37 CFR 1.84(g):

    top 1"   left 1"   right 5/8"   bottom 3/8"

It can also stamp a uniform header ("Replacement Sheet" for amended
drawings under 37 CFR 1.121(d)), and it flattens sticky-note style
annotations, which Patent Center renders inconsistently or drops.

Usage
-----
    uspto-pdf --check figures.pdf               # report only, no writes
    uspto-pdf figures.pdf                       # writes figures-uspto.pdf
    uspto-pdf --header "Replacement Sheet" figures.pdf
    uspto-pdf --size a4 -o fixed.pdf figures.pdf
    uspto-pdf --in-place drawings/*.pdf         # overwrite, keeping .bak copies

The original file is never modified unless --in-place is given.

Not legal advice. Verify that every filing meets the USPTO rules in force
at the time you file.

Exit status is 0 on success, 1 if a file could not be processed, and (in
--check mode) 2 if any file is non-compliant.
"""

import argparse
import io
import shutil
import subprocess
import sys
import warnings
from pathlib import Path

try:
    from pypdf import PdfReader, PdfWriter, Transformation
    from pypdf.generic import ContentStream, NameObject
except ImportError:
    sys.exit("uspto-pdf: needs pypdf  ->  sudo apt install python3-pypdf python3-reportlab")

PT = 72.0
SHEETS = {"letter": (8.5 * PT, 11 * PT), "a4": (595.276, 841.890)}

# 37 CFR 1.84(g) drawing margins
MARGIN_TOP = 1.000 * PT
MARGIN_LEFT = 1.000 * PT
MARGIN_RIGHT = 0.625 * PT
MARGIN_BOTTOM = 0.375 * PT

TOLERANCE = 1.0  # pts; page size match slack


def sheet_label(width, height):
    for name, (w, h) in SHEETS.items():
        if abs(width - w) <= TOLERANCE and abs(height - h) <= TOLERANCE:
            return name
        if abs(width - h) <= TOLERANCE and abs(height - w) <= TOLERANCE:
            return name + "-landscape"
    return None


def find_font(family="DejaVu Sans"):
    """Path to an embeddable .ttf for the header, or None.

    The header must use a TrueType file we can embed -- reportlab's built-in
    Helvetica is a base-14 font that never embeds, and Patent Center flags
    non-embedded fonts. Prefers `family`, then any common system face.
    `family` may also be a path to a .ttf file.
    """
    if family.lower().endswith(".ttf") and Path(family).expanduser().is_file():
        return str(Path(family).expanduser())

    # fontconfig (Linux/macOS)
    try:
        out = subprocess.run(
            ["fc-match", "-f", "%{file}", family],
            capture_output=True, text=True, timeout=10,
        ).stdout.strip()
        if out.lower().endswith(".ttf") and Path(out).is_file():
            return out
    except (OSError, subprocess.SubprocessError):
        pass

    # Explicit search, so this works on Windows where fc-match is absent
    dirs = []
    if sys.platform == "win32":
        import os
        for var in ("LOCALAPPDATA", "WINDIR"):
            base = os.environ.get(var)
            if base:
                dirs.append(Path(base) / ("Microsoft/Windows/Fonts"
                                          if var == "LOCALAPPDATA" else "Fonts"))
    else:
        dirs += [Path.home() / ".local/share/fonts", Path.home() / ".fonts",
                 Path("/usr/share/fonts"), Path("/Library/Fonts"),
                 Path.home() / "Library/Fonts"]

    fam = family.lower().replace(" ", "")
    wanted = [fam + "_regular", fam + "-regular",
              fam, "arial", "calibri", "verdana", "dejavusans",
              "helvetica"]
    found = {}
    for d in dirs:
        if not d.is_dir():
            continue
        try:
            for f in d.rglob("*.ttf"):
                stem = f.stem.lower().replace(" ", "")
                for i, name in enumerate(wanted):
                    # skip bold/italic/other weights; we want the regular cut
                    if stem == name or stem == name.replace("_regular", ""):
                        found.setdefault(i, str(f))
        except OSError:
            continue
    return found[min(found)] if found else None


def make_header_page(text, sheet_w, sheet_h, size=12, baseline=None, family="DejaVu Sans"):
    """A single page carrying only the header text, centered in the top margin."""
    try:
        from reportlab.pdfbase import pdfmetrics
        from reportlab.pdfbase.ttfonts import TTFont
        from reportlab.pdfgen import canvas
    except ImportError:
        sys.exit("uspto-pdf: --header needs reportlab  ->  sudo apt install python3-reportlab")

    font = "Helvetica"
    ttf = find_font(family)
    if ttf:
        try:
            pdfmetrics.registerFont(TTFont("HeaderFont", ttf))
            font = "HeaderFont"
        except Exception:
            pass
    if font == "Helvetica":
        print("  WARNING no embeddable TrueType font found for the header; "
              "falling back to non-embedded Helvetica (pass --font PATH.ttf)",
              file=sys.stderr)

    if baseline is None:
        baseline = sheet_h - MARGIN_TOP / 2 - size / 3

    buf = io.BytesIO()
    c = canvas.Canvas(buf, pagesize=(sheet_w, sheet_h))
    c.setFont(font, size)
    c.drawCentredString(sheet_w / 2, baseline, text)
    c.save()
    buf.seek(0)

    reader = PdfReader(buf)
    page = reader.pages[0]

    # reportlab emits a "/F1 12 Tf" preamble for its default Helvetica even
    # when that font is never drawn with. Helvetica is a non-embedded base-14
    # font, and Patent Center flags non-embedded fonts -- so strip the dead
    # operator and its resource entry.
    if font != "Helvetica":
        try:
            cs = ContentStream(page.get_contents(), reader)
            keep, dead = [], set()
            for operands, operator in cs.operations:
                if operator == b"Tf" and str(operands[0]) == "/F1":
                    dead.add("/F1")
                    continue
                keep.append((operands, operator))
            cs.operations = keep
            with warnings.catch_warnings():
                # replace_contents() warns on a page not owned by a writer;
                # this page is a scratch overlay, so the warning is noise.
                warnings.simplefilter("ignore", DeprecationWarning)
                page.replace_contents(cs)
            fonts = page["/Resources"]["/Font"]
            for name in dead:
                if name in fonts:
                    del fonts[NameObject(name)]
        except Exception:
            pass  # cosmetic only -- never fail the run over this

    return page


def source_box(page):
    """Visible source rectangle: the CropBox if it trims the MediaBox."""
    mb = page.mediabox
    box = mb
    try:
        cb = page.cropbox
        if (float(cb.width) <= float(mb.width) + TOLERANCE
                and float(cb.height) <= float(mb.height) + TOLERANCE):
            box = cb
    except Exception:
        pass
    return (float(box.left), float(box.bottom), float(box.width), float(box.height))


def normalize_page(page, writer, sheet_w, sheet_h, header, valign, max_scale):
    """Place one source page, upright and scaled, on a fresh compliant sheet."""
    x0, y0, sw, sh = source_box(page)
    rotate = int(page.get("/Rotate", 0) or 0) % 360

    # Move the source box origin to (0,0), then undo any /Rotate so the
    # content is upright before we measure it.
    t = Transformation().translate(-x0, -y0)
    if rotate == 90:
        t = t.rotate(-90).translate(0, sw)
        sw, sh = sh, sw
    elif rotate == 180:
        t = t.rotate(180).translate(sw, sh)
    elif rotate == 270:
        t = t.rotate(90).translate(sh, 0)
        sw, sh = sh, sw

    avail_w = sheet_w - MARGIN_LEFT - MARGIN_RIGHT
    avail_h = sheet_h - MARGIN_TOP - MARGIN_BOTTOM
    scale = min(avail_w / sw, avail_h / sh, max_scale)

    draw_w, draw_h = sw * scale, sh * scale
    tx = MARGIN_LEFT + (avail_w - draw_w) / 2
    if valign == "center":
        ty = MARGIN_BOTTOM + (avail_h - draw_h) / 2
    else:  # top -- patent drawings normally hang from the top margin
        ty = sheet_h - MARGIN_TOP - draw_h
    t = t.scale(scale).translate(tx, ty)

    new_page = writer.add_blank_page(width=sheet_w, height=sheet_h)
    new_page.merge_transformed_page(page, t)
    if header is not None:
        new_page.merge_page(header)
    if "/Annots" in new_page:
        del new_page[NameObject("/Annots")]
    return scale


def unembedded_fonts(path):
    """Font names referenced by the file but not embedded (Patent Center flags these)."""
    names = set()
    try:
        reader = PdfReader(str(path))
    except Exception:
        return names
    for page in reader.pages:
        try:
            fonts = page["/Resources"]["/Font"]
        except (KeyError, TypeError):
            continue
        for ref in fonts.values():
            try:
                font = ref.get_object()
                desc = font.get("/FontDescriptor")
                if desc is None and font.get("/Subtype") == "/Type0":
                    kids = font.get("/DescendantFonts")
                    if kids:
                        desc = kids[0].get_object().get("/FontDescriptor")
                if desc is None:
                    names.add(str(font.get("/BaseFont", "?")).lstrip("/"))
                    continue
                d = desc.get_object()
                if not any(k in d for k in ("/FontFile", "/FontFile2", "/FontFile3")):
                    names.add(str(font.get("/BaseFont", "?")).lstrip("/"))
            except Exception:
                continue
    return names


def check(path, want):
    """Report compliance without writing. True if the file is already clean."""
    try:
        reader = PdfReader(str(path))
    except Exception as exc:
        print(f"{path.name}: ERROR unreadable -- {exc}")
        return False

    target_w, target_h = SHEETS[want]
    sizes, annots, ok = {}, 0, True
    for page in reader.pages:
        w, h = float(page.mediabox.width), float(page.mediabox.height)
        rot = int(page.get("/Rotate", 0) or 0) % 360
        if rot in (90, 270):
            w, h = h, w
        sizes[(round(w), round(h))] = sizes.get((round(w), round(h)), 0) + 1
        if abs(w - target_w) > TOLERANCE or abs(h - target_h) > TOLERANCE:
            ok = False
        if page.get("/Annots"):
            annots += 1

    print(f"{path.name}: {len(reader.pages)} page(s)")
    for (w, h), n in sorted(sizes.items(), key=lambda kv: -kv[1]):
        label = sheet_label(w, h) or f'{w/PT:.2f}" x {h/PT:.2f}" -- NOT Letter or A4'
        flag = "ok " if (abs(w - target_w) <= TOLERANCE
                         and abs(h - target_h) <= TOLERANCE) else "BAD"
        print(f"  [{flag}] {n:>3} page(s)  {w:g} x {h:g} pts  ({label})")
    if annots:
        print(f"  [BAD] {annots} page(s) carry annotations (flattened on fix)")
        ok = False
    bad_fonts = unembedded_fonts(path)
    if bad_fonts:
        print(f"  [BAD] fonts not embedded: {', '.join(sorted(bad_fonts))}")
        ok = False
    print(f"  => {'compliant' if ok else 'needs fixing'}")
    return ok


def output_path(path, args):
    """Where the fixed copy goes. Never the input itself unless --in-place."""
    if args.in_place:
        return path
    if args.output:
        return Path(args.output)
    return path.with_name(f"{path.stem}{args.suffix}{path.suffix}")


def fix(path, args):
    want_w, want_h = SHEETS[args.size]
    reader = PdfReader(str(path))
    writer = PdfWriter()

    header = None
    if args.header:
        header = make_header_page(args.header, want_w, want_h, size=args.header_size,
                                  family=args.font)

    scales = []
    for page in reader.pages:
        if not args.keep_annots and "/Annots" in page:
            del page[NameObject("/Annots")]
        scales.append(
            normalize_page(page, writer, want_w, want_h,
                           header, args.valign, args.max_scale)
        )

    out = output_path(path, args)
    if out.resolve() == path.resolve() and not args.in_place:
        raise RuntimeError("refusing to overwrite the input without --in-place")
    if out == path and not args.no_backup:
        backup = path.with_suffix(path.suffix + ".bak")
        if backup.exists():
            backup.unlink()
        shutil.copy2(path, backup)

    tmp = out.with_name(out.name + ".uspto-tmp")
    with open(tmp, "wb") as fh:
        writer.write(fh)
    tmp.replace(out)

    lo, hi = min(scales), max(scales)
    span = f"{lo:.3f}" if abs(hi - lo) < 1e-6 else f"{lo:.3f}-{hi:.3f}"
    note = f"{len(scales)} page(s) -> {args.size}, scale {span}"
    if args.header:
        note += f', header "{args.header}"'
    print(f"{path.name}: {note}")

    bad_fonts = unembedded_fonts(out)
    if bad_fonts:
        print(f"  WARNING fonts not embedded: {', '.join(sorted(bad_fonts))}")
    if out == path and not args.no_backup:
        print(f"  original kept at {path.name}.bak")
    else:
        print(f"  written to {out}")


def main():
    ap = argparse.ArgumentParser(
        prog="uspto-pdf",
        description="Rebuild a PDF onto Letter/A4 sheets with 37 CFR 1.84 drawing "
                    "margins so Patent Center accepts it. Not legal advice: verify "
                    "filings against the current USPTO rules.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""examples:
  uspto-pdf --check figures.pdf
  uspto-pdf --header "Replacement Sheet" figures.pdf
  uspto-pdf --size a4 -o out.pdf figures.pdf
  uspto-pdf --in-place drawings/*.pdf""",
    )
    ap.add_argument("pdfs", nargs="+", type=Path, help="PDF file(s) to inspect or fix")
    ap.add_argument("--check", action="store_true",
                    help="report page sizes and compliance; write nothing")
    ap.add_argument("--size", choices=sorted(SHEETS), default="letter",
                    help="target sheet size (default: letter)")
    ap.add_argument("--header", metavar="TEXT",
                    help='stamp TEXT in the top margin of every page, '
                         'e.g. "Replacement Sheet"')
    ap.add_argument("--header-size", type=float, default=12, metavar="PT",
                    help="header font size in points (default: 12)")
    ap.add_argument("--valign", choices=("top", "center"), default="top",
                    help="vertical placement within the margins (default: top)")
    ap.add_argument("--max-scale", type=float, default=1.0, metavar="X",
                    help="cap on enlargement of small drawings (default: 1.0, "
                         "i.e. never enlarge)")
    ap.add_argument("--keep-annots", action="store_true",
                    help="keep PDF annotations instead of flattening them away")
    ap.add_argument("--font", default="DejaVu Sans", metavar="FAMILY|PATH",
                    help="TrueType font for --header, a family name or a .ttf path "
                         "(default: DejaVu Sans)")
    ap.add_argument("-o", "--output", metavar="PATH",
                    help="output file (single input only; default: NAME-uspto.pdf "
                         "next to the input)")
    ap.add_argument("--suffix", default="-uspto", metavar="TEXT",
                    help="suffix for default output names (default: -uspto)")
    ap.add_argument("--in-place", action="store_true",
                    help="overwrite the input, keeping NAME.pdf.bak")
    ap.add_argument("--no-backup", action="store_true",
                    help="with --in-place, skip the .bak copy")
    args = ap.parse_args()

    if args.output and len(args.pdfs) > 1:
        ap.error("-o/--output takes a single input file")
    if args.output and args.in_place:
        ap.error("-o/--output and --in-place are mutually exclusive")

    status = 0
    for path in args.pdfs:
        if not path.is_file():
            print(f"{path}: not found", file=sys.stderr)
            status = 1
            continue
        try:
            if args.check:
                if not check(path, args.size) and status == 0:
                    status = 2
            else:
                fix(path, args)
        except Exception as exc:
            print(f"{path.name}: ERROR {exc}", file=sys.stderr)
            status = 1
    return status


if __name__ == "__main__":
    sys.exit(main())
