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:
@@ -12,10 +12,13 @@ from .picture import Picture
|
|||||||
from .question import Question
|
from .question import Question
|
||||||
from .quote import Quote
|
from .quote import Quote
|
||||||
from .references import References
|
from .references import References
|
||||||
|
from .table import Table
|
||||||
from .tag import Tag
|
from .tag import Tag
|
||||||
from .underline import Underline
|
from .underline import Underline
|
||||||
from .unit import Unit
|
from .unit import Unit
|
||||||
|
|
||||||
|
table_alignment = {"l": "left", "c": "center", "r": "right"}
|
||||||
|
|
||||||
|
|
||||||
class FiftyOhmHtmlRenderer(HtmlRenderer):
|
class FiftyOhmHtmlRenderer(HtmlRenderer):
|
||||||
margin_anchor_id = 0
|
margin_anchor_id = 0
|
||||||
@@ -38,7 +41,8 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
|
|||||||
References,
|
References,
|
||||||
Question,
|
Question,
|
||||||
Picture,
|
Picture,
|
||||||
Photo
|
Photo,
|
||||||
|
Table,
|
||||||
)
|
)
|
||||||
self.question_renderer = question_renderer
|
self.question_renderer = question_renderer
|
||||||
|
|
||||||
@@ -182,7 +186,7 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
|
|||||||
</figure>
|
</figure>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def render_picture(self, token) :
|
def render_picture(self, token):
|
||||||
return self.render_picture_helper(token.id, token.ref, token.text, token.number)
|
return self.render_picture_helper(token.id, token.ref, token.text, token.number)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -194,5 +198,28 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
|
|||||||
</figure>
|
</figure>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def render_photo(self, token) :
|
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)
|
||||||
|
|
||||||
|
@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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .photo import Photo
|
|||||||
from .picture import Picture
|
from .picture import Picture
|
||||||
from .question import Question
|
from .question import Question
|
||||||
from .quote import Quote
|
from .quote import Quote
|
||||||
|
from .table import Table
|
||||||
from .tag import Tag
|
from .tag import Tag
|
||||||
from .underline import Underline
|
from .underline import Underline
|
||||||
|
|
||||||
@@ -25,7 +26,8 @@ class FiftyOhmLaTeXRenderer(LaTeXRenderer):
|
|||||||
NonbreakingSpacesDots,
|
NonbreakingSpacesDots,
|
||||||
Question,
|
Question,
|
||||||
Picture,
|
Picture,
|
||||||
Photo
|
Photo,
|
||||||
|
Table
|
||||||
)
|
)
|
||||||
self.question_renderer = question_renderer
|
self.question_renderer = question_renderer
|
||||||
|
|
||||||
@@ -113,4 +115,26 @@ class FiftyOhmLaTeXRenderer(LaTeXRenderer):
|
|||||||
\label{{{ref}}}"""
|
\label{{{ref}}}"""
|
||||||
|
|
||||||
def render_photo(self, token) :
|
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
@@ -87,4 +87,4 @@ class Morse(SpanToken):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
parse_inner = False
|
parse_inner = False
|
||||||
pattern = re.compile(r'"\[morse:\s*(.+)\]"')
|
pattern = re.compile(r'\[morse:\s*(.+)\]')
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
beautifulsoup4==4.13.4
|
||||||
coverage==7.6.9
|
coverage==7.6.9
|
||||||
iniconfig==2.0.0
|
iniconfig==2.0.0
|
||||||
|
jinja2==3.1.6
|
||||||
|
markupsafe==3.0.2
|
||||||
mistletoe==1.4.0
|
mistletoe==1.4.0
|
||||||
packaging==24.2
|
packaging==24.2
|
||||||
pluggy==1.5.0
|
pluggy==1.5.0
|
||||||
pytest==8.3.4
|
pytest==8.3.4
|
||||||
pytest-cov==6.0.0
|
pytest-cov==6.0.0
|
||||||
|
soupsieve==2.7
|
||||||
|
typing-extensions==4.13.2
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ CQ CQ hier ist DL1PZ mit einem allgemeinen Anruf, hier ist DL1PZ und hört.
|
|||||||
</indepth>
|
</indepth>
|
||||||
|
|
||||||
<webmargin>
|
<webmargin>
|
||||||
| c | l | c | l | c | l |
|
| c: | l: | c: | l: | c: | l: |
|
||||||
| | | | | | |
|
| | | | | | |
|
||||||
| A | [morse:a] | K | [morse:k] | U | [morse:u] |
|
| A | [morse:a] | K | [morse:k] | U | [morse:u] |
|
||||||
| B | [morse:b] | L | [morse:l] | V | [morse:v] |
|
| B | [morse:b] | L | [morse:l] | V | [morse:v] |
|
||||||
|
|||||||
+31
-33
@@ -8,59 +8,56 @@ from renderer.fifty_ohm_html_renderer import FiftyOhmHtmlRenderer
|
|||||||
|
|
||||||
|
|
||||||
def parse_katalog():
|
def parse_katalog():
|
||||||
with open('data/fragenkatalog3b.json') as fragenkatalog_file:
|
with open("data/fragenkatalog3b.json") as fragenkatalog_file:
|
||||||
fragenkatalog = json.load(fragenkatalog_file)
|
fragenkatalog = json.load(fragenkatalog_file)
|
||||||
|
|
||||||
questions = {}
|
questions = {}
|
||||||
|
|
||||||
for exampart in fragenkatalog["sections"] :
|
for exampart in fragenkatalog["sections"]:
|
||||||
for chapter in exampart["sections"] :
|
for chapter in exampart["sections"]:
|
||||||
if "questions" in chapter :
|
if "questions" in chapter:
|
||||||
for question in chapter["questions"] :
|
for question in chapter["questions"]:
|
||||||
questions[question["number"]] = question
|
questions[question["number"]] = question
|
||||||
if "sections" in chapter :
|
if "sections" in chapter:
|
||||||
for section in chapter["sections"] :
|
for section in chapter["sections"]:
|
||||||
for question in section["questions"] :
|
for question in section["questions"]:
|
||||||
questions[question["number"]] = question
|
questions[question["number"]] = question
|
||||||
|
|
||||||
return questions
|
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()
|
questions = parse_katalog()
|
||||||
|
|
||||||
|
|
||||||
def question_stub(input):
|
def question_stub(input):
|
||||||
""" Combines the original question dataset from BNetzA with our internal metadata """
|
"""Combines the original question dataset from BNetzA with our internal metadata"""
|
||||||
with open('data/metadata.json') as metadata_file :
|
with open("data/metadata.json") as metadata_file:
|
||||||
metadata = json.load(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]
|
question = questions[number]
|
||||||
metadata = metadata[f"{input}"]
|
metadata = metadata[f"{input}"]
|
||||||
|
|
||||||
if "answer_a" in question :
|
if "answer_a" in question:
|
||||||
answers = [
|
answers = [question["answer_a"], question["answer_b"], question["answer_c"], question["answer_d"]]
|
||||||
question["answer_a"],
|
else:
|
||||||
question["answer_b"],
|
|
||||||
question["answer_c"],
|
|
||||||
question["answer_d"]
|
|
||||||
]
|
|
||||||
else :
|
|
||||||
answers = []
|
answers = []
|
||||||
|
|
||||||
if metadata["picture_a"] != "" :
|
if metadata["picture_a"] != "":
|
||||||
answer_pictures = [
|
answer_pictures = [
|
||||||
metadata["picture_a"],
|
metadata["picture_a"],
|
||||||
metadata["picture_b"],
|
metadata["picture_b"],
|
||||||
metadata["picture_c"],
|
metadata["picture_c"],
|
||||||
metadata["picture_d"]
|
metadata["picture_d"],
|
||||||
]
|
]
|
||||||
else :
|
else:
|
||||||
answer_pictures = []
|
answer_pictures = []
|
||||||
|
|
||||||
if "picture_question" in question :
|
if "picture_question" in question:
|
||||||
picture_question = metadata["picture_question"]
|
picture_question = metadata["picture_question"]
|
||||||
else :
|
else:
|
||||||
picture_question = ""
|
picture_question = ""
|
||||||
|
|
||||||
return question_template.render(
|
return question_template.render(
|
||||||
@@ -69,9 +66,10 @@ def question_stub(input):
|
|||||||
layout=metadata["layout"],
|
layout=metadata["layout"],
|
||||||
picture_question=picture_question,
|
picture_question=picture_question,
|
||||||
answers=answers,
|
answers=answers,
|
||||||
answer_pictures=answer_pictures
|
answer_pictures=answer_pictures,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_html(capsys):
|
def test_html(capsys):
|
||||||
with capsys.disabled():
|
with capsys.disabled():
|
||||||
with open("test/acceptanceTest.md") as file:
|
with open("test/acceptanceTest.md") as file:
|
||||||
@@ -79,5 +77,5 @@ def test_html(capsys):
|
|||||||
with FiftyOhmHtmlRenderer(question_stub) as renderer:
|
with FiftyOhmHtmlRenderer(question_stub) as renderer:
|
||||||
output = renderer.render(Document(content))
|
output = renderer.render(Document(content))
|
||||||
with open("test/acceptanceTest.html", "w") as output_file:
|
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)
|
output_file.write(pretty)
|
||||||
|
|||||||
+5
-5
@@ -8,11 +8,11 @@ from test.util import paragraph
|
|||||||
@pytest.mark.html
|
@pytest.mark.html
|
||||||
def test_morse_html():
|
def test_morse_html():
|
||||||
assertions = {
|
assertions = {
|
||||||
'"[morse:a]"': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2]]),
|
'[morse:a]': FiftyOhmHtmlRenderer.render_morse_helper([[1, 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: aaa]': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2], [1, 2], [1, 2]]),
|
||||||
'"[morse: a a]"': FiftyOhmHtmlRenderer.render_morse_helper([[1, 2], [3], [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: db0]': FiftyOhmHtmlRenderer.render_morse_helper([[2, 1, 1], [2, 1, 1, 1], [2, 2, 2, 2, 2]]),
|
||||||
}
|
}
|
||||||
|
|
||||||
for assertion in assertions:
|
for assertion in assertions:
|
||||||
|
|||||||
@@ -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]
|
||||||
Reference in New Issue
Block a user