534 lines
18 KiB

  1. #!/usr/bin/env python3
  2. from abc import ABC, abstractmethod
  3. import datetime
  4. import json
  5. import os
  6. import tempfile
  7. import subprocess
  8. import shutil
  9. from pathlib import Path
  10. from kosmorrolib.exceptions import UnavailableFeatureError
  11. from tabulate import tabulate
  12. from termcolor import colored
  13. from kosmorrolib import AsterEphemerides, Event
  14. from kosmorrolib.model import ASTERS, MoonPhase
  15. from .i18n.utils import _, FULL_DATE_FORMAT, SHORT_DATETIME_FORMAT, TIME_FORMAT
  16. from .i18n import strings
  17. from .__version__ import __version__ as version
  18. from .exceptions import (
  19. CompileError,
  20. UnavailableFeatureError as KosmorroUnavailableFeatureError,
  21. )
  22. from .debug import debug_print
  23. class Dumper(ABC):
  24. def __init__(
  25. self,
  26. ephemerides: [AsterEphemerides] = None,
  27. moon_phase: MoonPhase = None,
  28. events: [Event] = None,
  29. date: datetime.date = datetime.date.today(),
  30. timezone: int = 0,
  31. with_colors: bool = True,
  32. show_graph: bool = False,
  33. ):
  34. self.ephemerides = ephemerides
  35. self.moon_phase = moon_phase
  36. self.events = events
  37. self.date = date
  38. self.timezone = timezone
  39. self.with_colors = with_colors
  40. self.show_graph = show_graph
  41. def get_date_as_string(self, capitalized: bool = False) -> str:
  42. date = self.date.strftime(FULL_DATE_FORMAT)
  43. if capitalized:
  44. return "".join([date[0].upper(), date[1:]])
  45. return date
  46. def __str__(self):
  47. return self.to_string()
  48. @abstractmethod
  49. def to_string(self):
  50. pass
  51. @staticmethod
  52. def is_file_output_needed() -> bool:
  53. return False
  54. class JsonDumper(Dumper):
  55. def to_string(self):
  56. return json.dumps(
  57. {
  58. "ephemerides": [
  59. ephemeris.serialize() for ephemeris in self.ephemerides
  60. ],
  61. "moon_phase": self.moon_phase.serialize(),
  62. "events": [event.serialize() for event in self.events],
  63. },
  64. indent=4,
  65. )
  66. class TextDumper(Dumper):
  67. def to_string(self):
  68. text = [self.style(self.get_date_as_string(capitalized=True), "h1")]
  69. if self.ephemerides is not None:
  70. text.append(self.stringify_ephemerides())
  71. text.append(self.get_moon(self.moon_phase))
  72. if len(self.events) > 0:
  73. text.append(
  74. "\n".join(
  75. [
  76. self.style(_("Expected events:"), "h2"),
  77. self.get_events(self.events),
  78. ]
  79. )
  80. )
  81. if self.timezone == 0:
  82. text.append(self.style(_("Note: All the hours are given in UTC."), "em"))
  83. else:
  84. tz_offset = str(self.timezone)
  85. if self.timezone > 0:
  86. tz_offset = "".join(["+", tz_offset])
  87. text.append(
  88. self.style(
  89. _(
  90. "Note: All the hours are given in the UTC{offset} timezone."
  91. ).format(offset=tz_offset),
  92. "em",
  93. )
  94. )
  95. return "\n\n".join(text)
  96. def style(self, text: str, tag: str) -> str:
  97. if not self.with_colors:
  98. return text
  99. styles = {
  100. "h1": lambda t: colored(t, "yellow", attrs=["bold"]),
  101. "h2": lambda t: colored(t, "magenta", attrs=["bold"]),
  102. "th": lambda t: colored(t, "white", attrs=["bold"]),
  103. "strong": lambda t: colored(t, attrs=["bold"]),
  104. "em": lambda t: colored(t, attrs=["dark"]),
  105. }
  106. return styles[tag](text)
  107. def stringify_ephemerides(self) -> str:
  108. data = []
  109. for ephemeris in self.ephemerides:
  110. object_name = strings.from_object(ephemeris.object.identifier)
  111. if object_name is None:
  112. continue
  113. name = self.style(object_name, "th")
  114. if ephemeris.rise_time is not None:
  115. time_fmt = (
  116. TIME_FORMAT
  117. if ephemeris.rise_time.day == self.date.day
  118. else SHORT_DATETIME_FORMAT
  119. )
  120. planet_rise = ephemeris.rise_time.strftime(time_fmt)
  121. else:
  122. planet_rise = "-"
  123. if ephemeris.culmination_time is not None:
  124. time_fmt = (
  125. TIME_FORMAT
  126. if ephemeris.culmination_time.day == self.date.day
  127. else SHORT_DATETIME_FORMAT
  128. )
  129. planet_culmination = ephemeris.culmination_time.strftime(time_fmt)
  130. else:
  131. planet_culmination = "-"
  132. if ephemeris.set_time is not None:
  133. time_fmt = (
  134. TIME_FORMAT
  135. if ephemeris.set_time.day == self.date.day
  136. else SHORT_DATETIME_FORMAT
  137. )
  138. planet_set = ephemeris.set_time.strftime(time_fmt)
  139. else:
  140. planet_set = "-"
  141. data.append([name, planet_rise, planet_culmination, planet_set])
  142. return tabulate(
  143. data,
  144. headers=[
  145. self.style(_("Object"), "th"),
  146. self.style(_("Rise time"), "th"),
  147. self.style(_("Culmination time"), "th"),
  148. self.style(_("Set time"), "th"),
  149. ],
  150. tablefmt="simple",
  151. stralign="center",
  152. colalign=("left",),
  153. )
  154. def get_events(self, events: [Event]) -> str:
  155. data = []
  156. for event in events:
  157. description = strings.from_event(event)
  158. time_fmt = (
  159. TIME_FORMAT
  160. if event.start_time.day == self.date.day
  161. else SHORT_DATETIME_FORMAT
  162. )
  163. data.append(
  164. [
  165. self.style(event.start_time.strftime(time_fmt), "th"),
  166. description,
  167. ]
  168. )
  169. return tabulate(data, tablefmt="plain", stralign="left")
  170. def get_moon(self, moon_phase: MoonPhase) -> str:
  171. if moon_phase is None:
  172. return _("Moon phase is unavailable for this date.")
  173. current_moon_phase = " ".join(
  174. [
  175. self.style(_("Moon phase:"), "strong"),
  176. strings.from_moon_phase(moon_phase.phase_type),
  177. ]
  178. )
  179. new_moon_phase = _(
  180. "{next_moon_phase} on {next_moon_phase_date} at {next_moon_phase_time}"
  181. ).format(
  182. next_moon_phase=_(strings.from_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(
  190. os.path.abspath(os.path.dirname(__file__)), "assets", "pdf", "template.tex"
  191. )
  192. with open(template_path, mode="r") as file:
  193. template = file.read()
  194. return self._make_document(template)
  195. def _make_document(self, template: str) -> str:
  196. kosmorro_logo_path = os.path.join(
  197. os.path.abspath(os.path.dirname(__file__)),
  198. "assets",
  199. "png",
  200. "kosmorro-logo.png",
  201. )
  202. moon_phase_graphics = os.path.join(
  203. os.path.abspath(os.path.dirname(__file__)),
  204. "assets",
  205. "moonphases",
  206. "png",
  207. ".".join(
  208. [self.moon_phase.phase_type.name.lower().replace("_", "-"), "png"]
  209. ),
  210. )
  211. document = template
  212. if self.ephemerides is None:
  213. document = self._remove_section(document, "ephemerides")
  214. if len(self.events) == 0:
  215. document = self._remove_section(document, "events")
  216. document = self.add_strings(document, kosmorro_logo_path, moon_phase_graphics)
  217. if self.show_graph:
  218. # The graphephemerides environment beginning tag must end with a percent symbol to ensure
  219. # that no extra space will interfere with the graph.
  220. document = document.replace(
  221. r"\begin{ephemerides}", r"\begin{graphephemerides}%"
  222. ).replace(r"\end{ephemerides}", r"\end{graphephemerides}")
  223. return document
  224. def add_strings(
  225. self, document: str, kosmorro_logo_path: str, moon_phase_graphics: str
  226. ) -> str:
  227. document = document.replace("+++KOSMORRO-VERSION+++", version)
  228. document = document.replace("+++KOSMORRO-LOGO+++", kosmorro_logo_path)
  229. document = document.replace("+++DOCUMENT-TITLE+++", _("Overview of your sky"))
  230. document = document.replace(
  231. "+++DOCUMENT-DATE+++", self.get_date_as_string(capitalized=True)
  232. )
  233. document = document.replace(
  234. "+++INTRODUCTION+++",
  235. "\n\n".join(
  236. [
  237. _(
  238. "This document summarizes the ephemerides and the events of {date}. "
  239. "It aims to help you to prepare your observation session. "
  240. "All the hours are given in {timezone}."
  241. ).format(
  242. date=self.get_date_as_string(),
  243. timezone="UTC+%d" % self.timezone
  244. if self.timezone != 0
  245. else "UTC",
  246. ),
  247. _(
  248. "Don't forget to check the weather forecast before you go out with your equipment."
  249. ),
  250. ]
  251. ),
  252. )
  253. document = document.replace(
  254. "+++SECTION-EPHEMERIDES+++", _("Ephemerides of the day")
  255. )
  256. document = document.replace("+++EPHEMERIDES-OBJECT+++", _("Object"))
  257. document = document.replace("+++EPHEMERIDES-RISE-TIME+++", _("Rise time"))
  258. document = document.replace(
  259. "+++EPHEMERIDES-CULMINATION-TIME+++", _("Culmination time")
  260. )
  261. document = document.replace("+++EPHEMERIDES-SET-TIME+++", _("Set time"))
  262. document = document.replace("+++EPHEMERIDES+++", self._make_ephemerides())
  263. document = document.replace("+++GRAPH_LABEL_HOURS+++", _("hours"))
  264. document = document.replace("+++MOON-PHASE-GRAPHICS+++", moon_phase_graphics)
  265. document = document.replace("+++CURRENT-MOON-PHASE-TITLE+++", _("Moon phase:"))
  266. document = document.replace(
  267. "+++CURRENT-MOON-PHASE+++",
  268. strings.from_moon_phase(self.moon_phase.phase_type),
  269. )
  270. document = document.replace("+++SECTION-EVENTS+++", _("Expected events"))
  271. document = document.replace("+++EVENTS+++", self._make_events())
  272. for aster in ASTERS:
  273. object_name = strings.from_object(aster.identifier)
  274. if object_name is None:
  275. continue
  276. document = document.replace(
  277. "+++ASTER_%s+++" % aster.skyfield_name.upper().split(" ")[0],
  278. object_name,
  279. )
  280. return document
  281. def _make_ephemerides(self) -> str:
  282. latex = []
  283. graph_y_component = 18
  284. if self.ephemerides is not None:
  285. for ephemeris in self.ephemerides:
  286. if ephemeris.rise_time is not None:
  287. time_fmt = (
  288. TIME_FORMAT
  289. if ephemeris.rise_time.day == self.date.day
  290. else SHORT_DATETIME_FORMAT
  291. )
  292. aster_rise = ephemeris.rise_time.strftime(time_fmt)
  293. else:
  294. aster_rise = "-"
  295. if ephemeris.culmination_time is not None:
  296. time_fmt = (
  297. TIME_FORMAT
  298. if ephemeris.culmination_time.day == self.date.day
  299. else SHORT_DATETIME_FORMAT
  300. )
  301. aster_culmination = ephemeris.culmination_time.strftime(time_fmt)
  302. else:
  303. aster_culmination = "-"
  304. if ephemeris.set_time is not None:
  305. time_fmt = (
  306. TIME_FORMAT
  307. if ephemeris.set_time.day == self.date.day
  308. else SHORT_DATETIME_FORMAT
  309. )
  310. aster_set = ephemeris.set_time.strftime(time_fmt)
  311. else:
  312. aster_set = "-"
  313. if not self.show_graph:
  314. object_name = strings.from_object(ephemeris.object.identifier)
  315. if object_name is not None:
  316. latex.append(
  317. r"\object{%s}{%s}{%s}{%s}"
  318. % (
  319. object_name,
  320. aster_rise,
  321. aster_culmination,
  322. aster_set,
  323. )
  324. )
  325. else:
  326. if ephemeris.rise_time is not None:
  327. raise_hour = ephemeris.rise_time.hour
  328. raise_minute = ephemeris.rise_time.minute
  329. else:
  330. raise_hour = raise_minute = 0
  331. aster_rise = ""
  332. if ephemeris.set_time is not None:
  333. set_hour = ephemeris.set_time.hour
  334. set_minute = ephemeris.set_time.minute
  335. else:
  336. set_hour = 24
  337. set_minute = 0
  338. aster_set = ""
  339. sets_after_end = set_hour > raise_hour
  340. if not sets_after_end:
  341. latex.append(
  342. r"\graphobject{%d}{gray}{0}{0}{%d}{%d}{}{%s}"
  343. % (graph_y_component, set_hour, set_minute, aster_set)
  344. )
  345. set_hour = 24
  346. set_minute = 0
  347. latex.append(
  348. r"\graphobject{%d}{gray}{%d}{%d}{%d}{%d}{%s}{%s}"
  349. % (
  350. graph_y_component,
  351. raise_hour,
  352. raise_minute,
  353. set_hour,
  354. set_minute,
  355. aster_rise,
  356. aster_set if sets_after_end else "",
  357. )
  358. )
  359. graph_y_component -= 2
  360. return "".join(latex)
  361. def _make_events(self) -> str:
  362. latex = []
  363. for event in self.events:
  364. event_name = strings.from_event(event)
  365. if event_name is None:
  366. continue
  367. latex.append(
  368. r"\event{%s}{%s}" % (event.start_time.strftime(TIME_FORMAT), event_name)
  369. )
  370. return "".join(latex)
  371. @staticmethod
  372. def _remove_section(document: str, section: str):
  373. begin_section_tag = "%%%%%% BEGIN-%s-SECTION" % section.upper()
  374. end_section_tag = "%%%%%% END-%s-SECTION" % section.upper()
  375. document = document.split("\n")
  376. new_document = []
  377. ignore_line = False
  378. for line in document:
  379. if begin_section_tag in line or end_section_tag in line:
  380. ignore_line = not ignore_line
  381. continue
  382. if ignore_line:
  383. continue
  384. new_document.append(line)
  385. return "\n".join(new_document)
  386. class PdfDumper(Dumper):
  387. def to_string(self):
  388. try:
  389. latex_dumper = _LatexDumper(
  390. self.ephemerides,
  391. self.moon_phase,
  392. self.events,
  393. date=self.date,
  394. timezone=self.timezone,
  395. with_colors=self.with_colors,
  396. show_graph=self.show_graph,
  397. )
  398. return self._compile(latex_dumper.to_string())
  399. except RuntimeError as error:
  400. raise KosmorroUnavailableFeatureError(
  401. _(
  402. "Building PDF was not possible, because some dependencies are not"
  403. " installed.\nPlease look at the documentation at https://kosmorro.space/cli/generate-pdf/ "
  404. "for more information."
  405. )
  406. ) from error
  407. @staticmethod
  408. def is_file_output_needed() -> bool:
  409. return True
  410. @staticmethod
  411. def _compile(latex_input) -> bytes:
  412. package = str(Path(__file__).parent.absolute()) + "/assets/pdf/kosmorro.sty"
  413. timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S")
  414. current_dir = (
  415. os.getcwd()
  416. ) # Keep the current directory to return to it after the PDFLaTeX execution
  417. try:
  418. temp_dir = tempfile.mkdtemp()
  419. shutil.copy(package, temp_dir)
  420. temp_tex = "%s/%s.tex" % (temp_dir, timestamp)
  421. with open(temp_tex, "w") as tex_file:
  422. tex_file.write(latex_input)
  423. os.chdir(temp_dir)
  424. debug_print("LaTeX content:\n%s" % latex_input)
  425. subprocess.run(
  426. ["pdflatex", "-interaction", "nonstopmode", "%s.tex" % timestamp],
  427. capture_output=True,
  428. check=True,
  429. )
  430. os.chdir(current_dir)
  431. with open("%s/%s.pdf" % (temp_dir, timestamp), "rb") as pdffile:
  432. return bytes(pdffile.read())
  433. except FileNotFoundError as error:
  434. raise KosmorroUnavailableFeatureError(
  435. "TeXLive is not installed."
  436. ) from error
  437. except subprocess.CalledProcessError as error:
  438. with open("/tmp/kosmorro-%s.log" % timestamp, "wb") as file:
  439. file.write(error.stdout)
  440. raise CompileError(
  441. _(
  442. "An error occurred during the compilation of the PDF.\n"
  443. "Please open an issue at https://github.com/Kosmorro/kosmorro/issues and share "
  444. "the content of the log file at /tmp/kosmorro-%s.log" % timestamp
  445. )
  446. ) from error