You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

369 lines
15 KiB

  1. #!/usr/bin/env python3
  2. # Kosmorro - Compute The Next Ephemerides
  3. # Copyright (C) 2019 Jérôme Deuchnord <jerome@deuchnord.fr>
  4. #
  5. # This program is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU Affero General Public License as
  7. # published by the Free Software Foundation, either version 3 of the
  8. # License, or (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU Affero General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU Affero General Public License
  16. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  17. from abc import ABC, abstractmethod
  18. import datetime
  19. import json
  20. import os
  21. from tabulate import tabulate
  22. from numpy import int64
  23. from termcolor import colored
  24. from .data import Object, AsterEphemerides, MoonPhase, Event
  25. from .i18n import _
  26. from .version import VERSION
  27. from .exceptions import UnavailableFeatureError
  28. try:
  29. from latex import build_pdf
  30. except ImportError:
  31. build_pdf = None
  32. FULL_DATE_FORMAT = _('{day_of_week} {month} {day_number}, {year}').format(day_of_week='%A', month='%B',
  33. day_number='%d', year='%Y')
  34. SHORT_DATETIME_FORMAT = _('{month} {day_number}, {hours}:{minutes}').format(month='%b', day_number='%d',
  35. hours='%H', minutes='%M')
  36. TIME_FORMAT = _('{hours}:{minutes}').format(hours='%H', minutes='%M')
  37. class Dumper(ABC):
  38. def __init__(self, ephemeris: dict, events: [Event], date: datetime.date = datetime.date.today(), timezone: int = 0,
  39. with_colors: bool = True):
  40. self.ephemeris = ephemeris
  41. self.events = events
  42. self.date = date
  43. self.timezone = timezone
  44. self.with_colors = with_colors
  45. if self.timezone != 0:
  46. self._convert_dates_to_timezones()
  47. def _convert_dates_to_timezones(self):
  48. if self.ephemeris['moon_phase'].time is not None:
  49. self.ephemeris['moon_phase'].time = self._datetime_to_timezone(self.ephemeris['moon_phase'].time)
  50. if self.ephemeris['moon_phase'].next_phase_date is not None:
  51. self.ephemeris['moon_phase'].next_phase_date = self._datetime_to_timezone(
  52. self.ephemeris['moon_phase'].next_phase_date)
  53. for aster in self.ephemeris['details']:
  54. if aster.ephemerides.rise_time is not None:
  55. aster.ephemerides.rise_time = self._datetime_to_timezone(aster.ephemerides.rise_time)
  56. if aster.ephemerides.culmination_time is not None:
  57. aster.ephemerides.culmination_time = self._datetime_to_timezone(aster.ephemerides.culmination_time)
  58. if aster.ephemerides.set_time is not None:
  59. aster.ephemerides.set_time = self._datetime_to_timezone(aster.ephemerides.set_time)
  60. for event in self.events:
  61. event.start_time = self._datetime_to_timezone(event.start_time)
  62. if event.end_time is not None:
  63. event.end_time = self._datetime_to_timezone(event.end_time)
  64. def _datetime_to_timezone(self, time: datetime.datetime):
  65. return time.replace(tzinfo=datetime.timezone.utc).astimezone(
  66. tz=datetime.timezone(
  67. datetime.timedelta(
  68. hours=self.timezone
  69. )
  70. )
  71. )
  72. def get_date_as_string(self, capitalized: bool = False) -> str:
  73. date = self.date.strftime(FULL_DATE_FORMAT)
  74. if capitalized:
  75. return ''.join([date[0].upper(), date[1:]])
  76. return date
  77. @abstractmethod
  78. def to_string(self):
  79. pass
  80. @staticmethod
  81. def is_file_output_needed() -> bool:
  82. return False
  83. class JsonDumper(Dumper):
  84. def to_string(self):
  85. self.ephemeris['events'] = self.events
  86. self.ephemeris['ephemerides'] = self.ephemeris.pop('details')
  87. return json.dumps(self.ephemeris,
  88. default=self._json_default,
  89. indent=4)
  90. @staticmethod
  91. def _json_default(obj):
  92. # Fixes the "TypeError: Object of type int64 is not JSON serializable"
  93. # See https://stackoverflow.com/a/50577730
  94. if isinstance(obj, int64):
  95. return int(obj)
  96. if isinstance(obj, datetime.datetime):
  97. return obj.isoformat()
  98. if isinstance(obj, Object):
  99. obj = obj.__dict__
  100. obj.pop('skyfield_name')
  101. obj.pop('radius')
  102. obj['object'] = obj.pop('name')
  103. obj['details'] = obj.pop('ephemerides')
  104. return obj
  105. if isinstance(obj, AsterEphemerides):
  106. return obj.__dict__
  107. if isinstance(obj, MoonPhase):
  108. moon_phase = obj.__dict__
  109. moon_phase['phase'] = moon_phase.pop('identifier')
  110. moon_phase['date'] = moon_phase.pop('time')
  111. return moon_phase
  112. if isinstance(obj, Event):
  113. event = obj.__dict__
  114. event['objects'] = [object.name for object in event['objects']]
  115. return event
  116. raise TypeError('Object of type "%s" could not be integrated in the JSON' % str(type(obj)))
  117. class TextDumper(Dumper):
  118. def to_string(self):
  119. text = [self.style(self.get_date_as_string(capitalized=True), 'h1')]
  120. if len(self.ephemeris['details']) > 0:
  121. text.append(self.get_asters(self.ephemeris['details']))
  122. text.append(self.get_moon(self.ephemeris['moon_phase']))
  123. if len(self.events) > 0:
  124. text.append('\n'.join([self.style(_('Expected events:'), 'h2'),
  125. self.get_events(self.events)]))
  126. if self.timezone == 0:
  127. text.append(self.style(_('Note: All the hours are given in UTC.'), 'em'))
  128. else:
  129. tz_offset = str(self.timezone)
  130. if self.timezone > 0:
  131. tz_offset = ''.join(['+', tz_offset])
  132. text.append(self.style(_('Note: All the hours are given in the UTC{offset} timezone.').format(
  133. offset=tz_offset), 'em'))
  134. return '\n\n'.join(text)
  135. def style(self, text: str, tag: str) -> str:
  136. if not self.with_colors:
  137. return text
  138. styles = {
  139. 'h1': lambda t: colored(t, 'yellow', attrs=['bold']),
  140. 'h2': lambda t: colored(t, 'magenta', attrs=['bold']),
  141. 'th': lambda t: colored(t, 'white', attrs=['bold']),
  142. 'strong': lambda t: colored(t, attrs=['bold']),
  143. 'em': lambda t: colored(t, attrs=['dark'])
  144. }
  145. return styles[tag](text)
  146. def get_asters(self, asters: [Object]) -> str:
  147. data = []
  148. for aster in asters:
  149. name = self.style(aster.name, 'th')
  150. if aster.ephemerides.rise_time is not None:
  151. time_fmt = TIME_FORMAT if aster.ephemerides.rise_time.day == self.date.day else SHORT_DATETIME_FORMAT
  152. planet_rise = aster.ephemerides.rise_time.strftime(time_fmt)
  153. else:
  154. planet_rise = '-'
  155. if aster.ephemerides.culmination_time is not None:
  156. time_fmt = TIME_FORMAT if aster.ephemerides.culmination_time.day == self.date.day \
  157. else SHORT_DATETIME_FORMAT
  158. planet_culmination = aster.ephemerides.culmination_time.strftime(time_fmt)
  159. else:
  160. planet_culmination = '-'
  161. if aster.ephemerides.set_time is not None:
  162. time_fmt = TIME_FORMAT if aster.ephemerides.set_time.day == self.date.day else SHORT_DATETIME_FORMAT
  163. planet_set = aster.ephemerides.set_time.strftime(time_fmt)
  164. else:
  165. planet_set = '-'
  166. data.append([name, planet_rise, planet_culmination, planet_set])
  167. return tabulate(data, headers=[self.style(_('Object'), 'th'),
  168. self.style(_('Rise time'), 'th'),
  169. self.style(_('Culmination time'), 'th'),
  170. self.style(_('Set time'), 'th')],
  171. tablefmt='simple', stralign='center', colalign=('left',))
  172. def get_events(self, events: [Event]) -> str:
  173. data = []
  174. for event in events:
  175. time_fmt = TIME_FORMAT if event.start_time.day == self.date.day else SHORT_DATETIME_FORMAT
  176. data.append([self.style(event.start_time.strftime(time_fmt), 'th'),
  177. event.get_description()])
  178. return tabulate(data, tablefmt='plain', stralign='left')
  179. def get_moon(self, moon_phase: MoonPhase) -> str:
  180. current_moon_phase = ' '.join([self.style(_('Moon phase:'), 'strong'), moon_phase.get_phase()])
  181. new_moon_phase = _('{next_moon_phase} on {next_moon_phase_date} at {next_moon_phase_time}').format(
  182. next_moon_phase=moon_phase.get_next_phase(),
  183. next_moon_phase_date=moon_phase.next_phase_date.strftime(FULL_DATE_FORMAT),
  184. next_moon_phase_time=moon_phase.next_phase_date.strftime(TIME_FORMAT)
  185. )
  186. return '\n'.join([current_moon_phase, new_moon_phase])
  187. class _LatexDumper(Dumper):
  188. def to_string(self):
  189. template_path = os.path.join(os.path.abspath(os.path.dirname(__file__)),
  190. 'assets', 'pdf', 'template.tex')
  191. with open(template_path, mode='r') as file:
  192. template = file.read()
  193. return self._make_document(template)
  194. def _make_document(self, template: str) -> str:
  195. kosmorro_logo_path = os.path.join(os.path.abspath(os.path.dirname(__file__)),
  196. 'assets', 'png', 'kosmorro-logo.png')
  197. moon_phase_graphics = os.path.join(os.path.abspath(os.path.dirname(__file__)),
  198. 'assets', 'moonphases', 'png',
  199. '.'.join([self.ephemeris['moon_phase'].identifier.lower().replace('_', '-'),
  200. 'png']))
  201. document = template
  202. if len(self.ephemeris['details']) == 0:
  203. document = self._remove_section(document, 'ephemerides')
  204. if len(self.events) == 0:
  205. document = self._remove_section(document, 'events')
  206. document = document \
  207. .replace('+++KOSMORRO-VERSION+++', VERSION) \
  208. .replace('+++KOSMORRO-LOGO+++', kosmorro_logo_path) \
  209. .replace('+++DOCUMENT-TITLE+++', _('A Summary of your Sky')) \
  210. .replace('+++DOCUMENT-DATE+++', self.get_date_as_string(capitalized=True)) \
  211. .replace('+++INTRODUCTION+++',
  212. '\n\n'.join([
  213. _("This document summarizes the ephemerides and the events of {date}. "
  214. "It aims to help you to prepare your observation session. "
  215. "All the hours are given in {timezone}.").format(
  216. date=self.get_date_as_string(),
  217. timezone='UTC+%d' % self.timezone if self.timezone != 0 else 'UTC'
  218. ),
  219. _("Don't forget to check the weather forecast before you go out with your equipment.")
  220. ])) \
  221. .replace('+++SECTION-EPHEMERIDES+++', _('Ephemerides of the day')) \
  222. .replace('+++EPHEMERIDES-OBJECT+++', _('Object')) \
  223. .replace('+++EPHEMERIDES-RISE-TIME+++', _('Rise time')) \
  224. .replace('+++EPHEMERIDES-CULMINATION-TIME+++', _('Culmination time')) \
  225. .replace('+++EPHEMERIDES-SET-TIME+++', _('Set time')) \
  226. .replace('+++EPHEMERIDES+++', self._make_ephemerides()) \
  227. .replace('+++MOON-PHASE-GRAPHICS+++', moon_phase_graphics) \
  228. .replace('+++CURRENT-MOON-PHASE-TITLE+++', _('Moon phase:')) \
  229. .replace('+++CURRENT-MOON-PHASE+++', self.ephemeris['moon_phase'].get_phase()) \
  230. .replace('+++SECTION-EVENTS+++', _('Expected events')) \
  231. .replace('+++EVENTS+++', self._make_events())
  232. return document
  233. def _make_ephemerides(self) -> str:
  234. latex = []
  235. for aster in self.ephemeris['details']:
  236. if aster.ephemerides.rise_time is not None:
  237. time_fmt = TIME_FORMAT if aster.ephemerides.rise_time.day == self.date.day else SHORT_DATETIME_FORMAT
  238. aster_rise = aster.ephemerides.rise_time.strftime(time_fmt)
  239. else:
  240. aster_rise = '-'
  241. if aster.ephemerides.culmination_time is not None:
  242. time_fmt = TIME_FORMAT if aster.ephemerides.culmination_time.day == self.date.day\
  243. else SHORT_DATETIME_FORMAT
  244. aster_culmination = aster.ephemerides.culmination_time.strftime(time_fmt)
  245. else:
  246. aster_culmination = '-'
  247. if aster.ephemerides.set_time is not None:
  248. time_fmt = TIME_FORMAT if aster.ephemerides.set_time.day == self.date.day else SHORT_DATETIME_FORMAT
  249. aster_set = aster.ephemerides.set_time.strftime(time_fmt)
  250. else:
  251. aster_set = '-'
  252. latex.append(r'\object{%s}{%s}{%s}{%s}' % (aster.name,
  253. aster_rise,
  254. aster_culmination,
  255. aster_set))
  256. return ''.join(latex)
  257. def _make_events(self) -> str:
  258. latex = []
  259. for event in self.events:
  260. latex.append(r'\event{%s}{%s}' % (event.start_time.strftime(TIME_FORMAT),
  261. event.get_description()))
  262. return ''.join(latex)
  263. @staticmethod
  264. def _remove_section(document: str, section: str):
  265. begin_section_tag = '%%%%%% BEGIN-%s-SECTION' % section.upper()
  266. end_section_tag = '%%%%%% END-%s-SECTION' % section.upper()
  267. document = document.split('\n')
  268. new_document = []
  269. ignore_line = False
  270. for line in document:
  271. if begin_section_tag in line or end_section_tag in line:
  272. ignore_line = not ignore_line
  273. continue
  274. if ignore_line:
  275. continue
  276. new_document.append(line)
  277. return '\n'.join(new_document)
  278. class PdfDumper(Dumper):
  279. def __init__(self, ephemerides, events, date=datetime.datetime.now(), timezone=0, with_colors=True):
  280. super(PdfDumper, self).__init__(ephemerides, events, date=date, timezone=0, with_colors=with_colors)
  281. self.timezone = timezone
  282. def to_string(self):
  283. try:
  284. latex_dumper = _LatexDumper(self.ephemeris, self.events,
  285. date=self.date, timezone=self.timezone, with_colors=self.with_colors)
  286. return self._compile(latex_dumper.to_string())
  287. except RuntimeError:
  288. raise UnavailableFeatureError(_("Building PDFs was not possible, because some dependencies are not"
  289. " installed.\nPlease look at the documentation at http://kosmorro.space "
  290. "for more information."))
  291. @staticmethod
  292. def is_file_output_needed() -> bool:
  293. return True
  294. @staticmethod
  295. def _compile(latex_input) -> bytes:
  296. if build_pdf is None:
  297. raise RuntimeError('Python latex module not found')
  298. return bytes(build_pdf(latex_input))