diff --git a/README.md b/README.md index 885ddac..8a29806 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,17 @@ ## Features -- **Open PDF Files**: Use Telescope to quickly search and open PDF files. +- **Open PDF Files**: Quickly search and open PDF files using the currently supported pickers: `telescope.nvim`, `fzf-lua`, or the built-in `vim.ui.select`. - **Extract Text**: Extract the text from a PDF using `pdftotext` for easy reading or note-taking. - **Pagination**: Navigate through the document using next/previous page commands. - **Customizable Pagination**: Set how many lines per page should be displayed. - **Virtual Text Display**: See page numbers displayed in the buffer. +- **Bookmarks**: Save your reading position with a bookmark and jump back to it anytime. + + +260709-14-22-34 + -![Preview](https://i.imgur.com/ClDZhnc.gif) --- @@ -22,8 +26,12 @@ ### Prerequisites - **Neovim** version 0.5 or higher -- **Telescope.nvim**: The plugin uses Telescope to search for PDF files. -- **pdftotext**: This plugin relies on the `pdftotext` command-line tool to extract text from PDFs. Install `pdftotext` using the following command: +- **Picker**: **Telescope.nvim** (optional), **Fzf-lua** (optional), or the **default** picker (`vim.ui.select`) +- **[Zathura](https://github.com/pwmt/zathura)**: document viewer + ```bash + sudo apt install zathura + ``` +- **pdftotext**: this plugin relies on the `pdftotext` command-line tool to extract text from PDFs. Install it using the following command: ```bash sudo apt install poppler-utils ``` @@ -35,16 +43,57 @@ To install `PDFview.nvim` using **LazyVim**, add the following configuration to ```lua { "basola21/PDFview", - lazy = false, - dependencies = { "nvim-telescope/telescope.nvim" } -} + event = "VeryLazy", + dependencies = { + "nvim-telescope/telescope.nvim" + }, + opts = { + path = "/path/to/pdf_folder", -- path pdf folder + save = "/path/to/save_folder", -- bookmark save folder + picker = "default", -- fzf-lua, telescope, default (using vim.ui.select) + open = { + cb = nil, + }, + window = { + winhighlight = nil, + }, + keymaps = { + menu = "", + go_to_page = "gf", + show_page_in_zathura = "x", + next_page = "", + prev_page = "", + bookmark = "b", + save = "s", + }, + keys = { + { + "fp", + function() + require("pdfview").select_file_pdf() + end, + desc = "Select pdf file", + }, + { + "fP", + function() + require("pdfview").menu() + end, + desc = "Open menu", + }, + }, +}, ``` ### Mappings -You can add the following key mappings to your Neovim configuration for easy navigation through the PDF pages: +The `next_page` and `prev_page` keymaps are already set up for you in the `opts.keymaps` table above. +However, if you'd rather define your own custom keys instead of using the built-in ones, you can call the underlying functions directly: ```lua +-- Open menu pdfview +map("n", "ff", ":lua require('pdfview').menu()", { desc = "PDFview: Menu" }) + -- Navigate to the next page in the PDF map("n", "jj", ":lua require('pdfview.renderer').next_page()", { desc = "PDFview: Next page" }) @@ -59,14 +108,16 @@ map("n", "kk", ":lua require('pdfview.renderer').previous_page()jj` for the next page. - - `kk` for the previous page. + - `` or `jj` for the next page. + - `` or `kk` for the previous page. 3. **Extracting Text from a PDF** When you select a PDF using Telescope, the plugin extracts the text using `pdftotext` and displays it in a buffer, allowing for easy reading or note-taking. @@ -86,14 +137,63 @@ map("n", "kk", ":lua require('pdfview.renderer').previous_page()" + end, + }, +``` + +### Support Statusline + +Example using `heirline.nvim`: + +```lua +local get_pdfview = function() + if not pdfview then + local ok, session = pcall(require, "pdfview.renderer") + if ok then + pdfview = session + end + end + return pdfview +end + + +... + +{ + ... + provider = function() + if vim.bo.filetype == "pdfview" then + if not pdfview then + pdfview = get_pdfview() + end + + local pdf_render = pdfview.get() + if pdf_render and pdf_render.pdf_path then + return vim.fn.fnamemodify(pdf_render.pdf_path, ":~") .. " (Page " .. pdf_render.current_page .. ")" + end + end + end, +} + +``` --- ## Planned Features - **Improved Navigation**: Refine the pagination and scrolling behavior for a smoother reading experience. -- **Customization**: Add options for setting default configurations like `lines_per_page` and buffer options. - **Document Search**: Implement a search feature to find specific text within the PDF. - **Improved Structure**: Enhance the project structure for better maintainability and scalability. diff --git a/TODO.org b/TODO.org new file mode 100644 index 0000000..0872061 --- /dev/null +++ b/TODO.org @@ -0,0 +1,11 @@ +* TODO + - [x] define bookmark save path + - [x] implement bookmark menu + - [x] implement bookmark saving + - [x] implement last bookmark + + - [X] implement text search + - [ ] implement snacks picker + - [ ] implement toggle case_sensitive + - [ ] implement popup search status + - [ ] update readme diff --git a/lua/pdfview/config.lua b/lua/pdfview/config.lua new file mode 100644 index 0000000..4abd246 --- /dev/null +++ b/lua/pdfview/config.lua @@ -0,0 +1,51 @@ +local M = {} + +---@type PDFviewCfg +M.defaults = { + path = vim.fn.stdpath "config", + save = vim.fn.stdpath "state", + picker = "default", + open = { + cb = nil, + }, + window = { + winhighlight = nil, + text_search_indicator = { + sep_front = "", + sep_back = "", + hl = "WarningMsg", + attr = "fg", + icon = " ", + }, + }, + keymaps = { + menu = "", + go_to_page = "gf", + show_page_in_zathura = "x", + next_page = "", + prev_page = "", + open_bookmark = "b", + save_bookmark = "s", + search = "", + pick_search = "s", + next_search_text = "", + prev_search_text = "", + }, +} + +---@param cfg_tbl PDFviewCfg +---@param opts PDFviewCfg +local function merge_settings(cfg_tbl, opts) + opts = opts or {} + local def = vim.tbl_deep_extend("force", cfg_tbl, opts) + return def +end + +---@param opts PDFviewCfg +function M.update_settings(opts) + opts = opts or {} + + M.defaults = merge_settings(M.defaults, opts) +end + +return M diff --git a/lua/pdfview/init.lua b/lua/pdfview/init.lua index 5264233..296a11f 100644 --- a/lua/pdfview/init.lua +++ b/lua/pdfview/init.lua @@ -1,55 +1,260 @@ -local parser = require("pdfview.parser") -local renderer = require("pdfview.renderer") -local telescope = require("telescope.builtin") -local previewers = require("telescope.previewers") -local actions = require("telescope.actions") -local action_state = require("telescope.actions.state") +local Config = require "pdfview.config" +local Util = require "pdfview.utils" + +local parser = require "pdfview.parser" +local renderer = require "pdfview.renderer" +local search = require "pdfview.search" +local picker = require "pdfview.pickers" local M = {} +---@param opts PDFviewCfg +function M.setup(opts) + Config.update_settings(opts) + + Config.defaults.group = "PDFview" + Config.defaults.save = Config.defaults.save .. "/pdfview.lua" + + Util.clear_autocmd_group(Config.defaults.group) + local group = vim.api.nvim_create_augroup(Config.defaults.group, { clear = true }) + + local keymaps = require "pdfview.keymaps" + keymaps.setup_filetype_mappings(group, renderer.get()) +end + +---@param pdf_path string +local function ensure_callback(pdf_path) + if Config.defaults.open and Config.defaults.open.cb then + if type(Config.defaults.open.cb) ~= "function" then + return + end + Config.defaults.open.cb(pdf_path) + end +end + +function M.select_file_pdf() + local path = Config.defaults.path + if not Util.is_dir(path) then + return + end + + picker.select(Config.defaults.picker or "default", "files", path, function(pdf_path) + if not Util.is_file(pdf_path) then + Util.warn("PDF path doesn't exist: `" .. pdf_path .. "`.") + return + end + + Config.defaults.pdf_path = pdf_path + M.open(pdf_path) + ensure_callback(pdf_path) + end) +end + +function M.select_bookmark() + local file_saved = Config.defaults.save + if not Util.is_file(file_saved) then + Util.warn "Bookmark save file not found. Please create one first." + return + end + + picker.select(Config.defaults.picker or "default", "bookmark", file_saved, function(opts) + Config.defaults.pdf_path = opts.pdf_path + M.open(opts.pdf_path, opts) + ensure_callback(opts.pdf_path) + end) +end + +function M.select_text_search() + local path = Config.defaults.path + if not Util.is_dir(path) then + return + end + + picker.select(Config.defaults.picker or "default", "search", path, nil) +end + +function M.open_from_last_bookmark() + local pdf_bookmarks = Util.get_pdf_bookmarks() + if not pdf_bookmarks then + return + end + + local opts = pdf_bookmarks[1] + Config.defaults.pdf_path = opts.pdf_path + M.open(opts.pdf_path, opts) + ensure_callback(opts.pdf_path) +end + +function M.history() + Util.not_implemented_yet() +end + +function M.menu() + local ui = require "pdfview.ui" + ui.call("menu", Config.defaults) +end + +---@param page_num number|nil +---@param state? PDFviewStateRender +---@param notify? boolean +function M.go_to(page_num, state, notify) + state = state or renderer.get() + notify = notify or false + + if page_num then + state.current_page = page_num + renderer.display_current_page() + if notify then + Util.info(string.format("Go to page: %d", state.current_page)) + end + return + end + + vim.ui.input({ + prompt = "Go to page: ", + }, function(input) + if not input then + return + end + + local num = tonumber(input) + if not num then + Util.warn("go_to", "not a number `" .. input .. "`") + return + end + + state.current_page = num + + if notify then + Util.info(string.format("Go to page: %d", state.current_page)) + end + renderer.display_current_page() + end) +end + +---@param page_num number|nil +---@param state? PDFviewStateRender +function M.open_in_zathura(page_num, state) + state = state or renderer.get() + + if not page_num then + page_num = state.current_page + end + + if not Util.is_file(state.pdf_path) or state.pdf_path == "" then + return + end + + local zathura_cmd = { "zathura", "-P", tostring(page_num), state.pdf_path } + Util.system_command(zathura_cmd) +end + +local last_open_pdf + -- Function to open the full PDF text (runs when PDF is selected) -function M.open(pdf_path) - local text = parser.extract_text(pdf_path) - if text then - renderer.display_text(text) - end +---@param pdf_path string +---@param opts? table +function M.open(pdf_path, opts) + opts = opts or {} + + if not opts.pdf_path then + opts.pdf_path = pdf_path + end + + if not opts.last_page then + opts.last_page = 1 + end + + if last_open_pdf and last_open_pdf == opts.pdf_path and vim.bo.filetype == "pdfview" then + M.go_to(opts.last_page) + return + end + + last_open_pdf = opts.pdf_path + + local text = parser.extract_text(opts.pdf_path) + if text then + renderer.display_text(text, opts.last_page) + local pdffile = vim.fn.fnamemodify(opts.pdf_path, ":~") + Util.info("Loaded PDF: `" .. pdffile .. "`") + end +end + +---@param pdf_path string +---@param query string +---@param state? PDFviewStateRender +local function search_to(pdf_path, query, state) + state = state or renderer.get() + + local matches + if state.search and state.search.cache and state.search.cache[query] then + matches = state.search.cache[query] + else + matches = search.find_matches(pdf_path, query, state) + end + + if #matches == 0 then + Util.warn("no matches for '" .. query .. "'") + return + end + + if not state.win then + state.win = vim.api.nvim_get_current_win() + end + + state.search = state.search or { cache = {} } + state.search.cache[query] = matches + state.search.current_query = query + state.ns_search_id = vim.api.nvim_create_namespace "pdfview-search" + + Util.info(string.format("Found `%d %s` for query `%s`", #matches, (#matches == 1) and "match" or "matches", query)) + + -- debug: test open item matches in quickfix.. + -- local qf_items = {} + -- for _, m in ipairs(matches) do + -- table.insert(qf_items, { + -- filename = m.filename, + -- lnum = m.page, + -- col = m.col, + -- text = m.text_line, + -- page = m.line, + -- }) + -- end + -- vim.fn.setqflist({}, " ", { title = "PDFview: " .. query, items = qf_items }) + -- vim.cmd "copen" +end + +---@param pdf_path? string +---@param query? string +function M.text_search(pdf_path, query) + if pdf_path and query then + search_to(pdf_path, query) + end + + vim.ui.input({ + prompt = "Text Search: ", + }, function(q) + if not q then + return + end + + local state = renderer.get() + if state and state.pdf_path then + search_to(state.pdf_path, q) + end + end) end -- Function to extract and display the first page (used for preview) +---@param pdf_path string +---@return string function M.preview_first_page(pdf_path) - local first_page_text = parser.extract_text(pdf_path, 1, 1) - if first_page_text then - return first_page_text - else - return "Could not extract text from the first page of the PDF." - end -end - --- Custom previewer for Telescope to show the first page -local pdf_previewer = previewers.new_buffer_previewer({ - define_preview = function(self, entry) - local pdf_path = entry.path - local preview_text = M.preview_first_page(pdf_path) - vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, vim.split(preview_text, "\n")) - end, -}) - --- Telescope function with preview and open functionality -function M.telescope_open() - telescope.find_files({ - prompt_title = "Select PDF", - find_command = { "find", ".", "-type", "f", "-name", "*.pdf" }, - previewer = pdf_previewer, - attach_mappings = function(_, map) - map("i", "", function(prompt_bufnr) - local selected_file = action_state.get_selected_entry(prompt_bufnr) - actions.close(prompt_bufnr) - local pdf_path = selected_file.path - M.open(pdf_path) - end) - return true - end, - }) + local first_page_text = parser.extract_text(pdf_path, 1, 1) + if first_page_text then + return first_page_text + else + return "Could not extract text from the first page of the PDF." + end end return M diff --git a/lua/pdfview/keymaps.lua b/lua/pdfview/keymaps.lua new file mode 100644 index 0000000..e2a8f50 --- /dev/null +++ b/lua/pdfview/keymaps.lua @@ -0,0 +1,170 @@ +local Config = require "pdfview.config" +local Util = require "pdfview.utils" +local renderer = require "pdfview.renderer" + +local M = {} + +---@param state PDFviewStateRender +local function save(state) + local file_saved = Config.defaults.save + if not Util.is_file(file_saved) then + Util.create_file(file_saved) + end + + local pdf_bookmarks = Util.get_pdf_bookmarks() + if not pdf_bookmarks then + return + end + + local inserted_at = os.time() + + ---@type PDFviewBookmarkSaved + local _data = { + last_page = state.current_page, + real_page = state.current_page + (state.page_offset or 0), + total_pages = state.total_pages + (state.page_offset or 0), + pdf_path = Config.defaults.pdf_path, + created_at = inserted_at, + text_page = "Page " .. state.current_page, + text_path = vim.fn.fnamemodify(Config.defaults.pdf_path, ":~"), + } + + pdf_bookmarks[#pdf_bookmarks + 1] = _data + + table.sort(pdf_bookmarks, function(a, b) + return (a.created_at or 0) > (b.created_at or 0) + end) + + Util.save_table_to_file(pdf_bookmarks, file_saved) + Util.info("bookmark", string.format("Saved: %s · %s", _data.text_page, _data.text_path)) +end + +local idx = 1 + +---@param state PDFviewStateRender +---@param step integer +---@param bufnr integer +local function search(state, step, bufnr) + if not state.search or not state.search.cache or not state.search.current_query then + return + end + + local items = state.search.cache[state.search.current_query] + + local total_items = #items + idx = ((idx - 1 + step) % total_items) + 1 + local item = items[idx] + + require("pdfview").go_to(item.page, state) + Util.__add_buf_highlight(item, state, idx, total_items) + + --- Attach a keybinding to delete search indicators. + if state.win_status_search_indicator and vim.api.nvim_win_is_valid(state.win_status_search_indicator) then + vim.keymap.set("n", "", function() + if state.win_status_search_indicator and vim.api.nvim_win_is_valid(state.win_status_search_indicator) then + vim.api.nvim_win_close(state.win_status_search_indicator, true) + pcall(vim.keymap.del, "n", "", { buffer = bufnr }) + end + end) + vim.keymap.set("n", "q", function() + if state.win_status_search_indicator and vim.api.nvim_win_is_valid(state.win_status_search_indicator) then + vim.api.nvim_win_close(state.win_status_search_indicator, true) + pcall(vim.keymap.del, "n", "q", { buffer = bufnr }) + end + end) + end +end + +---@param state PDFviewStateRender +---@param bufnr integer +local function next_search_text(state, bufnr) + search(state, 1, bufnr) +end + +---@param state PDFviewStateRender +---@param bufnr integer +local function prev_search_text(state, bufnr) + search(state, -1, bufnr) +end + +---@param ctx vim.api.keyset.create_autocmd.callback_args +---@param state PDFviewStateRender +local function setup_pdfview_ft_mappings(ctx, state) + local pdfview = require "pdfview" + local keymaps = Config.defaults.keymaps + local bufnr = ctx.buf + + -- stylua: ignore + ---@type PDFviewKeySpec[] + local _keys = { + { key = keymaps.go_to_page, fun = function() pdfview.go_to(nil, state, true) end, desc = "go to page", buf = bufnr }, + { key = keymaps.show_page_in_zathura, fun = function() pdfview.open_in_zathura(nil, state) end, desc = "show page in Zathura", buf = bufnr }, + { key = keymaps.next_page, fun = renderer.next_page, desc = "next page", buf = bufnr }, + { key = keymaps.prev_page, fun = renderer.previous_page, desc = "previous page", buf = bufnr }, + { key = keymaps.open_bookmark, fun = function() pdfview.select_bookmark() end, desc = "select bookmarks", buf = bufnr }, + { key = keymaps.save_bookmark, fun = function() save(state) end, desc = "save bookmark", buf = bufnr }, + { key = keymaps.menu, fun = function() pdfview.menu() end, desc = "open menu", buf = bufnr }, + + { key = keymaps.search, fun = function() pdfview.search_text() end, desc = "search text", buf = bufnr }, + { key = keymaps.pick_search, fun = function() pdfview.select_search() end, desc = "select search result", buf = bufnr }, + { key = keymaps.next_search_text, fun = function() next_search_text(state, bufnr) end, desc = "next search result", buf = bufnr }, + { key = keymaps.prev_search_text, fun = function() prev_search_text(state, bufnr) end, desc = "previous search result", buf = bufnr }, + } + + M.append_to(_keys) + + local augroup = Util.create_augroup_name("SearchCleanup_" .. state.buf) + vim.api.nvim_create_autocmd({ "BufWipeout", "BufUnload" }, { + group = augroup, + buffer = state.buf, + once = true, + callback = function() + if vim.api.nvim_buf_is_valid(bufnr) then + if state.search then + state.search = nil + end + if state.pages then + state.pages = nil + end + end + end, + }) +end + +---@param group integer +---@param state PDFviewStateRender +local function augroup(group, state, cb) + vim.api.nvim_create_autocmd("FileType", { + group = group, + pattern = state.filetype, + desc = "PDFview filetype mappings", + callback = function(ctx) + cb(ctx) + end, + }) +end + +---@param tbl_keys PDFviewKeySpec +function M.append_to(tbl_keys) + for _, k in pairs(tbl_keys) do + local keys = type(k.key) == "string" and { k.key } or k.key + for _, key in ipairs(keys) do + if type(k.fun) == "function" then + vim.keymap.set("n", key, k.fun, { + desc = k.desc, + buffer = k.buf, + }) + end + end + end +end + +---@param group integer +---@param state PDFviewStateRender +function M.setup_filetype_mappings(group, state) + augroup(group, state, function(ctx) + setup_pdfview_ft_mappings(ctx, state) + end) +end + +return M diff --git a/lua/pdfview/parser.lua b/lua/pdfview/parser.lua index 7fe69a5..2d3f0fc 100644 --- a/lua/pdfview/parser.lua +++ b/lua/pdfview/parser.lua @@ -1,40 +1,41 @@ +local Util = require "pdfview.utils" local M = {} -- Check if the PDF file exists -local function file_exists(pdf_path) - return vim.fn.filereadable(pdf_path) ~= 0 -end +-- local function file_exists(pdf_path) +-- return vim.fn.filereadable(pdf_path) ~= 0 +-- end -- Build the shell command for extracting text local function build_command(pdf_path, start_page, end_page) - local escaped_pdf_path = vim.fn.shellescape(pdf_path) - local page_args = "" - if start_page and end_page then - page_args = string.format("-f %d -l %d", start_page, end_page) - end - return string.format("pdftotext -layout %s %s -", page_args, escaped_pdf_path) + local escaped_pdf_path = vim.fn.shellescape(pdf_path) + local page_args = "" + if start_page and end_page then + page_args = string.format("-f %d -l %d", start_page, end_page) + end + return string.format("pdftotext -layout %s %s -", page_args, escaped_pdf_path) end -- Run the shell command and return the output local function run_command(cmd) - local result = vim.fn.system(cmd) - if result and #result > 0 then - return result - else - vim.api.nvim_err_writeln("PDFview: Failed to extract text from PDF.") - return nil - end + local result = vim.fn.system(cmd) + if result and #result > 0 then + return result + else + Util.error("parser", "Failed to extract text from PDF.") + return nil + end end -- Main function to extract text from the PDF with optional page range function M.extract_text(pdf_path, start_page, end_page) - if not file_exists(pdf_path) then - vim.api.nvim_err_writeln("PDFview: File does not exist: " .. pdf_path) - return nil - end + if not Util.is_file(pdf_path) then + Util.error("parser", "File does not exist: " .. pdf_path) + return nil + end - local cmd = build_command(pdf_path, start_page, end_page) - return run_command(cmd) + local cmd = build_command(pdf_path, start_page, end_page) + return run_command(cmd) end return M diff --git a/lua/pdfview/pickers/default.lua b/lua/pdfview/pickers/default.lua new file mode 100644 index 0000000..e7c2172 --- /dev/null +++ b/lua/pdfview/pickers/default.lua @@ -0,0 +1,128 @@ +local Util = require "pdfview.utils" +local UtilPicker = require "pdfview.pickers.utils" + +local M = {} + +local function list_files(path) + local cmd = UtilPicker.find_command(path) + local files = vim.fn.systemlist(cmd) + return files +end + +---@param path string +---@param cb function +function M.files(path, cb) + local files = list_files(path) + vim.ui.select(files, { prompt = Util.format_title "pdf files" }, function(file) + if not file then + return + end + + local pdf_path = vim.fs.normalize(file) + cb(pdf_path) + end) +end + +---@param path string +---@param cb function +function M.bookmark(path, cb) + local pdf_bookmarks = Util.get_pdf_bookmarks() + if not pdf_bookmarks then + return + end + + local contents = UtilPicker.bookmark_contents(pdf_bookmarks) + if Util.is_blank(contents) then + return + end + + vim.ui.select(contents, { prompt = Util.format_title "bookmarks" }, function(selection) + if not selection then + return + end + + local sel = vim.split(selection, "·") + + local sel_page_num = Util.strip_whitespace(sel[1]) + local sel_pdf_path = Util.strip_whitespace(sel[2]) + + for i, _pdf in pairs(pdf_bookmarks) do + if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then + return cb(pdf_bookmarks[i]) + end + end + end) +end + +function M.delete_item_bookmark() + local pdf_bookmarks = Util.get_pdf_bookmarks() + if not pdf_bookmarks then + return + end + + local contents = UtilPicker.bookmark_contents(pdf_bookmarks) + if Util.is_blank(contents) then + return + end + + vim.ui.select(contents, { prompt = Util.format_title "delete bookmarks" }, function(selection) + if not selection then + return + end + + local sel = vim.split(selection, "·") + + local sel_page_num = Util.strip_whitespace(sel[1]) + local sel_pdf_path = Util.strip_whitespace(sel[2]) + local file_saved = require("pdfview.config").defaults.save + + for i, _pdf in pairs(pdf_bookmarks) do + if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then + table.remove(pdf_bookmarks, i) + + table.sort(pdf_bookmarks, function(a, b) + return (a.created_at and a.created_at or 0) > (b.created_at and b.created_at or 0) + end) + + Util.save_table_to_file(pdf_bookmarks, file_saved) + Util.info(_pdf.text_path .. " removed.") + return + end + end + end) +end + +function M.search() + local renderer = require "pdfview.renderer" + local state = renderer.get() + + local data = UtilPicker.search_cache(state) + if not data then + Util.warn("picker", "No active search") + return + end + + local contents = data.contents + local seen = data.seen + + vim.ui.select( + contents, + { prompt = Util.format_title "" }, + function(selection) + if not selection then + return + end + + local sel = selection + local item = seen[vim.trim(sel)] + if not item then + return + end + + require("pdfview").go_to(item.page, state, true) + Util.__add_buf_highlight(item, state) + end + ) +end + +return M diff --git a/lua/pdfview/pickers/fzf-lua.lua b/lua/pdfview/pickers/fzf-lua.lua new file mode 100644 index 0000000..75c39bb --- /dev/null +++ b/lua/pdfview/pickers/fzf-lua.lua @@ -0,0 +1,209 @@ +local Util = require "pdfview.utils" +local UtilPicker = require "pdfview.pickers.utils" + +local M = {} + +local loaded = false +local FzfLua + +local silent_notify = false + +local function setup_fzflua() + if loaded then + return FzfLua + end + + local ok, _ = pcall(require, "fzf-lua") + if not ok then + if not silent_notify then + Util.error "This extension requires ibhagwan/fzf-lua (https://github.com/ibhagwan/fzf-lua)" + silent_notify = true + return + end + return + end + + FzfLua = require "fzf-lua" + loaded = true + + return FzfLua +end + +local Mapping = {} + +---@param path string +---@param cb function +function Mapping.default(path, cb) + return function(selection) + if not selection then + return + end + + local sel = selection[1] + + local pdf_path = path .. "/" .. sel + pdf_path = vim.fs.normalize(pdf_path) + cb(pdf_path) + end +end + +---@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param cb function +function Mapping.default_bookmark(pdf_bookmarks, cb) + return function(selection) + if not selection then + return + end + + local sel = vim.split(selection[1], "·") + + local sel_page_num = Util.strip_whitespace(sel[1]) + local sel_pdf_path = Util.strip_whitespace(sel[2]) + + for i, _pdf in pairs(pdf_bookmarks) do + if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then + return cb(pdf_bookmarks[i]) + end + end + end +end + +---@param pdf_bookmarks PDFviewBookmarkSaved[] +function Mapping.delete_bookmark(pdf_bookmarks) + return function(selection) + if not selection then + return + end + + local sel = vim.split(selection[1], "·") + + local sel_page_num = Util.strip_whitespace(sel[1]) + local sel_pdf_path = Util.strip_whitespace(sel[2]) + local file_saved = require("pdfview.config").defaults.save + + for i, _pdf in pairs(pdf_bookmarks) do + if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then + table.remove(pdf_bookmarks, i) + + table.sort(pdf_bookmarks, function(a, b) + return (a.created_at and a.created_at or 0) > (b.created_at and b.created_at or 0) + end) + + Util.save_table_to_file(pdf_bookmarks, file_saved) + Util.info(_pdf.text_path .. " removed.") + + -- unplanned: should resume or exit? + -- FzfLua.actions.resume() + return + end + end + end +end + +---@param state PDFviewStateRender +---@param seen table +function Mapping.search(state, seen) + return function(selection) + if not selection then + return + end + + local sel = selection[1] + if not sel then + return + end + + local item = seen[vim.trim(sel)] + if not item then + return + end + + require("pdfview").go_to(item.page, state, true) + Util.__add_buf_highlight(item, state) + end +end + +---@return boolean +function M.is_available() + return (pcall(require, "fzf-lua")) +end + +---@param path string +---@param cb function +function M.files(path, cb) + setup_fzflua() + + if not loaded then + return + end + + FzfLua.files { + cwd = path, + no_header = true, + no_header_i = true, + -- fzf_opts = { ["--header"] = [[^x:delete ^r:rename]] }, + winopts = { title = Util.format_title "pdf files", preview = { hidden = false } }, + actions = { + ["default"] = Mapping.default(path, cb), + }, + } +end + +---@param path string +---@param cb function +function M.bookmark(path, cb) + setup_fzflua() + + if not loaded then + return + end + + local pdf_bookmarks = Util.get_pdf_bookmarks() + if not pdf_bookmarks then + return + end + + local contents = UtilPicker.bookmark_contents(pdf_bookmarks) + if Util.is_blank(contents) then + return + end + + FzfLua.fzf_exec(contents, { + no_header = true, + no_header_i = true, + fzf_opts = { ["--header"] = [[ delete]] }, + winopts = { title = Util.format_title "bookmarks", preview = { hidden = true } }, + actions = { + ["default"] = Mapping.default_bookmark(pdf_bookmarks, cb), + ["ctrl-x"] = Mapping.delete_bookmark(pdf_bookmarks), + }, + }) +end + +function M.search() + setup_fzflua() + + local renderer = require "pdfview.renderer" + local state = renderer.get() + + local data = UtilPicker.search_cache(state) + if not data then + Util.warn("picker.fzf-lua", "No active search") + return + end + + local contents = data.contents + local seen = data.seen + + FzfLua.fzf_exec(contents, { + no_header = true, + no_header_i = true, + fzf_opts = { ["--header"] = [[:delete]] }, + winopts = { title = Util.format_title "", preview = { hidden = true } }, + actions = { + ["default"] = Mapping.search(state, seen), + }, + }) +end + +return M diff --git a/lua/pdfview/pickers/init.lua b/lua/pdfview/pickers/init.lua new file mode 100644 index 0000000..7b4746a --- /dev/null +++ b/lua/pdfview/pickers/init.lua @@ -0,0 +1,87 @@ +local M = {} + +local Util = require "pdfview.utils" + +-- NOTE: For maintainers: if you add a new picker interface, make sure to register it here as well. +local AUTO_PICKER_PRIORITY = { "fzf-lua", "snacks", "telescope", "default" } + +local silent_warn_notify = false +local warn_msg_picker_not_installed +local set_picker + +---@return string +local function default_picker() + for _, name in ipairs(AUTO_PICKER_PRIORITY) do + local ok, picker = pcall(require, string.format("pdfview.pickers.%s", name)) + if ok and picker.is_available and picker.is_available() then + return name + end + end + return "default" +end + +---@param picker_name string? +local function resolve_picker(picker_name) + picker_name = picker_name or "" + + if Util.is_blank(picker_name) then + picker_name = default_picker() + end + + local ok, picker = pcall(require, string.format("pdfview.pickers.%s", picker_name)) + + if not ok then + if not silent_warn_notify then + warn_msg_picker_not_installed = string.format("The picker `%s` is not installed", picker_name) + end + + set_picker = default_picker() + return resolve_picker(set_picker) + end + + if picker.is_available and not picker.is_available() then + return resolve_picker() + end + + if ok and not silent_warn_notify and warn_msg_picker_not_installed then + Util.warn( + "picker", + string.format("%s.\nFalling back to the picker `%s`.", warn_msg_picker_not_installed, picker_name) + ) + silent_warn_notify = true + end + + set_picker = picker_name + return picker +end + +local resolved_pickers = {} + +local function setup_picker(picker_name) + local cache_key = picker_name or "__auto__" + if resolved_pickers[cache_key] then + return resolved_pickers[cache_key] + end + local picker = resolve_picker(picker_name) + resolved_pickers[cache_key] = picker + return picker +end + +---@param picker_name string +---@param method string +---@param path string +---@param cb function|nil +function M.select(picker_name, method, path, cb) + local picker = setup_picker(picker_name) + if not picker or not picker[method] then + Util.warn( + "picker", + string.format("The picker '%s' does not implement the '%s' method.", picker_name or "auto", method) + ) + return + end + + picker[method](path, cb) +end + +return M diff --git a/lua/pdfview/pickers/snacks.lua b/lua/pdfview/pickers/snacks.lua new file mode 100644 index 0000000..7e1346b --- /dev/null +++ b/lua/pdfview/pickers/snacks.lua @@ -0,0 +1,198 @@ +local snacks = require "snacks" + +local Util = require "pdfview.utils" +local UtilPicker = require "pdfview.pickers.utils" + +local M = {} + +-- local function pdf_previewer(ctx) +-- local item = ctx.item +-- if not item or not item.file then +-- return +-- end +-- -- local text = Parser.extract_text(item.file, 1, 1) -- preview halaman pertama aja +-- +-- local text = require("pdfview").preview_first_page(item.file) +-- local lines = vim.split(text or "", "\n") +-- ctx.preview:set_lines(lines) +-- ctx.preview:set_title(vim.fn.fnamemodify(item.file, ":t")) +-- end + +local Mapping = {} + +---@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param cb function +function Mapping.default_bookmark(pdf_bookmarks, cb, selection) + if not selection then + return + end + + -- local sel = vim.split(selection[1], "·") + local sel = selection + + local sel_page_num = Util.strip_whitespace(sel[1]) + local sel_pdf_path = Util.strip_whitespace(sel[2]) + + for i, _pdf in pairs(pdf_bookmarks) do + if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then + return cb(pdf_bookmarks[i]) + end + end +end + +---@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param selection string +function Mapping.delete_bookmark(pdf_bookmarks, selection) + if not selection then + return + end + + local sel = vim.split(selection, "·") + + local sel_page_num = Util.strip_whitespace(sel[1]) + local sel_pdf_path = Util.strip_whitespace(sel[2]) + local file_saved = require("pdfview.config").defaults.save + + for i, _pdf in pairs(pdf_bookmarks) do + if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then + table.remove(pdf_bookmarks, i) + + table.sort(pdf_bookmarks, function(a, b) + return (a.created_at and a.created_at or 0) > (b.created_at and b.created_at or 0) + end) + + Util.save_table_to_file(pdf_bookmarks, file_saved) + Util.info(_pdf.text_path .. " removed.") + return + end + end +end + +---@param state PDFviewStateRender +---@param seen table +function Mapping.search(state, seen, selection) + if not selection then + return + end + + local sel = selection + local item = seen[vim.trim(sel)] + if not item then + return + end + + require("pdfview").go_to(item.page, state, true) + Util.__add_buf_highlight(item, state) +end + +---@return boolean +function M.is_available() + return (pcall(require, "snacks")) +end + +---@param path string +---@param cb function +function M.files(path, cb) + snacks.picker.pick { + source = "files", + cwd = path, + title = Util.format_title "pdf files", + ft = "pdf", + -- preview = pdf_previewer, -- use the default previewer from Snacks + confirm = function(picker, item) + picker:close() + if item then + cb(path .. "/" .. item.file) + end + end, + } +end + +---@param path string +---@param cb function +function M.bookmark(path, cb) + local pdf_bookmarks = Util.get_pdf_bookmarks() + if not pdf_bookmarks then + return + end + + local contents = UtilPicker.bookmark_contents(pdf_bookmarks) + if Util.is_blank(contents) then + return + end + + local items = {} + for i, line in ipairs(contents) do + table.insert(items, { idx = i, text = line }) + end + + snacks.picker.pick { + title = Util.format_title "bookmarks", + items = items, + layout = { preset = "select" }, + format = function(item) + return { { item.text } } + end, + confirm = function(picker, item) + picker:close() + if item then + Mapping.default_bookmark(pdf_bookmarks, cb, item.text) + end + end, + actions = { + delete_bookmark = function(picker, item) + if not item then + return + end + Mapping.delete_bookmark(pdf_bookmarks, item.text) + picker:close() + vim.schedule(function() + M.bookmark(path, cb) + end) + end, + }, + win = { + input = { + keys = { + [""] = { "delete_bookmark", mode = { "n", "i" } }, + }, + }, + }, + } +end + +function M.search() + local renderer = require "pdfview.renderer" + local state = renderer.get() + + local data = UtilPicker.search_cache(state) + if not data then + Util.warn("picker.fzf-lua", "No active search") + return + end + + local contents = data.contents + local seen = data.seen + + local items = {} + for i, line in ipairs(contents) do + table.insert(items, { idx = i, text = line }) + end + + snacks.picker.pick { + title = Util.format_title "", + items = items, + layout = { preset = "select" }, + format = function(item) + return { { item.text } } + end, + confirm = function(picker, item) + picker:close() + if item then + Mapping.search(state, seen, item.text) + end + end, + } +end + +return M diff --git a/lua/pdfview/pickers/telescope.lua b/lua/pdfview/pickers/telescope.lua new file mode 100644 index 0000000..df3f8ac --- /dev/null +++ b/lua/pdfview/pickers/telescope.lua @@ -0,0 +1,217 @@ +local telescope = require "telescope.builtin" +local pickers = require "telescope.pickers" +local finders = require "telescope.finders" +local conf = require("telescope.config").values +local previewers = require "telescope.previewers" +local actions = require "telescope.actions" +local action_state = require "telescope.actions.state" + +local Util = require "pdfview.utils" +local UtilPicker = require "pdfview.pickers.utils" + +local M = {} + +-- Custom previewer for Telescope to show the first page +local pdf_previewer = previewers.new_buffer_previewer { + define_preview = function(self, entry) + local pdf_path = entry.path + local preview_text = require("pdfview").preview_first_page(pdf_path) + vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, vim.split(preview_text, "\n")) + end, +} + +local Mapping = {} + +---@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param cb function +---@param entry string +function Mapping.default_bookmark(pdf_bookmarks, cb, entry) + if not entry then + return + end + + local sel = vim.split(entry, "·") + + local sel_page_num = Util.strip_whitespace(sel[1]) + local sel_pdf_path = Util.strip_whitespace(sel[2]) + + for i, _pdf in pairs(pdf_bookmarks) do + if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then + return cb(pdf_bookmarks[i]) + end + end +end + +---@param pdf_bookmarks PDFviewBookmarkSaved[] +---@param entry string +function Mapping.delete_bookmark_entry(pdf_bookmarks, entry) + if not entry then + return + end + local sel = vim.split(entry, "·") + + local sel_page_num = Util.strip_whitespace(sel[1]) + local sel_pdf_path = Util.strip_whitespace(sel[2]) + local file_saved = require("pdfview.config").defaults.save + + for i, _pdf in pairs(pdf_bookmarks) do + if _pdf.text_page == sel_page_num and _pdf.text_path == sel_pdf_path then + table.remove(pdf_bookmarks, i) + + Util.save_table_to_file(pdf_bookmarks, file_saved) + Util.info(_pdf.text_path .. " removed.") + return + end + end +end + +---@param state PDFviewStateRender +---@param seen table +---@param entry string +function Mapping.search(state, seen, entry) + if not entry then + return + end + + local item = seen[vim.trim(entry)] + if not item then + return + end + + require("pdfview").go_to(item.page, state, true) + Util.__add_buf_highlight(item, state) +end + +---@return boolean +function M.is_available() + return (pcall(require, "telescope")) +end + +-- Telescope function with preview and open functionality +---@param path string +---@param cb function +function M.files(path, cb) + telescope.find_files { + prompt_title = Util.format_title "pdf files", + find_command = UtilPicker.find_command(path), + previewer = pdf_previewer, + attach_mappings = function(_, map) + map("i", "", function(prompt_bufnr) + ---@diagnostic disable-next-line: redundant-parameter + local selected_file = action_state.get_selected_entry(prompt_bufnr) + actions.close(prompt_bufnr) + local pdf_path = selected_file.path + cb(pdf_path) + end) + return true + end, + } +end + +---@param path string +---@param cb function +function M.bookmark(path, cb) + local pdf_bookmarks = Util.get_pdf_bookmarks() + if not pdf_bookmarks then + return + end + + local contents = UtilPicker.bookmark_contents(pdf_bookmarks) + if Util.is_blank(contents) then + return + end + + pickers + .new({}, { + prompt_title = Util.format_title "bookmarks · [ delete]", + finder = finders.new_table { + results = contents, + entry_maker = function(entry) + return { + value = entry, + display = entry, + ordinal = entry, + } + end, + }, + sorter = conf.generic_sorter {}, + attach_mappings = function(prompt_bufnr, map) + actions.select_default:replace(function() + local entry = action_state.get_selected_entry() + actions.close(prompt_bufnr) + if entry then + Mapping.default_bookmark(pdf_bookmarks, cb, entry.value) + end + end) + + map({ "i", "n" }, "", function() + local entry = action_state.get_selected_entry() + if not entry then + return + end + Mapping.delete_bookmark_entry(pdf_bookmarks, entry.value) + + -- Refresh list + local current_picker = action_state.get_current_picker(prompt_bufnr) + local new_contents = UtilPicker.bookmark_contents(pdf_bookmarks) + current_picker:refresh( + finders.new_table { + results = new_contents, + entry_maker = function(e) + return { value = e, display = e, ordinal = e } + end, + }, + { reset_prompt = false } + ) + end) + + return true + end, + }) + :find() +end + +function M.search() + local renderer = require "pdfview.renderer" + local state = renderer.get() + + local data = UtilPicker.search_cache(state) + if not data then + Util.warn("picker.telescope", "No active search") + return + end + + local contents = data.contents + local seen = data.seen + + pickers + .new({}, { + prompt_title = Util.format_title "", + results_title = string.format("%d %s found", #contents, #contents == 1 and "result" or "results"), + finder = finders.new_table { + results = contents, + entry_maker = function(entry) + return { + value = entry, + display = entry, + ordinal = entry, + } + end, + }, + sorter = conf.generic_sorter {}, + attach_mappings = function(prompt_bufnr) + actions.select_default:replace(function() + local entry = action_state.get_selected_entry() + actions.close(prompt_bufnr) + if entry then + Mapping.search(state, seen, entry.value) + end + end) + + return true + end, + }) + :find() +end + +return M diff --git a/lua/pdfview/pickers/utils.lua b/lua/pdfview/pickers/utils.lua new file mode 100644 index 0000000..95fd4ce --- /dev/null +++ b/lua/pdfview/pickers/utils.lua @@ -0,0 +1,63 @@ +local Util = require "pdfview.utils" + +local M = {} + +---@param pdf_bookmarks PDFviewBookmarkSaved[] +local padding = function(pdf_bookmarks) + local _pad = 0 + for _, x in ipairs(pdf_bookmarks) do + if _pad < #x.text_page then + _pad = #x.text_page + end + end + return _pad +end + +---@param path string +---@return string[] +function M.find_command(path) + return { "find", path or ".", "-type", "f", "-name", "*.pdf" } +end + +---@param pdf_bookmarks PDFviewBookmarkSaved[] +---@return string[] +function M.bookmark_contents(pdf_bookmarks) + if not pdf_bookmarks then + return {} + end + if Util.is_blank(pdf_bookmarks) then + Util.warn "No saved PDF bookmarks found. Please create one first." + return {} + end + + local _pad = padding(pdf_bookmarks) + + local contents = {} + for _, _pdf in pairs(pdf_bookmarks) do + contents[#contents + 1] = string.format("%-" .. _pad .. "s · %s", _pdf.text_page, _pdf.text_path) + end + return contents +end + +---@param state PDFviewStateRender +---@return {contents: table, seen: table}|nil +function M.search_cache(state) + if not state.search or not state.search.cache or not state.search.current_query then + return nil + end + + local items = state.search.cache[state.search.current_query] + local contents = {} + local seen = {} + for _, x in pairs(items) do + contents[#contents + 1] = x.text_line + seen[x.text_line] = x + end + + return { + contents = contents, + seen = seen, + } +end + +return M diff --git a/lua/pdfview/renderer.lua b/lua/pdfview/renderer.lua index ce5b47b..6d1a745 100644 --- a/lua/pdfview/renderer.lua +++ b/lua/pdfview/renderer.lua @@ -1,15 +1,28 @@ +local Config = require "pdfview.config" +local Util = require "pdfview.utils" + local M = {} -- State to keep track of pages +---@type PDFviewStateRender local state = { current_page = 1, total_pages = 0, + pdf_path = "", + filetype = "pdfview", + page_offset = 0, + buf = nil, + ns_id = nil, + ns_search_id = nil, pages = {}, } -- Function to display the current page function M.display_current_page() local buf = state.buf + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return + end -- Set buffer to modifiable before making changes vim.api.nvim_set_option_value("modifiable", true, { buf = buf }) @@ -31,10 +44,19 @@ end -- Function to update page information function M.update_page_info() + if Config.defaults.pdf_path then + state.pdf_path = Config.defaults.pdf_path + end + local buf = state.buf - local page_info = string.format("Page %d/%d", state.current_page, state.total_pages) + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return + end + + local real_page = state.current_page + (state.page_offset or 0) + local real_total = state.total_pages + (state.page_offset or 0) + local page_info = string.format("Page %d/%d", real_page, real_total) - -- Set virtual text at the end of the buffer vim.api.nvim_buf_clear_namespace(buf, state.ns_id, 0, -1) vim.api.nvim_buf_set_extmark(buf, state.ns_id, 0, 0, { virt_text = { { page_info, "Comment" } }, @@ -43,46 +65,53 @@ function M.update_page_info() end -- Function to split text into pages -function M.paginate_text(text, lines_per_page) - local lines = vim.split(text, "\n") +function M.paginate_text(text) + text = text:gsub("\f%s*$", "") + + local raw_pages = vim.split(text, "\f", { plain = true }) local pages = {} - for i = 1, #lines, lines_per_page do - local page = vim.list_slice(lines, i, i + lines_per_page - 1) - table.insert(pages, page) + for _, page_text in ipairs(raw_pages) do + table.insert(pages, vim.split(page_text, "\n")) end return pages end -- Function to initialize the buffer and display the first page -function M.display_text(text) - -- Default lines per page - local lines_per_page = 50 -- You can make this configurable +function M.display_text(text, start_page, pdf_path) + if Config.defaults.pdf_path then + state.pdf_path = pdf_path + end - -- Split text into pages - state.pages = M.paginate_text(text, lines_per_page) + state.pages = M.paginate_text(text) state.total_pages = #state.pages - state.current_page = 1 - -- Create a new buffer or reuse existing one + if start_page then + state.current_page = start_page + else + state.current_page = 1 + end + + state.page_offset = (start_page or 1) - 1 + if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then state.buf = vim.api.nvim_create_buf(false, true) end + local buf = state.buf - -- Set buffer-local options using nvim_set_option_value vim.api.nvim_set_option_value("buftype", "nofile", { buf = buf }) vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = buf }) vim.api.nvim_set_option_value("swapfile", false, { buf = buf }) vim.api.nvim_set_option_value("modifiable", false, { buf = buf }) - vim.api.nvim_set_option_value("filetype", "pdfview", { buf = buf }) + vim.api.nvim_set_option_value("filetype", state.filetype, { buf = buf }) - state.ns_id = vim.api.nvim_create_namespace("PDFview") + state.ns_id = vim.api.nvim_create_namespace "PDFview" - -- Display the current page M.display_current_page() - -- Open a new window with the buffer - vim.api.nvim_set_current_buf(buf) + if buf and vim.api.nvim_buf_is_valid(buf) then + vim.api.nvim_set_current_buf(state.buf) + end end -- Function to go to the next page @@ -91,7 +120,7 @@ function M.next_page() state.current_page = state.current_page + 1 M.display_current_page() else - vim.api.nvim_echo({ { "PDFview: Already at the last page.", "WarningMsg" } }, false, {}) + Util.warn("renderer", "Already at the last page.") end end @@ -101,8 +130,12 @@ function M.previous_page() state.current_page = state.current_page - 1 M.display_current_page() else - vim.api.nvim_echo({ { "PDFview: Already at the first page.", "WarningMsg" } }, false, {}) + Util.warn("renderer", "Already at the firts page.") end end +function M.get() + return state +end + return M diff --git a/lua/pdfview/search.lua b/lua/pdfview/search.lua new file mode 100644 index 0000000..4d521e6 --- /dev/null +++ b/lua/pdfview/search.lua @@ -0,0 +1,46 @@ +local Util = require "pdfview.utils" + +local parser = require "pdfview.parser" +local renderer = require "pdfview.renderer" + +local M = {} + +---@param pdf_path string +---@param query string +---@param state? PDFviewStateRender +---@param opts? {case_sensitive: boolean, pages:table} +---@return PDFviewMatch[] +function M.find_matches(pdf_path, query, state, opts) + opts = opts or {} + state = state or renderer.get() + + local pages = state.pages + if Util.is_blank(pages) then + local full_text = parser.extract_text(pdf_path) + if not full_text then + return {} + end + pages = renderer.paginate_text(full_text) + end + + local matches = {} + local pattern = opts.case_sensitive and query or query:lower() + + for page_num, lines in ipairs(pages) do + for line_num, line in ipairs(lines) do + local haystack = opts.case_sensitive and line or line:lower() + local col = haystack:find(pattern, 1, true) + if col then + table.insert(matches, { + page = page_num, + line = line_num, + col = col, + text = vim.trim(line), + }) + end + end + end + return matches +end + +return M diff --git a/lua/pdfview/types.lua b/lua/pdfview/types.lua new file mode 100644 index 0000000..4e6878b --- /dev/null +++ b/lua/pdfview/types.lua @@ -0,0 +1,67 @@ +---@class PDFviewKeySpec +---@field key string|string[] +---@field fun string|function +---@field desc string +---@field buf? integer + +---@class PDFviewKeymaps +---@field go_to_page string +---@field show_page_in_zathura string +---@field next_page string +---@field prev_page string +---@field open_bookmark string +---@field menu string +---@field save_bookmark string +---@field search string +---@field pick_search string +---@field next_search_text string +---@field prev_search_text string + +---@class PDFviewPopup +---@field winhighlight string|nil +---@field text_search_indicator {sep_front: string|nil, sep_back: string|nil, hl: string|nil, attr: string|nil, icon: string|nil} + +---@class PDFviewBookmarkSaved +---@field last_page integer +---@field real_page integer +---@field pdf_path string +---@field created_at number +---@field text_path string +---@field text_page string + +---@class PDFviewCfg +---@field path string +---@field save string +---@field picker string +---@field window PDFviewPopup +---@field pdf_path string|nil +---@field keymaps PDFviewKeymaps +---@field open? {cb?:function} +---@field ui? {cb?:function, menu?: table} +---@field group? string + +---@class PDFviewMatch +---@field page integer +---@field line integer +---@field col integer +---@field text string +---@field text_line string +---@field filename string + +---@class PDFviewMatchQuery +---@field current_query string +---@field cache table + +---@class PDFviewStateRender +---@field current_page integer +---@field total_pages integer +---@field pdf_path string +---@field filetype string +---@field pages table +---@field buf integer|nil +---@field ns_id integer|nil +---@field ns_search_id integer|nil +---@field page_offset integer +---@field win? integer +---@field win_status_search_indicator? integer +---@field search? PDFviewMatchQuery diff --git a/lua/pdfview/ui.lua b/lua/pdfview/ui.lua new file mode 100644 index 0000000..ea85906 --- /dev/null +++ b/lua/pdfview/ui.lua @@ -0,0 +1,305 @@ +local Util = require "pdfview.utils" + +local M = {} + +local _ui = { + menu_filetype = "pdfview_menu", + ns = "PDFviewU", +} + +---@alias WinCfg { buf: integer, enter: boolean, wincfg: vim.api.keyset.win_config } +---@alias WinSizeCfg { row: integer, col: integer, height: integer, width: integer, title: string, title_pos: string, buf?: integer} + +---@return {width: integer, height: integer} +local function get_editor_size() + local ui = vim.api.nvim_list_uis()[1] + return { + width = ui.width, + height = ui.height, + } +end + +---@param win_opts WinCfg +---@param lines? table +---@return integer, integer +local function new_open(win_opts, lines) + lines = lines or {} + + local opts = win_opts + + if not opts.buf then + opts.buf = vim.api.nvim_create_buf(false, true) + end + + vim.api.nvim_buf_set_lines(opts.buf, 0, -1, false, lines) + + local win = vim.api.nvim_open_win(opts.buf, opts.enter, opts.wincfg) + return opts.buf, win +end + +local view = {} + +---@param opts {buf: integer, win:integer, hval: table} +function view.setup_ui_mappings(opts) + local keymaps = require "pdfview.keymaps" + local pdfview = require "pdfview" + + ---@type PDFviewKeySpec[] + local keys = {} + + keys = { + -- Enter + { + key = "", + fun = function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + local cur_line = vim.api.nvim_win_get_cursor(0)[1] + Util.close_win { win = { opts.win }, buf = { opts.buf } } + + vim.schedule(function() + if opts.hval[cur_line].method == "delete_item_bookmark" then + require("pdfview.pickers.default").delete_item_bookmark() + elseif pdfview[opts.hval[cur_line].method] then + pdfview[opts.hval[cur_line].method]() + end + end) + end, + desc = "select item", + buf = opts.buf, + }, + -- Quit + { + key = { "q", "", "" }, + fun = function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + Util.close_win { win = { opts.win }, buf = { opts.buf } } + end, + desc = "quit", + buf = opts.buf, + }, + + -- Navigate + { + key = { "k", "", "" }, + fun = function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("k", true, false, true), "n", true) + end, + desc = "quit", + buf = opts.buf, + }, + { + key = { "j", "", "" }, + fun = function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("j", true, false, true), "n", true) + end, + desc = "quit", + buf = opts.buf, + }, + } + + for _, h in pairs(opts.hval) do + keys[#keys + 1] = { + key = h.shortcut, + fun = function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "n", true) + Util.close_win { win = { opts.win }, buf = { opts.buf } } + vim.schedule(function() + if h.method == "delete_item_bookmark" then + require("pdfview.pickers.default").delete_item_bookmark() + elseif pdfview[h.method] then + pdfview[h.method]() + end + end) + end, + desc = "select item", + buf = opts.buf, + } + end + + keymaps.append_to(keys) +end + +---@param buf integer +---@param lines string[] +---@param padding_line integer +local function apply_higlights(buf, lines, padding_line) + local ns = vim.api.nvim_create_namespace(_ui.ns) + Util.del_namespace(buf, ns) + + for lnum, line in ipairs(lines) do + if not line or line == "" then + goto continue + end + + local row = lnum - 1 + + local _, bullet_end = line:find("●", 1, true) + if not bullet_end then + goto continue + end + + local item_field_width = padding_line + local item_start = bullet_end + 2 + local item_end = item_start + item_field_width + local shortcut_start = item_end + 1 + + -- Highlight name item + Util.set_extmark(buf, ns, row, item_start, { + end_col = item_end, + hl_group = "Function", + priority = 8, + }) + + -- Highlight shortcut + Util.set_extmark(buf, ns, row, shortcut_start, { + end_col = #line, + hl_group = "Boolean", + priority = 8, + }) + + ::continue:: + end +end + +---@param cfg PDFviewCfg +function view.menu(cfg) + local win_buf = vim.api.nvim_create_buf(false, true) + + local lines = { + "Select file pdf", + "Select bookmark", + } + + if vim.bo.filetype ~= "pdfview" then + lines[#lines + 1] = "Open from last bookmark" + lines[#lines + 1] = "Open last" + end + + if vim.bo.filetype == "pdfview" then + lines[#lines + 1] = "Open in zathura" + lines[#lines + 1] = "Go to" + lines[#lines + 1] = "Text search" + lines[#lines + 1] = "Select text search" + + if cfg.picker and cfg.picker == "default" then + lines[#lines + 1] = "Delete item bookmark" + end + end + + ---@type table + local hval = {} + local display_lines = {} + + local seen = {} + local resolve_shortcut = function(item) + for i = 1, #item do + local shortcut = item:sub(i, i) + shortcut = shortcut:lower() + if not seen[shortcut] then + seen[shortcut] = true + return shortcut + end + end + end + + local padding_line = 0 + for _, str_line in pairs(lines) do + local str_w = vim.fn.strdisplaywidth(str_line) + if padding_line < str_w then + padding_line = str_w + 2 + end + end + + table.sort(lines, function(a, b) + return a:lower() < b:lower() + end) + + local padding_display_lines = 0 + for i, item in ipairs(lines) do + local shortcut = resolve_shortcut(item) + local text_line = string.format(" %s %-" .. padding_line .. "s %s", "●", item, shortcut) + table.insert(display_lines, text_line) + hval[i] = { + idx = i, + item = item, + shortcut = shortcut, + method = item:gsub(" ", "_"):lower(), + } + local len_text_line = vim.fn.strdisplaywidth(text_line) + if padding_display_lines < len_text_line then + padding_display_lines = len_text_line + end + end + + local editor_size = get_editor_size() + local width = math.floor(editor_size.width * 20 / 100) + width = math.min(width, padding_display_lines + 4) + local height = #display_lines + 1 + + ---@param height_editor integer + ---@param width_editor integer + local function get_center_col_row(height_editor, width_editor) + local row = math.ceil((editor_size.height - height_editor) / 2) - 5 + local col = math.ceil((editor_size.width - width_editor) / 2) + return col, row + end + + local col, row = get_center_col_row(height, width) + + ---@type WinCfg + local wincfg = { + buf = win_buf, + enter = true, + wincfg = { + relative = "editor", + width = width, + height = height, + row = row, + col = col, + style = "minimal", + border = "rounded", + title = Util.format_title "menu", + title_pos = "center", + footer = " / quit ", + footer_pos = "center", + }, + } + local main_buf, main_win = new_open(wincfg, display_lines) + + vim.bo[main_buf].filetype = _ui.menu_filetype + cfg.ui.menu = hval + + vim.api.nvim_set_option_value("cursorline", true, { win = main_win, scope = "local" }) + vim.api.nvim_set_option_value( + "winhighlight", + cfg.window.winhighlight and cfg.window.winhighlight + or "Normal:Error," + .. "NormalFloat:NormalFloat," + .. "FloatBorder:FloatBorder," + .. "FloatTitle:FloatTitle," + .. "FloatFooter:FloatBorder,", + { win = main_win, scope = "local" } + ) + + vim.api.nvim_set_option_value("modifiable", false, { buf = main_buf }) + vim.api.nvim_set_option_value("readonly", true, { buf = main_buf }) + vim.api.nvim_set_option_value("buftype", "nofile", { buf = main_buf }) + + apply_higlights(main_buf, display_lines, padding_line) + + local opts = { buf = main_buf, win = main_win, hval = hval } + view.setup_ui_mappings(opts) +end + +---@param cfg PDFviewCfg +function M.call(call_name, cfg) + if not view[call_name] then + return + end + cfg.ui = _ui + view[call_name](cfg) +end + +return M diff --git a/lua/pdfview/utils.lua b/lua/pdfview/utils.lua index 79b1b88..2343ddb 100644 --- a/lua/pdfview/utils.lua +++ b/lua/pdfview/utils.lua @@ -1,3 +1,5 @@ +local Plenary_path = require "plenary.path" + local M = {} function M.is_iterm2() @@ -8,4 +10,395 @@ function M.is_kitty() return vim.env.TERM == "xterm-kitty" end +---@param module_or_message string +---@param message? string +local function notify(hl, module_or_message, message) + local module + + if message == nil then + message = module_or_message + else + module = module_or_message + end + + local prefix = module and ("PDFview." .. module) or "PDFview" + + vim.api.nvim_echo({ + { ("(%s) "):format(prefix), hl }, + { message }, + }, true, {}) +end + +---@param module_or_message string +---@param message? string +function M.info(module_or_message, message) + notify("Directory", module_or_message, message) +end + +---@param module_or_message string +---@param message? string +function M.warn(module_or_message, message) + notify("WarningMsg", module_or_message, message) +end + +---@param module_or_message string +---@param message? string +function M.error(module_or_message, message) + notify("ErrorMsg", module_or_message, message) +end + +---@param msg? string +function M.not_implemented_yet(msg) + if msg == nil then + msg = "" + end + if #msg > 0 then + msg = "Not impelemented, -> " .. msg + else + msg = "Not impelemented yet" + end + M.warn(msg) +end + +---@return boolean +function M.is_blank(s) + return ( + s == nil + or s == vim.NIL + or (type(s) == "string" and string.match(s, "%S") == nil) + or (type(s) == "table" and next(s) == nil) + ) +end + +---@param filename string +---@return boolean | string +function M.exists(filename) + local stat + if filename then + stat = vim.loop.fs_stat(filename) + end + + return stat and stat.type or false +end + +---@param filename string +---@return boolean +function M.is_dir(filename) + return M.exists(filename) == "directory" +end + +---@return boolean +function M.is_file(filename) + return M.exists(filename) == "file" +end + +function M.create_file(path) + local p = Plenary_path.new(path) + if not p:exists() then + p:touch() + end +end + +function M.create_dir(path) + local p = Plenary_path.new(path) + if not p:exists() then + p:mkdir() + end +end + +function M.system_command(cmds) + vim.system(cmds, { detach = true }, function(res) + if res.code ~= 0 then + vim.schedule(function() + ---@diagnostic disable-next-line: undefined-field + M.error("utils", "failed to open Zathura:" .. (res.stderr or "")) + end) + end + end) +end + +---@param name string +---@param opts? {group: string} +function M.create_augroup_name(name, opts) + opts = opts or { group = "PDFview" } + return vim.api.nvim_create_augroup(opts.group .. name, { clear = true }) +end + +---@param augroup_name string +function M.clear_autocmd_group(augroup_name) + pcall(vim.api.nvim_clear_autocmds, { group = augroup_name }) + pcall(vim.api.nvim_del_augroup_by_name, augroup_name) +end + +---@param title string +---@param prefix? string +function M.format_title(title, prefix) + return " " .. (prefix or "PDFview:") .. " " .. title .. " " +end + +local function delete_bufnr(buf) + vim.api.nvim_buf_delete(buf, { force = true }) +end + +---@param opts {buf?: integer|integer[], win?: integer|integer[]} +function M.close_win(opts) + opts = opts or {} + + ---@param val integer|integer[]|nil + ---@return integer[] + local function to_list(val) + if val == nil then + return {} + elseif type(val) == "table" then + return val + else + return { val } + end + end + + for _, w in ipairs(to_list(opts.win)) do + if vim.api.nvim_win_is_valid(w) then + vim.api.nvim_win_close(w, true) + end + end + + for _, b in ipairs(to_list(opts.buf)) do + if vim.api.nvim_buf_is_valid(b) then + delete_bufnr(b) + end + end +end + +---@param contents PDFviewBookmarkSaved[] +---@param filename string +function M.save_table_to_file(contents, filename) + local file = io.open(filename, "w") + if file then + file:write "return " + file:write(tostring(vim.inspect(contents))) + file:close() + else + M.warn("utils", "Failed to save data table to file") + end +end + +---@param str string +---@return string +local rstrip_whitespace = function(str) + str = string.gsub(str, "%s+$", "") + return str +end + +---@param str string +---@param limit? string|nil +---@return string +local lstrip_whitespace = function(str, limit) + if limit ~= nil then + local num_found = 0 + while num_found < limit do + str = string.gsub(str, "^%s", "") + num_found = num_found + 1 + end + else + str = string.gsub(str, "^%s+", "") + end + return str +end + +---@param str string +---@return string +function M.strip_whitespace(str) + if str then + return rstrip_whitespace(lstrip_whitespace(str)) + end + return "" +end + +local get_hl = function(group) + return vim.api.nvim_get_hl(0, { name = group }) +end + +---@return PDFviewBookmarkSaved[]|nil +function M.get_pdf_bookmarks() + local Config = require "pdfview.config" + local file_saved = Config.defaults.save + if not M.is_file(file_saved) then + M.create_file(file_saved) + end + return dofile(file_saved) or {} +end + +---@param opts {buffer_line: integer, target_line: integer, total_text_search: integer, idx_text_search: integer} +---@param state PDFviewStateRender +local function __add_text_search_indicator(opts, state) + local cfg = require("pdfview.config").defaults + + -- stylua: ignore + local sepFront = (cfg.window.text_search_indicator and cfg.window.text_search_indicator.sep_front) and cfg.window.text_search_indicator.sep_front or "" + -- stylua: ignore + local sepBack = (cfg.window.text_search_indicator and cfg.window.text_search_indicator.sep_back) and cfg.window.text_search_indicator.sep_back or "" + local hl_fg_group = (cfg.window.text_search_indicator and cfg.window.text_search_indicator.hl) or "WarningMsg" + local hl_fg_attr = (cfg.window.text_search_indicator and cfg.window.text_search_indicator.attr) or "fg" + -- stylua: ignore + local icon = (cfg.window.text_search_indicator and cfg.window.text_search_indicator.icon) and cfg.window.text_search_indicator.icon or " " + local text = string.format( + "%d/%d %s found | query: %s", + opts.idx_text_search or 0, + opts.total_text_search or 0, + (opts.total_text_search == 1 or opts.total_text_search == 0) and "result" or "results", + state.search.current_query + ) + + local hl_bg = get_hl(hl_fg_group)[hl_fg_attr] + local hl_fg = get_hl("Normal")["bg"] + + vim.api.nvim_set_hl(0, "PDFviewStatusSearchSep", { fg = hl_bg, bg = hl_fg }) + vim.api.nvim_set_hl(0, "PDFviewStatusSearchIcon", { fg = hl_fg, bg = hl_bg }) + vim.api.nvim_set_hl(0, "PDFviewStatusSearchNormal", { fg = hl_fg, bg = hl_bg, bold = true }) + + local segments = { + { text = sepFront, hl = "PDFviewStatusSearchSep" }, + { text = icon, hl = "PDFviewStatusSearchIcon" }, + { text = " " .. text, hl = nil }, + { text = sepBack, hl = "PDFviewStatusSearchSep" }, + } + + local full_line = "" + for _, seg in ipairs(segments) do + full_line = full_line .. seg.text + end + + -- Close before respawn the indicator + if state.win_status_search_indicator and vim.api.nvim_win_is_valid(state.win_status_search_indicator) then + vim.api.nvim_win_close(state.win_status_search_indicator, true) + end + + -- no need this? + -- M.del_namespace(state.buf, state.ns_search_id) + -- state.ns_search_id = vim.api.nvim_create_namespace "pdfview_status_search_indicator" + + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { full_line }) + + local ns = vim.api.nvim_create_namespace "pdfview_status_search_indicator" + + local current_col = 0 + for _, seg in ipairs(segments) do + if seg.hl then + M.set_extmark(buf, ns, 0, current_col, { + end_row = 0, + end_col = current_col + #seg.text, + hl_group = seg.hl, + }) + end + current_col = current_col + #seg.text + end + + local winnr = vim.api.nvim_get_current_win() + + local height = 1 + local width = vim.fn.strdisplaywidth(full_line) + + local win_width = vim.api.nvim_win_get_width(winnr) + + local padding = 2 + + local row = padding + local col = math.floor((win_width - width) / 2) + + local win = vim.api.nvim_open_win(buf, false, { + relative = "win", + win = winnr, + row = row, + col = col, + width = width, + height = height, + style = "minimal", + focusable = false, + zindex = 8, + }) + + vim.api.nvim_set_option_value("modifiable", false, { buf = state.buf }) + vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = state.buf }) + + local winhl = "Normal:PDFviewStatusSearchNormal,FloatBorder:Normal," + vim.api.nvim_set_option_value("winhighlight", winhl, { win = win, scope = "local" }) + + state.win_status_search_indicator = win +end + +---@param item PDFviewMatch +---@param state PDFviewStateRender +---@param idx_text_search? integer +---@param total_search_text? integer +function M.__add_buf_highlight(item, state, idx_text_search, total_search_text) + vim.schedule(function() + if state.win and vim.api.nvim_win_is_valid(state.win) then + vim.api.nvim_set_current_win(state.win) + + local bufline_count = vim.api.nvim_buf_line_count(state.buf) + local target_line = math.min(item.line, bufline_count) + local target_col = math.max((item.col or 1) - 1, 0) + + vim.api.nvim_win_set_cursor(state.win, { target_line, target_col }) + + local line_text = vim.api.nvim_buf_get_lines(state.buf, target_line - 1, target_line, false)[1] or "" + local end_col = #line_text + + if idx_text_search and total_search_text then + local __opts = { + buffer_line = bufline_count, + target_line = target_line, + idx_text_search = idx_text_search, + total_text_search = total_search_text, + } + __add_text_search_indicator(__opts, state) + end + + M.del_namespace(state.buf, state.ns_search_id) + local mark_id = M.set_extmark(state.buf, state.ns_search_id, target_line - 1, target_col, { + end_row = target_line - 1, + end_col = end_col, + hl_group = "Search", + }) + + if mark_id then + vim.defer_fn(function() + M.del_extmark(state.buf, state.ns_search_id, mark_id) + end, 2000) + end + end + end) +end + +function M.set_extmark(bufnr, namespace_name, line, col, opts) + if vim.api.nvim_buf_is_valid(bufnr) then + local ok, id = pcall(vim.api.nvim_buf_set_extmark, bufnr, namespace_name, line, col, opts) + + if not ok then + M.error "failed to create extmark annotation." + return nil + end + + return id + end +end + +---@param bufnr integer +---@param ns integer +---@param id integer +function M.del_extmark(bufnr, ns, id) + if vim.api.nvim_buf_is_valid(bufnr) then + return pcall(vim.api.nvim_buf_del_extmark, bufnr, ns, id) + end +end + +---@param bufnr integer +---@param ns integer +function M.del_namespace(bufnr, ns) + if vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + end +end + return M diff --git a/selena.toml b/selena.toml new file mode 100644 index 0000000..161f9f0 --- /dev/null +++ b/selena.toml @@ -0,0 +1,6 @@ +std = "lua51+vim" + +[rules] +global_usage = "allow" +multiple_statements = "allow" +incorrect_standard_library_use = "allow" diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..4a648e5 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,6 @@ +indent_type = "Spaces" +quote_style = "AutoPreferDouble" +no_call_parentheses = true +column_width = 120 +indent_width = 2 +syntax = "Lua52"