;;; calibredb-epub-config --- Functionality for Ebook Management and Display -*- lexical-binding: t; coding: utf-8; -*- ;; author Craig Jennings ;;; Commentary: ;; ;; Layer: 4 (Optional). ;; Category: O/D/P. ;; Load shape: eager. ;; Eager reason: none; optional ebook workflow, a command-loaded deferral ;; candidate for Phase 4. ;; Top-level side effects: one add-hook, one advice-add, package config. ;; Runtime requires: user-constants, subr-x. ;; Direct test load: yes. ;; ;; This module provides a comprehensive ebook management and reading experience ;; within Emacs, integrating CalibreDB for library management and Nov for EPUB ;; reading. ;; ;; FEATURES: ;; - CalibreDB integration for managing your Calibre ebook library ;; - Nov mode for reading EPUB files with customized typography and layout ;; - Seamless navigation between Nov reading buffers and CalibreDB entries ;; - Image centering in EPUB documents without modifying buffer text ;; - Quick filtering and searching within your ebook library ;; ;; KEY BINDINGS: ;; - M-B: Open CalibreDB library browser ;; - In CalibreDB search mode: ;; - l: Filter by tag ;; - L: Clear all filters ;; - In Nov mode: ;; - z: Open current EPUB in external viewer (zathura) ;; - C-c C-b: Jump to CalibreDB entry for current book ;; - m: Set bookmark ;; - b: List bookmarks ;; ;; WORKFLOW: ;; 1. Press M-B to browse your Calibre library ;; 2. Use filters (l for tags, L to clear) to narrow results ;; 3. Open an EPUB to read it in Nov with optimized typography ;; 4. While reading, use C-c C-b to jump back to the book's metadata ;; 5. Use z to open in external reader when needed ;; ;; CONFIGURATION NOTES: ;; - Prefers EPUB format when available, falls back to PDF ;; - Centers images in EPUB documents using display properties ;; - Applies custom typography with larger fonts for comfortable reading ;; - Uses visual-fill-column for centered text with appropriate margins ;;; Code: (require 'user-constants) ;; for books-dir (require 'subr-x) ;; Declare functions from lazy-loaded packages (declare-function calibredb-find-create-search-buffer "calibredb" ()) (declare-function calibredb-search-keyword-filter "calibredb" (keyword)) (declare-function cj/open-file-with-command "system-utils" (command)) (declare-function nov-render-document "nov" ()) (defvar nov-text-width) ; from nov.el; set buffer-local here ;; -------------------------- CalibreDB Ebook Manager -------------------------- (defun cj/calibredb-clear-filters () "Clear active filters and show all results." (interactive) (setq calibredb-tag-filter-p nil calibredb-favorite-filter-p nil calibredb-author-filter-p nil calibredb-date-filter-p nil calibredb-format-filter-p nil calibredb-search-current-page 1) ;; empty string resets keyword filter and refreshes listing (calibredb-search-keyword-filter "")) (use-package calibredb :commands calibredb :bind ("M-S-b" . calibredb) ;; was M-B, overrides backward-word ;; use built-in filter by tag, add clear-filters (:map calibredb-search-mode-map ("l" . calibredb-filter-by-tag) ("L" . cj/calibredb-clear-filters)) :config ;; basic config (setq calibredb-root-dir books-dir) (setq calibredb-db-dir (expand-file-name "metadata.db" calibredb-root-dir)) (setq calibredb-program "/usr/bin/calibredb") (setq calibredb-preferred-format "epub") (setq calibredb-search-page-max-rows 500) ;; search window display (setq calibredb-size-show nil) (setq calibredb-order "asc") (setq calibredb-id-width 7) (setq calibredb-favorite-icon "🔖") (setq calibredb-favorite-keyword "in-progress")) ;; ------------------------------ Nov Epub Reader ------------------------------ (defvar cj/nov-margin-percent 10 "Percent of the window's natural width used as a margin on each side in epubs. 10 leaves 80% of the columns for text. Clamped to 0..25, so the text column runs from 50% (margin 25) to 100% (margin 0) of the window. Adjust it live with `cj/nov-widen-text' and `cj/nov-narrow-text'.") (defvar cj/nov-min-text-width 40 "Minimum text width in columns for Nov reading buffers.") (defvar cj/nov-margin-step 2 "Percentage points each `cj/nov-widen-text'/`cj/nov-narrow-text' press changes.") ;; Prevent magic-fallback-mode-alist from opening epub as archive-mode ;; Advise set-auto-mode to force nov-mode for .epub files before magic-fallback runs (defun cj/force-nov-mode-for-epub (orig-fun &rest args) "Force nov-mode for .epub files, bypassing archive-mode detection." (if (and buffer-file-name (string-match-p "\\.epub\\'" buffer-file-name)) (progn ;; Load nov if not already loaded (unless (featurep 'nov) (require 'nov nil t)) ;; Call nov-mode if available, otherwise fallback to default behavior (if (fboundp 'nov-mode) (nov-mode) (apply orig-fun args))) (apply orig-fun args))) (advice-add 'set-auto-mode :around #'cj/force-nov-mode-for-epub) ;; Define helper functions before use-package so they're available for hooks (defun cj/forward-paragraph-and-center () "Forward one paragraph and center the page." (interactive) (forward-paragraph) (recenter)) (defun cj/nov--text-width (total-cols) "Return the Nov text-column width for TOTAL-COLS of usable window width. `cj/nov-margin-percent' is clamped to 0..25 and taken off each side; the result is at least `cj/nov-min-text-width'." (let* ((margin-percent (max 0 (min 25 cj/nov-margin-percent))) (text-width-ratio (- 1.0 (* 2 (/ margin-percent 100.0))))) (max cj/nov-min-text-width (floor (* text-width-ratio total-cols))))) (defun cj/nov--natural-window-width (&optional window) "Return WINDOW's column count, adding back any display margins already set. WINDOW defaults to the window showing the current buffer on any frame; 80 if there is none. Adding the margins back makes the value stable under repeated layout passes -- each pass narrows the body width but not the natural width." (if-let ((win (or window (get-buffer-window (current-buffer) t)))) (let ((m (window-margins win))) (+ (window-body-width win) (or (car m) 0) (or (cdr m) 0))) 80)) (defun cj/nov--text-width-for-window (&optional window) "Return the preferred EPUB text column count for WINDOW." (cj/nov--text-width (cj/nov--natural-window-width window))) (defun cj/nov-update-layout (&optional _frame) "Size the EPUB text column for this buffer and center it in its window. `nov-text-width' is set so nov's `shr' fills the text to roughly 80% of the window, and the window's display margins are set to center that block (no `visual-fill-column' -- its margin-setting wasn't taking effect in nov-mode). When the width changes the document is re-rendered, restoring the reading position approximately. Runs from `window-configuration-change-hook' and is a command." (interactive) (when (derived-mode-p 'nov-mode) (let* ((win (get-buffer-window (current-buffer) t)) (total (cj/nov--natural-window-width win)) (width (cj/nov--text-width total))) (unless (eql nov-text-width width) (setq-local nov-text-width width) (let ((frac (when (> (point-max) (point-min)) (/ (float (- (point) (point-min))) (- (point-max) (point-min)))))) (nov-render-document) (when frac (goto-char (+ (point-min) (round (* frac (- (point-max) (point-min))))))))) (when win ;; floor: never let the margins squeeze the text area below WIDTH. (let ((margin (max 0 (/ (- total width) 2)))) (set-window-margins win margin margin)) ;; Push the fringes out to the window's edge; otherwise they sit between ;; the margin and the text and show as thin vertical lines beside it. (set-window-fringes win nil nil t))))) (defun cj/--nov-adjust-margin (delta) "Add DELTA to `cj/nov-margin-percent' (clamped 0..25), re-lay-out, and report. A positive DELTA narrows the text column; a negative DELTA widens it." (setq cj/nov-margin-percent (max 0 (min 25 (+ cj/nov-margin-percent delta)))) (cj/nov-update-layout) (message "EPUB text width: %d%% of the window (margin %d%% each side)" (- 100 (* 2 cj/nov-margin-percent)) cj/nov-margin-percent)) (defun cj/nov-widen-text () "Give the EPUB text column more of the window, up to the full width." (interactive) (cj/--nov-adjust-margin (- cj/nov-margin-step))) (defun cj/nov-narrow-text () "Give the EPUB text column less of the window, down to 50%." (interactive) (cj/--nov-adjust-margin cj/nov-margin-step)) (defun cj/nov-apply-preferences () "Apply preferences after nov-mode has launched." (interactive) ;; Use Merriweather for comfortable reading with appropriate scaling ;; Darker sepia color (#E8DCC0) is easier on the eyes than pure white (face-remap-add-relative 'variable-pitch :family "Merriweather" :height 1.0 :foreground "#E8DCC0") (face-remap-add-relative 'default :family "Merriweather" :height 180 :foreground "#E8DCC0") (face-remap-add-relative 'fixed-pitch :height 180 :foreground "#E8DCC0") ;; Enable visual-line-mode for proper text wrapping (visual-line-mode 1) ;; Set fill-column as a fallback (setq-local fill-column 100) ;; Have nov's `shr' fill the text to a column count, and `cj/nov-update-layout' ;; center it with the window's display margins. This deliberately does NOT ;; use `visual-fill-column' -- its margin-setting wasn't taking effect here. (setq-local nov-text-width (cj/nov--text-width-for-window)) (cj/nov-update-layout) ;; Keep the width and centering responsive to splits/resizes. (add-hook 'window-configuration-change-hook #'cj/nov-update-layout nil t) ;; Drop the centering margins from the window when this EPUB buffer goes away, ;; so a later buffer in the same window isn't left indented. (add-hook 'kill-buffer-hook (lambda () (when-let ((w (get-buffer-window (current-buffer)))) (set-window-margins w nil) (set-window-fringes w nil))) nil t) ;; Render once now; the window may not exist yet (so this uses the fallback ;; width), and `cj/nov-update-layout' on the first window-config change ;; re-flows at the real width. (nov-render-document)) (defun cj/nov-open-external () "Open the current EPUB with zathura." (interactive) (cj/open-file-with-command "zathura")) ;; Jump from a Nov buffer to the corresponding CalibreDB entry. (defun cj/nov--metadata-get (key) "Return a metadata value from nov-metadata trying KEY as symbol and string." (let* ((v (or (and (boundp 'nov-metadata) (or (alist-get key nov-metadata nil nil #'equal) (alist-get (if (symbolp key) (symbol-name key) key) nov-metadata nil nil #'equal))) nil))) (cond ((and (listp v) (= (length v) 1)) (car v)) ((stringp v) v) (t v)))) (defun cj/nov--file-path () "Return the current EPUB file path when in nov-mode, or nil. Falls back to nov's own `nov-file-name' (set by `nov-mode' from the visited file) so the function still resolves when `buffer-file-name' has been cleared." (when (derived-mode-p 'nov-mode) (or buffer-file-name (and (boundp 'nov-file-name) nov-file-name)))) (defun cj/nov-jump-to-calibredb () "Open CalibreDB focused on the current EPUB's book entry. Try to use the Calibre book id from the parent folder name (for example, \"Title (123)\"). Fall back to a title or author search when no id exists." (interactive) (require 'calibredb) (let* ((file (cj/nov--file-path)) (title (or (cj/nov--metadata-get 'title) (cj/nov--metadata-get "title"))) (author (or (cj/nov--metadata-get 'creator) (cj/nov--metadata-get 'author) (cj/nov--metadata-get "creator") (cj/nov--metadata-get "author"))) (id (when file (let* ((parent (file-name-nondirectory (directory-file-name (file-name-directory file))))) (when (string-match " (\\([0-9]+\\))\\'" parent) (match-string 1 parent)))))) (calibredb) (with-current-buffer (calibredb-find-create-search-buffer) (setq calibredb-search-current-page 1) (cond (id (calibredb-search-keyword-filter (format "id:%s" id)) (message "CalibreDB: focused by id:%s" id)) ((or title author) (let* ((q (string-join (delq nil (list (and title (format "title:\"%s\"" title)) (and author (format "authors:\"%s\"" author)))) " and "))) (calibredb-search-keyword-filter q) (message "CalibreDB: search %s" (if (string-empty-p q) "" q)))) (t (calibredb-search-keyword-filter "") (message "CalibreDB: no metadata; showing all")))))) (use-package nov :mode ("\\.epub\\'" . nov-mode) :hook (nov-mode . cj/nov-apply-preferences) :bind (:map nov-mode-map ("m" . bookmark-set) ("b" . bookmark-bmenu-list) ("r" . nov-render-document) ("l" . recenter-top-bottom) ("d" . sdcv-search-input) ("." . cj/forward-paragraph-and-center) ("<" . nov-history-back) (">" . nov-history-forward) ("," . backward-paragraph) ;; +/= widen the text column, -/_ narrow it (50%..100% of the window) ("+" . cj/nov-widen-text) ("=" . cj/nov-widen-text) ("-" . cj/nov-narrow-text) ("_" . cj/nov-narrow-text) ;; open current EPUB with zathura (same key in pdf-view) ("z" . cj/nov-open-external) ("t" . nov-goto-toc) ("C-c C-b" . cj/nov-jump-to-calibredb))) (defun cj/--nov-image-padding-cols (col-width img-px font-width-px) "Return left-padding columns to center an IMG-PX-wide image in COL-WIDTH cols. FONT-WIDTH-PX is the column width in pixels; clamped up to 1 so a zero or negative value can't divide. When the image is at least as wide as COL-WIDTH the result is 0 -- no centering is possible." (let* ((fw (max 1 font-width-px)) (img-cols (max 1 (ceiling (/ (float img-px) fw)))) (pad (/ (- col-width img-cols) 2))) (max 0 pad))) (defun cj/nov-center-images () "Center images in the current Nov buffer without modifying text. Use `line-prefix' and `wrap-prefix' with a space display property aligned to a computed column based on the window text area width." (let ((inhibit-read-only t)) ;; Clear any prior centering prefixes first (fresh render usually makes this ;; unnecessary, but it makes the function idempotent). (remove-text-properties (point-min) (point-max) '(line-prefix nil wrap-prefix nil)) (save-excursion (goto-char (point-min)) ;; Work in the selected window showing this buffer (if any). (when-let* ((win (get-buffer-window (current-buffer) t)) (col-width (window-body-width win)) ;; columns (col-px (* col-width (window-font-width win)))) (while (let ((m (text-property-search-forward 'display nil (lambda (_ p) (and (consp p) (eq (car-safe p) 'image)))))) (when m (let* ((img (prop-match-value m)) (img-px (car (image-size img t))) ;; pixel width (pad-cols (cj/--nov-image-padding-cols col-width img-px (window-font-width win))) (prefix (propertize " " 'display `(space :align-to ,pad-cols)))) (save-excursion (goto-char (prop-match-beginning m)) (beginning-of-line) (let ((bol (point)) (eol (line-end-position))) (add-text-properties bol eol `(line-prefix ,prefix wrap-prefix ,prefix))))) t))))))) (add-hook 'nov-post-html-render-hook #'cj/nov-center-images) (provide 'calibredb-epub-config) ;;; calibredb-epub-config.el ends here