#!/usr/bin/env python # 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.2" 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() 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=[] 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("##"): first_slide_found=True if len(new_page)>0: self.data.append(new_page) new_page=[] # 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]] ) 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.") 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.") 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") 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: 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()