First commit

This commit is contained in:
MangoPig
2026-02-21 21:55:42 +00:00
commit 060629ca94
27 changed files with 2739 additions and 0 deletions
@@ -0,0 +1 @@
# JingTian sample document generators
+60
View File
@@ -0,0 +1,60 @@
"""CJK font registration helper for reportlab using WenQuanYi Micro Hei TTF."""
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
_REGISTERED = False
CJK_FONT = "Helvetica" # fallback
def register_cjk_font():
"""Register a CJK font with reportlab. Returns the font name to use."""
global _REGISTERED, CJK_FONT
if _REGISTERED:
return CJK_FONT
# WenQuanYi Micro Hei: TTC with TrueType outlines (reportlab compatible)
# subfontIndex 0 = regular
candidates = [
"/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc",
"/usr/share/fonts/wqy-microhei/wqy-microhei.ttc",
]
for path in candidates:
try:
pdfmetrics.registerFont(TTFont("WenQuanYi", path, subfontIndex=0))
CJK_FONT = "WenQuanYi"
_REGISTERED = True
return CJK_FONT
except Exception:
continue
print("WARNING: No CJK font available, Chinese text may not render correctly")
_REGISTERED = True
return CJK_FONT
def _has_cjk(text: str) -> bool:
"""Check if text contains CJK characters."""
for ch in text:
cp = ord(ch)
if (
0x4E00 <= cp <= 0x9FFF # CJK Unified Ideographs
or 0x3400 <= cp <= 0x4DBF # CJK Extension A
or 0x3000 <= cp <= 0x303F # CJK Symbols
or 0xFF00 <= cp <= 0xFFEF # Fullwidth Forms
or 0x2E80 <= cp <= 0x2EFF # CJK Radicals
or 0xF900 <= cp <= 0xFAFF # CJK Compatibility
):
return True
return False
def draw_cjk_text(c, x, y, text, font_name="Helvetica", font_size=10):
"""Draw text, switching to CJK font if needed."""
cjk_font = register_cjk_font()
if _has_cjk(text) and cjk_font != "Helvetica":
c.setFont(cjk_font, font_size)
else:
c.setFont(font_name, font_size)
c.drawString(x, y, text)
@@ -0,0 +1,102 @@
"""Generate DOCX: Letter TO client from Jingtian & Gongcheng."""
from pathlib import Path
from docx import Document
from docx.shared import Pt, Inches, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
def generate(
output_path: Path,
firm: dict,
client: dict,
body: str,
closing: str,
ref_number: str,
date: str,
re_line: str,
):
"""Generate a formal letter from the firm to a client."""
doc = Document()
style = doc.styles["Normal"]
font = style.font
font.name = "Times New Roman"
font.size = Pt(11)
# Firm letterhead
header = doc.add_paragraph()
header.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = header.add_run(firm["name"])
run.bold = True
run.font.size = Pt(14)
run.font.color.rgb = RGBColor(0, 51, 102)
subheader = doc.add_paragraph()
subheader.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = subheader.add_run(firm.get("chinese_name", ""))
run.font.size = Pt(12)
run.font.color.rgb = RGBColor(0, 51, 102)
addr = doc.add_paragraph()
addr.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = addr.add_run(firm["address"])
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(100, 100, 100)
# Divider line
doc.add_paragraph("_" * 72).runs[0].font.color.rgb = RGBColor(180, 180, 180)
# Reference and date
ref_para = doc.add_paragraph()
ref_para.add_run(f"Our Ref: {ref_number}").font.size = Pt(10)
date_para = doc.add_paragraph()
date_para.add_run(f"Date: {date}").font.size = Pt(10)
doc.add_paragraph() # spacer
# Recipient
recipient = doc.add_paragraph()
recipient.add_run(f"{client['contact_person']}").font.size = Pt(11)
recipient.add_run("\n")
recipient.add_run(f"{client['name']}").font.size = Pt(11)
recipient.add_run("\n")
recipient.add_run(client["address"]).font.size = Pt(11)
doc.add_paragraph()
# RE line
re_para = doc.add_paragraph()
run = re_para.add_run(f"RE: {re_line}")
run.bold = True
run.underline = True
doc.add_paragraph()
# Body paragraphs — strip any closing already in body text
body_clean = body
for strip_phrase in [
"Yours sincerely,",
"Yours faithfully,",
"Kind regards,",
"Best regards,",
]:
if strip_phrase in body_clean:
body_clean = body_clean[: body_clean.index(strip_phrase)].rstrip()
for para_text in body_clean.split("\n\n"):
if para_text.strip():
doc.add_paragraph(para_text.strip())
doc.add_paragraph()
# Signature block
doc.add_paragraph("Yours faithfully,")
doc.add_paragraph()
sig = doc.add_paragraph()
run = sig.add_run(firm["attorney"]["name"])
run.bold = True
doc.add_paragraph("Partner")
doc.add_paragraph(firm["name"])
doc.save(str(output_path))
@@ -0,0 +1,68 @@
"""Generate DOCX: Internal memo."""
from pathlib import Path
from docx import Document
from docx.shared import Pt, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
def generate(output_path: Path, firm: dict, body: str, date: str, subject: str):
"""Generate an internal memo document."""
doc = Document()
style = doc.styles["Normal"]
font = style.font
font.name = "Arial"
font.size = Pt(11)
# MEMO header
title = doc.add_paragraph()
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = title.add_run("INTERNAL MEMORANDUM")
run.bold = True
run.font.size = Pt(16)
run.font.color.rgb = RGBColor(0, 51, 102)
# Confidential marker
conf = doc.add_paragraph()
conf.alignment = WD_ALIGN_PARAGRAPH.CENTER
run = conf.add_run("CONFIDENTIAL")
run.font.size = Pt(9)
run.font.color.rgb = RGBColor(200, 0, 0)
doc.add_paragraph("_" * 60).runs[0].font.color.rgb = RGBColor(180, 180, 180)
# Memo fields
fields = [
("TO:", firm["attorney"]["name"]),
("FROM:", firm["attorney"]["name"]),
("DATE:", date),
("RE:", subject),
]
for label, value in fields:
p = doc.add_paragraph()
run_label = p.add_run(f"{label}\t")
run_label.bold = True
run_label.font.size = Pt(11)
run_value = p.add_run(value)
run_value.font.size = Pt(11)
doc.add_paragraph("_" * 60).runs[0].font.color.rgb = RGBColor(180, 180, 180)
doc.add_paragraph()
# Body
for para_text in body.split("\n\n"):
if para_text.strip():
doc.add_paragraph(para_text.strip())
doc.add_paragraph()
# Sign-off
p = doc.add_paragraph()
run = p.add_run(firm["attorney"]["name"])
run.bold = True
doc.add_paragraph("Partner")
doc.add_paragraph(firm["name"])
doc.save(str(output_path))
@@ -0,0 +1,140 @@
"""Generate PNG: Email screenshot from client (Outlook-style)."""
from pathlib import Path
try:
from PIL import Image, ImageDraw, ImageFont
HAS_PIL = True
except ImportError:
HAS_PIL = False
def _get_font(size, bold=False):
"""Get a font with CJK support, falling back to default if unavailable."""
# Prefer WenQuanYi Micro Hei for CJK support
cjk_names = [
"/usr/share/fonts/wenquanyi/wqy-microhei/wqy-microhei.ttc",
]
latin_names = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
if bold
else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"
if bold
else "/usr/share/fonts/TTF/DejaVuSans.ttf",
]
# Try CJK font first (it includes Latin glyphs too)
for name in cjk_names:
try:
return ImageFont.truetype(name, size, index=0)
except (OSError, IOError):
continue
# Fall back to Latin fonts
for name in latin_names:
try:
return ImageFont.truetype(name, size)
except (OSError, IOError):
continue
return ImageFont.load_default()
def generate(
output_path: Path,
from_email: str,
from_name: str,
to_email: str,
to_name: str,
subject: str,
body: str,
date: str,
):
"""Generate an Outlook-style email screenshot as PNG."""
if not HAS_PIL:
# Fallback: write a text file
output_path.with_suffix(".txt").write_text(
f"From: {from_name} <{from_email}>\n"
f"To: {to_name} <{to_email}>\n"
f"Date: {date}\n"
f"Subject: {subject}\n\n{body}"
)
return
# Image dimensions (simulate a screen capture)
width, height = 800, 600
bg_color = (255, 255, 255)
header_bg = (242, 242, 242)
accent_color = (0, 120, 212) # Outlook blue
text_color = (51, 51, 51)
label_color = (130, 130, 130)
img = Image.new("RGB", (width, height), bg_color)
draw = ImageDraw.Draw(img)
font_header = _get_font(11, bold=True)
font_label = _get_font(10)
font_body = _get_font(11)
font_subject = _get_font(13, bold=True)
# Top bar (Outlook-style)
draw.rectangle([0, 0, width, 45], fill=accent_color)
draw.text((15, 12), "Mail - Outlook", fill=(255, 255, 255), font=font_header)
# Email header area
y = 55
draw.rectangle([0, 45, width, 200], fill=header_bg)
# Subject
draw.text((20, y), subject, fill=text_color, font=font_subject)
y += 30
# From
draw.text((20, y), "From:", fill=label_color, font=font_label)
draw.text((80, y), f"{from_name} <{from_email}>", fill=text_color, font=font_body)
y += 22
# Sent
draw.text((20, y), "Sent:", fill=label_color, font=font_label)
draw.text((80, y), date, fill=text_color, font=font_body)
y += 22
# To
draw.text((20, y), "To:", fill=label_color, font=font_label)
draw.text((80, y), f"{to_name} <{to_email}>", fill=text_color, font=font_body)
y += 22
# Subject line in header
draw.text((20, y), "Subject:", fill=label_color, font=font_label)
draw.text((80, y), subject[:60], fill=text_color, font=font_body)
y += 30
# Divider
draw.line([(15, y), (width - 15, y)], fill=(220, 220, 220), width=1)
y += 15
# Body text
for para in body.split("\n\n"):
for line in _wrap_text(para.strip(), 90):
if y > height - 30:
break
draw.text((25, y), line, fill=text_color, font=font_body)
y += 18
y += 8
img.save(str(output_path), "PNG")
def _wrap_text(text, max_chars):
"""Simple word-wrap."""
words = text.split()
lines = []
current = ""
for word in words:
if len(current) + len(word) + 1 > max_chars:
lines.append(current)
current = word
else:
current = f"{current} {word}" if current else word
if current:
lines.append(current)
return lines or [""]
@@ -0,0 +1,138 @@
"""Generate PDF: IPD Filing Receipt (native text PDF, bilingual)."""
from pathlib import Path
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from reportlab.lib.colors import HexColor
from generators.cjk_font import register_cjk_font
def _set_font(c, bold=False, size=10):
"""Set font with CJK support."""
cjk = register_cjk_font()
if cjk != "Helvetica":
c.setFont(cjk, size)
else:
c.setFont("Helvetica-Bold" if bold else "Helvetica", size)
def _has_cjk(text):
"""Check if text contains CJK characters."""
return any(ord(ch) > 0x2E80 for ch in text)
def generate(
output_path: Path,
ipd: dict,
tm_number: str,
tm_text: str,
applicant: str,
applicant_address: str,
agent: str,
agent_address: str,
filing_date: str,
response_deadline: str,
nice_class: str,
class_description: str,
):
"""Generate an IPD filing receipt PDF."""
cjk = register_cjk_font()
c = canvas.Canvas(str(output_path), pagesize=A4)
w, h = A4
def draw_text(x, y_pos, text, size=10, bold=False, centered=False):
"""Draw text, auto-switching to CJK font if needed."""
if _has_cjk(text) and cjk != "Helvetica":
c.setFont(cjk, size)
else:
c.setFont("Helvetica-Bold" if bold else "Helvetica", size)
if centered:
c.drawCentredString(x, y_pos, text)
else:
c.drawString(x, y_pos, text)
# Header
draw_text(w / 2, h - 50, ipd["name"], 12, bold=True, centered=True)
draw_text(w / 2, h - 66, ipd["chinese_name"], 10, centered=True)
draw_text(
w / 2,
h - 82,
"The Government of the Hong Kong Special Administrative Region",
10,
centered=True,
)
# Divider
c.setStrokeColor(HexColor("#333333"))
c.setLineWidth(1)
c.line(50, h - 95, w - 50, h - 95)
# Title
draw_text(
w / 2,
h - 120,
"E-Filing Receipt / Acknowledgment",
14,
bold=True,
centered=True,
)
# Receipt details
y = h - 160
fields = [
("Receipt No.:", f"EF-{tm_number[-6:]}"),
("Application No. / 申請編號:", tm_number),
("Trade Mark Text / 商標文字:", tm_text),
("Mark Type / 商標種類:", "Ordinary"),
("Class No. / 類別編號:", nice_class),
("Specification / 貨品/服務說明:", class_description),
("", ""),
("Applicant / 申請人:", applicant),
("Address / 地址:", applicant_address),
("", ""),
("Agent / 代理人:", agent),
("Agent Address / 代理人地址:", agent_address),
("", ""),
("Date of Filing / 提交日期:", filing_date),
("Date of Receipt / 確認日期:", filing_date),
]
for label, value in fields:
if not label and not value:
y -= 10
continue
draw_text(50, y, label, 10, bold=True)
# Wrap long values
if len(value) > 60:
draw_text(250, y, value[:60], 10)
y -= 16
draw_text(250, y, value[60:120], 10)
else:
draw_text(250, y, value, 10)
y -= 18
# Response deadline (highlighted)
y -= 15
c.setStrokeColor(HexColor("#cc0000"))
c.setLineWidth(0.5)
c.rect(40, y - 10, w - 80, 40, stroke=1, fill=0)
c.setFillColor(HexColor("#cc0000"))
draw_text(50, y + 12, "IMPORTANT / 重要通知:", 11, bold=True)
c.setFillColor(HexColor("#000000"))
draw_text(50, y - 4, f"Response deadline / 回覆限期: {response_deadline}", 10)
# Footer
y -= 50
c.setFillColor(HexColor("#666666"))
draw_text(
w / 2,
y,
"This is a computer-generated receipt. No signature is required.",
8,
centered=True,
)
draw_text(w / 2, y - 14, "此為電腦自動產生之收據,毋須簽署。", 8, centered=True)
c.save()
@@ -0,0 +1,144 @@
"""Generate PDF: Scanned letter FROM client (simulated scan effect)."""
import io
from pathlib import Path
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas
from reportlab.lib.colors import HexColor
try:
from PIL import Image, ImageFilter, ImageEnhance
import random as _random
HAS_PIL = True
except ImportError:
HAS_PIL = False
def _create_clean_pdf(
buffer, from_name, from_company, from_address, to_name, to_firm, body, date
):
"""Create a clean PDF letter."""
c = canvas.Canvas(buffer, pagesize=A4)
w, h = A4
# Company header
c.setFont("Helvetica-Bold", 14)
c.drawString(50, h - 60, from_company)
c.setFont("Helvetica", 9)
y = h - 78
for line in from_address.split(","):
c.drawString(50, y, line.strip())
y -= 14
# Date
y -= 20
c.setFont("Helvetica", 11)
c.drawString(50, y, date)
# Recipient
y -= 30
c.drawString(50, y, f"Mr. {to_name}")
y -= 16
c.drawString(50, y, to_firm)
y -= 16
c.drawString(50, y, "Suites 3203-3207, 32/F, Edinburgh Tower, The Landmark")
y -= 16
c.drawString(50, y, "15 Queen's Road Central, Central, Hong Kong")
# Body
y -= 35
c.setFont("Helvetica", 11)
for para in body.split("\n\n"):
for line_text in _wrap_text(para.strip(), 85):
if y < 80:
c.showPage()
y = h - 60
c.setFont("Helvetica", 11)
c.drawString(50, y, line_text)
y -= 16
y -= 10
# Signature area
y -= 20
c.drawString(50, y, "Yours sincerely,")
y -= 40
# Simulate a signature squiggle
c.setStrokeColor(HexColor("#1a1a8a"))
c.setLineWidth(1.5)
import random
random.seed(hash(from_name))
sx = 50
sy = y + 10
c.line(sx, sy, sx + 30, sy + 8)
c.line(sx + 30, sy + 8, sx + 50, sy - 5)
c.line(sx + 50, sy - 5, sx + 80, sy + 3)
c.setStrokeColor(HexColor("#000000"))
y -= 10
c.setFont("Helvetica-Bold", 11)
c.drawString(50, y, from_name)
y -= 16
c.setFont("Helvetica", 10)
c.drawString(50, y, from_company)
c.save()
def _wrap_text(text, max_chars):
"""Simple word-wrap."""
words = text.split()
lines = []
current = ""
for word in words:
if len(current) + len(word) + 1 > max_chars:
lines.append(current)
current = word
else:
current = f"{current} {word}" if current else word
if current:
lines.append(current)
return lines
def generate(
output_path: Path,
from_name: str,
from_company: str,
from_address: str,
to_name: str,
to_firm: str,
body: str,
date: str,
):
"""Generate a scanned-looking PDF letter."""
# First create a clean PDF in memory
buf = io.BytesIO()
_create_clean_pdf(
buf, from_name, from_company, from_address, to_name, to_firm, body, date
)
buf.seek(0)
if HAS_PIL:
# Convert to image, degrade slightly, save back as PDF
try:
from pdf2image import convert_from_bytes
images = convert_from_bytes(buf.read(), dpi=150)
if images:
img = images[0]
# Add slight grey tint (paper simulation)
enhancer = ImageEnhance.Brightness(img)
img = enhancer.enhance(0.95)
# Slight blur for scan effect
img = img.filter(ImageFilter.GaussianBlur(radius=0.3))
# Save as PDF
img.save(str(output_path), "PDF", resolution=150)
return
except Exception:
pass
# Fallback: save clean PDF as-is
output_path.write_bytes(buf.getvalue())
@@ -0,0 +1,165 @@
"""Generate PDF: Trademark registry record (mimics HK IPD format)."""
from pathlib import Path
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.colors import HexColor
from .cjk_font import register_cjk_font, _has_cjk
def generate(
output_path: Path,
ipd: dict,
tm_number: str,
tm_text: str,
status: str,
nice_class: str,
class_description: str,
applicant: str,
applicant_address: str,
agent: str,
agent_address: str,
filing_date: str,
publication_date: str,
):
"""Generate a TM registry record PDF (mimicking 306457384.pdf)."""
cjk = register_cjk_font()
c = canvas.Canvas(str(output_path), pagesize=A4)
w, h = A4
def draw_text(x, y_pos, text, size=10, bold=False, centered=False):
if _has_cjk(text) and cjk != "Helvetica":
c.setFont(cjk, size)
else:
c.setFont("Helvetica-Bold" if bold else "Helvetica", size)
if centered:
c.drawCentredString(x, y_pos, text)
else:
c.drawString(x, y_pos, text)
# ── Page 1: Basic Information ──────────────────────────────────
# Header
draw_text(w / 2, h - 45, ipd["chinese_name"], 11, bold=True, centered=True)
draw_text(w / 2, h - 60, ipd["name"], 10, centered=True)
draw_text(
w / 2,
h - 75,
"The Government of the Hong Kong Special Administrative Region",
9,
centered=True,
)
# Divider
c.setStrokeColor(HexColor("#333333"))
c.line(50, h - 88, w - 50, h - 88)
# Titles
draw_text(w / 2, h - 108, "商標記錄", 12, bold=True, centered=True)
draw_text(w / 2, h - 124, "Trade Mark Records", 12, bold=True, centered=True)
# Section: Basic Information
y = h - 150
draw_text(50, y, "基本資料 Basic information", 10, bold=True)
y -= 5
c.line(50, y, w - 50, y)
y -= 20
value_x = 280
fields_page1 = [
("[210/111]", "商標編號:\nTrade Mark No.:", tm_number),
("", "狀況:\nStatus:", status),
("", "商標文字:\nTrade Mark Text:", tm_text),
("[550]", "商標種類:\nMark Type:", "Ordinary"),
("[511]", "類別編號:\nClass No.:", nice_class),
("[511]", "貨品 / 服務說明:\nSpecification:", class_description),
]
for code, label, value in fields_page1:
if code:
c.setFont("Helvetica", 8)
c.drawString(50, y, code)
# Draw bilingual label
label_lines = label.split("\n")
for i, ll in enumerate(label_lines):
draw_text(105, y - (i * 14), ll, 9, bold=True)
# Draw value
draw_text(value_x, y, value, 10)
if len(value) > 55:
# Wrap
draw_text(value_x, y, value[:55], 10)
y -= 16
draw_text(value_x, y, value[55:110], 10)
y -= max(len(label_lines) * 14, 18) + 8
# Dates section
y -= 10
draw_text(50, y, "日期 (日日-月月-年年年年)", 10, bold=True)
c.setFont("Helvetica-Bold", 10)
c.drawString(50, y - 14, "Dates (DD-MM-YYYY)")
y -= 19
c.line(50, y, w - 50, y)
y -= 20
date_fields = [
("[220]", "提交日期:\nDate of Filing:", filing_date),
("[442]", "公布獲接納註冊申請日期:\nDate of Publication:", publication_date),
]
for code, label, value in date_fields:
c.setFont("Helvetica", 8)
c.drawString(50, y, code)
label_lines = label.split("\n")
for i, ll in enumerate(label_lines):
draw_text(105, y - (i * 14), ll, 9, bold=True)
draw_text(value_x, y, value, 10)
y -= max(len(label_lines) * 14, 18) + 8
# ── Page 2: Applicant/Owner ────────────────────────────────────
c.showPage()
y = h - 60
draw_text(50, y, "申請人/擁有人", 10, bold=True)
c.setFont("Helvetica-Bold", 10)
c.drawString(50, y - 14, "Applicant/Owner")
y -= 19
c.line(50, y, w - 50, y)
y -= 20
owner_fields = [
("[730]", "姓名/名稱:\nName:", applicant),
("[730]", "地址:\nAddress:", applicant_address),
("[842]", "類別:\nType:", "Incorporated"),
("[842]", "公司成立為法團的所在地:\nPlace of Incorporation:", "HONG KONG"),
(
"[750]",
"供送達文件的地址:\nAddress for Service:",
f"{agent}\n{agent_address}",
),
("[740]", "代理人詳情:\nAgent's Details:", f"{agent}\n{agent_address}"),
]
for code, label, value in owner_fields:
c.setFont("Helvetica", 8)
c.drawString(50, y, code)
label_lines = label.split("\n")
for i, ll in enumerate(label_lines):
draw_text(105, y - (i * 14), ll, 9, bold=True)
value_lines = value.split("\n")
for i, vl in enumerate(value_lines):
if len(vl) > 50:
draw_text(value_x, y - (i * 14), vl[:50], 10)
draw_text(value_x, y - ((i + 1) * 14), vl[50:100], 10)
else:
draw_text(value_x, y - (i * 14), vl, 10)
y -= max(len(label_lines), len(value_lines)) * 14 + 12
c.save()
@@ -0,0 +1,111 @@
"""Generate XLSX: Invoice/billing schedule."""
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
def generate(output_path: Path, rows: list, firm: dict):
"""Generate an invoice schedule Excel file."""
wb = Workbook()
ws = wb.active
ws.title = "Invoice Schedule"
# Styles
header_font = Font(name="Arial", size=12, bold=True, color="003366")
col_header_font = Font(name="Arial", size=10, bold=True, color="FFFFFF")
col_header_fill = PatternFill(
start_color="003366", end_color="003366", fill_type="solid"
)
data_font = Font(name="Arial", size=10)
currency_format = "#,##0"
thin_border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
# Title
ws.merge_cells("A1:G1")
ws["A1"] = f"{firm['name']} - Invoice Schedule"
ws["A1"].font = header_font
ws["A1"].alignment = Alignment(horizontal="center")
ws.merge_cells("A2:G2")
ws["A2"] = firm["address"]
ws["A2"].font = Font(name="Arial", size=8, color="666666")
ws["A2"].alignment = Alignment(horizontal="center")
# Column headers
headers = [
"Client",
"Matter Ref",
"TM Number",
"Description",
"Amount (HKD)",
"Due Date",
"Status",
]
col_widths = [25, 15, 14, 40, 15, 14, 12]
for i, (header, width) in enumerate(zip(headers, col_widths), start=1):
cell = ws.cell(row=4, column=i, value=header)
cell.font = col_header_font
cell.fill = col_header_fill
cell.alignment = Alignment(horizontal="center")
cell.border = thin_border
ws.column_dimensions[get_column_letter(i)].width = width
# Data rows
for row_idx, row_data in enumerate(rows, start=5):
ws.cell(row=row_idx, column=1, value=row_data["client"]).font = data_font
ws.cell(row=row_idx, column=2, value=row_data["matter_ref"]).font = data_font
ws.cell(row=row_idx, column=3, value=row_data["tm_number"]).font = data_font
ws.cell(row=row_idx, column=4, value=row_data["description"]).font = data_font
amount_cell = ws.cell(row=row_idx, column=5, value=row_data["amount_hkd"])
amount_cell.font = data_font
amount_cell.number_format = currency_format
amount_cell.alignment = Alignment(horizontal="right")
ws.cell(row=row_idx, column=6, value=row_data["due_date"]).font = data_font
status_cell = ws.cell(row=row_idx, column=7, value=row_data["status"])
status_cell.font = data_font
status_cell.alignment = Alignment(horizontal="center")
# Color-code status
status_colors = {
"Draft": "FFF3CD",
"Sent": "D1ECF1",
"Overdue": "F8D7DA",
"Paid": "D4EDDA",
}
if row_data["status"] in status_colors:
status_cell.fill = PatternFill(
start_color=status_colors[row_data["status"]],
end_color=status_colors[row_data["status"]],
fill_type="solid",
)
# Apply borders
for col in range(1, 8):
ws.cell(row=row_idx, column=col).border = thin_border
# Total row
total_row = 5 + len(rows)
ws.cell(row=total_row, column=4, value="TOTAL").font = Font(
name="Arial", size=10, bold=True
)
total_cell = ws.cell(
row=total_row,
column=5,
value=sum(r["amount_hkd"] for r in rows),
)
total_cell.font = Font(name="Arial", size=10, bold=True)
total_cell.number_format = currency_format
total_cell.alignment = Alignment(horizontal="right")
wb.save(str(output_path))