#!/usr/bin/python3

'''Yet Another Word Processor, an automatic word processor for text files, with PDF output

                       I sound my barbaric yawp over the roofs of the world
                       
                                                               Walt Whitman

The  name  "yawp"  here  means  Yet  Another Word Processor, and yawp is an
automatic word processor for plain text files, with PDF output. If you want
to quickly create a no-frills but well-formatted document, you can:

    • edit a text file by your favorite editor
    • run yawp in order to:
        • backup read format and rewrite the text file
        • export the text file in a PDF file
        • open the PDF file for check or print
    • go back to the editor and update the text file or finish

The following flowchart illustrates inputs and outputs:

    ┌───────────┐                       ┌───────────┐
    │           │       ┌───────┐ read  │           │       ┌───────┐    
    │           │ edit  │ text  ├──────▷│           │export │ PDF   │
    │  editor   ├──────▷│       │rewrite│   yawp    ├──────▷│       │
    │           │       │ file  │◁──────┤           │       │ file  │
    │           │       └───────┘       │           │       └───────┘
    └───────────┘                       └───┬───────┘
                                            │   △
                                            │   │
                                     backup │   │ restore (undo)
                                            │   │
                                            ▽   │
                                          ┌─────┴─┐
                                          │backup │
                                          │       │
                                          │ file  │
                                          └───────┘

Main features are:

    • yawp  processes  in place a single text file, hereinafter referred to
      simply as the "file"
    • yawp  before  processing  makes  a  timestamped  backup  of the file,
      allowing undo operation
    • yawp  processing  is  driven by the text in the file and by arguments
      only, not by commands or tags embedded in text
    • yawp justifies text at left and right in:
        • unindented paragraphs
        • dot-marked indented paragraphs (as this one)
    • yawp  accepts  unjustified  pictures  (as  schemas,  tables  and code
      examples) freely intermixed with text
    • you can sketch in pictures segments (by '`') and arrowheads (by '^'),
      yawp redraws them by proper graphic characters
    • yawp  adopts  an  ad  hoc  policy  for  Python  files, formatting the
      docstrings but not the Python code
    • yawp performs multi-level chapter renumbering
    • yawp inserts an automatic contents chapter in the file
    • yawp  recognizes  relevant  subjects  (quoted  by '"') and inserts an
      automatic index chapter in the file
    • yawp cuts the file in pages, by automatic insertion of two-lines page
      headers
    • yawp  exports  the  resulting  lines in PDF format, with control over
      character  size  and page layout, and opens for you the generated PDF
      file, allowing preview and printing
    • yawp  corrects  errors  made  by  CUPS-PDF  about  font size and page
      margins
    • yawp is "stable", namely if after a yawp execution you run yawp again
      on  the  same  file  with  the  same  arguments then the file content
      doesn't change (except date and time in page headers)
    • as  a  beta  release, yawp 0.5.1. contains some debug functionalities
      not aimed at the end user, they will disappear in some future release
      
To install, for example on a Debian-derived Linux, type:

    $ sudo apt-get -y update
    $ sudo apt-get -y install printer-driver-cups-pdf
    $ pip3 install -U yawp

For any detail, see the yawp-generated "yawp Manual" by typing:

    $ yawp -H
'''

__version__ = '0.5.1'

__all__ = []

#----- imports -----

from fnmatch import fnmatchcase
from glob import glob
from os import chdir, lstat, popen
from os.path import join as joinpath, split as splitpath, abspath, expanduser, splitext, exists, isfile, isdir
from sys import exit, stdout, stderr
from time import localtime

#----- path & file functions -----

def longpath(path='.'):
    return abspath(expanduser(path))

def shortpath(path='.'):
    path, home = longpath(path), longpath('~')
    return '~' + path[len(home):] if path.startswith(home) else path

def splitpath4(pathfile):
    '''return (long, short, file, ext), where:

    • long is the long path to file, without ending slash
    • short is the short path to file, without ending slash
    • file is the file name, without extension
    • ext is the file extension, without initial dot

    >>> splitpath4('~/yyy/aaa.bbb.ccc')
    ('/home/xxxx/yyy', '~/yyy', 'aaa.bbb', 'ccc')
    >>> splitpath4('/home/xxxx/yyy/aaa.bbb.ccc')
    ('/home/xxxx/yyy', '~/yyy', 'aaa.bbb', 'ccc')
'''
    long, filext = splitpath(longpath(pathfile))
    short = shortpath(long)
    fil, ext = splitext(filext)
    return long, short, fil, ext[1:]

def listfiles(pattern):
    "return a list of absolute paths to pattern-matching files ('**' in path is allowed)"
    return [file for file in glob(longpath(pattern), recursive=True) if isfile(file)]

def getfile(pattern):
    "return unique pattern-matching file, or '' if not found or ambiguous"
    files = listfiles(pattern)
    return files[0] if len(files) == 1 else ''

def lastfile(pattern):
    "return the newest path_file among matching path_files, or '' if not found"
    files = listfiles(pattern)
    return max((lstat(file).st_mtime, file) for file in files)[1] if files else ''

def localfile(pathfile):
    'return absolute path of a package relative pathfile'
    return joinpath(splitpath(__file__)[0], pathfile)

def listdirs(pattern):
    "return a list of absolute paths to pattern-matching dirs ('**' in path is allowed)"
    return [dir for dir in glob(longpath(pattern), recursive=True) if isdir(dir)]

def getdir(pattern):
    "return unique pattern-matching dir, or '' if not found or ambiguous"
    dirs = listdirs(pattern)
    return dirs[0] if len(dirs) == 1 else ''

def chdir2(dir='~'):
    "like os.chdir(), but it accepts '~' '.' '..' '?' '*' '**' etc"
    where = getdir(dir)
    chdir(where) if where else print(f'cd: path {dir!r} not found or ambiguous', file=stderr)

def newbackfile(file, YmdHMS=None):
    'create a timestamped filename for backup of file'
    path, ext = splitext(longpath(file))
    return f'{path}-%04d.%02d.%02d-%02d.%02d.%02d{ext}' % (YmdHMS or localtime())[:6]

def oldbackfile(file):
    "return filename of the newest timestamped backup of file, or '' if not found"
    path, ext = splitext(longpath(file))
    return lastfile(f'{path}-????.??.??-??.??.??{ext}')

#----- string functions -----

def hold(string, charpattern, default=''):
    'hold charpattern-matching chars in string and replace not matching chars with default'
    return ''.join(char if fnmatchcase(char, charpattern) else default for char in string)

def chars(charpattern):
    'return a sorted string of all charpattern-matching characters'
    kernel = charpattern[1:-1]
    a, z = ord(min(kernel)), ord(max(kernel)) + 1
    return ''.join(chr(j) for j in range(a, z) if fnmatchcase(chr(j), charpattern))

def upperin(string):
    'there is an uppercase letter in string?'
    return string != string.lower()

def lowerin(string):
    'there is a lowercase letter in string?'
    return string != string.upper()

def letterin(string):
    'there is a letter in string?'
    return string != string.lower() or string != string.upper()

def digitin(string):
    'there is a digit in string?'
    return any('0' <= char <= '9' for char in string)

def specialin(string):
    'there is a special (not alphanumeric) character in string?'
    return any(char == char.lower() == char.upper() and not '0' <= char <= '9' for char in string)

def split(string, separator=None):
    'return [] if not string else string.split(separator)'
    return [] if not string else string.split(separator)

def replace(string, *oldsnews):
    'replace(string, a, b, c, d, ...) == string.replace(a, b).replace(c, d)...'
    for j in range(0, len(oldsnews), 2):
        string = string.replace(oldsnews[j], oldsnews[j+1])
    return string

def change(string, olds, news, char='%'):
    'replace char + x with y for x, y in zip(olds, news)'
    trans = {old: new for old, new in zip(olds, news)}
    trans[char] = char # replace char + char with char
    value = ''
    skip = False
    for j, charj in enumerate(string):
        if charj == char:
            try:
                value += trans[string[j+1]]
            except (KeyError, IndexError):
                raise ValueError(string[j:j+2])
            else:
                skip = True
        elif skip:
            skip = False
        else:
            value += charj
    return value

def shrink(string, joinby=' ', splitby=None):
    'eliminate from string all leading, multiple and trailing blanks'
    return joinby.join(string.split(splitby))

def expand(string, width):
    "insert blanks into string until len(string) == width, on error return ''"
    string = shrink(string)
    if len(string) == width:
        return string
    if ' ' not in string[1:] or len(string) > width:
        raise ValueError(f'Impossible to right-justify: {string!r}')
    chars = list(string)
    jchar = 0
    while True:
        if chars[jchar] != ' ' and chars[jchar + 1] == ' ':
            chars.insert(jchar + 1, ' ')
            if len(chars) == width:
                return ''.join(chars)
        jchar = jchar + 1 if jchar < len(chars) - 2 else 0
        
def findchar(string, pattern):
    'return index of first pattern-matching char found in string, or -1 if not found'
    for j, char in enumerate(string):
        if fnmatchcase(char, pattern):
            return j
    else:
        return -1

def rfindchar(string, pattern):
    'return index of last pattern-matching char found in string, -1 if not found'
    for j, char in retroenum(string):
        if fnmatchcase(char, pattern):
            return j
    else:
        return -1

def chrs(jj):
    "return ''.join(chr(j) for j in jj)"
    return ''.join(chr(j) for j in jj)

def ords(string):
    'return [ord(char) for char in string]'
    return [ord(char) for char in string]

def edit(item, width=0):
    'convert item to str, justifying numbers at right and anything else at left'
    if isinstance(item, int):
        return str(item).rjust(width)
    elif isinstance(item, float):
        string = str(item)
        return (string[:-2] if string.endswith('.0') else string).rjust(width)
    else:
        return str(item).ljust(width)

def plural(num, sing, plur=''):
    '''return f'1 {sing}' if num == 1 else f'{num} {plur}' if plur else f'{num} {sing}s'

    >>> plural(1, 'cat')
    '1 cat'
    >>> plural(3, 'cat')
    '3 cats'
    >>> plural(1, 'mouse')
    '1 mouse'
    >>> plural(3, 'mouse')
    '3 mouses'
    >>> plural(3, 'mouse', 'mice')
    '3 mice'
'''
    return f'1 {sing}' if num == 1 else f'{num} {plur}' if plur else f'{num} {sing}s'

def prettytable(head, body):
    head = [name.upper() for name in head]
    wids = [len(name) for name in head]
    lenhead = len(head)
    for jrow, row in enumerate(body):
        row = tuple(row[:lenhead]) + (lenhead - len(row)) * ('',)
        for jcol, item in enumerate(row):
            wids[jcol] = max(wids[jcol], len(edit(item)))
        body[jrow] = row
    yield('┌' + '┬'.join(wid * '─' for wid in wids) + '┐')
    yield('│' + '│'.join(name.center(wid) for name, wid in zip(head, wids)) + '│')
    yield('├' + '┼'.join(wid * '─' for wid in wids) + '┤')
    for row in body:
        yield('│' + '│'.join(edit(item, wid) for item, wid in zip(row, wids)) + '│')
    yield('└' + '┴'.join(wid * '─' for wid in wids) + '┘')
    
#----- sequence functions -----

def retroenum(sequence):
    'like enumerate(sequence), but backwards from last to first item'
    for j in range(len(sequence) - 1, -1, -1):
        yield j, sequence[j]

def find(sequence, item):
    'return index of first item found in sequence, or -1 if not found'
    for j, itemj in enumerate(sequence):
        if itemj == item:
            return j
    else:
        return -1

def rfind(sequence, item):
    'return index of last item found in sequence, or -1 if not found'
    for j, itemj in retroenum(sequence):
        if itemj == item:
            return j
    else:
        return -1

def get(sequence, index, default=None):
    'return sequence[index] if 0 <= index < len(sequence) else default'
    return sequence[index] if 0 <= index < len(sequence) else default

def unique(sequence):
    olds = set()
    result = []
    for item in sequence:
        if item not in olds:
            olds.add(item)
            result.append(item)
    return result

#----- shell functions -----

def shell(command, mode='', file=stderr):
    '''
minimal shell, stderr is not intercepted
stdout is intercepted and returned as a list of strings
if 'c' in mode: print command
if 'o' in mode: print output
'''
    if 'c' in mode:
        print(f'{shortpath()} --> {command}', file=file)
    command = command.strip()
    if command == 'cd':
        chdir2()
        return []
    elif command.startswith('cd '):
        chdir2(command[3:].strip())
        return []
    else:
        stdout = [line.rstrip() for line in popen(command)]
        if 'o' in mode:
            for line in stdout:
                print(line, file=file)
        return stdout

def term(stdout_to=stdout):
    "minimal terminal, type 'exit' to exit"
    print('-----', term.__doc__, '-----')
    while True:
        command = input(f'{shortpath()} --> ')
        if command.split('#')[0].strip() == 'exit':
            return
        shell(command, 'o', stdout)

#----- metric functions -----

in2in = lambda In: float(In) # inch converters
in2pt = lambda In: In * 72.0
pt2in = lambda pt: pt / 72.0
in2cm = lambda In: In * 2.54
cm2in = lambda cm: cm / 2.54
in2mm = lambda In: In * 25.4
mm2in = lambda mm: mm / 25.4

def in2str(inch, units='pt in mm cm', digits=4):
    'convert float inch into a human-readable string'
    return ' = '.join(str(round({'pt':in2pt, 'in':in2in, 'mm':in2mm, 'cm':in2cm}[unit](inch), digits)) + unit for unit in units.split())
    
def str2in(string):
    '''convert '{float}{suffix}' into an inch float value, on error raise ValueError
suffix can be: 'in' = inch, 'pt' = point, 'cm' = centimeter or 'mm' = millimeter
only zero value (as '0', '0.0'...) can lack the suffix
'''
    if tryfunc(float, (string,), 1.0) == 0.0:
        return 0.0
    else:
        try:
            return {'in':in2in, 'pt':pt2in, 'cm':cm2in, 'mm':mm2in}[string[-2:].lower()](float(string[:-2]))
        except (ValueError, KeyError):
            raise ValueError

def str2inxin(string):
    '''convert '{float}x{float}{suffix}' into two inch float values, on error raise ValueError
suffix can be: 'in' = inch, 'pt' = point, 'cm' = centimeter or 'mm' = millimeter
'''
    left, right = string.lower().split('x')
    return str2in(left + right[-2:]), str2in(right)

def ratio(string):
    '''convert '{float}/{float}' or '{float}' into a float value'''
    try:
        if '/' in string:
            over, under = string.split('/')
            return float(over) / float(under)
        else:
            return float(string)
    except (ValueError, ZeroDivisionError):
        raise ValueError
    
#----- numeric functions -----

def interpol1(xyxy):
    '''least squares linear interpolation
xyxy = [(x, y), (x, y)...], len(xyxy) = n
return (a, b, err) such that:
    . y = a * x + b is the interpolating line
    . err = sqrt(sum((a * x + b - y) ** 2) / n)
'''
    n = len(xyxy)
    sx = sum(x for x, y in xyxy)
    sx2 = sum(x * x for x, y in xyxy)
    sy = sum(y for x, y in xyxy)
    sxy = sum(x * y for x, y in xyxy)
    d = n * sx2 - sx * sx
    a = (n * sxy - sx * sy) / d
    b = (sx2 * sy - sx * sxy) / d
    err = (sum((a * x + b - y) ** 2 for x, y in xyxy) / n) ** 0.5
    return a, b, err

def interpoly(xyxy, x):
    for j, (xj, yj) in enumerate(xyxy[:-1]):
        xjp, yjp = xyxy[j+1] 
        if xj <= x <= xjp:
            return yj + (x - xj) * (yjp - yj) / (xjp - xj)
    raise ValueError(x)

def interpolx(xyxy, y):
    for j, (xj, yj) in enumerate(xyxy[:-1]):
        xjp, yjp = xyxy[j+1] 
        if yj <= y <= yjp:
            return xj + (y - yj) * (xjp - xj) / (yjp - yj)
    raise ValueError(y)

#----- messages -----

def inform(message):
    'print an information message and continue'
    print(message, file=stderr)

def warning(message, jline=-1, line=''):
    'print a warning message and continue'
    if jline > -1:
        print(f'LINE {jline+1}: {line}', file=stderr)
    print(f'WARNING: {message}', file=stderr)

def error(message, jline=-1, line=''):
    'print an error message and exit'
    if jline > -1:
        print(f'LINE {jline+1}: {line}', file=stderr)
    exit(f'ERROR: {message}')

#----- other functions -----

def tryfunc(func, args, default=None):
    'try: return func(*args); except: return default'
    try:
        return func(*args)
    except:
        return default

def idem(x):
    'return x'
    return x

def dump(object):
    items = sorted((key, value) for key, value in object.__dict__.items() if not key.startswith('_'))
    length = max((len(key) for key, value in items), default=0)
    for key, value in items:
        value = (in2str if isinstance(value, float) else str)(value)
        print(f'... {edit(key, length)} = {value}')

#----- classes -----

class Listdict(dict):
    'dictionary of lists'

    def append(listdict, key, value):
        try:
            listdict[key].append(value)
        except KeyError:
            listdict[key] = []
            listdict[key].append(value)

class Setdict(dict):
    'dictionary of sets'

    def add(setdict, key, value):
        try:
            setdict[key].add(value)
        except KeyError:
            setdict[key] = set()
            setdict[key].add(value)

#----- end -----
