dotemacs

My Emacs configuration
git clone git://git.entf.net/dotemacs
Log | Files | Refs | LICENSE

nov.el (36283B)


      1 ;;; nov.el --- Featureful EPUB reader mode
      2 
      3 ;; Copyright (C) 2017 Vasilij Schneidermann <mail@vasilij.de>
      4 
      5 ;; Author: Vasilij Schneidermann <mail@vasilij.de>
      6 ;; URL: https://depp.brause.cc/nov.el
      7 ;; Package-Version: 0.4.0
      8 ;; Package-Commit: 12faf16fbbaf09aadec26dfbda5809d886248c02
      9 ;; Version: 0.4.0
     10 ;; Package-Requires: ((esxml "0.3.6") (emacs "25.1"))
     11 ;; Keywords: hypermedia, multimedia, epub
     12 
     13 ;; This file is NOT part of GNU Emacs.
     14 
     15 ;; This program is free software; you can redistribute it and/or modify
     16 ;; it under the terms of the GNU General Public License as published by
     17 ;; the Free Software Foundation, either version 3 of the License, or
     18 ;; (at your option) any later version.
     19 
     20 ;; This program is distributed in the hope that it will be useful,
     21 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
     22 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     23 ;; GNU General Public License for more details.
     24 
     25 ;; You should have received a copy of the GNU General Public License
     26 ;; along with this program.  If not, see <http://www.gnu.org/licenses/>.
     27 
     28 ;;; Commentary:
     29 
     30 ;; nov.el provides a major mode for reading EPUB documents.
     31 ;;
     32 ;; Features:
     33 ;;
     34 ;; - Basic navigation (jump to TOC, previous/next chapter)
     35 ;; - Remembering and restoring the last read position
     36 ;; - Jump to next chapter when scrolling beyond end
     37 ;; - Storing and following Org links to EPUB files
     38 ;; - Renders EPUB2 (.ncx) and EPUB3 (<nav>) TOCs
     39 ;; - Hyperlinks to internal and external targets
     40 ;; - Supports textual and image documents
     41 ;; - Info-style history navigation
     42 ;; - View source of document files
     43 ;; - Metadata display
     44 ;; - Image rescaling
     45 
     46 ;;; Code:
     47 
     48 (require 'cl-lib)
     49 (require 'dom)
     50 (require 'esxml-query)
     51 (require 'image)
     52 (require 'seq)
     53 (require 'shr)
     54 (require 'url-parse)
     55 (require 'xml)
     56 
     57 (require 'bookmark)
     58 (require 'easymenu)
     59 (require 'imenu)
     60 (require 'org)
     61 (require 'recentf)
     62 
     63 (when (not (fboundp 'libxml-parse-xml-region))
     64   (message "Your Emacs wasn't compiled with libxml support"))
     65 
     66 
     67 ;;; EPUB preparation
     68 
     69 (defgroup nov nil
     70   "EPUB reader mode"
     71   :group 'multimedia)
     72 
     73 (defcustom nov-unzip-program (executable-find "unzip")
     74   "Path to `unzip` executable."
     75   :type '(file :must-match t)
     76   :group 'nov)
     77 
     78 (defcustom nov-variable-pitch t
     79   "Non-nil if a variable pitch face should be used.
     80 Otherwise the default face is used."
     81   :type 'boolean
     82   :group 'nov)
     83 
     84 (defcustom nov-text-width nil
     85   "Width filled text shall occupy.
     86 An integer is interpreted as the number of columns.  If nil, use
     87 the full window's width.  If t, disable filling completely.  Note
     88 that this variable only has an effect in Emacs 25.1 or greater."
     89   :type '(choice (integer :tag "Fixed width in characters")
     90                  (const   :tag "Use the width of the window" nil)
     91                  (const   :tag "Disable filling" t))
     92   :group 'nov)
     93 
     94 (defcustom nov-render-html-function 'nov-render-html
     95   "Function used to render HTML.
     96 It's called without arguments with a buffer containing HTML and
     97 should change it to contain the rendered version of it."
     98   :type 'function
     99   :group 'nov)
    100 
    101 (defcustom nov-pre-html-render-hook nil
    102   "Hook run before `nov-render-html'."
    103   :type 'hook
    104   :group 'nov)
    105 
    106 (defcustom nov-post-html-render-hook nil
    107   "Hook run after `nov-render-html'."
    108   :type 'hook
    109   :group 'nov)
    110 
    111 (defcustom nov-save-place-file (locate-user-emacs-file "nov-places")
    112   "File name where last reading places are saved to and restored from.
    113 If set to `nil', no saving and restoring is performed."
    114   :type '(choice (file  :tag "File name")
    115                  (const :tag "Don't save last reading places" nil))
    116   :group 'nov)
    117 
    118 (defvar-local nov-file-name nil
    119   "Path to the EPUB file backing this buffer.")
    120 
    121 (defvar-local nov-temp-dir nil
    122   "Temporary directory containing the buffer's EPUB files.")
    123 
    124 (defvar-local nov-content-file nil
    125   "Path to the EPUB buffer's .opf file.")
    126 
    127 (defvar-local nov-epub-version nil
    128   "Version string of the EPUB buffer.")
    129 
    130 (defvar-local nov-metadata nil
    131   "Metadata of the EPUB buffer.")
    132 
    133 (defvar-local nov-documents nil
    134   "Alist for the EPUB buffer's documents.
    135 Each alist item consists of the identifier and full path.")
    136 
    137 (defvar-local nov-documents-index 0
    138   "Index of the currently rendered document in the EPUB buffer.")
    139 
    140 (defvar-local nov-toc-id nil
    141   "TOC identifier of the EPUB buffer.")
    142 
    143 (defvar-local nov-history nil
    144   "Stack of documents user has visited.
    145 Each element of the stack is a list (NODEINDEX BUFFERPOS).")
    146 
    147 (defvar-local nov-history-forward nil
    148   "Stack of documents user has visited with `nov-history-back' command.
    149 Each element of the stack is a list (NODEINDEX BUFFERPOS).")
    150 
    151 (defun nov-make-path (directory file)
    152   "Create a path from DIRECTORY and FILE."
    153   (concat (file-name-as-directory directory) file))
    154 
    155 (defun nov-directory-files (directory)
    156   "Returns a list of files in DIRECTORY except for . and .."
    157   (seq-remove (lambda (file) (string-match-p "/\\.\\(?:\\.\\)?\\'" file))
    158               (directory-files directory t)))
    159 
    160 (defun nov-contains-nested-directory-p (directory)
    161   "Non-nil if DIRECTORY contains exactly one directory."
    162   (let* ((files (nov-directory-files directory))
    163          (file (car files)))
    164     (and (= (length files) 1)
    165          (file-directory-p file)
    166          file)))
    167 
    168 (defun nov-unnest-directory (directory child)
    169   "Move contents of CHILD into DIRECTORY, then delete CHILD."
    170   ;; FIXME: this will most certainly fail for con/con
    171   (dolist (item (nov-directory-files child))
    172     (rename-file item directory))
    173   (delete-directory child))
    174 
    175 (defun nov--fix-permissions (file-or-directory mode)
    176   (let* ((modes (file-modes file-or-directory))
    177          (fixed-mode (file-modes-symbolic-to-number mode modes)))
    178     (set-file-modes file-or-directory fixed-mode)))
    179 
    180 (defun nov-fix-permissions (directory)
    181   "Iterate recursively through DIRECTORY to fix its files."
    182   (nov--fix-permissions directory "+rx")
    183   (dolist (file (nov-directory-files directory))
    184     (if (file-directory-p file)
    185         (nov-fix-permissions file)
    186       (nov--fix-permissions file "+r"))))
    187 
    188 (defun nov-unzip-epub (directory filename)
    189   "Extract FILENAME into DIRECTORY.
    190 Unnecessary nesting is removed with `nov-unnest-directory'."
    191   (let ((status (call-process nov-unzip-program nil "*nov unzip*" t
    192                               "-od" directory filename))
    193         child)
    194     (while (setq child (nov-contains-nested-directory-p directory))
    195       (nov-unnest-directory directory child))
    196     ;; HACK: unzip preserves file permissions, no matter how silly they
    197     ;; are, so ensure files and directories are readable
    198     (nov-fix-permissions directory)
    199     status))
    200 
    201 (defmacro nov-ignore-file-errors (&rest body)
    202   "Like `ignore-errors', but for file errors."
    203   `(condition-case nil (progn ,@body) (file-error nil)))
    204 
    205 (defun nov-slurp (filename &optional parse-xml-p)
    206   "Return the contents of FILENAME.
    207 If PARSE-XML-P is t, return the contents as parsed by libxml."
    208   (with-temp-buffer
    209     (insert-file-contents filename)
    210     (if parse-xml-p
    211         (libxml-parse-xml-region (point-min) (point-max))
    212       (buffer-string))))
    213 
    214 (defun nov-mimetype-valid-p (directory)
    215   "Return t if DIRECTORY contains a valid EPUB mimetype file."
    216   (nov-ignore-file-errors
    217     (let ((filename (nov-make-path directory "mimetype")))
    218       (equal (nov-slurp filename) "application/epub+zip"))))
    219 
    220 (defun nov-container-filename (directory)
    221   "Return the container filename for DIRECTORY."
    222   (let ((filename (nov-make-path directory "META-INF")))
    223     (nov-make-path filename "container.xml")))
    224 
    225 (defun nov-container-content-filename (content)
    226   "Return the content filename for CONTENT."
    227   (let* ((query "container>rootfiles>rootfile[media-type='application/oebps-package+xml']")
    228          (node (esxml-query query content)))
    229     (dom-attr node 'full-path)))
    230 
    231 (defun nov-container-valid-p (directory)
    232   "Return t if DIRECTORY holds a valid EPUB container."
    233   (let ((filename (nov-container-filename directory)))
    234     (when (and filename (file-exists-p filename))
    235       (let* ((content (nov-slurp filename t))
    236              (content-file (nov-container-content-filename content)))
    237         (when (and content content-file)
    238           (file-exists-p (nov-make-path directory content-file)))))))
    239 
    240 (defun nov-epub-valid-p (directory)
    241   "Return t if DIRECTORY makes up a valid EPUB document."
    242   (when (not (nov-mimetype-valid-p directory))
    243     (message "Invalid mimetype"))
    244   (nov-container-valid-p directory))
    245 
    246 (defun nov-urldecode (string)
    247   "Return urldecoded version of STRING or nil."
    248   (when string
    249     (url-unhex-string string)))
    250 
    251 (defun nov-content-version (content)
    252   "Return the EPUB version for CONTENT."
    253   (let* ((node (esxml-query "package" content))
    254          (version (dom-attr node 'version)))
    255     (when (not version)
    256       (error "Version not specified"))
    257     version))
    258 
    259 (defun nov-content-unique-identifier-name (content)
    260   "Return the unique identifier name referenced in CONTENT.
    261 This is used in `nov-content-unique-identifier' to retrieve the
    262 the specific type of unique identifier."
    263   (let* ((node (esxml-query "package[unique-identifier]" content))
    264          (name (dom-attr node 'unique-identifier)))
    265     (when (not name)
    266       (error "Unique identifier name not specified"))
    267     name))
    268 
    269 (defun nov-content-unique-identifier (content)
    270   "Return the the unique identifier for CONTENT."
    271   (let* ((name (nov-content-unique-identifier-name content))
    272          (selector (format "package>metadata>identifier[id='%s']"
    273                            (esxml-query-css-escape name)))
    274          (id (car (dom-children (esxml-query selector content)))))
    275     (when (not id)
    276       (error "Unique identifier not found by its name: %s" name))
    277     (intern id)))
    278 
    279 ;; NOTE: unique identifier is queried separately as identifiers can
    280 ;; appear more than once and only one of them can be the unique one
    281 (defvar nov-required-metadata-tags '(title language)
    282   "Required metadata tags used for `nov-content-metadata'.")
    283 
    284 (defvar nov-optional-metadata-tags
    285   '(contributor coverage creator date description format
    286     publisher relation rights source subject type)
    287   "Optional metadata tags used for 'nov-content-metadata'.")
    288 
    289 (defun nov-content-metadata (content)
    290   "Return a metadata alist for CONTENT.
    291 Required keys are 'identifier and everything in
    292 `nov-required-metadata-tags', optional keys are in
    293 `nov-optional-metadata-tags'."
    294   (let* ((identifier (nov-content-unique-identifier content))
    295          (candidates (mapcar (lambda (node)
    296                                (cons (dom-tag node) (car (dom-children node))))
    297                              (esxml-query-all "package>metadata>*" content)))
    298          (required (mapcar (lambda (tag)
    299                              (let ((candidate (cdr (assq tag candidates))))
    300                                (when (not candidate)
    301                                  ;; NOTE: this should ideally be a
    302                                  ;; warning, but `warn' is too obtrusive
    303                                  (message "Required metadatum %s not found" tag))
    304                                (cons tag candidate)))
    305                            nov-required-metadata-tags))
    306          (optional (mapcar (lambda (tag) (cons tag (cdr (assq tag candidates))))
    307                            nov-optional-metadata-tags)))
    308     (append `((identifier . ,identifier)) required optional)))
    309 
    310 (defun nov-content-manifest (directory content)
    311   "Extract an alist of manifest files for CONTENT in DIRECTORY.
    312 Each alist item consists of the identifier and full path."
    313   (mapcar (lambda (node)
    314             (let ((id (dom-attr node 'id))
    315                   (href (dom-attr node 'href)))
    316               (cons (intern id)
    317                     (nov-make-path directory (nov-urldecode href)))))
    318           (esxml-query-all "package>manifest>item" content)))
    319 
    320 (defun nov-content-spine (content)
    321   "Extract a list of spine identifiers for CONTENT."
    322   (mapcar (lambda (node) (intern (dom-attr node 'idref)))
    323           (esxml-query-all "package>spine>itemref" content)))
    324 
    325 (defun nov--content-epub2-files (content manifest files)
    326   (let* ((node (esxml-query "package>spine[toc]" content))
    327          (id (dom-attr node 'toc)))
    328     (when (not id)
    329       (error "EPUB 2 NCX ID not found"))
    330     (setq nov-toc-id (intern id))
    331     (let ((toc-file (assq nov-toc-id manifest)))
    332       (when (not toc-file)
    333         (error "EPUB 2 NCX file not found"))
    334       (cons toc-file files))))
    335 
    336 (defun nov--content-epub3-files (content manifest files)
    337   (let* ((node (esxml-query "package>manifest>item[properties~=nav]" content))
    338          (id (dom-attr node 'id)))
    339     (when (not id)
    340       (error "EPUB 3 <nav> ID not found"))
    341     (setq nov-toc-id (intern id))
    342     (let ((toc-file (assq nov-toc-id manifest)))
    343       (when (not toc-file)
    344         (error "EPUB 3 <nav> file not found"))
    345       (setq files (seq-remove (lambda (item) (eq (car item) nov-toc-id)) files))
    346       (cons toc-file files))))
    347 
    348 (defun nov-content-files (directory content)
    349   "Create correctly ordered file alist for CONTENT in DIRECTORY.
    350 Each alist item consists of the identifier and full path."
    351   (let* ((manifest (nov-content-manifest directory content))
    352          (spine (nov-content-spine content))
    353          (files (mapcar (lambda (item) (assq item manifest)) spine)))
    354     (if (version< nov-epub-version "3.0")
    355         (nov--content-epub2-files content manifest files)
    356       (nov--content-epub3-files content manifest files))))
    357 
    358 (defun nov--walk-ncx-node (node)
    359   (let ((tag (dom-tag node))
    360         (children (seq-filter (lambda (child) (eq (dom-tag child) 'navPoint))
    361                               (dom-children node))))
    362     (cond
    363      ((eq tag 'navMap)
    364       (insert "<ol>\n")
    365       (mapc (lambda (node) (nov--walk-ncx-node node)) children)
    366       (insert "</ol>\n"))
    367      ((eq tag 'navPoint)
    368       (let* ((label-node (esxml-query "navLabel>text" node))
    369              (content-node (esxml-query "content" node))
    370              (href (nov-urldecode (dom-attr content-node 'src)))
    371              (label (car (dom-children label-node))))
    372         (when (not href)
    373           (error "Navigation point is missing href attribute"))
    374         (let ((link (format "<a href=\"%s\">%s</a>"
    375                             (xml-escape-string href)
    376                             (xml-escape-string (or label href)))))
    377           (if children
    378               (progn
    379                 (insert (format "<li>\n%s\n<ol>\n" link))
    380                 (mapc (lambda (node) (nov--walk-ncx-node node))
    381                       children)
    382                 (insert (format "</ol>\n</li>\n")))
    383             (insert (format "<li>\n%s\n</li>\n" link)))))))))
    384 
    385 (defun nov-ncx-to-html (path)
    386   "Convert NCX document at PATH to HTML."
    387   (let ((root (esxml-query "navMap" (nov-slurp path t))))
    388     (with-temp-buffer
    389       (nov--walk-ncx-node root)
    390       (buffer-string))))
    391 
    392 
    393 ;;; UI
    394 
    395 (defvar nov-mode-map
    396   (let ((map (make-sparse-keymap)))
    397     (define-key map (kbd "g") 'nov-render-document)
    398     (define-key map (kbd "v") 'nov-view-source)
    399     (define-key map (kbd "V") 'nov-view-content-source)
    400     (define-key map (kbd "a") 'nov-reopen-as-archive)
    401     (define-key map (kbd "m") 'nov-display-metadata)
    402     (define-key map (kbd "n") 'nov-next-document)
    403     (define-key map (kbd "]") 'nov-next-document)
    404     (define-key map (kbd "p") 'nov-previous-document)
    405     (define-key map (kbd "[") 'nov-previous-document)
    406     (define-key map (kbd "t") 'nov-goto-toc)
    407     (define-key map (kbd "l") 'nov-history-back)
    408     (define-key map (kbd "r") 'nov-history-forward)
    409     (define-key map (kbd "TAB") 'shr-next-link)
    410     (define-key map (kbd "M-TAB") 'shr-previous-link)
    411     (define-key map (kbd "<backtab>") 'shr-previous-link)
    412     (define-key map (kbd "SPC") 'nov-scroll-up)
    413     (define-key map (kbd "S-SPC") 'nov-scroll-down)
    414     (define-key map (kbd "DEL") 'nov-scroll-down)
    415     (define-key map (kbd "<home>") 'beginning-of-buffer)
    416     (define-key map (kbd "<end>") 'end-of-buffer)
    417     map))
    418 
    419 (defvar nov-button-map
    420   (let ((map (copy-keymap nov-mode-map)))
    421     (set-keymap-parent map shr-map)
    422     (define-key map (kbd "RET") 'nov-browse-url)
    423     (define-key map (kbd "<mouse-2>") 'nov-browse-url)
    424     (define-key map (kbd "c") 'nov-copy-url)
    425     map))
    426 
    427 (easy-menu-define nov-mode-menu nov-mode-map "Menu for nov-mode"
    428   '("EPUB"
    429     ["Next" nov-next-document
    430      :help "Go to the next document"]
    431     ["Previous" nov-previous-document
    432      :help "Go to the previous document"]
    433     ["Backward" nov-history-back
    434      :help "Go back in the history to the last visited document"]
    435     ["Forward" nov-history-forward
    436      :help "Go forward in the history of visited documents"]
    437     ["Next Link" shr-next-link
    438      :help "Go to the next link"]
    439     ["Previous Link" shr-previous-link
    440      :keys "M-TAB"
    441      :help "Go to the previous link"]
    442     ["Table of Contents" nov-goto-toc
    443      :help "Display the table of contents"]
    444     ["Redisplay" nov-render-document
    445      :help "Redisplay the document"]
    446     "---"
    447     ["View Metadata" nov-display-metadata
    448      :help "View the metadata of the EPUB document"]
    449     ["View HTML Source" nov-view-source
    450      :help "View the HTML source of the current document in a new buffer"]
    451     ["View OPF Source" nov-view-content-source
    452      :help "View the OPF source of the EPUB document in a new buffer"]
    453     ["View as Archive" nov-reopen-as-archive
    454      :help "Reopen the EPUB document as an archive"]))
    455 
    456 (defun nov-clean-up ()
    457   "Delete temporary files of the current EPUB buffer."
    458   (when nov-temp-dir
    459     (let ((identifier (cdr (assq 'identifier nov-metadata)))
    460           (index (if (integerp nov-documents-index)
    461                      nov-documents-index
    462                    0)))
    463       (nov-save-place identifier index (point)))
    464     (nov-ignore-file-errors
    465      (delete-directory nov-temp-dir t))))
    466 
    467 (defun nov-clean-up-all ()
    468   "Delete temporary files of all opened EPUB buffers."
    469   (dolist (buffer (buffer-list))
    470     (with-current-buffer buffer
    471       (when (eq major-mode 'nov-mode)
    472         (nov-clean-up)))))
    473 
    474 (defun nov-external-url-p (url)
    475   "Return t if URL refers to an external document."
    476   (and (url-type (url-generic-parse-url url)) t))
    477 
    478 (defun nov-url-filename-and-target (url)
    479   "Return a list of URL's filename and target."
    480   (setq url (url-generic-parse-url url))
    481   (mapcar 'nov-urldecode (list (url-filename url) (url-target url))))
    482 
    483 ;; adapted from `shr-rescale-image'
    484 (defun nov-insert-image (path alt)
    485   "Insert an image for PATH at point, falling back to ALT.
    486 This function honors `shr-max-image-proportion' if possible."
    487   (let ((type (if (or (and (fboundp 'image-transforms-p) (image-transforms-p))
    488                       (not (fboundp 'imagemagick-types)))
    489                   nil
    490                 'imagemagick)))
    491     (if (not (display-graphic-p))
    492         (insert alt)
    493       (seq-let (x1 y1 x2 y2) (window-inside-pixel-edges
    494                               (get-buffer-window (current-buffer)))
    495         (let ((image
    496                ;; `create-image' errors out for unsupported image types
    497                (ignore-errors
    498                  (create-image path type nil
    499                                :ascent 100
    500                                :max-width (truncate (* shr-max-image-proportion
    501                                                        (- x2 x1)))
    502                                :max-height (truncate (* shr-max-image-proportion
    503                                                         (- y2 y1)))))))
    504           (if image
    505               (insert-image image)
    506             (insert alt)))))))
    507 
    508 (defvar nov-original-shr-tag-img-function
    509   (symbol-function 'shr-tag-img))
    510 
    511 (defun nov-render-img (dom &optional url)
    512   "Custom <img> rendering function for DOM.
    513 Uses `shr-tag-img' for external paths and `nov-insert-image' for
    514 internal ones."
    515   (let ((url (or url (cdr (assq 'src (cadr dom)))))
    516         (alt (or (cdr (assq 'alt (cadr dom))) "")))
    517     (if (nov-external-url-p url)
    518         ;; HACK: avoid hanging in an infinite loop when using
    519         ;; `cl-letf' to override `shr-tag-img' with a function that
    520         ;; might call `shr-tag-img' again
    521         (funcall nov-original-shr-tag-img-function dom url)
    522       (setq url (expand-file-name (nov-urldecode url)))
    523       (nov-insert-image url alt))))
    524 
    525 (defun nov-render-title (dom)
    526   "Custom <title> rendering function for DOM.
    527 Sets `header-line-format' to a combination of the EPUB title and
    528 chapter title."
    529   (let ((title (cdr (assq 'title nov-metadata)))
    530         (chapter-title (car (dom-children dom))))
    531     (when (not chapter-title)
    532       (setq chapter-title '(:propertize "No title" face italic)))
    533     ;; this shouldn't happen for properly authored EPUBs
    534     (when (not title)
    535       (setq title '(:propertize "No title" face italic)))
    536     (setq header-line-format (list title ": " chapter-title))))
    537 
    538 (defvar nov-shr-rendering-functions
    539   '(;; default function uses url-retrieve and fails on local images
    540     (img . nov-render-img)
    541     ;; titles are rendered *inside* the document by default
    542     (title . nov-render-title))
    543   "Alist of rendering functions used with `shr-render-region'.")
    544 
    545 (defun nov-render-html ()
    546   "Render HTML in current buffer with shr."
    547   (run-hooks 'nov-pre-html-render-hook)
    548   (let (;; HACK: make buttons use our own commands
    549         (shr-map nov-button-map)
    550         (shr-external-rendering-functions nov-shr-rendering-functions)
    551         (shr-use-fonts nov-variable-pitch))
    552     ;; HACK: `shr-external-rendering-functions' doesn't cover
    553     ;; every usage of `shr-tag-img'
    554     (cl-letf (((symbol-function 'shr-tag-img) 'nov-render-img))
    555       (if (eq nov-text-width t)
    556           (cl-letf (((symbol-function 'shr-fill-line) 'ignore))
    557             (shr-render-region (point-min) (point-max)))
    558         (let ((shr-width nov-text-width))
    559           (shr-render-region (point-min) (point-max))))))
    560   (run-hooks 'nov-post-html-render-hook))
    561 
    562 (defun nov-render-document ()
    563   "Render the document referenced by `nov-documents-index'.
    564 If the document path refers to an image (as determined by
    565 `image-type-file-name-regexps'), an image is inserted, otherwise
    566 the HTML is rendered with `nov-render-html-function'."
    567   (interactive)
    568   (seq-let (id &rest path) (aref nov-documents nov-documents-index)
    569     (let (;; HACK: this should be looked up in the manifest
    570           (imagep (seq-find (lambda (item) (string-match-p (car item) path))
    571                             image-type-file-name-regexps))
    572           ;; NOTE: allows resolving image references correctly
    573           (default-directory (file-name-directory path))
    574           buffer-read-only)
    575       (erase-buffer)
    576 
    577       (cond
    578        (imagep
    579         (nov-insert-image path ""))
    580        ((and (version< nov-epub-version "3.0")
    581              (eq id nov-toc-id))
    582         (insert (nov-ncx-to-html path)))
    583        (t
    584         (insert (nov-slurp path))))
    585 
    586       (when (not imagep)
    587         (funcall nov-render-html-function))
    588       (goto-char (point-min)))))
    589 
    590 (defun nov-find-document (predicate)
    591   "Return first item in `nov-documents' PREDICATE is true for."
    592   (let ((i 0)
    593         done)
    594     (while (and (not done)
    595                 (< i (length nov-documents)))
    596       (when (funcall predicate (aref nov-documents i))
    597         (setq done t))
    598       (setq i (1+ i)))
    599     (when done
    600       (1- i))))
    601 
    602 (defun nov-goto-document (index)
    603   "Go to the document denoted by INDEX."
    604   (let ((history (cons (list nov-documents-index (point))
    605                        nov-history)))
    606     (setq nov-documents-index index)
    607     (nov-render-document)
    608     (setq nov-history history)))
    609 
    610 (defun nov-goto-toc ()
    611   "Go to the TOC index and render the TOC document."
    612   (interactive)
    613   (let ((index (nov-find-document (lambda (doc) (eq (car doc) nov-toc-id)))))
    614     (when (not index)
    615       (error "Couldn't locate TOC"))
    616     (nov-goto-document index)))
    617 
    618 (defun nov-view-source ()
    619   "View the source of the current document in a new buffer."
    620   (interactive)
    621   (find-file (cdr (aref nov-documents nov-documents-index))))
    622 
    623 (defun nov-view-content-source ()
    624   "View the source of the content file in a new buffer."
    625   (interactive)
    626   (find-file nov-content-file))
    627 
    628 (defun nov-reopen-as-archive ()
    629   "Reopen the EPUB document using `archive-mode'."
    630   (interactive)
    631   (with-current-buffer (find-file-literally nov-file-name)
    632     (archive-mode)))
    633 
    634 (defun nov-display-metadata ()
    635   "View the metadata of the EPUB document in a new buffer."
    636   (interactive)
    637   (let ((buffer "*EPUB metadata*")
    638         (metadata nov-metadata)
    639         (version nov-epub-version))
    640     (with-current-buffer (get-buffer-create buffer)
    641       (special-mode)
    642       (let (buffer-read-only)
    643         (erase-buffer)
    644         (insert (format "EPUB Version: %s\n" version))
    645         (dolist (item metadata)
    646           (seq-let (key &rest value) item
    647             (insert (format "%s: " (capitalize (symbol-name key))))
    648             (if value
    649                 (if (eq key 'description)
    650                     (let ((beg (point)))
    651                       (insert value)
    652                       (shr-render-region beg (point)))
    653                   (insert (format "%s" value)))
    654               (insert (propertize "None" 'face 'italic)))
    655             (insert "\n")))
    656         (goto-char (point-min))))
    657     (pop-to-buffer buffer)))
    658 
    659 (defun nov-next-document ()
    660   "Go to the next document and render it."
    661   (interactive)
    662   (when (< nov-documents-index (1- (length nov-documents)))
    663     (nov-goto-document (1+ nov-documents-index))))
    664 
    665 (defun nov-previous-document ()
    666   "Go to the previous document and render it."
    667   (interactive)
    668   (when (> nov-documents-index 0)
    669     (nov-goto-document (1- nov-documents-index))))
    670 
    671 (defun nov-scroll-up (arg)
    672   "Scroll with `scroll-up' or visit next chapter if at bottom."
    673   (interactive "P")
    674   (if (>= (window-end) (point-max))
    675       (nov-next-document)
    676     (scroll-up arg)))
    677 
    678 (defun nov-scroll-down (arg)
    679   "Scroll with `scroll-down' or visit previous chapter if at top."
    680   (interactive "P")
    681   (if (and (<= (window-start) (point-min))
    682            (> nov-documents-index 0))
    683       (progn
    684         (nov-previous-document)
    685         (goto-char (point-max)))
    686     (scroll-down arg)))
    687 
    688 (defun nov-visit-relative-file (filename target)
    689   "Visit the document as specified by FILENAME and TARGET."
    690   (let (index)
    691     (when (not (zerop (length filename)))
    692       (let* ((current-path (cdr (aref nov-documents nov-documents-index)))
    693              (directory (file-name-directory current-path))
    694              (path (file-truename (nov-make-path directory filename)))
    695              (match (nov-find-document
    696                      (lambda (doc) (equal path (file-truename (cdr doc)))))))
    697         (when (not match)
    698           (error "Couldn't locate document"))
    699         (setq index match)))
    700     ;; HACK: this binding is only need for Emacs 27.1 and older, as of
    701     ;; Emacs 28.1, shr.el always adds the shr-target-id property
    702     (let ((shr-target-id target))
    703       (nov-goto-document (or index nov-documents-index))))
    704   (when target
    705     (let ((pos (point-min))
    706           done)
    707       (while (and (not done)
    708                   (setq pos (next-single-property-change pos 'shr-target-id)))
    709         (let ((property (get-text-property pos 'shr-target-id)))
    710           (when (or (equal property target)
    711                     ;; NOTE: as of Emacs 28.1 this may be a list of targets
    712                     (and (consp property) (member target property)))
    713             (goto-char pos)
    714             (recenter (1- (max 1 scroll-margin)))
    715             (setq done t))))
    716       (when (not done)
    717         (error "Couldn't locate target")))))
    718 
    719 ;; adapted from `shr-browse-url'
    720 (defun nov-browse-url (&optional mouse-event)
    721   "Follow an external url with `browse-url'.
    722 Internal URLs are visited with `nov-visit-relative-file'."
    723   (interactive (list last-nonmenu-event))
    724   (mouse-set-point mouse-event)
    725   (let ((url (get-text-property (point) 'shr-url)))
    726     (when (not url)
    727       (user-error "No link under point"))
    728     (if (nov-external-url-p url)
    729         (browse-url url)
    730       (apply 'nov-visit-relative-file (nov-url-filename-and-target url)))))
    731 
    732 (defun nov-copy-url (&optional mouse-event)
    733   (interactive (list last-nonmenu-event))
    734   (mouse-set-point mouse-event)
    735   (let ((url (get-text-property (point) 'shr-url)))
    736     (when (not url)
    737       (user-error "No link under point"))
    738     (kill-new url)
    739     (message "%s" url)))
    740 
    741 (defun nov-saved-places ()
    742   "Retrieve saved places in `nov-save-place-file'."
    743   (when (and nov-save-place-file (file-exists-p nov-save-place-file))
    744     (with-temp-buffer
    745       (insert-file-contents-literally nov-save-place-file)
    746       (goto-char (point-min))
    747       (read (current-buffer)))))
    748 
    749 (defun nov-saved-place (identifier)
    750   "Retrieve saved place for IDENTIFIER in `nov-saved-place-file'."
    751   (cdr (assq identifier (nov-saved-places))))
    752 
    753 (defun nov-save-place (identifier index point)
    754   "Save place as identified by IDENTIFIER, INDEX and POINT.
    755 Saving is only done if `nov-save-place-file' is set."
    756   (when nov-save-place-file
    757     (let* ((place `(,identifier (index . ,index)
    758                                 (point . ,point)))
    759            (places (cons place (assq-delete-all identifier (nov-saved-places))))
    760            print-level
    761            print-length)
    762       (with-temp-file nov-save-place-file
    763         (insert (prin1-to-string places))))))
    764 
    765 (defun nov--index-valid-p (documents index)
    766   (and (integerp index)
    767        (>= index 0)
    768        (< index (length documents))))
    769 
    770 (defun nov-history-back ()
    771   "Go back in the history to the last visited document."
    772   (interactive)
    773   (or nov-history
    774       (user-error "This is the first document you looked at"))
    775   (let ((history-forward (cons (list nov-documents-index (point))
    776                                nov-history-forward)))
    777     (seq-let (index opoint) (car nov-history)
    778       (setq nov-history (cdr nov-history))
    779       (nov-goto-document index)
    780       (setq nov-history (cdr nov-history))
    781       (setq nov-history-forward history-forward)
    782       (goto-char opoint)
    783       (recenter (1- (max 1 scroll-margin))))))
    784 
    785 (defun nov-history-forward ()
    786   "Go forward in the history of visited documents."
    787   (interactive)
    788   (or nov-history-forward
    789       (user-error "This is the last document you looked at"))
    790   (let ((history-forward (cdr nov-history-forward)))
    791     (seq-let (index opoint) (car nov-history-forward)
    792       (nov-goto-document index)
    793       (setq nov-history-forward history-forward)
    794       (goto-char opoint)
    795       (recenter (1- (max 1 scroll-margin))))))
    796 
    797 ;;;###autoload
    798 (define-derived-mode nov-mode special-mode "EPUB"
    799   "Major mode for reading EPUB documents"
    800   (add-hook 'kill-buffer-hook 'nov-clean-up nil t)
    801   (add-hook 'kill-emacs-hook 'nov-clean-up-all)
    802   (add-hook 'change-major-mode-hook 'nov-clean-up nil t)
    803   (when (not buffer-file-name)
    804     (error "EPUB must be associated with file"))
    805   (when (not nov-unzip-program)
    806     (error "unzip executable not found, customize `nov-unzip-program'"))
    807   (setq nov-temp-dir (make-temp-file "nov-" t ".epub"))
    808   (let ((exit-code (nov-unzip-epub nov-temp-dir buffer-file-name)))
    809     (when (not (integerp exit-code))
    810       (nov-clean-up)
    811       (error "EPUB extraction aborted by signal %s" exit-code))
    812     (when (> exit-code 1) ; exit code 1 is most likely a warning
    813       (nov-clean-up)
    814       (error "EPUB extraction failed with exit code %d (see *nov unzip* buffer)"
    815              exit-code)))
    816   (when (not (nov-epub-valid-p nov-temp-dir))
    817     (nov-clean-up)
    818     (error "Invalid EPUB file"))
    819   (let* ((content (nov-slurp (nov-container-filename nov-temp-dir) t))
    820          (content-file-name (nov-container-content-filename content))
    821          (content-file (nov-make-path nov-temp-dir content-file-name))
    822          (work-dir (file-name-directory content-file))
    823          (content (nov-slurp content-file t)))
    824     (setq nov-content-file content-file)
    825     (setq nov-epub-version (nov-content-version content))
    826     (setq nov-metadata (nov-content-metadata content))
    827     (setq nov-documents (apply 'vector (nov-content-files work-dir content)))
    828     (setq nov-documents-index 0))
    829   (setq buffer-undo-list t)
    830   (setq nov-file-name (buffer-file-name))
    831   (setq-local bookmark-make-record-function
    832               'nov-bookmark-make-record)
    833   (set-visited-file-name nil t) ; disable autosaves and save questions
    834   (let ((place (nov-saved-place (cdr (assq 'identifier nov-metadata)))))
    835     (if place
    836         (let ((index (cdr (assq 'index place)))
    837               (point (cdr (assq 'point place))))
    838           (if (nov--index-valid-p nov-documents index)
    839               (progn
    840                 (setq nov-documents-index index)
    841                 (nov-render-document)
    842                 (goto-char point))
    843             (warn "Couldn't restore last position")
    844             (nov-render-document)))
    845       (nov-render-document))))
    846 
    847 
    848 ;;; recentf interop
    849 
    850 (defun nov-add-to-recentf ()
    851   "Add real path to recentf list if possible."
    852   (when nov-file-name
    853     (recentf-add-file nov-file-name)))
    854 
    855 (add-hook 'nov-mode-hook 'nov-add-to-recentf)
    856 (add-hook 'nov-mode-hook 'hack-dir-local-variables-non-file-buffer)
    857 
    858 
    859 (defun nov--find-file (file index point)
    860   "Open FILE in nov-mode and go to the specified INDEX and POSITION.
    861 If FILE is nil, the current buffer is used."
    862   (when file
    863     (find-file file))
    864   (unless (eq major-mode 'nov-mode)
    865     (nov-mode))
    866   (when (not (nov--index-valid-p nov-documents index))
    867     (error "Invalid documents index"))
    868   (setq nov-documents-index index)
    869   (nov-render-document)
    870   (goto-char point))
    871 
    872 ;; Bookmark interop
    873 (defun nov-bookmark-make-record  ()
    874   "Create a bookmark epub record."
    875   (cons (buffer-name)
    876         `((filename . ,nov-file-name)
    877           (index . ,nov-documents-index)
    878           (position . ,(point))
    879           (handler . nov-bookmark-jump-handler))))
    880 
    881 ;;;###autoload
    882 (defun nov-bookmark-jump-handler (bmk)
    883   "The bookmark handler-function interface for bookmark BMK.
    884 
    885 See also `nov-bookmark-make-record'."
    886   (let ((file (bookmark-prop-get bmk 'filename))
    887         (index (bookmark-prop-get bmk 'index))
    888         (position (bookmark-prop-get bmk 'position)))
    889     (nov--find-file file index position)))
    890 
    891 
    892 ;;; Org interop
    893 
    894 (defun nov-org-link-follow (path)
    895   "Follow nov: link designated by PATH."
    896   (if (string-match "^\\(.*\\)::\\([0-9]+\\):\\([0-9]+\\)$" path)
    897       (let ((file (match-string 1 path))
    898             (index (string-to-number (match-string 2 path)))
    899             (point (string-to-number (match-string 3 path))))
    900         (nov--find-file file index point))
    901     (error "Invalid nov.el link")))
    902 
    903 (defun nov-org-link-store ()
    904   "Store current EPUB location as nov: link."
    905   (when (and (eq major-mode 'nov-mode) nov-file-name)
    906     (when (not (integerp nov-documents-index))
    907       (setq nov-documents-index 0))
    908     (let ((org-store-props-function
    909            (if (fboundp 'org-link-store-props)
    910                'org-link-store-props
    911              'org-store-link-props))
    912           (link (format "nov:%s::%d:%d"
    913                         nov-file-name
    914                         nov-documents-index
    915                         (point)))
    916           (description (format "EPUB file at %s" nov-file-name)))
    917       (funcall org-store-props-function
    918                :type "nov"
    919                :link link
    920                :description description))))
    921 
    922 (cond
    923  ((fboundp 'org-link-set-parameters)
    924   (org-link-set-parameters
    925    "nov"
    926    :follow 'nov-org-link-follow
    927    :store 'nov-org-link-store))
    928  ((fboundp 'org-add-link-type)
    929   (org-add-link-type "nov" 'nov-org-link-follow)
    930   (add-hook 'org-store-link-functions 'nov-org-link-store)))
    931 
    932 
    933 ;;; Imenu interop
    934 
    935 (defun nov-imenu-goto-function (_name filename target)
    936   "Visit imenu item using FILENAME and TARGET."
    937   (nov-visit-relative-file filename target))
    938 
    939 (defun nov-imenu-create-index ()
    940   "Generate Imenu index."
    941   (let* ((toc-path (cdr (aref nov-documents 0)))
    942          (ncxp (version< nov-epub-version "3.0"))
    943          (toc (with-temp-buffer
    944                 (if ncxp
    945                     (insert (nov-ncx-to-html toc-path))
    946                   (insert-file-contents toc-path))
    947                 (libxml-parse-html-region (point-min) (point-max)))))
    948     (mapcar
    949      (lambda (node)
    950        (let ((href (dom-attr node 'href))
    951              (label (dom-text node)))
    952          (seq-let (filename target) (nov-url-filename-and-target href)
    953            (list label filename 'nov-imenu-goto-function target))))
    954      (esxml-query-all "a" toc))))
    955 
    956 (defun nov-imenu-setup ()
    957   (setq imenu-create-index-function 'nov-imenu-create-index))
    958 (add-hook 'nov-mode-hook 'nov-imenu-setup)
    959 
    960 (provide 'nov)
    961 ;;; nov.el ends here