#! /usr/bin/env python3

####################################################
# Generate a fakenewcomputermodern.sty package and #
# associated font files that work with different   #
# TeX engines.                                     #
#                                                  #
# Author: Scott Pakin <scott-clsl@pakin.org>       #
####################################################

import dateutil.parser
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path


# Specify the set of food-allergy symbols.
allergy_symbols = [
    ('E033', 'crustaceans'),
    ('E034', 'eggs'),
    ('E035', 'gluten'),
    ('E036', 'fish'),
    ('E037', 'lupin'),
    ('E038', 'milk'),
    ('E039', 'mollusks'),
    ('E03A', 'mustard'),
    ('E03B', 'peanuts'),
    ('E03C', 'sesame'),
    ('E03D', 'soy'),
    ('E03E', 'nuts'),
    ('E03F', 'celery'),
    ('E040', 'SOO'),
]

# Specify the subset of math symbols we want to present.
math_symbols = [
    ('E032', 'nleftleftarrows'),
    ('E033', 'nrightrightarrows'),
    ('E034', 'twoheadhookrightarrow'),
    ('E035', 'twoheadhookleftarrow'),
    ('E036', 'tconvolution'),
    ('E037', 'dconvolution'),
]


def kpsewhich(fname, must_exist=True, format=None):
    'Find a filename in the TeX tree.'
    cmd = ['kpsewhich']
    if format is not None:
        cmd.append(f'--format={format}')
    cmd.append(fname)
    proc = subprocess.run(cmd, capture_output=True,
                          check=must_exist, encoding='utf-8')
    if proc.returncode != 0:
        return None
    return proc.stdout.strip()


def header_string(c):
    'Return a "generated file" header string using a comment character.'
    full_name = os.path.abspath(sys.argv[0])
    gen_line = 'This is a generated file.  DO NOT EDIT.'
    edit_line = f'Edit {full_name} instead.'
    max_chars = max(len(gen_line), len(edit_line))
    comment_line = c * (max_chars + 4)
    return '\n'.join([
        comment_line,
        '%s %-*.*s %s' % (c, max_chars, max_chars, gen_line, c),
        '%s %-*.*s %s' % (c, max_chars, max_chars, edit_line, c),
        comment_line,
        '',
    ])


def get_sans_symbols():
    '''Return a list of (hex, name) tuples of symbols to declare with
    newcomputermodern's sans-serif variant.'''

    # Parse Aegean numbers from fspdefault.tex.
    aegean_symbols = []
    aegean_re = re.compile(r'\\newcommand\*\{\\(aegean[a-z]+)\}\{\\textsf\{\\char"([0-9A-F]+)\}\}')
    fsp = kpsewhich('fspdefault.tex')
    with open(fsp) as r:
        for ln in r:
            match = aegean_re.match(ln)
            if match is None:
                continue
            aegean_symbols.append((match[2], match[1]))

    # Return all sans-serif symbols we care about.
    return allergy_symbols + aegean_symbols


def get_braille_symbols():
    'Return a list of (hex, name) tuples of braille symbols,'
    # Read Unicode names from unicode.txt.
    braille = []
    dig2let = str.maketrans('123456', 'ABCDEF')
    with open('unicode.txt') as r:
        for ln in r:
            # Find 6-bit braille patterns.
            fields = ln.split(None, 1)
            if len(fields) != 2:
                continue
            code = int(fields[0], 16)
            if code < 0x2801 or code >= 0x2840:
                continue

            # Convert the name from something like "braille pattern
            # dots-1234" to something like "brailleABCD" for use as a
            # control sequence.
            name = fields[1][21:].strip()  # Skip "braille pattern dots-".
            name = 'braille' + name.translate(dig2let)
            braille.append((hex(code)[2:].upper(), name))
    braille.append(('2820', 'braillecapital'))
    return braille


def get_serif_symbols():
    '''Return a list of (hex, name) tuples of symbols to declare with
    newcomputermodern's serif variant.'''
    return get_braille_symbols()


def write_dummy_sty():
    '''Create an empty fakenewcomputermodern.sty file to use when
    newcomputermodern.sty is unavailable.'''
    with open('fakenewcomputermodern.sty', 'w') as w:
        w.write(header_string('%'))
        w.write('\n')
        w.write('\\endinput\n')


def read_provides_package(sty):
    '''Extract the \\ProvidesPackage line from newcomputermodern.sty.
    Abort if such a line is not found.'''
    # Extract the \ProvidesPackage line.
    provides_package = None
    with open(sty) as r:
        for ln in r:
            if ln.startswith('\\ProvidesPackage'):
                provides_package = ln
                break
    if provides_package is None:
        raise RuntimeError(f'failed to find a \\ProvidesPackage line in {sty}')

    # At the time of this writing (6-Jan-2026), newcomputermodern's
    # \ProvidesPackage version string lacks a date.  This causes
    # \fakeusepackage to crash.  As a workaround we try to extract the date
    # from newcomputermodern's documentation, newcm-doc.pdf.
    unversioned = '\\ProvidesPackage{newcomputermodern}'
    if re.search(r'\[\d+[-/]\d+[-/]\d+\s', provides_package) is not None:
        # newcomputermodern provides a date now.
        return provides_package
    doc = kpsewhich('newcm-doc.pdf', must_exist=False,
                    format='TeX system documentation')
    if doc is None:
        # No documentation file.  Return an unversioned \ProvidesPackage line.
        return unversioned
    proc = subprocess.run([
        'pdfinfo',
        '-isodates',
        doc,
    ], capture_output=True, check=False, encoding='utf-8')
    if proc.returncode != 0:
        # pdfinfo failed.  Return an unversioned \ProvidesPackage line.
        return unversioned
    date_str = None
    for ln in proc.stdout.split('\n'):
        fields = ln.split(None, maxsplit=1)
        if len(fields) == 2 and fields[0] == 'CreationDate:':
            date_str = fields[1]
            break
    if date_str is None:
        # No creation date found.  Return an unversioned \ProvidesPackage line.
        return unversioned
    try:
        date = dateutil.parser.isoparse(date_str)
    except ValueError:
        # Creation date is not parseable.  Return an unversioned
        # \ProvidesPackage line.
        return unversioned
    match = re.search(r'\[(.*)\]', provides_package)
    if match is None:
        ver_str = 'v0.0 Faked New Computer Modern'
    else:
        ver_str = '%04d-%02d-%02d %s' % \
            (date.year, date.month, date.day, match[1])
    return '\\ProvidesPackage{newcomputermodern}[%s]\n' % ver_str


def write_lualatex_sty(sty):
    '''Create a basic fakenewcomputermodern.sty file to use with
    LuaLaTeX.  It replicates newcomputermodern's \\ProvidesPackage line
    and defines a few helper macros.'''
    with open('fakenewcomputermodern.sty', 'w') as w:
        # Write a style-file header.
        w.write(header_string('%'))
        w.write('\n')
        w.write(read_provides_package(sty))
        w.write('\\RequirePackage{fontspec}\n')
        w.write('\n')

        # Define symbols from newcomputermodern's serif font.
        w.write(r'\newcommand*{\NCMrmfamily}{\fontspec{NewCM10-Book}}' + '\n')
        for cp, sym in get_serif_symbols():
            w.write('\\DeclareRobustCommand*{\\NCM%s}{{\\NCMrmfamily\\char"%s}}\n' %
                    (sym, cp))
        w.write('\n')

        # Define symbols from newcomputermodern's sans-serif font.
        w.write(r'\newcommand*{\NCMsffamily}{\fontspec{NewCMSans10-Book}}' + '\n')
        for cp, sym in get_sans_symbols():
            w.write('\\DeclareRobustCommand*{\\NCM%s}{{\\NCMsffamily\\char"%s}}\n' %
                    (sym, cp))
        w.write('\n')

        # Define symbols from newcomputermodern's math font.
        w.write(r'\newcommand*{\NCMmathfamily}{\fontspec{NewCMMath-Book}}' + '\n')
        for cp, sym in math_symbols:
            w.write('\\DeclareRobustCommand*{\\NCM%s}{{\\NCMmathfamily\\char"%s}}\n' %
                    (sym, cp))
        w.write('\n')

        w.write('\\endinput\n')


def create_auxiliary_files(otf, base, symbols):
    '''Generate a .pfb file, a .tfm file, and a .enc file.  Return a
    map-file line that associates all three of these.'''

    # Create an encoding file, to be deleted on exit.
    enc_in = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
                                         suffix='.enc')
    enc_in.write(header_string('%'))
    enc_in.write('\n')
    enc_in.write(f'/{base} [\n')
    for i, (cp, sym) in enumerate(symbols):
        enc_in.write('  /u%s   %% %3d: %s\n' % (cp, i, sym))
    enc_in.write(']\n')
    enc_in.flush()

    # Use otftotfm to generate various auxiliary files.
    otf = kpsewhich(otf)
    map_fname = base + '.map'
    try:
        # We don't want otftotfm to append to an existing file.
        os.remove(map_fname)
    except FileNotFoundError:
        pass
    subprocess.run(['otftotfm',
                    '--encoding=' + enc_in.name,
                    f'--map-file={map_fname}',
                    '--no-updmap',
                    otf,
                    base],
                   check=True, encoding='utf-8')

    # Parse the map file for the name of the generated encoding file.
    enc = None
    toks = []
    with open(map_fname, encoding='utf-8') as r:
        for ln in r:
            toks = ln.split()
            if len(toks) == 6:
                enc = toks[4][2:]
                break
    if enc is None:
        raise RuntimeError(f'failed to parse {map_fname}')

    # Rename the encoding file from something like "a_2wefkb.enc" to
    # "{base}.enc".
    os.rename(enc, f'{base}.enc')

    # Replace the name of the .enc file in the line read from the map
    # file with "{base}.enc" then remove the map file.
    toks[4] = toks[4][:2] + base + '.enc'
    os.remove(map_fname)

    # Return the modified line read from the map file.
    return ' '.join(toks)


def write_pdflatex_sty(sty):
    '''Create a fakenewcomputermodern.sty package and related font maps to
    use with pdfLaTeX.  The package replicates newcomputermodern's
    \\ProvidesPackage line, loads pdfLaTeX-compatible fonts, and defines
    a few helper macros.'''
    # Write a style-file header.
    with open('fakenewcomputermodern.sty', 'w') as w:
        w.write(header_string('%'))
        w.write('\n')
        w.write(read_provides_package(sty))
        w.write('\n')

    # Define symbols from newcomputermodern's serif font.
    serif_symbols = get_serif_symbols()
    serif_map_line = create_auxiliary_files('NewCM10-Book.otf',
                                            'newcm',
                                            serif_symbols)
    with open('fakenewcomputermodern.sty', 'a') as w:
        w.write('\\pdfmapline{=%s}\n' % serif_map_line)
        w.write(r'\font\newcm=newcm at 10pt' + '\n')
        for i, (cp, sym) in enumerate(serif_symbols):
            w.write('\\DeclareRobustCommand*{\\NCM%s}{{\\newcm\\char"%02X}}   %% Was "%s\n' %
                    (sym, i, cp))
        w.write('\n')

    # Define symbols from newcomputermodern's sans-serif font.
    sans_symbols = get_sans_symbols()
    sans_map_line = create_auxiliary_files('NewCMSans10-Book.otf',
                                           'newcmsans',
                                           sans_symbols)
    with open('fakenewcomputermodern.sty', 'a') as w:
        w.write('\\pdfmapline{=%s}\n' % sans_map_line)
        w.write(r'\font\newcmsans=newcmsans at 10pt' + '\n')
        for i, (cp, sym) in enumerate(sans_symbols):
            w.write('\\DeclareRobustCommand*{\\NCM%s}{{\\newcmsans\\char"%02X}}   %% Was "%s\n' %
                    (sym, i, cp))
        w.write('\n')

    # Define symbols from newcomputermodern's math font.
    math_map_line = create_auxiliary_files('NewCMMath-Book.otf',
                                           'newcmmath',
                                           math_symbols)
    with open('fakenewcomputermodern.sty', 'a') as w:
        w.write('\\pdfmapline{=%s}\n' % math_map_line)
        w.write(r'\font\newcmmath=newcmmath at 10pt' + '\n')
        for i, (cp, sym) in enumerate(math_symbols):
            w.write('\\DeclareRobustCommand*{\\NCM%s}{{\\newcmmath\\char"%02X}}   %% Was "%s\n' %
                    (sym, i, cp))
        w.write('\n')
        w.write('\\endinput\n')


if __name__ == '__main__':
    # Determine which TeX engine we're targeting.
    if len(sys.argv) == 1:
        sys.stderr.write('Usage: %s <latex_command>\n' % sys.argv[0])
        sys.exit(1)
    latex = Path(sys.argv[1]).stem.lower()

    # Determine what sort of file(s) we need to generate.
    sty = kpsewhich('newcomputermodern.sty', must_exist=False)
    if sty is None:
        # newcomputermodern.sty is unavailable.
        write_dummy_sty()
        sys.exit(0)
    if latex in ['lualatex', 'xelatex']:
        # LuaLaTeX or XeLaTeX
        write_lualatex_sty(sty)
        sys.exit(0)
    if latex == 'pdflatex':
        # pdfLaTeX
        write_pdflatex_sty(sty)
        sys.exit(0)
    raise RuntimeError(f'unrecognized LaTeX command {latex}')
