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