Pull request #30: Feature/table

Merge in FRAG/50ohm.de from feature/table to main

* commit '5fe7510819e85412ea0dbc80bfa6c23789249343':
  feature: caption for tables in LaTeX
  feat: parse table captions
  fix: parse column alignment with regex
  refactor: format file with ruff
  refactor: simplify rendering of table cells
  feat: improve table tests
  refactor: format file with ruff
  fix: include new dependencies in requirements.txt
  refactor: inline row_helper
  feature: render latex table
  feature: html table implemented
  fix: fixed bug in morse code
  feature: first table implementation
This commit is contained in:
Prof.Dr. Matthias Jung DL9MJ
2025-05-15 21:31:49 +00:00
9 changed files with 217 additions and 46 deletions
+31 -4
View File
@@ -12,10 +12,13 @@ from .picture import Picture
from .question import Question
from .quote import Quote
from .references import References
from .table import Table
from .tag import Tag
from .underline import Underline
from .unit import Unit
table_alignment = {"l": "left", "c": "center", "r": "right"}
class FiftyOhmHtmlRenderer(HtmlRenderer):
margin_anchor_id = 0
@@ -38,7 +41,8 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
References,
Question,
Picture,
Photo
Photo,
Table,
)
self.question_renderer = question_renderer
@@ -182,7 +186,7 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
</figure>
"""
def render_picture(self, token) :
def render_picture(self, token):
return self.render_picture_helper(token.id, token.ref, token.text, token.number)
@staticmethod
@@ -194,5 +198,28 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
</figure>
"""
def render_photo(self, token) :
return self.render_photo_helper(token.id, token.ref, token.text, token.number)
def render_photo(self, token):
return self.render_photo_helper(token.id, token.ref, token.text, token.number)
@staticmethod
def render_cell_helper(cell, alignment, type):
style = ""
if alignment in table_alignment:
style = f' style="text-align: {table_alignment[alignment]};"'
return f"<{type}{style}>{cell}</{type}>\n"
def render_table(self, token):
alignment = token.alignment
table = "<table>\n"
for i, row in enumerate(token.children):
content = ""
for j, cell in enumerate(row.children):
content += self.render_cell_helper(self.render_inner(cell), alignment[j], ("th" if i == 0 else "td"))
table += f"<tr>\n{content}</tr>\n"
if token.caption != "":
table += f"<caption>{token.caption}</caption>"
table += "</table>"
return table
+26 -2
View File
@@ -8,6 +8,7 @@ from .photo import Photo
from .picture import Picture
from .question import Question
from .quote import Quote
from .table import Table
from .tag import Tag
from .underline import Underline
@@ -25,7 +26,8 @@ class FiftyOhmLaTeXRenderer(LaTeXRenderer):
NonbreakingSpacesDots,
Question,
Picture,
Photo
Photo,
Table
)
self.question_renderer = question_renderer
@@ -113,4 +115,26 @@ class FiftyOhmLaTeXRenderer(LaTeXRenderer):
\label{{{ref}}}"""
def render_photo(self, token) :
return self.render_photo_helper(token.id, token.ref, token.text, token.number)
return self.render_photo_helper(token.id, token.ref, token.text, token.number)
def render_table(self, token):
alignments = token.alignment
align = ""
for alignment in alignments :
align += alignment
table = f"\\begin{{DARCtabular}}{{{align}}}\n"
for row in token.children:
for j, cell in enumerate(row.children):
table += self.render_inner(cell)
if j < len(row.children) - 1:
table += " & "
table += r"\\"+ "\n"
table += "\\end{DARCtabular}"
if token.caption != "" :
table += f"\\captionof{{figure}}{{{token.caption}}}\n"
table += f"\\label{{{token.name}}}\n"
return table
+1 -1
View File
@@ -87,4 +87,4 @@ class Morse(SpanToken):
return result
parse_inner = False
pattern = re.compile(r'"\[morse:\s*(.+)\]"')
pattern = re.compile(r'\[morse:\s*(.+)\]')
+78
View File
@@ -0,0 +1,78 @@
import re
from mistletoe import span_token
from mistletoe.block_token import BlockToken
class Table(BlockToken):
@staticmethod
def start(line):
return "|" in line
@classmethod
def parse_header(cls, line):
# Parse header, extract alignment and make an ordinary row out of it
line = line.strip().strip("|") # Remove leading/trailing whitespace and pipes
columns = [col.strip() for col in line.split("|")] # Split by pipe |
alignment = []
header = "| "
for column in columns:
attr, val = re.match(r"^ ?(?:([lcrX]):)?(.*)", column).group(1, 2)
alignment.append(attr)
header += val + " | "
return header, alignment
@classmethod
def read(cls, lines):
header = next(lines)
header, alignment = cls.parse_header(header)
name = ""
caption = ""
rows = [TableRow(header)]
# Read table until the end: No more column definitions, caption, or empty line.
line_buffer = []
next_line = lines.peek()
while next_line is not None and next_line.strip() != "":
maybe_caption = re.match(r"\[table:([^:\]]+):([^:\]]+)\]", next_line)
if maybe_caption is not None:
name, caption = maybe_caption.group(1, 2)
next(lines)
break
elif "|" not in next_line:
break
else:
line_buffer.append(next(lines))
next_line = lines.peek()
rows.extend([TableRow(line) for line in line_buffer])
return alignment, rows, name, caption
def __init__(self, match):
self.alignment, self.children, self.name, self.caption = match
class TableRow(BlockToken):
@classmethod
def parse_row(cls, line):
line = line.strip().strip("|")
columns = [col.strip() for col in line.split("|")]
data = []
for column in columns:
data.append(TableCell(column))
return data
def __init__(self, match):
self.children = self.parse_row(match)
class TableCell(BlockToken):
def __init__(self, match):
super().__init__(match, span_token.tokenize_inner)
+5
View File
@@ -1,7 +1,12 @@
beautifulsoup4==4.13.4
coverage==7.6.9
iniconfig==2.0.0
jinja2==3.1.6
markupsafe==3.0.2
mistletoe==1.4.0
packaging==24.2
pluggy==1.5.0
pytest==8.3.4
pytest-cov==6.0.0
soupsieve==2.7
typing-extensions==4.13.2
+1 -1
View File
@@ -79,7 +79,7 @@ CQ CQ hier ist DL1PZ mit einem allgemeinen Anruf, hier ist DL1PZ und hört.
</indepth>
<webmargin>
| c | l | c | l | c | l |
| c: | l: | c: | l: | c: | l: |
| | | | | | |
| A | [morse:a] | K | [morse:k] | U | [morse:u] |
| B | [morse:b] | L | [morse:l] | V | [morse:v] |
+31 -33
View File
@@ -8,59 +8,56 @@ from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer
def parse_katalog():
with open('data/fragenkatalog3b.json') as fragenkatalog_file:
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"] :
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"] :
if "sections" in chapter:
for section in chapter["sections"]:
for question in section["questions"]:
questions[question["number"]] = question
return questions
env = Environment(loader=FileSystemLoader('templates/html'))
question_template = env.get_template('question.html')
env = Environment(loader=FileSystemLoader("templates/html"))
question_template = env.get_template("question.html")
questions = parse_katalog()
def question_stub(input):
""" Combines the original question dataset from BNetzA with our internal metadata """
with open('data/metadata.json') as metadata_file :
"""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
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 :
if "answer_a" in question:
answers = [question["answer_a"], question["answer_b"], question["answer_c"], question["answer_d"]]
else:
answers = []
if metadata["picture_a"] != "" :
if metadata["picture_a"] != "":
answer_pictures = [
metadata["picture_a"],
metadata["picture_b"],
metadata["picture_c"],
metadata["picture_d"]
]
else :
metadata["picture_a"],
metadata["picture_b"],
metadata["picture_c"],
metadata["picture_d"],
]
else:
answer_pictures = []
if "picture_question" in question :
if "picture_question" in question:
picture_question = metadata["picture_question"]
else :
else:
picture_question = ""
return question_template.render(
@@ -69,9 +66,10 @@ def question_stub(input):
layout=metadata["layout"],
picture_question=picture_question,
answers=answers,
answer_pictures=answer_pictures
answer_pictures=answer_pictures,
)
def test_html(capsys):
with capsys.disabled():
with open("test/acceptanceTest.md") as file:
@@ -79,5 +77,5 @@ def test_html(capsys):
with FiftyOhmHtmlRenderer(question_stub) as renderer:
output = renderer.render(Document(content))
with open("test/acceptanceTest.html", "w") as output_file:
pretty = BeautifulSoup(output, 'html.parser').prettify()
pretty = BeautifulSoup(output, "html.parser").prettify()
output_file.write(pretty)
+5 -5
View File
@@ -8,11 +8,11 @@ from test.util import paragraph
@pytest.mark.html
def test_morse_html():
assertions = {
'"[morse:a]"': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2]]),
'"[morse: a]"': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2]]),
'"[morse: aaa]"': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2], [1, 2], [1, 2]]),
'"[morse: a a]"': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2], [3], [1, 2]]),
'"[morse: db0]"': FiftyOhmHtmlRenderer.render_morse_helper([[2, 1, 1], [2, 1, 1, 1], [2, 2, 2, 2, 2]]),
'[morse:a]': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2]]),
'[morse: a]': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2]]),
'[morse: aaa]': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2], [1, 2], [1, 2]]),
'[morse: a a]': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2], [3], [1, 2]]),
'[morse: db0]': FiftyOhmHtmlRenderer.render_morse_helper([[2, 1, 1], [2, 1, 1, 1], [2, 2, 2, 2, 2]]),
}
for assertion in assertions:
+39
View File
@@ -0,0 +1,39 @@
import pytest
from mistletoe import Document
from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer
from renderer.fifty_ohm_latex_renderer import FiftyOhmLaTeXRenderer
@pytest.mark.html
def test_table_html():
assertions = {
"| l: a |": '<table>\n<tr>\n<th style="text-align: left;">a</th>\n</tr>\n</table>\n',
"| r: a |": '<table>\n<tr>\n<th style="text-align: right;">a</th>\n</tr>\n</table>\n',
"| c: a |": '<table>\n<tr>\n<th style="text-align: center;">a</th>\n</tr>\n</table>\n',
"| l: a |\n| b |": '<table>\n<tr>\n<th style="text-align: left;">a</th>\n</tr>\n<tr>\n<td style="text-align: left;">b</td>\n</tr>\n</table>\n',
"| l: a |\n| b |\n| c |": '<table>\n<tr>\n<th style="text-align: left;">a</th>\n</tr>\n<tr>\n<td style="text-align: left;">b</td>\n</tr>\n<tr>\n<td style="text-align: left;">c</td>\n</tr>\n</table>\n',
"| a |\n| b |\n| c |": "<table>\n<tr>\n<th>a</th>\n</tr>\n<tr>\n<td>b</td>\n</tr>\n<tr>\n<td>c</td>\n</tr>\n</table>\n",
"| l: a |\n| *b* |": '<table>\n<tr>\n<th style="text-align: left;">a</th>\n</tr>\n<tr>\n<td style="text-align: left;"><em>b</em></td>\n</tr>\n</table>\n',
"| l: a |\n| b |\n[table:n_ab:A und B]": '<table>\n<tr>\n<th style="text-align: left;">a</th>\n</tr>\n<tr>\n<td style="text-align: left;">b</td>\n</tr>\n<caption>A und B</caption></table>\n',
"| l: *a* |": '<table>\n<tr>\n<th style="text-align: left;"><em>a</em></th>\n</tr>\n</table>\n',
"| l: a |\n| [morse:a] |": '<table>\n<tr>\n<th style="text-align: left;">a</th>\n</tr>\n<tr>\n<td style="text-align: left;"><span class="morse"><span class="morse_char">\n<span class="morse_char">\n▄</span>\n<span class="morse_char">\n▄▄▄</span>\n</span>\n</span></td>\n</tr>\n</table>\n',
"| l: abc | r: abc | X: abc |\n| 123 | 456 | 789 | \n | 987 | 654 | 321 | ": '<table>\n<tr>\n<th style="text-align: left;">abc</th>\n<th style="text-align: right;">abc</th>\n<th>abc</th>\n</tr>\n<tr>\n<td style="text-align: left;">123</td>\n<td style="text-align: right;">456</td>\n<td>789</td>\n</tr>\n<tr>\n<td style="text-align: left;">987</td>\n<td style="text-align: right;">654</td>\n<td>321</td>\n</tr>\n</table>\n',
}
with FiftyOhmHtmlRenderer() as renderer:
for assertion in assertions:
assert renderer.render(Document(assertion)) == assertions[assertion]
@pytest.mark.latex
def test_thematic_break_latex():
assertions = {
"| l: a |": "\\begin{DARCtabular}{l}\na\\\\\n\\end{DARCtabular}",
"| l: a |\n| *b* |": "\\begin{DARCtabular}{l}\na\\\\\n\\emph{b}\\\\\n\\end{DARCtabular}",
"| l: a | X: b |\n| c | d |": "\\begin{DARCtabular}{lX}\na & b\\\\\nc & d\\\\\n\\end{DARCtabular}",
}
with FiftyOhmLaTeXRenderer() as renderer:
for assertion in assertions:
assert renderer.render(Document(assertion)) == assertions[assertion]