File: book.py
   1 #!/usr/bin/python3
   2 
   3 # The MIT License (MIT)
   4 #
   5 # Copyright © 2024 pacman64
   6 #
   7 # Permission is hereby granted, free of charge, to any person obtaining a copy
   8 # of this software and associated documentation files (the “Software”), to deal
   9 # in the Software without restriction, including without limitation the rights
  10 # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  11 # copies of the Software, and to permit persons to whom the Software is
  12 # furnished to do so, subject to the following conditions:
  13 #
  14 # The above copyright notice and this permission notice shall be included in
  15 # all copies or substantial portions of the Software.
  16 #
  17 # THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  18 # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  19 # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  20 # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  21 # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  22 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  23 # SOFTWARE.
  24 
  25 
  26 from sys import argv, exit, stderr, stdin, stdout
  27 from typing import List
  28 
  29 
  30 info = '''
  31 book [page-height] [filepath/URI...]
  32 
  33 Layout lines on 2 page-like side-by-side columns, just like a book.
  34 
  35 Book shows lays out text-lines the same way pairs of pages are laid out in
  36 books, letting you take advantage of wide screens. Every pair of pages ends
  37 with a special dotted line to visually separate it from the next pair.
  38 
  39 The help option is `-h`, `--h`, `-help`, or `--help`.
  40 
  41 If you're using Linux or MacOS, you may find this cmd-line shortcut useful:
  42 
  43 # Like A Book lays lines as pairs of pages, the same way books do it
  44 lab() { book "$(expr $(tput lines) - 1)" "$@" | less -JMKiCRS; }
  45 '''
  46 
  47 # a leading help-option arg means show the help message and quit
  48 if len(argv) == 2 and argv[1] in ('-h', '--h', '-help', '--help'):
  49     print(info.strip(), file=stderr)
  50     exit(0)
  51 
  52 
  53 def seems_url(s: str) -> bool:
  54     protocols = ('https://', 'http://', 'file://', 'ftp://', 'data:')
  55     return any(s.startswith(p) for p in protocols)
  56 
  57 
  58 # spaces is used only in func write_spaces: it's a global so it's only
  59 # allocated once for sure
  60 spaces = ' ' * 64
  61 
  62 
  63 def write_spaces(w, n: int) -> None:
  64     block_len = len(spaces)
  65     while n >= block_len:
  66         w.write(spaces)
  67         n -= block_len
  68     if n > 0:
  69         w.write(spaces[:n])
  70 
  71 
  72 def calc_width(s: str) -> int:
  73     ansi = 0
  74     start = 0
  75     while True:
  76         i = s.find('\x1b[', start)
  77         if i < 0:
  78             break
  79         j = s.find('m', i)
  80         if j < 0:
  81             ansi += len(s[i:])
  82             break
  83         ansi += len(s[i:j])
  84         start = j + 1
  85 
  86     return len(s) - ansi
  87 
  88 
  89 def safe_index(lines: List[str], i: int) -> str:
  90     return lines[i] if i < len(lines) else ''
  91 
  92 
  93 def show_book(w, lines: List[str], page_height: int) -> None:
  94     if page_height < 2:
  95         raise ValueError('page-height must be at least 2')
  96     inner_height = page_height - 1
  97 
  98     face = 0
  99     widths = [0, 0]
 100     start = 0
 101     while start < len(lines):
 102         width = 0
 103         end = min(start + inner_height, len(lines))
 104         for i in range(start, end):
 105             width = max(width, calc_width(lines[i]))
 106 
 107         widths[face] = max(widths[face], width)
 108         start += inner_height
 109         face = 1 - face
 110 
 111     book_fold = ''
 112     book_fold_end = book_fold.rstrip(' ')
 113     bottom_margin = '·' * (sum(widths) + 3)
 114 
 115     start = 0
 116     pages = 0
 117     while start < len(lines):
 118         pages += 1
 119         if pages > 1:
 120             print(bottom_margin, file=w)
 121 
 122         end = min(start + inner_height, len(lines))
 123         for i in range(start, end):
 124             left = safe_index(lines, i)
 125             right = safe_index(lines, i + inner_height)
 126 
 127             print(left, end='', file=w)
 128             write_spaces(w, widths[0] - calc_width(left))
 129             print(book_fold if right else book_fold_end, end='', file=w)
 130             print(right, file=w)
 131 
 132         start += 2 * inner_height
 133 
 134     # bottom-pad last page-pair with empty lines, so page-scrolling
 135     # on viewers like `less` stays in sync with the page boundaries
 136     extras = inner_height - (len(lines) % inner_height)
 137 
 138     for _ in range(extras):
 139         print(file=w)
 140 
 141     # end last page with an empty line, instead of the usual page-separator
 142     if extras > 0:
 143         print(file=w)
 144 
 145 
 146 def fix_line(s: str, tapstop: int = 4) -> str:
 147     return s.expandtabs(tapstop).rstrip('\r\n').rstrip('\n')
 148 
 149 
 150 try:
 151     args = argv[1:]
 152     if len(args) == 0:
 153         msg = 'expected page-height as an integer bigger than 1'
 154         raise ValueError(msg)
 155     try:
 156         page_height = int(args[0])
 157         args = args[1:]
 158     except Exception:
 159         msg = 'expected page-height as an integer bigger than 1'
 160         raise ValueError(msg)
 161 
 162     if args.count('-') > 1:
 163         msg = 'reading from `-` (standard input) more than once not allowed'
 164         raise ValueError(msg)
 165 
 166     if any(seems_url(e) for e in args):
 167         from urllib.request import urlopen
 168 
 169     lines = []
 170 
 171     # handle all named inputs given
 172     for path in args:
 173         if path == '-':
 174             for line in stdin:
 175                 lines.append(fix_line(line))
 176             continue
 177 
 178         if seems_url(path):
 179             with urlopen(path) as inp:
 180                 for line in inp:
 181                     lines.append(fix_line(str(line, encoding='utf-8')))
 182             continue
 183 
 184         with open(path, encoding='utf-8') as inp:
 185             for line in inp:
 186                 lines.append(fix_line(line))
 187 
 188     if len(args) == 0:
 189         for line in stdin:
 190             lines.append(fix_line(line))
 191 
 192     show_book(stdout, lines, page_height)
 193 except BrokenPipeError:
 194     # quit quietly, instead of showing a confusing error message
 195     stderr.close()
 196 except KeyboardInterrupt:
 197     exit(2)
 198 except Exception as e:
 199     print(f'\x1b[31m{e}\x1b[0m', file=stderr)
 200     exit(1)