slidegen.py 17 KB

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