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)