import tkinter as tk
from tkinter import ttk, filedialog, messagebox
from PIL import Image, ImageTk, ImageDraw
import fitz
import img2pdf
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
import os
import tempfile
import shutil
class InvoicePrinterApp:
def __init__(self, root):
self.root = root
self.root.title("电子发票A4纸打印软件")
self.root.geometry("1200x800")
self.root.resizable(True, True)
self.uploaded_invoices = []
self.current_preview_index = 0
self.export_dir = tempfile.mkdtemp()
self.current_page = 0
self.print_settings = {
'margin': 50,
'spacing': 50,
'scale': 1.0,
'orientation': 'portrait'
}
self.style = ttk.Style()
self.style.configure("TButton", padding=6, font=('Arial', 10))
self.style.configure("TLabel", font=('Arial', 10))
self.style.configure("Treeview", font=('Arial', 9))
self.main_frame = ttk.Frame(self.root, padding="10")
self.main_frame.pack(fill=tk.BOTH, expand=True)
self.create_menu()
self.create_file_management()
self.create_preview_control()
self.create_status_bar()
def create_menu(self):
"""创建菜单栏"""
menubar = tk.Menu(self.root)
file_menu = tk.Menu(menubar, tearoff=0)
file_menu.add_command(label="上传发票", command=self.upload_invoices)
file_menu.add_command(label="保存排版PDF", command=self.save_pdf)
file_menu.add_separator()
file_menu.add_command(label="退出", command=self.root.quit)
menubar.add_cascade(label="文件", menu=file_menu)
help_menu = tk.Menu(menubar, tearoff=0)
help_menu.add_command(label="关于", command=self.show_about)
menubar.add_cascade(label="帮助", menu=help_menu)
self.root.config(menu=menubar)
def create_file_management(self):
"""创建文件管理区域"""
left_frame = ttk.Frame(self.main_frame)
left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
upload_btn = ttk.Button(left_frame, text="上传发票", command=self.upload_invoices)
upload_btn.pack(fill=tk.X, pady=(0, 10))
list_frame = ttk.LabelFrame(left_frame, text="已上传发票")
list_frame.pack(fill=tk.BOTH, expand=True)
columns = ("#1", "#2", "#3")
self.invoice_tree = ttk.Treeview(list_frame, columns=columns, show="headings", selectmode="extended")
self.invoice_tree.heading("#1", text="序号")
self.invoice_tree.heading("#2", text="文件名")
self.invoice_tree.heading("#3", text="格式")
self.invoice_tree.column("#1", width=50, anchor=tk.CENTER)
self.invoice_tree.column("#2", width=200, anchor=tk.W)
self.invoice_tree.column("#3", width=80, anchor=tk.CENTER)
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.invoice_tree.yview)
self.invoice_tree.configure(yscroll=scrollbar.set)
self.invoice_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.invoice_tree.bind("<<TreeviewSelect>>", self.on_invoice_select)
btn_frame = ttk.Frame(left_frame)
btn_frame.pack(fill=tk.X, pady=10)
move_frame = ttk.Frame(left_frame)
move_frame.pack(fill=tk.X, pady=5)
move_up_btn = ttk.Button(move_frame, text="上移", command=self.move_up)
move_up_btn.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True)
move_down_btn = ttk.Button(move_frame, text="下移", command=self.move_down)
move_down_btn.pack(side=tk.LEFT, fill=tk.X, expand=True)
delete_btn = ttk.Button(btn_frame, text="删除选中", command=self.delete_selected)
delete_btn.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True)
clear_btn = ttk.Button(btn_frame, text="清空列表", command=self.clear_all)
clear_btn.pack(side=tk.LEFT, fill=tk.X, expand=True)
def create_preview_control(self):
"""创建预览和控制区域"""
right_frame = ttk.Frame(self.main_frame)
right_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
preview_frame = ttk.LabelFrame(right_frame, text="预览")
preview_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
self.preview_canvas = tk.Canvas(preview_frame, bg="white", relief=tk.SUNKEN, bd=2)
self.preview_canvas.pack(fill=tk.BOTH, expand=True)
self.pagination_frame = ttk.Frame(preview_frame)
self.pagination_frame.pack(fill=tk.X, pady=5)
self.prev_btn = ttk.Button(self.pagination_frame, text="上一页", command=self.prev_page, state=tk.DISABLED)
self.prev_btn.pack(side=tk.LEFT, padx=5)
self.page_label = ttk.Label(self.pagination_frame, text="第 0 页 / 共 0 页")
self.page_label.pack(side=tk.LEFT, padx=5)
self.next_btn = ttk.Button(self.pagination_frame, text="下一页", command=self.next_page, state=tk.DISABLED)
self.next_btn.pack(side=tk.LEFT, padx=5)
control_frame = ttk.Frame(right_frame)
control_frame.pack(fill=tk.X)
layout_btn = ttk.Button(control_frame, text="自动排版", command=self.auto_layout)
layout_btn.pack(side=tk.LEFT, padx=(0, 5), fill=tk.X, expand=True)
save_btn = ttk.Button(control_frame, text="保存PDF", command=self.save_pdf)
save_btn.pack(side=tk.LEFT, fill=tk.X, expand=True)
def create_status_bar(self):
"""创建状态栏"""
self.status_var = tk.StringVar()
self.status_var.set("就绪")
status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W)
status_bar.pack(side=tk.BOTTOM, fill=tk.X)
def upload_invoices(self):
"""上传发票文件"""
filetypes = [
("PDF文件", "*.pdf"),
("所有文件", "*.*")
]
file_paths = filedialog.askopenfilenames(title="选择发票文件", filetypes=filetypes)
if not file_paths:
return
for file_path in file_paths:
filename = os.path.basename(file_path)
ext = os.path.splitext(filename)[1].lower()
if ext != ".pdf":
messagebox.showerror("错误", f"仅支持PDF格式,不支持:{ext}")
continue
if any(invoice["path"] == file_path for invoice in self.uploaded_invoices):
continue
invoice_info = {
"path": file_path,
"filename": filename,
"format": ext[1:],
"image": None
}
self.uploaded_invoices.append(invoice_info)
self.update_invoice_list()
self.status_var.set(f"已上传 {len(file_paths)} 个文件")
def update_invoice_list(self):
"""更新发票列表"""
for item in self.invoice_tree.get_children():
self.invoice_tree.delete(item)
for i, invoice in enumerate(self.uploaded_invoices, 1):
self.invoice_tree.insert("", tk.END, values=(i, invoice["filename"], invoice["format"]))
def on_invoice_select(self, event):
"""选择发票时的事件处理"""
selected_items = self.invoice_tree.selection()
if not selected_items:
return
item = selected_items[0]
index = int(self.invoice_tree.item(item, "values")[0]) - 1
self.show_invoice_preview(index)
def show_invoice_preview(self, index):
"""显示发票预览"""
if index < 0 or index >= len(self.uploaded_invoices):
return
invoice = self.uploaded_invoices[index]
if invoice["image"] is None:
image = self.convert_to_image(invoice["path"], invoice["format"])
if image:
invoice["image"] = image
else:
return
self.display_image(invoice["image"])
def convert_to_image(self, file_path, file_format):
"""将不同格式的发票转换为图片"""
try:
if file_format == "pdf":
return self.pdf_to_image(file_path)
else:
return None
except Exception as e:
messagebox.showerror("错误", f"转换失败:{str(e)}")
return None
def pdf_to_image(self, pdf_path):
"""PDF转换为图片"""
doc = fitz.open(pdf_path)
if doc.page_count == 0:
return None
page = doc.load_page(0)
pix = page.get_pixmap(dpi=300)
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
doc.close()
return img
def ofd_to_image(self, ofd_path):
"""OFD转换为图片"""
width, height = 1240, 1754
img = Image.new('RGB', (width, height), color='white')
draw = ImageDraw.Draw(img)
from PIL import ImageFont
font = None
font_paths = [
'C:/Windows/Fonts/simhei.ttf',
'C:/Windows/Fonts/simsun.ttc',
'C:/Windows/Fonts/msyh.ttc',
'C:/Windows/Fonts/msyhbd.ttc',
]
for font_path in font_paths:
try:
font = ImageFont.truetype(font_path, 24)
break
except:
continue
if font is None:
font = ImageFont.load_default()
draw.text((50, 50), '电子发票', font=font, fill='black')
draw.text((50, 100), '文件格式: OFD', font=font, fill='black')
draw.text((50, 150), f'文件名: {os.path.basename(ofd_path)}', font=font, fill='black')
draw.text((50, 250), 'OFD格式说明:', font=font, fill='blue')
draw.text((50, 300), '1. OFD是我国自主研发的电子文件格式', font=font, fill='black')
draw.text((50, 350), '2. 如需查看完整内容,请使用', font=font, fill='black')
draw.text((50, 400), ' 专门的OFD阅读器打开原始文件', font=font, fill='black')
draw.text((50, 500), '3. 该文件已成功添加到排版列表', font=font, fill='black')
draw.rectangle([(20, 20), (width-20, height-20)], outline='black', width=2)
return img
def display_image(self, image):
"""在画布上显示图片"""
self.preview_canvas.delete("all")
canvas_width = self.preview_canvas.winfo_width()
canvas_height = self.preview_canvas.winfo_height()
if canvas_width == 1 or canvas_height == 1:
self.root.after(100, lambda: self.display_image(image))
return
img_width, img_height = image.size
scale = min(canvas_width / img_width, canvas_height / img_height) * 0.9
new_width = int(img_width * scale)
new_height = int(img_height * scale)
resized_img = image.resize((new_width, new_height), Image.LANCZOS)
photo = ImageTk.PhotoImage(resized_img)
x = (canvas_width - new_width) // 2
y = (canvas_height - new_height) // 2
self.preview_photo = photo
self.preview_canvas.create_image(x, y, anchor=tk.NW, image=photo)
def auto_layout(self):
"""自动排版发票"""
if len(self.uploaded_invoices) == 0:
messagebox.showwarning("警告", "请先上传发票")
return
for invoice in self.uploaded_invoices:
if invoice["image"] is None:
image = self.convert_to_image(invoice["path"], invoice["format"])
if image:
invoice["image"] = image
else:
messagebox.showerror("错误", f"无法转换发票:{invoice['filename']}")
return
self.status_var.set("正在排版...")
self.root.update_idletasks()
self.layout_images = self.layout_invoices()
if self.layout_images:
self.current_page = 0
self.display_image(self.layout_images[0])
self.update_pagination()
self.status_var.set(f"排版完成,共生成 {len(self.layout_images)} 页")
def layout_invoices(self):
"""将发票排版到A4纸上"""
layout_images = []
margin = self.print_settings['margin']
spacing = self.print_settings['spacing']
scale_factor = self.print_settings['scale']
orientation = self.print_settings['orientation']
if orientation == 'portrait':
a4_width, a4_height = 2480, 3508
else:
a4_width, a4_height = 3508, 2480
for i in range(0, len(self.uploaded_invoices), 2):
a4_img = Image.new("RGB", (a4_width, a4_height), color="white")
available_width = a4_width - 2 * margin
available_height = (a4_height - 2 * margin - spacing) // 2
if i < len(self.uploaded_invoices):
invoice1 = self.uploaded_invoices[i]
img1 = invoice1["image"]
base_scale = min(available_width / img1.width, available_height / img1.height)
scale1 = base_scale * scale_factor
new_width1 = int(img1.width * scale1)
new_height1 = int(img1.height * scale1)
resized1 = img1.resize((new_width1, new_height1), Image.LANCZOS)
x1 = margin + (available_width - new_width1) // 2
y1 = margin
a4_img.paste(resized1, (x1, y1))
if i + 1 < len(self.uploaded_invoices):
invoice2 = self.uploaded_invoices[i + 1]
img2 = invoice2["image"]
base_scale = min(available_width / img2.width, available_height / img2.height)
scale2 = base_scale * scale_factor
new_width2 = int(img2.width * scale2)
new_height2 = int(img2.height * scale2)
resized2 = img2.resize((new_width2, new_height2), Image.LANCZOS)
x2 = margin + (available_width - new_width2) // 2
y2 = margin + available_height + spacing
a4_img.paste(resized2, (x2, y2))
layout_images.append(a4_img)
return layout_images
def print_invoices(self):
"""打印发票"""
if not hasattr(self, 'layout_images') or not self.layout_images:
messagebox.showwarning("警告", "请先进行排版")
return
pdf_path = os.path.join(self.export_dir, "temp_invoices.pdf")
image_paths = []
for i, img in enumerate(self.layout_images):
img_path = os.path.join(self.export_dir, f"temp_page_{i}.jpg")
img.save(img_path, format="JPEG", quality=100, subsampling=0)
image_paths.append(img_path)
try:
if self.print_settings['orientation'] == 'portrait':
a4_width, a4_height = 2480, 3508
else:
a4_width, a4_height = 3508, 2480
layout_fun = img2pdf.get_layout_fun((a4_width, a4_height))
with open(pdf_path, "wb") as f:
f.write(img2pdf.convert(image_paths, layout_fun=layout_fun))
except Exception as e:
messagebox.showerror("错误", f"生成PDF失败:{str(e)}")
return
self.status_var.set("正在打印...")
self.root.update_idletasks()
try:
if os.name == 'nt':
os.startfile(pdf_path, "print")
elif os.name == 'posix':
os.system(f"lp {pdf_path}")
self.status_var.set("打印任务已发送")
except Exception as e:
messagebox.showerror("错误", f"打印失败:{str(e)}")
self.status_var.set("打印失败")
def save_pdf(self):
"""保存排版后的PDF文件"""
if not hasattr(self, 'layout_images') or not self.layout_images:
messagebox.showwarning("警告", "请先进行排版")
return
save_path = filedialog.asksaveasfilename(
title="保存PDF文件",
defaultextension=".pdf",
filetypes=[("PDF文件", "*.pdf")]
)
if not save_path:
return
image_paths = []
for i, img in enumerate(self.layout_images):
img_path = os.path.join(self.export_dir, f"temp_page_{i}.jpg")
img.save(img_path, format="JPEG", quality=100, subsampling=0)
image_paths.append(img_path)
try:
if self.print_settings['orientation'] == 'portrait':
a4_width, a4_height = 2480, 3508
else:
a4_width, a4_height = 3508, 2480
layout_fun = img2pdf.get_layout_fun((a4_width, a4_height))
with open(save_path, "wb") as f:
f.write(img2pdf.convert(image_paths, layout_fun=layout_fun))
messagebox.showinfo("成功", f"PDF文件已保存到:{save_path}")
self.status_var.set(f"PDF已保存")
except Exception as e:
messagebox.showerror("错误", f"保存PDF失败:{str(e)}")
def delete_selected(self):
"""删除选中的发票"""
selected_items = self.invoice_tree.selection()
if not selected_items:
messagebox.showwarning("警告", "请先选择要删除的发票")
return
indices_to_delete = []
for item in selected_items:
index = int(self.invoice_tree.item(item, "values")[0]) - 1
indices_to_delete.append(index)
for index in sorted(indices_to_delete, reverse=True):
del self.uploaded_invoices[index]
self.update_invoice_list()
self.preview_canvas.delete("all")
self.reset_pagination()
self.status_var.set(f"已删除 {len(selected_items)} 个文件")
def clear_all(self):
"""清空所有发票"""
if not self.uploaded_invoices:
return
if messagebox.askyesno("确认", "确定要清空所有上传的发票吗?"):
self.uploaded_invoices.clear()
self.update_invoice_list()
self.preview_canvas.delete("all")
self.reset_pagination()
self.status_var.set("已清空所有文件")
def print_settings(self):
"""打印设置"""
settings_window = tk.Toplevel(self.root)
settings_window.title("打印设置")
settings_window.geometry("400x350")
settings_window.resizable(False, False)
settings_window.transient(self.root)
settings_window.grab_set()
main_frame = ttk.Frame(settings_window, padding="20")
main_frame.pack(fill=tk.BOTH, expand=True)
margin_frame = ttk.LabelFrame(main_frame, text="边距设置")
margin_frame.pack(fill=tk.X, pady=(0, 15))
ttk.Label(margin_frame, text="边距(像素):").grid(row=0, column=0, sticky=tk.W, pady=5, padx=5)
self.margin_var = tk.IntVar(value=self.print_settings['margin'])
margin_spinbox = ttk.Spinbox(margin_frame, from_=0, to=200, textvariable=self.margin_var, width=10)
margin_spinbox.grid(row=0, column=1, sticky=tk.W, pady=5, padx=5)
spacing_frame = ttk.LabelFrame(main_frame, text="间距设置")
spacing_frame.pack(fill=tk.X, pady=(0, 15))
ttk.Label(spacing_frame, text="发票间距(像素):").grid(row=0, column=0, sticky=tk.W, pady=5, padx=5)
self.spacing_var = tk.IntVar(value=self.print_settings['spacing'])
spacing_spinbox = ttk.Spinbox(spacing_frame, from_=0, to=200, textvariable=self.spacing_var, width=10)
spacing_spinbox.grid(row=0, column=1, sticky=tk.W, pady=5, padx=5)
scale_frame = ttk.LabelFrame(main_frame, text="缩放设置")
scale_frame.pack(fill=tk.X, pady=(0, 15))
ttk.Label(scale_frame, text="缩放比例:").grid(row=0, column=0, sticky=tk.W, pady=5, padx=5)
self.scale_var = tk.DoubleVar(value=self.print_settings['scale'])
scale_spinbox = ttk.Spinbox(scale_frame, from_=0.5, to=2.0, increment=0.1, textvariable=self.scale_var, width=10)
scale_spinbox.grid(row=0, column=1, sticky=tk.W, pady=5, padx=5)
orientation_frame = ttk.LabelFrame(main_frame, text="打印方向")
orientation_frame.pack(fill=tk.X, pady=(0, 15))
self.orientation_var = tk.StringVar(value=self.print_settings['orientation'])
ttk.Radiobutton(orientation_frame, text="纵向", variable=self.orientation_var, value="portrait").grid(
row=0, column=0, sticky=tk.W, pady=5, padx=5)
ttk.Radiobutton(orientation_frame, text="横向", variable=self.orientation_var, value="landscape").grid(
row=0, column=1, sticky=tk.W, pady=5, padx=5)
button_frame = ttk.Frame(main_frame)
button_frame.pack(fill=tk.X, pady=10)
ok_button = ttk.Button(button_frame, text="确定", command=lambda: self.save_print_settings(settings_window))
ok_button.pack(side=tk.RIGHT, padx=(5, 0))
cancel_button = ttk.Button(button_frame, text="取消", command=settings_window.destroy)
cancel_button.pack(side=tk.RIGHT)
def save_print_settings(self, window):
"""保存打印设置"""
self.print_settings['margin'] = self.margin_var.get()
self.print_settings['spacing'] = self.spacing_var.get()
self.print_settings['scale'] = self.scale_var.get()
self.print_settings['orientation'] = self.orientation_var.get()
window.destroy()
messagebox.showinfo("成功", "打印设置已保存")
def move_up(self):
"""上移选中的发票"""
selected_items = self.invoice_tree.selection()
if not selected_items:
messagebox.showwarning("警告", "请先选择要移动的发票")
return
item = selected_items[0]
index = self.invoice_tree.index(item)
if index > 0:
self.uploaded_invoices[index], self.uploaded_invoices[index - 1] = \
self.uploaded_invoices[index - 1], self.uploaded_invoices[index]
self.update_invoice_list()
new_item = self.invoice_tree.get_children()[index - 1]
self.invoice_tree.selection_set(new_item)
def move_down(self):
"""下移选中的发票"""
selected_items = self.invoice_tree.selection()
if not selected_items:
messagebox.showwarning("警告", "请先选择要移动的发票")
return
item = selected_items[0]
index = self.invoice_tree.index(item)
if index < len(self.uploaded_invoices) - 1:
self.uploaded_invoices[index], self.uploaded_invoices[index + 1] = \
self.uploaded_invoices[index + 1], self.uploaded_invoices[index]
self.update_invoice_list()
new_item = self.invoice_tree.get_children()[index + 1]
self.invoice_tree.selection_set(new_item)
def update_pagination(self):
"""更新分页控件状态"""
if not hasattr(self, 'layout_images') or not self.layout_images:
return
total_pages = len(self.layout_images)
self.page_label.config(text=f"第 {self.current_page + 1} 页 / 共 {total_pages} 页")
self.prev_btn.config(state=tk.NORMAL if self.current_page > 0 else tk.DISABLED)
self.next_btn.config(state=tk.NORMAL if self.current_page < total_pages - 1 else tk.DISABLED)
def reset_pagination(self):
"""重置分页控件"""
self.page_label.config(text="第 0 页 / 共 0 页")
self.prev_btn.config(state=tk.DISABLED)
self.next_btn.config(state=tk.DISABLED)
def prev_page(self):
"""上一页"""
if self.current_page > 0:
self.current_page -= 1
self.display_image(self.layout_images[self.current_page])
self.update_pagination()
def next_page(self):
"""下一页"""
if self.current_page < len(self.layout_images) - 1:
self.current_page += 1
self.display_image(self.layout_images[self.current_page])
self.update_pagination()
def show_about(self):
"""显示关于信息"""
about_text = "电子发票A4纸打印软件\n\n" \
"版本:1.0\n" \
"功能:将电子发票排版到A4纸上,每页两张\n" \
"支持格式:PDF\n" \
"\n© 2025"
messagebox.showinfo("关于", about_text)
def __del__(self):
"""清理临时文件"""
if hasattr(self, 'export_dir') and os.path.exists(self.export_dir):
shutil.rmtree(self.export_dir)
if __name__ == "__main__":
root = tk.Tk()
app = InvoicePrinterApp(root)
root.mainloop()