Created
October 7, 2018 15:22
-
-
Save goerz/d0e1a28be8f322bf7bad3e9b8672bc67 to your computer and use it in GitHub Desktop.
Script to generate a 3-page yearly calendar via luatex
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
"""Script to generate a yearly calendar via luatex. | |
Requires the `lualatex` executable to be in the PATH. | |
Requires the Rotis Semi Serif (55 Roman) and Rotis Semi Sans (55 Regular) | |
fonts, available at | |
https://www.linotype.com/49242/rotis-semi-serif-family.html | |
https://www.linotype.com/49198/rotis-semi-sans-family.html | |
You may edit the tex template in this script to use different fonts, or to only | |
use Rotis Sans. | |
The Jinja-templates for LaTeX are based on the ideas in | |
http://eosrei.net/articles/2015/11/latex-templates-python-and-jinja2-generate-pdfs | |
https://web.archive.org/web/20121024021221/http://e6h.de/post/11/ | |
The templates output for letter-sized paper; again, you can modify the template | |
to change this. | |
""" | |
import sys | |
import os | |
import subprocess | |
import shutil | |
import tempfile | |
import calendar | |
import click | |
import jinja2 | |
def weeknumber(d): | |
"""Week number for given day""" | |
return d.isocalendar()[1] | |
def get_num_weeks(year, month): | |
"""Get the number of weeks in the given month | |
This includes the week with the first of the month, but excludes the week | |
with the first of the next months. The result is either 4 or 5 | |
""" | |
cal = calendar.Calendar() | |
weeks = list(cal.monthdatescalendar(int(year), int(month))) | |
num_weeks = len(weeks) | |
if weeks[-1][-1].month != month: | |
num_weeks += -1 | |
return num_weeks | |
def get_row_week(year, month): | |
"""Return a string with tikz-tuples 'row/week' | |
In the result, row is the one-based weeknumber within the month (i.e. the | |
row at which it appears in the calendar), and week is the calendar week | |
number | |
""" | |
cal = calendar.Calendar() | |
weeks = list(cal.monthdatescalendar(int(year), int(month))) | |
out_tuples = [] | |
for row, week in enumerate(weeks): | |
if week[-1].month == month: | |
out_tuples.append("%d/%d" % (row+1, weeknumber(week[0]))) | |
return ", ".join(out_tuples) | |
def get_row_col_day(year, month): | |
"""Return a string with tikz-tuples 'row/col/day' | |
In the result, `row` is the one-based week number within the month (i.e. | |
the row at which it appears in the calendar), `col` is the one-based index | |
of the week day (i.e. the column at which it appears in the calendar), and | |
`day` is the day number within the month. | |
""" | |
cal = calendar.Calendar() | |
weeks = list(cal.monthdatescalendar(int(year), int(month))) | |
out_tuples = [] | |
for row, week in enumerate(weeks): | |
if week[-1].month == month: | |
for col, day in enumerate(week): | |
out_tuples.append("%d/%d/%d" % (row+1, col+1, day.day)) | |
return ", ".join(out_tuples) | |
LATEX_JINJA_ENV = jinja2.Environment( | |
block_start_string='\BLOCK{', | |
block_end_string='}', | |
variable_start_string='\VAR{', | |
variable_end_string='}', | |
comment_start_string='\#{', | |
comment_end_string='}', | |
line_statement_prefix='%%', | |
line_comment_prefix='%#', | |
trim_blocks=True, | |
autoescape=False, | |
) | |
MAIN_TEMPLATE = LATEX_JINJA_ENV.from_string(r''' | |
% compile with lualatex | |
\documentclass{article} | |
\usepackage{xcolor} | |
\usepackage[letterpaper, total={8.5in, 10.75in}]{geometry} % no margins | |
\usepackage[utf8]{inputenc} | |
\usepackage{fontspec} | |
\usepackage{tikz} | |
\usetikzlibrary{calc} | |
\pagestyle{empty} | |
\setlength{\parindent}{0in} | |
\definecolor{405U}{cmyk}{0.5,0.45,0.52,0.1} % PANTONE 405 U | |
\setmainfont[Color=405U]{RotisSemiSerifStd} | |
\setsansfont[Color=405U]{RotisSemiSansStd} | |
\renewcommand{\small}{\fontsize{6.0}{7.2}\selectfont} | |
\renewcommand{\normalsize}{\fontsize{8.0}{9.6}\selectfont} | |
\renewcommand{\large}{\fontsize{10.0}{12.0}\selectfont} | |
\renewcommand{\familydefault}{\sfdefault} % use sans-serif by default | |
\normalsize | |
\begin{document} | |
\tikzset{% | |
every picture/.style={line width=0.2pt, color=405U}, | |
thick/.style={line width=0.5pt}, | |
} | |
\pgfmathsetmacro{\Margin}{0.25} | |
\pgfmathsetmacro{\FullMonthHeight}{5.125} | |
\pgfmathsetmacro{\DayHeight}{0.8} | |
\pgfmathsetmacro{\DayWidth}{0.54} | |
\pgfmathsetmacro{\MondayExtraWidth}{0.653 - \DayWidth} | |
\pgfmathsetmacro{\FullMonthWidth}{\MondayExtraWidth + 7 * \DayWidth} | |
\begin{tikzpicture}[overlay, remember picture, x=1in, y=1in] | |
\begin{scope}[xshift=0.25in, yshift=-5.135in] | |
\VAR{month01} | |
\end{scope} | |
\begin{scope}[xshift=4.375in, yshift=-5.135in] | |
\VAR{month02} | |
\end{scope} | |
\begin{scope}[xshift=0.25in, yshift=-10.51in] | |
\VAR{month03} | |
\end{scope} | |
\begin{scope}[xshift=4.375in, yshift=-10.51in] | |
\VAR{month04} | |
\end{scope} | |
\end{tikzpicture} | |
\newpage | |
\begin{tikzpicture}[overlay, remember picture, x=1in, y=1in] | |
\begin{scope}[xshift=0.25in, yshift=-5.135in] | |
\VAR{month05} | |
\end{scope} | |
\begin{scope}[xshift=4.375in, yshift=-5.135in] | |
\VAR{month06} | |
\end{scope} | |
\begin{scope}[xshift=0.25in, yshift=-10.51in] | |
\VAR{month07} | |
\end{scope} | |
\begin{scope}[xshift=4.375in, yshift=-10.51in] | |
\VAR{month08} | |
\end{scope} | |
\end{tikzpicture} | |
\newpage | |
\begin{tikzpicture}[overlay, remember picture, x=1in, y=1in] | |
\begin{scope}[xshift=0.25in, yshift=-5.135in] | |
\VAR{month09} | |
\end{scope} | |
\begin{scope}[xshift=4.375in, yshift=-5.135in] | |
\VAR{month10} | |
\end{scope} | |
\begin{scope}[xshift=0.25in, yshift=-10.51in] | |
\VAR{month11} | |
\end{scope} | |
\begin{scope}[xshift=4.375in, yshift=-10.51in] | |
\VAR{month12} | |
\end{scope} | |
\end{tikzpicture} | |
\end{document} | |
''') | |
MONTH_TEMPLATE = LATEX_JINJA_ENV.from_string(r''' | |
\draw[thick, rounded corners=10pt] | |
(0,0) rectangle +(\FullMonthWidth, \FullMonthHeight); | |
% vertical grid lines | |
\foreach \x in {1,...,6}{% | |
\draw | |
(\MondayExtraWidth + \x * \DayWidth, \Margin + \BLOCK{ if num_weeks == 4 }1\BLOCK{ else }0\BLOCK{ endif } * \DayHeight) | |
-- +(0, \VAR{num_weeks} * \DayHeight); | |
} | |
% horizontal grid lines | |
\draw[thick] (0, \Margin + \BLOCK{ if num_weeks == 4 }1\BLOCK{ else }0\BLOCK{ endif } * \DayHeight) -- +(\FullMonthWidth, 0); | |
\foreach \y in {\BLOCK{ if num_weeks == 4 }2\BLOCK{ else } 1 \BLOCK{ endif },...,4}{% | |
\draw (0, \Margin + \y * \DayHeight) -- +(\FullMonthWidth, 0); | |
} | |
\foreach \row/\week in {% | |
\VAR{row_week_tuple} | |
} {% | |
\node[anchor=south west] | |
at (0, \Margin + 5 * \DayHeight - \row * \DayHeight) {\small w.\week}; | |
} | |
\foreach \row/\col/\day in {% | |
\VAR{row_col_day_tuple} | |
}{% | |
\node[anchor=base] at ($ | |
(\MondayExtraWidth + \col * \DayWidth - \DayWidth/2, | |
\Margin + 5 * \DayHeight - \row * \DayHeight) + | |
(0, 4pt) $){\day}; | |
} | |
\draw[thick] (0, \Margin + 5 * 0.8) -- +(3.875, 0); | |
\node[anchor=base] at ($ | |
(\MondayExtraWidth + 0*\DayWidth + \DayWidth/2, | |
\Margin + 5 * \DayHeight) + (0, 4pt) $){Monday}; | |
\node[anchor=base] at ($ | |
(\MondayExtraWidth + 1*\DayWidth + \DayWidth/2, | |
\Margin + 5 * \DayHeight) + (0, 4pt) $){Tuesday}; | |
\node[anchor=base] at ($ | |
(\MondayExtraWidth + 2*\DayWidth + \DayWidth/2, | |
\Margin + 5 * \DayHeight) + (0, 4pt) $){Wednesday}; | |
\node[anchor=base] at ($ | |
(\MondayExtraWidth + 3*\DayWidth + \DayWidth/2, | |
\Margin + 5 * \DayHeight) + (0, 4pt) $){Thursday}; | |
\node[anchor=base] at ($ | |
(\MondayExtraWidth + 4*\DayWidth + \DayWidth/2, | |
\Margin + 5 * \DayHeight) + (0, 4pt) $){Friday}; | |
\node[anchor=base] at ($ | |
(\MondayExtraWidth + 5*\DayWidth + \DayWidth/2, | |
\Margin + 5 * \DayHeight) + (0, 4pt) $){Saturday}; | |
\node[anchor=base] at ($ | |
(\MondayExtraWidth + 6*\DayWidth + \DayWidth/2, | |
\Margin + 5 * \DayHeight) + (0, 4pt) $){Sunday}; | |
\node[right] at (\Margin, \FullMonthHeight-0.225) | |
{{\rmfamily \large \VAR{month|upper}}}; | |
\node[left] at (\FullMonthWidth - \Margin, \FullMonthHeight-0.225) | |
{{\rmfamily \large \VAR{year}}}; | |
''') | |
@click.command() | |
@click.help_option('--help', '-h') | |
@click.argument('year') | |
@click.argument('outfile') | |
def main(year, outfile): | |
"""Generate a 3-page yearly calendar. | |
The OUTFILE must either have a tex or a pdf extension. If tex, the | |
resulting file should be compiled with lualatex: `lualatex OUTFILE`. It | |
requires Rotis fonts to be installed on your system. If pdf, the | |
compilation with lualatex will be done in the background and the resulting | |
pdf will be stored in OUTFILE. | |
""" | |
months = { | |
"month%02d" % month: MONTH_TEMPLATE.render(dict( | |
month=calendar.month_name[month], year=year, | |
num_weeks=get_num_weeks(year, month), | |
row_week_tuple=get_row_week(year, month), | |
row_col_day_tuple=get_row_col_day(year, month))) | |
for month in range(1, 13) | |
} | |
if outfile.endswith('.tex'): | |
with open(outfile, "w") as out_fh: | |
out_fh.write(MAIN_TEMPLATE.render(**months)) | |
elif outfile.endswith('.pdf'): | |
try: | |
dir = tempfile.mkdtemp() | |
with open(os.path.join(dir, 'calendar.tex'), "w") as out_fh: | |
out_fh.write(MAIN_TEMPLATE.render(**months)) | |
cmd = ['lualatex', '--halt-on-error', '--interaction=nonstopmode', | |
'calendar.tex'] | |
proc = subprocess.run(cmd, cwd=dir, stdout=subprocess.PIPE) | |
if proc.returncode != 0: | |
click.echo(proc.stdout) | |
proc.check_returncode() # raises SubprocessError | |
shutil.copy(os.path.join(dir, 'calendar.pdf'), outfile) | |
except (OSError, subprocess.SubprocessError) as exc_info: | |
click.echo(str(exc_info)) | |
sys.exit(1) | |
finally: | |
shutil.rmtree(dir, ignore_errors=True) | |
else: | |
click.echo("OUTFILE must have either a .pdf or a .tex extension") | |
sys.exit(1) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment