#!/usr/bin/env python3
"""font_tools.py - standalone font forge extracted from triodesktop/start.py.

A single-file, dependency-light font toolkit: inspect metrics + cmap, remap a
codepoint to a glyph, merge donor glyphs into a base font (the "never tofu"
move), round-trip font metadata through XML/TTX, render a glyph to PNG, rebuild
WOFF2 from OTF/TTF, and repair terminal-font metrics.

Importable as a library::

    from font_tools import Font, Fonts
    info = Font('mono.ttf').metadataReportRows(Font('mono.ttf').openFont())

Command line::

    python font_tools.py info <font>                     metrics + cmap summary
    python font_tools.py dump-xml <font> [out.ttx]       font -> XML (full TTX dump)
    python font_tools.py apply-xml <font> <edited.ttx>    XML -> font (round-trip back)
    python font_tools.py map <font> U+2605 <glyphName> [out]   remap codepoint -> glyph
    python font_tools.py merge <base> <donor> [out]      merge donor glyphs into base
    python font_tools.py extract-glyph <font> U+2605 [out.png]
    python font_tools.py to-woff2 <font.otf|ttf> [out.woff2]
    python font_tools.py repair <font>

Requires: fonttools (pip install fonttools).
Optional: brotli/zopfli (WOFF2 compression), Pillow (glyph->PNG), fontforge,
nanoemoji, psMat. Missing optional deps degrade gracefully with a clear
"feature X needs package Y" message rather than crashing on import.

Note: this console may be cp1252 (Windows). Never print raw non-ASCII glyph
characters to stdout - print U+XXXX codepoints instead.
"""

from __future__ import annotations

import argparse
import datetime
import html
import os
import re
import sys
from collections import Counter
from pathlib import Path
from typing import Any, cast

EMPTY_STRING = ''
BASE_DIR = Path(__file__).resolve().parent


# ----------------------------------------------------------------------------
# Local replacements for start.py helpers (no `import start`).
# ----------------------------------------------------------------------------
def captureException(context: str, error: BaseException | None = None) -> None:
    """Log a swallowed/handled exception. Replaces start.py's recordException.

    Never raises; this is the project's "no bare except: pass" discipline made
    visible - every swallowed exception leaves a greppable warning line.
    """
    if error is None:
        error = sys.exc_info()[1]
    if error is None:
        return
    print(f'[font_tools:WARN] {context}: {type(error).__name__}: {error}',
          file=sys.stderr, flush=True)


def safeInt(value: object, default: int = 0) -> int:
    try:
        return int(cast(Any, value))
    except Exception as error:
        captureException('safeInt', error)
        return int(default)


def safeFileMd5Hex(path: Path | str) -> str:
    try:
        import hashlib
        p = Path(path)
        if not p.exists() or not p.is_file():
            return EMPTY_STRING
        digest = hashlib.md5()
        with p.open('rb') as handle:
            for chunk in iter(lambda: handle.read(1 << 20), b''):
                digest.update(chunk)
        return digest.hexdigest()
    except Exception as error:
        captureException('safeFileMd5Hex', error)
        return EMPTY_STRING


def copyFile(source: Path | str, target: Path | str) -> None:
    import shutil
    Path(target).parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(str(source), str(target))


def writeText(path: Path | str, text: str) -> None:
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(text, encoding='utf-8')


# Constants pulled verbatim from start.py (font subsystem).
RECOMMENDED_TERMINAL_FONT = 'TrioDesktop PS Mono'
RECOMMENDED_TERMINAL_COLOR_FONT = 'TrioDesktop PS Color'
FONT_REPAIR_TARGET_WIDTH = 600
FONT_REPAIR_ASCENDER = 800
FONT_REPAIR_DESCENDER = -250
FONT_METADATA_DIRNAME = 'metadata'
FONT_XML_SUFFIX = '.xml'
TRIODESKTOP_MONO_REPAIR_STEMS = {'triodesktoppsmono', 'psmono', 'default'}


# ----------------------------------------------------------------------------
# fontTools lazy-import machinery (mirrors start.py ~5177-5210).
# ----------------------------------------------------------------------------
def _optionalModuleImport(moduleName: str) -> tuple[Any, str]:
    try:
        import importlib
        return importlib.import_module(moduleName), EMPTY_STRING
    except Exception as error:  # optional dep absent - degrade gracefully
        return None, f'{type(error).__name__}: {error}'


FONTTOOLS_TTLIB_MODULE = FONTTOOLS_MERGE_MODULE = FONTTOOLS_SUBSET_MODULE = FONTTOOLS_TTX_MODULE = None
NANOEMOJI_MODULE = FONTFORGE_MODULE = PSMAT_MODULE = BROTLI_MODULE = ZOPFLI_MODULE = None
FONTTOOLS_TTLIB_IMPORT_ERROR = FONTTOOLS_MERGE_IMPORT_ERROR = FONTTOOLS_SUBSET_IMPORT_ERROR = FONTTOOLS_TTX_IMPORT_ERROR = EMPTY_STRING
NANOEMOJI_IMPORT_ERROR = FONTFORGE_IMPORT_ERROR = PSMAT_IMPORT_ERROR = BROTLI_IMPORT_ERROR = ZOPFLI_IMPORT_ERROR = EMPTY_STRING
for _optionalGlobalPrefix, _optionalModuleName in (
    ('FONTTOOLS_TTLIB', 'fontTools.ttLib'), ('FONTTOOLS_MERGE', 'fontTools.merge'),
    ('FONTTOOLS_SUBSET', 'fontTools.subset'), ('FONTTOOLS_TTX', 'fontTools.ttx'),
    ('NANOEMOJI', 'nanoemoji'), ('FONTFORGE', 'fontforge'), ('PSMAT', 'psMat'),
    ('BROTLI', 'brotli'), ('ZOPFLI', 'zopfli'),
):
    globals()[f'{_optionalGlobalPrefix}_MODULE'], globals()[f'{_optionalGlobalPrefix}_IMPORT_ERROR'] = _optionalModuleImport(_optionalModuleName)

TTFont = getattr(FONTTOOLS_TTLIB_MODULE, 'TTFont', None) if FONTTOOLS_TTLIB_MODULE is not None else None
FontMerger = getattr(FONTTOOLS_MERGE_MODULE, 'Merger', None) if FONTTOOLS_MERGE_MODULE is not None else None
FontSubsetOptions = getattr(FONTTOOLS_SUBSET_MODULE, 'Options', None) if FONTTOOLS_SUBSET_MODULE is not None else None
FontSubsetter = getattr(FONTTOOLS_SUBSET_MODULE, 'Subsetter', None) if FONTTOOLS_SUBSET_MODULE is not None else None

FONT_COMPOSER_IMPORT_STATUS = {
    moduleName: {'available': globals().get(f'{globalPrefix}_MODULE') is not None, 'error': globals().get(f'{globalPrefix}_IMPORT_ERROR')}
    for globalPrefix, moduleName in (
        ('FONTTOOLS_TTLIB', 'fontTools.ttLib'), ('FONTTOOLS_MERGE', 'fontTools.merge'),
        ('FONTTOOLS_SUBSET', 'fontTools.subset'), ('FONTTOOLS_TTX', 'fontTools.ttx'),
        ('NANOEMOJI', 'nanoemoji'), ('FONTFORGE', 'fontforge'), ('PSMAT', 'psMat'),
        ('BROTLI', 'brotli'), ('ZOPFLI', 'zopfli'),
    )
}


def ensureFontToolsAvailable() -> bool:
    return TTFont is not None


def requiredTTFontClass():
    if TTFont is None:
        raise RuntimeError('fontTools.ttLib is required. Install with: pip install fonttools')
    return cast(Any, TTFont)


def _parseGlyphCodepoint(raw: str) -> int | None:
    text = str(raw or EMPTY_STRING).strip()
    if not text:
        return None
    try:
        if text.upper().startswith('U+'):
            return int(text[2:], 16)
        if text.lower().startswith('0x'):
            return int(text, 16)
        if text.startswith('&#') and text.endswith(';'):
            body = text[2:-1]
            return int(body[1:], 16) if body.lower().startswith('x') else int(body)
        return int(text)
    except Exception as error:
        captureException('_parseGlyphCodepoint', error)
        if len(text) == 1:
            return ord(text)
        return None


# ----------------------------------------------------------------------------
# Glyph -> PNG rendering (Pillow fallback; the self-contained path from start.py).
# ----------------------------------------------------------------------------
def renderGlyphToPng(font_path: Path, codepoint: int, output_path: Path | None = None, size: int = 256) -> Path | None:
    """Render one glyph to a transparent PNG using Pillow."""
    try:
        from PIL import Image, ImageFont, ImageDraw
    except Exception as error:
        captureException('renderGlyphToPng: Pillow missing', error)
        print('[Font:WARN] glyph->PNG needs Pillow (pip install Pillow)', file=sys.stderr, flush=True)
        return None
    char = chr(int(codepoint))
    output = Path(output_path or (BASE_DIR / f'glyph_U+{int(codepoint):04X}.png'))
    output.parent.mkdir(parents=True, exist_ok=True)
    image = Image.new('RGBA', (int(size), int(size)), (0, 0, 0, 0))
    draw = ImageDraw.Draw(image)
    font = ImageFont.truetype(str(font_path), int(size * 0.75))
    draw.text((int(size * 0.1), int(size * 0.05)), char, font=font, fill=(255, 255, 255, 255))
    image.save(str(output))
    return output


def extractGlyphToPng(font_path: Path, codepoint: int, output_path: Path | None = None, size: int = 256) -> Path | None:
    try:
        return renderGlyphToPng(Path(font_path), int(codepoint), output_path=output_path, size=int(size or 256))
    except Exception as error:
        captureException('extractGlyphToPng', error)
        print(f'[Font:ERROR] glyph extraction failed: {type(error).__name__}: {error}', file=sys.stderr, flush=True)
        return None


def printFontList(fonts: list['Font']) -> None:
    print('\n=== Font Manager ===')
    for index, font in enumerate(list(fonts or []), 1):
        print(f'{index:>2}. {font.path.name}  face={font.faceName}  path={font.path}')


# ----------------------------------------------------------------------------
# Font metric formatting helpers (start.py ~10610-10643, 12879-12959).
# ----------------------------------------------------------------------------
def _formatFontMetricValue(value: Any) -> str:
    try:
        return str(int(value))
    except Exception as error:
        captureException('_formatFontMetricValue', error)
        return str(value)


def _fontMetricSnapshot(font: Any) -> dict[str, str]:
    snapshot: dict[str, str] = {}
    try:
        for table_name, attrs in {
            'head': ('unitsPerEm', 'xMin', 'yMin', 'xMax', 'yMax'),
            'hhea': ('ascent', 'descent', 'lineGap', 'advanceWidthMax'),
            'OS/2': ('xAvgCharWidth', 'sTypoAscender', 'sTypoDescender', 'sTypoLineGap', 'usWinAscent', 'usWinDescent'),
            'post': ('isFixedPitch', 'italicAngle'),
        }.items():
            if table_name in font:
                table = font[table_name]
                for attr in attrs:
                    if hasattr(table, attr):
                        snapshot[f'{table_name}.{attr}'] = _formatFontMetricValue(getattr(table, attr))
    except Exception as error:
        captureException('_fontMetricSnapshot', error)
    return snapshot


def _fontMetricNumber(value: object, default: int = 0) -> int:
    return safeInt(value, default)


def _fontMetricOptionalNumber(value: object) -> int | str:
    if value is None:
        return EMPTY_STRING
    text = str(value).strip()
    if not text:
        return EMPTY_STRING
    return safeInt(value, 0)


def _fontMetricBounds(*values: object) -> tuple[int | str, int | str, int | str, int | str]:
    padded = list(values[:4])
    while len(padded) < 4:
        padded.append(None)
    return cast('tuple[int | str, int | str, int | str, int | str]', tuple(_fontMetricOptionalNumber(value) for value in padded))


def _fontMetricPairValues(metrics: object, index: int) -> list[int]:
    values: list[int] = []
    if not isinstance(metrics, dict):
        return values
    for pair in metrics.values():
        if not isinstance(pair, (tuple, list)) or len(pair) <= index:
            continue
        values.append(safeInt(pair[index], 0))
    return values


def _fontMetricSummary(path: Path) -> dict[str, object]:
    summary: dict[str, object] = {'path': str(path), 'exists': path.exists(), 'md5': Font.md5(path) if path.exists() else EMPTY_STRING}
    if not path.exists() or TTFont is None:
        return summary
    font = None
    try:
        font = requiredTTFontClass()(str(path), recalcBBoxes=False, recalcTimestamp=False)
        head = font['head'] if 'head' in font else None
        hhea = font['hhea'] if 'hhea' in font else None
        os2 = font['OS/2'] if 'OS/2' in font else None
        hmtx = font['hmtx'].metrics if 'hmtx' in font else {}
        advances = _fontMetricPairValues(hmtx, 0)
        lefts = _fontMetricPairValues(hmtx, 1)
        metric_sources = (
            ('head.unitsPerEm', head, 'unitsPerEm'),
            ('hhea.ascent', hhea, 'ascent'), ('hhea.descent', hhea, 'descent'),
            ('hhea.lineGap', hhea, 'lineGap'), ('hhea.advanceWidthMax', hhea, 'advanceWidthMax'),
            ('OS/2.xAvgCharWidth', os2, 'xAvgCharWidth'), ('OS/2.sTypoAscender', os2, 'sTypoAscender'),
            ('OS/2.sTypoDescender', os2, 'sTypoDescender'), ('OS/2.sTypoLineGap', os2, 'sTypoLineGap'),
            ('OS/2.usWinAscent', os2, 'usWinAscent'), ('OS/2.usWinDescent', os2, 'usWinDescent'),
        )
        summary.update({key: _fontMetricOptionalNumber(getattr(source, attr, None)) for key, source, attr in metric_sources})
        summary['head.bbox'] = _fontMetricBounds(getattr(head, 'xMin', None), getattr(head, 'yMin', None), getattr(head, 'xMax', None), getattr(head, 'yMax', None))
        for prefix, values in (('hmtx.advance', advances), ('hmtx.lsb', lefts)):
            summary[f'{prefix}.min'] = min(values) if values else EMPTY_STRING
            summary[f'{prefix}.max'] = max(values) if values else EMPTY_STRING
        summary['hmtx.advance.unique'] = len(set(advances)) if advances else 0
    except Exception as error:
        captureException('_fontMetricSummary', error)
        summary['error'] = f'{type(error).__name__}: {error}'
    finally:
        try:
            if font is not None:
                font.close()
        except Exception as error:
            captureException('_fontMetricSummary.close', error)
    return summary


def _fontMetricSummaryText(label: str, path: Path) -> str:
    data = _fontMetricSummary(path)
    keys = ['md5', 'head.unitsPerEm', 'head.bbox', 'hhea.ascent', 'hhea.descent', 'hhea.lineGap', 'hhea.advanceWidthMax', 'OS/2.xAvgCharWidth', 'OS/2.sTypoAscender', 'OS/2.sTypoDescender', 'OS/2.sTypoLineGap', 'OS/2.usWinAscent', 'OS/2.usWinDescent', 'hmtx.advance.min', 'hmtx.advance.max', 'hmtx.advance.unique', 'hmtx.lsb.min', 'hmtx.lsb.max']
    parts = [f'{key}={data.get(key, EMPTY_STRING)}' for key in keys]
    return f'{label} {path.name}: ' + ' '.join(parts)


# ----------------------------------------------------------------------------
# Fonts - directory-level inventory.
# ----------------------------------------------------------------------------
class Fonts:
    """Directory-level font inventory."""

    SUPPORTED_SUFFIXES = {'.otf', '.ttf', '.woff', '.woff2'}

    def __init__(self, root: str | Path | None = None):
        self.root = Path(root or BASE_DIR).resolve()

    @classmethod
    def fontsDir(cls) -> Path:
        return BASE_DIR.resolve()

    @classmethod
    def fontDiscoveryDirs(cls, root: str | Path | None = None) -> list[Path]:
        roots: list[Path] = []
        if root is not None:
            roots.append(Path(root).resolve())
        roots.append(BASE_DIR.resolve())
        unique: list[Path] = []
        seen: set[str] = set()
        for folder in roots:
            key = str(folder).lower() if os.name == 'nt' else str(folder)
            if key not in seen:
                seen.add(key)
                unique.append(folder)
        return unique

    @classmethod
    def discover(cls, root: str | Path | None = None) -> list[dict[str, object]]:
        folders = cls.fontDiscoveryDirs(root)
        candidates: list[Path] = []
        for folder in folders:
            if folder.exists():
                for path in sorted(folder.iterdir(), key=lambda item: item.name.lower()):
                    if path.is_file() and path.suffix.lower() in cls.SUPPORTED_SUFFIXES:
                        candidates.append(path.resolve())
        rows: list[dict[str, object]] = []
        seen: set[str] = set()
        for path in candidates:
            key = str(path).lower()
            if key in seen:
                continue
            seen.add(key)
            rows.append({
                'path': str(path),
                'name': path.name,
                'suffix': path.suffix.lower(),
                'size': path.stat().st_size if path.exists() else 0,
                'md5': safeFileMd5Hex(path) if path.exists() else EMPTY_STRING,
                'is_color': 'color' in path.stem.lower() or 'colour' in path.stem.lower(),
            })
        return rows

    @classmethod
    def discoverAll(cls, root: str | Path | None = None) -> list['Font']:
        return [Font(Path(str(row.get('path') or EMPTY_STRING))) for row in cls.discover(root)]


# ----------------------------------------------------------------------------
# Font - core inspect / remap / metrics-repair / metadata round-trip.
# ----------------------------------------------------------------------------
class Font:
    """Single source of truth for font inspection and repair."""

    SUPPORTED_SUFFIXES = {'.otf', '.ttf', '.woff', '.woff2', '.wolf2'}
    GLYPH_MAPPING_FIXES = {
        'macron': 0x00AF,
        'macronmod': 0x00AF,
        'overscore': 0x00AF,
        'uni00AF': 0x00AF,
        'u00AF': 0x00AF,
        'u1F525': 0x1F525,
        'uni28FF': 0x28FF,
    }

    def __init__(self, path: Path):
        self.path = Path(path).resolve()
        self.name = self.path.stem
        self.extension = self.path.suffix.lower()
        self.isColor = 'color' in self.name.lower() or 'colour' in self.name.lower()
        self.faceName = self.detectFaceName()

    def detectFaceName(self) -> str:
        if self.isColor:
            return RECOMMENDED_TERMINAL_COLOR_FONT
        return RECOMMENDED_TERMINAL_FONT

    @classmethod
    def fontsDir(cls) -> Path:
        return Fonts.fontsDir()

    @classmethod
    def discoverAll(cls) -> list['Font']:
        return Fonts.discoverAll()

    @staticmethod
    def md5(path: Path) -> str:
        try:
            return safeFileMd5Hex(path)
        except Exception as error:
            captureException('Font.md5', error)
            return EMPTY_STRING

    def backup(self) -> Path | None:
        try:
            folder = self.path.parent / 'backups'
            folder.mkdir(parents=True, exist_ok=True)
            digest = self.md5(self.path)[:12] or 'unknown'
            target = folder / f'{self.path.name}.{digest}.bak'
            if not target.exists():
                copyFile(self.path, target)
            return target
        except Exception as error:
            captureException('Font.backup', error)
            print(f'[WARN:font-repair] backup failed for {self.path.name}: {type(error).__name__}: {error}', file=sys.stderr, flush=True)
            return None

    @staticmethod
    def _configInt(section: str, key: str, default: int, minimum: int, maximum: int) -> int:
        # Standalone build has no config.ini; honor sensible clamped defaults.
        return max(int(minimum), min(int(maximum), int(default)))

    @staticmethod
    def repairTargetWidth(default: int = 600) -> int:
        return Font._configInt('terminal_font', 'repair_target_width', default, 40, 900)

    @staticmethod
    def repairAscender(default: int = 800) -> int:
        return Font._configInt('terminal_font', 'repair_ascender', default, 500, 1200)

    @staticmethod
    def repairDescender(default: int = -250) -> int:
        return Font._configInt('terminal_font', 'repair_descender', default, -500, -50)

    def openFont(self):
        if TTFont is None:
            raise RuntimeError('fontTools.ttLib is required. Install with: pip install fonttools')
        return requiredTTFontClass()(str(self.path), recalcBBoxes=False, recalcTimestamp=False)

    def installFont(self, font_name: str | None = None) -> bool:
        """Install this font for the current Windows user."""
        desired = str(font_name or self.faceName or RECOMMENDED_TERMINAL_FONT).strip() or RECOMMENDED_TERMINAL_FONT
        return self.installToSystem()

    def installForCurrentUser(self, font_name: str | None = None) -> bool:
        return self.installFont(font_name)

    @staticmethod
    def unicodeCmapTables(font) -> list:
        tables = []
        try:
            cmap_table = font['cmap'] if 'cmap' in font else None
            for table in list(getattr(cmap_table, 'tables', []) or []):
                try:
                    if bool(table.isUnicode()) and hasattr(table, 'cmap'):
                        tables.append(table)
                except Exception as error:
                    captureException('unicodeCmapTables.table', error)
                    continue
        except Exception as error:
            captureException('unicodeCmapTables', error)
        return tables

    @staticmethod
    def glyphNamesForCodepoint(font, codepoint: int) -> list[str]:
        names: list[str] = []
        for table in Font.unicodeCmapTables(font):
            try:
                glyph = table.cmap.get(int(codepoint))
                if glyph and str(glyph) not in names:
                    names.append(str(glyph))
            except Exception as error:
                captureException('glyphNamesForCodepoint', error)
                continue
        return names

    @staticmethod
    def mapCodepoint(font, codepoint: int, glyphName: str) -> bool:
        changed = False
        glyph_text = str(glyphName or EMPTY_STRING).strip()
        if not glyph_text:
            return False
        cp = int(codepoint)
        for table in Font.unicodeCmapTables(font):
            try:
                table_format = int(getattr(table, 'format', 0) or 0)
                if cp > 0xFFFF and table_format not in {12, 13}:
                    continue
                if table.cmap.get(cp) != glyph_text:
                    table.cmap[cp] = glyph_text
                    changed = True
            except Exception as error:
                captureException('mapCodepoint', error)
                continue
        return changed

    @staticmethod
    def removeCodepointForGlyph(font, codepoint: int, glyphName: str) -> bool:
        changed = False
        glyph_text = str(glyphName or EMPTY_STRING).strip()
        for table in Font.unicodeCmapTables(font):
            try:
                if table.cmap.get(int(codepoint)) == glyph_text:
                    del table.cmap[int(codepoint)]
                    changed = True
            except Exception as error:
                captureException('removeCodepointForGlyph', error)
                continue
        return changed

    def repairGlyphMappings(self, font) -> tuple[bool, list[str]]:
        changed = False
        notes: list[str] = []
        glyph_order = set(str(item) for item in list(font.getGlyphOrder() or []))
        for glyph_name, codepoint in self.GLYPH_MAPPING_FIXES.items():
            if glyph_name in glyph_order:
                if self.mapCodepoint(font, int(codepoint), glyph_name):
                    changed = True
                    notes.append(f'{glyph_name}->U+{int(codepoint):04X}')
        for wrong_codepoint in (0x02C9,):
            for glyph_name in self.glyphNamesForCodepoint(font, wrong_codepoint):
                lowered = glyph_name.lower().replace('-', '').replace('_', '')
                if 'macron' in lowered or lowered in {'overscore', 'uni00af', 'u00af', 'maron', 'miron', 'maran'}:
                    current_u00af = self.glyphNamesForCodepoint(font, 0x00AF)
                    if not current_u00af and self.mapCodepoint(font, 0x00AF, glyph_name):
                        changed = True
                        notes.append(f'{glyph_name}:U+02C9->U+00AF')
                    if self.removeCodepointForGlyph(font, wrong_codepoint, glyph_name):
                        changed = True
                        notes.append(f'removed wrong macron mapping {glyph_name} from U+02C9')
        best = font.getBestCmap() or {}
        fire_glyph = best.get(0x1F525) or best.get(0x2668) or best.get(0xE525)
        if fire_glyph:
            for codepoint in (0x1F525, 0x2668, 0xE525):
                if self.mapCodepoint(font, codepoint, fire_glyph):
                    changed = True
                    notes.append(f'fire-alias U+{codepoint:04X}')
        for codepoint in range(0x2800, 0x2900):
            if codepoint in best:
                continue
            for candidate in (f'uni{codepoint:04X}', f'u{codepoint:04X}', f'braille{codepoint:04X}'):
                if candidate in glyph_order:
                    if self.mapCodepoint(font, codepoint, candidate):
                        changed = True
                        notes.append(f'braille U+{codepoint:04X}')
                    break
        return changed, notes

    def repairMetrics(self, font) -> tuple[bool, list[str]]:
        target_width = self.repairTargetWidth()
        ascender = self.repairAscender()
        descender = self.repairDescender()
        padding = max(1, min(4, int(target_width * 0.03)))
        usable_width = max(1, int(target_width) - (padding * 2))
        usable_height = max(1, int(ascender) - int(descender))
        changed = False
        notes: list[str] = []
        geometry_changed = 0
        metric_changed = 0
        if 'glyf' in font:
            try:
                glyf = font['glyf']
                hmtx = font['hmtx'].metrics if 'hmtx' in font else {}
                priority_glyph_names: set[str] = set()
                try:
                    best_cmap = font.getBestCmap() or {}
                    priority_codepoints: set[int] = set(range(0x21, 0x7F)) | set(range(0x2500, 0x25A0)) | set(range(0x2800, 0x2900)) | {0x00AF, 0x02C9, 0x1F525, 0x2668, 0xE525}
                    priority_glyph_names = {str(best_cmap.get(cp)) for cp in priority_codepoints if best_cmap.get(cp)}
                except Exception as error:
                    captureException('repairMetrics.priority', error)
                    priority_glyph_names = set()
                for glyph_name in list(font.getGlyphOrder() or []):
                    if glyph_name not in glyf:
                        continue
                    glyph = glyf[glyph_name]
                    try:
                        glyph.recalcBounds(glyf)
                    except Exception as error:
                        captureException('repairMetrics.recalcBounds', error)
                    x_min = getattr(glyph, 'xMin', None)
                    x_max = getattr(glyph, 'xMax', None)
                    y_min = getattr(glyph, 'yMin', None)
                    y_max = getattr(glyph, 'yMax', None)
                    has_bounds = None not in (x_min, x_max, y_min, y_max)
                    if has_bounds and (str(glyph_name) in priority_glyph_names):
                        x_min_i = safeInt(x_min, 0)
                        x_max_i = safeInt(x_max, 0)
                        y_min_i = safeInt(y_min, 0)
                        y_max_i = safeInt(y_max, 0)
                        width = max(0, x_max_i - x_min_i)
                        height = max(0, y_max_i - y_min_i)
                        skip_scale_names = {'.notdef', '.null', 'nonmarkingreturn', 'space', 'uni0020', 'u0020', 'tab'}
                        is_substantial = (
                            str(glyph_name).lower() not in skip_scale_names
                            and width >= max(4, int(target_width * 0.08))
                            and height >= int(usable_height * 0.22)
                        )
                        scale_x = usable_width / float(width) if width > usable_width and width else 1.0
                        scale_y = usable_height / float(height) if height > usable_height and height else 1.0
                        if is_substantial and width and width < int(usable_width * 0.82):
                            scale_x = min(1.45, usable_width / float(width))
                        if is_substantial and height and height < int(usable_height * 0.88):
                            scale_y = min(1.45, usable_height / float(height))
                        try:
                            coords, _end_pts, _flags = glyph.getCoordinates(glyf)
                            if scale_x != 1.0 or scale_y != 1.0:
                                coords.scale((scale_x, scale_y))
                                try:
                                    coords.toInt()
                                except Exception as error:
                                    captureException('repairMetrics.scale.toInt', error)
                                geometry_changed += 1
                                changed = True
                                try:
                                    glyph.recalcBounds(glyf)
                                except Exception as error:
                                    captureException('repairMetrics.scale.recalc', error)
                                x_min = getattr(glyph, 'xMin', x_min)
                                x_max = getattr(glyph, 'xMax', x_max)
                                y_min = getattr(glyph, 'yMin', y_min)
                                y_max = getattr(glyph, 'yMax', y_max)
                            dx = 0
                            dy = 0
                            if None not in (x_min, x_max):
                                x_min_i = safeInt(x_min, 0)
                                x_max_i = safeInt(x_max, 0)
                                width = max(0, x_max_i - x_min_i)
                                desired_x_min = int(round((int(target_width) - width) / 2.0))
                                dx = desired_x_min - x_min_i
                            if None not in (y_min, y_max):
                                y_min_i = safeInt(y_min, 0)
                                y_max_i = safeInt(y_max, 0)
                                height = max(0, y_max_i - y_min_i)
                                if height > 0:
                                    desired_y_min = int(round(int(descender) + ((usable_height - height) / 2.0)))
                                    dy = desired_y_min - y_min_i
                                if y_max_i + dy > int(ascender):
                                    dy += int(ascender) - (y_max_i + dy)
                                if y_min_i + dy < int(descender):
                                    dy += int(descender) - (y_min_i + dy)
                            if dx or dy:
                                coords.translate((int(dx), int(dy)))
                                try:
                                    coords.toInt()
                                except Exception as error:
                                    captureException('repairMetrics.translate.toInt', error)
                                geometry_changed += 1
                                changed = True
                                try:
                                    glyph.recalcBounds(glyf)
                                except Exception as error:
                                    captureException('repairMetrics.translate.recalc', error)
                        except Exception as error:
                            captureException('repairMetrics.coords', error)
                    if 'hmtx' in font:
                        try:
                            if has_bounds:
                                lsb = safeInt(getattr(glyph, 'xMin', 0), 0)
                            else:
                                raw_metric_pair = hmtx.get(glyph_name, (target_width, 0))
                                if isinstance(raw_metric_pair, (list, tuple)) and len(raw_metric_pair) > 1:
                                    lsb = safeInt(raw_metric_pair[1], 0)
                                else:
                                    lsb = 0
                            new_pair = (safeInt(target_width, 600), safeInt(max(-2, min(2, lsb)), 0))
                            if hmtx.get(glyph_name) != new_pair:
                                hmtx[glyph_name] = new_pair
                                metric_changed += 1
                                changed = True
                        except Exception as error:
                            captureException('repairMetrics.hmtx', error)
                            continue
                if geometry_changed:
                    notes.append(f'glyph outlines packed={geometry_changed} padding={padding}')
                if metric_changed:
                    notes.append(f'hmtx normalized={metric_changed} advance={target_width}')
            except Exception as error:
                captureException('repairMetrics.glyf', error)
                notes.append(f'glyf-pack skipped {type(error).__name__}: {error}')
        if 'hhea' in font:
            hhea = font['hhea']
            for attr, value in (('ascent', ascender), ('descent', descender), ('lineGap', 0), ('advanceWidthMax', target_width), ('minLeftSideBearing', -2), ('minRightSideBearing', -2), ('xMaxExtent', target_width)):
                try:
                    if safeInt(getattr(hhea, attr, 0), 0) != safeInt(value, 0):
                        setattr(hhea, attr, safeInt(value, 0))
                        changed = True
                except Exception as error:
                    captureException('repairMetrics.hhea', error)
            notes.append(f'hhea ascent={ascender} descent={descender}')
        if 'OS/2' in font:
            os2 = font['OS/2']
            for attr, value in (('xAvgCharWidth', target_width), ('usWidthClass', 5), ('sTypoAscender', ascender), ('sTypoDescender', descender), ('sTypoLineGap', 0), ('usWinAscent', max(ascender, 0)), ('usWinDescent', abs(min(descender, 0)))):
                try:
                    if safeInt(getattr(os2, attr, 0), 0) != safeInt(value, 0):
                        setattr(os2, attr, safeInt(value, 0))
                        changed = True
                except Exception as error:
                    captureException('repairMetrics.os2', error)
            try:
                os2.panose.bProportion = 9
                os2.panose.bFamilyType = max(safeInt(getattr(os2.panose, 'bFamilyType', 0), 0), 2)
            except Exception as error:
                captureException('repairMetrics.panose', error)
            notes.append(f'OS/2 avg={target_width}')
        if 'post' in font:
            post = font['post']
            try:
                if safeInt(getattr(post, 'isFixedPitch', 0), 0) != 1:
                    post.isFixedPitch = 1
                    changed = True
                    notes.append('post fixed-pitch=1')
            except Exception as error:
                captureException('repairMetrics.post', error)
        if 'head' in font:
            try:
                font['head'].xMin = 0
                font['head'].yMin = safeInt(descender, -250)
                font['head'].xMax = safeInt(target_width, 600)
                font['head'].yMax = safeInt(ascender, 820)
                changed = True
                notes.append(f'head bbox={(0, safeInt(descender, -250), safeInt(target_width, 600), safeInt(ascender, 820))}')
            except Exception as error:
                captureException('repairMetrics.head', error)
        if 'kern' in font:
            try:
                del font['kern']
                changed = True
                notes.append('removed kern table')
            except Exception as error:
                captureException('repairMetrics.kern', error)
        return changed, notes

    def repair(self) -> dict[str, object]:
        before = self.md5(self.path)
        result: dict[str, object] = {'path': str(self.path), 'before': before, 'after': before, 'changed': False, 'ok': False, 'notes': []}
        if self.extension not in self.SUPPORTED_SUFFIXES:
            result['notes'] = ['unsupported suffix']
            return result
        self.backup()
        try:
            font = self.openFont()
            glyph_changed, glyph_notes = self.repairGlyphMappings(font)
            metric_changed, metric_notes = self.repairMetrics(font)
            changed = bool(glyph_changed or metric_changed)
            if changed:
                try:
                    font.recalcBBoxes = False
                except Exception as error:
                    captureException('repair.recalcBBoxes', error)
                font.save(str(self.path))
            try:
                font.close()
            except Exception as error:
                captureException('repair.close', error)
            after = self.md5(self.path)
            result.update({'after': after, 'changed': before != after, 'ok': True, 'notes': glyph_notes + metric_notes})
            return result
        except Exception as error:
            captureException('repair', error)
            result['notes'] = [f'{type(error).__name__}: {error}']
            return result

    def showInfo(self) -> None:
        print(f'{self.name}{self.extension}  face={self.faceName}  md5={self.md5(self.path)}  path={self.path}', flush=True)

    @staticmethod
    def _xmlEscape(value: object) -> str:
        try:
            return html.escape(str(value), quote=True)
        except Exception as error:
            captureException('_xmlEscape', error)
            return EMPTY_STRING

    @staticmethod
    def _tableTagText(font) -> str:
        try:
            return ','.join(str(tag) for tag in list(font.keys() or []))
        except Exception as error:
            captureException('_tableTagText', error)
            return EMPTY_STRING

    @staticmethod
    def _metricValue(table: object, attr: str, default: object = EMPTY_STRING) -> object:
        try:
            return getattr(table, attr, default)
        except Exception as error:
            captureException('_metricValue', error)
            return default

    @staticmethod
    def _glyphBounds(font, glyph_name: str):
        try:
            if not glyph_name or 'glyf' not in font or glyph_name not in font['glyf']:
                return None
            glyf = font['glyf']
            glyph = glyf[glyph_name]
            try:
                glyph.recalcBounds(glyf)
            except Exception as error:
                captureException('_glyphBounds.recalc', error)
            return (
                getattr(glyph, 'xMin', None),
                getattr(glyph, 'yMin', None),
                getattr(glyph, 'xMax', None),
                getattr(glyph, 'yMax', None),
            )
        except Exception as error:
            captureException('_glyphBounds', error)
            return None

    @staticmethod
    def _glyphMetricRows(font) -> list[str]:
        rows: list[str] = []
        try:
            cmap = font.getBestCmap() or {}
            hmtx = font['hmtx'].metrics if 'hmtx' in font else {}
            probes = [
                ('A', 0x0041),
                ('fire', 0x1F525),
                ('hot-springs-fallback', 0x2668),
                ('private-use-fire', 0xE525),
                ('braille-full', 0x28FF),
                ('macron', 0x00AF),
                ('wrong-macron', 0x02C9),
            ]
            for label, codepoint in probes:
                glyph_name = str(cmap.get(codepoint) or EMPTY_STRING)
                advance = hmtx.get(glyph_name, ('', '')) if glyph_name else ('', '')
                bounds = Font._glyphBounds(font, glyph_name) if glyph_name else None
                rows.append(f'  glyph {label} U+{codepoint:04X}: name={glyph_name or "MISSING"} hmtx={advance} bbox={bounds}')
        except Exception as error:
            captureException('_glyphMetricRows', error)
            rows.append(f'  glyph probe failed: {type(error).__name__}: {error}')
        return rows

    def metadataReportRows(self, font) -> list[str]:
        rows: list[str] = []
        try:
            head = font['head'] if 'head' in font else None
            hhea = font['hhea'] if 'hhea' in font else None
            os2 = font['OS/2'] if 'OS/2' in font else None
            maxp = font['maxp'] if 'maxp' in font else None
            post = font['post'] if 'post' in font else None
            cmap = font.getBestCmap() or {}
            hmtx = font['hmtx'].metrics if 'hmtx' in font else {}
            advances = _fontMetricPairValues(hmtx, 0)
            lefts = _fontMetricPairValues(hmtx, 1)
            rows.extend([
                f'font={self.path.name}',
                f'path={self.path}',
                f'md5={self.md5(self.path)}',
                f'flavor={getattr(font, "flavor", None)} sfntVersion={getattr(font, "sfntVersion", EMPTY_STRING)}',
                f'tables={self._tableTagText(font)}',
                f'maxp.numGlyphs={self._metricValue(maxp, "numGlyphs")}',
                f'head.unitsPerEm={self._metricValue(head, "unitsPerEm")}',
                f'head.bbox=({self._metricValue(head, "xMin")},{self._metricValue(head, "yMin")},{self._metricValue(head, "xMax")},{self._metricValue(head, "yMax")})',
                f'hhea.ascent={self._metricValue(hhea, "ascent")} hhea.descent={self._metricValue(hhea, "descent")} hhea.lineGap={self._metricValue(hhea, "lineGap")} hhea.advanceWidthMax={self._metricValue(hhea, "advanceWidthMax")}',
                f'OS/2.xAvgCharWidth={self._metricValue(os2, "xAvgCharWidth")} OS/2.sTypoAscender={self._metricValue(os2, "sTypoAscender")} OS/2.sTypoDescender={self._metricValue(os2, "sTypoDescender")} OS/2.sTypoLineGap={self._metricValue(os2, "sTypoLineGap")}',
                f'OS/2.usWinAscent={self._metricValue(os2, "usWinAscent")} OS/2.usWinDescent={self._metricValue(os2, "usWinDescent")} OS/2.fsSelection={self._metricValue(os2, "fsSelection")}',
                f'post.isFixedPitch={self._metricValue(post, "isFixedPitch")}',
                f'cmap.best.count={len(cmap)}',
                f'hmtx.advance.min={min(advances) if advances else EMPTY_STRING} hmtx.advance.max={max(advances) if advances else EMPTY_STRING} hmtx.advance.unique={len(set(advances)) if advances else 0}',
                f'hmtx.lsb.min={min(lefts) if lefts else EMPTY_STRING} hmtx.lsb.max={max(lefts) if lefts else EMPTY_STRING}',
            ])
            rows.extend(self._glyphMetricRows(font))
        except Exception as error:
            captureException('metadataReportRows', error)
            rows.append(f'metadata report failed: {type(error).__name__}: {error}')
        return rows

    def writeMetadataXml(self, xmlPath: Path | None = None) -> tuple[bool, Path, list[str]]:
        """Full TTX dump via font.saveXML, plus a human-readable metric report."""
        xml_path = Path(xmlPath) if xmlPath is not None else self.path.with_name(self.path.name + '.xml')
        try:
            font = self.openFont()
            try:
                report_rows = self.metadataReportRows(font)
                try:
                    table_tags = [str(tag) for tag in font.keys()]
                except Exception as error:
                    captureException('writeMetadataXml.keys', error)
                    table_tags = []
                tags_preview = ','.join(table_tags[:32])
                print(f'[TRACE:font-metadata] saveXML-start font={self.path} xml={xml_path} tables={len(table_tags)} tags={tags_preview}', file=sys.stderr, flush=True)
                font.saveXML(str(xml_path))
                wrote = safeInt(xml_path.stat().st_size, 0) if xml_path.exists() else 0
                print(f'[TRACE:font-metadata] saveXML-complete font={self.path} xml={xml_path} bytes={wrote}', file=sys.stderr, flush=True)
            finally:
                try:
                    font.close()
                except Exception as error:
                    captureException('writeMetadataXml.close', error)
            return True, xml_path, report_rows
        except Exception as error:
            captureException('writeMetadataXml', error)
            return False, xml_path, [f'{self.path.name}: {type(error).__name__}: {error}']

    @staticmethod
    def _metadataString(value: object) -> str:
        if value is None:
            return EMPTY_STRING
        try:
            text = value.decode('utf-8', 'replace') if isinstance(value, bytes) else str(value)
        except Exception as error:
            captureException('_metadataString', error)
            text = repr(value)
        return ''.join(ch if (ch in '\t\n\r' or 0x20 <= ord(ch) <= 0xD7FF or 0xE000 <= ord(ch) <= 0xFFFD) else '�' for ch in text)

    def metadataXmlPath(self) -> Path:
        directory = BASE_DIR / FONT_METADATA_DIRNAME
        directory.mkdir(parents=True, exist_ok=True)
        return directory / f'{self.path.name}{FONT_XML_SUFFIX}'

    def _collectCmap(self, font: object) -> dict[int, str]:
        collected: dict[int, str] = {}
        try:
            if 'cmap' not in cast(Any, font):
                return collected
            for table in cast(Any, font)['cmap'].tables:
                cmap = getattr(table, 'cmap', None)
                if isinstance(cmap, dict):
                    for code, glyph_name in cmap.items():
                        try:
                            collected[int(code)] = str(glyph_name)
                        except Exception as error:
                            captureException('_collectCmap.item', error)
                            continue
        except Exception as error:
            captureException('_collectCmap', error)
        return collected

    def _appendNameRecords(self, font: object, parent: object) -> None:
        import xml.etree.ElementTree as ET
        names_node = ET.SubElement(cast(Any, parent), 'names')
        try:
            if 'name' not in cast(Any, font):
                names_node.set('present', 'false')
                return
            names_node.set('present', 'true')
            seen: set[tuple[int, int, int, int, str]] = set()
            for record in cast(Any, font)['name'].names:
                try:
                    text = record.toUnicode().strip()
                except Exception as error:
                    captureException('_appendNameRecords.unicode', error)
                    raw = getattr(record, 'string', b'')
                    text = raw.decode('utf-8', 'replace').strip() if isinstance(raw, bytes) else str(raw).strip()
                key = (
                    safeInt(getattr(record, 'nameID', -1), -1),
                    safeInt(getattr(record, 'platformID', -1), -1),
                    safeInt(getattr(record, 'platEncID', -1), -1),
                    safeInt(getattr(record, 'langID', -1), -1),
                    self._metadataString(text),
                )
                if key in seen:
                    continue
                seen.add(key)
                ET.SubElement(names_node, 'name', {'nameID': str(key[0]), 'platformID': str(key[1]), 'encodingID': str(key[2]), 'languageID': str(key[3])}).text = key[4]
        except Exception as error:
            captureException('_appendNameRecords', error)
            names_node.set('error', 'see-exception-log')

    def _appendTableDirectory(self, font: object, parent: object) -> None:
        import xml.etree.ElementTree as ET
        tables_node = ET.SubElement(cast(Any, parent), 'tables')
        try:
            reader = getattr(font, 'reader', None)
            for tag in list(cast(Any, font).keys()):
                attrs: dict[str, str] = {'tag': str(tag).strip() or str(tag), 'present': 'true'}
                try:
                    entry = getattr(reader, 'tables', {}).get(tag) if reader is not None else None
                    if entry is not None:
                        for attr in ('offset', 'length', 'origLength', 'origChecksum', 'checkSum'):
                            if hasattr(entry, attr):
                                attrs[attr] = self._metadataString(getattr(entry, attr))
                except Exception as error:
                    captureException('_appendTableDirectory.entry', error)
                ET.SubElement(tables_node, 'table', attrs)
        except Exception as error:
            captureException('_appendTableDirectory', error)
            tables_node.set('error', 'see-exception-log')

    def _appendMetricStats(self, font: object, parent: object) -> None:
        import xml.etree.ElementTree as ET
        metrics_node = ET.SubElement(cast(Any, parent), 'metrics')
        try:
            font_any = cast(Any, font)
            if 'head' in font_any:
                head = font_any['head']
                ET.SubElement(metrics_node, 'head', {'unitsPerEm': self._metadataString(getattr(head, 'unitsPerEm', EMPTY_STRING)), 'xMin': self._metadataString(getattr(head, 'xMin', EMPTY_STRING)), 'yMin': self._metadataString(getattr(head, 'yMin', EMPTY_STRING)), 'xMax': self._metadataString(getattr(head, 'xMax', EMPTY_STRING)), 'yMax': self._metadataString(getattr(head, 'yMax', EMPTY_STRING)), 'flags': self._metadataString(getattr(head, 'flags', EMPTY_STRING)), 'macStyle': self._metadataString(getattr(head, 'macStyle', EMPTY_STRING))})
            if 'maxp' in font_any:
                maxp = font_any['maxp']
                ET.SubElement(metrics_node, 'maxp', {'numGlyphs': self._metadataString(getattr(maxp, 'numGlyphs', EMPTY_STRING)), 'tableVersion': self._metadataString(getattr(maxp, 'tableVersion', EMPTY_STRING))})
            if 'hhea' in font_any:
                hhea = font_any['hhea']
                ET.SubElement(metrics_node, 'hhea', {'ascent': self._metadataString(getattr(hhea, 'ascent', EMPTY_STRING)), 'descent': self._metadataString(getattr(hhea, 'descent', EMPTY_STRING)), 'lineGap': self._metadataString(getattr(hhea, 'lineGap', EMPTY_STRING)), 'advanceWidthMax': self._metadataString(getattr(hhea, 'advanceWidthMax', EMPTY_STRING)), 'minLeftSideBearing': self._metadataString(getattr(hhea, 'minLeftSideBearing', EMPTY_STRING)), 'minRightSideBearing': self._metadataString(getattr(hhea, 'minRightSideBearing', EMPTY_STRING)), 'xMaxExtent': self._metadataString(getattr(hhea, 'xMaxExtent', EMPTY_STRING)), 'numberOfHMetrics': self._metadataString(getattr(hhea, 'numberOfHMetrics', EMPTY_STRING))})
            if 'OS/2' in font_any:
                os2 = font_any['OS/2']
                ET.SubElement(metrics_node, 'OS_2', {'version': self._metadataString(getattr(os2, 'version', EMPTY_STRING)), 'xAvgCharWidth': self._metadataString(getattr(os2, 'xAvgCharWidth', EMPTY_STRING)), 'usWeightClass': self._metadataString(getattr(os2, 'usWeightClass', EMPTY_STRING)), 'usWidthClass': self._metadataString(getattr(os2, 'usWidthClass', EMPTY_STRING)), 'sTypoAscender': self._metadataString(getattr(os2, 'sTypoAscender', EMPTY_STRING)), 'sTypoDescender': self._metadataString(getattr(os2, 'sTypoDescender', EMPTY_STRING)), 'sTypoLineGap': self._metadataString(getattr(os2, 'sTypoLineGap', EMPTY_STRING)), 'usWinAscent': self._metadataString(getattr(os2, 'usWinAscent', EMPTY_STRING)), 'usWinDescent': self._metadataString(getattr(os2, 'usWinDescent', EMPTY_STRING)), 'sxHeight': self._metadataString(getattr(os2, 'sxHeight', EMPTY_STRING)), 'sCapHeight': self._metadataString(getattr(os2, 'sCapHeight', EMPTY_STRING)), 'achVendID': self._metadataString(getattr(os2, 'achVendID', EMPTY_STRING))})
            if 'post' in font_any:
                post = font_any['post']
                ET.SubElement(metrics_node, 'post', {'formatType': self._metadataString(getattr(post, 'formatType', EMPTY_STRING)), 'isFixedPitch': self._metadataString(getattr(post, 'isFixedPitch', EMPTY_STRING)), 'italicAngle': self._metadataString(getattr(post, 'italicAngle', EMPTY_STRING)), 'underlinePosition': self._metadataString(getattr(post, 'underlinePosition', EMPTY_STRING)), 'underlineThickness': self._metadataString(getattr(post, 'underlineThickness', EMPTY_STRING))})
            if 'hmtx' in font_any:
                hmtx = font_any['hmtx']
                widths: list[int] = []
                lefts: list[int] = []
                for metric in getattr(hmtx, 'metrics', {}).values():
                    try:
                        width, left = metric
                        widths.append(safeInt(width, 0))
                        lefts.append(safeInt(left, 0))
                    except Exception as error:
                        captureException('_appendMetricStats.hmtx.metric', error)
                        continue
                hmtx_node = ET.SubElement(metrics_node, 'hmtx')
                if widths:
                    counts = Counter(widths)
                    dominant_width, dominant_count = counts.most_common(1)[0]
                    non_zero_widths = [width for width in widths if width > 0]
                    hmtx_node.attrib.update({'glyphMetricCount': str(len(widths)), 'minAdvanceWidth': str(min(widths)), 'maxAdvanceWidth': str(max(widths)), 'uniqueAdvanceWidthCount': str(len(counts)), 'uniqueAdvanceWidthsPreview': ','.join(str(width) for width in sorted(counts)[:64]), 'dominantAdvanceWidth': str(dominant_width), 'dominantAdvanceWidthCount': str(dominant_count), 'zeroWidthGlyphCount': str(sum(1 for width in widths if width == 0)), 'nonZeroAllSame': str(len(set(non_zero_widths)) == 1 if non_zero_widths else False).lower(), 'minLeftSideBearing': str(min(lefts) if lefts else 0), 'maxLeftSideBearing': str(max(lefts) if lefts else 0)})
                    if non_zero_widths and (len(set(non_zero_widths)) == 1 or self.shouldNormalizeMonospaceWidth()):
                        hmtx_node.set('targetAdvanceWidth', str(dominant_width))
        except Exception as error:
            captureException('_appendMetricStats', error)
            metrics_node.set('error', 'see-exception-log')

    def _appendCmapStats(self, font: object, parent: object) -> None:
        import xml.etree.ElementTree as ET
        cmap_node = ET.SubElement(cast(Any, parent), 'cmap')
        try:
            if 'cmap' not in cast(Any, font):
                cmap_node.set('present', 'false')
                return
            cmap_node.set('present', 'true')
            cmap_node.set('mergedCodepointCount', str(len(self._collectCmap(font))))
            for table in cast(Any, font)['cmap'].tables:
                cmap = getattr(table, 'cmap', {})
                sub = ET.SubElement(cmap_node, 'subtable', {'platformID': self._metadataString(getattr(table, 'platformID', EMPTY_STRING)), 'encodingID': self._metadataString(getattr(table, 'platEncID', EMPTY_STRING)), 'language': self._metadataString(getattr(table, 'language', EMPTY_STRING)), 'format': self._metadataString(getattr(table, 'format', EMPTY_STRING)), 'length': str(len(cmap) if isinstance(cmap, dict) else 0)})
                if isinstance(cmap, dict):
                    for code, glyph_name in list(sorted(cmap.items(), key=lambda item: int(item[0])))[:128]:
                        ET.SubElement(sub, 'map', {'codepoint': f'U+{int(code):04X}', 'glyph': str(glyph_name)})
        except Exception as error:
            captureException('_appendCmapStats', error)
            cmap_node.set('error', 'see-exception-log')

    def _appendLayoutFeatures(self, font: object, parent: object) -> None:
        import xml.etree.ElementTree as ET
        features_node = ET.SubElement(cast(Any, parent), 'layoutFeatures')
        for tag in ('GSUB', 'GPOS'):
            table_node = ET.SubElement(features_node, 'table', {'tag': tag, 'present': str(tag in cast(Any, font)).lower()})
            if tag not in cast(Any, font):
                continue
            try:
                feature_list = cast(Any, font)[tag].table.FeatureList
                for record in getattr(feature_list, 'FeatureRecord', []) or []:
                    ET.SubElement(table_node, 'feature', {'tag': self._metadataString(getattr(record, 'FeatureTag', EMPTY_STRING))})
            except Exception as error:
                captureException('_appendLayoutFeatures', error)
                table_node.set('error', 'see-exception-log')

    def metadataElement(self) -> object:
        import xml.etree.ElementTree as ET
        try:
            font = self.openFont()
            try:
                root = ET.Element('fontMetadata', {'fileName': self.path.name, 'path': str(self.path), 'stem': self.path.stem, 'extension': self.path.suffix.lower(), 'faceName': self.faceName, 'flavor': self._metadataString(getattr(font, 'flavor', None) or 'sfnt'), 'generatedAt': datetime.datetime.now().isoformat(sep='T', timespec='seconds'), 'sizeBytes': str(self.path.stat().st_size if self.path.exists() else 0)})
                self._appendTableDirectory(font, root)
                self._appendNameRecords(font, root)
                self._appendMetricStats(font, root)
                self._appendCmapStats(font, root)
                self._appendLayoutFeatures(font, root)
                return root
            finally:
                try:
                    font.close()
                except Exception as error:
                    captureException('metadataElement.close', error)
        except Exception as error:
            captureException('metadataElement', error)
            return ET.Element('fontMetadata', {'fileName': self.path.name, 'path': str(self.path), 'extension': self.path.suffix.lower(), 'generatedAt': datetime.datetime.now().isoformat(sep='T', timespec='seconds'), 'error': f'{type(error).__name__}: {error}'})

    def writeRoundTripMetadataXml(self, xmlPath: Path | None = None) -> Path:
        import xml.etree.ElementTree as ET
        root = cast(Any, self.metadataElement())
        path = Path(xmlPath) if xmlPath is not None else self.metadataXmlPath()
        if str(root.attrib.get('error', EMPTY_STRING) or EMPTY_STRING).strip():
            print(f'[Font:WARN] metadata XML not written because font read failed: {root.attrib.get("error")}', flush=True)
            return path
        tree = ET.ElementTree(root)
        try:
            ET.indent(tree, space='  ')
        except Exception as error:
            captureException('writeRoundTripMetadataXml.indent', error)
        tree.write(str(path), encoding='utf-8', xml_declaration=True)
        print(f'[Font] metadata XML: {path}', flush=True)
        return path

    def _xmlInt(self, value: object, default: int | None = None) -> int | None:
        text = str(value if value is not None else EMPTY_STRING).strip()
        if text == EMPTY_STRING:
            return default
        try:
            if text.lower().startswith('0x'):
                return int(text, 16)
            return int(float(text))
        except Exception as error:
            captureException('_xmlInt', error)
            return default

    def _xmlFloat(self, value: object, default: float | None = None) -> float | None:
        text = str(value if value is not None else EMPTY_STRING).strip()
        if text == EMPTY_STRING:
            return default
        try:
            return float(text)
        except Exception as error:
            captureException('_xmlFloat', error)
            return default

    def _parseCodepoint(self, value: object) -> int | None:
        text = str(value if value is not None else EMPTY_STRING).strip()
        if not text:
            return None
        try:
            if text.upper().startswith('U+'):
                return int(text[2:], 16)
            if text.lower().startswith('0x'):
                return int(text, 16)
            return int(text)
        except Exception as error:
            captureException('_parseCodepoint', error)
            return None

    def _applyIntAttrs(self, table: object, node: object | None, attrs: tuple[str, ...]) -> int:
        if table is None or node is None:
            return 0
        changed = 0
        node_any = cast(Any, node)
        for attr in attrs:
            if attr not in node_any.attrib or not hasattr(table, attr):
                continue
            value = self._xmlInt(node_any.attrib.get(attr), None)
            if value is None:
                continue
            try:
                if getattr(table, attr) != value:
                    setattr(table, attr, value)
                    changed += 1
            except Exception as error:
                captureException('_applyIntAttrs', error)
        return changed

    def _applyFloatAttrs(self, table: object, node: object | None, attrs: tuple[str, ...]) -> int:
        if table is None or node is None:
            return 0
        changed = 0
        node_any = cast(Any, node)
        for attr in attrs:
            if attr not in node_any.attrib or not hasattr(table, attr):
                continue
            value = self._xmlFloat(node_any.attrib.get(attr), None)
            if value is None:
                continue
            try:
                if float(getattr(table, attr)) != float(value):
                    setattr(table, attr, value)
                    changed += 1
            except Exception as error:
                captureException('_applyFloatAttrs', error)
        return changed

    def _applyNameMetadata(self, font: object, root: object) -> int:
        names_node = cast(Any, root).find('names')
        if names_node is None or 'name' not in cast(Any, font):
            return 0
        changed = 0
        name_table = cast(Any, font)['name']
        for node in names_node.findall('name'):
            if str(node.attrib.get('locked', 'false')).lower() in {'1', 'true', 'yes'}:
                continue
            text = node.text or EMPTY_STRING
            name_id = self._xmlInt(node.attrib.get('nameID'), None)
            platform_id = self._xmlInt(node.attrib.get('platformID'), None)
            encoding_id = self._xmlInt(node.attrib.get('encodingID'), None)
            language_id = self._xmlInt(node.attrib.get('languageID'), None)
            if None in (name_id, platform_id, encoding_id, language_id):
                continue
            try:
                existing = None
                for record in getattr(name_table, 'names', []) or []:
                    if (safeInt(getattr(record, 'nameID', -1), -1) == int(cast(int, name_id)) and safeInt(getattr(record, 'platformID', -1), -1) == int(cast(int, platform_id)) and safeInt(getattr(record, 'platEncID', -1), -1) == int(cast(int, encoding_id)) and safeInt(getattr(record, 'langID', -1), -1) == int(cast(int, language_id))):
                        try:
                            existing = record.toUnicode()
                        except Exception as error:
                            captureException('_applyNameMetadata.existing', error)
                            existing = None
                        break
                if existing != text:
                    name_table.setName(text, int(cast(int, name_id)), int(cast(int, platform_id)), int(cast(int, encoding_id)), int(cast(int, language_id)))
                    changed += 1
            except Exception as error:
                captureException('_applyNameMetadata', error)
        return changed

    def _applyMetricMetadata(self, font: object, root: object) -> int:
        metrics_node = cast(Any, root).find('metrics')
        if metrics_node is None:
            return 0
        changed = 0
        font_any = cast(Any, font)
        if 'head' in font_any:
            changed += self._applyIntAttrs(font_any['head'], metrics_node.find('head'), ('unitsPerEm', 'xMin', 'yMin', 'xMax', 'yMax', 'flags', 'macStyle'))
        if 'maxp' in font_any:
            changed += self._applyIntAttrs(font_any['maxp'], metrics_node.find('maxp'), ('maxZones', 'maxTwilightPoints', 'maxStorage', 'maxFunctionDefs', 'maxInstructionDefs', 'maxStackElements', 'maxSizeOfInstructions', 'maxComponentElements', 'maxComponentDepth'))
        if 'hhea' in font_any:
            changed += self._applyIntAttrs(font_any['hhea'], metrics_node.find('hhea'), ('ascent', 'descent', 'lineGap', 'advanceWidthMax', 'minLeftSideBearing', 'minRightSideBearing', 'xMaxExtent', 'numberOfHMetrics'))
        if 'OS/2' in font_any:
            os2_node = metrics_node.find('OS_2')
            changed += self._applyIntAttrs(font_any['OS/2'], os2_node, ('version', 'xAvgCharWidth', 'usWeightClass', 'usWidthClass', 'sTypoAscender', 'sTypoDescender', 'sTypoLineGap', 'usWinAscent', 'usWinDescent', 'sxHeight', 'sCapHeight'))
            if os2_node is not None and 'achVendID' in os2_node.attrib and hasattr(font_any['OS/2'], 'achVendID'):
                value = str(os2_node.attrib.get('achVendID') or EMPTY_STRING)[:4]
                if value and getattr(font_any['OS/2'], 'achVendID') != value:
                    font_any['OS/2'].achVendID = value
                    changed += 1
        if 'post' in font_any:
            post_node = metrics_node.find('post')
            changed += self._applyFloatAttrs(font_any['post'], post_node, ('formatType', 'italicAngle'))
            changed += self._applyIntAttrs(font_any['post'], post_node, ('isFixedPitch', 'underlinePosition', 'underlineThickness'))
        changed += self._applyHmtxMetadata(font, metrics_node.find('hmtx'))
        return changed

    def _truthyXmlFlag(self, value: object) -> bool:
        return str(value if value is not None else EMPTY_STRING).strip().lower() in {'1', 'true', 'yes', 'y', 'on', 'all', 'force'}

    def _applyHmtxMetadata(self, font: object, hmtx_node: object | None) -> int:
        if hmtx_node is None or 'hmtx' not in cast(Any, font):
            return 0
        hmtx = cast(Any, font)['hmtx']
        node_any = cast(Any, hmtx_node)
        changed = 0
        target_width: int | None = None
        all_glyphs = False
        for attr in ('setAllAdvanceWidth', 'forceAllAdvanceWidth', 'targetAllAdvanceWidth', 'advance.width', 'hmtx.advance.width'):
            if attr in node_any.attrib:
                target_width = self._xmlInt(node_any.attrib.get(attr), None)
                all_glyphs = True
                break
        if target_width is None:
            for attr in ('setAllNonZeroAdvanceWidth', 'targetAdvanceWidth'):
                if attr in node_any.attrib:
                    target_width = self._xmlInt(node_any.attrib.get(attr), None)
                    break
        requested_min = self._xmlInt(node_any.attrib.get('minAdvanceWidth', node_any.attrib.get('advance.min', node_any.attrib.get('hmtx.advance.min'))), None)
        requested_max = self._xmlInt(node_any.attrib.get('maxAdvanceWidth', node_any.attrib.get('advance.max', node_any.attrib.get('hmtx.advance.max'))), None)
        requested_unique = self._xmlInt(node_any.attrib.get('uniqueAdvanceWidthCount', node_any.attrib.get('advance.unique', node_any.attrib.get('hmtx.advance.unique'))), None)
        if requested_min is not None and requested_max is not None and requested_unique == 1 and requested_min == requested_max and requested_min > 0:
            target_width = requested_min
            all_glyphs = True
        if self._truthyXmlFlag(node_any.attrib.get('includeZeroAdvanceWidths')) or self._truthyXmlFlag(node_any.attrib.get('forceZeroAdvanceWidths')):
            all_glyphs = True
        if target_width is not None and target_width > 0:
            changed_glyphs = 0
            for glyph_name, metrics in list(getattr(hmtx, 'metrics', {}).items()):
                try:
                    width, lsb = metrics
                    width = safeInt(width, 0)
                    if (all_glyphs or width > 0) and width != target_width:
                        hmtx.metrics[glyph_name] = (safeInt(target_width, FONT_REPAIR_TARGET_WIDTH), safeInt(lsb, 0))
                        changed_glyphs += 1
                except Exception as error:
                    captureException('_applyHmtxMetadata.metric', error)
                    continue
            changed += changed_glyphs
        samples = node_any.find('samples')
        apply_samples = self._truthyXmlFlag(node_any.attrib.get('applySamples'))
        if samples is not None:
            for glyph_node in samples.findall('glyph'):
                if not apply_samples and not self._truthyXmlFlag(glyph_node.attrib.get('apply')):
                    continue
                glyph_name = str(glyph_node.attrib.get('name') or EMPTY_STRING).strip()
                if not glyph_name or glyph_name not in hmtx.metrics:
                    continue
                old_width, old_lsb = hmtx.metrics[glyph_name]
                new_width = self._xmlInt(glyph_node.attrib.get('advanceWidth'), safeInt(old_width, 0))
                new_lsb = self._xmlInt(glyph_node.attrib.get('leftSideBearing'), safeInt(old_lsb, 0))
                if new_width is None or new_lsb is None:
                    continue
                if (safeInt(old_width, 0), safeInt(old_lsb, 0)) != (safeInt(new_width, 0), safeInt(new_lsb, 0)):
                    hmtx.metrics[glyph_name] = (int(new_width), int(new_lsb))
                    changed += 1
        return changed

    def _applyCmapMetadata(self, font: object, root: object) -> int:
        cmap_node = cast(Any, root).find('cmap')
        if cmap_node is None or 'cmap' not in cast(Any, font):
            return 0
        changed = 0
        cmap_apply_all = str(cmap_node.attrib.get('apply', 'false')).lower() in {'1', 'true', 'yes'}
        for sub_node in cmap_node.findall('subtable'):
            sub_apply_all = cmap_apply_all or str(sub_node.attrib.get('apply', 'false')).lower() in {'1', 'true', 'yes'}
            platform_id = self._xmlInt(sub_node.attrib.get('platformID'), None)
            encoding_id = self._xmlInt(sub_node.attrib.get('encodingID'), None)
            fmt = self._xmlInt(sub_node.attrib.get('format'), None)
            language = self._xmlInt(sub_node.attrib.get('language'), None)
            for table in cast(Any, font)['cmap'].tables:
                if platform_id is not None and safeInt(getattr(table, 'platformID', -999), -999) != int(platform_id):
                    continue
                if encoding_id is not None and safeInt(getattr(table, 'platEncID', -999), -999) != int(encoding_id):
                    continue
                if fmt is not None and safeInt(getattr(table, 'format', -999), -999) != int(fmt):
                    continue
                if language is not None and safeInt(getattr(table, 'language', 0), 0) != int(language):
                    continue
                cmap = getattr(table, 'cmap', None)
                if not isinstance(cmap, dict):
                    continue
                for map_node in sub_node.findall('map'):
                    delete = str(map_node.attrib.get('delete', 'false')).lower() in {'1', 'true', 'yes'}
                    map_apply = sub_apply_all or delete or str(map_node.attrib.get('apply', 'false')).lower() in {'1', 'true', 'yes'}
                    if not map_apply:
                        continue
                    code = self._parseCodepoint(map_node.attrib.get('codepoint'))
                    if code is None:
                        continue
                    glyph_name = str(map_node.attrib.get('glyph') or EMPTY_STRING).strip()
                    if delete:
                        if code in cmap:
                            del cmap[code]
                            changed += 1
                        continue
                    if glyph_name and cmap.get(code) != glyph_name:
                        cmap[code] = glyph_name
                        changed += 1
        return changed

    def shouldNormalizeMonospaceWidth(self) -> bool:
        stem = self.path.stem.lower().replace('-', '').replace('_', '')
        if self.isColor or 'emoji' in stem or 'symbols' in stem:
            return False
        return stem in TRIODESKTOP_MONO_REPAIR_STEMS

    def applyMetadataXml(self, xmlPath: Path | str | None = None, backup: bool = True, regenerate: bool = False) -> bool:
        import xml.etree.ElementTree as ET
        xml_path = Path(xmlPath) if xmlPath is not None else self.metadataXmlPath()
        print(f'\n[Font] Applying metadata XML: {xml_path.name} -> {self.path.name}', flush=True)
        if not xml_path.exists():
            print(f'   [Font:WARN] metadata XML does not exist: {xml_path}', flush=True)
            return False
        try:
            root = ET.parse(str(xml_path)).getroot()
        except Exception as error:
            captureException('applyMetadataXml.parse', error)
            print(f'   [Font:ERROR] cannot parse XML: {type(error).__name__}: {error}', flush=True)
            return False
        # Accept TrioDesktop's round-trip <fontMetadata> form. A raw fontTools
        # TTX dump (root <ttFont>) is a different schema; tell the user to use
        # round-trip XML for apply, or apply-ttx semantics are unsupported.
        if root.tag != 'fontMetadata':
            print(f'   [Font:ERROR] unsupported metadata root: <{root.tag}>. Use a round-trip XML produced by writeRoundTripMetadataXml (root <fontMetadata>).', flush=True)
            return False
        if backup:
            self.backup()
        changed = 0
        try:
            font = self.openFont()
            try:
                changed += self._applyNameMetadata(font, root)
                changed += self._applyMetricMetadata(font, root)
                changed += self._applyCmapMetadata(font, root)
                if changed:
                    flavor = str(root.attrib.get('flavor') or EMPTY_STRING).strip().lower()
                    if self.extension == '.woff2' or flavor == 'woff2':
                        font.flavor = 'woff2'
                    elif self.extension == '.woff' or flavor == 'woff':
                        font.flavor = 'woff'
                    font.save(str(self.path))
                    print(f'   [Font] updated physical font file; changed fields/glyphs/maps={changed}', flush=True)
                else:
                    print('   [Font] XML matched current font; no file changes needed', flush=True)
            finally:
                try:
                    font.close()
                except Exception as error:
                    captureException('applyMetadataXml.close', error)
        except Exception as error:
            captureException('applyMetadataXml', error)
            print(f'   [Font:ERROR] metadata update failed: {type(error).__name__}: {error}', flush=True)
            return False
        if regenerate:
            self.writeRoundTripMetadataXml()
        return bool(changed)

    def installToSystem(self) -> bool:
        if os.name != 'nt':
            print('   [Font:WARN] system font install is currently Windows-only', flush=True)
            return False
        try:
            destination = Path(r'C:\Windows\Fonts') / self.path.name
            copyFile(self.path, destination)
            print(f'   [Font] installed to system fonts: {destination}', flush=True)
            return True
        except PermissionError:
            print('   [Font:ERROR] permission denied; run elevated for system font install', flush=True)
        except Exception as error:
            captureException('installToSystem', error)
            print(f'   [Font:ERROR] system install failed: {type(error).__name__}: {error}', flush=True)
        return False

    def regenerateWoff2FromOtf(self, target: Path | None = None) -> Path | None:
        """Rebuild a WOFF2 from this OTF/TTF. Needs brotli for compression."""
        if TTFont is None:
            print('[Font:ERROR] fontTools required for WOFF2 (pip install fonttools)', file=sys.stderr, flush=True)
            return None
        if BROTLI_MODULE is None and ZOPFLI_MODULE is None:
            print('[Font:WARN] WOFF2 compression needs brotli (pip install brotli); attempting anyway', file=sys.stderr, flush=True)
        out = Path(target) if target is not None else self.path.with_suffix('.woff2')
        try:
            font = requiredTTFontClass()(str(self.path), recalcBBoxes=True, recalcTimestamp=False)
            font.flavor = 'woff2'
            font.save(str(out))
            try:
                font.close()
            except Exception as error:
                captureException('regenerateWoff2FromOtf.close', error)
            print(f'[Font] woff2 written: {out} md5={self.md5(out)}', flush=True)
            return out
        except Exception as error:
            captureException('regenerateWoff2FromOtf', error)
            print(f'[Font:ERROR] woff2 build failed: {type(error).__name__}: {error}', file=sys.stderr, flush=True)
            return None

    def updateEmbeddedFontBlobs(self, report: list[str]) -> bool:
        report.append('embedded font blobs are not used by the standalone font_tools build')
        return False

    @classmethod
    def autoRepairAll(cls, root: str | Path | None = None) -> tuple[int, list[str]]:
        report: list[str] = ['font auto-repair report', f'timestamp={datetime.datetime.now().isoformat()}']
        fonts = Fonts.discoverAll(root)
        if not fonts:
            report.append('no fonts found')
            return 1, report
        exit_code = 0
        for font in fonts:
            before = font.md5(font.path)
            if font.path.suffix.lower() in {'.woff2', '.woff'}:
                report.append(f'{font.path.name}: skipped (web-font; rebuild from OTF/TTF source)')
                continue
            result = font.repair()
            after = font.md5(font.path)
            ok = bool(result.get('ok'))
            if not ok:
                exit_code = 1
            raw_notes = result.get('notes', [])
            note_items = raw_notes if isinstance(raw_notes, list) else []
            notes = '; '.join(str(item) for item in note_items)
            line = f'{font.path.name}: ok={int(ok)} changed={int(before != after)} before={before} after={after} {notes}'
            report.append(line)
            print('[FontRepair] ' + line, flush=True)
        return int(exit_code), report


# ----------------------------------------------------------------------------
# Merge - the never-tofu move: pull donor glyphs into a base font.
# ----------------------------------------------------------------------------
def mergeFonts(base_path: Path | str, donor_path: Path | str, output_path: Path | str | None = None,
               codepoints: list[int] | None = None, dryRun: bool = False) -> Path | None:
    """Merge donor glyphs into base. If codepoints is given, subset the donor to
    just those codepoints first (so e.g. only U+2605 from Dela lands in Noto).
    With dryRun, report what would merge without writing output.
    """
    if TTFont is None or FontMerger is None:
        print('[Merge:ERROR] fontTools (ttLib + merge) required. Install with: pip install fonttools', file=sys.stderr, flush=True)
        return None
    base = Path(base_path)
    donor = Path(donor_path)
    if not base.exists():
        print(f'[Merge:ERROR] base font not found: {base}', file=sys.stderr, flush=True)
        return None
    if not donor.exists():
        print(f'[Merge:ERROR] donor font not found: {donor}', file=sys.stderr, flush=True)
        return None
    out = Path(output_path) if output_path is not None else base.with_name(base.stem + '_merged' + base.suffix)

    # Report what the donor offers for the requested codepoints.
    donor_best: dict[int, str] = {}
    try:
        df = requiredTTFontClass()(str(donor), recalcBBoxes=False, recalcTimestamp=False)
        donor_best = dict(df.getBestCmap() or {})
        df.close()
    except Exception as error:
        captureException('mergeFonts.donorCmap', error)
    base_best: dict[int, str] = {}
    base_upem = 0
    donor_upem = 0
    try:
        bf = requiredTTFontClass()(str(base), recalcBBoxes=False, recalcTimestamp=False)
        base_best = dict(bf.getBestCmap() or {})
        base_upem = safeInt(getattr(bf['head'], 'unitsPerEm', 0), 0) if 'head' in bf else 0
        bf.close()
    except Exception as error:
        captureException('mergeFonts.baseCmap', error)
    try:
        df2 = requiredTTFontClass()(str(donor), recalcBBoxes=False, recalcTimestamp=False)
        donor_upem = safeInt(getattr(df2['head'], 'unitsPerEm', 0), 0) if 'head' in df2 else 0
        df2.close()
    except Exception as error:
        captureException('mergeFonts.donorUpem', error)

    wanted = list(codepoints or [])
    if wanted:
        donatable = [cp for cp in wanted if cp in donor_best]
        missing = [cp for cp in wanted if cp not in donor_best]
        already = [cp for cp in wanted if cp in base_best]
        for cp in wanted:
            tag = []
            if cp in donor_best:
                tag.append(f'donor={donor_best[cp]}')
            else:
                tag.append('donor=MISSING')
            tag.append('base=present' if cp in base_best else 'base=absent')
            print(f'[Merge] U+{cp:04X}: ' + ' '.join(tag), flush=True)
        print(f'[Merge] donor supplies {len(donatable)}/{len(wanted)} requested codepoints; {len(missing)} missing; {len(already)} already in base', flush=True)
        if not donatable:
            print('[Merge:WARN] donor has none of the requested codepoints; nothing to merge', flush=True)
            return None
    else:
        print(f'[Merge] base cmap={len(base_best)} codepoints; donor cmap={len(donor_best)} codepoints (full merge)', flush=True)

    if dryRun:
        print('[Merge] dry-run: no output written', flush=True)
        return None

    # Subset the donor down to the wanted codepoints (keeps the merge clean and
    # avoids name collisions). Requires fontTools.subset.
    donor_for_merge = str(donor)
    temp_subset: Path | None = None
    if wanted and FontSubsetter is not None and FontSubsetOptions is not None:
        try:
            subopts = cast(Any, FontSubsetOptions)()
            subopts.glyph_names = True
            subopts.recalc_bounds = False
            subopts.recalc_timestamp = False
            subopts.notdef_outline = True
            subopts.name_IDs = ['*']
            subopts.name_legacy = True
            subopts.layout_features = ['*']
            subsetter = cast(Any, FontSubsetter)(options=subopts)
            sf = requiredTTFontClass()(str(donor), recalcBBoxes=False, recalcTimestamp=False)
            subsetter.populate(unicodes=[cp for cp in wanted if cp in donor_best])
            subsetter.subset(sf)
            # fontTools.merge.Merger requires matching unitsPerEm across fonts.
            # Rescale the donor subset to the base's em square so a 1000-upem
            # donor glyph drops cleanly into a 1024-upem base (or vice-versa).
            if base_upem and donor_upem and base_upem != donor_upem:
                try:
                    from fontTools.ttLib.scaleUpem import scale_upem
                    scale_upem(sf, base_upem)
                    print(f'[Merge] rescaled donor subset unitsPerEm {donor_upem} -> {base_upem}', flush=True)
                except Exception as error:
                    captureException('mergeFonts.scaleUpem', error)
                    print('[Merge:WARN] could not rescale donor unitsPerEm; merge may fail on mismatched em squares', file=sys.stderr, flush=True)
            temp_subset = out.with_name(out.stem + '_donorsubset_tmp' + donor.suffix)
            sf.save(str(temp_subset))
            sf.close()
            donor_for_merge = str(temp_subset)
            print(f'[Merge] donor subset to {len([cp for cp in wanted if cp in donor_best])} glyph(s) -> {temp_subset.name}', flush=True)
        except Exception as error:
            captureException('mergeFonts.subset', error)
            print('[Merge:WARN] donor subset failed; merging full donor instead', file=sys.stderr, flush=True)
            donor_for_merge = str(donor)

    try:
        merger = cast(Any, FontMerger)()
        merged = merger.merge([str(base), donor_for_merge])
        merged.save(str(out))
        try:
            merged.close()
        except Exception as error:
            captureException('mergeFonts.close', error)
        # Verify result.
        try:
            rf = requiredTTFontClass()(str(out), recalcBBoxes=False, recalcTimestamp=False)
            result_best = dict(rf.getBestCmap() or {})
            rf.close()
            if wanted:
                landed = [cp for cp in wanted if cp in result_best]
                print(f'[Merge] OK -> {out}', flush=True)
                for cp in wanted:
                    status = 'present' if cp in result_best else 'MISSING'
                    print(f'[Merge] result U+{cp:04X}: {status} (glyph={result_best.get(cp, "-")})', flush=True)
                print(f'[Merge] {len(landed)}/{len(wanted)} requested codepoints now in merged font; total cmap={len(result_best)}', flush=True)
            else:
                print(f'[Merge] OK -> {out} (merged cmap={len(result_best)} codepoints)', flush=True)
        except Exception as error:
            captureException('mergeFonts.verify', error)
            print(f'[Merge] OK -> {out} (verification read failed)', flush=True)
        return out
    except Exception as error:
        captureException('mergeFonts.merge', error)
        print(f'[Merge:ERROR] merge failed: {type(error).__name__}: {error}', file=sys.stderr, flush=True)
        return None
    finally:
        if temp_subset is not None and temp_subset.exists():
            try:
                temp_subset.unlink()
            except Exception as error:
                captureException('mergeFonts.cleanup', error)


# ----------------------------------------------------------------------------
# CLI.
# ----------------------------------------------------------------------------
def _cmdInfo(args: argparse.Namespace) -> int:
    if not ensureFontToolsAvailable():
        print('[ERROR] fontTools required (pip install fonttools)', file=sys.stderr, flush=True)
        return 2
    font = Font(Path(args.font))
    if not font.path.exists():
        print(f'[ERROR] font not found: {font.path}', file=sys.stderr, flush=True)
        return 1
    try:
        opened = font.openFont()
        try:
            for row in font.metadataReportRows(opened):
                print(row, flush=True)
        finally:
            opened.close()
        return 0
    except Exception as error:
        captureException('cmd.info', error)
        print(f'[ERROR] {type(error).__name__}: {error}', file=sys.stderr, flush=True)
        return 1


def _cmdDumpXml(args: argparse.Namespace) -> int:
    if not ensureFontToolsAvailable():
        print('[ERROR] fontTools required (pip install fonttools)', file=sys.stderr, flush=True)
        return 2
    font = Font(Path(args.font))
    if not font.path.exists():
        print(f'[ERROR] font not found: {font.path}', file=sys.stderr, flush=True)
        return 1
    out = Path(args.out) if args.out else font.path.with_name(font.path.name + '.ttx')
    ok, xml_path, rows = font.writeMetadataXml(out)
    for row in rows:
        print(row, flush=True)
    if ok and xml_path.exists():
        print(f'[OK] TTX/XML written: {xml_path} bytes={xml_path.stat().st_size}', flush=True)
        return 0
    print(f'[ERROR] XML dump failed for {font.path.name}', file=sys.stderr, flush=True)
    return 1


def _cmdApplyXml(args: argparse.Namespace) -> int:
    if not ensureFontToolsAvailable():
        print('[ERROR] fontTools required (pip install fonttools)', file=sys.stderr, flush=True)
        return 2
    font = Font(Path(args.font))
    if not font.path.exists():
        print(f'[ERROR] font not found: {font.path}', file=sys.stderr, flush=True)
        return 1
    xml_path = Path(args.xml)
    if not xml_path.exists():
        print(f'[ERROR] xml not found: {xml_path}', file=sys.stderr, flush=True)
        return 1
    changed = font.applyMetadataXml(xml_path, backup=True, regenerate=False)
    print(f'[OK] apply-xml completed; changed={changed}', flush=True)
    return 0


def _cmdMap(args: argparse.Namespace) -> int:
    if not ensureFontToolsAvailable():
        print('[ERROR] fontTools required (pip install fonttools)', file=sys.stderr, flush=True)
        return 2
    font = Font(Path(args.font))
    if not font.path.exists():
        print(f'[ERROR] font not found: {font.path}', file=sys.stderr, flush=True)
        return 1
    cp = _parseGlyphCodepoint(args.codepoint)
    if cp is None:
        print(f'[ERROR] cannot parse codepoint: {args.codepoint}', file=sys.stderr, flush=True)
        return 1
    out = Path(args.out) if args.out else font.path.with_name(font.path.stem + '_mapped' + font.path.suffix)
    try:
        opened = font.openFont()
        glyph_order = set(str(g) for g in (opened.getGlyphOrder() or []))
        if args.glyph not in glyph_order:
            # cmap subtables only accept glyph names that exist in the font; a
            # name not in the glyph order cannot be saved. To add a glyph the
            # base lacks, use the `merge` command instead.
            print(f'[ERROR:map] glyph {args.glyph!r} is not in {font.path.name} (font has {len(glyph_order)} glyphs).', file=sys.stderr, flush=True)
            print('           A codepoint can only map to a glyph the font already contains. To import a glyph from another font, use: font_tools.py merge <base> <donor> -c <U+...>', file=sys.stderr, flush=True)
            opened.close()
            return 1
        changed = Font.mapCodepoint(opened, cp, args.glyph)
        if changed:
            opened.save(str(out))
            print(f'[OK] mapped U+{cp:04X} -> {args.glyph}; wrote {out}', flush=True)
        else:
            print(f'[map] U+{cp:04X} already maps to {args.glyph}; no change', flush=True)
        opened.close()
        return 0
    except Exception as error:
        captureException('cmd.map', error)
        print(f'[ERROR] {type(error).__name__}: {error}', file=sys.stderr, flush=True)
        return 1


def _cmdMerge(args: argparse.Namespace) -> int:
    cps: list[int] = []
    for raw in (args.codepoints or []):
        cp = _parseGlyphCodepoint(raw)
        if cp is not None:
            cps.append(cp)
    out = mergeFonts(args.base, args.donor, args.out, codepoints=cps or None, dryRun=args.dry_run)
    return 0 if (out is not None or args.dry_run) else 1


def _cmdExtractGlyph(args: argparse.Namespace) -> int:
    font_path = Path(args.font)
    if not font_path.exists():
        print(f'[ERROR] font not found: {font_path}', file=sys.stderr, flush=True)
        return 1
    cp = _parseGlyphCodepoint(args.codepoint)
    if cp is None:
        print(f'[ERROR] cannot parse codepoint: {args.codepoint}', file=sys.stderr, flush=True)
        return 1
    out = Path(args.out) if args.out else None
    result = extractGlyphToPng(font_path, cp, output_path=out, size=int(args.size or 256))
    if result is not None and result.exists():
        print(f'[OK] glyph U+{cp:04X} -> {result}', flush=True)
        return 0
    print(f'[ERROR] could not render glyph U+{cp:04X}', file=sys.stderr, flush=True)
    return 1


def _cmdToWoff2(args: argparse.Namespace) -> int:
    if not ensureFontToolsAvailable():
        print('[ERROR] fontTools required (pip install fonttools)', file=sys.stderr, flush=True)
        return 2
    font = Font(Path(args.font))
    if not font.path.exists():
        print(f'[ERROR] font not found: {font.path}', file=sys.stderr, flush=True)
        return 1
    out = Path(args.out) if args.out else None
    result = font.regenerateWoff2FromOtf(out)
    return 0 if (result is not None and result.exists()) else 1


def _cmdRepair(args: argparse.Namespace) -> int:
    if not ensureFontToolsAvailable():
        print('[ERROR] fontTools required (pip install fonttools)', file=sys.stderr, flush=True)
        return 2
    font = Font(Path(args.font))
    if not font.path.exists():
        print(f'[ERROR] font not found: {font.path}', file=sys.stderr, flush=True)
        return 1
    print('[Repair] ' + _fontMetricSummaryText('BEFORE', font.path), flush=True)
    result = font.repair()
    ok = bool(result.get('ok'))
    notes = result.get('notes', [])
    print(f'[Repair] ok={int(ok)} changed={int(bool(result.get("changed")))} before={result.get("before")} after={result.get("after")}', flush=True)
    for note in (notes if isinstance(notes, list) else []):
        print(f'   - {note}', flush=True)
    print('[Repair] ' + _fontMetricSummaryText('AFTER', font.path), flush=True)
    return 0 if ok else 1


def _cmdList(args: argparse.Namespace) -> int:
    fonts = Fonts.discoverAll(args.dir)
    printFontList(fonts)
    return 0


def buildParser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog='font_tools.py', description='Standalone font forge (inspect/remap/merge/TTX round-trip/glyph->PNG/OTF->WOFF2/repair).')
    sub = parser.add_subparsers(dest='command', required=True)

    p = sub.add_parser('info', help='metrics + cmap summary')
    p.add_argument('font')
    p.set_defaults(func=_cmdInfo)

    p = sub.add_parser('dump-xml', help='font -> XML (full TTX dump)')
    p.add_argument('font')
    p.add_argument('out', nargs='?', default=None)
    p.set_defaults(func=_cmdDumpXml)

    p = sub.add_parser('apply-xml', help='XML -> font (round-trip back)')
    p.add_argument('font')
    p.add_argument('xml')
    p.set_defaults(func=_cmdApplyXml)

    p = sub.add_parser('map', help='remap a codepoint to a glyph')
    p.add_argument('font')
    p.add_argument('codepoint', help='e.g. U+2605, 0x2605, 9733')
    p.add_argument('glyph', help='target glyph name')
    p.add_argument('out', nargs='?', default=None)
    p.set_defaults(func=_cmdMap)

    p = sub.add_parser('merge', help='merge donor glyphs into base (never-tofu move)')
    p.add_argument('base')
    p.add_argument('donor')
    p.add_argument('out', nargs='?', default=None)
    p.add_argument('--codepoint', '-c', dest='codepoints', action='append', default=[], help='restrict merge to these codepoints (repeatable), e.g. -c U+2605')
    p.add_argument('--dry-run', action='store_true', help='report what would merge, write nothing')
    p.set_defaults(func=_cmdMerge)

    p = sub.add_parser('extract-glyph', help='render a glyph to PNG')
    p.add_argument('font')
    p.add_argument('codepoint')
    p.add_argument('out', nargs='?', default=None)
    p.add_argument('--size', type=int, default=256)
    p.set_defaults(func=_cmdExtractGlyph)

    p = sub.add_parser('to-woff2', help='OTF/TTF -> WOFF2')
    p.add_argument('font')
    p.add_argument('out', nargs='?', default=None)
    p.set_defaults(func=_cmdToWoff2)

    p = sub.add_parser('repair', help='repair terminal-font metrics + glyph mappings')
    p.add_argument('font')
    p.set_defaults(func=_cmdRepair)

    p = sub.add_parser('list', help='list fonts in a directory (default: this dir)')
    p.add_argument('dir', nargs='?', default=None)
    p.set_defaults(func=_cmdList)

    return parser


def main(argv: list[str] | None = None) -> int:
    parser = buildParser()
    args = parser.parse_args(list(argv) if argv is not None else None)
    return int(args.func(args))


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