#!/usr/bin/env python3 import argparse import calendar import datetime import locale import logging import os import shutil import subprocess import sys from typing import Optional import dateutil.tz try: from lesana.command import _get_first_docstring_line # type: ignore except ImportError: def _get_first_docstring_line(obj): return "" try: import argcomplete # type: ignore except ImportError: argcomplete = False import jinja2 locale.setlocale(locale.LC_ALL, '') class Generator: """ """ default_template = "planner-A6" def __init__( self, year: Optional[int] = None, template: Optional[str] = None, cover_template: Optional[str] = None, out_file: Optional[str] = None, build_dir: Optional[str] = "build", latitude: Optional[float] = None, longitude: Optional[float] = None, timezone: Optional[str] = None, ): self.year = year or ( datetime.date.today() + datetime.timedelta(days=334) ).year self.latitude = latitude self.longitude = longitude self.timezone = timezone lang_name = locale.getlocale()[0].split("_")[0] self.out_file = out_file or ( template or self.default_template ) + "-" + lang_name + ".pdf" if not template: template = self.default_template self.paper_size = self._get_paper_size(template) env = jinja2.Environment() self.templates_dir = "templates" loader = jinja2.FileSystemLoader(self.templates_dir) self.template_recto = loader.load(env, template + "-r.svg") self.template_verso = loader.load(env, template + "-v.svg") self.template_cover = loader.load( env, self._get_cover_name(cover_template) ) self.build_dir = build_dir self.page_fname = os.path.join( self.build_dir, template + "-{year}-{page:03}.svg" ) def _get_paper_size(self, template): if "A6" in template: return "a6" if "A5" in template: return "a5" if "A4" in template: return "a4" return "a6" def _get_cover_name(self, cover_template): if cover_template: return cover_template return "cover-{}-r.svg".format(self.paper_size.upper()) def render_page(self, page: int, **kw): # page counts starts with 0 if page == 0: template = self.template_cover elif page % 2 == 0: template = self.template_recto else: template = self.template_verso with open(self.page_fname.format( year=self.year, page=page ), "w") as fp: fp.write(template.render(**kw)) def run(self): self.clean_build_dir() self.generate_cover_page() self.generate_pages() self.convert_pages_to_svg() self.join_pages() def clean_build_dir(self): try: shutil.rmtree(self.build_dir) except FileNotFoundError: pass os.makedirs(self.build_dir) def generate_cover_page(self): self.render_page(page=0, year=self.year) def generate_pages(self): pass def convert_pages_to_svg(self): inkscape_commands = ";\n".join([ ( "file-open:{build_dir}/{svg};" + " export-type: pdf;" + " export-filename:build/{pdf};" + " export-text-to-path;" + " export-do" ).format( build_dir=self.build_dir, svg=s, pdf=os.path.splitext(s)[0] + ".pdf", ) for s in os.listdir(self.build_dir) ]) try: subprocess.run( ["inkscape", "--shell"], input=inkscape_commands, text=True, ) except FileNotFoundError: logging.warning("Inkscape is not installed, can't convert to pdf") logging.warning("Stopping here, you can use the svgs as you like") sys.exit(1) def get_pdf_pages(self): pdf_pages = sorted([ os.path.join(self.build_dir, p) for p in os.listdir(self.build_dir) if p.endswith(".pdf") ]) return pdf_pages def join_pages(self): pdf_pages = self.get_pdf_pages() try: subprocess.run([ "pdfjam", "--outfile", self.out_file, "--scale", "1", "--paper", "{}paper".format(self.paper_size), *pdf_pages ]) except FileNotFoundError: logging.warning("pdfjam is not installed") logging.warning("you will have to join the pdf pages yourself") sys.exit(1) class WeeklyGenerator(Generator): """ """ default_template = "week_on_two_pages-A6" def generate_pages(self): cal = calendar.Calendar() weeks = sum( [r[0] for r in cal.yeardatescalendar(self.year, width=1)], [] ) last_monday = None page = 1 for week in weeks: # yeardatescalendar will have the same week twice at the # margin of a month, but we want to skip one of those if week[0] == last_monday: continue last_monday = week[0] self.render_page(page=page, week=week) page += 1 self.render_page(page=page, week=week) page += 1 class DailyGenerator(Generator): """ """ default_template = "daily-A6" def generate_pages(self): day = datetime.date(self.year, 1, 1) # we want to start with a left side page (starting from 0) page = 2 while day.year == self.year: self.render_page(page=page, day=day) page += 1 day += datetime.timedelta(days=1) if day.year > self.year: break self.render_page(page=page, day=day) page += 1 day += datetime.timedelta(days=1) def get_pdf_pages(self): pdf_pages = super().get_pdf_pages() # insert an empty page on the second page, to start the year on # a left page pdf_pages.insert(1, "1, {}") return pdf_pages class BiweeklyGenerator(Generator): """ """ default_template = "week_on_one_page-A6" def generate_pages(self): cal = calendar.Calendar() weeks = sum( [r[0] for r in cal.yeardatescalendar(self.year, width=1)], [] ) last_monday = None page = 1 for week in weeks: # yeardatescalendar will have the same week twice at the # margin of a month, but we want to skip one of those if week[0] == last_monday: continue last_monday = week[0] texts = self.get_texts(week) self.render_page(page=page, week=week, text=texts) page += 1 def get_texts(self, week): return ["" for i in range(7)] class EphemerisbiweeklyGenerator(BiweeklyGenerator): """ """ default_template = "week_on_one_page-A6" def get_texts(self, week): # we import suntime just here, because it's a third party # library and not used elsewhere try: import astral import astral.sun import astral.moon except ImportError: print("Printing a planner with ephemeris requires " "the astral library version 3.x.") sys.exit(1) if not self.latitude or not self.longitude or not self.timezone: print("Printing ephemeris requires latitude and longitude") sys.exit(1) location = astral.LocationInfo( "", "", self.timezone, self.latitude, self.longitude, ) local_tz = dateutil.tz.gettz(self.timezone) texts = [] for day in week: sun = astral.sun.sun(location.observer, date=day) sunrise = sun["sunrise"].astimezone(local_tz) noon = sun["noon"].astimezone(local_tz) sunset = sun["sunset"].astimezone(local_tz) moon_phase = astral.moon.phase(day) if moon_phase < 7: moon_icon = "●" elif moon_phase < 14: moon_icon = "☽" elif moon_phase < 21: moon_icon = "○" else: moon_icon = "☾" moon_phase = round(moon_phase, 2) try: moon_rise = astral.moon.moonrise(location, date=day) moon_rise = moon_rise.astimezone(local_tz).strftime("%H:%M") except ValueError: moon_rise = "" try: moon_set = astral.moon.moonset(location, date=day) moon_set = moon_set.astimezone(local_tz).strftime("%H:%M") except ValueError: moon_set= "" text = ("☼ {sunrise} — {noon} — {sunset} " + "{moon_icon} ({moon_phase}) " + "{moon_rise} — {moon_set} " ).format( sunrise=sunrise.strftime("%H:%M"), noon=noon.strftime("%H:%M"), sunset=sunset.strftime("%H:%M"), moon_icon=moon_icon, moon_phase=moon_phase, moon_rise=moon_rise, moon_set=moon_set, ) texts.append(text) return texts class MonthGenerator(Generator): """ """ default_template = "month-A6" def generate_pages(self): cal = calendar.Calendar() full_year = cal.yeardatescalendar(self.year, width=1) months = [] for i in range(12): months.append([ day for week in full_year[i][0] for day in week if day.month == i + 1 ]) texts = self.get_texts() page = 2 for i, month in enumerate(months): self.render_page(page=page, month=month, text=texts[i]) page += 1 def generate_cover_page(self): pass def get_texts(self): return [[] for i in range(12)] class EphemerismonthGenerator(MonthGenerator): """ """ default_template = "month-A6" def get_texts(self): # we import suntime just here, because it's a third party # library and not used elsewhere try: import astral import astral.sun import astral.moon except ImportError: print("Printing a month planner with ephemeris requires " "the astral library version 3.x.") sys.exit(1) if not self.latitude or not self.longitude or not self.timezone: print("Printing ephemeris requires latitude and longitude") sys.exit(1) location = astral.LocationInfo( "", "", self.timezone, self.latitude, self.longitude, ) local_tz = dateutil.tz.gettz(self.timezone) day = datetime.date(self.year, 1, 1) texts = [] while day.year == self.year: month = [] this_month = day.month while day.month == this_month: sun = astral.sun.sun(location.observer, date=day) sunrise = sun["sunrise"].astimezone(local_tz) noon = sun["noon"].astimezone(local_tz) sunset = sun["sunset"].astimezone(local_tz) moon_phase = astral.moon.phase(day) if moon_phase < 7: moon_icon = "●" elif moon_phase < 14: moon_icon = "☽" elif moon_phase < 21: moon_icon = "○" else: moon_icon = "☾" moon_phase = round(moon_phase) text = ("☼ {sunrise} — {noon} — {sunset} " + "{moon_icon} {moon_phase}").format( sunrise=sunrise.strftime("%H:%M"), noon=noon.strftime("%H:%M"), sunset=sunset.strftime("%H:%M"), moon_icon=moon_icon, moon_phase=moon_phase, ) month.append(text) day += datetime.timedelta(days=1) texts.append(month) return texts class Command: """ Generate a planner """ def get_parser(self): desc = _get_first_docstring_line(self) parser = argparse.ArgumentParser(description=desc) parser.add_argument( "--year", '-y', default=None, help="Default is next year, or this year in January." ) parser.add_argument( "--template", '-t', default=None, help="Base name of the template (without -[rv].svg)", ) parser.add_argument( "--cover-template", default=None, help="Full name of the template (including -[rv].svg)", ) parser.add_argument( "--latitude", default=None, type=float, help="Latitude for ephemeris calculation", ) parser.add_argument( "--longitude", default=None, type=float, help="Longitude for ephemeris calculation", ) parser.add_argument( "--timezone", default=None, help="Timezone for ephemeris calculation (e.g. Europe/Rome)", ) parser.add_argument( "command", ) return parser def main(self): self.parser = self.get_parser() if argcomplete: argcomplete.autocomplete(self.parser) self.args = self.parser.parse_args() generator = getattr( sys.modules[__name__], self.args.command.capitalize() + "Generator", None ) if generator: generator( year=self.args.year, template=self.args.template, cover_template=self.args.cover_template, latitude=self.args.latitude, longitude=self.args.longitude, timezone=self.args.timezone, ).run() else: print("command not supported: {}".format(self.args.command)) if __name__ == "__main__": Command().main()