Files
emacs/lisp/emacs-application-framework/app/pdf-viewer/eaf-pdf-viewer.el
2022-01-04 15:21:47 +01:00

610 lines
23 KiB
EmacsLisp

;;; eaf-pdf-viewer.el --- PDF Viewer -*- lexical-binding: t; -*-
;; Filename: eaf-pdf-viewer.el
;; Description: PDF Viewer
;; Author: Andy Stewart <lazycat.manatee@gmail.com>
;; Maintainer: Andy Stewart <lazycat.manatee@gmail.com>
;; Copyright (C) 2021, Andy Stewart, all rights reserved.
;; Created: 2021-07-20 22:16:40
;; Version: 0.1
;; Last-Updated: Fri Jul 30 00:48:38 2021 (-0400)
;; By: Mingde (Matthew) Zeng
;; URL: http://www.emacswiki.org/emacs/download/eaf-pdf-viewer.el
;; Keywords:
;; Compatibility: GNU Emacs 28.0.50
;;
;; Features that might be required by this library:
;;
;;
;;
;;; This file is NOT part of GNU Emacs
;;; License
;;
;; 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, 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; see the file COPYING. If not, write to
;; the Free Software Foundation, Inc., 51 Franklin Street, Fifth
;; Floor, Boston, MA 02110-1301, USA.
;;; Commentary:
;;
;; PDF Viewer
;;
;;; Installation:
;;
;; Put eaf-pdf-viewer.el to your load-path.
;; The load-path is usually ~/elisp/.
;; It's set in your ~/.emacs like this:
;; (add-to-list 'load-path (expand-file-name "~/elisp"))
;;
;; And the following to your ~/.emacs startup file.
;;
;; (require 'eaf-pdf-viewer)
;;
;; No need more.
;;; Customize:
(defgroup eaf-pdf-viewer nil
"The PDF viewer application of Emacs application framework."
:group 'eaf)
(defcustom eaf-pdf-extension-list
'("pdf" "xps" "oxps" "cbz" "epub" "fb2" "fbz")
"The extension list of pdf application."
:type 'list
:group 'eaf-pdf-viewer)
(defcustom eaf-office-extension-list
'("docx" "doc" "ppt" "pptx" "xlsx" "xls")
"The extension list of office application."
:type 'list
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-store-history t
"If it is t, the pdf file path will be stored in eaf-config-location/pdf/history/log.txt for eaf-open-pdf-from-history to use"
:type 'boolean
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-notify-file-changed t
"If it is t, pdf-viewer will notify that the displayed pdf file is changed. Otherwise the pdf-viewer buffer will be refreshed silently."
:type 'boolean
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-show-progress-on-page t
"If it is t, pdf-viewer will show progress (in percentage) and page number directly on the document."
:type 'boolean
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-dark-mode "follow"
"Whether to enable inverted color rendering when starting the pdf-viewer app.
Possible values are
- \"follow\" Follow the background color of the theme of user.
- \"force\" Force inverted color rendering on start-up.
- \"ignore\" Don't do inverted rendering."
:type '(choice (string :tag "Force inverted color rendering." "force")
(string :tag "Follow the background color of user's theme." "follow")
(other :tag "Do normal rendering." "ignore"))
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-default-zoom 1.0
"The default zooming percentage when starting the pdf-viewer app."
:type 'float
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-zoom-step 0.2
"The ratio step of the current page size to perform zoom in, zoom out."
:type 'float
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-scroll-ratio 0.05
"The ratio of the page in each step when scrolling."
:type 'float
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-dark-exclude-image t
"Don't invert images when toggling inverted color rendering.
Nil means also invert images.
Non-nil means don't invert images."
:type 'boolean
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-marker-fontsize 8
"The font size used by pdf marker."
:type 'integer
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-inline-text-annot-fontsize 8
"The font size used by pdf inline text annot."
:type 'integer
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-inline-text-annot-color "#ec3f00"
"The color used by pdf inline text annot."
:type 'string
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-text-highlight-annot-color "#ffd815"
"The color used by pdf text highlighting annot."
:type 'string
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-text-underline-annot-color "#11e32a"
"The color used by pdf text underlining annot."
:type 'string
:group 'eaf-pdf-viewer)
(defcustom eaf-pdf-viewer-keybinding
'(("j" . "scroll_up")
("<down>" . "scroll_up")
("C-n" . "scroll_up")
("k" . "scroll_down")
("<up>" . "scroll_down")
("C-p" . "scroll_down")
("h" . "scroll_left")
("<left>" . "scroll_left")
("C-b" . "scroll_left")
("l" . "scroll_right")
("<right>" . "scroll_right")
("C-f" . "scroll_right")
("SPC" . "scroll_up_page")
("b" . "scroll_down_page")
("C-v" . "scroll_up_page")
("M-v" . "scroll_down_page")
("c" . "scroll_center_horizontal")
("t" . "toggle_read_mode")
("0" . "zoom_reset")
("=" . "zoom_in")
("-" . "zoom_out")
("w" . "zoom_fit_text_width")
("g" . "scroll_to_begin")
("G" . "scroll_to_end")
("p" . "jump_to_page")
("P" . "jump_to_percent")
("[" . "save_current_pos")
("]" . "jump_to_saved_pos")
("i" . "toggle_inverted_mode")
("C-i" . "toggle_inverted_image_mode")
("m" . "toggle_mark_link")
("f" . "jump_to_link")
("M-w" . "copy_select")
("C-s" . "search_text_forward")
("C-r" . "search_text_backward")
("C-/" . "undo_annot_action")
("C-?" . "redo_annot_action")
("x" . "close_buffer")
("r" . "reload_document")
("C-<right>" . "rotate_clockwise")
("C-<left>" . "rotate_counterclockwise")
("M-h" . "add_annot_highlight")
("M-u" . "add_annot_underline")
("M-s" . "add_annot_squiggly")
("M-d" . "add_annot_strikeout_or_delete_annot")
("M-t" . "add_annot_popup_text")
("M-T" . "add_annot_inline_text")
("M-e" . "edit_annot_text")
("M-r" . "move_annot_text")
("M-p" . "toggle_presentation_mode")
("J" . "select_left_tab")
("K" . "select_right_tab")
("o" . "eaf-pdf-outline")
("T" . "toggle_trim_white_margin"))
"The keybinding of EAF PDF Viewer."
:type '(alist :key-type (string :tag "Key bindings (e.g. \"C-n\", \"<f4>\", etc.)")
:value-type (string :tag "Function name"))
:group 'eaf-pdf-viewer)
;;
;; All of the above can customize by:
;; M-x customize-group RET eaf-pdf-viewer RET
;;
;;; Change log:
;;
;; 2021/07/20
;; * First released.
;; 2021/08/16
;; * More elaborate defcustoms
;; * Divide code via outline-mode
;;
;;; Acknowledgements:
;;
;;
;;
;;; TODO
;;
;;
;;
;;; Require
(require 'outline)
;;; Code:
;;;; Outline buffer & Imenu
(defcustom eaf-pdf-outline-buffer-indent 4
"The level of indent in the Outline buffer."
:type 'integer
:group 'eaf-pdf-viewer)
(defvar-local eaf-pdf-outline-pdf-document nil
"The PDF filename or buffer corresponding to this outline
buffer.")
(defvar-local eaf-pdf--outline-window-configuration nil
"Save window configure before popup outline buffer.")
(defvar eaf-pdf-outline-mode-map
(let ((map (make-sparse-keymap)))
(dotimes (i 10)
(define-key map (vector (+ i ?0)) 'digit-argument))
(define-key map "-" 'negative-argument)
;; Navigation
(define-key map (kbd "p") 'previous-line)
(define-key map (kbd "P") 'eaf-pdf-outline-view-prev)
(define-key map (kbd "n") 'next-line)
(define-key map (kbd "N") 'eaf-pdf-outline-view-next)
(define-key map (kbd "SPC") 'eaf-pdf-outline-view-next)
(define-key map (kbd "RET") 'eaf-pdf-outline-jump)
(define-key map (kbd "o") 'eaf-pdf-outline-view)
(define-key map (kbd "v") 'eaf-pdf-outline-view)
;; Outline-mode stuffs
(define-key map (kbd "b") 'outline-backward-same-level)
(define-key map (kbd "d") 'outline-hide-subtree)
(define-key map (kbd "a") 'outline-show-all)
(define-key map (kbd "s") 'outline-show-subtree)
(define-key map (kbd "f") 'outline-forward-same-level)
(define-key map (kbd "Q") 'outline-hide-sublevels)
(define-key map (kbd "RET") 'eaf-pdf-outline-jump)
(define-key map (kbd "Q") 'hide-sublevels)
map)
"Keymap used in `eaf-pdf-outline-mode'.")
(define-derived-mode eaf-pdf-outline-mode outline-mode "PDF Outline"
"EAF pdf outline mode."
(setq-local outline-regexp "\\( *\\).")
(setq-local outline-level
#'(lambda nil (1+ (/ (length (match-string 1))
eaf-pdf-outline-buffer-indent))))
(toggle-truncate-lines 1)
(setq buffer-read-only t))
(defun eaf-pdf-outline-buffer-name (&optional pdf-buffer)
(unless pdf-buffer (setq pdf-buffer (current-buffer)))
(format "*Outline: %s*"
(if (bufferp pdf-buffer)
(buffer-name pdf-buffer)
pdf-buffer)))
(defun eaf-pdf-outline ()
"Display an PDF outline of the current buffer."
(interactive)
(let ((pdf-buffer (current-buffer))
(toc (eaf-call-sync "execute_function" eaf--buffer-id "get_toc"))
(page-number (string-to-number (eaf-call-sync "execute_function" eaf--buffer-id "current_page")))
(outline-buf (get-buffer-create (eaf-pdf-outline-buffer-name))))
;; Save window configuration before outline.
(setq eaf-pdf--outline-window-configuration (current-window-configuration))
;; Insert outline content.
(with-current-buffer outline-buf
(setq buffer-read-only nil)
(erase-buffer)
(insert toc)
(setq toc (mapcar #'(lambda (line)
(string-to-number (car (last (split-string line " ")))))
(butlast (split-string (buffer-string) "\n"))))
(goto-line (seq-count (apply-partially #'>= page-number) toc))
(let ((view-read-only nil))
(read-only-mode 1))
(eaf-pdf-outline-mode)
(setq-local eaf-pdf-outline-pdf-document pdf-buffer))
;; Popup outline buffer.
(pop-to-buffer outline-buf)))
(defun eaf-pdf-outline-jump ()
"Jump into specific page."
(interactive)
(let* ((line (thing-at-point 'line))
(page-num (substring-no-properties (replace-regexp-in-string "\n" "" (car (last (split-string line " ")))))))
;; Jump to page.
(switch-to-buffer-other-window eaf-pdf-outline-pdf-document)
(eaf-call-sync "execute_function_with_args" eaf--buffer-id "jump_to_page_with_num" (format "%s" page-num))
;; Restore window configuration before outline operation.
(when eaf-pdf--outline-window-configuration
(set-window-configuration eaf-pdf--outline-window-configuration)
(setq eaf-pdf--outline-window-configuration nil))))
(defun eaf-pdf-outline-view ()
"View the specific page."
(interactive)
(let* ((line (thing-at-point 'line))
(page-num (substring-no-properties (replace-regexp-in-string "\n" "" (car (last (s-split " " line)))))))
;; Jump to page.
(eaf-call-async "execute_function_with_args"
(buffer-local-value 'eaf--buffer-id eaf-pdf-outline-pdf-document)
"jump_to_page_with_num" (format "%s" page-num))))
(defun eaf-pdf-outline-view-prev ()
"View the specific page in the previous line."
(interactive)
(previous-line)
(eaf-pdf-outline-view))
(defun eaf-pdf-outline-view-next ()
"View the specific page in the next line."
(interactive)
(next-line)
(eaf-pdf-outline-view))
(defun eaf-pdf-imenu-create-index-from-toc ()
"Create an alist based on the table of contents of this buffer.
It call the Python's function \"get_toc\" then from the output, make an alist
with each element that looks like
(\"CHAPTER_NAME\" PAGE_NUMBER 'eaf-pdf-imenu-go-to-index nil).
(See why the element has to be that way in `imenu--index-alist'
Hint: Look for \"Special elements\" in the documentation.)
the \"CHAPTER_NAME\" part will be replace with \"Page PAGE_NUMBER\"
when there is no table of contents for the buffer."
(interactive)
(or imenu--index-alist
(setq imenu--index-alist
(let ((toc (eaf-call-sync "execute_function" eaf--buffer-id "get_toc")))
(cond ((string= toc "")
(mapcar #'(lambda (page-num)
(list (concat "Page " (number-to-string page-num)) page-num
#'eaf-pdf-imenu-go-to-index
nil))
(number-sequence 1
(string-to-number
(eaf-call-sync "execute_function"
eaf--buffer-id
"page_total_number")))))
(t
(mapcar #'(lambda (line)
(let ((line-split (split-string line " ")))
(list (string-join (butlast line-split) " ")
(string-to-number (car (last line-split)))
#'eaf-pdf-imenu-go-to-index
nil)))
(split-string toc "\n"))))))))
(defun eaf-pdf-imenu-go-to-index (_chapter-name page-num _arg)
"Ignore _CHAPTER-NAME and _ARG, call Python's \"jump_page\" function with PAGE-NUM as its argument.
The _CHAPTER-NAME is from the car of a element in `eaf-pdf-imenu-create-index-from-toc'
The _ARG is hardcoded to be nil from `eaf-pdf-imenu-create-index-from-toc'
Just ignore them and call \"jump_page\" to PAGE-NUM."
(eaf-call-async "handle_input_response" eaf--buffer-id "jump_page" page-num))
(defun eaf-pdf-imenu-setup ()
(setq imenu-create-index-function 'eaf-pdf-imenu-create-index-from-toc))
(add-hook 'eaf-pdf-viewer-hook 'eaf-pdf-imenu-setup)
;;;; PDF-viewer
(defun eaf-pdf-get-page-annots (page)
"Return a map of annotations on PAGE.
The key is the annot id on PAGE."
(eaf-call-sync "execute_function_with_args" eaf--buffer-id "get_page_annots" (format "%s" page)))
(defun eaf-pdf-get-document-annots ()
"Return a map of page_index of annots.
The key is the page_index."
(eaf-call-sync "execute_function" eaf--buffer-id "get_document_annots"))
(defun eaf-pdf-jump-to-annot (annot)
"Jump to specifical pdf annot."
(let ((rect (gethash "rect" annot))
(page (gethash "page" annot)))
(eaf-call-sync "execute_function_with_args" eaf--buffer-id "jump_to_rect" (format "%s" page) rect)))
(defun eaf--pdf-viewer-bookmark ()
"Restore EAF buffer according to pdf bookmark from the current file path or web URL."
`((handler . eaf--bookmark-restore)
(eaf-app . "pdf-viewer")
(defaults . ,(list eaf--bookmark-title))
(filename . ,(eaf-get-path-or-url))))
(defun eaf--pdf-viewer-bookmark-restore (bookmark)
(eaf-open (cdr (assq 'filename bookmark))))
(defun eaf--pdf-update-position (buffer-id page-index page-total-number)
"Format mode line position indicator to show the current page and the total pages."
(let ((buffer (eaf-get-buffer buffer-id)))
(when buffer
(with-current-buffer buffer
(let ((need-update
(condition-case ex
(or (not (equal (cadr mode-line-position) page-index))
(not (equal (cadddr mode-line-position) page-total-number)))
('error t))))
(when need-update
(setq-local mode-line-position `(" P" ,page-index
"/" ,page-total-number))
(force-mode-line-update)))))))
(defun eaf-open-pdf-from-history ()
"A wrapper around `eaf-open' that provides pdf history candidates.
This function works best if paired with a fuzzy search package."
(interactive)
(let* ((pdf-history-file-path
(concat eaf-config-location
(file-name-as-directory "pdf")
(file-name-as-directory "history")
"log.txt"))
(history-pattern "^\\(.+\\)\\.pdf$")
(history-file-exists (file-exists-p pdf-history-file-path))
(history-pdf (completing-read
"[EAF/pdf] Search || History: "
(if history-file-exists
(mapcar
(lambda (h) (when (string-match history-pattern h)
(if (file-exists-p h)
(format "%s" h))))
(with-temp-buffer (insert-file-contents pdf-history-file-path)
(split-string (buffer-string) "\n" t)))
(make-directory (file-name-directory pdf-history-file-path) t)
(with-temp-file pdf-history-file-path "")))))
(if history-pdf (eaf-open history-pdf))))
;;;###autoload
(defun eaf-open-office (file)
"View Microsoft Office FILE as READ-ONLY PDF."
(interactive "f[EAF/office] Open Office file as PDF: ")
(if (executable-find "libreoffice")
(let* ((file-md5 (eaf-get-file-md5 file))
(basename (file-name-base file))
(pdf-file (format "/tmp/%s.pdf" file-md5))
(pdf-argument (format "%s.%s_office_pdf" basename (file-name-extension file))))
(if (file-exists-p pdf-file)
(eaf-open pdf-file "pdf-viewer" pdf-argument)
(message "Converting %s to PDF, EAF will start shortly..." file)
(make-process
:name ""
:buffer " *eaf-open-office*"
:command (list "libreoffice" "--headless" "--convert-to" "pdf" (file-truename file) "--outdir" "/tmp")
:sentinel (lambda (_ event)
(when (string= (substring event 0 -1) "finished")
(rename-file (format "/tmp/%s.pdf" basename) pdf-file)
(eaf-open pdf-file "pdf-viewer" pdf-argument)
)))))
(error "[EAF/office] libreoffice is required convert Office file to PDF!")))
(defun eaf-get-file-md5 (file)
"Get the MD5 value of a specified FILE."
(car (split-string (shell-command-to-string (format "md5sum '%s'" (file-truename file))) " ")))
;;;; Synctex support
(defvar eaf-pdf-synctex-path "synctex"
"Path of synctex tool.")
(defvar eaf-pdf-synctex-file-directory-function 'eaf-pdf--get-synctex-file-directory
"Function to get the *.synctex.gz file directory, look `eaf-pdf--get-synctex-file-directory'")
(defun eaf-pdf--get-synctex-file-directory (pdf-file)
"Get *.synctex.gz file directory through `pdf-file'"
(file-name-directory (directory-file-name pdf-file)))
(defun eaf-pdf--find-buffer (pdf-url)
"Find opened buffer by `pdf-url'"
(let ((opened-buffer))
(catch 'found-match-buffer
(dolist (buffer (buffer-list))
(set-buffer buffer)
(when (equal major-mode 'eaf-mode)
(when (and (string= eaf--buffer-url pdf-url)
(string= eaf--buffer-app-name "pdf-viewer"))
(setq opened-buffer buffer)
(throw 'found-match-buffer t)))))
opened-buffer))
(defun eaf-pdf--get-synctex-info (tex-file line-num pdf-file)
"Use synctex tool to get the page num of `pdf-file' through `tex-file' and `line-num'."
(if (executable-find eaf-pdf-synctex-path)
(let ((synctex-result)
(page-num 1) (pos-x 0) (pos-y 0)
(synctex-view-command (format "%s view -i %s:1:%s -o %s"
eaf-pdf-synctex-path line-num
(prin1-to-string tex-file)
(prin1-to-string pdf-file))))
(setq synctex-result (shell-command-to-string synctex-view-command))
;; (message "Synctex Result: %s" synctex-result)
(when synctex-result
(and (string-match "Page:\\([0-9]+\\)\n" synctex-result)
(setq page-num (string-to-number (match-string 1 synctex-result))))
(and (string-match "x:\\([0-9\\.]+\\)\n" synctex-result)
(setq pos-x (string-to-number (match-string 1 synctex-result))))
(and (string-match "y:\\([0-9\\.]+\\)\n" synctex-result)
(setq pos-y (string-to-number (match-string 1 synctex-result)))))
(format "%d:%f:%f" page-num pos-x pos-y))
(message "Can not found %s" eaf-pdf-synctex-path)))
(defun eaf-pdf--get-tex-and-line (pdf-file page-num x y)
"Use synctex tool to get tex file and line num through `page-file', `page-num', `x' and `y'."
(if (executable-find eaf-pdf-synctex-path)
(let* ((synctex-result)
(synctex-dir (funcall eaf-pdf-synctex-file-directory-function pdf-file))
(synctex-edit-command (format "%s edit -o %s:%s:%s:%s -d %s"
eaf-pdf-synctex-path page-num x y
(prin1-to-string pdf-file)
(prin1-to-string synctex-dir)))
(tex-file nil)
(line-num nil))
(setq synctex-result (shell-command-to-string synctex-edit-command))
(if (and synctex-result (string-match "Input:\\(.*\\)\nLine:\\([0-9]+\\)\n" synctex-result))
(setq tex-file (expand-file-name (match-string 1 synctex-result))
line-num (string-to-number (match-string 2 synctex-result)))
(message "Execute %s failed!" synctex-view-command))
`(,tex-file ,line-num))
(message "Can not found %s" eaf-pdf-synctex-path)
nil))
(defun eaf-pdf-synctex-forward-view ()
"View the PDF file of Tex synchronously."
(interactive)
(let* ((pdf-url (expand-file-name (TeX-active-master (TeX-output-extension))))
(tex-buffer (window-buffer (minibuffer-selected-window)))
(tex-file (buffer-file-name tex-buffer))
(line-num (progn (set-buffer tex-buffer) (line-number-at-pos)))
(opened-buffer (eaf-pdf--find-buffer pdf-url))
(synctex-info (eaf-pdf--get-synctex-info tex-file line-num pdf-url)))
(if (not opened-buffer)
(eaf-open pdf-url "pdf-viewer" (format "synctex_info=%s" synctex-info))
(pop-to-buffer opened-buffer)
(eaf-call-sync "execute_function_with_args" eaf--buffer-id
"jump_to_page_synctex" (format "%s" synctex-info)))))
(defun eaf-pdf-synctex-backward-edit (pdf-file page-num x y)
"Edit the Tex file corresponding to (`page-num', `x' , `y') of the `pdf-file'."
(let* ((tex-and-line (eaf-pdf--get-tex-and-line pdf-file page-num x y))
(tex-file (nth 0 tex-and-line))
(line-num (nth 1 tex-and-line)))
(when (and tex-file line-num)
(let ((buffer (get-buffer (file-name-nondirectory tex-file))))
(if buffer
(switch-to-buffer-other-window buffer)
(find-file-other-window tex-file))
(goto-line line-num)))))
;;;; Register as module for EAF
(add-to-list 'eaf-app-binding-alist '("pdf-viewer" . eaf-pdf-viewer-keybinding))
(setq eaf-pdf-viewer-module-path (concat (file-name-directory load-file-name) "buffer.py"))
(add-to-list 'eaf-app-module-path-alist '("pdf-viewer" . eaf-pdf-viewer-module-path))
(add-to-list 'eaf-app-bookmark-handlers-alist '("pdf-viewer" . eaf--pdf-viewer-bookmark))
(add-to-list 'eaf-app-bookmark-restore-alist '("pdf-viewer" . eaf--pdf-viewer-bookmark-restore))
(add-to-list 'eaf-app-extensions-alist '("pdf-viewer" . eaf-pdf-extension-list))
(add-to-list 'eaf-app-extensions-alist '("office" . eaf-office-extension-list))
(provide 'eaf-pdf-viewer)
;;; eaf-pdf-viewer.el ends here