You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1035 lines
40 KiB
1035 lines
40 KiB
#!/usr/bin/env python3 |
|
# -*- coding: utf-8 -*- |
|
|
|
# Copyright (C) 2018 Andy Stewart |
|
# |
|
# Author: Andy Stewart <lazycat.manatee@gmail.com> |
|
# Maintainer: Andy Stewart <lazycat.manatee@gmail.com> |
|
# |
|
# This program is free software: you can redistribute it and/or modify |
|
# it under the terms of the GNU General Public License as published by |
|
# the Free Software Foundation, either version 3 of the License, or |
|
# any later version. |
|
# |
|
# This program is distributed in the hope that it will be useful, |
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
# GNU General Public License for more details. |
|
# |
|
# You should have received a copy of the GNU General Public License |
|
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
|
|
from PyQt5 import QtCore |
|
from PyQt5.QtCore import Qt, QRect, QEvent |
|
from PyQt5.QtGui import QColor, QPixmap, QImage, QFont, QCursor |
|
from PyQt5.QtGui import QPainter |
|
from PyQt5.QtWidgets import QWidget |
|
from core.buffer import Buffer |
|
from core.utils import touch |
|
import fitz |
|
import time |
|
import random |
|
import math |
|
import os |
|
import hashlib |
|
|
|
class AppBuffer(Buffer): |
|
def __init__(self, buffer_id, url, config_dir, arguments, emacs_var_dict): |
|
Buffer.__init__(self, buffer_id, url, arguments, emacs_var_dict, False, QColor(0, 0, 0, 255)) |
|
|
|
self.add_widget(PdfViewerWidget(url, config_dir, QColor(0, 0, 0, 255), buffer_id, emacs_var_dict)) |
|
self.buffer_widget.translate_double_click_word.connect(self.translate_text) |
|
|
|
def get_table_file(self): |
|
return self.buffer_widget.table_file_path |
|
|
|
def handle_input_message(self, result_type, result_content): |
|
if result_type == "jump_page": |
|
self.buffer_widget.jump_to_page(int(result_content)) |
|
elif result_type == "jump_percent": |
|
self.buffer_widget.jump_to_percent(int(result_content)) |
|
elif result_type == "jump_link": |
|
self.buffer_widget.jump_to_link(str(result_content)) |
|
elif result_type == "search_text": |
|
self.buffer_widget.search_text(str(result_content)) |
|
|
|
def cancel_input_message(self, result_type): |
|
if result_type == "jump_link": |
|
self.buffer_widget.cleanup_links() |
|
|
|
def scroll(self, scroll_direction, scroll_type): |
|
if scroll_type == "page": |
|
if scroll_direction == "up": |
|
self.buffer_widget.scroll_up_page() |
|
else: |
|
self.buffer_widget.scroll_down_page() |
|
else: |
|
if scroll_direction == "up": |
|
self.buffer_widget.scroll_up() |
|
else: |
|
self.buffer_widget.scroll_down() |
|
|
|
def save_session_data(self): |
|
return "{0}:{1}:{2}:{3}".format(self.buffer_widget.scroll_offset, |
|
self.buffer_widget.scale, |
|
self.buffer_widget.read_mode, |
|
self.buffer_widget.inverted_mode) |
|
|
|
def restore_session_data(self, session_data): |
|
(scroll_offset, scale, read_mode, inverted_mode) = session_data.split(":") |
|
self.buffer_widget.scroll_offset = float(scroll_offset) |
|
self.buffer_widget.scale = float(scale) |
|
self.buffer_widget.read_mode = read_mode |
|
self.buffer_widget.inverted_mode = inverted_mode == "True" |
|
self.buffer_widget.update() |
|
|
|
def scroll_up(self): |
|
self.buffer_widget.scroll_up() |
|
|
|
def scroll_down(self): |
|
self.buffer_widget.scroll_down() |
|
|
|
def scroll_up_page(self): |
|
self.buffer_widget.scroll_up_page() |
|
|
|
def scroll_down_page(self): |
|
self.buffer_widget.scroll_down_page() |
|
|
|
def toggle_read_mode(self): |
|
self.buffer_widget.toggle_read_mode() |
|
|
|
def scroll_to_home(self): |
|
self.buffer_widget.scroll_to_home() |
|
|
|
def scroll_to_end(self): |
|
self.buffer_widget.scroll_to_end() |
|
|
|
def zoom_reset(self): |
|
self.buffer_widget.zoom_reset() |
|
|
|
def zoom_in(self): |
|
self.buffer_widget.zoom_in() |
|
|
|
def zoom_out(self): |
|
self.buffer_widget.zoom_out() |
|
|
|
def jump_to_page(self): |
|
self.send_input_message("Jump to Page: ", "jump_page") |
|
|
|
def jump_to_percent(self): |
|
self.send_input_message("Jump to Percent: ", "jump_percent") |
|
|
|
def save_current_pos(self): |
|
self.buffer_widget.save_current_pos() |
|
|
|
def jump_to_saved_pos(self): |
|
self.buffer_widget.jump_to_saved_pos() |
|
|
|
def toggle_inverted_mode(self): |
|
self.buffer_widget.toggle_inverted_mode() |
|
|
|
def toggle_mark_link(self): |
|
self.buffer_widget.toggle_mark_link() |
|
|
|
def jump_to_link(self): |
|
self.buffer_widget.add_mark_jump_link_tips() |
|
self.send_input_message("Jump to Link: ", "jump_link") |
|
|
|
def action_quit(self): |
|
if self.buffer_widget.is_mark_search: |
|
self.buffer_widget.cleanup_search() |
|
if self.buffer_widget.is_jump_link: |
|
self.buffer_widget.cleanup_links() |
|
if self.buffer_widget.is_select_mode: |
|
self.buffer_widget.cleanup_select() |
|
|
|
def search_text_forward(self): |
|
if self.buffer_widget.is_mark_search: |
|
self.buffer_widget.jump_next_match() |
|
else: |
|
self.send_input_message("Search Text: ", "search_text") |
|
|
|
def search_text_backward(self): |
|
if self.buffer_widget.is_mark_search: |
|
self.buffer_widget.jump_last_match() |
|
else: |
|
self.send_input_message("Search Text: ", "search_text") |
|
|
|
def copy_select(self): |
|
if self.buffer_widget.is_select_mode: |
|
content = self.buffer_widget.parse_select_char_list() |
|
self.eval_in_emacs.emit('''(kill-new "{}")'''.format(content)) |
|
self.message_to_emacs.emit(content) |
|
self.buffer_widget.cleanup_select() |
|
else: |
|
self.message_to_emacs.emit("Cannot copy, you should double click your mouse and hover through the text on the PDF. Don't click and drag!") |
|
|
|
def add_annot_highlight(self): |
|
if self.buffer_widget.is_select_mode: |
|
self.buffer_widget.annot_select_char_area("highlight") |
|
|
|
def add_annot_strikeout_or_delete_annot(self): |
|
if self.buffer_widget.is_select_mode: |
|
self.buffer_widget.annot_select_char_area("strikeout") |
|
elif self.buffer_widget.is_hover_annot: |
|
self.buffer_widget.annot_handler("delete") |
|
|
|
def add_annot_underline(self): |
|
if self.buffer_widget.is_select_mode: |
|
self.buffer_widget.annot_select_char_area("underline") |
|
|
|
def add_annot_squiggly(self): |
|
if self.buffer_widget.is_select_mode: |
|
self.buffer_widget.annot_select_char_area("squiggly") |
|
|
|
def add_annot_text_or_edit_annot(self): |
|
if self.buffer_widget.is_select_mode: |
|
self.buffer_widget.get_focus_text.emit(self.buffer_id, "") |
|
elif self.buffer_widget.is_hover_annot: |
|
self.buffer_widget.annot_handler("edit") |
|
|
|
def set_focus_text(self, new_text): |
|
if self.buffer_widget.is_select_mode: |
|
self.buffer_widget.annot_select_char_area("text", new_text) |
|
elif self.buffer_widget.is_hover_annot: |
|
self.buffer_widget.update_annot_text(new_text) |
|
|
|
class PdfViewerWidget(QWidget): |
|
translate_double_click_word = QtCore.pyqtSignal(str) |
|
get_focus_text = QtCore.pyqtSignal(str, str) |
|
|
|
def __init__(self, url, config_dir, background_color, buffer_id, emacs_var_dict): |
|
super(PdfViewerWidget, self).__init__() |
|
|
|
self.url = url |
|
self.config_dir = config_dir |
|
self.background_color = background_color |
|
self.buffer_id = buffer_id |
|
self.installEventFilter(self) |
|
self.setMouseTracking(True) |
|
self.emacs_var_dict = emacs_var_dict |
|
|
|
# Load document first. |
|
self.document = fitz.open(url) |
|
|
|
# Get document's page information. |
|
self.first_pixmap = self.document.getPagePixmap(0) |
|
self.page_width = self.first_pixmap.width |
|
self.page_height = self.first_pixmap.height |
|
self.page_total_number = self.document.pageCount |
|
|
|
# Init scale and scale mode. |
|
self.scale = 1.0 |
|
self.read_mode = "fit_to_width" |
|
|
|
# Inverted mode. |
|
self.inverted_mode = False |
|
|
|
# mark link |
|
self.is_mark_link = False |
|
self.mark_link_annot_cache_dict = {} |
|
|
|
#jump link |
|
self.is_jump_link = False |
|
self.jump_link_key_cache_dict = {} |
|
self.jump_link_annot_cache_dict = {} |
|
|
|
#global search text |
|
self.is_mark_search = False |
|
self.search_text_offset_list = [] |
|
self.search_text_annot_cache_dict = {} |
|
|
|
# select text |
|
self.is_select_mode = False |
|
self.start_char_rect_index = None |
|
self.start_char_page_index = None |
|
self.last_char_rect_index = None |
|
self.last_char_page_index = None |
|
self.select_area_annot_cache_dict = {} |
|
self.select_area_annot_quad_cache_dict = {} |
|
self.char_dict = {k:None for k in range(self.page_total_number)} |
|
|
|
# annot |
|
self.is_hover_annot = False |
|
|
|
# Init scroll attributes. |
|
self.scroll_step = 20 |
|
self.scroll_offset = 0 |
|
self.mouse_scroll_offset = 20 |
|
|
|
# Padding between pages. |
|
self.page_padding = 10 |
|
|
|
# Init font. |
|
self.page_annotate_height = 22 |
|
self.page_annotate_padding_right = 10 |
|
self.page_annotate_padding_bottom = 10 |
|
self.page_annotate_light_color = QColor("#333333") |
|
self.page_annotate_dark_color = QColor("#999999") |
|
self.font = QFont() |
|
self.font.setPointSize(12) |
|
|
|
# Page cache. |
|
self.page_cache_pixmap_dict = {} |
|
self.page_cache_scale = self.scale |
|
self.page_cache_trans = None |
|
self.page_cache_context_delay = 1000 |
|
|
|
self.last_action_time = 0 |
|
|
|
self.is_page_just_changed = False |
|
|
|
self.remember_offset = None |
|
|
|
# Save table in file for search framework, such as snails, search table to navigate. |
|
table_info = "" |
|
for info in self.document.getToC(): |
|
indentation_num = info[0] |
|
title = info[1] |
|
page = info[2] |
|
|
|
table_info += str(page) + self.repeat_to_length(" ", indentation_num * 4) + title + "\n" |
|
|
|
table_file_hash = hashlib.md5(self.url.encode()).hexdigest() |
|
self.table_file_path = os.path.join(config_dir, "pdf-viewer", "table", table_file_hash) |
|
touch(self.table_file_path) |
|
with open(self.table_file_path, "w") as f: |
|
f.write(table_info) |
|
|
|
def repeat_to_length(self, string_to_expand, length): |
|
return (string_to_expand * (int(length/len(string_to_expand))+1))[:length] |
|
|
|
def save_current_pos(self): |
|
self.remember_offset = self.scroll_offset |
|
self.buffer.message_to_emacs.emit("Saved current position.") |
|
|
|
def jump_to_saved_pos(self): |
|
if self.remember_offset is None: |
|
self.buffer.message_to_emacs.emit("Cannot jump from this position.") |
|
else: |
|
current_scroll_offset = self.scroll_offset |
|
self.scroll_offset = self.remember_offset |
|
self.update() |
|
self.remember_offset = current_scroll_offset |
|
self.buffer.message_to_emacs.emit("Jumped to saved position.") |
|
|
|
def get_page_pixmap(self, index, scale): |
|
# Just return cache pixmap when found match index and scale in cache dict. |
|
if self.page_cache_scale == scale: |
|
if index in self.page_cache_pixmap_dict.keys(): |
|
return self.page_cache_pixmap_dict[index] |
|
# Clear dict if page scale changed. |
|
else: |
|
self.page_cache_pixmap_dict.clear() |
|
self.page_cache_scale = scale |
|
self.page_cache_trans = fitz.Matrix(scale, scale) |
|
|
|
page = self.document[index] |
|
if self.is_mark_link: |
|
page = self.add_mark_link(index) |
|
|
|
# follow page search text |
|
if self.is_mark_search: |
|
page = self.add_mark_search_text(page, index) |
|
|
|
# cache page char_dict |
|
if self.char_dict[index] is None: |
|
self.char_dict[index] = self.get_page_char_rect_list(index) |
|
self.select_area_annot_cache_dict[index] = None |
|
|
|
trans = self.page_cache_trans if self.page_cache_trans is not None else fitz.Matrix(scale, scale) |
|
pixmap = page.getPixmap(matrix=trans, alpha=False) |
|
|
|
if self.inverted_mode: |
|
pixmap.invertIRect(pixmap.irect) |
|
|
|
img = QImage(pixmap.samples, pixmap.width, pixmap.height, pixmap.stride, QImage.Format_RGB888) |
|
qpixmap = QPixmap.fromImage(img) |
|
|
|
self.page_cache_pixmap_dict[index] = qpixmap |
|
|
|
return qpixmap |
|
|
|
def clean_unused_page_cache_pixmap(self): |
|
# We need expand render index bound that avoid clean cache around current index. |
|
start_page_index = max(0, self.get_start_page_index() - 1) |
|
last_page_index = min(self.page_total_number, self.get_last_page_index() + 1) |
|
index_list = list(range(start_page_index, last_page_index)) |
|
|
|
# Try to clean unused cache. |
|
cache_index_list = list(self.page_cache_pixmap_dict.keys()) |
|
|
|
for cache_index in cache_index_list: |
|
if cache_index not in index_list: |
|
self.page_cache_pixmap_dict.pop(cache_index) |
|
|
|
def resizeEvent(self, event): |
|
# Update scale attributes after widget resize. |
|
self.update_scale() |
|
|
|
QWidget.resizeEvent(self, event) |
|
|
|
def paintEvent(self, event): |
|
# Init painter. |
|
painter = QPainter(self) |
|
painter.save() |
|
|
|
# Draw background. |
|
background_color = self.background_color |
|
if self.inverted_mode: # change color of background if inverted mode is enable |
|
background_color = QColor(20, 20, 20, 255) |
|
painter.setBrush(background_color) |
|
painter.setPen(background_color) |
|
painter.drawRect(0, 0, self.rect().width(), self.rect().height()) |
|
|
|
# Get start/last render index. |
|
start_page_index = self.get_start_page_index() |
|
last_page_index = self.get_last_page_index() |
|
|
|
# Translate painter at y coordinate. |
|
translate_y = (start_page_index * self.scale * self.page_height) - self.scroll_offset |
|
painter.translate(0, translate_y) |
|
|
|
# Render pages in visible area. |
|
for index in list(range(start_page_index, last_page_index)): |
|
if index < self.page_total_number: |
|
# Get page image. |
|
qpixmap = self.get_page_pixmap(index, self.scale) |
|
|
|
# Init render rect. |
|
render_width = self.page_width * self.scale |
|
render_height = self.page_height * self.scale |
|
render_x = (self.rect().width() - render_width) / 2 |
|
render_y = (index - start_page_index) * self.scale * self.page_height |
|
|
|
# Add padding between pages. |
|
if (index - start_page_index) > 0: |
|
painter.translate(0, self.page_padding) |
|
|
|
# Draw page image. |
|
painter.drawPixmap(QRect(render_x, render_y, render_width, render_height), qpixmap) |
|
|
|
# Clean unused pixmap cache that avoid use too much memory. |
|
self.clean_unused_page_cache_pixmap() |
|
|
|
painter.restore() |
|
|
|
# Render current page. |
|
painter.setFont(self.font) |
|
|
|
if self.inverted_mode: |
|
painter.setPen(self.page_annotate_dark_color) |
|
else: |
|
painter.setPen(self.page_annotate_light_color) |
|
|
|
painter.drawText(QRect(self.rect().x(), |
|
self.rect().y() + self.rect().height() - self.page_annotate_height - self.page_annotate_padding_bottom, |
|
self.rect().width() - self.page_annotate_padding_right, |
|
self.page_annotate_height), |
|
Qt.AlignRight, |
|
"{0}% ({1}/{2})".format(int((start_page_index + 1) * 100 / self.page_total_number), start_page_index + 1, self.page_total_number)) |
|
|
|
def build_context_wrap(f): |
|
def wrapper(*args): |
|
# Get self instance object. |
|
self_obj = args[0] |
|
|
|
# Record page before action. |
|
page_before_action = self_obj.get_start_page_index() |
|
|
|
# Do action. |
|
ret = f(*args) |
|
|
|
# Record page after action. |
|
page_after_action = self_obj.get_start_page_index() |
|
self_obj.is_page_just_changed = (page_before_action != page_after_action) |
|
|
|
# Start build context timer. |
|
self_obj.last_action_time = time.time() |
|
QtCore.QTimer().singleShot(self_obj.page_cache_context_delay, self_obj.build_context_cache) |
|
|
|
return ret |
|
|
|
return wrapper |
|
|
|
@build_context_wrap |
|
def wheelEvent(self, event): |
|
if not event.accept(): |
|
self.update_scroll_offset(max(min(self.scroll_offset - self.scale * event.angleDelta().y() / 120 * self.mouse_scroll_offset, self.max_scroll_offset()), 0)) |
|
|
|
def get_start_page_index(self): |
|
return int(self.scroll_offset * 1.0 / self.scale / self.page_height) |
|
|
|
def get_last_page_index(self): |
|
return int((self.scroll_offset + self.rect().height()) * 1.0 / self.scale / self.page_height) + 1 |
|
|
|
def build_context_cache(self): |
|
# Just build context cache when action duration longer than delay |
|
# Don't build contexnt cache when is_page_just_changed is True, avoid flickr when user change page. |
|
last_action_duration = (time.time() - self.last_action_time) * 1000 |
|
if last_action_duration > self.page_cache_context_delay and not self.is_page_just_changed: |
|
start_page_index = max(0, self.get_start_page_index() - 1) |
|
last_page_index = min(self.page_total_number, self.get_last_page_index() + 1) |
|
|
|
for index in list(range(start_page_index, last_page_index)): |
|
self.get_page_pixmap(index, self.scale) |
|
|
|
def scale_to(self, new_scale): |
|
self.scroll_offset = new_scale * 1.0 / self.scale * self.scroll_offset |
|
self.scale = new_scale |
|
|
|
def scale_to_width(self): |
|
self.scale_to(self.rect().width() * 1.0 / self.page_width) |
|
|
|
def scale_to_height(self): |
|
self.scale_to(self.rect().size().height() * 1.0 / self.page_height) |
|
|
|
def update_scale(self): |
|
if self.read_mode == "fit_to_width": |
|
self.scale_to_width() |
|
elif self.read_mode == "fit_to_height": |
|
self.scale_to_height() |
|
|
|
def max_scroll_offset(self): |
|
return self.scale * self.page_height * self.page_total_number - self.rect().height() |
|
|
|
def toggle_read_mode(self): |
|
if self.read_mode == "fit_to_customize": |
|
self.read_mode = "fit_to_width" |
|
elif self.read_mode == "fit_to_width": |
|
self.read_mode = "fit_to_height" |
|
elif self.read_mode == "fit_to_height": |
|
self.read_mode = "fit_to_width" |
|
|
|
self.update_scale() |
|
self.update() |
|
|
|
def scroll_up(self): |
|
self.update_scroll_offset(min(self.scroll_offset + self.scale * self.scroll_step, self.max_scroll_offset())) |
|
|
|
def scroll_down(self): |
|
self.update_scroll_offset(max(self.scroll_offset - self.scale * self.scroll_step, 0)) |
|
|
|
def scroll_up_page(self): |
|
# Adjust scroll step to make users continue reading fluently. |
|
self.update_scroll_offset(min(self.scroll_offset + self.rect().height() - self.scroll_step, self.max_scroll_offset())) |
|
|
|
def scroll_down_page(self): |
|
# Adjust scroll step to make users continue reading fluently. |
|
self.update_scroll_offset(max(self.scroll_offset - self.rect().height() + self.scroll_step, 0)) |
|
|
|
def scroll_to_home(self): |
|
self.update_scroll_offset(0) |
|
|
|
def scroll_to_end(self): |
|
self.update_scroll_offset(self.max_scroll_offset()) |
|
|
|
def zoom_in(self): |
|
if self.is_mark_search: |
|
self.cleanup_search() |
|
self.read_mode = "fit_to_customize" |
|
self.scale_to(min(10, self.scale + 0.2)) |
|
self.update() |
|
|
|
def zoom_out(self): |
|
if self.is_mark_search: |
|
self.cleanup_search() |
|
self.read_mode = "fit_to_customize" |
|
self.scale_to(max(1, self.scale - 0.2)) |
|
self.update() |
|
|
|
def zoom_reset(self): |
|
if self.is_mark_search: |
|
self.cleanup_search() |
|
self.read_mode = "fit_to_width" |
|
self.update_scale() |
|
self.update() |
|
|
|
def toggle_inverted_mode(self): |
|
# Need clear page cache first, otherwise current page will not inverted until next page. |
|
self.page_cache_pixmap_dict.clear() |
|
|
|
# Toggle inverted status. |
|
self.inverted_mode = not self.inverted_mode |
|
|
|
# Re-render page. |
|
self.update() |
|
|
|
def toggle_mark_link(self): # mark_link will add underline mark on link, using prompt link position. |
|
if self.is_mark_link: |
|
self.cleanup_mark_link() |
|
else: |
|
self.is_mark_link = True |
|
|
|
self.page_cache_pixmap_dict.clear() |
|
self.update() |
|
|
|
def add_mark_link(self, index): |
|
annot_list = [] |
|
page = self.document[index] |
|
if page.firstLink: |
|
for link in page.getLinks(): |
|
annot = page.addUnderlineAnnot(link["from"]) |
|
annot.parent = page # Must assign annot parent, else deleteAnnot cause parent is None problem. |
|
annot_list.append(annot) |
|
self.mark_link_annot_cache_dict[index] = annot_list |
|
return page |
|
|
|
def cleanup_mark_link(self): |
|
if self.mark_link_annot_cache_dict: |
|
for index in self.mark_link_annot_cache_dict.keys(): |
|
page = self.document[index] |
|
for annot in self.mark_link_annot_cache_dict[index]: |
|
page.deleteAnnot(annot) |
|
self.is_mark_link = False |
|
self.mark_link_annot_cache_dict.clear() |
|
|
|
def generate_random_key(self, count): |
|
letters = self.emacs_var_dict["eaf-marker-letters"] |
|
key_list = [] |
|
key_len = 1 if count == 1 else math.ceil(math.log(count) / math.log(len(letters))) |
|
while count > 0: |
|
key = ''.join(random.choices(letters, k=key_len)) |
|
if key not in key_list: |
|
key_list.append(key) |
|
count -= 1 |
|
return key_list |
|
|
|
def add_mark_jump_link_tips(self): |
|
# Only mark display page |
|
start_page_index = self.get_start_page_index() |
|
last_page_index = self.get_last_page_index() |
|
tips_size = 4 |
|
annot_list = [] |
|
|
|
for page_index in range(start_page_index, last_page_index): |
|
page = self.document[page_index] |
|
annot_list = [] |
|
if page.firstLink: |
|
links = page.getLinks() |
|
key_list = self.generate_random_key(len(links)) |
|
for index, link in enumerate(links): |
|
key = key_list[index] |
|
link_rect = link["from"] |
|
annot_rect = fitz.Rect(link_rect.top_left, link_rect.x0 + (tips_size * len(key)), link_rect.y0 + 7) |
|
annot = page.addFreetextAnnot(annot_rect, str(key), fontsize=6, fontname="Cour", \ |
|
text_color=[0.0, 0.0, 0.0], fill_color=[255/255.0, 197/255.0, 36/255.0]) |
|
annot.parent = page |
|
annot_list.append(annot) |
|
self.jump_link_key_cache_dict[key] = link |
|
|
|
self.jump_link_annot_cache_dict[page_index] = annot_list |
|
|
|
self.page_cache_pixmap_dict.clear() |
|
self.update() |
|
|
|
def delete_all_mark_jump_link_tips(self): |
|
if self.jump_link_annot_cache_dict: |
|
for index in self.jump_link_annot_cache_dict.keys(): |
|
page = self.document[index] |
|
for annot in self.jump_link_annot_cache_dict[index]: |
|
page.deleteAnnot(annot) |
|
self.jump_link_key_cache_dict.clear() |
|
self.jump_link_annot_cache_dict.clear() |
|
|
|
def jump_to_link(self, key): |
|
self.is_jump_link = True |
|
key = key.upper() |
|
if key in self.jump_link_key_cache_dict: |
|
link = self.jump_link_key_cache_dict[key] |
|
if "page" in link: |
|
self.cleanup_links() |
|
|
|
self.save_current_pos() |
|
self.jump_to_page(link["page"] + 1) |
|
|
|
self.buffer.message_to_emacs.emit("Landed on Page " + str(link["page"] + 1)) |
|
elif "uri" in link: |
|
self.cleanup_links() |
|
|
|
self.buffer.open_url_in_new_tab.emit(link["uri"]) |
|
self.buffer.message_to_emacs.emit("Open " + link["uri"]) |
|
|
|
def cleanup_links(self): |
|
self.is_jump_link = False |
|
self.delete_all_mark_jump_link_tips() |
|
self.page_cache_pixmap_dict.clear() |
|
|
|
self.update() |
|
|
|
def add_mark_search_text(self, page, page_index): |
|
quads_list = page.searchFor(self.search_term, hit_max=999, quads=True) |
|
annot_list = [] |
|
if quads_list: |
|
for quads in quads_list: |
|
annot = page.addHighlightAnnot(quads) |
|
annot.parent = page |
|
annot_list.append(annot) |
|
self.search_text_annot_cache_dict[page_index] = annot_list |
|
|
|
return page |
|
|
|
def search_text(self, text): |
|
self.is_mark_search = True |
|
self.search_term = text |
|
self.page_cache_pixmap_dict.clear() |
|
|
|
search_text_index = 0 |
|
self.search_text_index = 0 |
|
for page_index in range(self.page_total_number): |
|
quads_list = self.document.searchPageFor(page_index, text, hit_max=999, quads=True) |
|
if quads_list: |
|
for quad in quads_list: |
|
search_text_offset = (page_index * self.page_height + quad.ul.y) * self.scale |
|
|
|
self.search_text_offset_list.append(search_text_offset) |
|
if search_text_offset > self.scroll_offset and search_text_offset < (self.scroll_offset + self.rect().height()): |
|
self.search_text_index = search_text_index |
|
search_text_index += 1 |
|
self.update() |
|
if(len(self.search_text_offset_list) == 0): |
|
self.buffer.message_to_emacs.emit("No results found with \"" + text + "\".") |
|
self.is_mark_search = False |
|
else: |
|
self.update_scroll_offset(self.search_text_offset_list[self.search_text_index]) |
|
self.buffer.message_to_emacs.emit("Found " + str(len(self.search_text_offset_list)) + " results with \"" + text + "\".") |
|
|
|
def jump_next_match(self): |
|
if len(self.search_text_offset_list) > 0: |
|
self.search_text_index = (self.search_text_index + 1) % len(self.search_text_offset_list) |
|
self.update_scroll_offset(self.search_text_offset_list[self.search_text_index]) |
|
self.buffer.message_to_emacs.emit("Match " + str(self.search_text_index + 1) + "/" + str(len(self.search_text_offset_list))) |
|
|
|
def jump_last_match(self): |
|
if len(self.search_text_offset_list) > 0: |
|
self.search_text_index = (self.search_text_index - 1) % len(self.search_text_offset_list) |
|
self.update_scroll_offset(self.search_text_offset_list[self.search_text_index]) |
|
self.buffer.message_to_emacs.emit("Match " + str(self.search_text_index + 1) + "/" + str(len(self.search_text_offset_list))) |
|
|
|
def cleanup_search(self): |
|
self.buffer.message_to_emacs.emit("Unmarked all matched results.") |
|
if self.search_text_annot_cache_dict: |
|
for page_index in self.search_text_annot_cache_dict.keys(): |
|
page = self.document[page_index] |
|
for annot in self.search_text_annot_cache_dict[page_index]: |
|
page.deleteAnnot(annot) |
|
self.is_mark_search = False |
|
self.search_term = None |
|
self.search_text_annot_cache_dict.clear() |
|
self.page_cache_pixmap_dict.clear() |
|
self.search_text_offset_list.clear() |
|
self.update() |
|
|
|
def get_page_char_rect_list(self, page_index): |
|
lines_list = [] |
|
spans_list = [] |
|
chars_list = [] |
|
|
|
page_rawdict = self.document[page_index].getText("rawdict") |
|
for block in page_rawdict["blocks"]: |
|
if "lines" in block: |
|
lines_list += block["lines"] |
|
|
|
for line in lines_list: |
|
if "spans" in line: |
|
spans_list += line["spans"] |
|
|
|
for span in spans_list: |
|
if "chars" in span: |
|
chars_list += span["chars"] |
|
|
|
return chars_list |
|
|
|
def get_char_rect_index(self): |
|
offset = 15 |
|
ex, ey, page_index = self.get_cursor_absolute_position() |
|
if ex and ey and page_index: |
|
rect = fitz.Rect(ex, ey, ex + offset, ey + offset) |
|
for char_index, char in enumerate(self.char_dict[page_index]): |
|
if fitz.Rect(char["bbox"]).intersect(rect): |
|
return char_index, page_index |
|
return None, None |
|
|
|
def get_select_char_list(self): |
|
page_dict = {} |
|
if self.start_char_rect_index and self.last_char_rect_index: |
|
# start and last page |
|
sp_index = min(self.start_char_page_index, self.last_char_page_index) |
|
lp_index = max(self.start_char_page_index, self.last_char_page_index) |
|
for page_index in range(sp_index, lp_index + 1): |
|
page_char_list = self.char_dict[page_index] |
|
|
|
if page_char_list: |
|
# handle forward select and backward select on multi page. |
|
# backward select on multi page. |
|
if self.start_char_page_index > self.last_char_page_index: |
|
sc = self.last_char_rect_index if page_index == sp_index else 0 |
|
lc = self.start_char_rect_index if page_index == lp_index else len(page_char_list) |
|
else: |
|
# forward select on multi page. |
|
sc = self.start_char_rect_index if page_index == sp_index else 0 |
|
lc = self.last_char_rect_index if page_index == lp_index else len(page_char_list) |
|
|
|
# handle forward select and backward select on same page. |
|
sc_index = min(sc, lc) |
|
lc_index = max(sc, lc) |
|
|
|
page_dict[page_index] = page_char_list[sc_index : lc_index + 1] |
|
|
|
return page_dict |
|
|
|
def parse_select_char_list(self): |
|
string = "" |
|
page_dict = self.get_select_char_list() |
|
for index, chars_list in enumerate(page_dict.values()): |
|
if chars_list: |
|
string += "".join(list(map(lambda x: x["c"], chars_list))) |
|
|
|
if index != 0: |
|
string += "\n\n" # add new line on page end. |
|
return string |
|
|
|
def annot_select_char_area(self, annot_type="highlight", text=None): |
|
self.cleanup_select() # needs first cleanup select highlight mark. |
|
for page_index, quad_list in self.select_area_annot_quad_cache_dict.items(): |
|
page = self.document[page_index] |
|
|
|
if annot_type == "highlight": |
|
new_annot = page.addHighlightAnnot(quad_list) |
|
elif annot_type == "strikeout": |
|
new_annot = page.addStrikeoutAnnot(quad_list) |
|
elif annot_type == "underline": |
|
new_annot = page.addUnderlineAnnot(quad_list) |
|
elif annot_type == "squiggly": |
|
new_annot = page.addSquigglyAnnot(quad_list) |
|
elif annot_type == "text": |
|
point = quad_list[-1].lr # lower right point |
|
new_annot = page.addTextAnnot(point, text, icon="Note") |
|
|
|
new_annot.parent = page |
|
self.document.saveIncr() |
|
self.select_area_annot_quad_cache_dict.clear() |
|
|
|
def cleanup_select(self): |
|
self.is_select_mode = False |
|
self.delete_all_mark_select_area() |
|
self.page_cache_pixmap_dict.clear() |
|
self.update() |
|
|
|
def mark_select_char_area(self): |
|
page_dict = self.get_select_char_list() |
|
for page_index, chars_list in page_dict.items(): |
|
# Using multi line rect make of abnormity select area. |
|
line_rect_list = [] |
|
if chars_list: |
|
# every char has bbox property store char rect. |
|
bbox_list = list(map(lambda x: x["bbox"], chars_list)) |
|
|
|
# With char order is left to right, if the after char x-axis more than before |
|
# char x-axis, will determine have "\n" between on both. |
|
if len(bbox_list) >= 2: |
|
tl_x, tl_y = 0, 0 # top left point |
|
for index, bbox in enumerate(bbox_list[:-1]): |
|
if (tl_x == 0) or (tl_y == 0): |
|
tl_x, tl_y = bbox[:2] |
|
if bbox[0] > bbox_list[index + 1][2]: |
|
br_x, br_y = bbox[2:] # bottom right |
|
line_rect_list.append((tl_x, tl_y, br_x, br_y)) |
|
tl_x, tl_y = 0, 0 |
|
|
|
lc = bbox_list[-1] # The last char |
|
line_rect_list.append((tl_x, tl_y, lc[2], lc[3])) |
|
else: |
|
# if only one char selected. |
|
line_rect_list.append(bbox_list[0]) |
|
|
|
line_rect_list = list(map(lambda x: fitz.Rect(x), line_rect_list)) |
|
|
|
page = self.document[page_index] |
|
old_annot = self.select_area_annot_cache_dict[page_index] |
|
if old_annot: |
|
page.deleteAnnot(old_annot) |
|
|
|
quad_list = list(map(lambda x: x.quad, line_rect_list)) |
|
annot = page.addHighlightAnnot(quad_list) |
|
annot.parent = page |
|
|
|
# refresh annot |
|
self.select_area_annot_cache_dict[page_index] = annot |
|
self.select_area_annot_quad_cache_dict[page_index] = quad_list |
|
|
|
self.page_cache_pixmap_dict.clear() |
|
self.update() |
|
|
|
def delete_all_mark_select_area(self): |
|
if self.select_area_annot_cache_dict: |
|
for page_index, annot in self.select_area_annot_cache_dict.items(): |
|
page = self.document[page_index] |
|
if annot and annot.parent: |
|
page.deleteAnnot(annot) |
|
self.select_area_annot_cache_dict[page_index] = None # restore cache |
|
self.last_char_page_index = None |
|
self.last_char_rect_index = None |
|
self.start_char_page_index = None |
|
self.start_char_rect_index = None |
|
|
|
def hover_annot(self): |
|
ex, ey, page_index = self.get_cursor_absolute_position() |
|
page = self.document[page_index] |
|
annot = page.firstAnnot |
|
if not annot: |
|
return None |
|
|
|
annots = [] |
|
while annot: |
|
annots.append(annot) |
|
annot = annot.next |
|
|
|
for annot in annots: |
|
if fitz.Point(ex, ey) in annot.rect: |
|
self.is_hover_annot = True |
|
annot.setOpacity(0.5) |
|
self.buffer.message_to_emacs.emit("[d]Delete Annot [e]Edit Annot") |
|
else: |
|
annot.setOpacity(1) # restore annot |
|
self.is_hover_annot = False |
|
annot.update() |
|
|
|
self.page_cache_pixmap_dict.clear() |
|
self.update() |
|
return page, annot |
|
|
|
def save_annot(self): |
|
self.document.saveIncr() |
|
self.page_cache_pixmap_dict.clear() |
|
self.update() |
|
|
|
def annot_handler(self, action=None): |
|
page, annot = self.hover_annot() |
|
if annot.parent: |
|
if action == "delete": |
|
page.deleteAnnot(annot) |
|
self.save_annot() |
|
if action == "edit": |
|
if annot.type[0] == 0: |
|
self.get_focus_text.emit(self.buffer_id, annot.info["content"]) |
|
else: |
|
self.buffer.message_to_emacs.emit("Cannot edit. Only support text annot type.") |
|
|
|
def update_annot_text(self, annot_text): |
|
page, annot = self.hover_annot() |
|
if annot.parent: |
|
annot.setInfo(content=annot_text) |
|
annot.update() |
|
self.save_annot() |
|
|
|
def jump_to_page(self, page_num): |
|
self.update_scroll_offset(min(max(self.scale * (int(page_num) - 1) * self.page_height, 0), self.max_scroll_offset())) |
|
|
|
def jump_to_percent(self, percent): |
|
self.update_scroll_offset(min(max(self.scale * (self.page_total_number * self.page_height * percent / 100.0), 0), self.max_scroll_offset())) |
|
|
|
def update_scroll_offset(self, new_offset): |
|
if self.scroll_offset != new_offset: |
|
self.scroll_offset = new_offset |
|
self.update() |
|
|
|
def get_cursor_absolute_position(self): |
|
start_page_index = self.get_start_page_index() |
|
last_page_index = self.get_last_page_index() |
|
pos = self.mapFromGlobal(QCursor.pos()) # map global coordinate to widget coordinate. |
|
ex, ey = pos.x(), pos.y() |
|
|
|
for index in list(range(start_page_index, last_page_index)): |
|
if index < self.page_total_number: |
|
render_width = self.page_width * self.scale |
|
render_x = int((self.rect().width() - render_width) / 2) |
|
|
|
# computer absolute coordinate of page |
|
x = (ex - render_x) * 1.0 / self.scale |
|
if ey + self.scroll_offset < (start_page_index + 1) * self.scale * self.page_height: |
|
page_offset = self.scroll_offset - start_page_index * self.scale * self.page_height |
|
page_index = index |
|
else: |
|
# if display two pages, pos.y() will add page_padding |
|
page_offset = self.scroll_offset - (start_page_index + 1) * self.scale * self.page_height - self.page_padding |
|
page_index = index + 1 |
|
y = (ey + page_offset) * 1.0 / self.scale |
|
|
|
return x, y, page_index |
|
return None, None, None |
|
|
|
def get_event_link(self): |
|
ex, ey, page_index = self.get_cursor_absolute_position() |
|
if page_index is None: |
|
return None |
|
|
|
page = self.document[page_index] |
|
for link in page.getLinks(): |
|
rect = link["from"] |
|
if ex >= rect.x0 and ex <= rect.x1 and ey >= rect.y0 and ey <= rect.y1: |
|
if link["page"]: |
|
return link |
|
|
|
return None |
|
|
|
def get_double_click_word(self): |
|
ex, ey, page_index = self.get_cursor_absolute_position() |
|
if page_index is None: |
|
return None |
|
page = self.document[page_index] |
|
word_offset = 10 # 10 pixel is enough for word intersect operation |
|
draw_rect = fitz.Rect(ex, ey, ex + word_offset, ey + word_offset) |
|
|
|
page.setCropBox(page.rect) |
|
page_words = page.getTextWords() |
|
rect_words = [w for w in page_words if fitz.Rect(w[:4]).intersect(draw_rect)] |
|
if rect_words: |
|
return rect_words[0][4] |
|
|
|
def eventFilter(self, obj, event): |
|
if event.type() in [QEvent.MouseMove, QEvent.MouseButtonDblClick, QEvent.MouseButtonPress]: |
|
if not self.document.isPDF: |
|
return False |
|
|
|
if event.type() == QEvent.MouseMove: |
|
if self.is_select_mode: |
|
rect_index, page_index = self.get_char_rect_index() |
|
if rect_index and page_index: |
|
if self.start_char_rect_index is None or self.start_char_page_index is None: |
|
self.start_char_rect_index, self.start_char_page_index = rect_index, page_index |
|
else: |
|
self.last_char_rect_index, self.last_char_page_index = rect_index, page_index |
|
self.mark_select_char_area() |
|
else: |
|
self.hover_annot() |
|
|
|
elif event.type() == QEvent.MouseButtonPress: |
|
if event.button() == Qt.LeftButton: |
|
event_link = self.get_event_link() |
|
if event_link: |
|
self.jump_to_page(event_link["page"] + 1) |
|
|
|
elif event.type() == QEvent.MouseButtonDblClick: |
|
if self.is_mark_search: |
|
self.cleanup_search() |
|
if event.button() == Qt.RightButton: |
|
double_click_word = self.get_double_click_word() |
|
if double_click_word: |
|
self.translate_double_click_word.emit(double_click_word) |
|
elif event.button() == Qt.LeftButton: |
|
self.is_select_mode = True |
|
|
|
return False |
|
|
|
if __name__ == '__main__': |
|
import sys |
|
from PyQt5.QtWidgets import QApplication |
|
|
|
app = QApplication(sys.argv) |
|
|
|
w = PdfViewerWidget(sys.argv[1], QColor(0, 0, 0, 255)) |
|
w.resize(1920, 1080) |
|
w.show() |
|
|
|
sys.exit(app.exec_())
|
|
|