Rogue Class Linux Download Login

Artifact Content

Artifact a11ac4b167a7879dfafcc68d90da3e848e2fb524:


#!/usr/bin/tclsh
#
# djvoo.tcl is a TCL/Dagar DJVU reader loosely based on the following Tk
# prototype
# http://code.root.sx/djvu2x/finfo?name=src/obsolete/prototype.tcl
# It depends on ddialog, dagar, dimage, libagar, SDL_image, TclOO,
# djvulibre, and NetPBM.

package require Tcl 8.5
package require TclOO
package require Tclx
package require dagar
package require dimage
package require framebuffer
package require oo::util
package require rclutil
package require rig

namespace import ::dagar::*
namespace import ::dimage::*

proc ::clip {value min max} {
    if {$value < $min} {
        set retval $min
    } elseif {$value > $max} {
        set retval $max
    } else {
        set retval $value
    }
    return $retval
}
 
proc ::log {msg} {
    set fh [open "~/djvoo.log" "a"]
    puts $fh $msg
    close $fh
}

# controllerClass
# ===============
# Responsible for controlling the whole application.
# Creates the other objects.
# Opens the .djvu file in ::reader
# Responds to input from ::display
# Changes page, scroll, and zoom in ::view

oo::class create controllerClass {
    variable _args
    variable _history
    variable _lastsearch
    variable _path
    variable _tty

    constructor {path args} {
        set opt {}
        catch {set opt [dict create {*}$args]}
        set _args [dictFind $opt -args {}]
        set _history [rigClass new "~/settings/history.cfg" true]
        set _lastsearch ""
        set _path $path
        set _tty [exec tty]
    }

    destructor {
        $::display destroy
        $::reader destroy
    }

    method doEventCopyPageText {} {
        set pageno [$::view getPageno]
        $::display copyText [$::reader getPageText $pageno]
        $::view viewPageSegment $pageno
        return
    }

    method doEventGoToPage {} {
        set str [$::display getPromptText]
        $::display closePrompt
        if {[string is integer $str]} {
            $::view viewPageSegment $str
        }
        return
    }

    method doEventInputLeft {data} {
        $::view scrollHorizontal $data
        return
    }

    method doEventInputManual {} {
        set pageno [$::view getPageno]
        set x [$::view getX]
        set y [$::view getY]
        set zoom [$::view getZoom]
        set cmd "djvoo \"$_path\" -page $pageno -x $x -y $y -zoom $zoom"
        $_history push $_tty $cmd
        $_history push $_tty "~/menu/view-man-page.sh djvoo 1"
        my doEventQuit
        return
    }

    method doEventInputNext {data} {
        set result [$::view scrollVertical $data]
        if {!$result} {
            $::view viewPageSegment next
        }
        return
    }

    method doEventInputPrev {data} {
        set result [$::view scrollVertical $data]
        if {!$result} {
            $::view viewPageSegment prev
        }
        return
    }

    method doEventInputRight {data} {
        $::view scrollHorizontal $data
        return
    }

    method doEventInputToggleInverted {} {
        $::reader setIsInverted [expr {![$::reader getIsInverted]}]
        $::view viewPageSegment [$::view getPageno]
        return
    }

    method doEventInputZoomIn {} {
        $::view zoomIn
        return
    }

    method doEventInputZoomOut {} {
        $::view zoomOut
        return
    }

    method doEventPromptForPage {} {
        set pageno [$::view getPageno]
        $::display openPrompt "Page number: " $pageno {
            $::app doEventGoToPage
        }
        return
    }

    method doEventPromptForSearch {} {
        $::display openPrompt "Search for text: " $_lastsearch {
            $::app doEventSearch
        }
        return
    }

    method doEventSayPageText {} {
        set pageno [$::view getPageno]
        set text [$::reader getPageText $pageno]
        $::display say $text
        $::view viewPageSegment $pageno
        return
    }

    method doEventSearch {} {
        set pagecount [$::reader getPageCount]
        set pageno [$::view getPageno]
        set str [$::display getPromptText]
        $::display closePrompt

        set _lastsearch $str

        set pattern "*$str*"
        set found false
        set i $pageno

        # search from the next page to the end of the document
        for {incr i} {$i <= $pagecount} {incr i} {
            set pagetext [$::reader getPageText $i]
            if {[string match -nocase $pattern $pagetext]} {
                set found true
                break
            }
        }

        # if not found, search from page 1 to the current page
        if {!$found} {
            for {set i 1} {$i <= $pageno} {incr i} {
                set pagetext [$::reader getPageText $i]
                if {[string match -nocase $pattern $pagetext]} {
                    set found true
                    break
                }
            }
        }

        if {$found} {
            $::view viewPageSegment $i
            $::display notify "Found \"$str\" on page $i" 5000
        } else {
            $::display notify "Could not find \"$str\"" 5000
        }
        return
    }

    method doEventSilence {} {
        $::display silence
        return
    }

    method doEventStart {} {
        set ::display [displayClass new]
        set height [$::display getHeight]
        set width [$::display getWidth]

        set ::reader [readerClass new $_path]
        set pagecount [$::reader getPageCount]

        set page [dictFind $_args -page 1]
        set x [dictFind $_args -x 0]
        set y [dictFind $_args -y 0]
        set zoom [dictFind $_args -zoom 1]
        set ::view [viewClass new $width $height $pagecount $x $y $zoom]
        $::view viewPageSegment $page
        $::display doEventLoop
        return
    }

    method doEventOutput {} {
        $::display pageOutput
        return
    }

    method doEventQuit {} {
        my destroy
        exit
    }
}

# displayClass
# ============
# Responsible for handling the Agar display.
# Handles input and passes events to ::app.
# Displays a page segment obtained from ::view
# Displays an arbitrary new screen image, and deletes the previous one.

oo::class create displayClass {
    variable _busy
    variable _flite
    variable _height
    variable _menu
    variable _notifyid
    variable _notifywin
    variable _pixmap
    variable _promptinput
    variable _promptwin
    variable _saypid
    variable _scrollbar
    variable _scrollbarobj
    variable _scrollhorizontal
    variable _scrollvertical
    variable _surface
    variable _width
    variable _win

    constructor {} {
        set _busy undefined
        set _flite undefined
        set _height undefined
        set _menu undefined
        set _notifyid 0
        set _notifywin undefined
        set _pixmap undefined
        set _promptinput undefined
        set _promptwin undefined
        set _saypid undefined
        set _scrollbar undefined
        set _scrollbarobj undefined
        set _scrollhorizontal undefined
        set _scrollvertical undefined
        set _surface undefined
        set _width undefined
        set _win undefined

        # switch to graphics mode and create new window
        set ::env(VJKAPP) "djvoo"
        set result [AG_InitCore "djvoo" 0]
        if {$result == -1} {
            error [AG_GetError]
        }
        if {[package present framebuffer]} {
            set framebuffer [framebufferClass new]
            set mode [$framebuffer getMode]
        } else {
            set mode "<SDL>"
        }
        set result [AG_InitGraphics $mode]
        if {$result == -1} {
            error [AG_GetError]
        }

        # disable agTextComposition because it breaks the ^ key
        set ::dagar::agTextComposition 0

        AG_AddTclSpinner

        set fontcfg [rigClass new "~/settings/font.cfg" false]
        set fontface [$fontcfg get font]
        set fontsize [$fontcfg get size]
        set font [AG_FetchFont $fontface $fontsize -1]
        if {$font ne "NULL"} {
            AG_SetDefaultFont $font
        }

        lassign [AG_GetDisplayGeometry] _width _height
        set _win [AG_WindowNew $::dagar::AG_WINDOW_PLAIN]

        # create the top menu
        set _menu [AG_MenuNew $_win $::dagar::AG_MENU_HFILL]
        set root [$_menu cget -root]
        set node [AG_MenuNode $root "File" NULL]
        my addMenu $node "Quit"       "Esc"    {$::app doEventQuit}
        set node [AG_MenuNode $root "Edit" NULL]
        my addMenu $node "Copy page"  "Ctrl-C" {$::app doEventCopyPageText}
        my addMenu $node "Say page"   "F7"     {$::app doEventSayPageText}
        my addMenu $node "Silence"    "F8"     {$::app doEventSilence}
        set node [AG_MenuNode $root "View" NULL]
        my addMenu $node "Zoom in"    "+"      {$::app doEventInputZoomIn}
        my addMenu $node "Zoom out"   "-"      {$::app doEventInputZoomOut}
        my addMenu $node "Invert"     ""       {
            $::app doEventInputToggleInverted
        }
        set node [AG_MenuNode $root "Search" NULL]
        my addMenu $node "Find"       "Ctrl-F" {$::app doEventPromptForSearch}
        my addMenu $node "Go to page" "Ctrl-J" {$::app doEventPromptForPage}
        set node [AG_MenuNode $root "Help" NULL]
        my addMenu $node "Manual"     "F1"     {$::app doEventInputManual}

        # quit djvoo when the ESC key is pressed
        proc ::dagar::quit {}            {AG_QuitGUI}
        AG_BindGlobalKeyQuit $::dagar::AG_KEY_ESCAPE $::dagar::AG_KEYMOD_ANY

        # determine the geometry of the view window
        set scrollbarwidth 25
        incr _height -[$_menu cget -itemh]
        incr _height -8
        incr _width -$scrollbarwidth

        # create horizonal box for the view window and scrollbar
        set hbox [AG_BoxNew $_win $::dagar::AG_BOX_HORIZ $::dagar::AG_BOX_HFILL]
        AG_BoxSetPadding $hbox 0
        AG_BoxSetSpacing $hbox 0

        # set up the view window and associated surface
        set _surface [AG_SurfaceStdRGB $_width $_height]
        set _pixmap [AG_PixmapFromSurface $hbox 0 $_surface]

        # set up the scrollbar in all its detail
        set _scrollhorizontal [expr {$_width - 10}]
        set _scrollvertical [expr {$_height - 10}]
        set _scrollbar [AG_ScrollbarNew $hbox $::dagar::AG_SCROLLBAR_VERT \
            $::dagar::AG_SCROLLBAR_VFILL]
        AG_ScrollbarSetWidth $_scrollbar $scrollbarwidth

        # quick reference to the scrollbar object
        # used by the setScrollbarFoo methods
        set _scrollbarobj [[$_scrollbar cget -wid] cget -obj]

        # initialize scrollbar minimum and maximum to sane values
        my setScrollbarMax 100
        my setScrollbarMin 100
        my setScrollbarValue 100

        # bind scrollbar down button
        AG_ScrollbarSetDecHandler $_scrollbar
        set name [AG_GetCallbackName $_scrollbarobj "Dec"]
        proc $name {} {
            set pressed [AG_EventGetInt 2]
            if {!$pressed} {
                $::app doEventInputPrev -[$::display getScrollVertical]
            }
            return
        }

        # bind scrollbar up button
        AG_ScrollbarSetIncHandler $_scrollbar
        set name [AG_GetCallbackName $_scrollbarobj "Inc"]
        proc $name {} {
            set pressed [AG_EventGetInt 2]
            if {!$pressed} {
                $::app doEventInputNext [$::display getScrollVertical]
            }
            return
        }

        # bind scrollbar drag
        AG_AddEventHandler $_scrollbarobj "scrollbar-drag-end"
        set name [AG_GetCallbackName $_scrollbarobj "scrollbar-drag-end"]
        proc $name {} {
            set oldpage [$::view getPageSub]
            set newpage [$::display getScrollbarValue]
            if {$newpage != $oldpage} {
                $::view viewPageSub $newpage
            }
            return
        }

        # prevent the scrollbar widget from stealing keyboard focus
        AG_WidgetSetFocusable $_win 1
        AG_WidgetSetFocusable $_scrollbar 0

        # bind main window keyboard event
        set _busy false
        set obj [[$_win cget -wid] cget -obj]
        set name [AG_GetCallbackName $obj "key-down"]
        proc $name {} {
            $::display doEventKeyDown
        }
        AG_AddEventHandler $obj "key-down"

        set _flite [exec say -c]
        set _saypid {}
        return
    }

    destructor {
        my silence
        AG_DelTclSpinner
        AG_DestroyGraphics
        AG_Destroy
    }

    method addMenu {parent text shortcut body} {
        set label [format "%-30s%s" $text $shortcut]
        set retval [AG_MenuActionHandler $parent $label NULL]
        set obj [[[$parent cget -pmenu] cget -wid] cget -obj]
        set name [AG_GetCallbackName $obj $label]
        proc $name {} $body
        return $retval
    }

    method alert {msg} {
        AG_TextMsgS $::dagar::AG_MSG_INFO $msg
        return
    }

    method closePrompt {} {
        AG_WindowHide $_promptwin
        AG_WindowDetach NULL $_promptwin
        return
    }

    method copyText {text} {
        set fh [open "/tmp/clipboard" "w"]
        puts $fh $text
        close $fh
        return
    }

    method doEventKeyDown {} {
        if {$_busy} {
            return
        }
        set _busy true

        set key [AG_EventGetIntNamed "key"]
        set mod [AG_EventGetIntNamed "mod"]
        set ctrl [expr {$mod & (
            $::dagar::AG_KEYMOD_LCTRL |
            $::dagar::AG_KEYMOD_RCTRL
        )}]
        set shift [expr {$mod & $::dagar::AG_KEYMOD_SHIFT}]

        if {$key == $::dagar::AG_KEY_C} {
            if {$ctrl} {
                $::app doEventCopyPageText
            }
        } elseif {$key == $::dagar::AG_KEY_DOWN} {
            if {$shift} {
                $::app doEventInputNext 10
            } else {
                $::app doEventInputNext $_scrollvertical
            }
        } elseif {$key == $::dagar::AG_KEY_EQUALS} {
            if {$shift} {
                $::app doEventInputZoomIn
            }
        } elseif {$key == $::dagar::AG_KEY_ESCAPE} {
            $::app doEventQuit
        } elseif {$key == $::dagar::AG_KEY_F} {
            if {$ctrl} {
                $::app doEventPromptForSearch
            }
        } elseif {$key == $::dagar::AG_KEY_F1} {
            $::app doEventInputManual
        } elseif {$key == $::dagar::AG_KEY_F7} {
            $::app doEventSayPageText
        } elseif {$key == $::dagar::AG_KEY_F8} {
            $::app doEventSilence
        } elseif {$key == $::dagar::AG_KEY_G} {
            if {$ctrl} {
                $::app doEventPromptForPage
            }
        } elseif {$key == $::dagar::AG_KEY_J} {
            if {$ctrl} {
                $::app doEventPromptForPage
            }
        } elseif {$key == $::dagar::AG_KEY_KP_MINUS ||
            $key == $::dagar::AG_KEY_MINUS
        } {
            $::app doEventInputZoomOut
        } elseif {$key == $::dagar::AG_KEY_KP_PLUS ||
            $key == $::dagar::AG_KEY_PLUS
        } {
            $::app doEventInputZoomIn
        } elseif {$key == $::dagar::AG_KEY_LEFT} {
            if {$shift} {
                $::app doEventInputLeft -10
            } else {
                $::app doEventInputLeft -$_scrollhorizontal
            }
        } elseif {$key == $::dagar::AG_KEY_PAGEDOWN} {
            $::app doEventInputNext $_scrollvertical
        } elseif {$key == $::dagar::AG_KEY_PAGEUP} {
            $::app doEventInputPrev -$_scrollvertical
        } elseif {$key == $::dagar::AG_KEY_Q} {
            $::app doEventQuit
        } elseif {$key == $::dagar::AG_KEY_RIGHT} {
            if {$shift} {
                $::app doEventInputRight 10
            } else {
                $::app doEventInputRight $_scrollhorizontal
            }
        } elseif {$key == $::dagar::AG_KEY_SLASH} {
            $::app doEventPromptForSearch
        } elseif {$key == $::dagar::AG_KEY_UP} {
            if {$shift} {
                $::app doEventInputPrev -10
            } else {
                $::app doEventInputPrev -$_scrollvertical
            }
        }

        set _busy false
        return
    }

    method doEventLoop {} {
        AG_WindowMaximize $_win
        AG_WindowShow $_win
        AG_EventLoop
        return
    }

    method getHeight {} {
        return $_height
    }

    method getPromptText {} {
        set ed [$_promptinput cget -ed]
        set retval [AG_EditableGetString $ed]
        return $retval
    }

    method getScrollHorizontal {} {
        return $_scrollhorizontal
    }

    method getScrollVertical {} {
        return $_scrollvertical
    }

    method getScrollbarValue {} {
        set retval [AG_GetSint32 $_scrollbarobj "value"]
        return $retval
    }

    method getWidth {} {
        return $_width
    }

    method notify {msg {delay 1000}} {
        if {$_notifywin ne "undefined"} {
            my notifyDismiss
        }
        incr _notifyid
        set flags [expr {
            $::dagar::AG_WINDOW_MODAL      |
            $::dagar::AG_WINDOW_NORESIZE   |
            $::dagar::AG_WINDOW_NOCLOSE    |
            $::dagar::AG_WINDOW_NOMINIMIZE |
            $::dagar::AG_WINDOW_NOMAXIMIZE
        }]
        set _notifywin [AG_WindowNew $flags]
        $_notifywin configure -wmType $::dagar::AG_WINDOW_WM_NOTIFICATION
        AG_WindowSetCaptionS $_notifywin "Notification"
        AG_WindowSetPosition $_notifywin $::dagar::AG_WINDOW_MC 1
        set flags [expr {
            $::dagar::AG_LABEL_EXPAND |
            $::dagar::AG_LABEL_FRAME
        }]
        set lbl [AG_LabelNewS $_notifywin $flags $msg]
        AG_WindowShow $_notifywin
        AG_WidgetFocus $_win
        after $delay [mymethod notifyDismiss $_notifyid]
        return
    }

    method notifyDismiss {{id -1}} {
        if {$_notifywin eq "undefined"} {
            return
        }
        if {$id > -1 && $id != $_notifyid} {
            return
        }
        AG_WindowHide $_notifywin
        AG_ObjectDetach $_notifywin
        AG_WidgetFocus $_win
        set _notifywin "undefined"
        return
    }

    method openPrompt {prompt value body} {
        # create new modal dialog window for prompt
        set flags [expr {
            $::dagar::AG_WINDOW_MODAL |
            $::dagar::AG_WINDOW_NOTITLE
        }]
        set _promptwin [AG_WindowNew $flags]
        $_promptwin configure -wmType $::dagar::AG_WINDOW_WM_DIALOG
        AG_WindowSetPosition $_promptwin $::dagar::AG_WINDOW_MC 0
        AG_WindowSetSpacing $_promptwin 8

        # create top box and label
        set box [AG_BoxNew $_promptwin $::dagar::AG_BOX_VERT \
            $::dagar::AG_BOX_HFILL]
        AG_LabelNewS $box 0 $prompt

        # create middle box and input
        set box [AG_BoxNew $_promptwin $::dagar::AG_BOX_VERT \
            $::dagar::AG_BOX_HFILL]
        set flags [expr {
            $::dagar::AG_TEXTBOX_EXCL    |
            $::dagar::AG_TEXTBOX_NOPOPUP
        }]
        set _promptinput [AG_TextboxNewS $box $flags ""]
        AG_ExpandHoriz $_promptinput

        # set input to initial value
        set ed [$_promptinput cget -ed]
        AG_EditableSetString $ed $value

        # bind return key to callback script
        set widget [$ed cget -wid]
        set obj [$widget cget -obj]
        set name [AG_GetCallbackName $obj "editable-return"]
        AG_AddEventHandler $obj "editable-return"
        proc $name {} $body

        # create bottom box and OK button
        set flags [expr {$::dagar::AG_BOX_HFILL | $::dagar::AG_BOX_HOMOGENOUS}]
        set box [AG_BoxNew $_promptwin $::dagar::AG_BOX_HORIZ $flags]
        set ok [AG_ButtonNewS $box 0 "Ok"]

        # bind OK button to callback script
        set obj [[$ok cget -wid] cget -obj]
        set name [AG_GetCallbackName $obj "button-pushed"]
        AG_AddEventHandler $obj "button-pushed"
        proc $name {} $body

        # focus on the input and show the prompt modal dialog window
        AG_WidgetFocus $widget
        AG_WindowShow $_promptwin
        return
    }

    method output {surface} {
        set widget [$_pixmap cget -wid]

        set rect [AG_RECT 0 0 $_width $_height]
        set sH [$surface cget -h]
        set sW [$surface cget -w]
        set cx 0
        set cy 0

        # make adjustments if the page segment is smaller than the display
        if {$sW < $_width || $sH < $_height} {
            # clear the display
            AG_FillRect $_surface $rect [AG_ColorHex 0]

            # vertically center segment if it is short
            if {$sH < $_height} {
                set cy [expr {$_height / 2 - $sH / 2}]
            }

            # horizontally center segment if it is narrow
            if {$sW < $_width} {
                set cx [expr {$_width / 2 - $sW / 2}]
            }
        }

        # display the page segment and update the window
        AG_SurfaceBlit $surface NULL $_surface $cx $cy
        AG_Redraw $widget
        return
    }

    method pageOutput {} {
        # refresh the scroll bar whenever the page is drawn
        my setScrollbarValue [$::view getPageSub]
        my setScrollbarMax [$::view getPageSubMax]

        # load the page data to an SDL surface
        set data [$::view getPageData]
        set rwops [SDL_RWFromBytearray $data]
        set sdlsurface [IMG_LoadPNM_RW $rwops]
        if {$sdlsurface eq "NULL"} {
            error [IMG_GetErrorMsg]
        }

        # convert the SDL surface to an Agar surface
        set agarsurface [AG_SurfaceFromSDL $sdlsurface]
        SDL_FreeSurface $sdlsurface
        SDL_FreeRWops $rwops

        # display the page segment
        my output $agarsurface
        AG_SurfaceFree $agarsurface
        AG_WidgetFocus $_win
        return
    }

    method say {text} {
        foreach {pid} $_saypid {
            set result [catch {exec ps -p $pid} output]
            if {$result == 0} {
                return
            }
        }

        set text [string trim $text]
        if {[string length $text] > 256} {
            exec {*}$_flite -t "working..."
        }
        set _saypid [exec {*}$_flite -t $text &]
        return
    }

    method setScrollbarMax {value} {
        AG_SetSint32 $_scrollbarobj "max" $value
        return
    }

    method setScrollbarMin {value} {
        AG_SetSint32 $_scrollbarobj "min" $value
        return
    }

    method setScrollbarValue {value} {
        AG_SetSint32 $_scrollbarobj "value" $value
        return
    }

    method silence {} {
        catch {kill $_saypid}
        return
    }
}

# readerClass
# ===========
# Responsible for reading information from .djvu files.
# Uses ddjvu and djvudump command line utilities.
# Reads page count, dimensions, and DPI.
# Reads page segment images.

oo::class create readerClass {
    variable _ddjvu
    variable _djvudump
    variable _djvutxt
    variable _isinverted
    variable _pagecount
    variable _pagedata
    variable _pagesizes
    variable _path
    variable _pnminvert

    constructor {path} {
        set _ddjvu undefined
        set _djvudump undefined
        set _djvutxt undefined
        set _isinverted undefined
        set _pagecount undefined
        set _pagedata undefined
        set _pagesizes undefined
        set _path undefined
        set _pnminvert undefined

        set _ddjvu "ddjvu"
        set _djvudump "djvudump"
        set _djvutxt "djvutxt"
        set _isinverted false
        set _path $path
        set _pnminvert "pnminvert"
        if {![file exists $path]} {
            error "Could not find file \"$path\""
        }
        my getPageMeta
        return
    }

    method getIsInverted {} {
        return $_isinverted
    }

    method getPageCount {} {
        return $_pagecount
    }

    method getPageData {} {
        return $_pagedata
    }

    method getPageMeta {} {
        set _pagesizes [list [list 0 0 0]]
        set pageCount 0
        set pageCountPattern  {^    DIRM.* (\d+) pages}
        set pageSizePattern  {^\s+INFO.*DjVu (\d+)x(\d+),.* (\d+) dpi}
        set cmd "$_djvudump \"$_path\""
        set fh [open "|$cmd" "r"]
        while {![eof $fh]} {
            set line [gets $fh]
            if {[regexp $pageCountPattern $line -> pageCount]} {
                set _pagecount $pageCount
            }
            if {[regexp $pageSizePattern $line -> pageWidth pageHeight pageDPI]
            } {
                lappend _pagesizes [list $pageWidth $pageHeight $pageDPI]
            }
        }
        close $fh
        return
    }

    method getPageSize {pageno} {
        set pageData [lindex $_pagesizes $pageno]
        return $pageData
    }

    method getPageText {pageno} {
        set cmd "$_djvutxt -page=$pageno \"$_path\""
        set fh [open "|$cmd" "r"]
        fconfigure $fh -translation binary
        set retval [read $fh]
        close $fh
        return $retval
    }

    # ddjvu has routines to quickly decode part of a page
    # Also, AG_SurfaceBlit has multiple bugs when blitting a sub-section
    # of a surface.  Using ddjvu to extract part of a page works around this.

    method loadPageSegment {pageno width height x y zoom zoomedPageWidth
        zoomedPageHeight
    } {
        # determine source rectangle
        set bottom [expr {$height + $y}]
        if {$bottom > $zoomedPageHeight} {
            set adjustedHeight $zoomedPageHeight
            set offset 0
        } else {
            set adjustedHeight $height
            set offset [expr {max(0, $zoomedPageHeight - $adjustedHeight - $y)}]
        }
        set right [expr {$width + $x}]
        if {$right > $zoomedPageWidth} {
            set adjustedWidth $zoomedPageWidth
        } else {
            set adjustedWidth $width
        }
        set segment [format "%dx%d+%d+%d" $adjustedWidth $adjustedHeight \
            $x $offset]

        set cmd "$_ddjvu -format=ppm -page=$pageno -segment=$segment -subsample=$zoom \"$_path\""

        # invert colors if required
        if {$_isinverted} {
            append cmd " | $_pnminvert"
        }

        # decode and load the page segment
        set fh [open "|$cmd" "r"]
        fconfigure $fh -translation binary
        set _pagedata [read $fh]
        close $fh

        return $_pagedata
    }

    method setIsInverted {value} {
        set _isinverted $value
        return
    }
}

# viewClass
# =========
# Responsible for tracking the page number and view window.
# Displays changed view using ::app
# Gets page count and dimensions from ::reader
# Loads page segment images using ::reader
# Changes the view page, scroll, and zoom.
# Crops arbitrary page numbers to fit within the current document.
# Crops arbitrary coordinates to fit within the current page.

oo::class create viewClass {
    variable _pagecount
    variable _pageno
    variable _viewheight
    variable _viewwidth
    variable _x
    variable _y
    variable _zoom
    variable _zoomedpageheight
    variable _zoomedpagewidth

    constructor {viewwidth viewheight pagecount x y zoom} {
        set _pagecount undefined
        set _pageno undefined
        set _viewheight undefined
        set _viewwidth undefined
        set _x undefined
        set _y undefined
        set _zoom undefined
        set _zoomedpageheight undefined
        set _zoomedpagewidth undefined

        set _pagecount $pagecount
        set _viewheight $viewheight
        set _viewwidth $viewwidth
        set _x $x
        set _y $y
        set _zoom $zoom
    }

    method cropPageNo {pageno} {
        return [::clip $pageno 1 $_pagecount]
    }

    method cropX {x} {
        return [::clip $x 0 [expr {max(0, $_zoomedpagewidth - $_viewwidth)}]]
    }

    method cropY {y} {
        return [::clip $y 0 [expr {max(0, $_zoomedpageheight - $_viewheight)}]]
    }

    method cropZoom {zoom} {
        return [::clip $zoom 1 12]
    }

    method getPageCount {} {
        return $_pagecount
    }

    method getPageData {} {
        return [$::reader getPageData]
    }

    method getPageno {} {
        return $_pageno
    }

    method getPageSub {} {
        return [expr {int(100 * (
            $_pageno + 1.0 * $_y / $_zoomedpageheight
        ))}]
    }

    method getPageSubMax {} {
        return [expr {int(100 * (
            $_pagecount + 1 - 1.0 * $_viewheight / $_zoomedpageheight
        ))}]
    }

    method getX {} {
        return $_x
    }

    method getY {} {
        return $_y
    }

    method getZoom {} {
        return $_zoom
    }

    method getZoomPageSize {{zoom "undefined"}} {
        if {$zoom eq "undefined"} {
            set zoom $_zoom
        }
        set result [$::reader getPageSize $_pageno]
        lassign $result pageWidth pageHeight pageDPI
        set zoomFactor [expr {1.0 / $zoom}]
        set zoomPageHeight [expr {int($pageHeight * $zoomFactor)}]
        set zoomPageWidth [expr {int($pageWidth * $zoomFactor)}]
        return [list $zoomPageWidth $zoomPageHeight]
    }

    method scrollHorizontal {amount} {
        set x $_x
        set _x [my cropX [expr {$x + $amount}]]
        if {$x != $_x} {
            my viewPageSegment $_pageno
        }
        return
    }

    method scrollVertical {amount} {
        set y $_y
        set _y [my cropY [expr {$y + $amount}]]
        if {$y == $_y} {
            set retval false
        } else {
            my viewPageSegment $_pageno
            set retval true
        }
        return $retval
    }

    method viewPageSegment {pageno} {
        if {$pageno eq "next"} {
            set newpage [my cropPageNo [expr {$_pageno + 1}]]
            if {$newpage > $_pageno} {
                set _y 0
            }
        } elseif {$pageno eq "prev"} {
            set newpage [my cropPageNo [expr {$_pageno - 1}]]
            if {$newpage < $_pageno} {
                set _y [my cropY 99999]
            }
        } else {
            set newpage [my cropPageNo $pageno]
        }
        set _pageno $newpage

        # re-center and zoom because page size varies
        set _zoom [my cropZoom $_zoom]
        lassign [my getZoomPageSize] _zoomedpagewidth _zoomedpageheight
        set _x [my cropX $_x]
        set _y [my cropY $_y]

        $::reader loadPageSegment $_pageno $_viewwidth $_viewheight $_x $_y \
            $_zoom $_zoomedpagewidth $_zoomedpageheight
        $::app doEventOutput
        return
    }

    method viewPageSub {pagesel} {
        set pageno [expr {int($pagesel / 100)}]
        set percent [expr {$pagesel / 100.0 - $pageno}]
        set _y [my cropY [expr {int($_zoomedpageheight * $percent)}]]
        my viewPageSegment $pageno
        return
    }

    method zoomIn {} {
        incr _zoom -1
        set _zoom [my cropZoom $_zoom]
        set _x 0
        set _y 0
        my viewPageSegment $_pageno
        set pct [expr {int(100.0 / $_zoom)}]
        $::display notify "Zoomed to $pct %"
        return
    }

    method zoomOut {} {
        incr _zoom
        set _zoom [my cropZoom $_zoom]
        set _x 0
        set _y 0
        my viewPageSegment $_pageno
        set pct [expr {int(100.0 / $_zoom)}]
        $::display notify "Zoomed to $pct %"
        return
    }
}

# on error, gracefully exit graphics mode and return the error
set result [catch {
    set args [lassign $::argv path]
    set ::app [controllerClass new $path -args $args]
    $::app doEventStart
    $::app destroy
} error]
if {$result != 0} {
    SDL_QuitVideo
    puts "Error: $error"
    puts "Backtrace:"
    puts [string map [list " CALL" "\nCALL"] [info errorstack]]
    exit $result
}