slidegen.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574
  1. #!/usr/bin/env python3
  2. """
  3. Copyright © 2022 Noah Vogt <noah@noahvogt.com>
  4. This program is free software: you can redistribute it and/or modify
  5. it under the terms of the GNU General Public License as published by
  6. the Free Software Foundation, either version 3 of the License, or
  7. (at your option) any later version.
  8. This program is distributed in the hope that it will be useful,
  9. but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. GNU General Public License for more details.
  12. You should have received a copy of the GNU General Public License
  13. along with this program. If not, see <http://www.gnu.org/licenses/>.
  14. """
  15. from os import name, path
  16. from abc import ABC, abstractmethod
  17. import sys
  18. import re
  19. from termcolor import colored
  20. import colorama
  21. from wand.image import Image
  22. from wand.color import Color
  23. from wand.display import display
  24. from wand.drawing import Drawing
  25. from wand.font import Font
  26. from wand.exceptions import BlobError
  27. IMAGE_FORMAT = "jpeg"
  28. FILE_EXTENSION = "jpg"
  29. FILE_NAMEING = "folie"
  30. WIDTH = 1920
  31. HEIGHT = 1080
  32. BG_COLOR = Color("white")
  33. FG_COLOR = Color("#6298a4")
  34. TITLE_COLOR = Color("#d8d5c4")
  35. MAX_TITLE_FONT_SIZE = 70
  36. MIN_TITLE_FONT_SIZE = 20
  37. TITLE_FONT_SIZE_STEP = 10
  38. TITLE_HEIGHT = 160
  39. TITLEBAR_Y = 65
  40. INFODISPLAY_FONT_SIZE = 25
  41. INFODISPLAY_ITEM_WIDTH = 20
  42. PLAYER_WIDTH = 560
  43. PLAYER_HEIGHT = 315
  44. BOLD_FONT_PATH = (
  45. "/usr/share/fonts/TTF/century-gothic/CenturyGothicBold.ttf"
  46. if name == "posix"
  47. else "winPATH"
  48. )
  49. FONT_PATH = (
  50. "/usr/share/fonts/TTF/century-gothic/CenturyGothic.ttf"
  51. if name == "posix"
  52. else "winPATH"
  53. )
  54. FONT = "Century-Gothic"
  55. BOLD_FONT = "Century-Gothic-Bold"
  56. TRIANGLE_WIDTH = 80
  57. TRIANGLE_HEIGTH = 160
  58. METADATA_FONT_SIZE = 36
  59. METADATA_X = 70
  60. BOOK_Y = 260
  61. ATTRIBUTIONS_Y = 930
  62. TEXT_COLOR = Color("black")
  63. VERSE_X = 80
  64. VERSE_Y = 400
  65. TEXT_CANVAS_X = 160
  66. TEXT_CANVAS_Y = 400
  67. TEXT_CANVAS_WIDTH = 1600
  68. TEXT_CANVAS_HEIGHT = 600
  69. STRUCTURE_X = 1650
  70. STRUCTURE_Y = 1000
  71. MAX_CANVAS_FONT_SIZE = 55
  72. MIN_CANVAS_FONT_SIZE = 35
  73. CANVAS_FONT_SIZE_STEP = 5
  74. INTERLINE_SPACING = 30
  75. METADATA_STRINGS = ("title", "book", "text", "melody", "structure")
  76. def error_msg(msg: str):
  77. print(colored("[*] Error: {}".format(msg), "red"))
  78. sys.exit(1)
  79. def log(message: str):
  80. print(colored("[*] {}".format(message), "green"))
  81. def get_empty_image() -> Image:
  82. img = Image(width=1, height=1, background=Color("white"))
  83. return img.clone()
  84. def structure_as_list(structure: str) -> list:
  85. return structure.replace(" ", "").split(",")
  86. def get_unique_structure_elements(structure: list) -> list:
  87. return list(dict.fromkeys(structure))
  88. def get_songtext_by_structure(content: list, structure: str) -> str:
  89. found_desired_structure = False
  90. output_str = ""
  91. for line in content:
  92. stripped_line = line.strip()
  93. if found_desired_structure:
  94. if stripped_line.startswith("[") and stripped_line.endswith("]"):
  95. break
  96. output_str += stripped_line + "\n"
  97. if (
  98. stripped_line.startswith("[")
  99. and stripped_line.endswith("]")
  100. and structure in stripped_line
  101. ):
  102. found_desired_structure = True
  103. return output_str[:-1]
  104. class SongTemplate(ABC):
  105. @abstractmethod
  106. def get_template(self, title: str) -> Image:
  107. pass
  108. class StartSlide(ABC):
  109. @abstractmethod
  110. def get_slide(
  111. self,
  112. template_img: Image,
  113. book: str,
  114. text_author: str,
  115. melody_author: str,
  116. ):
  117. pass
  118. class SongSlide(ABC):
  119. @abstractmethod
  120. def get_slide(
  121. self,
  122. template_img: Image,
  123. slide_text: str,
  124. song_structure: list,
  125. index: int,
  126. ):
  127. pass
  128. class ClassicSongSlide(SongSlide):
  129. def get_slide(
  130. self,
  131. template_img: Image,
  132. slide_text: str,
  133. song_structure: list,
  134. index: int,
  135. ):
  136. canvas_img, font_size = self.get_text_canvas(slide_text)
  137. verse_or_chorus = song_structure[index]
  138. bg_img = template_img.clone()
  139. if "R" not in verse_or_chorus:
  140. bg_img.composite(
  141. self.get_index(verse_or_chorus, font_size),
  142. top=VERSE_Y,
  143. left=VERSE_X,
  144. )
  145. bg_img.composite(canvas_img, top=TEXT_CANVAS_Y, left=TEXT_CANVAS_X)
  146. bg_img.composite(
  147. self.get_structure_info_display(song_structure, index),
  148. top=STRUCTURE_Y,
  149. left=STRUCTURE_X,
  150. )
  151. return bg_img
  152. def get_text_canvas(self, slide_text: str) -> tuple:
  153. font_size = MAX_CANVAS_FONT_SIZE
  154. while font_size >= MIN_CANVAS_FONT_SIZE:
  155. with Drawing() as draw:
  156. draw.fill_color = TEXT_COLOR
  157. draw.text_interline_spacing = INTERLINE_SPACING
  158. draw.font_size = font_size
  159. draw.font = FONT
  160. draw.text(0, font_size, slide_text)
  161. with Image(
  162. width=WIDTH, height=HEIGHT, background=BG_COLOR
  163. ) as img:
  164. draw(img)
  165. img.trim()
  166. if (
  167. img.width > TEXT_CANVAS_WIDTH
  168. or img.height > TEXT_CANVAS_HEIGHT
  169. ):
  170. font_size -= CANVAS_FONT_SIZE_STEP
  171. display(img)
  172. else:
  173. return img.clone(), font_size
  174. return get_empty_image(), 0
  175. def get_structure_info_display(self, structure: list, index: int) -> Image:
  176. with Drawing() as draw:
  177. draw.fill_color = TEXT_COLOR
  178. draw.font_size = INFODISPLAY_FONT_SIZE
  179. draw.font = FONT
  180. for current_index, item in enumerate(structure):
  181. if current_index == index:
  182. draw.font = BOLD_FONT
  183. draw.text(
  184. current_index * INFODISPLAY_ITEM_WIDTH,
  185. INFODISPLAY_FONT_SIZE,
  186. item,
  187. )
  188. draw.font = FONT
  189. else:
  190. draw.text(
  191. current_index * INFODISPLAY_ITEM_WIDTH,
  192. INFODISPLAY_FONT_SIZE,
  193. item,
  194. )
  195. with Image(width=WIDTH, height=HEIGHT, background=BG_COLOR) as img:
  196. draw(img)
  197. img.trim()
  198. return img.clone()
  199. def get_index(self, verse: str, font_size: int) -> Image:
  200. with Image(width=WIDTH, height=HEIGHT, background=BG_COLOR) as img:
  201. img.caption(
  202. verse + ".",
  203. font=Font(FONT_PATH, size=font_size, color=TEXT_COLOR),
  204. )
  205. img.trim()
  206. return img.clone()
  207. class ClassicStartSlide(StartSlide):
  208. def get_slide(
  209. self,
  210. template_img: Image,
  211. book: str,
  212. text_author: str,
  213. melody_author: str,
  214. ):
  215. start_img = template_img.clone()
  216. start_img.composite(
  217. self.get_attributions(text_author, melody_author),
  218. left=METADATA_X,
  219. top=ATTRIBUTIONS_Y,
  220. )
  221. start_img.composite(self.get_book(book), left=METADATA_X, top=BOOK_Y)
  222. return start_img.clone()
  223. def get_metadata(self, text: str) -> Image:
  224. with Image(width=WIDTH, height=HEIGHT, background=BG_COLOR) as img:
  225. img.caption(
  226. text,
  227. font=Font(FONT_PATH, size=METADATA_FONT_SIZE, color=TEXT_COLOR),
  228. )
  229. img.trim()
  230. return img.clone()
  231. def get_attributions(self, text_author: str, melody_author: str) -> Image:
  232. if text_author == melody_author:
  233. return self.get_metadata("Text & Melodie: " + text_author)
  234. return self.get_metadata(
  235. "Text: " + text_author + "\nMelodie: " + melody_author
  236. )
  237. def get_book(self, book: str) -> Image:
  238. return self.get_metadata(book)
  239. class ClassicSongTemplate(SongTemplate):
  240. def __init__(self):
  241. self.song_template = ""
  242. def get_base_image(self) -> Image:
  243. with Image(width=WIDTH, height=HEIGHT, background=BG_COLOR) as img:
  244. return img.clone()
  245. def get_titlebar_rectangle(self, text: str) -> Image:
  246. font_size = MAX_TITLE_FONT_SIZE
  247. while font_size >= MIN_TITLE_FONT_SIZE:
  248. with Image(
  249. width=WIDTH, height=TITLE_HEIGHT, background=FG_COLOR
  250. ) as img:
  251. img.caption(
  252. text,
  253. font=Font(
  254. BOLD_FONT_PATH, size=font_size, color=TITLE_COLOR
  255. ),
  256. )
  257. img.trim()
  258. img.border(color=FG_COLOR, width=30, height=0)
  259. trimmed_img_width = img.width
  260. trimmed_img_height = img.height
  261. concat_height = int((TITLE_HEIGHT - trimmed_img_height) / 2)
  262. correction_heigt = (
  263. TRIANGLE_HEIGTH - trimmed_img_height - (2 * concat_height)
  264. )
  265. concatenated_img = Image(
  266. width=trimmed_img_width,
  267. height=concat_height,
  268. background=FG_COLOR,
  269. )
  270. concatenated_img.sequence.append(img)
  271. concatenated_img.sequence.append(
  272. Image(
  273. width=trimmed_img_width,
  274. height=concat_height + correction_heigt,
  275. background=FG_COLOR,
  276. )
  277. )
  278. concatenated_img.concat(stacked=True)
  279. if concatenated_img.width > (
  280. WIDTH - PLAYER_WIDTH - TRIANGLE_WIDTH
  281. ):
  282. font_size -= TITLE_FONT_SIZE_STEP
  283. continue
  284. return concatenated_img.clone()
  285. return get_empty_image()
  286. def get_template(self, title: str) -> Image:
  287. titlebar_rectangle = self.get_titlebar_rectangle(title)
  288. titlebar_rectangle.sequence.append(self.get_titlebar_triangle())
  289. titlebar_rectangle.concat(stacked=False)
  290. base_img = self.get_base_image()
  291. base_img.composite(titlebar_rectangle, top=TITLEBAR_Y)
  292. self.song_template = base_img.clone()
  293. return base_img.clone()
  294. def get_titlebar_triangle(self) -> Image:
  295. with Drawing() as draw:
  296. draw.fill_color = FG_COLOR
  297. draw.path_start()
  298. draw.path_move(to=(TRIANGLE_WIDTH, 0))
  299. draw.path_line(to=(0, 0))
  300. draw.path_line(to=(0, TRIANGLE_HEIGTH))
  301. draw.path_close()
  302. draw.path_finish()
  303. with Image(
  304. width=TRIANGLE_WIDTH,
  305. height=TRIANGLE_HEIGTH,
  306. background=BG_COLOR,
  307. ) as img:
  308. draw(img)
  309. return img.clone()
  310. def display(self):
  311. display(self.song_template)
  312. class Slidegen:
  313. def __init__(self, song_template_form, start_slide_form, song_slide_form):
  314. self.metadata: dict = {"": ""}
  315. self.songtext: dict = {"": ""}
  316. self.song_file_path: str = ""
  317. self.song_file_content: list = []
  318. self.output_dir: str = ""
  319. self.chosen_structure: list | str = ""
  320. self.generated_slides: list = []
  321. self.song_template_form = song_template_form
  322. self.start_slide_form = start_slide_form
  323. self.song_slide_form = song_slide_form
  324. self.parse_argv()
  325. def execute(self):
  326. self.parse_file()
  327. self.calculate_desired_structures()
  328. self.generate_slides()
  329. def generate_slides(self):
  330. song_template = self.song_template_form()
  331. log("generating template...")
  332. template_img = song_template.get_template(self.metadata["title"])
  333. first_slide = self.start_slide_form()
  334. log("generating start slide...")
  335. start_slide_img = first_slide.get_slide(
  336. template_img,
  337. self.metadata["book"],
  338. self.metadata["text"],
  339. self.metadata["melody"],
  340. )
  341. start_slide_img.format = IMAGE_FORMAT
  342. try:
  343. start_slide_img.save(
  344. filename=path.join(
  345. self.output_dir, FILE_NAMEING + "1." + FILE_EXTENSION
  346. )
  347. )
  348. except BlobError:
  349. error_msg("could not write start slide to target directory")
  350. log("generating song slides...")
  351. for index, structure in enumerate(self.chosen_structure):
  352. log(
  353. "generating song slide [{} / {}]...".format(
  354. index + 1, len(self.chosen_structure)
  355. )
  356. )
  357. song_slide = self.song_slide_form()
  358. song_slide_img = song_slide.get_slide(
  359. template_img,
  360. self.songtext[structure],
  361. self.chosen_structure,
  362. index,
  363. )
  364. song_slide_img.format = IMAGE_FORMAT
  365. try:
  366. song_slide_img.save(
  367. filename=path.join(
  368. self.output_dir,
  369. FILE_NAMEING + str(index + 2) + "." + FILE_EXTENSION,
  370. )
  371. )
  372. except BlobError:
  373. error_msg("could not write slide to target directory")
  374. def parse_file(self):
  375. self.parse_metadata()
  376. self.parse_songtext()
  377. def parse_metadata(self):
  378. metadata_dict = dict.fromkeys(METADATA_STRINGS)
  379. try:
  380. with open(self.song_file_path, mode="r", encoding="utf8") as opener:
  381. content = opener.readlines()
  382. except IOError:
  383. error_msg(
  384. "could not read the the song input file: '{}'".format(
  385. self.song_file_path
  386. )
  387. )
  388. valid_metadata_strings = list(METADATA_STRINGS)
  389. for line_nr, line in enumerate(content):
  390. if len(valid_metadata_strings) == 0:
  391. content = content[line_nr:]
  392. break
  393. if not re.match(
  394. r"^(?!structure)\S+: .+|^structure: ([0-9]+|R)(,([0-9]+|R))+$",
  395. line,
  396. ):
  397. if line[-1] == "\n":
  398. line = line[:-1]
  399. missing_metadata_strs = ""
  400. for metadata_str in valid_metadata_strings:
  401. missing_metadata_strs += ", " + metadata_str
  402. missing_metadata_strs = missing_metadata_strs[2:]
  403. error_msg(
  404. "invalid metadata syntax on line {}:\n{}\nThe ".format(
  405. line_nr + 1, line
  406. )
  407. + "following metadata strings are still missing: {}".format(
  408. missing_metadata_strs
  409. )
  410. )
  411. metadata_str = line[: line.index(":")]
  412. if metadata_str in valid_metadata_strings:
  413. metadata_dict[metadata_str] = line[line.index(": ") + 2 : -1]
  414. valid_metadata_strings.remove(metadata_str)
  415. continue
  416. error_msg("invalid metadata string '{}'".format(metadata_str))
  417. self.metadata = metadata_dict
  418. self.song_file_content = content
  419. def parse_songtext(self):
  420. unique_structures = get_unique_structure_elements(
  421. structure_as_list(self.metadata["structure"])
  422. )
  423. output_dict = dict.fromkeys(unique_structures)
  424. for structure in unique_structures:
  425. output_dict[structure] = get_songtext_by_structure(
  426. self.song_file_content, structure
  427. )
  428. self.songtext = output_dict
  429. def calculate_desired_structures(self):
  430. full_structure_str = str(self.metadata["structure"])
  431. full_structure_list = structure_as_list(full_structure_str)
  432. if len(self.chosen_structure) == 0:
  433. self.chosen_structure = structure_as_list(full_structure_str)
  434. log("chosen structure: {}".format(str(self.chosen_structure)))
  435. return
  436. if not "-" in self.chosen_structure:
  437. self.chosen_structure = structure_as_list(
  438. str(self.chosen_structure)
  439. )
  440. log("chosen structure: {}".format(str(self.chosen_structure)))
  441. return
  442. dash_index = str(self.chosen_structure).find("-")
  443. start_verse = str(self.chosen_structure[:dash_index]).strip()
  444. end_verse = str(self.chosen_structure[dash_index + 1 :]).strip()
  445. try:
  446. if int(start_verse) >= int(end_verse):
  447. error_msg("{} < {} must be true".format(start_verse, end_verse))
  448. if start_verse not in full_structure_str:
  449. error_msg("structure {} unknown".format(start_verse))
  450. if end_verse not in full_structure_str:
  451. error_msg("structure {} unknown".format(end_verse))
  452. except (ValueError, IndexError):
  453. error_msg("please choose a valid integer for the song structure")
  454. start_index = full_structure_list.index(start_verse)
  455. if start_index != 0:
  456. if (
  457. full_structure_list[0] == "R"
  458. and full_structure_list[start_index - 1] == "R"
  459. ):
  460. start_index -= 1
  461. end_index = full_structure_list.index(end_verse)
  462. if end_index != len(full_structure_list) - 1:
  463. if (
  464. full_structure_list[-1] == "R"
  465. and full_structure_list[end_index + 1] == "R"
  466. ):
  467. end_index += 1
  468. self.chosen_structure = full_structure_list[start_index : end_index + 1]
  469. log("chosen structure: {}".format(str(self.chosen_structure)))
  470. def parse_argv(self):
  471. try:
  472. self.song_file_path = sys.argv[1]
  473. self.output_dir = sys.argv[2]
  474. except IndexError:
  475. error_msg("incorrect amount of arguments provided, exiting...")
  476. try:
  477. self.chosen_structure = sys.argv[3]
  478. if self.chosen_structure.strip() == "":
  479. self.chosen_structure = ""
  480. except IndexError:
  481. self.chosen_structure = ""
  482. log("parsing {}...".format(self.song_file_path))
  483. def main():
  484. colorama.init()
  485. slidegen = Slidegen(
  486. ClassicSongTemplate, ClassicStartSlide, ClassicSongSlide
  487. )
  488. slidegen.execute()
  489. if __name__ == "__main__":
  490. main()