diff --git a/Python/Ultimate PDF highlighter/helpers.py b/Python/Ultimate PDF highlighter/helpers.py new file mode 100644 index 0000000..0345454 --- /dev/null +++ b/Python/Ultimate PDF highlighter/helpers.py @@ -0,0 +1,360 @@ +""" +This file stores hepler functions to enable PDF highlight scripts run smoothly + without the main code being polluted with unnecessary logic. +""" + +import pyautogui + +class Helpers: + @staticmethod + def _get_position_of_object(object_image_location: str, + confidence_of_locating: float = 1, + coords_and_square_size: dict = None) -> dict: + """ + Determines the position of a certain object on the screen. + When we suspect the object will be around a certain location, + we can input dictionary with these data, and it will + first investigate this smaller location - thus making + the search possibly quicker that searching in the whole screen + + Using None as a default argument, because using empty dictionary is bad: + - https://docs.quantifiedcode.com/python-anti-patterns/correctness/mutable_default_value_as_argument.html + """ + + # Trying to locate the object in the vicinity of current position (if wanted) + if coords_and_square_size is not None: + # Getting the coordination of the region + region_where_to_look = Helpers.__safely_create_square_region_on_the_screen( + x_coord=coords_and_square_size["x_coord"], + y_coord=coords_and_square_size["y_coord"], + square_size=coords_and_square_size["square_size"], + screen_size=pyautogui.size()) + + # Trying to locate the object in a small region + position_of_object = pyautogui.locateOnScreen( + object_image_location, + region=region_where_to_look, + confidence=confidence_of_locating) + + # If we managed to find the object in the smaller region, return it + # Otherwise the object will be searched for on the whole screen + if position_of_object: + return {"found": True, "coords": position_of_object} + + # Locating the object on the whole screen (after first trial failed + # or was not even wanted) + position_of_object = pyautogui.locateOnScreen( + object_image_location, + confidence=confidence_of_locating) + + return {"found": position_of_object is not None, + "coords": position_of_object} + + @staticmethod + def __safely_create_square_region_on_the_screen(x_coord: int, + y_coord: int, + square_size: int, + screen_size: tuple, + region_type: str = "pyautogui") -> tuple: + """ + Determines a square region on the screen, that has a defined square size + and is surrounding the point with given x and y coordinates. + It tries to put the point in the middle of the square, but when the + point is close to screen boundary, it is not possible - in this case + it returns still the same-sized square, but fully located on + the screen. + """ + + # There are two possibilities of returning the result: + # Pyautogui needs format (x_upper_left_corner, y_upper_left_corner, + # x_rectangle_length, y_rectangle_length), whereas PIL demands + # (x_upper_left_corner, y_upper_left_corner, x_bottom_right_corner, + # y_bottom_right_corner) + assert(region_type in ["pyautogui", "PIL"]) + + # Finding out the resolution of the screen, to righly construct the square + x_screen_size, y_screen_size = screen_size + + # Guard against square sizes bigger than the whole screen + assert(square_size <= x_screen_size and square_size <= y_screen_size) + + # Calculating the coordinates of ideal top-left square corner + x_corner = int(x_coord - square_size / 2) + y_corner = int(y_coord - square_size / 2) + + # Transforming the possibly negative coordinates to non-negative, + # as negative coordinations do not exist + x_corner = x_corner if x_corner > -1 else 0 + y_corner = y_corner if y_corner > -1 else 0 + + # Identifying if the square would be outside the screen, and if so, + # adjust the corner further from the border, to fix this + if x_corner + square_size >= x_screen_size: + x_corner = x_screen_size - square_size - 1 + if y_corner + square_size >= y_screen_size: + y_corner = y_screen_size - square_size - 1 + + # Last sanity check before returning + assert(0 <= x_corner < x_screen_size - square_size) + assert(0 <= y_corner < y_screen_size - square_size) + + if region_type == "pyautogui": + return (x_corner, y_corner, square_size, square_size) + elif region_type == "PIL": + return(x_corner, y_corner, x_corner + square_size, y_corner + square_size) + + @staticmethod + def _locate_pixel_in_the_image(PIL_image, + colour_to_match: tuple, + x_coord: int, + y_coord: int, + square_size: int, + pixels_to_skip_on_margin_if_possible: int, + grid_size_when_searching: int) -> dict: + """ + Searching the image for a certain pixel, according to supplied + colour. + At the beginning smaller area around a certain point is searched, + as there is the highest chance of locating what we want. + """ + + # First checking if the pixel colour to match really exists in the image + # In the negative case immediately return that there is nothing + is_there_my_colour = Helpers.__is_there_a_colour_in_a_PIL_image( + PIL_image=PIL_image, + colour_to_locate=colour_to_match) + + if not is_there_my_colour["is_there"]: + return {"should_i_click": False, "coords": None} + + # There is a high chance the mouse will be already pointing at + # our desired pixel, so before investigating bigger picture + # try to see if we are not already there + if PIL_image.getpixel((x_coord, y_coord)) == colour_to_match: + return {"should_i_click": True, "coords": (x_coord, y_coord)} + + # If we are not so lucky, we have to examine the image more thoroughly + # Getting the coordination of our region in vicinity of cursor + region_where_to_look = Helpers.__safely_create_square_region_on_the_screen( + x_coord=x_coord, + y_coord=y_coord, + square_size=square_size, + screen_size=PIL_image.size, + region_type="PIL") + + # Trying to locate our colour in our region + is_there_colour_in_region = Helpers.__is_there_a_colour_in_a_PIL_image( + PIL_image=PIL_image.crop(box=region_where_to_look), + colour_to_locate=colour_to_match) + + # If we manage to locate it in our smaller region, locate it there + if is_there_colour_in_region["is_there"]: + x_values_list = Helpers.__generate_list_from_range_starting_in_the_middle( + lower_end=region_where_to_look[0], + higher_end=region_where_to_look[2], + step=grid_size_when_searching) + + for x_value in x_values_list: + analyze_y_axis = Helpers._search_the_y_axis_for_pixel( + PIL_image=PIL_image, + colour_to_match=colour_to_match, + x_coord=x_value, + y_coord_current=y_coord, + distance_to_search=square_size, + pixels_to_skip_on_margin_if_possible=pixels_to_skip_on_margin_if_possible) + + if analyze_y_axis["success"]: + return {"should_i_click": True, "coords": analyze_y_axis["coords"]} + # If it is not in out smaller region, we must find it in the whole image + else: + # Starting with the current grid size, and if we are not successful + # divide it by two, so at the end we really examine all the pixels + while grid_size_when_searching // 2 > 0: + x_values_list = Helpers.__generate_list_from_range_starting_in_the_middle( + lower_end=0, + higher_end=PIL_image.size[0] - 1, + step=grid_size_when_searching) + + y_coord_in_the_middle = PIL_image.size[1] // 2 + + for x_value in x_values_list: + analyze_y_axis = Helpers._search_the_y_axis_for_pixel( + PIL_image=PIL_image, + colour_to_match=colour_to_match, + x_coord=x_value, + y_coord_current=y_coord_in_the_middle, + distance_to_search=PIL_image.size[1], + pixels_to_skip_on_margin_if_possible=pixels_to_skip_on_margin_if_possible) + + if analyze_y_axis["success"]: + return {"should_i_click": True, "coords": analyze_y_axis["coords"]} + + grid_size_when_searching = grid_size_when_searching // 2 + + # It should never reach this assert, because the colour was identified + # in the image, and in the end we searched it pixel by pixel, so + # something must have gone wrong + assert False + + @staticmethod + def __generate_list_from_range_starting_in_the_middle( + lower_end: int, + higher_end: int, + step: int, + first_direction_from_middle: str = None) -> list: + """ + Generates a list of numbers starting at the middle of the range + and continuing gradually to the edges + """ + + assert(lower_end < higher_end) + assert(step > 0) + assert(first_direction_from_middle in [None, "up", "down", "mixed"]) + + middle_point = int((lower_end + higher_end) / 2) + + bottom_part = [] + middle_part = [middle_point] + upper_part = [] + + # Filling the bottom part + current_point = middle_point + while current_point > lower_end: + current_point -= step + if current_point > lower_end: + bottom_part.append(current_point) + else: + bottom_part.append(lower_end) + + # Filling the upper part + current_point = middle_point + while current_point < higher_end: + current_point += step + if current_point < higher_end: + upper_part.append(current_point) + else: + upper_part.append(higher_end) + + # Starting from the middle position, and serving other parts + # according to the wanted direction + # First going through the bottom part + if first_direction_from_middle in [None, "down"]: + resulting_list = middle_part + bottom_part + upper_part + # First going through the upper part + elif first_direction_from_middle == "up": + resulting_list = middle_part + upper_part + bottom_part + # When there should be mixed direction, taking gradually elements + # from both the bottom and upper part, until they are both + # exhausted + elif first_direction_from_middle == "mixed": + mixed_list = [] + max_length = max(len(bottom_part), len(upper_part)) + for index in range(max_length): + try: + mixed_list.append(bottom_part[index]) + except IndexError: + pass + try: + mixed_list.append(upper_part[index]) + except IndexError: + pass + resulting_list = middle_part + mixed_list + + return resulting_list + + @staticmethod + def _search_the_y_axis_for_pixel(PIL_image, + colour_to_match: tuple, + x_coord: int, + y_coord_current: int, + distance_to_search: int, + pixels_to_skip_on_margin_if_possible: int = 0) -> dict: + """ + Examining the given image for a certain pixel on a specific "x" + coordination. + Starting at the certain point and explore both directions, with + the upward (decreasing "y" coordination) direction at first + """ + + screen_x_size, screen_y_size = PIL_image.size + + # Determining the ideal boundaries in which to locate the pixel + y_low_boundary = int(y_coord_current - distance_to_search / 2) + y_high_boundary = int(y_coord_current + distance_to_search / 2) + + # Transforming the possibly negative y_low_boundary to non-negative, + # as negative coordinations do not exist + if y_low_boundary < 0: + y_low_boundary = 0 + + # Identifying if the y_high_boundary would be outside the screen, + # and if so, adjust it + if y_high_boundary >= screen_y_size: + y_high_boundary = screen_y_size - 1 + + # We are "guessing" the most probable position of match is upwards + # on the screen, so create the interval to search accordingly + upward_direction = list(range(y_coord_current, y_low_boundary, -1)) + downward_direction = list(range(y_coord_current, y_high_boundary)) + y_coords = upward_direction + downward_direction + + # Running the searches - depending on whether we want to skip the + # first n pixels on the margin, not to conflict with anything + # EXAMPLE: When there is already some highlighted text between + # the newly highlighted text and the direction we are approaching this, + # the already highlighted one is activated when we click on the + # very margin - and we do not want that + # Therefore we will continue for couple pixels in the same direction + # before searching again, and hopefully click there + if pixels_to_skip_on_margin_if_possible < 1: + for y_coord in y_coords: + pixel = (x_coord, y_coord) + if PIL_image.getpixel(pixel) == colour_to_match: + return {"success": True, "coords": pixel} + else: + margin_pixel_position = None + counter_of_skipped_pixels = 0 + margin_found = False + + for y_coord in y_coords: + pixel = (x_coord, y_coord) + # If we have not located anything, search for the margin + if not margin_found: + if PIL_image.getpixel(pixel) == colour_to_match: + margin_pixel_position = pixel + margin_found = True + # When the margin is already located, skip next n pixels + else: + if counter_of_skipped_pixels < pixels_to_skip_on_margin_if_possible: + counter_of_skipped_pixels += 1 + else: + if PIL_image.getpixel(pixel) == colour_to_match: + return {"success": True, "coords": pixel} + + # If we found margin, but nothing else after those pixels to skip, + # return the margin at least + if margin_pixel_position is not None: + return {"success": True, "coords": margin_pixel_position} + + # If nothing was ever found, return negative results + return {"success": False, "coords": None} + + @staticmethod + def __is_there_a_colour_in_a_PIL_image(PIL_image, + colour_to_locate: tuple) -> dict: + """ + Determining whether colour is located in a PIL image + (whether at least one pixel has this specific colour) + It is surprisingly quick, 1920*1080 image with 12,000 + colours can identify arbitrary colour there in 0.02 seconds + """ + + # Getting the list of all colours in that image + ocurrences_and_colours = PIL_image.getcolors(maxcolors=66666) + + # Trying to locate our wanted colour there + for ocurrence, colour in ocurrences_and_colours: + if colour == colour_to_locate: + return {"is_there": True, "ocurrences": ocurrence} + else: + return {"is_there": False, "ocurrences": None} diff --git a/Python/Ultimate PDF highlighter/pdf_highlighter_clicking_version.py b/Python/Ultimate PDF highlighter/pdf_highlighter_clicking_version.py index 54c9b89..c0b4fe9 100644 --- a/Python/Ultimate PDF highlighter/pdf_highlighter_clicking_version.py +++ b/Python/Ultimate PDF highlighter/pdf_highlighter_clicking_version.py @@ -49,10 +49,9 @@ import os import time -class Global: - # Determining the directory the file is located in - WORKING_DIRECTORY = os.path.dirname(os.path.realpath(__file__)) +from helpers import Helpers +class Global: # Specifying which mouse clicks to listen for, where is the picture # we want to be clicking, and which keyboard action we want to take mouse_button_to_listen = mouse.Button.middle @@ -164,7 +163,7 @@ def copy_and_highlight() -> None: "square_size": Global.square_size_where_to_look_for_button_first} # Trying to locate the object on the current screen - position_of_object = _get_position_of_object( + position_of_object = Helpers._get_position_of_object( object_image_location=Global.where_to_click_picture_location, confidence_of_locating=Global.confidence_of_locating, coords_and_square_size=coords_and_square_size) @@ -182,90 +181,6 @@ def copy_and_highlight() -> None: else: print("OBJECT NOT LOCATED!!!") -def _get_position_of_object(object_image_location: str, - confidence_of_locating: float = 1, - coords_and_square_size: dict = None) -> dict: - """ - Determines the position of a certain object on the screen. - When we suspect the object will be around a certain location, - we can input dictionary with these data, and it will - first investigate this smaller location - thus making - the search possibly quicker that searching in the whole screen - - Using None as a default argument, because using empty dictionary is bad: - - https://docs.quantifiedcode.com/python-anti-patterns/correctness/mutable_default_value_as_argument.html - """ - - # Trying to locate the object in the vicinity of current position (if wanted) - if coords_and_square_size is not None: - # Getting the coordination of the region - region_where_to_look = _safely_create_square_region_on_the_screen( - x_coord=coords_and_square_size["x_coord"], - y_coord=coords_and_square_size["y_coord"], - square_size=coords_and_square_size["square_size"], - screen_size=pyautogui.size()) - - # Trying to locate the object in a small region - position_of_object = pyautogui.locateOnScreen( - object_image_location, - region=region_where_to_look, - confidence=confidence_of_locating) - - # If we managed to find the object in the smaller region, return it - # Otherwise the object will be searched for on the whole screen - if position_of_object: - return {"found": True, "coords": position_of_object} - - # Locating the object on the whole screen (after first trial failed - # or was not even wanted) - position_of_object = pyautogui.locateOnScreen( - object_image_location, - confidence=confidence_of_locating) - - return {"found": position_of_object is not None, - "coords": position_of_object} - -def _safely_create_square_region_on_the_screen(x_coord: int, - y_coord: int, - square_size: int, - screen_size: tuple) -> tuple: - """ - Determines a square region on the screen, that has a defined square size - and is surrounding the point with given x and y coordinates. - It tries to put the point in the middle of the square, but when the - point is close to screen boundary, it is not possible - in this case - it returns still the same-sized square, but fully located on - the screen. - """ - - # Finding out the resolution of the screen, to righly construct the square - x_screen_size, y_screen_size = screen_size - - # Guard against square sizes bigger than the whole screen - assert(square_size <= x_screen_size and square_size <= y_screen_size) - - # Calculating the coordinates of ideal top-left square corner - x_corner = int(x_coord - square_size / 2) - y_corner = int(y_coord - square_size / 2) - - # Transforming the possibly negative coordinates to non-negative, - # as negative coordinations do not exist - x_corner = x_corner if x_corner > -1 else 0 - y_corner = y_corner if y_corner > -1 else 0 - - # Identifying if the square would be outside the screen, and if so, - # adjust the corner further from the border, to fix this - if x_corner + square_size >= x_screen_size: - x_corner = x_screen_size - square_size - 1 - if y_corner + square_size >= y_screen_size: - y_corner = y_screen_size - square_size - 1 - - # Last sanity check before returning - assert(0 <= x_corner < x_screen_size - square_size) - assert(0 <= y_corner < y_screen_size - square_size) - - return (x_corner, y_corner, square_size, square_size) - if __name__ == "__main__": # Notifying the user and starting to listen if Global.SHOW_NOTIFICATIONS: diff --git a/Python/Ultimate PDF highlighter/pdf_highlighter_keyboard_version.py b/Python/Ultimate PDF highlighter/pdf_highlighter_keyboard_version.py new file mode 100644 index 0000000..31cd5c1 --- /dev/null +++ b/Python/Ultimate PDF highlighter/pdf_highlighter_keyboard_version.py @@ -0,0 +1,218 @@ +""" +This script is automatically highlighting text in a PDF document, + as well as storing the text in a clipboard. +It was developed for Adobe Acrobat Reader DC, version 2019 +It is listening for a certain key (right Control), + and is triggering action when it registers it. +The whole action takes around 0.5 seconds, and when the mouse travels + to click somewhere, you get the sensation of magic! + +It is invaluable in combination with clipboard monitoring script. +The combination of those two will handle everything from highlighting + the chosen text with a colour, processing the PDF text and saving it + to a file. +All what is needed is to choose the text you really like by highlighting it + with a mouse, and pressing the right Control. +Weee, automation!!!!!!!!!!!!!!! + +NOTE: You can improve the speed of highlighting by having your mouse + close or even better over the highlighted area when triggering action - + so the script does not have to locate the right spot where to click. + +I am also experimenting here with static type-checking in python. +Needed steps to perform this validation: +- pip install mypy +- mypy script.py +- it can happen (and it happens now), that it will find errors, because the + imported libraries do not incorporate types +Very nice articles about type-checking in python can be found here: +- https://realpython.com/python-type-checking/ +- https://medium.com/@ageitgey/learn-how-to-use-static-type-checking-in-python-3-6-in-10-minutes-12c86d72677b + +The final thing is that I have just started using virtual environments + in python, so I am enclosing a requirements.txt file for everybody. +Super article about it: +- https://medium.com/@boscacci/why-and-how-to-make-a-requirements-txt-f329c685181e + +Possible improvements: +- incorporating sounds - when it succeeds or fails, play some nice melody :) +""" + +from pynput.keyboard import Key, Listener +import pyautogui +import subprocess +import os +import time + +from helpers import Helpers + +class Global: + # Specifying which key to listen for, key for stopping, + # and which keyboard action we want to take + key_to_action = Key.ctrl_r + key_to_stop = Key.esc + hotkeys_to_press = ["ctrl", "c"] + + # Whether to first explore where to click, not to cause some + # unexpected action + # If set to True, we will only click on below-specified background + VALIDATE_BEFORE_CLICKING = True + + # Specifying the colour of the highlighted background, to enable + # validating whether we are clicking on the right place + pdf_mouse_highlighting_colour = (153, 193, 218) + + # Defines the size of the square around cursor that highlight colour will + # be located at first, to speed up the process + # The lower this value, the quicker the identification process, + # however when too small, it does not have to find it inside + # this small area, which will make the search take longer time + square_size_where_to_look_for_highlight_first = 500 + + # Defines the grid-size (step) in pixels that the y-axis will be sliced + # into when searching on the screen for a highlighted area + # The bigger it is, the quicker the locating can be, but there can be + # a danger of missing small (one-letter) highlights when having it + # over 15-20 pixels + # When we analyze the whole picture, we are gradually dividing this by + # two if we are not successful - so finally we should find everything + grid_size_when_searching = 10 + + # Defines how many pixels we should ideally skip after we locate + # the background where to click + # EXPLANATION: When there is already some highlighted text between + # the newly highlighted text and the direction we are approaching this, + # the already highlighted one is activated when we click on the + # very margin - and we do not want that + # Therefore we will continue for couple pixels in the same direction + # before searching again, and hopefully click there + pixels_to_skip_on_margin_if_possible = 10 + + # Storing the information about the last key pressed, to be able to + # recognize pressing the stopping key two times in a row + last_key_pressed = None + + # Storing how many times the function was used + amount_of_usage = 0 + + # Whether to show user notifications + # NOTE: at first I had these notifications in the form of opening + # a Notepad with a file where the wanted text was written + # (and deleting the file right after it was opened). + # Only then I discovered these Javascript-like alerts and other + # possibilities pyautogui offers. + # The "Notepad notifications" have however one advantage - user can + # both read the notifications and use the program, while here the + # open alert blocks the program itself - and user has to close it. + SHOW_NOTIFICATIONS = True + + # Instruction for the user that will be shown at the beginning + start_instructions = \ + """ + This program is making PDF color highlighting and data copying a breeze. + + Just highlight the wanted text in PDF and press right Control. + Program will press Ctrl+C for you, to put data into clipboard, + as well as colourfully highlight the text in PDF. + + Already got everything highlighted and processed? + Press Escape key two times in a row to terminate the program. + """ + + # Template for the message that will be shown after user ends the script + end_summary = "" + \ + "Thank you for using the service!\n\n" + \ + "You have used the function {} times!" + +def on_release(key) -> bool: + """ + What should happen when a key is released + Listening on release of the button - not on press, to prevent + overload when the key would be pressed for a long time + (there can be multiple on_press events, but only one on_release) + """ + + # Contacting stop function with the same parameters, + # and if it decides we should stop, then stop by returning False + if stop_listening(key): + if Global.SHOW_NOTIFICATIONS: + pyautogui.alert( + text=Global.end_summary.format(Global.amount_of_usage), + title='SEE YOU SOON!', + button='OK') + + return False + + # If the right key is released, trigger the main action + if key == Global.key_to_action: + copy_and_highlight() + + return True + +def stop_listening(key) -> bool: + """ + Condition for which to stop listening (finish the script) + """ + + # Return True when we press our stop key two times in a row + if key == Global.key_to_stop and Global.last_key_pressed == Global.key_to_stop: + return True + + Global.last_key_pressed = key + return False + +def copy_and_highlight() -> None: + """ + Responsible for pressing configured keys and performing + other logic to highlight the text + """ + + # Increasing the amount of usages of this function + Global.amount_of_usage += 1 + + # If we chose to validate the place we click, perform the validation + if Global.VALIDATE_BEFORE_CLICKING: + # Gathering information for the scree analysis + x_coord_original, y_coord_original = pyautogui.position() + my_screen = pyautogui.screenshot() + + # Contacting the helper function which will search for the + # right place where to click + where_should_i_click = Helpers._locate_pixel_in_the_image( + PIL_image=my_screen, + colour_to_match=Global.pdf_mouse_highlighting_colour, + x_coord=x_coord_original, + y_coord=y_coord_original, + square_size=Global.square_size_where_to_look_for_highlight_first, + pixels_to_skip_on_margin_if_possible=Global.pixels_to_skip_on_margin_if_possible, + grid_size_when_searching=Global.grid_size_when_searching) + + # Performing the action only when we received positive results + # Because the mouse could have to move to click, move it to its original + # position afterwards (also to show the user which powers we have) + if where_should_i_click["should_i_click"]: + assert(my_screen.getpixel(where_should_i_click["coords"]) == Global.pdf_mouse_highlighting_colour) + pyautogui.hotkey(*Global.hotkeys_to_press) + pyautogui.click(*where_should_i_click["coords"], button="right") + pyautogui.typewrite("z") + pyautogui.moveTo(x_coord_original, y_coord_original) + # If the validation is not chosen, just clicking at the current spot + # and hope it will trigger wanted action + else: + # Pressing the configured keys + pyautogui.hotkey(*Global.hotkeys_to_press) + + # Pressing right mouse button and then "z" letter, which causes the + # highlighted text to be coloured + pyautogui.click(button="right") + pyautogui.typewrite("z") + +if __name__ == "__main__": + # Notifying the user and starting to listen + if Global.SHOW_NOTIFICATIONS: + pyautogui.alert( + text=Global.start_instructions, + title='PDF HIGLIGHTER - INSTRUCTIONS', + button='OK') + with Listener(on_release=on_release) as listener: + listener.join()