-
Notifications
You must be signed in to change notification settings - Fork 43
Open
Description
@itsjunetime If you just want to display a full width pdf that is panned vertically, you should be able to do that quite efficiently with sixel on terminals that support the ANSI paging sequences. Here's an example python script that demonstrates the technique using img2sixel to load an image that is scaled to the width of the display:
sixelpan.py
''' This is an example of how you can pan a sixel image that is taller than the terminal window. It requires a terminal with support for the ANSI paging sequences, as well as the DEC rectangular editing extension to copy content between pages. It uses img2sixel to load the initial image. Use the up/down arrow keys to pan over the image and `q` to quit. ''' import math import subprocess import sys class RawInputOutput: def __init__(self): import tty import termios self.fd = sys.stdin.fileno() self.saved_attr = termios.tcgetattr(self.fd) tty.setraw(self.fd) def __del__(self): import termios termios.tcsetattr(self.fd, termios.TCSADRAIN, self.saved_attr) def read(self): return sys.stdin.read(1) def read_until(self, final_char): s = '' while not s or s[-1] != final_char: s += self.read() return s def write(self, s): sys.stdout.write(s) sys.stdout.flush() class ImageBuffer: def __init__(self, filename, screen_size, cell_size): cell_height,cell_width = cell_size screen_height,screen_width = screen_size # We want to scale the image up to fill the width of the screen except # for two columns that are used by the viewer's frame. width = (screen_width - 2) * cell_width result = subprocess.run(['img2sixel', '-w', str(width), filename], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) output = result.stdout error = result.stderr if error: print(error) quit() # We split the returned sixel into a header (containing the sequence # introducer and palette definition), body (containing the actual image # content), and footer (the sequence terminator). The body is also then # split into an array of sixel rows. start = output.rfind(';') + 1 while output[start] in '0123456789': start += 1 end = output.rfind('\033') self.head = output[:start] self.body = output[start:end].split('-') self.foot = output[end:] self.sixel_height = len(self.body) self.height = self.sixel_height*6 def slice(self, start, end): return self.head + '-'.join(self.body[start:end]) + self.foot class ImageViewer: def __init__(self, image, screen_size, cell_size, max_pages): self.screen_height,self.screen_width = screen_size cell_height,cell_width = cell_size self.draw_frame() # This is the smallest vertical span that is an exact multiple of the # the row height and also an exact multiple of sixels, so we can split # the image on multiples of this size, and each split is guaranteed to # be on a text row boundary. segment_size = math.lcm(cell_height,6) rows_per_segment = segment_size // cell_height sixels_per_segment = segment_size // 6 segments_per_page = self.screen_height // rows_per_segment sixels_per_page = segments_per_page * sixels_per_segment # Here we're splitting the image along segment boundaries that will fit # on a page, and then loading each chunk into a background page. We start # with page 2 since page 1 is the foreground page. page = 2 for i in range(0, image.sixel_height, sixels_per_page): self.load_image_slice(image, page, i, sixels_per_page) page += 1 if page > max_pages: break self.rows_per_page = segments_per_page * rows_per_segment self.max_offset = ((image.height + cell_height - 1) // cell_height) - (self.screen_height - 2) self.max_pages = max_pages def draw_frame(self): # This is just a little border around the edge of the page to show # that we aren't just scrolling a full screen image. raw.write('\033[H') raw.write('\033[2J') raw.write('\033(0') raw.write('l' + 'q'*(self.screen_width - 2) + 'k\r\n') for i in range(self.screen_height - 2): raw.write('x\033[%dCx\r\n' % (self.screen_width - 2)) raw.write('m' + 'q'*(self.screen_width - 2) + 'j') raw.write('\033(B') def load_image_slice(self, image, page, first_sixel, sixel_count): # This loads a portion of the sixel image onto the specified page. # The synchronized updates shouldn't be necessary, but might help # avoid flickering on terminals that don't support DECPCCM. seq = '\033[?80h' # Enable sixel display mode to prevent scrolling seq += '\033[?2026h' # Begin synchronized update seq += '\033[%d P' % page # Move to the target page seq += '\033[H' # Home the cursor seq += '\033[J' # Clear the screen seq += image.slice(first_sixel, first_sixel+sixel_count) # Load image content seq += '\033[1 P' # Move back to page 1 seq += '\033[?2026l' # End synchronized update seq += '\033[?80l' # Restore sixel scrolling raw.write(seq) def render_image(self, pan_offset): # The area of the image that we're viewing will typically span two # pages, so we need to copy it to the foreground page in two parts. source_page = pan_offset // self.rows_per_page + 2 source_row = pan_offset % self.rows_per_page + 1 sequence,copied_rows = self.copy(source_page, source_row, 2, 2) sequence += self.copy(source_page+1, 1, 2+copied_rows, 2)[0] raw.write(sequence) def copy(self, source_page, source_row, target_row, target_col): # We're using a DECCRA (copy rectangular area) control to copy a slice # of the image from one of the background pages onto the foreground page. free_space = (self.screen_height - 2) - (target_row - 2) width = self.screen_width - 2 height = min(self.rows_per_page - source_row + 1, free_space) top = source_row left = 1 bottom = source_row + height - 1 right = width # If the page is out of range, we just fill the target area. We could # potentially load more segments on demand, but that's more complicated. if source_page > self.max_pages: sequence = '\033[32;%d;%d;%d;%d$x' % (target_row,target_col,target_row+height-1,target_col+width-1) else: sequence = '\033[%d;%d;%d;%d;%d;%d;%d;%d$v' % (top,left,bottom,right,source_page,target_row,target_col,1) copied_rows = bottom - top + 1 if copied_rows < 1: sequence = '' return (sequence, copied_rows) if len(sys.argv) < 2: print('Usage: python %s <image filename>' % sys.argv[0]) quit() raw = RawInputOutput() try: def parse_response(s, count): # Strip CSI and final, split parms, grab last n values. return tuple(int(n) for n in (s[2:-1].split(';')[-count:])) # Query the window dimensions raw.write('\033[18t') screen_size = parse_response(raw.read_until('t'), 2) # Query the cell pixel dimensions raw.write('\033[16t') cell_size = parse_response(raw.read_until('t'), 2) # Disable page cursor coupling raw.write('\033[?64l') # Detect how many pages are accessible raw.write('\033[100 P\033[?6n\033[1 P') max_pages = parse_response(raw.read_until('R'), 1)[0] if max_pages < 3: print('This requires a terminal with paging support\r') quit() # Hide cursor raw.write('\033[?25l') image = ImageBuffer(sys.argv[1], screen_size, cell_size) viewer = ImageViewer(image, screen_size, cell_size, max_pages) pan_offset = 0 done = False while not done: viewer.render_image(pan_offset) while True: ch = raw.read() if ch == 'q' or ch == chr(3): done = True break elif ch == 'A' and pan_offset > 0: pan_offset -= 1 break elif ch == 'B' and pan_offset < viewer.max_offset: pan_offset += 1 break # Clear the screen and restore cursor visibility raw.write('\033[H\033[J') raw.write('\033[?25h') finally: del rawThere are some limitation you need to be aware of though.
- The paging controls don't generally interoperate with the xterm alt buffer mode, so you need to be in the main buffer for this to work (that might depend on the terminal though).
- The height of image that can be handled is limited by the number of pages that the terminal supports. If you're just rendering something like A4 content this likely won't be an issue, but otherwise you might need a more complicated implementation that swaps in additional content as it's required.
- I don't think there are many terminals that support paging in addition to sixel, but I have confirmed that this example does at least work on MLTerm and Windows Terminal.
Demo video on Windows Terminal
sixelpan.mp4
Metadata
Metadata
Assignees
Labels
No labels