본문 바로가기
STUDY

Python으로 Office file property 변경 및 텍스트 찾기 바꾸기 일괄 자동수행.

by PsychoFLOOD 2024. 11. 29.
728x90

Office_Property_Changer_Replacer.zip.001
15.00MB
Office_Property_Changer_Replacer.zip.002
6.36MB

얼마 전에 아래와 같이 오피스 파일들의 Creator와 Last Modified by 두가지 속성을 일괄로 수정해주는 파이썬 코드를 작성해보았다.

https://nooneelseme.tistory.com/248

 

Python으로 Pptx Docx Xlsx 모두 속성 변경하기..

몇일 전에.. 엑셀파일의 속성 정보를 일괄로 변경하는 python script를 간단히 작성했었다.https://nooneelseme.tistory.com/244 엑셀파일 정보를 일괄로 변경해보기.엑셀파일을 아무거나 하나 열어서 파일탭

nooneelseme.tistory.com

업무상 이런 저런 작업을 하다 보니 오피스 파일들에 대하여서 특정 스트링을 모두 찾기 바꾸기를 할 일도 생각보다 많아져서 원래 만들었던 코드에 특정 스트링을 찾아서 찾기 바꾸기를 할 수 있는 기능까지 추가를 해보았다.(오피스 파일이 많은 경우 일일히 하나하나 열어서 특정 스트링을 찾아서 찾기 바꾸기를 하는 일이 생각보다 쉽지 않다... 시간도 오래 걸리고..)

거두절미하고 코드부터...

import sys
import tkinter as tk
import tkinter.ttk as ttk
import tkinter.filedialog as fd
from tkinter import *
from tkinter import messagebox
from unicodedata import normalize
import os
import traceback
import clipboard

import openpyxl
from openpyxl import Workbook
from openpyxl import load_workbook
from openpyxl.styles import PatternFill, Border, Side, Alignment, Protection, Font, Color
from openpyxl.styles.borders import Border, Side
from openpyxl.drawing.image import Image
from openpyxl.cell.text import InlineFont
from openpyxl.cell.rich_text import TextBlock, CellRichText
from openpyxl.worksheet.datavalidation import DataValidation
from openpyxl.comments import Comment
from openpyxl.worksheet.properties import WorksheetProperties, PageSetupProperties
from openpyxl.packaging.extended import ExtendedProperties

from pptx import Presentation
from docx import Document
from python_pptx_text_replacer import TextReplacer
from python_docx_replace import docx_replace

recursive_level = 0
all_file_count = 0
current_count =0
offset=8

root = tk.Tk()
root.geometry('450x480')
root.title('All Office files property changer and find&replacer.')
root.resizable(False, False)

path = os.path.join(os.path.dirname(__file__), 'icon.ico')
if os.path.isfile(path):
    root.iconbitmap(path)

frame = tk.Frame(root)
frame.pack()

txt = Label(root, pady=offset, text="Step 1 : Please set Path to click SetPath button.\nStep 2 : Click Start Property Change and Find&Replace Button to Start.")
txt.pack()

def getDir():
    root.dirName = fd.askdirectory()
    root.dirName.replace("/", "\\")
    print("GetDirPath : "  + root.dirName)
    getAllFilesCount(root.dirName)
    txt.configure(text="Path : "+root.dirName)
    btn2.configure(state='normal')

def getAllFilesCount(dirname):
    global all_file_count
    filenames = os.listdir(dirname)
    for filename in filenames:
        tempname = os.path.join(dirname, filename)
        all_file_count+=1
        
        if os.path.isdir(tempname):
            getAllFilesCount(tempname)
    
#below Execute and WordReplace class is for docx file find&replace
#source code from comment of https://stackoverflow.com/questions/34779724/python-docx-replace-string-in-paragraph-while-keeping-style
class Execute:
    '''
        Execute Paragraphs KeyWords Replace
        paragraph: docx paragraph
    '''

    def __init__(self, paragraph):
        self.paragraph = paragraph


    def p_replace(self, x:int, key:str, value:str):
        '''
        paragraph replace
        The reason why you do not replace the text in a paragraph directly is that it will cause the original format to
        change. Replacing the text in runs will not cause the original format to change
        :param x:       paragraph id
        :param key:     Keywords that need to be replaced
        :param value:   The replaced keywords
        :return:
        '''
        # Gets the coordinate index values of all the characters in this paragraph [{run_index , char_index}]
        p_maps = [{"run": y, "char": z} for y, run in enumerate(self.paragraph.runs) for z, char in enumerate(list(run.text))]
        # Handle the number of times key occurs in this paragraph, and record the starting position in the list.
        # Here, while self.text.find(key) >= 0, the {"ab":"abc"} term will enter an endless loop
        # Takes a single paragraph as an independent body and gets an index list of key positions within the paragraph, or if the paragraph contains multiple keys, there are multiple index values
        k_idx = [s for s in range(len(self.paragraph.text)) if self.paragraph.text.find(key, s, len(self.paragraph.text)) == s]
        for i, start_idx in enumerate(reversed(k_idx)):       # Reverse order iteration
            end_idx = start_idx + len(key)                    # The end position of the keyword in this paragraph
            k_maps = p_maps[start_idx:end_idx]                # Map Slice List A list of dictionaries for sections that contain keywords in a paragraph
            self.r_replace(k_maps, value)
            print(f"\t |Paragraph {x+1: >3}, object {i+1: >3} replaced successfully! | {key} ===> {value}")


    def r_replace(self, k_maps:list, value:str):
        '''
        :param k_maps: The list of indexed dictionaries containing keywords, e.g:[{"run":15, "char":3},{"run":15, "char":4},{"run":16, "char":0}]
        :param value:
        :return:
        Accept arguments, removing the characters in k_maps from back to front, leaving the first one to replace with value
        Note: Must be removed in reverse order, otherwise the list length change will cause IndedxError: string index out of range
        '''
        for i, position in enumerate(reversed(k_maps), start=1):
            y, z = position["run"], position["char"]
            run:object = self.paragraph.runs[y]         # "k_maps" may contain multiple run ids, which need to be separated
            # Pit: Instead of the replace() method, str is converted to list after a single word to prevent run.text from making an error in some cases (e.g., a single run contains a duplicate word)
            thisrun = list(run.text)
            if i < len(k_maps):
                thisrun.pop(z)          # Deleting a corresponding word
            if i == len(k_maps):        # The last iteration (first word), that is, the number of iterations is equal to the length of k_maps
                thisrun[z] = value      # Replace the word in the corresponding position with the new content
            run.text = ''.join(thisrun) # Recover

class WordReplace:
    '''
        file: Microsoft Office word file,only support .docx type file
    '''

    def __init__(self, file):
        self.docx = Document(file)

    def body_content(self, replace_dict:dict):
        print("\t☺Processing keywords in the body...")
        for key, value in replace_dict.items():
            for x, paragraph in enumerate(self.docx.paragraphs):
                Execute(paragraph).p_replace(x, key, value)
        print("\t |Body keywords in the text are replaced!")


    def body_tables(self,replace_dict:dict):
        print("\t☺Processing keywords in the body'tables...")
        for key, value in replace_dict.items():
            for table in self.docx.tables:
                for row in table.rows:
                    for cell in row.cells:
                        for x, paragraph in enumerate(cell.paragraphs):
                            Execute(paragraph).p_replace(x, key, value)
        print("\t |Body'tables keywords in the text are replaced!")


    def header_content(self,replace_dict:dict):
        print("\t☺Processing keywords in the header'body ...")
        for key, value in replace_dict.items():
            for section in self.docx.sections:
                for x, paragraph in enumerate(section.header.paragraphs):
                    Execute(paragraph).p_replace(x, key, value)
        print("\t |Header'body keywords in the text are replaced!")


    def header_tables(self,replace_dict:dict):
        print("\t☺Processing keywords in the header'tables ...")
        for key, value in replace_dict.items():
            for section in self.docx.sections:
                for table in section.header.tables:
                    for row in table.rows:
                        for cell in row.cells:
                            for x, paragraph in enumerate(cell.paragraphs):
                                Execute(paragraph).p_replace(x, key, value)
        print("\t |Header'tables keywords in the text are replaced!")


    def footer_content(self, replace_dict:dict):
        print("\t☺Processing keywords in the footer'body ...")
        for key, value in replace_dict.items():
            for section in self.docx.sections:
                for x, paragraph in enumerate(section.footer.paragraphs):
                    Execute(paragraph).p_replace(x, key, value)
        print("\t |Footer'body keywords in the text are replaced!")


    def footer_tables(self, replace_dict:dict):
        print("\t☺Processing keywords in the footer'tables ...")
        for key, value in replace_dict.items():
            for section in self.docx.sections:
                for table in section.footer.tables:
                    for row in table.rows:
                        for cell in row.cells:
                            for x, paragraph in enumerate(cell.paragraphs):
                                Execute(paragraph).p_replace(x, key, value)
        print("\t |Footer'tables keywords in the text are replaced!")


    def save(self, filepath:str):
        '''
        :param filepath: File saving path
        :return:
        '''
        self.docx.save(filepath)


    @staticmethod
    def docx_list(dirPath):
        '''
        :param dirPath:
        :return: List of docx files in the current directory
        '''
        fileList = []
        for roots, dirs, files in os.walk(dirPath):
            for file in files:
                if file.endswith("docx") and file[0] != "~":  # Find the docx document and exclude temporary files
                    fileRoot = os.path.join(roots, file)
                    fileList.append(fileRoot)
        print("This directory finds a total of {0} related files!".format(len(fileList)))
        return fileList

btn = Button(root, width=100, pady=offset, text="SetPath", command=getDir, font="Tahoma 16")
btn2 = Button(root, width=100, pady=offset, text="Start Property Change and Find&Replace!", font="Tahoma 16", foreground="Red", command=lambda: change_property_office_file(root.dirName))
btn.pack()
btn2.pack()
btn2.configure(state='disabled')

#Author & Last modified by labelframe and input entry
frame2 = LabelFrame(root, text="Author & Last Modified By", padx=5, pady=5, height = 100, width = 440)
frame2.place(x=5, y=220)

txt2 = Label(frame2, text="Author : ")
txt2.place(x=10, y=10)
input1 = Entry(frame2, width=40)
input1.place(x=120, y=10)
input1.insert(0, "AUTHOR")

txt3 = Label(frame2, text="Last Modified By : ")
txt3.place(x=10, y=45)
input2 = Entry(frame2, width=40)
input2.place(x=120, y=45)
input2.insert(0, "LASTMODIFIEDBY")

#progress bar
pb_var = DoubleVar()
pb = ttk.Progressbar(root, maximum=100, length=450, variable=pb_var, mode="determinate")
pb.pack(ipady=10)

#Find & Replace labelframe and input entry and checkbutton
frame1 = LabelFrame(root, text="Find & Replace", padx=5, pady=5, height = 130, width = 440)
frame1.place(x=5, y=335)
#frame1.pack()
txt4 = Label(frame1, text="Find What : ")
txt4.place(x=10, y=10)
input3 = Entry(frame1, width=40)
input3.place(x=120, y=10)

txt5 = Label(frame1, text="Replace With : ")
txt5.place(x=10, y=45)
input4 = Entry(frame1, width=40)
input4.place(x=120, y=45)

mc_check = IntVar()
c1=Checkbutton(frame1,text="Match Case(This option only avaliable with Excel file..)",variable=mc_check)
c1.place(x= 10, y= 80)
c1.toggle()

# change property and find & replace function
def change_property_office_file(dirname):
    global recursive_level
    global all_file_count
    global pb
    global current_count
    global input1
    global input2
    global input3
    global input4
    global mc_check
    
    author = input1.get()
    modifier = input2.get()
    find_string = input3.get()
    replace_string = input4.get()
                
    recursive_level += 1
    filenames = os.listdir(dirname)
    for filename in filenames:
        current_count+=1
        pb_var.set(current_count/all_file_count * 100)
        pb.update()

        full_filename = os.path.join(dirname, filename)
        filename2, fileExtention = os.path.splitext(filename)
        
        print("File : " + full_filename + "Processing..." + "(" + str(current_count) + "/" + str(all_file_count) +  ")")
        
        if os.path.isdir(full_filename):    
            change_property_office_file(full_filename)
        elif fileExtention == '.xlsx':
            wb = load_workbook(full_filename)
            if wb.properties.creator is not None and wb.properties.lastModifiedBy is not None:
                print("File : " + full_filename + " creator : " + wb.properties.creator + " lastModifier : " + wb.properties.lastModifiedBy)
            wb.properties.creator = author
            wb.properties.lastModifiedBy = modifier

            if len(find_string) > 0:
                modified_cells = []
                for sheet_name in wb.sheetnames:
                    sheet = wb[sheet_name]
                    
                    if mc_check.get() == 1:
                        for row in sheet.iter_rows():
                            for cell in row:
                                if isinstance(cell.value, str) and find_string in cell.value:
                                    cell.value = cell.value.replace(find_string, replace_string)                            
                                    modified_cells.append((sheet_name, cell.row, cell.column))            
                    else:
                        for row in sheet.iter_rows():
                            for cell in row:
                                if isinstance(cell.value, str) and find_string.lower() in cell.value.lower():
                                    index = cell.value.lower().find(find_string.lower())
                                    replace_string2 = cell.value[0:index] + replace_string + cell.value[index+len(find_string):]
                                    cell.value = replace_string2                            
                                    modified_cells.append((sheet_name, cell.row, cell.column))
                                        
                print(f"Filename : {full_filename} Totally {len(modified_cells)} cells modified.")
                for cell_info in modified_cells:
                    print(f"Sheet: {cell_info[0]}, row: {cell_info[1]}, column: {cell_info[2]}")

            wb.save(full_filename)
        elif fileExtention == '.pptx':
            parsed = Presentation(full_filename)
            if parsed.core_properties.author is not None and parsed.core_properties.last_modified_by is not None:           
                print("File : " + full_filename + " creator : " + parsed.core_properties.author + " lastModifier : " + parsed.core_properties.last_modified_by)
            parsed.core_properties.author = author
            parsed.core_properties.last_modified_by = modifier
                        
            parsed.save(full_filename)
            
            if len(find_string) > 0:
                replacer = TextReplacer(full_filename, slides='', tables=True, charts=True, textframes=True)
                replacer.replace_text( [ (find_string,replace_string) ] )
                replacer.write_presentation_to_file(full_filename)

        elif fileExtention == '.docx':
            parsed = Document(full_filename)
            if parsed.core_properties.author is not None and parsed.core_properties.last_modified_by is not None:
                print("File : " + full_filename + " creator : " + parsed.core_properties.author + " lastModifier : " + parsed.core_properties.last_modified_by)
            parsed.core_properties.author = author
            parsed.core_properties.last_modified_by = modifier
            parsed.save(full_filename)
            
            if len(find_string) > 0:
                replace_dict = { find_string: replace_string, }
                wordreplace = WordReplace(full_filename)
                wordreplace.header_content(replace_dict)
                wordreplace.header_tables(replace_dict)
                wordreplace.body_content(replace_dict)
                wordreplace.body_tables(replace_dict)
                wordreplace.footer_content(replace_dict)
                wordreplace.footer_tables(replace_dict)
                wordreplace.save(full_filename)           
                        
    recursive_level -= 1
    if recursive_level==0:
        messagebox.showinfo("Info", "Processing Complete!")


root.mainloop()

찾기 바꾸기 기능을 넣다보니 코드가 전보다는 살짝 길어졌다..

엑셀의 경우에는 각 셀값을 들고와서 찾기/바꾸기를 하기가 손쉬운 편인데 파워포인트와 워드의 경우에는 녹록지가 않다.

왜냐하면 pptx와 docx의 경우는 문서를 구성하는 구성요소들이 많고 고려사항들이 많아서(각종 shpaes를 모두 고려해야 하고.. 차트 테이블 머리말 꼬리말 그리고 머리/꼬리에 들어있는 테이블 등등등... 그리고 원래의 서식을 유지하는 것까지..) 모두를 일일히 고려하려면 코드가 엄청 길어질 수 밖에 없다..ㅠㅠ

하여 이미 선배님들이 만들어놓은 패키지 or 소스를 찾아보니... 물론 있었다. ㅎㅎ

pptx의 경우는 python-pptx-text-replacer 라는 패키지를 설치하면 편하게 텍스트 찾기바꾸기를 수행할 수 있다.

다만 추가한 버튼에 있는 대소문자 구분없이 찾기 바꾸기는 되지 않는다..ㅠㅠ(해당 패키지의 함수에서 대소문자 구분없이 replace 하는 기능을 원저작자가 넣지 않음..ㅎㅎ...)

그리고 docx 의 경우는 stack overflow 사이트에서 훌륭한 코드를 코멘트로 작성해준 분이 있어서(https://stackoverflow.com/questions/34779724/python-docx-replace-string-in-paragraph-while-keeping-style  글 참조...)

해당 코드를 들고 왔다. ㅎㅎ(역시 대소문자 구분없이 찾기 바꾸기는... 안된다.. 코드를 이해하고 수정해보려 하였으나 이해가 잘 안됨 ㅋ...)

찾기 바꾸기 기능같은 경우는 Find_String 의 테스트 엔트리 에디트텍스트 창에 무언가 스트링이 들어있어야만 수행되도록 코드를 작성하였다.

수행화면은 아래와 같다.

UI를 이쁘게 하는건 나하고는 안 맞는듯...ㅠㅠ;;

일단 간단한 xlsx/pptx/docx 파일들에 대하여서는 잘 동작하는 것은 확인하였으나... 복잡한 pptx나 docx의 경우 잘 동작하는지는...확언할 수 없다 ㅎㅎ..

-> 엑셀/파워포인트/워드 문서가 약 100여개 들어있는 폴더로 테스트를 해보니... 일단 찾기/바꾸기를 수행하지 않으면 수행이 금방 되는 편이다... 다만... 찾기/바꾸기를 수행하게 되면... 좀만 큰 워드파일이 들어와도 워드파일 처리 부분에서 시간이 꽤나 소요되는 단점이 있다..ㅠㅠ
그리고 파워포인트의 경우 스트링이 바뀌면서 원래의 스트링 포매팅이 유지되지 않는 치명적인...단점이 있다..ㅠㅠ
이 부분은 개선이 필요하다..

필요하신 분들은 pyinstaller 를 통해 생성한 exe 파일을 압축하여 첨부에 올려놓았으니 활용하시면 될 것 같다.

사용법은 일단 변경하기 원하는 Author와 Last Modified By를 해당 텍스트 에디트 창에 넣으면 되고...
찾기 바꾸기 하고 싶은 스트링을 각각 Find What과 Replace With 의 테스트 에디트 창에 넣으면 된다.

이후 첫번째 SetPath 버튼을 통해서 작업을 할 오피스 파일들이 들어있는 최상위 폴더를 선택한 후 (하위 폴더까지 모두 돌면서 확장자 기준으로 xlsx/pptx/docx 인 파일들만 작업한다..) 두번 째 버튼을 클릭하면 작업을 수행하게 된다..

728x90

댓글