Skip to content

Commit dc96c79

Browse files
authored
Merge pull request #12 from LeafCreeper/copilot/modify-export-course-summary
Fix export course summary: CID-embedded LaTeX, multi-course support, 宋体 PDF font
2 parents 862a970 + 7e707f8 commit dc96c79

2 files changed

Lines changed: 157 additions & 58 deletions

File tree

.github/workflows/export.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ on:
44
workflow_dispatch:
55
inputs:
66
course_id:
7-
description: "Course ID to export (e.g. 30004)"
7+
description: "Course ID(s) to export, comma-separated (e.g. 30004 or 30004,30005)"
88
required: true
99
type: string
1010
export_pdf:
@@ -36,7 +36,7 @@ jobs:
3636
if: inputs.export_pdf == true
3737
run: |
3838
sudo apt-get update
39-
sudo apt-get install -y fonts-noto-cjk libpango-1.0-0 libcairo2
39+
sudo apt-get install -y fonts-noto-cjk fonts-noto-serif-cjk libpango-1.0-0 libcairo2
4040
pip install weasyprint pygments
4141
4242
- name: Fetch database from data branch

scripts/export_course.py

Lines changed: 155 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
"""Export all summaries for a course as an email or PDF attachment.
1+
"""Export all summaries for one or more courses as emails or PDF attachments.
22
33
Usage:
44
python scripts/export_course.py --course-id 30004
5-
python scripts/export_course.py --course-id 30004 --pdf
5+
python scripts/export_course.py --course-id 30004,30005
6+
python scripts/export_course.py --course-id 30004,30005 --pdf
67
78
Options:
8-
--course-id Course ID to export (required).
9+
--course-id Comma-separated course IDs to export (required).
910
--pdf Convert summaries to PDF and send as attachment.
10-
Without this flag the summaries are sent as an HTML email.
11+
Without this flag the summaries are sent as HTML emails.
1112
--db Database path (default: data/icourse.db).
13+
14+
When multiple course IDs are given:
15+
- PDF mode: each course becomes a separate PDF; all PDFs are sent in
16+
a single email as attachments.
17+
- Email mode: each course is sent as a separate HTML email.
1218
"""
1319

1420
import argparse
@@ -17,6 +23,7 @@
1723
import sys
1824
from email import encoders
1925
from email.mime.base import MIMEBase
26+
from email.mime.image import MIMEImage
2027
from email.mime.multipart import MIMEMultipart
2128
from email.mime.text import MIMEText
2229
from email.utils import formataddr
@@ -33,12 +40,22 @@
3340
# WeasyPrint maps CSS px to physical size at 96 DPI, which makes the
3441
# pre-scaled latex images appear too small. This CSS lets the renderer
3542
# size them naturally based on the image's intrinsic dimensions instead.
36-
_PDF_LATEX_CSS = "img { max-width: 100% !important; height: auto !important; }"
43+
_PDF_LATEX_CSS = (
44+
"img { max-width: 100% !important; height: auto !important; }\n"
45+
'body { font-family: "SimSun", "Noto Serif CJK SC", "AR PL SungtiL GB", serif; }'
46+
)
3747

3848

3949
def _build_html(course_title: str, teacher: str, lectures: list[dict],
40-
pdf: bool = False) -> str:
41-
"""Build a complete styled HTML document from course summaries."""
50+
pdf: bool = False, cid_images: dict | None = None) -> str:
51+
"""Build a complete styled HTML document from course summaries.
52+
53+
Args:
54+
cid_images: When provided (dict), LaTeX images are downloaded and
55+
embedded via CID references. The dict is populated with
56+
``{cid_name: png_bytes}`` entries for the caller to attach
57+
to the MIME message.
58+
"""
4259
body_parts = [
4360
f"<h1>{escape(course_title)}</h1>",
4461
f"<p>任课教师:{escape(teacher)}</p>",
@@ -49,7 +66,7 @@ def _build_html(course_title: str, teacher: str, lectures: list[dict],
4966
f"<h2>{escape(lec['sub_title'])} "
5067
f"<small>({escape(lec['date'])})</small></h2>"
5168
)
52-
body_parts.append(_md_to_html(lec["summary"]))
69+
body_parts.append(_md_to_html(lec["summary"], cid_images=cid_images))
5370
body_parts.append("<hr>")
5471

5572
extra_css = f"\n{_PDF_LATEX_CSS}" if pdf else ""
@@ -85,61 +102,75 @@ def _smtp_connect():
85102
return server
86103

87104

88-
def _send_html_email(subject: str, html: str, plain: str) -> None:
89-
"""Send a multipart HTML email."""
90-
msg = MIMEMultipart("alternative")
105+
def _send_html_email(subject: str, html: str, plain: str,
106+
cid_images: dict[str, bytes] | None = None) -> None:
107+
"""Send a multipart HTML email with CID-embedded LaTeX images.
108+
109+
Uses ``multipart/related`` wrapping ``multipart/alternative`` so that
110+
CID image references in the HTML resolve correctly, matching the MIME
111+
structure used in the main programme (``src/emailer.py``).
112+
"""
113+
msg = MIMEMultipart("related")
91114
msg["Subject"] = subject
92115
msg["From"] = formataddr(("iCourse Subscriber", config.SMTP_EMAIL))
93116
msg["To"] = config.RECEIVER_EMAIL
94-
msg.attach(MIMEText(plain, "plain", "utf-8"))
95-
msg.attach(MIMEText(html, "html", "utf-8"))
117+
118+
msg_alt = MIMEMultipart("alternative")
119+
msg_alt.attach(MIMEText(plain, "plain", "utf-8"))
120+
msg_alt.attach(MIMEText(html, "html", "utf-8"))
121+
msg.attach(msg_alt)
122+
123+
if cid_images:
124+
for cid, png_data in cid_images.items():
125+
img_part = MIMEImage(png_data, "png")
126+
img_part.add_header("Content-ID", f"<{cid}>")
127+
img_part.add_header("Content-Disposition", "inline",
128+
filename=f"{cid}.png")
129+
msg.attach(img_part)
96130

97131
with _smtp_connect() as server:
98132
server.sendmail(config.SMTP_EMAIL, config.RECEIVER_EMAIL, msg.as_string())
99133

100134

101-
def _send_pdf_email(subject: str, pdf_bytes: bytes, filename: str) -> None:
102-
"""Send an email with a PDF file attached."""
135+
def _send_pdf_email(subject: str,
136+
attachments: list[tuple[bytes, str]]) -> None:
137+
"""Send an email with one or more PDF files attached.
138+
139+
Args:
140+
attachments: List of ``(pdf_bytes, filename)`` tuples.
141+
"""
103142
msg = MIMEMultipart()
104143
msg["Subject"] = subject
105144
msg["From"] = formataddr(("iCourse Subscriber", config.SMTP_EMAIL))
106145
msg["To"] = config.RECEIVER_EMAIL
107146

108-
part = MIMEBase("application", "pdf", name=filename)
109-
part.set_payload(pdf_bytes)
110-
encoders.encode_base64(part)
111-
part.add_header("Content-Disposition", "attachment", filename=filename)
112-
msg.attach(part)
147+
for pdf_bytes, filename in attachments:
148+
part = MIMEBase("application", "pdf", name=filename)
149+
part.set_payload(pdf_bytes)
150+
encoders.encode_base64(part)
151+
part.add_header("Content-Disposition", "attachment", filename=filename)
152+
msg.attach(part)
113153

114154
with _smtp_connect() as server:
115155
server.sendmail(config.SMTP_EMAIL, config.RECEIVER_EMAIL, msg.as_string())
116156

117157

118-
def main():
119-
parser = argparse.ArgumentParser(description="Export course summaries.")
120-
parser.add_argument("--course-id", required=True, help="Course ID to export")
121-
parser.add_argument(
122-
"--pdf",
123-
action="store_true",
124-
help="Export as PDF attachment instead of inline HTML email",
125-
)
126-
parser.add_argument(
127-
"--db", default="data/icourse.db", help="Database path (default: data/icourse.db)"
128-
)
129-
args = parser.parse_args()
158+
def _safe_filename(title: str) -> str:
159+
"""Sanitise a course title for use as a filename."""
160+
return "".join(c if c.isalnum() or c in " _-" else "_" for c in title)
130161

131-
if not os.path.isfile(args.db):
132-
print(f"Database not found: {args.db}")
133-
sys.exit(1)
134162

135-
db = Database(args.db)
163+
def _query_course(db: Database, course_id: str) -> tuple[str, str, list[dict]] | None:
164+
"""Return ``(course_title, teacher, lectures)`` for *course_id*.
136165
166+
Returns ``None`` if the course is missing or has no summaries.
167+
"""
137168
course = db.conn.execute(
138-
"SELECT * FROM courses WHERE course_id = ?", (args.course_id,)
169+
"SELECT * FROM courses WHERE course_id = ?", (course_id,)
139170
).fetchone()
140171
if not course:
141-
print(f"Course {args.course_id} not found in database.")
142-
sys.exit(1)
172+
print(f"Course {course_id} not found in database – skipping.")
173+
return None
143174

144175
course_title = course["title"]
145176
teacher = course["teacher"]
@@ -149,42 +180,110 @@ def main():
149180
FROM lectures
150181
WHERE course_id = ? AND summary IS NOT NULL
151182
ORDER BY CAST(sub_id AS INTEGER) ASC""",
152-
(args.course_id,),
183+
(course_id,),
153184
).fetchall()
154185
lectures = [dict(row) for row in rows]
155186

156187
if not lectures:
157-
print(f"No summaries found for course {args.course_id} ({course_title}).")
158-
sys.exit(0)
188+
print(f"No summaries found for course {course_id} ({course_title}) – skipping.")
189+
return None
159190

160191
print(f"Found {len(lectures)} summarized lecture(s) for {course_title}.")
192+
return course_title, teacher, lectures
193+
194+
195+
def main():
196+
parser = argparse.ArgumentParser(description="Export course summaries.")
197+
parser.add_argument(
198+
"--course-id", required=True,
199+
help="Comma-separated course IDs to export (e.g. 30004 or 30004,30005)",
200+
)
201+
parser.add_argument(
202+
"--pdf",
203+
action="store_true",
204+
help="Export as PDF attachment instead of inline HTML email",
205+
)
206+
parser.add_argument(
207+
"--db", default="data/icourse.db",
208+
help="Database path (default: data/icourse.db)",
209+
)
210+
args = parser.parse_args()
211+
212+
if not os.path.isfile(args.db):
213+
print(f"Database not found: {args.db}")
214+
sys.exit(1)
215+
216+
db = Database(args.db)
217+
218+
# Parse comma-separated course IDs
219+
course_ids = [cid.strip() for cid in args.course_id.split(",") if cid.strip()]
220+
if not course_ids:
221+
print("No valid course IDs provided.")
222+
sys.exit(1)
161223

162224
if not config.SMTP_EMAIL or not config.SMTP_PASSWORD or not config.RECEIVER_EMAIL:
163225
print("Email configuration incomplete. Set SMTP_EMAIL, SMTP_PASSWORD, RECEIVER_EMAIL.")
164226
sys.exit(1)
165227

166-
html = _build_html(course_title, teacher, lectures, pdf=args.pdf)
167-
subject = f"[iCourse 课程摘要导出] {course_title}"
168-
169228
if args.pdf:
170229
try:
171230
import weasyprint # noqa: PLC0415
172231
except ImportError:
173232
print("weasyprint is required for PDF export. Install it with: pip install weasyprint")
174233
sys.exit(1)
175234

176-
print("Generating PDF...")
177-
pdf_bytes = weasyprint.HTML(string=html).write_pdf()
178-
safe_title = "".join(c if c.isalnum() or c in " _-" else "_" for c in course_title)
179-
filename = f"{safe_title}_summaries.pdf"
180-
print(f"Sending PDF email ({len(pdf_bytes)} bytes)...")
181-
_send_pdf_email(subject, pdf_bytes, filename)
182-
else:
183-
plain = _build_plain(course_title, teacher, lectures)
184-
print("Sending HTML email...")
185-
_send_html_email(subject, html, plain)
235+
# PDF mode: one PDF per course, all PDFs in one email
236+
attachments: list[tuple[bytes, str]] = []
237+
titles: list[str] = []
238+
for cid in course_ids:
239+
result = _query_course(db, cid)
240+
if result is None:
241+
continue
242+
course_title, teacher, lectures = result
243+
titles.append(course_title)
244+
245+
html = _build_html(course_title, teacher, lectures, pdf=True)
246+
print(f"Generating PDF for {course_title}...")
247+
pdf_bytes = weasyprint.HTML(string=html).write_pdf()
248+
filename = f"{_safe_filename(course_title)}_summaries.pdf"
249+
attachments.append((pdf_bytes, filename))
250+
print(f" PDF ready ({len(pdf_bytes)} bytes): {filename}")
251+
252+
if not attachments:
253+
print("No courses with summaries found – nothing to send.")
254+
sys.exit(0)
255+
256+
subject = "[iCourse 课程摘要导出] " + ", ".join(titles)
257+
total_bytes = sum(len(b) for b, _ in attachments)
258+
print(f"Sending email with {len(attachments)} PDF(s) ({total_bytes} bytes)...")
259+
_send_pdf_email(subject, attachments)
260+
print(f"[OK] Sent: {subject}")
186261

187-
print(f"[OK] Sent: {subject}")
262+
else:
263+
# Email mode: one CID-embedded HTML email per course
264+
sent = 0
265+
for cid in course_ids:
266+
result = _query_course(db, cid)
267+
if result is None:
268+
continue
269+
course_title, teacher, lectures = result
270+
271+
cid_images: dict[str, bytes] = {}
272+
html = _build_html(course_title, teacher, lectures,
273+
cid_images=cid_images)
274+
plain = _build_plain(course_title, teacher, lectures)
275+
subject = f"[iCourse 课程摘要导出] {course_title}"
276+
277+
print(f"Sending HTML email for {course_title}...")
278+
if cid_images:
279+
print(f" Embedded {len(cid_images)} LaTeX image(s) as CID")
280+
_send_html_email(subject, html, plain, cid_images=cid_images)
281+
print(f"[OK] Sent: {subject}")
282+
sent += 1
283+
284+
if sent == 0:
285+
print("No courses with summaries found – nothing to send.")
286+
sys.exit(0)
188287

189288

190289
if __name__ == "__main__":

0 commit comments

Comments
 (0)