目录:

PDF合并:pdftk、pymupdf

未试用,可见时间内应该不会用到。

PDF批注:Drawboard PDF(免费版)

使用体验:

  • 的确能做到提笔写字;笔上两个按钮一个擦除、一个区域框选。这点使用体验和微软OneNote相同。
  • 支持分页,以及手写批注。
  • 打开用批注写作业的PDF比较慢,可能是因为要加载学生的批注?顺带,对于这些学生,改错时要小心擦除,因为可能不小心就擦掉了学生的作业……
    • 所以先压缩,这样就不会有学生的批注了。
  • 最不满意的地方:写字时会占满一个单核CPU(我是战66G2,轻薄本已连电源)。饶是如此,仍然会间或掉帧:体现为部分笔画未记录;与此同时,感觉笔触相当不跟手。

总体来说,我不是很喜欢这个软件,大概我还是很在意字迹跟手以及不掉帧的。Onenote做得很好,可惜不支持PDF手写批注或者分页。想知道评论区有没有建议。

PDF压缩:pymupdf

使用如下命令安装pymupdf的两个包:

pip install fitz
pip install pymupdf

注意必须先安装fitz再安装pymupdf,顺序很重要!见:报错 import fitz no moduler named ‘frontend’

python实现PDF压缩提供了某种压缩方法。但是感觉非常丑陋(先创建并保存图片,再读取图片并合并)。

因此,我直接参考pymupdf教程,写了一版:

def compress(ori_file: str, new_file: str | None, *, dpi: int | None = None):
    """以dpi的分辨率打印文档,并重新输出到pdf

    new_file=None时,覆盖原文件。"""
    import fitz

    input = fitz.open(ori_file)  # open document
    output = fitz.open()
    for page in input:  # iterate through the pages
        pix: fitz.Pixmap = page.get_pixmap(dpi=dpi)  # render page to an image
        img = fitz.open(
            "png", pix.tobytes("png")
        )  # make a PNG stream and open stream as PNG
        imgPDF = fitz.open(
            "pdf", img.convert_to_pdf()
        )  # make a PDF stream and open stream as PDF
        output.insert_pdf(imgPDF)
        img.close()
        imgPDF.close()
    input.close()
    output.ez_save(new_file or ori_file)
    output.close()


compress("try.pdf", "try2.pdf", dpi=100)

实测效果显著:合并前后效果

发送邮件

参考:python3使用smtplib发送邮件,带xlsx附件 - 腾讯云开发者社区-腾讯云

以下代码:

# encoding: utf-8

WEEK: int = 1  # 第几周
DRY_RUN = False  # 是否为测试模式,测试模式不会真的发邮件
UNKNOWN_MAIL = "#N/A"  # 无法获取邮箱时的邮箱地址
ALL_STUDENTS = {
    "学生姓名": "邮箱@qq.com",
}

RECEIVER_NAMES: list[str] | None = None  # None向所有人发邮件,否则向list中的人发邮件


class write_log(object):
    """记录日志

    Init
    - log = write_log("filename.log")
    - log = write_log(open("filename.log", mode="a", encoding="utf-8"))

    Call
    - log("This is a log")

    Close
    - log.close()
    """
    import io

    def __init__(self, log_name: str | io.TextIOWrapper | None) -> None:
        import io
        import sys
        import typing
        if type(log_name) == str:
            self.log = open(log_name, mode="a", encoding="utf-8")
        elif type(log_name) == io.TextIOWrapper:
            self.log = log_name
        elif log_name is None:
            # 默认输出到控制台
            self.log = typing.cast(io.TextIOWrapper, sys.stdout)
        else:
            raise TypeError("log_name must be str or io.TextIOWrapper")

    def __call__(self, *values: object, **kwds) -> None:
        from datetime import datetime

        timestamp = lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
        print(timestamp(), *values, **kwds, file=self.log)

    def close(self):
        self.log.close()


class SendMail(object):
    """发送邮件

    Init:
    - send_email = SendMail("name@qq.com", "passwordpassword", "MyName")
    - send_email = SendMail("name@qq.com", "passwordpassword", "MyName", log_file=log)

    Call:
    - send_email("receiver@example.com","title","This is the content.")
    - send_email("receiver@example.com","title","This is the content.", receiver_name="Receiver Name")
    - send_email("receiver@example.com","title","<p>This is the content.</p>", receiver_name="Receiver Name", content_style="html")
    - send_email("receiver@example.com","title","<p>This is the content.</p>", receiver_name="Receiver Name", content_style="html", file_list=[("file/path/filename1.txt", "filename1.txt"),("file/path/filename2.txt", "邮件中显示的文件名.txt")])
    """

    def __init__(
        self,
        sender: str,
        sender_pwd: str,
        sender_name: str | None,
        *,
        log_file: write_log = write_log(None),
    ):
        from smtplib import SMTP_SSL

        self.count = 0
        self.total_size = 0
        self.sender_address = sender
        self.sender = (sender_name, sender)  # 发件人
        self.log = log_file
        # SMTP服务器
        self.server = SMTP_SSL("smtp.qq.com", 465, timeout=10)
        # 登录账户
        self.server.login(sender, sender_pwd)
        self.log("已连接邮箱服务器……")

    def __del__(self):
        self.server.close()

    def __call__(
        self,
        receiver: str,  # 收件人
        title: str,  # 标题
        content: str,  # 内容
        *,
        receiver_name: str | None,  # 收件人姓名
        content_style: str = "plain",
        file_list: list[tuple[str, str]] = [],  # 文件列表 (文件位置,附件名)
    ):
        """
        发送邮件
        """
        self.count = self.count + 1
        self.log(f"(第{self.count}封邮件) 正在生成……")

        from email.mime.multipart import MIMEMultipart
        from email import utils
        from email.mime.text import MIMEText
        from email.mime.application import MIMEApplication

        # 创建一个带附件的实例
        msg = MIMEMultipart()
        # 时间
        msg["Date"] = utils.format_datetime(utils.localtime())
        # 发件人格式
        msg["From"] = utils.formataddr(self.sender)
        # 收件人格式
        msg["To"] = utils.formataddr((receiver_name, receiver))
        # 邮件主题
        msg["Subject"] = title
        # 邮件正文内容
        msg.attach(MIMEText(content, content_style, "utf-8"))

        # 添加附件
        for file_path, file_name in file_list:
            log(f"    正在添加附件:{file_name}")
            # 构造附件
            file = MIMEApplication(open(file_path, "rb").read())
            # filename表示邮件中显示的附件名
            file.add_header("Content-Disposition", "attachment", filename=file_name)
            msg.attach(file)

        # 发送邮件
        log(
            f"    正在发送给:{receiver_name or receiver}({receiver}), {len(msg.as_string())/2**20*3/4:5.2f}MB"
        )
        self.total_size += len(msg.as_string())
        if not DRY_RUN:
            self.server.sendmail(
                self.sender_address,
                receiver,
                msg.as_string(),
            )


def 获取附件名(文件夹: str) -> dict[str, list[tuple[str, str]]]:
    from os import scandir

    attachments: dict[str, list[tuple[str, str]]] = {}
    with scandir(文件夹) as entries:
        for entry in entries:
            if entry.is_file():
                attachments.setdefault(entry.name[0:ID_LENGTH], []).append(
                    (entry.path, entry.name)
                )
    return attachments


COL_NAME = 1
COL_ID = 0
COL_MAIL = 2
ID_LENGTH = 12  # 学号长度
if DRY_RUN:
    log = write_log(f"第{WEEK}次作业邮件发送日志_dry.log")
    log("DRY RUN模式")
else:
    log = write_log(f"第{WEEK}次作业邮件发送日志.log")
if RECEIVER_NAMES:
    log(f"收件人:{RECEIVER_NAMES}")
else:
    log("即将向所有人发邮件")
    if not DRY_RUN and input(f"第{WEEK}次,即将向所有人发邮件……(y/n):")[0] != "y":
        log("已取消操作,日志结束")
        exit()
# 标题
title = f"第{WEEK}次作业批改情况"
# 发送内容
content = (
    lambda receiver, score, title_row, row: f"""<p><u>{receiver}</u>同学您好:</p>
<p>您本次作业分数为<u> {score} </u>。</p>
<p>
<table>
	<colgroup>
		{"".join("<col style='background-color:white'>" if i % 2 == 0 else "<col style='background-color:#E0E0E0'>" for i in range(COL_MAIL,len(title_row)))}
	</colgroup>
	<tbody>
		<tr>
			{"".join(f"<th>{i.value}</th>" for i in title_row[COL_MAIL+1:])}
		</tr>
		<tr>
			{"".join(f"<td>{i}</td>" for i in row[COL_MAIL+1:])}
		</tr>
	</tbody>
</table>
</p>
"""
)

from openpyxl import load_workbook
import typing

workbook = load_workbook("作业评分.xlsx", read_only=True, data_only=True, keep_links=False)
table = workbook["sheet1"]
title_row = table[1]

send_email = SendMail("account@qq.com", "passwordpassword", "Name", log_file=log)
for row in table.iter_rows(3, values_only=True):
    receiver_name = typing.cast(str, row[COL_NAME])
    if RECEIVER_NAMES and receiver_name not in RECEIVER_NAMES:
        continue
    receiver = ALL_STUDENTS.get(receiver_name, UNKNOWN_MAIL)
    if receiver == UNKNOWN_MAIL:
        log(f"! 无法向{receiver_name}发送邮件(无邮箱)!")
        continue
    score = row[COL_MAIL + WEEK]
    send_email(
        receiver,
        title,
        content(receiver_name, score, title_row, row),
        receiver_name=receiver_name,
        content_style="html",
    )
workbook.close()
log(f"共发送{send_email.count}封邮件,总计大小{send_email.total_size/2**20*3/4:5.2f}MB")
log("日志结束")
log.close()