Skip to content

Add paging in sixel supporting terminals #90

@j4james

Description

@j4james

@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 raw

There are some limitation you need to be aware of though.

  1. 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).
  2. 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.
  3. 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

Originally posted by @j4james in #7

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions