#!/usr/bin/env python3 # coding=utf-8 # # Copyright 2016 Ville Rantanen # # This program is free software: you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published # by the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . # """Markslider: a slideshow engine based on markdown.""" __author__ = "Ville Rantanen " __version__ = "1.3" import sys, os, argparse, re, datetime from argparse import ArgumentParser import traceback, tty, termios, subprocess, signal sys.path.append(os.path.dirname(os.path.realpath(__file__))) import ansicodes, md_color HL = ">" EOS = "# End of Slides" class getch: def get(self): fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(sys.stdin.fileno()) ch = sys.stdin.read(1) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return ch class EndProgram(Exception): """ Nice exit """ pass class slide_reader: """ Class for reading files. """ def __init__(self, files, opts): self.filename = files[0] self.files = files self.reader = None self.opts = opts self.pages = 0 self.page = 0 self.file_start_page = [] self.width = None self.height = None self.max_width = None self.max_height = None self.data = [] self.re_image_convert = re.compile("(.*)(!\[.*\])\((.*)\)>") self.re_command = re.compile("(.*)`(.*)`>(.*)") # ~ self.control_chars = ''.join(map(unichr, range(0,32) + range(127,160))) # ~ self.control_char_re = re.compile('[%s]' % re.escape(self.control_chars)) self.background = [] self.read() self.pygments = False if opts.syntax_pygments: try: self.pygments = Pygmentizer(opts.syntax_formatter, opts.syntax_style) except ImportError as e: self.pygments = False def read(self): """ Read a file, set pages and data """ self.pages = 0 self.background = [] self.data = [] self.file_start_page = [] first_slide_found = False for fname in self.files: first_slide_found = False f = open(fname, "r") new_page = [] in_code = False for row in f: if not row: continue row = row.rstrip("\n\r ") # find end of show if row == EOS: break # find header to start a new page if row.startswith("#") and not row.startswith("##") and not in_code: first_slide_found = True if len(new_page) > 0: self.data.append(new_page) new_page = [] if row.startswith("```"): in_code = not in_code # if first slide havent been found yet: if not first_slide_found: self.background.append(row) continue new_page.extend(self.generate_content(row)) if len(new_page) > 0: self.data.append(new_page) f.close() self.file_start_page.append(len(self.data)) if len(self.data) == 0: raise ValueError("File does not have a # header") self.rename_duplicates() self.toc() self.pages = len(self.data) self.inc_page_no(0) self.max_width = 0 self.max_height = 0 for page in self.data: self.max_height = max(self.max_height, len(page)) for row in page: self.max_width = max(self.max_width, len(row)) def get_data(self): return self.data def get_current_filename(self): for i, offset in enumerate(self.file_start_page): if offset > self.page: return self.files[i] return "NA" def get_filename(self): return self.filename def get_page(self, page): try: return self.data[page] except IndexError: return None def get_pages(self): return self.pages def get_current_page(self): return self.data[self.page] def get_page_no(self): return self.page def inc_page_no(self, inc=1, loop=False): self.page += inc if self.page < 0: self.page = 0 if loop: self.page = self.pages - 1 if self.page >= self.pages: self.page = self.pages - 1 if loop: self.page = 0 self.width = max([len(x) for x in self.data[self.page]]) self.height = len(self.data[self.page]) def last_page(self): self.page = self.pages - 1 def first_page(self): self.page = 0 def get_page_height(self): return self.height def get_page_width(self): return self.width def get_max_height(self): return self.max_height def get_max_width(self): return self.max_width def get_toc(self, display_position=False): title = self.opts.toc if self.opts.toc else "Table of Contents" TOC = ["# " + title, ""] offset = (self.opts.toc_page - 1) if self.opts.toc else 0 for h1, page in enumerate(self.data[offset:]): title = page[0].strip("# ") if display_position and h1 == self.page - offset: title = "_%s_" % (title,) TOC.append("%d. %s" % (h1 + 1, title)) subh = [0, 0, 0] if self.opts.toc_depth > 1: for line in page: title = line.strip("# ") if re.search("^##[^#]", line): subh = [subh[0] + 1, 0, 0] TOC.append(" %d.%d. %s" % (h1 + 1, subh[0], title)) if self.opts.toc_depth == 2: continue if re.search("^###[^#]", line): subh = [subh[0], subh[1] + 1, 0] TOC.append( " %d.%d.%d. %s" % (h1 + 1, subh[0], subh[1], title) ) if self.opts.toc_depth == 3: continue if re.search("^####[^#]", line): subh = [subh[0], subh[1], subh[2] + 1] TOC.append( " %d.%d.%d.%d. %s" % (h1 + 1, subh[0], subh[1], subh[2], title) ) return TOC def toc(self): if self.opts.toc: TOC = self.get_toc() self.data.insert(self.opts.toc_page - 1, TOC) # adding 1 is not fullproof, if toc page is after the first document self.file_start_page = [1 + i for i in self.file_start_page] def generate_content(self, s): """ Check for launchable items, or converted images """ if self.opts.execute_read: if s.find("`>") > -1: command = self.re_command.match(s) if command != None: return self.launch(command) image = self.re_image_convert.match(s) if image != None: return self.convert_image(image) return [s] def launch(self, command): """Launch in a string using tags `command`> Remove empty lines from beginning and end of stdout. """ output = subprocess.check_output(command.group(2).strip(), shell=True) if type(output) == bytes: output = output.decode("utf-8") output = output.split("\n") while len(output[0].strip()) == 0: if len(output) == 1: return [""] del output[0] while len(output[-1].strip()) == 0: if len(output) == 1: return [""] del output[-1] return_value = [command.group(1)] return_value.extend(output) return_value.append(command.group(3)) return return_value # return [s] def convert_image(self, image): """comnvert image using tags ![]()> Remove empty lines from beginning and end of stdout. """ # ~ 2=title # ~ 3=image command output = subprocess.check_output( "convert %s JPEG:- | jp2a --colors --width=70 -" % (image.group(3),), shell=True, ) if type(output) == bytes: output = output.decode("utf-8") output = output.split("\n") while len(output[0].strip()) == 0: if len(output) == 1: return [""] del output[0] while len(output[-1].strip()) == 0: if len(output) == 1: return [""] del output[-1] return_value = [image.group(1)] return_value.extend(output) # ~ return_value.append(image.group(4)) return return_value # return [s] def rename_duplicates(self): if not opts.rename_title: return titles = {} page_nos = [] for page in self.data: if not page[0] in titles: titles[page[0]] = 0 titles[page[0]] += 1 page_nos.append(titles[page[0]]) for page, page_no in zip(self.data, page_nos): if titles[page[0]] > 1: page[0] += " [%d/%d]" % (page_no, titles[page[0]]) class Pygmentizer: def __init__(self, formatter, style): import pygments import pygments.lexers import pygments.formatters import pygments.styles self.pygments = pygments self.lexers = pygments.lexers self.formatters = pygments.formatters self.styles = pygments.styles self.style = self.styles.get_style_by_name(style) self.formatter = self.formatters.get_formatter_by_name( formatter, style=self.style ) self.lexer = None def format(self, code): in_block = False blocks = [] current_block = -1 for i, row in enumerate(code): if row.startswith("```"): in_block = not in_block lexer_name = row.replace("```", "").strip() if len(lexer_name) == 0: in_block = False if in_block: blocks.append({"lexer": lexer_name, "code": [], "rows": []}) current_block += 1 else: if in_block: blocks[current_block]["code"].append(row) blocks[current_block]["rows"].append(i) preformatted_rows = [] for block in blocks: lexer = self.lexers.get_lexer_by_name(block["lexer"]) tokens = self.pygments.lex("\n".join(block["code"]), lexer) formatted = self.pygments.format(tokens, self.formatter) # ~ print(block["rows"]) # ~ print(formatted.split("\n")) for row, formatted_row in zip(block["rows"], formatted.split("\n")): code[row] = formatted_row preformatted_rows.append(row) return code, preformatted_rows def get_interactive_help_text(): return """ left/right,page up/down,home,end change page c list contents (toc) M modify file with VIM q exit browser r reload the presentation s toggle status bar t toggle timer (reqs. --timer switch) ,/. scroll page up/down move highlight enter execute highlighted line h help""" def setup_options(): """ Create command line options """ usage = ( """ MarkSlider: a markdown based slideshow engine Special syntaxes: * Colors: insert string ${C}, where C is one of KRGBYMCWkrgbymcwSUZ * Text before first "# header" is not shown * Text after a "# End of Slides" is not shown * Execute shell: ` command -switch `! Beware of malicious code! * Execute and print output: ` command `> Beware of malicious code! * Convert images to ASCII, with jp2a: ![](file.jpg)> Keyboard shortcuts: """ + get_interactive_help_text() ) parser = ArgumentParser( description=usage, formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__author__, ) parser.add_argument("-v", "--version", action="version", version=__version__) parser.add_argument( "--export", action="store", dest="screenshots", default=False, type=str, help="Take screenshots of the slideshow in the given folder.", ) content = parser.add_argument_group("content") execution = parser.add_argument_group("execution") control = parser.add_argument_group("controls") content.add_argument( "--background", action="store_true", dest="background", default=False, help="Use the rows before the first # header as slide background", ) content.add_argument( "--center", action="store_true", dest="center", default=False, help="Center slides on screen.", ) content.add_argument( "--dc", action="store_true", dest="dark_colors", default=False, help="Use dark colorscheme, better for white background terminals.", ) content.add_argument( "-m", action="store_false", dest="autocolor", default=True, help="Disable color by markdown structure.", ) content.add_argument( "--no-color", "-n", action="store_false", dest="color", default=True, help="Disable color.", ) content.add_argument( "--no-rename", "--nr", action="store_false", dest="rename_title", default=True, help="Disable automatic renaming of duplicate titles.", ) content.add_argument( "--toc", action="store", dest="toc", default=False, const="Table of Contents", type=str, nargs="?", help="Insert table of contents. Define the header, or use default: %(const)s", ) content.add_argument( "--toc-page", action="store", dest="toc_page", default=2, type=int, help="Insert table of contents on a chosen page. default: %(const)s", ) content.add_argument( "--toc-depth", action="store", dest="toc_depth", default=2, type=int, choices=list(range(1, 5)), help="Table of contents display depth. default: %(const)s", ) content.add_argument( "--no-pygments", action="store_false", dest="syntax_pygments", default=True, help="Disable autocoloring syntax with Pygments. Used only for ``` code blocks, if language is mentioned, ex.: ```python . Note! Do not have empty lines after ```", ) content.add_argument( "--syntax-formatter", action="store", dest="syntax_formatter", default="terminal256", choices=["terminal", "terminal256", "terminal16m"], help="Syntax highlighter formatter. default: %(default)s", ) content.add_argument( "--syntax-style", action="store", dest="syntax_style", default="monokai", help="Syntax highlighter style, see Pygments styles. default: %(default)s", ) execution.add_argument( "-e", action="store_true", dest="execute", default=False, help="Execute commands in `! or `> tags at show time with Enter key. WARNING: Potentially very dangerous to run others' slides with this switch!", ) execution.add_argument( "-E", action="store_true", dest="execute_read", default=False, help="Execute commands in ``> tags at file read time. WARNING: Potentially very dangerous to run others' slides with this switch!", ) control.add_argument( "--exit", action="store_true", dest="exit_last", default=False, help="Exit after last slide.", ) control.add_argument( "-s", action="store_false", dest="menu", default=True, help="Disable status bar.", ) control.add_argument( "--timer", action="store", dest="slideTimer", default=False, type=int, help="Timer for slideshow. If set, starts automatic slide changing.", ) content.add_argument( "-w", action="store_false", dest="wrap", default=True, help="Disable line wrapping. Cuts long lines.", ) parser.add_argument("files", type=str, nargs="+", help="File(s) to show") opts = parser.parse_args() opts.slideShow = not not opts.slideTimer if opts.screenshots: opts.slideShow = True opts.slideTimer = 1 opts.exit_last = True return opts def page_print(reader, opts, offset): """ Print a page """ page = reader.get_current_page() scrsize = opts.size # clear page bc.clear() if opts.center: # Placeholder for 80x25 center alignment align_width = reader.get_max_width() align_x_offset = int(scrsize[1] / 2 - align_width / 2) align_pad = " " * align_x_offset align_y_offset = int(scrsize[0] / 2 - reader.get_max_height() / 2) bc.down_line(align_y_offset) else: align_pad = "" # Print header if opts.dark_colors: coloring = "${b}${U}" else: coloring = "${U}${Y}" print(align_pad + colorify(coloring + page[0], opts) + bc.Z) if opts.background: bc.save() if opts.color: sys.stdout.write( "\n".join([align_pad + bc.color_string(x) for x in reader.background]) ) else: sys.stdout.write( "\n".join([align_pad + bc.nocolor_string(x) for x in reader.background]) ) bc.restore() if sys.version_info < (3, 0): # python2 magic page = [row.decode("utf-8") for row in page] # Print page rows if not opts.wrap: page = [cut_line(row, scrsize[1] - 1) for row in page] parsed = md_color.parse(page) if opts.autocolor: if reader.pygments: # ~ to_pygmentize = [] # ~ pygmented_rows = [] # ~ for token_i in range(len(parsed)): # ~ if parsed[token_i][0] == 'multiline_code': # ~ to_pygmentize.append(parsed[token_i][1]) # ~ pygmented_rows.append(token_i) # ~ if len(to_pygmentize) > 0: preformatted, preformatted_rows = reader.pygments.format( [x[1] for x in parsed] ) for row_i in preformatted_rows: parsed[row_i][0] = "preformatted" parsed[row_i][1] = preformatted[row_i] colored = md_color.colorize(parsed, not opts.color, opts.dark_colors) else: if opts.color: colored = [bc.color_string(row[1]) for row in parsed] else: colored = [bc.nocolor_string(row[1]) for row in parsed] r = 0 for row_i in range(len(page)): if row_i == 0: continue if row_i < offset[0]: continue row = page[row_i] # page[1+offset[0]:]: if opts.wrap: row_lines = int(float(len(row)) / scrsize[1]) else: row_lines = 0 row_lines = 0 colored_row = colored[row_i] # =colorify(row,opts) if offset[1] == r + 1 + offset[0]: colored_row = add_highlight(row, opts) sys.stdout.write(align_pad + colored_row) if r >= scrsize[0] - 2: break r += row_lines + 1 sys.stdout.write("\n") sys.stdout.flush() return def print_menu(reader, opts): bc.posprint( opts.size[0], 0, colorify( "${y}%d${Z}/%d %s|%s" % ( 1 + reader.get_page_no(), reader.get_pages(), os.path.basename(reader.get_current_filename()), "slideshow" if opts.slideShow else "", ), opts, ), ) def print_time(opts): now = datetime.datetime.now() bc.posprint( opts.size[0], opts.size[1] - 5, colorify("%02d:%02d" % (now.hour, now.minute), opts), ) def print_help(reader, opts): """ Create a window with help message """ helptext = get_interactive_help_text().split("\n") maxlen = max([len(x) for x in helptext]) bc.posprint(3, 5, " +" + "-" * maxlen + "+ ") bc.posprint( 4, 5, colorify( " |${U}${Y}" + ("{:^" + str(maxlen) + "}").format("Help") + "${Z}| ", opts ), ) for y, row in enumerate(helptext): bc.posprint(5 + y, 5, (" |{:<" + str(maxlen) + "}| ").format(row)) bc.posprint(6 + y, 5, " +" + "-" * maxlen + "+ ") sys.stdout.write(bc.pos(opts.size[0], opts.size[1])) inkey = getch.get() def print_toc(reader, opts): """ Create a window with TOC """ text = reader.get_toc(display_position=True) title = opts.toc if opts.toc else "Table of Contents" maxlen = max([len(x) for x in text]) bc.posprint(3, 2, " +" + "-" * maxlen + "+ ") parsed = md_color.parse(text) if opts.autocolor: colored = md_color.colorize(parsed, not opts.color, opts.dark_colors) else: if opts.color: colored = [bc.color_string(row[1]) for row in parsed] else: colored = [bc.nocolor_string(row[1]) for row in parsed] for y, row in enumerate(colored): bc.posprint(4 + y, 2, (" |{:<" + str(maxlen) + "}| ").format(" ")) bc.posprint(4 + y, 3, ("|{:<" + str(maxlen) + "}").format(row)) bc.posprint(5 + y, 2, " +" + "-" * maxlen + "+ ") sys.stdout.write(bc.pos(opts.size[0], opts.size[1])) inkey = getch.get() def offset_change(opts, reader, offset, new_offset): """ Change the display position of page """ new_offset = (offset[0] + new_offset[0], offset[1] + new_offset[1]) offsety = min(reader.get_page_height() - 1, new_offset[0]) offseth = min(reader.get_page_height(), new_offset[1]) return [max(0, o) for o in (offsety, offseth)] def timeouthandler(sig, frame): # ~ print(sig,frame) raise IOError("Input timeout") def getkeypress(): try: return ord(getch.get()) except: return False def browser(opts, files): """ Main function for printing """ try: reader = slide_reader(files, opts) except: print("Error in reading the file:") for o in sys.exc_info(): print(o) sys.exit(1) offset = (0, 0) try: while 1: opts.size = get_console_size() page_print(reader, opts, offset) if opts.menu: print_menu(reader, opts) print_time(opts) sys.stdout.write(bc.pos(opts.size[0], opts.size[1])) sys.stdout.flush() if opts.screenshots: take_screenshot(reader, opts) while True: if opts.slideTimer and opts.slideShow: signal.signal(signal.SIGALRM, timeouthandler) signal.alarm(opts.slideTimer) elif opts.menu: signal.signal(signal.SIGALRM, timeouthandler) signal.alarm(15) inkey = getkeypress() signal.alarm(0) if not inkey and not opts.slideShow and opts.menu: # normal operation, just update the time print_time(opts) continue if not inkey and opts.slideShow: # slideshow mode if opts.exit_last: if reader.page + 1 == reader.pages: return reader.inc_page_no(1, True) offset = (0, 0) break # ~ print(inkey) if inkey in [113, 3, 120]: # print('Exited in: ') return if inkey in [67, 54, 32]: # PGDN or space if opts.exit_last: if reader.page + 1 == reader.pages: return reader.inc_page_no(1) offset = (0, 0) if inkey in [68, 53, 127]: reader.inc_page_no(-1) offset = (0, 0) if inkey == 72 or inkey == 49: # HOME reader.first_page() offset = (0, 0) if inkey == 70 or inkey == 52: # END reader.last_page() offset = (0, 0) if inkey == ord("c"): print_toc(reader, opts) if inkey == ord("h"): print_help(reader, opts) if inkey == ord("s"): opts.menu = not opts.menu if inkey == ord("t"): opts.slideShow = not opts.slideShow if inkey == ord("r"): reader.read() offset = offset_change(opts, reader, offset, (0, 0)) if inkey == ord("M"): modify_file(reader, offset) reader.read() offset = offset_change(opts, reader, offset, (0, 0)) if inkey == ord(","): offset = offset_change(opts, reader, offset, (-1, 0)) if inkey == ord("."): offset = offset_change(opts, reader, offset, (1, 0)) if inkey == 65: # up offset = offset_change(opts, reader, offset, (0, -1)) if inkey == 66: # down offset = offset_change(opts, reader, offset, (0, 1)) if inkey == 13: # enter launch(reader, opts, offset) break except IOError: pass except KeyboardInterrupt: sys.exit(0) except EndProgram: pass except: print("Unexpected error:") print(traceback.format_exc()) sys.exit(1) def get_console_size(): rows, columns = os.popen("stty size", "r").read().split() return (int(rows), int(columns)) def colorify(s, opts): """ Add colors to string """ if not opts.color: return bc.nocolor_string(s) c = bc.color_string(s) # +bc.Z return c def cut_line(s, i): """ cut a color tagged string, and remove control chars """ s = s[:i] s = re.sub("\$$", "", re.sub("\$\{$", "", re.sub("\$\{.$", "", s))) return s def add_highlight(s, opts): """ Add cursor position highlight """ if len(s.strip()) == 0: cleaned = HL else: cleaned = bc.nocolor_string(s) tagged = "${Y}" + cleaned + "${Z}" return colorify(tagged, opts) def launch(reader, opts, offset): """Launch in a string using tags $!command$! or $>command$> Remove empty lines from beginning and end of stdout in $> commands. Detects URLS and markdown images ![Alt text](/path/to/img.jpg) """ if not opts.execute: bc.posprint(offset[1] - offset[0] + 1, 0, "Execution not enabled!") inkey = getch.get() return s = reader.get_current_page()[offset[1]] urls = re.findall( "http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+", s, ) images = re.findall("!\[[^]]+\]\([^\)]+\)", s) # sanity check if s.find("`") == -1 and len(urls) == 0 and len(images) == 0: return run_command = re.match("(.*)`(.*)`!(.*)", s) show_command = re.match("(.*)`(.*)`>(.*)", s) if show_command != None: output = subprocess.check_output(show_command.group(2).strip(), shell=True) if type(output) == bytes: output = output.decode("utf-8") output = output.split("\n") while len(output[0].strip()) == 0: if len(output) == 1: return del output[0] while len(output[-1].strip()) == 0: if len(output) == 1: return del output[-1] for y, l in enumerate(output): bc.posprint(y + offset[1] - offset[0] + 2, 0, " " * len(l)) bc.clear_to_end() bc.posprint(y + offset[1] - offset[0] + 2, 0, l) inkey = getch.get() return if run_command != None: subprocess.call(run_command.group(2), shell=True, executable="/bin/bash") inkey = getch.get() return # Open URLS last if len(urls) > 0: # Remove ) at the end of url: [name](link) markdown syntax subprocess.call( "%s '%s' &" % ( get_open_command(), urls[0].rstrip(")"), ), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, ) return if len(images) > 0: image = re.sub(".*\(([^\)]+)\).*", "\\1", images[0]) subprocess.call( "%s '%s' &" % ( get_open_command(), image, ), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, ) return return def modify_file(reader, offset): row = 1 row_restarts = reader.file_start_page for page in range(reader.page): if opts.toc_page == page + 1 and opts.toc: continue row += len(reader.data[page]) if (page + 1) in row_restarts: row = 1 subprocess.call( "vim +%d -c 'exe \"normal! zt\"' -c %d %s" % (row, row + offset[1], reader.get_current_filename()), shell=True, ) def take_screenshot(reader, opts): out_file = os.path.join(opts.screenshots, "slide%03d.png" % (reader.page + 1,)) if not os.path.exists(opts.screenshots): os.mkdir(opts.screenshots) subprocess.call( "sleep 0.5; import -window %s '%s'" % (opts.xwinid, out_file), stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, ) def get_open_command(): if sys.platform.startswith("darwin"): return "open" else: return "xdg-open" def main(): global bc global getch global opts bc = ansicodes.code() getch = getch() opts = setup_options() if opts.screenshots: print("Click the terminal window to read window ID") xwininfo = subprocess.check_output("xwininfo", shell=True) if type(xwininfo) == bytes: xwininfo = xwininfo.decode("utf-8") xwininfo = re.search(r"Window id: (0x[0-9]+)", xwininfo) if xwininfo: opts.xwinid = xwininfo.group(1) else: print("Cannot parse window ID") sys.exit(1) browser(opts, opts.files) print("\n\n") if opts.screenshots: print( "Crop the images for terminal tabs, and PDFize e.g.:\n- mogrify -chop 0x50 %s/*png\n- convert %s/*png %s.pdf" % ( opts.screenshots, opts.screenshots, os.path.basename(opts.screenshots), ) ) if __name__ == "__main__": main()