branch: elpa/pdf-tools
commit d54ba6423d8cdc34b79ba20c899616ad21d4a5bc
Author: Vedang Manerikar <[email protected]>
Commit: Vedang Manerikar <[email protected]>

    feat: add pdf-roll.el for continuous scroll support
    
    Add core infrastructure for displaying multiple PDF pages
    simultaneously with continuous scrolling. This introduces
    pdf-view-roll-minor-mode which can be enabled in any PDF buffer.
    
    The implementation is based on image-roll.el by Daniel Nicolai,
    with significant enhancements by Rahguzar for pdf-tools integration.
    
    Key features:
    - Smooth pixel-level scrolling across page boundaries
    - Multiple pages visible simultaneously
    - Integration with pixel-scroll-precision-mode
    - Efficient page display/undisplay management
    
    Closes #18, #104
    
    Co-authored-by: Daniel Nicolai <[email protected]>
    Co-authored-by: Rahguzar <[email protected]>
    Co-authored-by: Ihor Radchenko <[email protected]>
    Co-authored-by: NightMachinery <[email protected]>
---
 lisp/pdf-roll.el | 421 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 421 insertions(+)

diff --git a/lisp/pdf-roll.el b/lisp/pdf-roll.el
new file mode 100644
index 00000000000..e47063b749c
--- /dev/null
+++ b/lisp/pdf-roll.el
@@ -0,0 +1,421 @@
+;;; pdf-roll.el --- Add continuous scroll. -*- lexical-binding: t -*-
+
+;; Copyright (C) 2013, 2014  Andreas Politz
+
+;; Author: Daniel Nicolai <[email protected]>
+;; Keywords: files, multimedia
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU 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 General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+;;
+
+;;; Code:
+(require 'pdf-view)
+
+(put 'pdf-roll 'display '(space :width 25 :height 1000))
+(put 'pdf-roll 'evaporate t)
+(put 'pdf-roll-margin 'evaporate t)
+
+;;; Custom Variables
+(defgroup pdf-roll nil
+  "Image roll configurations."
+  :group 'pdf-view)
+
+(defface pdf-roll-default `((t :font ,(font-spec :family "monospace" :size 1)))
+  "Default face for image roll documents.")
+
+(defcustom pdf-roll-vertical-margin 2
+  "Vertical margin between images in pixels, i.e. page separation height."
+  :type 'integer)
+
+(defcustom pdf-roll-margin-color "gray"
+  "Background color of overlay, i.e. page separation color."
+  :type 'color
+  :set (lambda (_ color) (put 'pdf-roll-margin 'face `(:background ,color))))
+
+;;; Variables
+(defvar pdf-roll--state nil
+  "Local variable that tracks window, point and vscroll to handle changes.")
+
+;;; Utility Macros and functions
+(defsubst pdf-roll-page-to-pos (page)
+  "Get the buffer position displaing PAGE."
+  (- (* 4 page) 3))
+
+(defun pdf-roll--pos-overlay (pos window)
+  "Return an overlay for WINDOW at POS."
+  (cl-find window (overlays-at pos) :key (lambda (ov) (overlay-get ov 
'window))))
+
+(defun pdf-roll-page-overlay (&optional page window)
+  "Return overlay displaying PAGE in WINDOW."
+  (pdf-roll--pos-overlay
+   (pdf-roll-page-to-pos (or page (pdf-roll-page-at-current-pos)))
+   (or window (selected-window))))
+
+(defun pdf-roll-page-at-current-pos ()
+  "Page at point."
+  (if (cl-oddp (point))
+      (/ (+ (point) 3) 4)
+    (error "No page is displayed at current position (%s)" (point))))
+
+(defun pdf-roll-set-vscroll (vscroll win)
+  "Set vscroll to VSCROLL in window WIN."
+  (image-mode-winprops win t)
+  (image-mode-window-put 'vscroll vscroll win)
+  (set-window-vscroll win vscroll t))
+
+;;; Displaying/Undisplaying pages
+(defun pdf-roll-maybe-slice-image (image &optional window inhibit-slice-p)
+  "Return a sliced IMAGE if `pdf-view-current-slice' in WINDOW is non-nil.
+If INHIBIT-SLICE-P is non-nil, disregard `pdf-view-current-slice'."
+  (if-let ((slice (pdf-view-current-slice window))
+           ((not inhibit-slice-p)))
+      (list (cons 'slice
+                  (pdf-util-scale slice (image-size image t) 'round))
+            image)
+    image))
+
+(defun pdf-roll-display-image (image page &optional window inhibit-slice-p)
+  "Display IMAGE for PAGE in WINDOW.
+If INHIBIT-SLICE-P is non-nil, disregard `pdf-view-current-slice'."
+  (let* ((image (pdf-roll-maybe-slice-image image window inhibit-slice-p))
+         (size (image-display-size image t))
+         (overlay (pdf-roll-page-overlay page window))
+         (margin-pos (+ (pdf-roll-page-to-pos page) 2))
+         (margin-overlay (pdf-roll--pos-overlay margin-pos window))
+         (offset (when (> (window-width window t) (car size))
+                   `(space :width (,(/ (- (window-width window t) (car size)) 
2))))))
+    (overlay-put overlay 'display image)
+    (overlay-put overlay 'line-prefix offset)
+    (overlay-put margin-overlay 'display `(space :width (,(car size)) :height 
(,pdf-roll-vertical-margin)))
+    (overlay-put margin-overlay 'line-prefix offset)
+    (cdr size)))
+
+(defun pdf-roll-display-page (page window &optional force)
+  "Display PAGE in WINDOW.
+With FORCE non-nil display fetch page again even if it is already displayed."
+  (let ((display (overlay-get (pdf-roll-page-overlay page window) 'display)))
+    (if (or force (not display) (eq (car display) 'space))
+        (pdf-roll-display-image (pdf-view-create-page page window) page window)
+      (cdr (image-display-size display t)))))
+
+(defun pdf-roll-display-pages (page &optional window force pscrolling)
+  "Display pages to fill the WINDOW starting from PAGE.
+If FORCE is non-nill redisplay a page even if it is already displayed."
+  (let (displayed
+        (available-height (window-pixel-height window)))
+    (when (and pscrolling (> page 1))
+      (pdf-roll-display-page (1- page) window force)
+      (push (1- page) displayed))
+    (let ((vscroll (image-mode-window-get 'vscroll window))
+          (im-height (pdf-roll-display-page page window force)))
+      (pdf-roll-set-vscroll (min vscroll (1- im-height)) window)
+      (cl-callf - available-height (- im-height (window-vscroll window t))))
+    (push page displayed)
+    (while (and (> available-height 0) (< page (pdf-cache-number-of-pages)))
+      (cl-callf - available-height (pdf-roll-display-page (cl-incf page) 
window force))
+      (push page displayed))
+    (when (and pscrolling (< page (pdf-cache-number-of-pages)))
+      (pdf-roll-display-page (cl-incf page) window force)
+      (push page displayed))
+    ;; store displayed images for determining which images to update when 
update
+    ;; is triggered
+    (cl-callf cl-union (image-mode-window-get 'displayed-pages window) 
displayed)
+    displayed))
+
+(defun pdf-roll-undisplay-pages (pages &optional window)
+  "Undisplay PAGES from WINDOW.
+Replaces the display property of the overlay holding a page with a space."
+  (dolist (page pages)
+    (overlay-put (pdf-roll-page-overlay page window)
+                 'display (get 'pdf-roll 'display))))
+
+;;; State Management
+(defun pdf-roll-new-window-function (&optional win)
+  "Setup image roll in a new window WIN.
+If the buffer is newly created, then it does not contain any
+overlay and this function erases the buffer contents, after which
+it inserts empty spaces that each hold a overlay. If the buffer
+already has overlays (i.e. a second or subsequent window is
+created), the function simply copies the overlays and adds the
+new window as window overlay-property to each overlay.
+
+This function should be added to pdf-roll (continuous scroll)
+minor mode commands, after erasing the buffer to create the
+overlays."
+  (setq win (or (and (windowp win) win) (selected-window)))
+  (if (not (overlays-at 1))
+      (let ((pages (pdf-cache-number-of-pages))
+            (inhibit-read-only t))
+        (erase-buffer)
+        (setq pdf-roll--state (list t))
+        (dotimes (i (* 2 pages))
+          (insert " ")
+          (let ((o (make-overlay (1- (point)) (point))))
+            (overlay-put o 'category (if (eq 0 (mod i 2)) 'pdf-roll 
'pdf-roll-margin))
+            (overlay-put o 'window win))
+          (insert "\n"))
+        (delete-char -1)
+        (set-buffer-modified-p nil))
+    (unless (pdf-roll-page-overlay 1 win)
+      (dotimes (i (/ (point-max) 2))
+        (overlay-put (copy-overlay (car (overlays-at (1+ (* 2 i)))))
+                     'window win))
+      (dolist (win-st pdf-roll--state)
+        (when-let ((win-old (car-safe win-st))
+                   ((not (window-live-p win-old))))
+          (remove-overlays (point-min) (point-max) 'window win-old)))
+      (cl-callf2 cl-delete-if-not #'window-live-p pdf-roll--state :key 
#'car-safe)))
+  ;; initial `pdf-roll-redisplay' needs to know which page(s) to display
+  (cl-callf or (pdf-view-current-page win) 1)
+  (cl-callf or (image-mode-window-get 'vscroll win) 0))
+
+(defun pdf-roll-redisplay (&optional window)
+  "Analogue of `pdf-view-redisplay' for WINDOW."
+  (setq window (if (windowp window) window (selected-window)))
+  (when (pdf-roll-page-overlay 1 window)
+    (setf (alist-get window pdf-roll--state) nil)
+    (force-window-update window)))
+
+(defun pdf-roll-pre-redisplay (win)
+  "Handle modifications to the state in window WIN.
+It should be added to `pre-redisplay-functions' buffer locally."
+  (with-demoted-errors "Error in image roll pre-redisplay: %S"
+    (unless (pdf-roll-page-overlay 1 win)
+      (pdf-roll-new-window-function win))
+    (let* ((state (alist-get win pdf-roll--state))
+           (pscrolling (memq last-command
+                             '(pixel-scroll-precision 
pixel-scroll-start-momentum
+                               pixel-scroll-interpolate-up 
pixel-scroll-interpolate-down)))
+           (page (progn (when pscrolling
+                          (setf (pdf-view-current-page win)
+                                (/ (min (+ (window-start win) 5) (point-max)) 
4)))
+                        (pdf-view-current-page win)))
+           (height (window-pixel-height win))
+           (vscroll (image-mode-window-get 'vscroll win))
+           (size-changed (not (and (eq height (nth 1 state))
+                                   (eq (window-pixel-width win) (nth 2 
state)))))
+           (page-changed (not (eq page (nth 0 state))))
+           (vscroll-changed (not (eq vscroll (nth 3 state))))
+           (start (pdf-roll-page-to-pos page)))
+      (if (and pscrolling
+               (or (not (eq start (- (point-max) 3)))
+                   (let ((visible-pixels (nth 4 (pos-visible-in-window-p start 
win t))))
+                     (and visible-pixels (> visible-pixels (/ 
(window-text-height win t) 2))))
+                   (prog1 nil (message "End of buffer"))))
+          (progn (image-mode-window-put 'vscroll (window-vscroll win t) win)
+                 (image-mode-window-put 'hscroll (window-hscroll win)) win)
+        (set-window-vscroll win vscroll t)
+        (set-window-hscroll win (or (image-mode-window-get 'hscroll win) 0))
+        (set-window-start win start t))
+      (setq disable-point-adjustment t)
+      (when (or size-changed page-changed vscroll-changed)
+        (let ((old (image-mode-window-get 'displayed-pages win))
+              (new (pdf-roll-display-pages page win size-changed pscrolling)))
+          ;; If images/pages are small enough (or after jumps), there
+          ;; might be multiple image that need to get updated
+          (pdf-roll-undisplay-pages (cl-set-difference old new) win)
+          (image-mode-window-put 'displayed-pages new win)
+          (set-window-point win (+ start
+                                   (if (pos-visible-in-window-p (+ 2 start) 
win) 2 0))))
+        (setf (alist-get win pdf-roll--state)
+              `(,page ,height ,(window-pixel-width win) ,vscroll nil))
+        (when page-changed (run-hooks 'pdf-view-after-change-page-hook))))))
+
+;;; Page navigation commands
+(defun pdf-roll-goto-page-start ()
+  "Go to the start of the first displayed page."
+  (interactive)
+  (pdf-roll-set-vscroll 0 nil))
+
+(defun pdf-roll-goto-page (page &optional window)
+  "Go to PAGE in WINDOW."
+  (interactive
+   (list (if current-prefix-arg
+             (prefix-numeric-value current-prefix-arg)
+           (read-number "Page: "))))
+  (unless (and (>= page 1)
+               (<= page (pdf-cache-number-of-pages)))
+    (error "No such page: %d" page))
+  (setf (pdf-view-current-page window) page)
+  (pdf-roll-set-vscroll 0 window))
+
+(defun pdf-roll-next-page (&optional n)
+  "Go to next page or next Nth page."
+  (interactive "p")
+  (pdf-roll-goto-page (+ (pdf-roll-page-at-current-pos) n)))
+
+(defun pdf-roll-previous-page (&optional n)
+  "Go to previous page or previous Nth page."
+  (interactive "p")
+  (pdf-roll-next-page (- n)))
+
+;;; Scrolling Commands
+(defun pdf-roll-scroll-forward (&optional n window pixels)
+  "Scroll image N lines forward in WINDOW.
+Line height is determined by `frame-char-height'. When N is negative
+scroll backward instead. With a prefix arg N is its numeric value.
+
+If PIXELS is non-nil N is number of pixels instead of lines."
+  (interactive (list (prefix-numeric-value current-prefix-arg)))
+  (setq n (* (or n 1) (if pixels 1 (frame-char-height))))
+  (setq window (or window (selected-window)))
+  (when (> 0 n) (pdf-roll-scroll-backward (- n) window))
+  (let ((pos (goto-char (window-start window))))
+    (while (let* ((data (pos-visible-in-window-p (point) window t))
+                  (occupied-pixels (cond ((nth 2 data) (nth 4 data))
+                                         (data (line-pixel-height))
+                                         (t (pdf-roll-display-page
+                                             (pdf-roll-page-at-current-pos) 
window)))))
+             (if (eq (point) (- (point-max) 3))
+                 (prog1 nil
+                   (setq n (min n (max 0 (- occupied-pixels (/ 
(window-text-height window t) 2)))))
+                   (message "End of buffer"))
+               (when (>= n occupied-pixels)
+                 (cl-decf n occupied-pixels))))
+      (forward-char 4))
+    (setf (pdf-view-current-page window) (pdf-roll-page-at-current-pos))
+    (pdf-roll-set-vscroll (+ (if (eq pos (point)) (window-vscroll window t) 0) 
n)
+                          window)))
+
+(defun pdf-roll-scroll-backward (&optional n window pixels)
+  "Scroll image N lines backwards in WINDOW.
+Line height is determined by `frame-char-height'. When N is negative
+scroll forward instead. With a prefix arg N is its numeric value.
+
+If PIXELS is non-nil N is number of pixels instead of lines."
+  (interactive (list (prefix-numeric-value current-prefix-arg)))
+  (setq n (* (or n 1) (if pixels 1 (frame-char-height))))
+  (setq window (or window (selected-window)))
+  (when (> 0 n) (pdf-roll-scroll-backward (- n) window))
+  (goto-char (window-start window))
+  (let* ((data (pos-visible-in-window-p (point) window t))
+         (pixels-top (if (nth 2 data) (nth 2 data) 0)))
+    (if (< n pixels-top)
+        (pdf-roll-set-vscroll (- (window-vscroll window t) n)
+                                window)
+      (cl-decf n pixels-top)
+      (while (and (if (bobp)
+                      (prog1 nil (message "Beginning of buffer."))
+                    t)
+                  (progn (forward-char -4)
+                         (pdf-roll-display-page
+                          (pdf-roll-page-at-current-pos) window)
+                         (cl-decf n (line-pixel-height)))
+                  (> n 0)))
+      (pdf-roll-set-vscroll (- n) window)))
+  (setf (pdf-view-current-page window) (pdf-roll-page-at-current-pos)))
+
+(defun pdf-roll-scroll-screen-forward (&optional arg)
+  "Scroll forward by (almost) ARG many full screens."
+  (interactive "p")
+  (pdf-roll-scroll-forward
+   (- (* (window-text-height nil t) arg) (* next-screen-context-lines 
(frame-char-height)))
+   nil t))
+
+(defun pdf-roll-scroll-screen-backward (&optional arg)
+  "Scroll backward by (almost) ARG many full screens."
+  (interactive "p")
+  (pdf-roll-scroll-backward
+   (- (* (window-text-height nil t) arg) (* next-screen-context-lines 
(frame-char-height)))
+   nil t))
+
+;;; Minor mode
+(defun pdf-roll-initialize (&rest _args)
+  "Fun to initialize `pdf-view-roll-minor-mode'.
+It is also added to `revert-buffer-function'."
+  (let ((inhibit-read-only t))
+    (erase-buffer)
+    (remove-overlays))
+  (image-mode-window-put 'displayed-pages nil)
+  (pdf-roll-new-window-function))
+
+;;;###autoload
+(define-minor-mode pdf-view-roll-minor-mode
+  "If enabled display document on a virtual scroll providing continuous 
scrolling."
+  :lighter " Continuous"
+  :keymap (let ((map (make-sparse-keymap)))
+            (define-key map [remap pdf-view-previous-line-or-previous-page] 
'pdf-roll-scroll-backward)
+            (define-key map [remap pdf-view-next-line-or-next-page] 
'pdf-roll-scroll-forward)
+            (define-key map [remap pdf-view-scroll-down-or-previous-page] 
'pdf-roll-scroll-backward)
+            (define-key map [remap pdf-view-scroll-up-or-next-page] 
'pdf-roll-scroll-forward)
+            (define-key map [remap mouse-set-point] 'ignore)
+            (define-key map (kbd "S-<next>") 'pdf-roll-scroll-screen-forward)
+            (define-key map (kbd "S-<prior>") 'pdf-roll-scroll-screen-backward)
+            map)
+  :version 28.1
+
+  (cond (pdf-view-roll-minor-mode
+         (setq-local face-remapping-alist '((default . pdf-roll-default))
+                     mwheel-scroll-up-function #'pdf-roll-scroll-forward
+                     mwheel-scroll-down-function #'pdf-roll-scroll-backward)
+
+         (remove-hook 'window-configuration-change-hook 
'image-mode-reapply-winprops t)
+         (remove-hook 'window-configuration-change-hook 
'pdf-view-redisplay-some-windows t)
+         (remove-hook 
'image-mode-new-window-functions#'pdf-view-new-window-function t)
+
+         (add-hook 'pre-redisplay-functions 'pdf-roll-pre-redisplay nil t)
+         (add-hook 'pdf-roll-after-change-page-hook 
'pdf-history-before-change-page-hook nil t)
+
+         (add-function :after (local 'revert-buffer-function) 
#'pdf-roll-initialize)
+
+         (make-local-variable 'pdf-roll--state)
+
+         (when (local-variable-p 'pixel-scroll-precision-mode)
+           (kill-local-variable 'pixel-scroll-precision-mode)
+           (kill-local-variable 'mwheel-coalesce-scroll-events))
+
+         (pdf-roll-initialize))
+        (t
+         (setq-local mwheel-scroll-up-function 
#'pdf-view-scroll-up-or-next-page
+                     mwheel-scroll-down-function 
#'pdf-view-scroll-down-or-previous-page)
+
+         (add-hook 'window-configuration-change-hook 
'image-mode-reapply-winprops nil t)
+         (add-hook 'window-configuration-change-hook 
'pdf-view-redisplay-some-windows nil t)
+         (add-hook 'image-mode-new-window-functions 
#'pdf-view-new-window-function nil t)
+
+         (remove-function (local 'revert-buffer-function) 
#'pdf-roll-initialize)
+
+         (remove-hook 'pre-redisplay-functions 'pdf-roll-pre-redisplay t)
+         (remove-hook 'pdf-roll-after-change-page-hook 
'pdf-history-before-change-page-hook t)
+
+         (kill-local-variable 'pdf-roll--state)
+
+         (when (bound-and-true-p pixel-scroll-precision-mode)
+             (setq-local pixel-scroll-precision-mode nil)
+             (setq-local mwheel-coalesce-scroll-events t))
+
+         (let ((inhibit-read-only t))
+           (remove-overlays)
+           (image-mode-window-put 'displayed-pages nil)
+           (pdf-view-new-window-function (list (selected-window)))
+           (set-buffer-modified-p nil)))))
+
+(defun pdf-roll--get-display-property ()
+  "`:before-until' advice for `image-get-display-property'.
+`image-get-display-property' looks at the `point-min'. This function instead
+returns the display property for the current page if `pdf-view-roll-minor-mode'
+is non-nil."
+  (when pdf-view-roll-minor-mode
+    (get-char-property (pdf-roll-page-to-pos (pdf-view-current-page))
+                       'display
+                       (if (eq (window-buffer) (current-buffer))
+                           (selected-window)))))
+
+(advice-add 'image-get-display-property :before-until 
#'pdf-roll--get-display-property)
+
+(provide 'pdf-roll)
+
+;;; pdf-roll.el ends here

Reply via email to