My New Calendar Template (Engine) 🚀

Every year I am doing a calendar with family photos for the, eh, family. This is typically an about A4-sized printed monthly calendar, individual for each family member (as needed for these modern patchwork families). And twice, I even managed to make a pixls.us PlayRaw calendar. As I don’t want to rely on the templates of the calendar printers (my choice is saal-digital), i am doing my own design which has some hidden gimmicks such as moon phase indicators and indicators for the solstices. Also, I want a clean look with one full size image per month, or maybe two half size images.

So there are two things to prepare: The calendar “strips”, meaning a list of days with the weekend days in different color and the indicators mentioned above, and a template to integrate these strips with the pictures. For each month i am selecting either a left side or right side strip, whatever distracts less or gives a better composition.

Placing and cropping the images to the end format in combination with the strip is still something that sometimes needs fine tuning. Typically I crop to the end format in darktable using a custom aspect ratio that matches the one of the final print and using the exact pixel size used for the print (luckily, saal-digital has theese informations on their website). Hint: You can enter a special aspect ratio by just entering it into the unfolded selection of available aspect ratios such as 2847:1591 (or whatever you need). The final tuning is a very visual process, so i used scribus in the beginning, but since inkscape has multi-page support I switched over. In both of the tools, i import and align the template pdf on each page and save it as template, and this template is then used as basis for the individual calendars where i insert the images, delete one of the two strips on each page and sometimes fine-tune crop and position.

Until this year I have used LaTeX for the generation of the calendar strip template, as it has many benefits to automate this step and not enter and style every day in a wysiwyg software. However, when i heard about typst the first time i knew i wanted to try to replace my LaTeX approach and automate more of the process. Don’t be fooled by the typst website, typst is free and libre open source software and can be run locally just as LaTeX, but with the difference that typst comes in an almost-all-inclusive 40 MB binary including basic fonts instead of a 4 GB TeX distribution, and that, written in rust and using modern paradigms and utilizing modern hardware quite well, it is orders of magnitudes faster than LaTeX.

It needed some time to understand the scripting capabilities of typst. I have more than 25 years of experience with LaTeX, but I am not too proficient in TeX programming. I mean, I have been able to implement what i needed, but TeX does not feel simple. To me, typst’s scripting language is much more approachable. Hacking together the template took me about two hours starting from zero knowledge of the language, and only the most simple problem required me to approach the friendly typst community for help – i think it was mainly because i tried to solve the problem the same way i did in LaTeX, such that i did not see the obvious and simple typst solution.

If you read until here and wonder why i am writing all of this … this post made me want to share my recent findings. And after a failed attempt last time with the LaTeX template, where I did not make it beyond part 1 of 2 of the article, I also wanted to have the calendar template released to the community in time for the holiday season this year. Below you can therefore find the three components needed:

  1. A python script that calculates accurate moon phase data for a given year and time zone as input for typst (until last year i manually copy-pasted tables from the internet into a LaTeX-suitable format, so this is muuuuch better).
  2. The pre-calculated moon phase data for this year for my location for your convenience (a json file).
  3. The typst script itself, which you can, avoiding the web app, compile with the binary from here – it does not even need installation, it’s just a binary. Of course you can also use another distribution method, there might be flatpacks and snaps around, but the developers still consider typst a beta release, so there might not yet be packages in your distro’s repositories.

I hope you have as much fun as i have with this approach.

from zoneinfo import ZoneInfo
from datetime import datetime, timedelta
from skyfield import almanac
from skyfield.api import load
import json

def nearest_minute(dt):
    '''
    Add 30 seconds to time for functions that do not round time correctly.

    Parameters
    ----------
    dt : timelib.Time
        A time.

    Returns
    -------
    timelib.Time
        A time 30 seconds later.

    '''
    return dt + timedelta(seconds=30)  #.replace(second=0, microsecond=0)


YEAR = 2026


ts = load.timescale()
eph = load('de421.bsp')

zone = ZoneInfo("Europe/Berlin")

d0 = datetime.fromisoformat(str(YEAR) + '-01-01')
d1 = datetime.fromisoformat(str(YEAR+1) + '-01-01')
d0 = d0.replace(tzinfo=zone)
d1 = d1.replace(tzinfo=zone)

t0 = ts.from_datetime(d0)
t1 = ts.from_datetime(d1)

t, y = almanac.find_discrete(t0, t1, almanac.moon_phases(eph))

t = nearest_minute(t)  # this time is off by 30 seconds and only for rounding
                       # to minutes correctly
dtx = t.astimezone(zone)

datelist = []
for k, l in zip(dtx, y):
    print(k.strftime('%Y-%m-%d') + " - " + str(l) + " - " + almanac.MOON_PHASES[l])
    datelist.append((k.strftime('%Y-%m-%d'), float(l)))

with open('2026-moon_phases.json', 'w', encoding='utf-8') as f:
    json.dump(datelist, f, ensure_ascii=False, indent=4)
[
    [
        "2026-01-03",
        2.0
    ],
    [
        "2026-01-10",
        3.0
    ],
    [
        "2026-01-18",
        0.0
    ],
    [
        "2026-01-26",
        1.0
    ],
    [
        "2026-02-01",
        2.0
    ],
    [
        "2026-02-09",
        3.0
    ],
    [
        "2026-02-17",
        0.0
    ],
    [
        "2026-02-24",
        1.0
    ],
    [
        "2026-03-03",
        2.0
    ],
    [
        "2026-03-11",
        3.0
    ],
    [
        "2026-03-19",
        0.0
    ],
    [
        "2026-03-25",
        1.0
    ],
    [
        "2026-04-02",
        2.0
    ],
    [
        "2026-04-10",
        3.0
    ],
    [
        "2026-04-17",
        0.0
    ],
    [
        "2026-04-24",
        1.0
    ],
    [
        "2026-05-01",
        2.0
    ],
    [
        "2026-05-09",
        3.0
    ],
    [
        "2026-05-16",
        0.0
    ],
    [
        "2026-05-23",
        1.0
    ],
    [
        "2026-05-31",
        2.0
    ],
    [
        "2026-06-08",
        3.0
    ],
    [
        "2026-06-15",
        0.0
    ],
    [
        "2026-06-21",
        1.0
    ],
    [
        "2026-06-30",
        2.0
    ],
    [
        "2026-07-07",
        3.0
    ],
    [
        "2026-07-14",
        0.0
    ],
    [
        "2026-07-21",
        1.0
    ],
    [
        "2026-07-29",
        2.0
    ],
    [
        "2026-08-06",
        3.0
    ],
    [
        "2026-08-12",
        0.0
    ],
    [
        "2026-08-20",
        1.0
    ],
    [
        "2026-08-28",
        2.0
    ],
    [
        "2026-09-04",
        3.0
    ],
    [
        "2026-09-11",
        0.0
    ],
    [
        "2026-09-18",
        1.0
    ],
    [
        "2026-09-26",
        2.0
    ],
    [
        "2026-10-03",
        3.0
    ],
    [
        "2026-10-10",
        0.0
    ],
    [
        "2026-10-18",
        1.0
    ],
    [
        "2026-10-26",
        2.0
    ],
    [
        "2026-11-01",
        3.0
    ],
    [
        "2026-11-09",
        0.0
    ],
    [
        "2026-11-17",
        1.0
    ],
    [
        "2026-11-24",
        2.0
    ],
    [
        "2026-12-01",
        3.0
    ],
    [
        "2026-12-09",
        0.0
    ],
    [
        "2026-12-17",
        1.0
    ],
    [
        "2026-12-24",
        2.0
    ],
    [
        "2026-12-30",
        3.0
    ]
]
#let calendar(year: "", body) = {

  let mp = json("2026-moon_phases.json")
  let dict = mp.to-dict()
  let sw = ("2026-06-21", "2026-12-21")
  let mps = ("0": "🌑︎", "1": "🌓︎", "2": "🌕︎", "3": "🌗︎")
  
  set text(font: "Linux Biolinum O")
  set document(title: str(year) + " calendar")

  for month in range(1, 13) [

    #let month_date = datetime(
      year: year,
      month: month,
      day: 1,
    )

    #let monthly_days = ()

    #for day in range(0, 31) [
      #let month_accumulator = (month_date + duration(days: day))
      #if month_accumulator.month() != month {
        break
      }
      #monthly_days.push(month_accumulator)
    ]

    #for lr in range(2) [
      #let curcol = if lr == 1 { (auto, 1fr) } else { (1fr, auto) }

      #let a = table(
          columns: (auto),
          align: right,
          inset: 0% + 4pt,
          stroke: 0pt, //.1pt + black,
          ..monthly_days.map(day => {
            let qs = day.display()
            let sunstr = if sw.contains(qs) [#set text(font: "Noto Emoji")
🌞︎︎]
            let moonstr = if (qs in dict) [#set text(font: "Noto Emoji"); #mps.at(str(dict.at(qs)))]
            if day.weekday() in (6, 7) [
              #sunstr
              #moonstr
              #set text(size: 12pt, gray)
              #day.display("[day padding:none]")
            ] else [
              #sunstr
              #moonstr
              #set text(size: 12pt, black)
              #day.display("[day padding:none]")
            ]
          }
        )
      )

      #let b = text(size: 27pt, align(right, rotate(-90deg, reflow: true, origin: top + right, [#month_date.display("[month repr:long]")])))

      #grid(
        columns: curcol,
        align: (right, left),
        column-gutter: 10pt,
        if lr == 1 {b} else {a},
        if lr == 1 {a} else {b}
      )
      #pagebreak(weak: true)
    ]
  ]
}

#set page(height: 21cm, width: 5cm, margin: 10mm, fill: white.transparentize(50%))

//#set text(font: "TeX Gyre Pagella", size: 9pt)


#show: calendar.with(
  year: 2026  // datetime.today().year() + 1,
)

I know, the solstice days are still hard-coded in the file, i leave it as a homework to the reader to extend the implementation for automated solstice handling :wink:.

calendar_strips.pdf (37.4 KB)

11 Likes

Thanks, this is really great. With a little nudge to include (bank) holidays, as it is always good to know when I won’t have to work :stuck_out_tongue: , it will be perfect.

It inspires me to make a calendar this year. I was trying to do it 2 years ago but gave up after doing so much manual labor in GIMP and not really liking the result that much. This is definitely a better way.

1 Like

Great to see your tradition is alive and well. Trying new things leads to discoveries.

2 Likes

the holidays package is perfect for this holidays · PyPI I’m using it for my work projects

1 Like

I quickly tried this package independent of the calendar, and now i’m hooked. Now i have to decide if i want to include holidays in the calendar, and if yes how i want to do it design-wise … :see_no_evil:.

If you have suggestions, feel free …

2 Likes

Thanks a lot for sharing! I’m going to use this for the calendar I’m currently working on.

For this year’s calendar, I’ll be using both landscape and portrait orientated photos, so based on the photo, I want to use a vertical or horizontal strip (something which I’m not sure saal-digital’s software can do; anyway it’s pretty cumbersome to use, so I want to give it a try doing all the layout on my own).

In case others find it useful, I extended your script to also add horizontal strips. It’s my first time working with typst, so it could probably be done in a nicer way (especially the column widths…). I’m happy to finally have an opportunity to give typst a try :).
Since I want the months in German, I specified the month names manually at the top (so can easily be adjusted to whatever language you want).

#let YEAR = 2026

#let months = ("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember")

#let translated_month(dt) = months.at(dt.month() - 1)

#let calendar(year: "", body) = {

  let symbols_font = "Noto Emoji"

  let mp = json("moon_phases.json")
  let dict = mp.to-dict()
  let sw = ("2026-06-21", "2026-12-21")
  let mps = ("0": "🌑︎", "1": "🌓︎", "2": "🌕︎", "3": "🌗︎")

  set text(font: "Linux Biolinum O")
  set document(title: str(year) + " calendar")

  for month in range(1, 13) [

    #let month_date = datetime(
      year: year,
      month: month,
      day: 1,
    )

    #let monthly_days = ()

    #for day in range(0, 31) [
      #let month_accumulator = (month_date + duration(days: day))
      #if month_accumulator.month() != month {
        break
      }
      #monthly_days.push(month_accumulator)
    ]

    // ===========================
    #set page(height: 5cm, width: 21cm, margin: 10mm, fill: white.transparentize(50%))

    #let days_strip = table(
        columns: monthly_days.len(),
        rows: 2,
        align: center,
        inset: 0% + 4pt,
        stroke: 0pt, //.1pt + black,

        ..monthly_days.map(day => {
          if day.weekday() in (6, 7) [
            #set text(size: 12pt, luma(130))
            #day.display("[day padding:none]")
          ] else [
            #set text(size: 12pt, black)
            #day.display("[day padding:none]")
          ]
        }),

        ..monthly_days.map(day => {
          let qs = day.display()
          let sunstr = if sw.contains(qs) [#set text(font: symbols_font)
  🌞︎︎]
          // the else case adds a white moon symbol to achieve equal width of all columns (there is likely a better way for this...)
          let moonstr = if (qs in dict) [#set text(font: symbols_font, size:0.8em); #mps.at(str(dict.at(qs)))] else if not sw.contains(qs) [#set text(font: symbols_font, size:0.8em, white); 🌕︎] 

          [#moonstr #sunstr]
        }),


    )

    #let month_header = text(size: 27pt, align(left, [#translated_month(month_date)]))

    #set par(spacing:0.5em)
    #month_header
    #days_strip

    #pagebreak(weak: true)
    // ===========================

    #set page(height: 21cm, width: 5cm, margin: 10mm, fill: white.transparentize(50%))

    #for lr in range(2) [
      #let curcol = if lr == 1 { (auto, 1fr) } else { (1fr, auto) }

      #let a = table(
          columns: (auto),
          align: right,
          inset: 0% + 4pt,
          stroke: 0pt, //.1pt + black,
          ..monthly_days.map(day => {
            let qs = day.display()
            let sunstr = if sw.contains(qs) [#set text(font: symbols_font)
🌞︎︎]
            let moonstr = if (qs in dict) [#set text(font: symbols_font); #mps.at(str(dict.at(qs)))]
            if day.weekday() in (6, 7) [
              #sunstr
              #moonstr
              #set text(size: 12pt, gray)
              #day.display("[day padding:space]")
            ] else [
              #sunstr
              #moonstr
              #set text(size: 12pt, black)
              #day.display("[day padding:space]")
            ]
          }
        )
      )

      #let b = text(size: 27pt, align(right, rotate(-90deg, reflow: true, origin: top + right, [#translated_month(month_date)])))

      #grid(
        columns: curcol,
        align: (right, left),
        column-gutter: 10pt,
        if lr == 1 {b} else {a},
        if lr == 1 {a} else {b}
      )
      #pagebreak(weak: true)
    ]
  ]
}

//#set page(height: 21cm, width: 5cm, margin: 10mm, fill: white.transparentize(50%))



#show: calendar.with(
  year: YEAR
)
1 Like

I think the easiest (and not uncommon) approach would be to simply highlight them in the same way as weekends.

1 Like

I made a few more modifications:

  • Add pubic holidays. This is semi-automatic for now. A python script is used to get the list of dates (using the ‘holidays’ package, thanks for the suggestion @piratenpanda). They are then manually copy-pasted into the typst script.
  • To avoid confusion, Saturdays are no longer highlighted, only Sundays and holidays.
  • Always spread the dates over the whole available space. This means, spacing between days will be larger on months with viewer days. The cell for each day has the same width, independent on the text width. The moons are also more nicely aligned now in the vertical strips.
    I switched to using grid instead of table for this but maybe the same would have been possible with a table (I’m new typst, so I don’t know).
  • Adjusted sizes to a 305x305 mm calendar. So you may need to modify this if your calendar has a different size.

get_holidays.py (can be executed with uv, using uv run get_holidays.py to automatically install the dependencies). Adjust region in country_holidays to where you life:

#!/usr/bin/python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "holidays",
# ]
# ///

import holidays

def main() -> None:
    hd = holidays.country_holidays("DE", subdiv="BW", years=2026)

    dates = [date for date in sorted(hd.keys())]
    print("let holidays = (" + ", ".join(f'"{date}"' for date in dates) + ")")


if __name__ == "__main__":
    main()

And the updated typst script:

#let YEAR = 2026

#let months = ("Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember")

#let translated_month(dt) = months.at(dt.month() - 1)

#let calendar(year: "", body) = {

  let size_long = 305mm
  let size_short = 55mm
  let margin_horizontal = (bottom: 0mm, rest: 10mm)
  let margin_vertical = (top: 15mm, rest: 10mm)
  let font_size_monthname = 42pt
  let font_size_days = 18pt
  let symbols_font = "Noto Emoji"

  let mp = json("moon_phases.json")
  let dict = mp.to-dict()
  let sw = ("2026-06-21", "2026-12-21")
  let mps = ("0": "🌑︎", "1": "🌓︎", "2": "🌕︎", "3": "🌗︎")

  let holidays = ("2026-01-01", "2026-01-06", "2026-04-03", "2026-04-06", "2026-05-01", "2026-05-14", "2026-05-25", "2026-06-04", "2026-10-03", "2026-11-01", "2026-12-25", "2026-12-26")

  set text(font: "Linux Biolinum O")
  set document(title: str(year) + " calendar")

  for month in range(1, 13) [

    #let month_date = datetime(
      year: year,
      month: month,
      day: 1,
    )

    #let monthly_days = ()

    #for day in range(0, 31) [
      #let month_accumulator = (month_date + duration(days: day))
      #if month_accumulator.month() != month {
        break
      }
      #monthly_days.push(month_accumulator)
    ]

    // ===========================
    #set page(height: size_short, width: size_long, margin: margin_horizontal, fill: white.transparentize(50%))

    #let days = monthly_days.map(day => {
        if day.weekday() in (7,) or holidays.contains(day.display()) [
          #set text(size: font_size_days, gray)
          #day.display("[day padding:none]")
        ] else [
          #set text(size: font_size_days, black)
          #day.display("[day padding:none]")
        ]
      }
    )
    #let moons = monthly_days.map(day => {
      let qs = day.display()
      let sunstr = if sw.contains(qs) [#set text(font: symbols_font); 🌞︎︎]
      let moonstr = if (qs in dict) [#set text(font: symbols_font); #mps.at(str(dict.at(qs)))]

      [#sunstr #moonstr]
    })

    #let days_strip = grid(
      columns: range(monthly_days.len()).map(i => 1fr),
      rows: (auto, 7mm),
      row-gutter: 4mm,
      align: center,
      ..days,
      ..moons,
    )


    #let month_header = text(size: font_size_monthname, align(left, [#translated_month(month_date)]))

    #set par(spacing:0.5em)
    #month_header
    #days_strip

    #pagebreak(weak: true)
    // ===========================

    #set page(height: size_long, width: size_short, margin: margin_vertical, fill: white.transparentize(50%))

    #for lr in range(2) [
      #let curcol = if lr == 1 { (auto, 1fr) } else { (1fr, auto) }

      #let days = monthly_days.map(day => {
            if day.weekday() in (7,) or holidays.contains(day.display()) [
              #set text(size: font_size_days, gray)
              #align(horizon)[#day.display("[day padding:space]")]
            ] else [
              #set text(size: font_size_days, black)
              #align(horizon)[#day.display("[day padding:space]")]
            ]
          }
        )
      #let moons = monthly_days.map(day => {
            let qs = day.display()
            let sunstr = if sw.contains(qs) [#set text(font: symbols_font); 🌞︎︎]
            let moonstr = if (qs in dict) [#set text(font: symbols_font); #mps.at(str(dict.at(qs)))]

            [#align(horizon)[#sunstr #moonstr]]
          }
        )

      #let a = grid(
        columns: (10mm, 10mm),
        rows: 1fr,
        column-gutter: 2pt,
        align: center,
        ..days.zip(moons).flatten(),
      )

      #let b = text(size: font_size_monthname, align(right, rotate(-90deg, reflow: true, origin: top + right, [#translated_month(month_date)])))

      #grid(
        columns: curcol,
        align: (right, left),
        column-gutter: 10pt,
        if lr == 1 {b} else {a},
        if lr == 1 {a} else {b}
      )
      #pagebreak(weak: true)
    ]
  ]
}

//#set page(height: 21cm, width: 5cm, margin: 10mm, fill: white.transparentize(50%))



#show: calendar.with(
  year: YEAR
)

@chris Would it make sense to start a public git repo for this?

3 Likes

We are happy to host such projects under the pixls org. We are over at codeberg now: pixlsus - Codeberg.org

1 Like

I am searching for a reason to need a codeberg account anyway, so I’ll start this (hopefully) tomorrow. @luator, if you want you can add your changes then on codeberg, or I could commit in your name with git commit --author you then, as you want.

Sounds great. I’ll join and add my changes. I anyway wanted to work on it a little bit more, to make it more reusable.

2 Likes

So, I got a codeberg account, now the question is where to put it. There’s the “Scripts” repository where it might fit in a subfolder, or should it get its own repository? Any thoughts @paperdigits, @luator?

Seems cool enough to get its own repo.

2 Likes

I built a calendar engine with tables for the dates instead of strips since I prefer this style (inspired by the “simple” setup of Saal Digitals Calendars). building the tables by hand in scribus…probably would have taken just as long as automating it :-D.

I would still like to add Month titles and the names of the included holidays below the dates-table but time ran out today and my latex-fu even if supported by AI is severly limited.

I would have liked to use typst but couln’t find an easy way to generate calendar-tables like this. typst syntax is way more sane than latex.

\documentclass{article}

\usepackage{xcolor}
\usepackage{tikz}
\usetikzlibrary{calendar}

\usepackage[default]{sourcesanspro}
\renewcommand{\familydefault}{\sfdefault}

\usepackage[paperwidth=20cm, paperheight=6cm, margin=1cm]{geometry}

\newcommand*\calyear{2026} %input the current year

\newcommand*\holidays{ %currently german-hessian holidays
	2026-01-01,
	2026-04-03,
	2026-04-06,
	2026-05-01,
	2026-05-14,
	2026-05-25,
	2026-06-04,
	2026-10-03,
	2026-12-25,
	2026-12-26
}

\begin{document}
	\pagestyle{empty}
	
	% loop over months
	\foreach \m in {1,...,12} 
	{
		\begin{center}
			\begin{tikzpicture}[
				transform shape,
				% setup for calendardays
				every day/.style={anchor=mid,font=\fontsize{13}{13}\selectfont}
				]
			
				
				% weekdays
				\foreach \d/\x/\c in {
					Montag/0/black,
					Dienstag/1/black,
					Mittwoch/2/black,
					Donnerstag/3/black,
					Freitag/4/black,
					Samstag/5/black,
					Sonntag/6/red}
				{
					% font table title
					\node[font=\fontsize{6}{8}\selectfont, text=\c, anchor=east,
					text height=1em, text depth=0.25em] at (\x*2.45cm,0) {\d};
				}
				
				
				% Calendar
				\calendar[
				at={(0,-0.7)},
				dates=\calyear-\m-01 to \calyear-\m-last,
				week list,
				day xshift=2.45cm,
				day yshift=0.6cm,
				every day/.append style={anchor=east},
				]
					if (Sunday)[red]
					if (equals/.list/.expand once=\holidays) [red];

				
			\end{tikzpicture}
		\end{center}
		
		\newpage % new page
	}
	
\end{document}
1 Like

Final version of my calendar engine powered by latex/tikz. Now includes month titles and automated red marking of holidays and written holiday labels below the calendaria. The latter need two files generated by a python script. Weekdays for the title bars are unfortunately still hard coded.

Minos Calendar.zip (79.5 KB)

3 Likes

Just a suggestion, why not have holidays a slightly different color from the weekends? Like a darker red for example. It looks great besides this minor nitpick

2 Likes

How are you ordering with your custom PDF with Saal? It always wants to pipe me through the online creator and when uploading my PDF there some pages get weirdly scaled down.

1 Like

I used the PDF upload of the online creator (choosing the empty template) and everything was good for me.
How did you set up the PDF? Maybe there is some mismatch with the page size? I used their InDesign template which I could open in Scribus without issues.

2 Likes

I used their indesign template as well. Must be some weird bug with their PDF import tool because only 3 months were affected. Ended up exporting as JPG from Scribus and placing the 13 jpegs in their online designer manually.

1 Like

Be aware that Scribus excludes the bleeding area in the JPG exports (should only relevant if your images cover the whole page or you use a dark background)

2 Likes