ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python+Pillow实现横向年度日历:从日期数据处理到像素级排版

Python+Pillow实现横向年度日历:从日期数据处理到像素级排版 你有没有想过把一整年的日子摊开成一张横图会是什么效果我最近用Python写了个小工具输入年份自动生成一张横向年度日历图。所谓横向不是传统挂历那样一月一页而是把12个月全部铺在同一张画面上——可以排成2行×6列也可以一整行排完12个月做成超长的年度时间轴海报。这个需求看起来简单真做起来却要同时处理日期数据、网格布局、中文字体、图片输出这些环节正好把Python日常开发里的几个基本功串起来了。想做自动化办公的、想练手Pillow图像处理的、还有做手账做规划的朋友这篇文章可以直接照着抄。1. 横向年度日历的核心思路与方案选型1.1 横向布局怎么选两条思路先说“横向”的理解。很多人以为横向就是画布横过来但其实这里有两种完全不同的成品形态第一种是2行×6列的横版挂历式。12个月分两排每排6个月整体画面接近A4纸横向比例。这种形态信息密度高、单月区块大打印出来贴墙或者放进年度规划本都很合适也是我最推荐的方式。第二种是1行×12个月的超长横幅。所有月份从左到右排开适合做成滚动长图、网页头图、年度时间轴看板。缺点很明显单月宽度会非常窄如果画布只有几千像素一个月的格子可能只有两三百像素宽日期数字很难放下。我的建议是做印刷品选2行×6列做屏幕浏览选1行×12月。这篇博文的主线路用2行×6列展开最后会给出改成1行12列的思路两种形态共用一套排版逻辑改参数就能切换。1.2 三条实现路线对比确定布局之后还要选实现方式。我实际调研过三条路线各有各的适用场景方案输出物控制精细度中文字体打印适配改造成本Pillow绘图PNG/JPG图片像素级控制所有元素自己画需手动指定字体文件可设置dpi代码量较大HTMLCSS模板HTML/PDF用CSS控制浏览器渲染自动使用系统字体浏览器打印最稳定需要一点CSS基础Excel/openpyxlxlsx文件单元格级控制系统字体中规中矩适合后期手动编辑如果只是临时做一张日历HTML方案确实更快打开浏览器按CtrlP就能输出PDF。但如果你想把日历做成自动化生成的海报、壁纸、宣传图Pillow是绕不开的核心技能。尤其是“像素级布局”这个能力放在任何图形输出需求里都能复用。1.3 为什么我把Pillow作为主方案我选Pillow还有一个私心它逼你把所有排版细节搞清楚。用HTML时CSS自动帮你处理居中、间距、换行用Pillow时每个文字坐标都要自己算每个矩形框都要自己画。这个过程很磨人但跑通一次之后你对“坐标系”“文本锚点”“网格计算”的掌控感会完全不一样。另外Pillow生成的图片是真正的图片可以直接用于公众号封面、PPT插图、打印海报不依赖浏览器环境。对自动化脚本来说这种“输入年份→输出图片”的闭环最干净。如果你的最终目的是快速交付一份可打印的日历表我建议先看第4章的HTML方案如果想长期自动生成日历图主线路走Pillow准没错。2. 日历数据准备标准库calendar三板斧2.1 为什么不用自己算日期你可能会想日历不就是判断大小月、闰年、然后填数字吗自己写逻辑确实能做但完全没有必要。Python标准库里的calendar模块就是干这个的它已经处理好了所有细节闰年判断、每月天数、每个日期对应星期几。先跑一行代码感受一下import calendar print(calendar.month(2025, 1))输出是一段规整的文本月历和命令行里cal命令效果类似。我们做绘图日历不需要这段文本而是要拿到结构化的日期数据所以用到的是monthcalendar方法。2.2 monthcalendar 返回的二维列表怎么读calendar.monthcalendar(year, month)返回一个二维列表长度通常是5或6每一行正好是7个元素代表周一到周日。举个例子真实的返回结果长这样# calendar.monthcalendar(2025, 2) [ [0, 0, 0, 0, 0, 1, 2], [3, 4, 5, 6, 7, 8, 9], ... ]这里的0表示“不是这个月的日期”。因为2025年2月1日恰好是周六所以第一行前五个位置都是0第六个位置才是1。这种占位设计非常巧妙让我们可以直接按固定网格去绘制不用担心月初和月末的偏移。不过有个坑monthcalendar可能返回5行也可能返回6行。比如某个月第一天是周日、最后一天是周一就会占满6行而普通月份往往只有5行。如果不处理绘制出来的月份高度就会出现“忽高忽低”的问题。2.3 把每个月补成规整的6行7列我的处理办法是统一补成6行。这也是整个“数据清洗”环节里最关键的一步def build_month_matrix(year, month): matrix calendar.monthcalendar(year, month) while len(matrix) 6: matrix.append([0, 0, 0, 0, 0, 0, 0]) return matrix为什么要补到6行因为排版时需要提前算好每个月区块的高度如果1月是5行、2月是6行、3月又是5行每个月的日期区域高度不一样最终画面会像波浪一样参差不齐。补完6行后每个月的格子结构完全一致后面绘制时只需要按固定行高填充。这一步做完日历数据基本就准备好了。接下来才是重头戏怎么把这些数字画成一张好看的图。3. 用Pillow绘制横向年度日历完整代码与排版细节3.1 画布尺寸与网格计算画布大小直接决定清晰度和打印效果。我常用的一组配置是2480×1748这个尺寸接近A4纸横向比例屏幕上看足够清楚打印出来也不差。如果要做严格意义上的高精度打印A4横向300dpi对应3508×2480像素直接把画布变量改大即可后续所有布局计算都是按比例缩放设计的不会崩。先看画布和网格计算的核心逻辑CANVAS_W, CANVAS_H 2480, 1748 # 接近A4横向打印可改成3508, 2480 MARGIN_X, MARGIN_Y 80, 80 # 画布四周留白 H_GAP, V_GAP 30, 40 # 月与月之间的横向、纵向间隙 ROWS, COLS 2, 6 # 2行6列布局 inner_w CANVAS_W - 2 * MARGIN_X inner_h CANVAS_H - 2 * MARGIN_Y cell_w (inner_w - (COLS - 1) * H_GAP) // COLS cell_h (inner_h - (ROWS - 1) * V_GAP) // ROWS这里用一个简单思路理解先把整个画布内部区域算出来减去月份之间的间隙剩下的宽度均分给6列、高度均分给2行。之所以预留H_GAP和V_GAP而不是让每个月区块无缝拼接是因为月份之间需要视觉分割。如果完全贴在一起整张图会显得拥挤而且用户很难快速分辨“哪块区域属于哪个月”。3.2 中文字体、配色与通用居中函数用Pillow做中文日历最大的坑就是默认字体不支持中文。ImageFont.load_default()只能画英文和数字直接画中文会得到一排方框。解决办法是加载系统里已有的中文字体文件import os def find_font(): candidates [ C:/Windows/Fonts/msyh.ttc, # Windows 微软雅黑 C:/Windows/Fonts/simhei.ttf, # Windows 黑体 /System/Library/Fonts/PingFang.ttc, # macOS 苹方 /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc, # Linux Noto /usr/share/fonts/truetype/wqy/wqy-microhei.ttc, # Linux 文泉驿 ] for path in candidates: if os.path.exists(path): return path raise FileNotFoundError(请手动指定系统中文字体路径)配色方面我习惯把所有颜色集中定义成常量。这样想换主题时不需要逐个修改绘制代码BG (247, 248, 250) # 背景浅灰 COL_MONTH (44, 62, 80) # 月份标题深蓝 COL_WEEKDAY (120, 130, 140) # 星期标题普通灰色 COL_WEEKEND_TITLE (231, 76, 60) # 周末标题红色 COL_DAY (50, 55, 60) # 普通日期深灰 COL_WEEKEND_DAY (231, 76, 60) # 周末日期红色 COL_TODAY_BG (255, 235, 120) # 今天高亮底色 COL_LINE (220, 224, 230) # 外框线还有一个很重要的函数是“居中绘制”。很多新手直接draw.text((x w/2, y h/2), text)结果发现文字往右下跑。原因是Pillow文字锚点默认在左上角必须减去文本自身的宽度和高度才能真正居中def draw_centered(draw, x, y, w, h, text, font, fill): bbox draw.textbbox((0, 0), text, fontfont) tw bbox[2] - bbox[0] th bbox[3] - bbox[1] tx x (w - tw) / 2 - bbox[0] ty y (h - th) / 2 - bbox[1] draw.text((tx, ty), text, fontfont, fillfill)这里利用了textbbox方法拿到文字精确边界再计算偏移量。刚开始可能觉得烦但这是让日期数字、星期标题都“乖乖居中”的关键。3.3 绘制流程拆解每个月的绘制流程可以拆成五步画一个圆角或直角矩形作为月份的边框。在区块顶部居中写“2025年1月”这样的月份标题。下一行依次写“一 二 三 四 五 六 日”其中周六和周日用红色。遍历之前准备好的6行7列矩阵跳过值为0的位置其余位置填入日期数字。对周末列的数字改用红色对“今天”或特殊日期画底色高亮。这里最需要留意的细节是星期标题和日期区域要严格分成上下两个区域。我习惯先把月份标题区域留出来再画星期标题最后从固定位置开始画日期。如果所有内容都挤在一起算坐标改一个参数就要重新调半天。3.4 完整可运行代码我把整个脚本整合成一份可直接运行的文件注释都写在关键位置import calendar import os from datetime import date from PIL import Image, ImageDraw, ImageFont # 基础配置 YEAR 2025 ROWS, COLS 2, 6 CANVAS_W, CANVAS_H 2480, 1748 MARGIN_X, MARGIN_Y 80, 80 H_GAP, V_GAP 30, 40 BG (247, 248, 250) COL_MONTH (44, 62, 80) COL_WEEKDAY (120, 130, 140) COL_WEEKEND_TITLE (231, 76, 60) COL_DAY (50, 55, 60) COL_WEEKEND_DAY (231, 76, 60) COL_TODAY_BG (255, 235, 120) COL_LINE (220, 224, 230) WEEK_NAMES [一, 二, 三, 四, 五, 六, 日] MARKS {(1, 1), (1, 29)} # 需要标注的(月, 日) # 字体 def find_font(): candidates [ C:/Windows/Fonts/msyh.ttc, C:/Windows/Fonts/simhei.ttf, /System/Library/Fonts/PingFang.ttc, /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc, /usr/share/fonts/truetype/wqy/wqy-microhei.ttc, ] for path in candidates: if os.path.exists(path): return path raise FileNotFoundError(没有找到中文字体请手动指定字体路径。) FONT_PATH find_font() FONT_TITLE ImageFont.truetype(FONT_PATH, int(CANVAS_H * 0.048)) FONT_WEEK ImageFont.truetype(FONT_PATH, int(CANVAS_H * 0.026)) FONT_DAY ImageFont.truetype(FONT_PATH, int(CANVAS_H * 0.027)) # 工具函数 def build_month_matrix(year, month): matrix calendar.monthcalendar(year, month) while len(matrix) 6: matrix.append([0] * 7) return matrix def draw_centered(draw, x, y, w, h, text, font, fill): bbox draw.textbbox((0, 0), text, fontfont) tw bbox[2] - bbox[0] th bbox[3] - bbox[1] draw.text((x (w - tw) / 2 - bbox[0], y (h - th) / 2 - bbox[1]), text, fontfont, fillfill) def is_today(month, day): today date.today() return today.year YEAR and today.month month and today.day day # 绘制 img Image.new(RGB, (CANVAS_W, CANVAS_H), BG) draw ImageDraw.Draw(img) inner_w CANVAS_W - 2 * MARGIN_X inner_h CANVAS_H - 2 * MARGIN_Y cell_w (inner_w - (COLS - 1) * H_GAP) // COLS cell_h (inner_h - (ROWS - 1) * V_GAP) // ROWS PAD_TOP int(CANVAS_H * 0.023) WEEK_GAP int(CANVAS_H * 0.063) DAY_GAP int(CANVAS_H * 0.065) def draw_month(row, col, month): x0 MARGIN_X col * (cell_w H_GAP) y0 MARGIN_Y row * (cell_h V_GAP) # 月份外框 draw.rectangle([x0, y0, x0 cell_w, y0 cell_h], outlineCOL_LINE, width3) # 月份标题 month_title f{YEAR} 年 {month} 月 draw_centered(draw, x0, y0 PAD_TOP, cell_w, int(CANVAS_H * 0.055), month_title, FONT_TITLE, COL_MONTH) # 星期标题 week_y y0 PAD_TOP WEEK_GAP week_w cell_w / 7.0 for i, name in enumerate(WEEK_NAMES): color COL_WEEKEND_TITLE if i 5 else COL_WEEKDAY draw_centered(draw, x0 i * week_w, week_y, week_w, int(CANVAS_H * 0.040), name, FONT_WEEK, color) # 日期网格 matrix build_month_matrix(YEAR, month) day_area_top week_y DAY_GAP day_area_h y0 cell_h - day_area_top row_h day_area_h / 6.0 for r in range(6): for c in range(7): day matrix[r][c] if day 0: continue color COL_WEEKEND_DAY if c 5 else COL_DAY # 今天高亮 if is_today(month, day): draw.rounded_rectangle( [x0 c * week_w 8, day_area_top r * row_h 6, x0 (c 1) * week_w - 8, day_area_top (r 1) * row_h - 6], radius14, fillCOL_TODAY_BG ) # 特殊日期小圆点标注 if (month, day) in MARKS: draw.ellipse( (x0 c * week_w week_w / 2 - 6, day_area_top r * row_h row_h - 20, x0 c * week_w week_w / 2 6, day_area_top r * row_h row_h - 8), fill(222, 90, 90) ) draw_centered(draw, x0 c * week_w, day_area_top r * row_h, week_w, row_h, str(day), FONT_DAY, color) for idx in range(12): row, col divmod(idx, COLS) draw_month(row, col, idx 1) img.save(fcalendar_{YEAR}_horizontal.png, dpi(300, 300))跑完这段代码你会得到一张横向年度日历图1到6月在上排7到12月在下排周六周日全是红色今天的日期会有一个黄色底块。如果设置为历史年份比如生成2020年的日历因为年份不匹配高亮逻辑就不会触发这属于正常现象。4. 进阶玩法主题配色、节日标注与HTML输出4.1 用主题字典快速换皮肤基础版跑通之后你大概率会想换颜色。我建议把配色升级成字典结构THEME { bg: (245, 250, 245), month: (56, 108, 92), weekday: (120, 140, 130), weekend: (196, 82, 82), day: (50, 55, 60), today_bg: (255, 235, 120), line: (210, 225, 215), }然后在绘制函数里用THEME[month]替代原来的COL_MONTH常量。换主题时只需要改字典完全不需要动绘制逻辑。我做过的几个主题里最受欢迎的是“森林绿”和“莫兰迪灰”前者护眼后者适合办公室打印。4.2 给节假日和纪念日加标记节日标注可以做得非常简单维护一个(月, 日)的集合绘制时如果命中就在日期下面画一个小圆点或者直接在数字下方写两个字。MARKS { (1, 1): 元旦, (1, 29): 春节, # 以当年实际农历换算为准 }注意农历节日每年对应的公历日期不同如果你只是生成2025年这一年的日历手动维护一份就够了。如果要做成每年自动更新的工具可以用chinese_calendar这类第三方库来判断工作日和节假日但直接塞进图片渲染时要注意第三方库自带数据的维护时效。4.3 免绘图HTML模板输出矢量日历Pillow能做图但如果你想要一份可以随时编辑、打印又清晰的日历我推荐用HTML模板方案。核心思路很简单用Python循环生成HTML字符串再用CSS的Grid布局排版。关键结构大概长这样div classyear-grid div classmonth div classmonth-title2025年1月/div div classweekdays span一/spanspan二/span...span日/span /div div classdays !-- 日期格子 -- /div /div /divCSS部分最核心的两行.year-grid { display: grid; grid-template-columns: repeat(6, 1fr); } .days { display: grid; grid-template-columns: repeat(7, 1fr); }浏览器会自动处理字体渲染、居中和间距中文再也不会乱码。你只需要把HTML文件打开按CtrlP打印成PDF就能得到一份矢量级清晰的日历。这个方案的优点是几乎不依赖Pillow缺点是排版细节由CSS控制想精细调整背景色、间距时需要一点前端功底。4.4 Excel版本思路如果你需要在日历里填写工作安排Excel版本会是更好的选择。用openpyxl可以生成一个12个月横排的xlsx每个月占用7列两个月之间空1列两行排满6个月。月份标题用merge_cells合并单元格日期格子用Border画边框。这块我不展开完整代码了核心思路是把build_month_matrix拿到的二维列表按偏移量写到对应单元格。比Pillow简单的地方在于不用算坐标比HTML麻烦的地方在于样式调整需要逐个单元格设置。适合“日历日程表”二合一场景。5. 常见问题与排查技巧实录5.1 中文显示成方框这是Pillow日历最常见的翻车点几乎每个人都遇到过。原因只有一个加载的字体不支持中文。ImageFont.load_default()只覆盖ASCII字符画中文必现方框。解决办法就是本文第3章的find_font()函数主动去系统里找中文字体文件。如果代码跑在不同的系统上建议把find_font()放在配置阶段报错时能第一时间知道是字体路径问题而不是绘制逻辑问题。5.2 日期挤成一团日历版的“横坐标太密集”很多人在用matplotlib画图时遇到过横坐标太密集、日期标签叠成黑疙瘩的问题。日历绘制里同样会出现“数字挤成一团”的现象通常是因为画布太小、字体太大或者格子内边距不足。我的经验是字体大小控制在格子高度的30%左右最舒服。比如月格子的可用高度是700像素日期字号设置成40到50像素就合适如果字号超过6031号这种两位数很容易贴边甚至溢出。当画布整体放大时记得按比例重新计算字号不要沿用旧值。如果你非要用matplotlib画月历热力图横坐标密集时可以用plt.xticks([1, 5, 10, 15, 20, 25, 30])只显示关键刻度或者plt.xticks(rotation45)旋转标签。但做日历海报我还是推荐Pillow坐标轴这种东西在日历场景里根本不需要。5.3 月份高度对不齐第一次写出完整脚本时我生成的日历像波浪线每个月高度都不一样。原因就是monthcalendar返回的月份可能是5行也可能是6行。修复方法很简单就是循环补0到6行。补完之后每个月的日期区域高度严格一致画面立刻整齐了。5.4 打印模糊、尺寸不准生成图片在屏幕上看着挺清晰打印出来却明显发虚多半是画布分辨率不够。打印行业有个基础换算公式像素 物理尺寸英寸× dpi。A4纸横向是11.69英寸宽、8.27英寸高300dpi下对应3508×2480像素。如果你只是屏幕预览2480×1748完全够用要拿去打印请把画布改到300dpi标准。输出PNG时再带上dpi(300, 300)参数图片的物理尺寸信息也会被正确写入。5.5 文字居中偏差用draw.text((x w / 2, y h / 2), text)画出来的文字永远偏右下方。我第一次写居中函数时也偷懒过后来才意识到Pillow的默认锚点是文字左上角不是中心。正确做法是先通过textbbox拿到文字精确宽高再偏移坐标也就是本文里的draw_centered函数。这个函数建议单独封装后续加月份标题、星期标题、日期数字都会用到。6. 我的实际使用体会与扩展建议6.1 我平时怎么用这份日历写这个工具最初是为了解决我自己的问题。我每年年底都要做下一年计划传统做法是买一本厚厚的手账或者打印一堆零散的月历表东一张西一张根本对不上。生成横向年度日历后我把整年贴在工位侧面所有项目节点、出差安排、活动截止日都直接用铅笔往上写写完再擦特别方便。后来我发现这份日历还有个隐藏用法作为“年度复盘底图”。每个月末把当月完成的大事写在对应月份格子里到年底你就能看到一整年做了哪些事比流水账直观得多。我建议做手账的朋友把日期字号调小一些留出更多空白区域用来写字。6.2 可以继续扩展的方向这个项目往下走还有很多玩法。比如接一个CSV文件里面是你的待办事项和截止日期脚本自动把截止日期在日历上标成红点再比如引入农历和月相数据生成一张适合看月亮节点的日历还可以改成动态版每天自动高亮当天日期作为桌面小组件或网页挂件。我个人最想做的扩展是“连续时间段管理日历”比如高考倒计时100天、新员工培训周期、项目冲刺阶段把不同时间段用不同颜色横跨标注在横版日历上。本质上还是这张横向年度日历只是把“标注日期”升级成“标注区间”。这套排版框架一旦跑通换成甘特图、排课表、值日表也就只是改数据和改配色的问题。最后再分享一个小技巧我生成图片后通常还会顺手用HTML版打印一份因为图片适合贴墙展示HTML适合带出门随时查看。两份东西一个靠Pillow一个靠浏览器互补着用体验最好。
返回列表