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.
 
 
 
 

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