Merge branch 'main' into feature-alt-texts

This commit is contained in:
Tim Grelka
2026-01-09 20:54:20 +01:00
committed by GitHub
62 changed files with 22074 additions and 154 deletions
+107 -20
View File
@@ -3,6 +3,7 @@ from mistletoe import Document, HtmlRenderer
from .comment import BlockComment
from .dash import Dash
from .formula import Formula
from .halfwidth_spaces import HalfwidthSpaces
from .include import Include
from .morse import Morse
@@ -13,7 +14,7 @@ from .qso import Qso
from .question import Question
from .quote import Quote
from .references import References
from .table import Table
from .table import Table, TableBody, TableCell, TableHeader, TableRow
from .tag import Tag
from .underline import Underline
from .unit import Unit
@@ -28,7 +29,17 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
ref_id = 0
def __init__(
self, *extras, question_renderer=None, picture_handler=None, photo_handler=None, include_handler=None, **kwargs
self,
*extras,
question_renderer=None,
picture_handler=None,
photo_handler=None,
include_handler=None,
edition=None,
chapter=None,
section=None,
section_url=None,
**kwargs,
):
super().__init__(
Dash,
@@ -46,8 +57,13 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
Picture,
Photo,
Table,
TableHeader,
TableRow,
TableCell,
TableBody,
Qso,
Include,
Formula,
*extras,
**kwargs,
)
@@ -57,6 +73,21 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
self.photo_handler = photo_handler
self.include_handler = include_handler
# Figure numbering context
self.edition = edition
self.chapter = chapter
self.section = section
# Set section URL if provided, otherwise use default
if section_url is not None:
self.section_url = section_url
# Figure map: stores ref -> hierarchical number mapping
self.figure_map = {}
# Single unified counter for all figure types (pictures, photos, tables)
self.figure_counter = 0
def render_dash(self, token):
return " – "
@@ -104,7 +135,7 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
for char in morse_code:
result += '<span class="morse_char">\n'
for symbol in char:
result += '<span class="morse_char">\n'
result += '<span class="morse_symbol">\n'
if symbol == 1:
result += "▄"
elif symbol == 2:
@@ -171,10 +202,47 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
lookup = {"": "", " ": "&#160;"}
return f"{lookup[token.first]}{token.second}{lookup[token.third]}"
def collect_figures(self, document):
"""First pass: collect all figures and assign hierarchical numbers."""
self.figure_counter = 0
self.figure_map = {}
self._collect_figures_recursive(document)
def _collect_figures_recursive(self, token):
"""Recursively traverse the document tree to collect figures."""
if hasattr(token, "children") and token.children is not None:
for child in token.children:
# Check if this is a Picture, Photo, or Table token
if isinstance(child, Picture | Photo | Table):
# Get the reference ID (ref for Picture/Photo, name for Table)
ref_id = getattr(child, "ref", None) or getattr(child, "name", None)
if ref_id: # Only number figures with a reference ID
self.figure_counter += 1
hierarchical_num = self._format_figure_number(self.figure_counter)
self.figure_map[ref_id] = hierarchical_num
child.number = hierarchical_num
else:
child.number = ""
# Recurse into children
self._collect_figures_recursive(child)
def _format_figure_number(self, counter):
"""Format the hierarchical figure number based on context."""
if self.edition and self.chapter and self.section:
return f"{self.edition}-{self.chapter}.{self.section}.{counter}"
else:
# Fallback for tests or when context is not provided
return str(counter)
def render_references(self, token):
# Look up the figure number from the map
figure_num = self.figure_map.get(token.first, "?")
return (
f'<a href="{self.section_url}#ref_{token.first}"'
f" onclick=\"highlightRef('{token.first}');\">{self.ref_id}</a>"
f" onclick=\"highlightRef('{token.first}');\">{figure_num}</a>"
)
def render_question(self, token):
@@ -220,28 +288,47 @@ class FiftyOhmHtmlRenderer(HtmlRenderer):
alt_text = self.photo_handler(token.id) or alt_text
return self.render_photo_helper(token.id, token.ref, token.text, token.number, alt_text)
@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: Table):
# Add id and name attributes if table has a name
table_attrs = ""
if token.name:
table_attrs = f' id="ref_{token.name}" name="{token.name}"'
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"
table = f'<table class="table table-hover"{table_attrs}>\n{self.render_inner(token)}'
if token.caption != "":
table += f"<caption>{token.caption}</caption>"
# Include hierarchical number in caption if available
caption_text = token.caption
if hasattr(token, "number") and token.number:
caption_text = f"Tabelle {token.number}: {token.caption}"
table += f"<caption>{caption_text}</caption>\n"
table += "</table>"
return table
def render_table_row(self, token: TableRow):
return f"<tr>\n{self.render_inner(token)}</tr>\n"
def render_table_header(self, token: TableHeader):
return self.render_table_row(token)
def render_table_cell(self, token: TableCell):
type = "th" if token.header else "td"
style = ""
if token.alignment in table_alignment:
style = f' style="text-align: {table_alignment[token.alignment]};"'
return f"<{type}{style}>{self.render_inner(token)}</{type}>\n"
def render_table_body(self, token: TableBody):
if token.children is None or len(token.children) == 0:
return None
else:
type = "thead" if token.header else "tbody"
return f"<{type}>\n{self.render_inner(token)}</{type}>\n"
def render_include(self, token):
return self.include_handler(token.ident)
def render_formula(self, token):
return f"\n$${token.formula}$$\n"
@@ -10,6 +10,10 @@ class FiftyOhmHtmlSlideRenderer(FiftyOhmHtmlRenderer):
**kwargs,
)
# Add "S" suffix to edition for slides
if self.edition:
self.edition = f"{self.edition}S"
def render_slide_break(self, token):
inner = self.render_inner(token)
+26 -16
View File
@@ -8,7 +8,7 @@ from .photo import Photo
from .picture import Picture
from .question import Question
from .quote import Quote
from .table import Table
from .table import Table, TableBody, TableCell, TableHeader, TableRow
from .tag import Tag
from .underline import Underline
@@ -28,9 +28,17 @@ class FiftyOhmLaTeXRenderer(LaTeXRenderer):
Picture,
Photo,
Table,
TableBody,
TableRow,
TableHeader,
TableCell,
)
self.question_renderer = question_renderer
def render_inner(self, token, base="") -> str:
# Filter out None values, so block tokens can return None to not be rendered.
return base.join(filter(lambda x: x is not None, [self.render(child) for child in token.children]))
def render_document(self, token):
self.footnotes.update(token.footnotes)
return self.render_inner(token)
@@ -118,23 +126,25 @@ class FiftyOhmLaTeXRenderer(LaTeXRenderer):
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}"
table = "\\begin{DARCtabular}" + f"{self.render_inner(token)}" + "\\end{DARCtabular}"
if token.caption != "":
table += f"\\captionof{{figure}}{{{token.caption}}}\n"
table += f"\\label{{{token.name}}}\n"
return table
def render_table_body(self, token: TableBody):
return self.render_inner(token)
def render_table_row(self, token: TableRow):
return rf"{self.render_inner(token, ' & ')}\\" + "\n"
def render_table_header(self, token: TableHeader):
align = ""
for alignment in token.alignment:
align += alignment
return f"{{{align}}}\n{self.render_table_row(token)}"
def render_table_cell(self, token: TableCell):
return self.render_inner(token)
+27
View File
@@ -0,0 +1,27 @@
import re
from mistletoe.block_token import BlockToken
class Formula(BlockToken):
r"""
Block token ("$ a \cdot b $").
Transfroms a line that contains only a formula enclosed in dollar signs into a Formula token.
"""
@staticmethod
def start(line):
return re.match(r"^\s*\$[^$]+\$\s*$", line)
@classmethod
def check_interrupts_paragraph(cls, lines):
return cls.start(lines.peek())
@classmethod
def read(cls, lines):
line = next(lines) # Consume the line with the formula
formula = re.match(r"^\s*\$([^$]+)\$\s*$", line).group(1)
return formula
def __init__(self, match):
self.formula = match
+81 -41
View File
@@ -1,37 +1,99 @@
import re
from enum import Enum
from mistletoe import span_token
from mistletoe.block_token import BlockToken
class Table(BlockToken):
class CellAlignment(Enum):
LEFT = "l"
CENTER = "c"
RIGHT = "r"
EXPAND = "X"
class TableCell(BlockToken):
alignment: CellAlignment
header: bool
@staticmethod
def start(line):
return "|" in line
def start(_):
return False
def __init__(self, match, alignment: CellAlignment, header=False):
super().__init__(match, span_token.tokenize_inner)
self.alignment, self.header = alignment, header
class TableRow(BlockToken):
@staticmethod
def start(_):
return False
@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 |
def parse(cls, line) -> list[str]:
line = line.strip().strip("|")
return [col.strip() for col in line.split("|")]
alignment = []
header = "| "
def __init__(self, match, alignment: list[CellAlignment]):
self.children = [TableCell(cell, alignment[i]) for i, cell in enumerate(self.parse(match))]
class TableHeader(TableRow):
alignment: list[CellAlignment]
@staticmethod
def start(_):
return False
@classmethod
def parse(cls, line) -> list[str]:
alignment: list[CellAlignment] = []
columns: list[str] = []
for column in super().parse(line):
attr: CellAlignment
value: str
attr, value = re.match(r"^ ?(?:([lcrX]):)? ?(.*)", column).group(1, 2)
for column in columns:
attr, val = re.match(r"^ ?(?:([lcrX]):)?(.*)", column).group(1, 2)
alignment.append(attr)
header += val + " | "
columns.append(value)
return header, alignment
return columns, alignment
def __init__(self, match):
columns, self.alignment = self.parse(match)
self.children = [TableCell(cell, self.alignment[i], True) for i, cell in enumerate(columns)]
class TableBody(BlockToken):
header: bool
@staticmethod
def start(_):
return False
def __init__(self, rows: TableRow, header=False):
self.children, self.header = rows, header
class Table(BlockToken):
name: str
caption: str
header: list[TableHeader]
rows: list[TableRow]
@staticmethod
def start(line):
return line.lstrip().startswith("|")
@classmethod
def read(cls, lines):
header = next(lines)
header, alignment = cls.parse_header(header)
header = TableHeader(next(lines))
alignment = header.alignment
name = ""
caption = ""
rows = [TableRow(header)]
children = [TableBody([header], True)]
# Read table until the end: No more column definitions, caption, or empty line.
line_buffer = []
@@ -48,31 +110,9 @@ class Table(BlockToken):
line_buffer.append(next(lines))
next_line = lines.peek()
rows.extend([TableRow(line) for line in line_buffer])
children.append(TableBody([TableRow(line, alignment) for line in line_buffer]))
return alignment, rows, name, caption
return children, 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)
self.children, self.name, self.caption = match