diff --git a/.gitignore b/.gitignore index b9ff1d4..b5796f8 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,7 @@ cython_debug/ #.idea/ test/acceptanceTest.html +test/acceptanceTestSlides.html .DS_Store .envrc config/config.json diff --git a/build.py b/build.py index f86c6e7..e5143dc 100644 --- a/build.py +++ b/build.py @@ -7,7 +7,7 @@ conf = config.Config() api = directus.DirectusAPI(conf.question_base_url, conf.question_access_token) content_api = directus.DirectusAPI(conf.content_base_url, conf.content_access_token) dl = download.Download(api, content_api, conf) -bd = build.Build() +bd = build.Build(conf) # Download the text files: dl.download_edition("N") diff --git a/doc/Slides.md b/doc/Slides.md new file mode 100644 index 0000000..90e3293 --- /dev/null +++ b/doc/Slides.md @@ -0,0 +1,234 @@ +# Erstellen von Folien + +Das Erstellen von Folien ist direkt im Directus möglich. Wir verwenden reveal.js als Framework, um die Inhalte darzustellen. + +## Directus + +Im Directus gibt es zu jeder Sektion ein Feld _Slide_ in dem vereinfacht die Inhalte von Folien eingegeben werden können. + +## Unterstützte Formate + +### Folientrennung + +Jede Folie (außer die erste) beginnt mit `---`. Dadurch wird eine neue Folie erzeugt. + +#### Spezielle Folien + +Reveal.js erlaubt es, Folien bestimmte Attribute, wie beispielsweise die Hintergrundfarbe zu übermitteln. Das kann direkt beim Erstellen einer Folie angegeben werden. + +``` MarkDown +--- data-background="#FF0000" +``` + +Dadurch wird die Folie in knallrot gefärbt. Das sollte nur in Ausnahmen verwendet werden. + +Alternativ werden drei Begriffe ausgewertet, die bereits auf der Webseitenansicht zum Einsatz kommen. + +``` MarkDown +--- unit +``` + +Wird verwendet, um eine neue Einheit einzuführen. +Färbt die Folie hellgrün ein. + +``` MarkDown +--- attention +``` + +Wird verwendet, um die Aufmerksamkeit zu erregen. Beispielsweise bei Hinweisen auf die Gültigkeit bei Inkrafttreten der AfuV. +Färbt die Folie hellblau ein. + +``` attention +--- danger +``` + +Wird verwendet, um auf Gefahren hinzuweisen. Beispielsweise beim Umgang mit elektrischem Strom. +Färbt die Folie hellrot ein. + +### Überschriften + +Überschriften werden mit dem `#`-Zeichen eingeleitet. Dabei gibt die Häufigkeit des Zeichens die Ebene der Überschrift an. + +``` MarkDown +# Überschrift 1 +## Überschrift 2 +### Überschrift 3 +``` + +Die maximale Ebene ist 6. + +### Listen + +Häufig sind auf Folien Listen zu sehen. Diese lassen sich mit `*` einleiten. + +``` MarkDown +* Listenpunkt +* Noch ein Listenpunkt +``` + +TODO: Verschachtelte Listen sind noch nicht möglich + +### Tabellen + +Tabellen werden mit Zeichen `|` zur Unterteilung der Spalten gesetzt. Die erste Spalte ist eine Kopfspalte. In ihr kann angegeben werden, ob der Inhalt der Spalte linksbündig (`l`), rechtsbündig (`r`) oder mittig (`c`) ausgerichtet sein soll. + +``` Markdown +| l: Wert | l: Bereich | l: Bedeutung | l: Englisch | +| R | 1 - 5 | Lesbarkeit | Readability | +| S | 1 - 9 | Signalstärke | Signal Strength | +| T | 1 - 9 | Tonqualität | Tone | +[table:n_rst:Die Bestandteile des RST-Rapports] +``` + +Die Tabelle muss mit `[table:referenz:Beschreibung]` abgeschlossen werden. Dadurch wird intern eine Referenz gesetzt, auf die verwiesen werden kann. + +### Textauszeichnung + +Es kann einfache Textauszeichnung, wie _hervorgehoben_ oder unterstrichen verwendet werden. + +``` MarkDown +Es kann einfache Textauszeichnung, wie *hervorgehoben* oder unterstrichen verwendet werden. +``` + +### Bilder + +Bilder werden mit einer Referenz auf das Bild in der Datenbank eingefügt. + +``` MarkDown +[photo:123:n_rst_s-meter:Display eines IC9700-Transceivers, hervorgehoben ist das S-Meter, das den aktuellen Empfangspegel anzeigt] +``` + +Es kann `photo` oder `picture` verwendet werden, je nach Datenbanktabelle in der das Bild abgelegt ist. Als nächstes wird die Nummer des Bildes angegeben. Darauf folgt eine Referenz für Verweise. Und abschließend die Beschreibung, die unter dem Bild angezeigt wird. Die Bilder werden pro Dokument durchnummeriert. + +### Fragen aus dem Prüfungskatalog + +Fragen aus dem Fragenkatalog können durch eine Referenz auf die Fragennummer eingefügt werden. + +``` Markdown +[question:2482] +``` + +Fragen sollten auf einer eigenen Folie angezeigt werden, da sie in der Regel sehr groß sind. + +``` Markdown +--- +[question:2482] +--- +``` + +Die Antworten werden beim Erstellen der Folien in eine zufällige Reihenfolge gebracht, sodass nicht wie im Prüfungskatalog die erste Antwort die richtige ist. + +### QSO + +Ein beispielhaftes QSO kann folgendermaßen hinzugefügt werden: + +``` Markdown + +Ist diese Frequenz frei? DL1PZ +> *(keine Antwort)* +Ist diese Frequenz frei? DL1PZ +> *(keine Antwort)* +CQ CQ hier ist DL1PZ mit einem allgemeinen Anruf, hier ist DL1PZ und hört. +> DL1PZ hier ist DL9MJ bitte kommen + +``` + +Jede Zeile ist eine Sprechblase. Die einzelnen Sprechblasen werden nacheinander aufgedeckt. Zeilen, die mit `>` beginnen, werden als Teil der Gegenstelle angezeigt. + +### Einblendeeffekt + +Soll ein Teil der Folie erst später aufgedeckt werden, kann dieser in `` eingebettet werden. + +``` MarkDown + +Dieser Text erscheint erst später. + +``` + +Mehrere ``-Abschnitte nacheinander sorgen dafür, dass die Inhalte Schritt für Schritt aufgedeckt werden. + +### Notizen + +Es ist möglich, Notizen für den oder die Vortragende hinzuzufügen. Die sind nur in der "Speaker-Ansicht" (Taste 's' drücken) sichtbar. + +Sinnvoll sind kurze Hinweise auf die Erklärungen, die in der Webseitenversion gegeben werden oder Hintergrundinformationen. Die brauchen nicht auf die Folien. Und kurze Hinweise sollten für Funkamateure ausreichend sein, um die Notiz zu verstehen und den Inhalt zu vermitteln. + +``` MarkDown + +Die Notiz wird nur in der Speaker-Ansicht angezeigt +* Listen sind auch möglich +* Am besten kurz halten +* Der Platz ist begrenzt + +``` + +### Formeln + +Es können Formeln im LaTeX-Stil angegeben werden. Diese werden in `$` gesetzt. + +``` MarkDown +$\lambda[[\text{m}]] = \dfrac{300}{f[[\text{MHz}]]} = \dfrac{300}{145,3 \ \text{MHz}} \approx 2,06 \ \text{m}$ +``` + +Abweichend von LaTeX müssen die rechteckigen Klammern gedoppelt werden. + +### Links + +Es können Links eingefügt werden. + +``` MarkDown +[Link zum DARC](https://darc.de/) +``` + +### Zwei Spalten + +Manchmal ist es sinnvoll, zwei Spalten zu verwenden. Beispielsweise bei einem Bild mit Beschreibung. Dazu gibt es die beiden Tags `` und ``. + +``` MarkDown + +[photo:167:rufzeichen_flugzeug:Flugzeug mit dem Rufzeichen DEBPF] + + +* Funkstationen verwenden Rufzeichen, um sich zu identifizieren +* Folge von Buchstaben und Ziffern +* Jedes mit Funk ausgerüstete Flugzeug und Schiff hat ein Rufzeichen + +``` + +### Webseite interaktiv einbetten + +Es kann eine ganze Webseite interaktiv eingebettet werden, sodass darin agiert werden kann. Zusätzlich sind die Navigationspfeile und der Speaker-View erhalten. + +``` MarkDown +--- data-background-iframe="https://f5len.org/tools/locator/" data-background-interactive + + +* Es wird eine interaktive Karte mit Maidenhead-Locator gezeigt. + +``` + +Zusätzlich eine Speaker-Note einzufügen ist sinnvoll, um keine leere Slide zu erzeugen und den Speaker darauf hinzuweisen, dass die Webseite nur vom Hauptbildschirm bedient werden kann. + +### Kleinere Schrift + +In ganz seltenen Ausnahmen kann eine kleinere Schrift verwendet werden. Das kann dann angewandt werden, wenn ein oder zwei Zeilen einer Tabelle nicht mehr auf die Folie passen. + +``` MarkDown +--- style="font-size: smaller;" + +* Längere +* Liste +``` + +### Direkter Übergang + +Ohne verschieben der Folie zur nächsten Schalten. Das kann als Effekt genutzt werden, damit auf der Folie ein Austauscheffekt stattfindet. + +```MarkDown +--- +## Folie +Mit Text +--- data-transition="none" +## Folie +Mit anderem Text +``` diff --git a/pyproject.toml b/pyproject.toml index b9f34b5..c1d7ca4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,4 +42,4 @@ select = [ [tool.pytest.ini_options] addopts = ["--color=yes"] -markers = ["html", "latex"] +markers = ["html", "latex", "slide"] diff --git a/renderer/fifty_ohm_html_renderer.py b/renderer/fifty_ohm_html_renderer.py index e0d73b7..c61e5a9 100644 --- a/renderer/fifty_ohm_html_renderer.py +++ b/renderer/fifty_ohm_html_renderer.py @@ -1,5 +1,3 @@ - - from jinja2 import Environment, FileSystemLoader from mistletoe import Document, HtmlRenderer @@ -30,12 +28,8 @@ class FiftyOhmHtmlRenderer(HtmlRenderer): ref_id = 0 def __init__( - self, - question_renderer=None, - picture_handler=None, - photo_handler=None, - include_handler=None - ) : + self, *extras, question_renderer=None, picture_handler=None, photo_handler=None, include_handler=None, **kwargs + ): super().__init__( Dash, BlockComment, @@ -53,8 +47,11 @@ class FiftyOhmHtmlRenderer(HtmlRenderer): Photo, Table, Qso, - Include + Include, + *extras, + **kwargs, ) + self.question_renderer = question_renderer self.picture_handler = picture_handler self.photo_handler = photo_handler diff --git a/renderer/fifty_ohm_html_slide_renderer.py b/renderer/fifty_ohm_html_slide_renderer.py new file mode 100644 index 0000000..8ded848 --- /dev/null +++ b/renderer/fifty_ohm_html_slide_renderer.py @@ -0,0 +1,40 @@ +from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer + +from .slide_break import SlideBreak + + +class FiftyOhmHtmlSlideRenderer(FiftyOhmHtmlRenderer): + def __init__(self, **kwargs): + super().__init__( + SlideBreak, + **kwargs, + ) + + def render_slide_break(self, token): + inner = self.render_inner(token) + + if token.attribute is None: + return f"
\n{inner}\n
\n" + else: + return f"
\n{inner}\n
\n" + + def render_qso(self, token): + qso = '
\n' + for child in token.children: + direction = "other" if child.received else "own" + fade = "fragment fade-left" if child.received else "fragment fade-right" + qso += f'
{self.render_inner(child)}
\n' + qso += "
\n" + return qso + + def render_tag(self, token): + if token.tagtype == "fragment": + return f'
\n{self.render_inner(token)}\n
\n' + if token.tagtype == "left": + return f'
\n{self.render_inner(token)}\n
\n' + if token.tagtype == "right": + return f'\n' + elif token.tagtype == "note": + return f'\n' + + return "" # Ignore other tags in slide context anyway in tokenizer diff --git a/renderer/slide_break.py b/renderer/slide_break.py new file mode 100644 index 0000000..f8f4c04 --- /dev/null +++ b/renderer/slide_break.py @@ -0,0 +1,33 @@ +import re + +from mistletoe.block_token import BlockToken, tokenize + + +class SlideBreak(BlockToken): + @staticmethod + def start(line): + return re.match(r"^---.*$", line) + + @classmethod + def read(cls, lines): + child_lines = [] + first_line = next(lines) # Consume the line that started the slide break + + match = re.match(r"^---(.*)$", first_line) + attribute = match.group(1).strip() if match.group(1) else None + + # We have to work with peak here, since a normal for loop would already + # consume the next line, which might be a `---` line that marks the + # beginning of a new slide as well + next_line = lines.peek() + while next_line is not None: + if cls.start(next_line): + break + else: + child_lines.append(next(lines)) + next_line = lines.peek() + + return tokenize(child_lines), attribute + + def __init__(self, match): + self.children, self.attribute = match diff --git a/renderer/tag.py b/renderer/tag.py index 460d16a..803385d 100644 --- a/renderer/tag.py +++ b/renderer/tag.py @@ -3,6 +3,7 @@ import re from mistletoe.block_token import BlockToken, tokenize captures = [ + # Website: "margin", "indepth", "webmargin", @@ -18,6 +19,11 @@ captures = [ "webonly", "latexonly", "wordorigin", + # Slides: + "note", + "fragment", + "left", + "right", ] diff --git a/requirements.txt b/requirements.txt index 2166ff2..5245afd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,20 @@ beautifulsoup4==4.13.4 -coverage==7.6.9 -iniconfig==2.0.0 +certifi==2025.4.26 +charset-normalizer==3.4.2 +coverage==7.9.1 +idna==3.10 +iniconfig==2.1.0 jinja2==3.1.6 +joblib==1.5.1 markupsafe==3.0.2 mistletoe==1.4.0 -packaging==24.2 -pluggy==1.5.0 -pytest==8.3.4 -pytest-cov==6.0.0 +packaging==25.0 +pluggy==1.6.0 +pygments==2.19.1 +pytest==8.4.0 +pytest-cov==6.2.1 +requests==2.32.3 soupsieve==2.7 -typing-extensions==4.13.2 +tqdm==4.67.1 +typing-extensions==4.14.0 +urllib3==2.4.0 diff --git a/src/build.py b/src/build.py index a4990ca..dd5b144 100644 --- a/src/build.py +++ b/src/build.py @@ -1,5 +1,5 @@ import json -import os +import random import shutil from jinja2 import Environment, FileSystemLoader @@ -8,23 +8,31 @@ from mistletoe import Document from tqdm import tqdm from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer +from renderer.fifty_ohm_html_slide_renderer import FiftyOhmHtmlSlideRenderer + +from .config import Config class Build: - def __init__(self): + def __init__(self, config: Config): + self.config = config + memory = Memory("./cache", verbose=0) - self.env = Environment(loader=FileSystemLoader("templates/html")) + self.env = Environment(loader=FileSystemLoader("templates/")) + self.env.filters["shuffle_answers"] = self.__filter_shuffle_answers self.questions = self.__parse_katalog() # Decorate the method with memory.cache self.__build_question = memory.cache(self.__build_question) - self.__build_chapter = memory.cache(self.__build_chapter) - self.__build_section = memory.cache(self.__build_section) - self.__build_page = memory.cache(self.__build_page) - + self.__build_question_slide = memory.cache(self.__build_question_slide) + self.__build_chapter = memory.cache(self.__build_chapter) + self.__build_section = memory.cache(self.__build_section) + self.__build_page = memory.cache(self.__build_page) + self.__build_chapter_slidedeck = memory.cache(self.__build_chapter_slidedeck) + def __parse_katalog(self): - with open("data/fragenkatalog3b.json") as fragenkatalog_file: - fragenkatalog = json.load(fragenkatalog_file) + with self.config.p_fragenkatalog.open() as file: + fragenkatalog = json.load(file) questions = {} @@ -41,13 +49,13 @@ class Build: return questions # cached - def __build_question(self, input): + def __build_question(self, input, template_file="html/question.html"): """Combines the original question dataset from BNetzA with our internal metadata""" - question_template = self.env.get_template("question.html") + question_template = self.env.get_template(template_file) - with open("data/metadata.json") as metadata_file: - metadata = json.load(metadata_file) + with (self.config.p_data / "metadata.json").open() as file: + metadata = json.load(file) number = metadata[f"{input}"]["number"] # Fragennummer z.B. AB123 question = self.questions[number] @@ -88,32 +96,29 @@ class Build: answer_pictures=answer_pictures, ) + def __build_question_slide(self, input): + return self.__build_question(input, template_file="slide/question.html") + # cached - def __build_page(self, content, course_wrapper=False, sidebar=None) : - page_template = self.env.get_template("page.html") - return page_template.render( - content=content, - course_wrapper=course_wrapper, - sidebar=sidebar - ) - + def __build_page(self, content, course_wrapper=False, sidebar=None): + page_template = self.env.get_template("html/page.html") + return page_template.render(content=content, course_wrapper=course_wrapper, sidebar=sidebar) + def __picture_handler(self, id): - os.makedirs("build/pictures", exist_ok=True) - src = f"data/pictures/{id}.svg" - dst = f"build/pictures/{id}.svg" - shutil.copyfile(src, dst) + self.config.p_build_pictures.mkdir(parents=True, exist_ok=True) + file = f"{id}.svg" + shutil.copyfile(self.config.p_data_pictures / file, self.config.p_build_pictures / file) def __photo_handler(self, id): - os.makedirs("build/photos", exist_ok=True) - src = f"data/photos/{id}.jpg" - dst = f"build/photos/{id}.jpg" - shutil.copyfile(src, dst) + self.config.p_build_photos.mkdir(parents=True, exist_ok=True) + file = f"{id}.jpg" + shutil.copyfile(self.config.p_data_photos / file, self.config.p_build_photos / file) # cached def __build_chapter(self, edition, edition_name, number, chapter, next_chapter=None): - chapter_template = self.env.get_template("chapter.html") - next_chapter_template = self.env.get_template("next_chapter.html") - with open(f'build/{edition}_chapter_{chapter["ident"]}.html', 'w') as file: + chapter_template = self.env.get_template("html/chapter.html") + next_chapter_template = self.env.get_template("html/next_chapter.html") + with (self.config.p_build / f"{edition}_chapter_{chapter['ident']}.html").open("w") as file: result = chapter_template.render( edition=edition, name=edition_name, @@ -123,7 +128,7 @@ class Build: if next_chapter is not None: result += next_chapter_template.render( - url=f"{edition}_chapter_{next_chapter["ident"]}.html", + url=f"{edition}_chapter_{next_chapter['ident']}.html", title=next_chapter["title"], ) @@ -131,22 +136,23 @@ class Build: file.write(result) def __include_handler(self, include): - with open("data/includes.json") as file: + with (self.config.p_data / "includes.json").open() as file: includes = json.load(file) return includes.get(include) # cached - def __build_section(self, edition, edition_name, section, chapter, next_section=None, next_chapter=None): - section_template = self.env.get_template("section.html") - next_section_template = self.env.get_template("next_section.html") - next_chapter_template = self.env.get_template("next_chapter.html") - with open(f'build/{edition}_{section["ident"]}.html', 'w') as file: - + def __build_section( + self, edition, edition_name, section, section_id, chapter, next_section=None, next_chapter=None + ): + section_template = self.env.get_template("html/section.html") + next_section_template = self.env.get_template("html/next_section.html") + next_chapter_template = self.env.get_template("html/next_chapter.html") + with (self.config.p_build / f"{edition}_{section['ident']}.html").open("w") as file: with FiftyOhmHtmlRenderer( - self.__build_question, - self.__picture_handler, - self.__photo_handler, - self.__include_handler + question_renderer=self.__build_question, + picture_handler=self.__picture_handler, + photo_handler=self.__photo_handler, + include_handler=self.__include_handler, ) as renderer: section["content"] = renderer.render(Document(section["content"])) @@ -154,56 +160,117 @@ class Build: edition=edition, name=edition_name, section=section, + section_id=section_id, chapter=chapter, ) if next_section is not None: result += next_section_template.render( - url=f"{edition}_{next_section["ident"]}.html", + url=f"{edition}_{next_section['ident']}.html", title=next_section["title"], ) elif next_chapter is not None: result += next_chapter_template.render( - url=f"{edition}_chapter_{next_chapter["ident"]}.html", + url=f"{edition}_chapter_{next_chapter['ident']}.html", title=next_chapter["title"], ) result = self.__build_page(result, course_wrapper=True) file.write(result) + def __build_chapter_slidedeck(self, edition, chapter, sections, next_chapter): + with (self.config.p_build / f"{edition}_slide_{chapter['ident']}.html").open("w") as file: + slide_template = self.env.get_template("slide/slide.html") + help_template = self.env.get_template("slide/help.html") + next_template = self.env.get_template("slide/next.html") + with FiftyOhmHtmlSlideRenderer( + question_renderer=self.__build_question_slide, + picture_handler=self.__picture_handler, + photo_handler=self.__photo_handler, + include_handler=self.__include_handler, + ) as renderer: + result = "
\n" + result += f'
\n

{chapter["title"]}

\n
\n' + result += help_template.render() + result += "
\n" + for section in sections: + if not section["slide"].startswith("---"): + section["slide"] = "---\n" + section["slide"] + tmp = f'
\n

{section["title"]}

\n
\n' + tmp += renderer.render(Document(section["slide"])) + result += f"
{tmp}
\n" + result += next_template.render( + edition=edition, + next_chapter=next_chapter, + chapter=chapter, + ) + + result = slide_template.render(content=result) + file.write(result) + + def __filter_shuffle_answers(self, seq): + answers = [] + firstrun = True + for answer in seq: + if firstrun: + answers.append({"content": answer, "correct": True}) + firstrun = False + else: + answers.append({"content": answer, "correct": False}) + random.shuffle(answers) + return answers + def __build_book_index(self, book): - template = self.env.get_template("course_index.html") - with open(f"build/{book['edition']}_course_index.html", "w") as file: + template = self.env.get_template("html/course_index.html") + with (self.config.p_build / f"{book['edition']}_course_index.html").open("w") as file: result = template.render( book=book, ) result = self.__build_page(result) - file.write(result) + file.write(result) + + def __build_slide_index(self, book): + template = self.env.get_template("slide/slide_index.html") + with (self.config.p_build / f"{book['edition']}_slide_index.html").open("w") as file: + result = template.render( + book=book, + ) + result = self.__build_page(result) + file.write(result) def build_edition(self, edition): + self.config.p_build.mkdir(exist_ok=True) + edition = edition.upper() - with open(f'data/book_{edition}.json') as f: - book = json.load(f) + with (self.config.p_data / f"book_{edition}.json").open() as file: + book = json.load(file) chapters = book["chapters"] edition_name = book["title"] self.__build_book_index(book) - for number, chapter in enumerate(tqdm(chapters, desc=f"Build Edition: {edition}"),1): + self.__build_slide_index(book) + for number, chapter in enumerate(tqdm(chapters, desc=f"Build Edition: {edition}"), 1): next_chapter = chapters[number] if number < len(chapters) else None self.__build_chapter(edition, edition_name, number, chapter, next_chapter) + self.__build_chapter_slidedeck(edition, chapter, chapter["sections"], next_chapter) - for i, section in enumerate(chapter["sections"],1) : + for i, section in enumerate(chapter["sections"], 1): next_section = chapter["sections"][i] if i < len(chapter["sections"]) else None - self.__build_section(edition, edition_name, section, chapter, next_section, next_chapter) + self.__build_section(edition, edition_name, section, i, chapter, next_section, next_chapter) def build_assets(self): - shutil.copytree("assets", "build/assets", dirs_exist_ok=True) + shutil.copytree(self.config.p_assets, self.config.p_build_assets, dirs_exist_ok=True) def __parse_snippets(self): - with open("data/snippets.json") as f: - snippets = json.load(f) + with (self.config.p_data / "snippets.json").open() as file: + snippets = json.load(file) - with FiftyOhmHtmlRenderer(self.__build_question, self.__picture_handler, self.__photo_handler) as renderer: + with FiftyOhmHtmlRenderer( + question_renderer=self.__build_question, + picture_handler=self.__picture_handler, + photo_handler=self.__photo_handler, + include_handler=self.__include_handler, + ) as renderer: for key, value in snippets.items(): snippets[key] = renderer.render_inner(Document(value)) # Remove leading

and trailing

: @@ -211,35 +278,32 @@ class Build: return snippets def __parse_contents(self): - with open("data/content.json") as f: - contents = json.load(f) + with (self.config.p_data / "content.json").open() as file: + contents = json.load(file) return contents def __build_index(self, snippets): - template = self.env.get_template("index.html") + template = self.env.get_template("html/index.html") result = template.render({"snippets": snippets}) - with open("build/index.html", "w") as file: + with (self.config.p_build / "index.html").open("w") as file: result = self.__build_page(result) file.write(result) def __build_course_page(self, snippets, template, page): - template = self.env.get_template(f"{template}.html") - result=template.render({"snippets": snippets}) + template = self.env.get_template(f"html/{template}.html") + result = template.render({"snippets": snippets}) - with open(f"build/{page}.html", "w") as file: + with (self.config.p_build / f"{page}.html").open("w") as file: result = self.__build_page(result) file.write(result) def __build_html_page(self, contents, page): for content in contents: if content["url_part"] == page: - with open(f"build/{page}.html", "w") as file: - result = self.__build_page( - content=content["content"], - sidebar=content["sidebar"] - ) + with (self.config.p_build / f"{page}.html").open("w") as file: + result = self.__build_page(content=content["content"], sidebar=content["sidebar"]) file.write(result) def build_website(self): @@ -250,4 +314,4 @@ class Build: self.__build_course_page(snippets, "kurse-liste", "kurse_vor_ort_liste") self.__build_course_page(snippets, "patenkarte", "patenkarte") self.__build_html_page(contents, "pruefung") - self.__build_html_page(contents, "infos") \ No newline at end of file + self.__build_html_page(contents, "infos") diff --git a/src/config.py b/src/config.py index ef1f6c2..fe381b9 100644 --- a/src/config.py +++ b/src/config.py @@ -1,12 +1,10 @@ import json import os +from pathlib import Path class Config: - env_prefix = "OHM" - access_token: str - base_url: str - no_latex: bool + __env_prefix = "OHM" def __init__(self): if os.path.isfile("config/config.json"): @@ -19,14 +17,28 @@ class Config: self.content_base_url = self.get_config_value("directus_content_base_url", "https://redaktion.50ohm.de/") self.no_latex = self.get_config_value("no_latex", False) + self.p_fragenkatalog = Path(self.get_config_value("path_fragenkatalog", "./data/fragenkatalog3b.json")) + + self.p_data = Path("./data") + self.p_data_photos = self.p_data / "photos" + self.p_data_pictures = self.p_data / "pictures" + + self.p_build = Path("./build") + self.p_build_photos = self.p_build / "photos" + self.p_build_pictures = self.p_build / "pictures" + self.p_build_assets = self.p_build / "assets" + + self.p_assets = Path("./assets") + def get_config_value(self, key: str, default=None): if key in self.config: return self.config[key] - elif f"{self.env_prefix}_{key.upper()}" in os.environ.keys(): - return os.environ[f"{self.env_prefix}_{key.upper()}"] + elif f"{self.__env_prefix}_{key.upper()}" in os.environ.keys(): + return os.environ[f"{self.__env_prefix}_{key.upper()}"] elif default is not None: return default else: raise Exception( - f"ERROR: Required value for '{key}' not found. Add to config.json or env as '${self.env_prefix}_{key.upper()}'" + f"ERROR: Required value for '{key}' not found." + f"Add to config.json or env as '${self.__env_prefix}_{key.upper()}'" ) diff --git a/src/download.py b/src/download.py index 9295825..9459417 100644 --- a/src/download.py +++ b/src/download.py @@ -6,9 +6,13 @@ import shutil from joblib import Memory, Parallel, delayed from tqdm import tqdm +from api.directus import DirectusAPI + +from .config import Config + class Download: - def __init__(self, api, content_api, config): + def __init__(self, api: DirectusAPI, content_api: DirectusAPI, config: Config): self.api = api self.config = config self.content_api = content_api @@ -60,7 +64,7 @@ class Download: "edition": edition, } - with open("data/book_" + edition + ".json", "w", encoding="utf-8") as file: + with (self.config.p_data / f"book_{edition}.json").open("w", encoding="utf-8") as file: json.dump(out_edition, file, ensure_ascii=False, indent=4) def download_question_metadata(self): @@ -82,26 +86,26 @@ class Download: } # Serializing json: - with open("data/metadata.json", "w") as f: + with (self.config.p_data / "metadata.json").open("w", encoding="utf-8") as f: json.dump(result, f, indent=4) f.close() def download_photos(self): + self.config.p_data_photos.mkdir(parents=True, exist_ok=True) + photos = self.api.get("items/Fotos", params={"limit": -1}) for photo in tqdm(photos, desc="Downloading photos"): data = self.api.get_file("assets/" + photo["photo"]) - with open(f"./data/photos/{photo['id']}.jpg", "wb") as file: + with (self.config.p_data_photos / f"{photo['id']}.jpg").open("wb") as file: file.write(data) - def __symlink_pictures(self): - os.chdir("./data/pictures") - if not os.path.islink("foto"): - os.system("ln -s ../photos foto") - os.chdir("../..") - def download_pictures(self): - self.__symlink_pictures() + self.config.p_data_pictures.mkdir(parents=True, exist_ok=True) + # Symlink photos, so LaTeX can access these assets to render into graphics. + foto_link = self.config.p_data_pictures / "foto" + if not foto_link.is_symlink(): + foto_link.symlink_to(self.config.p_data_photos) memory = Memory("./cache", verbose=0) @@ -115,17 +119,22 @@ class Download: return if self.config.no_latex: - shutil.copyfile("./assets/images/50ohm_gray.svg", f"./data/pictures/{pid}.svg") + shutil.copyfile("./assets/images/50ohm_gray.svg", self.config.p_data_pictures / f"{pid}.svg") else: - with open(f"./data/pictures/img/{pid}include.tex", "w") as file: + with (self.config.p_data_pictures / f"img/{pid}include.tex").open("w") as file: file.write(picture["latex"]) - with open(f"./data/pictures/{pid}.tex", "w", encoding="utf-8") as file: + + aux_file = self.config.p_data_photos / f"{pid}.tex" + with aux_file.open("w", encoding="utf-8") as file: # Write the auxilary LaTeX file: file.write(f"\\documentclass{{BNetzA-Fragenkatalog}}\\DARCimageOnly{{9cm}}{{{pid}include}}") # Build the picture: - os.system(f"latexmk -lualatex -f -interaction=nonstopmode ./data/pictures/{pid}.tex > /dev/null 2>&1") + os.system(f"latexmk -lualatex -f -interaction=nonstopmode {aux_file} > /dev/null 2>&1") # Convert the PDF to SVG: - os.system(f"pdftocairo -svg data/pictures/{pid}.pdf - > data/pictures/{pid}.svg") + os.system( + "pdftocairo -svg" + f"{self.config.p_data_pictures / f'{pid}.pdf'} - > {self.config.p_data_pictures / f'{pid}.svg'}" + ) pictures = self.api.get("items/pictures", params={"limit": -1}) @@ -139,20 +148,22 @@ class Download: result = {} for include in tqdm(includes, desc="Downloading includes"): result[include["ident"]] = include["content"] - - with open("data/includes.json", "w") as f: - json.dump(result, f, indent=4) + + with (self.config.p_data / "includes.json").open("w", encoding="utf-8") as file: + json.dump(result, file, indent=4) def download_snippets(self): snippets = {} for snippet in tqdm(self.content_api.get("items/snippet"), desc="Downloading snippets"): snippets[snippet["ident"]] = snippet["content"] - with open("data/snippets.json", "w", encoding="utf-8") as file: + with (self.config.p_data / "snippets.json").open("w", encoding="utf-8") as file: json.dump(snippets, file, ensure_ascii=False, indent=4) def download_content(self): contents = [] - for content in self.content_api.get("items/content"): #tqdm(self.content_api.get("items/content"), desc="Downloading content"): + for content in self.content_api.get( + "items/content" + ): # tqdm(self.content_api.get("items/content"), desc="Downloading content"): contents.append( { "url_part": content["url_part"], @@ -160,6 +171,6 @@ class Download: "sidebar": content["sidebar"], } ) - - with open("data/content.json", "w", encoding="utf-8") as file: + + with (self.config.p_data / "content.json").open("w", encoding="utf-8") as file: json.dump(contents, file, ensure_ascii=False, indent=4) diff --git a/templates/slide/help.html b/templates/slide/help.html new file mode 100644 index 0000000..036c33a --- /dev/null +++ b/templates/slide/help.html @@ -0,0 +1,112 @@ +
+

Navigationshilfe

+

Diese Navigationshilfe zeigt die ersten Schritte zur Verwendung der Präsention. Sie kann mit ⟶ (Pfeiltaste + rechts) übersprungen werden.

+
+
+

Navigation

+
+

Zwischen den Folien und Abschnitten kann man mittels der Pfeiltasten hin- und herspringen, dazu kann man auch + die Pfeiltasten am Computer nutzen.

+ Navigationspfeile für die Präsentation +
+ +
+
+

Weitere Funktionen

+

Mit ein paar Tastenkürzeln können weitere Funktionen aufgerufen werden. Die wichtigsten sind:

+
+
F1
+
Help / Hilfe
+
o
+
Overview / Übersicht aller Folien
+
s
+
Speaker View / Referentenansicht
+
f
+
Full Screen / Vollbildmodus
+
b
+
Break, Black, Pause / Ausblenden der Präsentation
+
Alt-Click
+
In die Folie hin- oder herauszoomen
+
+
+
+

Übersicht

+
+

Die Präsentation ist zweidimensional aufgebaut. Dadurch sind in Spalten die einzelnen Abschnitte eines + Kapitels und in den Reihen die Folien zu den Abschnitten.

+

Tippt man ein „o“ ein, bekommt man eine Übersicht über alle Folien des jeweiligen Kapitels. Das hilft + sich zunächst einen Überblick zu verschaffen oder sich zu orientieren, wenn man das Gefühlt hat sich + „verlaufen“ zu haben. Die Navigation erfolgt über die Pfeiltasten.

+
+ +
+
+

Referentenansicht

+
+ Referentenansicht +

Tippt man ein „s“ ein, bekommt man ein neues Fenster, die Referentenansicht.

+

Indem man „Layout“ auswählt, kann man zwischen verschieden Anordnungen der Elemente auswählen.

+
+ +
+
+

Praxistipps zur Referentenansicht

+
    +
  • Wenn man mit einem Projektor arbeitet, stellt man im Betriebssystem die Nutzung von 2 Monitoren ein: Die + Referentenansicht wird dann zum Beispiel auf dem Laptop angezeigt, während die Teilnehmer die + Präsentation angezeigt bekommen.
  • +
  • Bei einer Online-Präsentation, wie beispielsweise auf TREFF.darc.de + präsentiert man den Browser-Tab und navigiert im „Speaker View“ Fenster.
  • +
  • Die Referentenansicht bezieht sich immer auf ein Kapitel. Am Ende des Kapitels muss sie geschlossen + werden, um im neuen Kapitel eine neue Referentenansicht zu öffnen.
  • +
  • Um mit dem Mauszeiger etwas zu markieren oder den Zoom zu verwenden, muss mit der Maus auf den + Bildschirm mit der Präsentation gewechselt werden.
  • +
+
+
+

Vollbild

+

Tippt man ein „f“ ein, wird die aktuelle Folie im Vollbild angezeigt. Mit „Esc“ kann man diesen wieder + verlassen.

+

Das ist insbesondere für den Bildschirm mit der Präsentation für das Publikum praktisch. +

+
+

Ausblenden

+

Tippt man ein „b“ ein, wird die Präsentation ausgeblendet.

+

Sie kann wie folgte wieder eingeblendet werden:

+
    +
  • Durch klicken in das Fenster.
  • +
  • Durch nochmaliges Drücken von „b“.
  • +
  • Durch klicken der Schaltfläche „Resume presentation:
  • +
+ Schaltfläche für Resume Presentation +
+
+

Zoom

+

Bei gedrückter Alt-Taste und einem Mausklick in der Präsentation wird in diesen Teil hineingezoomt. Das ist + praktisch, um Details von Schaltungen hervorzuheben. Durh einen nochmaligen Mausklick zusammen mit Alt wird + wieder herausgezoomt.

+

Das Zoomen funktioniert nur im ausgewählten Fenster. Die Referentenansicht ist hier nicht mit dem + Präsenationsansicht gesynct.

+
diff --git a/templates/slide/next.html b/templates/slide/next.html new file mode 100644 index 0000000..bd55881 --- /dev/null +++ b/templates/slide/next.html @@ -0,0 +1,15 @@ +
+

Fragen?

+
+

Links zu diesem Foliensatz

+ +
diff --git a/templates/slide/question.html b/templates/slide/question.html new file mode 100644 index 0000000..ac15882 --- /dev/null +++ b/templates/slide/question.html @@ -0,0 +1,24 @@ +{% set answer_label = ["A", "B", "C", "D"] -%} +{% if picture_question or (answer_pictures[0] and layout in ["1", "9", None]) -%} +{# OHM-13: Fix layout for 4x times picture in row to 2x2 field #} +
+{% else -%} +
+{% endif -%} +
{{ number }}: {{ question }}
+ {% if picture_question -%} +
+ {% endif -%} +
+ {% if answers[0] -%} + {% for answer in answers|shuffle_answers -%} + {{ answer_label[loop.index - 1] }}: {{ answer.content }}

+ {% endfor -%} + {% endif -%} + {% if answer_pictures[0] -%} + {% for answer in answer_pictures|shuffle_answers -%} +
{{ answer_label[loop.index - 1] }}:
+ {% endfor -%} + {% endif -%} +
+
diff --git a/templates/slide/slide.html b/templates/slide/slide.html new file mode 100644 index 0000000..c102b17 --- /dev/null +++ b/templates/slide/slide.html @@ -0,0 +1,56 @@ + + + + + + DARC Ausbildungsplattform + + + + + + + + + + + + + + + + +
+ +
+ {{ content }} +
+ + + + + + + + + + + \ No newline at end of file diff --git a/templates/slide/slide_index.html b/templates/slide/slide_index.html new file mode 100644 index 0000000..e52dd96 --- /dev/null +++ b/templates/slide/slide_index.html @@ -0,0 +1,9 @@ +

Folien: {{ book["title"] }}

+

{{ book["abstract"] }}

+
+ {% for chapter in book["chapters"] %} + + {{loop.index}}{{ chapter["title"]}} + + {% endfor %} +
\ No newline at end of file diff --git a/test/acceptanceTestSlides.md b/test/acceptanceTestSlides.md new file mode 100644 index 0000000..dca7dea --- /dev/null +++ b/test/acceptanceTestSlides.md @@ -0,0 +1,137 @@ +--- + +# Akzeptanztest + +--- style="font-size: 4pt;" + +## Kleinere Schrift +* Längere +* Liste + +--- + +## Folie mit direktem Übergang +Mit Text + +--- data-transition="none" + +## Folie mit direktem Übergang +Mit anderem Text + +--- + +## Externer Link und Formel + +[Link zum DARC](https://darc.de/) + +$\lambda[[\text{m}]] = \dfrac{300}{f[[\text{MHz}]]} = \dfrac{300}{145,3 \ \text{MHz}} \approx 2,06 \ \text{m}$ + +--- + +## Notes Test + +Diese Folie hat Notizen + + +Die Notiz wird nur in der Speaker-Ansicht angezeigt +* Listen sind auch möglich +* Am besten kurz halten +* Der Platz ist *begrenzt* + + +--- + +## Fragment Test + +Dies ist ein Text der direkt da ist + + +Dieser Text erscheint erst später. + + +--- + +## Slide mit QSO + + +Ist diese Frequenz frei? DL1PZ +> *(keine Antwort)* +Ist diese Frequenz frei? DL1PZ +> *(keine Antwort)* +CQ CQ hier ist DL1PZ mit einem allgemeinen Anruf, hier ist DL1PZ und hört. +> DL1PZ hier ist DL9MJ bitte kommen + + +--- + +## Slide mit Picture + +[picture:123:p_test:testbild] + +--- + +## Slide mit Photo + +[photo:123:n_rst_s-meter:Display eines IC9700-Transceivers, hervorgehoben ist das S-Meter, das den aktuellen Empfangspegel anzeigt] + +--- +[question:2482] +--- + +## Slide Tabelle + +| l: Wert | l: Bereich | l: Bedeutung | l: Englisch | +| R | 1 - 5 | Lesbarkeit | Readability | +| S | 1 - 9 | Signalstärke | Signal Strength | +| T | 1 - 9 | Tonqualität | Tone | +[table:n_rst:Die Bestandteile des RST-Rapports] + +--- + +## Slide Mit Markdown + +Das ist ein *Text* + +* Das ist ein Bullet +* Das ist noch ein Bullet + +Hier wird text unterstrichen + +--- unit + +## Slide Unit + +Slide C + +--- data-background="#FF00FF" + +## Slide PINK + +PINK + +## Iframe als Hintergrund + +--- data-background-iframe="https://f5len.org/tools/locator/" data-background-interactive + +--- + +## Double Column + + +[photo:167:rufzeichen_flugzeug:Flugzeug mit dem Rufzeichen DEBPF] + + +* Funkstationen verwenden Rufzeichen, um sich zu identifizieren +* Folge von Buchstaben und Ziffern +* Jedes mit Funk ausgerüstete Flugzeug und Schiff hat ein Rufzeichen + + +--- + +## Folie mit direktem Übergang +Mit Text + +--- data-transition="none" + +## Folie mit direktem Übergang +Mit anderem Text ... keine ahnung ob das je ging? \ No newline at end of file diff --git a/test/test_html_slides.py b/test/test_html_slides.py new file mode 100644 index 0000000..291e528 --- /dev/null +++ b/test/test_html_slides.py @@ -0,0 +1,98 @@ +import json +import random + +from jinja2 import Environment, FileSystemLoader +from mistletoe import Document + +from renderer.fifty_ohm_html_slide_renderer import FiftyOhmHtmlSlideRenderer + + +def parse_katalog(): + with open("data/fragenkatalog3b.json") as fragenkatalog_file: + fragenkatalog = json.load(fragenkatalog_file) + + questions = {} + + for exampart in fragenkatalog["sections"]: + for chapter in exampart["sections"]: + if "questions" in chapter: + for question in chapter["questions"]: + questions[question["number"]] = question + if "sections" in chapter: + for section in chapter["sections"]: + for question in section["questions"]: + questions[question["number"]] = question + + return questions + + +def filter_shuffle_answers(seq): + try: + answers = [] + firstrun = True + for answer in seq: + if firstrun: + answers.append({"content": answer, "correct": True}) + firstrun = False + else: + answers.append({"content": answer, "correct": False}) + random.shuffle(answers) + return answers + except Exception: + return seq + + +def question_stub(input): + """Combines the original question dataset from BNetzA with our internal metadata""" + with open("data/metadata.json") as metadata_file: + metadata = json.load(metadata_file) + number = metadata[f"{input}"]["number"] # Fragennummer z.B. AB123 + + question = questions[number] + metadata = metadata[f"{input}"] + + if "answer_a" in question: + answers = [question["answer_a"], question["answer_b"], question["answer_c"], question["answer_d"]] + else: + answers = [] + + if metadata["picture_a"] != "": + answer_pictures = [ + metadata["picture_a"], + metadata["picture_b"], + metadata["picture_c"], + metadata["picture_d"], + ] + else: + answer_pictures = [] + + if "picture_question" in question: + picture_question = metadata["picture_question"] + else: + picture_question = "" + + return question_template.render( + question=question["question"], + number=number, + layout=metadata["layout"], + picture_question=picture_question, + answers=answers, + answer_pictures=answer_pictures, + ) + + +env = Environment(loader=FileSystemLoader("templates/")) +env.filters["shuffle_answers"] = filter_shuffle_answers +question_template = env.get_template("slide/question.html") +slide_template = env.get_template("slide/slide.html") +questions = parse_katalog() + + +def test_html_slides(capsys): + with capsys.disabled(): + with open("test/acceptanceTestSlides.md") as file: + content = file.read() + with FiftyOhmHtmlSlideRenderer(question_renderer=question_stub) as renderer: + output = renderer.render(Document(content)) + with open("test/acceptanceTestSlides.html", "w") as output_file: + output_file.write(output) diff --git a/test/test_qso.py b/test/test_qso.py index 5495c7b..231c70c 100644 --- a/test/test_qso.py +++ b/test/test_qso.py @@ -2,10 +2,11 @@ import mistletoe import pytest from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer +from renderer.fifty_ohm_html_slide_renderer import FiftyOhmHtmlSlideRenderer @pytest.mark.html -def test_table_html(): +def test_qso_html(): assertions = { "\nfoo\n": FiftyOhmHtmlRenderer.render_tag_helper("qso", '
foo
\n', 1, 0) + "\n", @@ -21,3 +22,13 @@ def test_table_html(): for assertion in assertions: assert mistletoe.markdown(assertion, FiftyOhmHtmlRenderer) == assertions[assertion] + +@pytest.mark.slide +def test_qso_slide(): + assertions = { + "\nfoo\n> bar\n": + '
\n
foo
\n
bar
\n
\n\n', + } + + for assertion in assertions: + assert mistletoe.markdown(assertion, FiftyOhmHtmlSlideRenderer) == assertions[assertion] diff --git a/test/test_question.py b/test/test_question.py index d45d9ca..526677d 100644 --- a/test/test_question.py +++ b/test/test_question.py @@ -20,7 +20,7 @@ def test_question_html(): def test_function(input): return f"{input}" - with FiftyOhmHtmlRenderer(test_function) as renderer: + with FiftyOhmHtmlRenderer(question_renderer=test_function) as renderer: for assertion in assertions: assert renderer.render(Document(assertion)) == assertions[assertion] @@ -38,7 +38,7 @@ def test_question_translation_html(): metadata = json.load(file) return metadata[f"{input}"].get("number") - with FiftyOhmHtmlRenderer(test_function) as renderer: + with FiftyOhmHtmlRenderer(question_renderer=test_function) as renderer: for assertion in assertions: assert renderer.render(Document(assertion)) == assertions[assertion] diff --git a/test/test_tag.py b/test/test_tag.py index 55d4fbf..4af938d 100644 --- a/test/test_tag.py +++ b/test/test_tag.py @@ -2,6 +2,7 @@ import mistletoe import pytest from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer +from renderer.fifty_ohm_html_slide_renderer import FiftyOhmHtmlSlideRenderer from renderer.fifty_ohm_latex_renderer import FiftyOhmLaTeXRenderer @@ -29,7 +30,8 @@ def test_tag_html(): + "\n", "\nFoo\n": FiftyOhmHtmlRenderer.render_tag_helper("unit", "

Foo

", 1, 0) + "\n", "\nFoo\n": FiftyOhmHtmlRenderer.render_tag_helper("danger", "

Foo

", 1, 0) + "\n", - "\nFoo\n": FiftyOhmHtmlRenderer.render_tag_helper("wordorigin", "

Foo

", 1, 0) + "\n", + "\nFoo\n": FiftyOhmHtmlRenderer.render_tag_helper("wordorigin", "

Foo

", 1, 0) + + "\n", "\nFoo\n": "

Foo

\n", "\nFoo\n": "", } @@ -103,3 +105,30 @@ def test_thematic_break_and_tag_latex(): for assertion in assertions: assert mistletoe.markdown(assertion, FiftyOhmLaTeXRenderer) == assertions[assertion] + + +@pytest.mark.slide +def test_tag_slide(): + assertions = { + "\nFoo\n": '\n\n', + "\nFoo\n": '
\n

Foo

\n
\n\n', + "\nFoo\n": '
\n

Foo

\n
\n\n', + "\nFoo\n": '\n\n', + } + + for assertion in assertions: + assert mistletoe.markdown(assertion, FiftyOhmHtmlSlideRenderer) == assertions[assertion] + + +@pytest.mark.slide +def test_slide_break(): + assertions = { + "---\na\n": "
\n

a

\n
\n\n", + "---\na\n---\nb\n": "
\n

a

\n
\n\n
\n

b

\n
\n\n", + "--- foo\na\n": "
\n

a

\n
\n\n", + "--- foo bar\na\n": "
\n

a

\n
\n\n", + "--- foo bar \na\n": "
\n

a

\n
\n\n", + } + + for assertion in assertions: + assert mistletoe.markdown(assertion, FiftyOhmHtmlSlideRenderer) == assertions[assertion]