WUT_Computer_Science/Programming/ECOTE/program/translator/main.py
Krzysztof kuhy Rudnicki a8a2710ebd ECOTE and ECRYPT_PROJECT: zero findings, tests still green
kuhy chose the strict branch: no per-file-ignores at all, not even the S101
block that ruff's own docs and the rest of this fleet carry for test code. So
125 test assertions were rewritten to raise instead of assert.

Rather than 125 copies of `if x != y: raise AssertionError(...)`, each course
grows a small expect module -- expect_equal / expect_in / expect_true /
expect_false. That is also strictly better output than a bare assert here:
pytest's assertion rewriting does not reach into a helper, so the helper has
to print both values, and it does. The rewrite itself went through ast, not a
regex, because seven of ECOTE's asserts were wrapped over several lines.

Everything else in the two courses is gone as well:

- 66 argument and 37 return annotations on the translator, worked out from
  what each function actually returns rather than stamped on as Any --
  `list[str] | str` where a function returns "Error!" or the parsed columns.
- 16 prints became logger.error with %-style arguments. The messages still
  reach stderr with no basicConfig, via logging's last-resort handler, so the
  translator behaves exactly as before.
- Two `open()` calls became Path.open / Path.write_text, six RET504
  assign-then-return pairs collapsed, one 380-character expected-HTML literal
  split into implicitly concatenated pieces (it cannot be wrapped any other
  way, which is what ISC001 being off is for), and one block of commented-out
  code deleted.
- ECRYPT_PROJECT/__init__.py removed. It made the COURSE directory a package,
  which nothing imports, and it was the sole cause of `N999 Invalid module
  name: 'ECRYPT_PROJECT'`; with it gone, sieve is the top-level package and
  its name is fine.

Verified by running both, not by reading: ECOTE 26 tests pass and
`python -m translator.main --filename examples/complicated/table1.tex` still
writes the same HTML with the same three column-count warnings on stderr;
ECRYPT_PROJECT 99 tests pass.

Repo-wide: 3,686 findings -> 3,159.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
2026-08-28 20:03:01 +02:00

534 lines
18 KiB
Python

"""Program for converting Latex files into html files."""
import argparse
import logging
from pathlib import Path
logger = logging.getLogger(__name__)
def command_name_check(latex_string: str, command_name: str) -> str:
"""Check if command name given from latex was spelled correctly."""
if latex_string[1 : (len(command_name) + 1)] != command_name:
logger.error("%sError! command misspeled!", latex_string)
return "Error!"
return ""
def generic_checks(latex_string: str) -> str:
"""Check that a string was given and that its parameters close.
"Close" means the latex parameter list ends with a curly bracket.
"""
if latex_string == "":
logger.error("%sError! No input given to function", latex_string)
return "Error!"
if "}" not in latex_string:
logger.error("%sError! curly bracket not closed!", latex_string)
return "Error!"
return ""
def generic_checks_command(latex_string: str) -> str:
r"""Run the generic checks, and also require a leading backslash."""
if generic_checks(latex_string) == "Error!":
return "Error!"
if latex_string[0] != "\\":
logger.error("%sError! has no slash at begining", latex_string)
return "Error!"
return ""
def document_class_only_checks(latex_string: str) -> str:
"""Check only errors connected to document class."""
if len(latex_string) < len("\\documentclass{"):
logger.error(
"%sError! latex string to short to be document class!", latex_string
)
return "Error!"
if latex_string[len("\\documentclass{") - 1] != "{":
logger.error("%sError! curly bracket not opened!", latex_string)
return "Error!"
if command_name_check(latex_string, "documentclass") == "Error!":
return "Error!"
return ""
def document_class(latex_string: str) -> str:
r"""Convert LaTeX documentclass method to html.
\documentclass{article}.
"""
if generic_checks_command(latex_string) == "Error!":
return "Error!"
if document_class_only_checks(latex_string) == "Error!":
return "Error!"
return "<!DOCTYPE html>"
def begin_document(latex_string: str) -> str:
r"""Convert LaTeX begin document method to html.
\begin{document}.
"""
if generic_checks_command(latex_string) == "Error!":
return "Error!"
if command_name_check(latex_string, "begin") == "Error!":
return "Error!"
return "<html>"
def begin_tabular(latex_string: str) -> str:
r"""Check if LaTeX begin tabular method is correct."""
if generic_checks_command(latex_string) == "Error!":
return "Error!"
if command_name_check(latex_string, "begin") == "Error!":
return "Error!"
return "<table>"
def tabular_parameters(latex_string: str) -> str:
r"""Check if LaTeX tabular environment has any parameters."""
if latex_string == "":
return ""
if latex_string == "[]":
return ""
return "Error!"
def only_pipes_and_space(latex_string: str) -> bool:
"""Check if latex string only contains | or " ".
if yes returns True, if no returns false.
"""
return all(char in ("|", " ", "{", "}") for char in latex_string)
def handle_complicated_parameter(
latex_string: str, return_array: list[str], i: int
) -> tuple[list[str], int, str]:
"""Handle single complicated parameter from loop."""
closing_bracket = latex_string.find("}", i + 1)
columns_string = latex_string[i : closing_bracket + 1]
result = tabular_columns_parameters(columns_string)
if result == "Error!":
return return_array, i, result
return_array.append(result)
i = closing_bracket
return return_array, i, ""
def main_tabular_parameters_loop_inside(
latex_string: str,
i: int,
simple_parameters_dictionary: dict[str, str],
return_array: list[str],
) -> tuple[list[str], int, str]:
"""Handle single tabular parameter character."""
character = latex_string[i]
if character in ["l", "c", "r", "|"]:
return_array.append(simple_parameters_dictionary.get(latex_string[i]))
i += 1
return return_array, i, ""
if character in ["p", "m", "b"]:
return handle_complicated_parameter(latex_string, return_array, i)
i += 1
return return_array, i, ""
def main_tabular_parameters_loop(
latex_string: str, simple_parameters_dictionary: dict[str, str]
) -> list[str] | str:
"""Convert simple and paragraph table parameters into html style code."""
return_array = []
i = 0
latex_string_length = len(latex_string)
while i < latex_string_length:
return_array, i, result = main_tabular_parameters_loop_inside(
latex_string, i, simple_parameters_dictionary, return_array
)
if result == "Error!":
return result
return return_array
def tabular_required_errors(latex_string: str) -> str:
"""Run the generic checks and.
checks if there are any parameters put to latex table.
"""
if generic_checks(latex_string) == "Error!":
return "Error!"
if latex_string == "{}":
logger.error(
"tabular_required_parameters: required table parameters are empty: %s",
latex_string,
)
return "Error!"
return ""
def simple_parameters_dictionary_return() -> dict[str, str]:
"""Return the html styles for the simple tabular parameters."""
return {
"l": "align='left'",
"c": "align='center'",
"r": "align='right'",
"|": "style='border-left: 1px solid black'",
}
def tabular_required_parameters(latex_string: str) -> list[str] | str:
"""Convert simple tabular parameters to html style commands."""
if tabular_required_errors(latex_string) == "Error!":
return "Error!"
simple_parameters_dictionary = simple_parameters_dictionary_return()
if only_pipes_and_space(latex_string):
logger.error(
"tabular_required_parameters: parameters are only pipes and spaces: %s",
latex_string,
)
return "Error!"
return main_tabular_parameters_loop(latex_string, simple_parameters_dictionary)
def length_conversions(latex_length: str) -> float | None:
"""Convert latex lengths to html lengths."""
length_dictionary = {
"pt": [1.3, "px"],
"mm": [1, "mm"],
"cm": [1, "cm"],
"in": [1, "in"],
"ex": [1, "ex"],
"em": [1, "em"],
}
return length_dictionary.get(latex_length, "Error!")
def construct_return_string(
vertical_align_type: str, final_length: float, conversed_unit: str
) -> str:
"""Construsts entire html style of parameters."""
return (
"style='"
+ vertical_align_type
+ " width: "
+ str(final_length)
+ conversed_unit[1]
+ ";'"
)
def return_parameter_dictionary() -> dict[str, str]:
"""Return html styles for each complicated latex parameter."""
return {
"p": "vertical-align: top;",
"m": "vertical-align: middle;",
"b": "vertical-align: bottom;",
}
def tabular_columns_parameters_errors(
latex_string: str, vertical_align_type: str
) -> str:
"""Check for intermediate errors.
Checks if parameter length starts with curly bracket.
"""
if vertical_align_type == "Error!":
logger.error(
"tabular_columns_parameters: unknown parameter: %s", latex_string[0]
)
return "Error!"
if latex_string[1] != "{":
logger.error(
"tabular_columns_parameters: parameter length does not start with {: %s",
latex_string,
)
return "Error!"
return ""
def string_length_no_whitespace(length_parameter: str) -> tuple[int, str]:
"""Measure the string width, ignoring whitespace."""
i = 0
length_value = ""
for character in length_parameter:
if character.isalpha():
break
length_value += character
i += 1
return i, length_value
def tabular_columns_parameters(latex_string: str) -> str:
"""Handle tabular paragraph parameters and translates them to html style."""
if generic_checks(latex_string) == "Error!":
return "Error!"
parameter_dictionary = return_parameter_dictionary()
vertical_align_type = parameter_dictionary.get(latex_string[0], "Error!")
if tabular_columns_parameters_errors(latex_string, vertical_align_type) == "Error!":
return "Error!"
length_parameter_with_bracket = latex_string.partition("{")[2]
length_parameter = length_parameter_with_bracket.partition("}")[0]
i, length_value = string_length_no_whitespace(length_parameter)
length_unit = length_parameter[i:]
conversed_unit = length_conversions(length_unit)
if conversed_unit == "Error!":
logger.error(
"tabular_columns_parameters: unit could not be converted: %s", latex_string
)
return "Error!"
final_length = round(float(length_value) * conversed_unit[0], 2)
return construct_return_string(vertical_align_type, final_length, conversed_unit)
def split_rows(latex_string: str) -> list[str]:
"""Split table row string into separate strings and puts them in array."""
double_backslash = "\\"
return latex_string.split(double_backslash)
def split_columns(table_row: str, column_count: int) -> list[str] | str:
"""Split table row columns into separate strings and puts them in array."""
columns = table_row.split("&")
if len(columns) != column_count and columns != [""]:
logger.error(
"Error! extracted columns length: %s and columns_count: %s is different!",
columns,
column_count,
)
return "Error!"
return columns
def translate_column(latex_column: str) -> str:
"""Translate insides of a single latex tabular column to html."""
hline_string_literal = "\\hline"
replaced_hline = latex_column.replace(hline_string_literal, "<hr>")
return replaced_hline.replace("\newline", "<br>")
def handle_line_strings(
line_string: str, column_style: list[str], column_number: int, return_string: str
) -> tuple[str, int]:
"""Convert lines untill there are no more lines."""
current_style = column_style[column_number]
if current_style == line_string:
while current_style == line_string:
return_string += line_string
column_number += 1
current_style = column_style[column_number]
return return_string, column_number
def handle_single_column(
column: str,
column_style: list[str],
column_number: int,
line_string: str,
return_string: str,
) -> tuple[str, int]:
"""Handle single column from columns loop."""
return_string += "<td "
if column_number >= len(column_style):
logger.error(
"Error! column_number index %s is past the end of column_style %s",
column_number,
column_style,
)
return "Error!", column_number
return_string, column_number = handle_line_strings(
line_string, column_style, column_number, return_string
)
return_string += column_style[column_number] + ">"
return_string += translate_column(column)
column_number += 1
return_string += "</td>"
return return_string, column_number
def handle_latex_columns(
columns: list[str],
column_style: list[str],
column_number: int,
line_string: str,
return_string: str,
) -> tuple[str, int]:
"""Go through every column in a row and translates it to html."""
if columns != "Error!":
for column in columns:
return_string, column_number = handle_single_column(
column, column_style, column_number, line_string, return_string
)
return return_string, column_number
def go_through_latex_rows(
rows: list[str],
return_string: str,
column_amount: int,
column_style: list[str],
line_string: str,
) -> str:
"""Go through latex table insides row by row."""
for row in rows:
return_string += "<tr>"
columns = split_columns(row, column_amount)
column_number = 0
return_string, column_number = handle_latex_columns(
columns, column_style, column_number, line_string, return_string
)
return_string += "</tr>"
return return_string
def translate_inside_to_html(latex_table_inside: str, column_style: list[str]) -> str:
"""Translate entire table insides to html."""
return_string = "<table>"
column_amount = 0
line_string = "style='border-left: 1px solid black'"
for style in column_style:
if style != line_string:
column_amount += 1
rows = split_rows(latex_table_inside)
return_string = go_through_latex_rows(
rows, return_string, column_amount, column_style, line_string
)
return_string += "</table>"
return return_string
def read_file(tex_filename: str) -> str:
"""Read a latex file into one string, with newlines stripped."""
with Path(tex_filename).open(encoding="UTF-8") as tex_file:
return tex_file.read().replace("\n", "")
def read_document_class(latex_full_string: str) -> tuple[str, str] | str:
"""Find documentclass in latex file and checks function correctness."""
document_class_index = latex_full_string.find("\\documentclass")
if document_class_index == -1:
logger.error("Main function error! documentclass not found")
return "Error!"
document_class_close_bracket = latex_full_string.find("}", document_class_index)
document_class_string = latex_full_string[
document_class_index : document_class_close_bracket + 1
]
latex_full_string = latex_full_string[
document_class_close_bracket + 1 : len(latex_full_string) - 1
]
return document_class(document_class_string), latex_full_string
def read_begin_document(latex_full_string: str) -> tuple[str, str]:
r"""Find the LaTeX \begin{document} command and return its html counterpart."""
begin_document_index = latex_full_string.find(r"\begin{document}")
if begin_document_index == -1:
logger.error("read_begin_document error! begin{document not found")
return "Error!", latex_full_string
begin_document_close_bracket = latex_full_string.find("}", begin_document_index)
begin_document_string = latex_full_string[
begin_document_index : begin_document_close_bracket + 1
]
latex_full_string = latex_full_string[
begin_document_close_bracket + 1 : len(latex_full_string) - 1
]
return_string = begin_document(begin_document_string)
return return_string, latex_full_string
def read_start(html_string: str, data: str) -> tuple[str, str]:
"""Find and read the latex document class.
and begin document and translates them to html.
"""
document_class_result, data = read_document_class(data)
if document_class_result == "Error!":
return "Error!", data
html_string += document_class_result
begin_document_results, data = read_begin_document(data)
if begin_document_results == "Error!":
return "Error!", data
html_string += begin_document_results
return html_string, data
def handle_table_whole(
html_string: str, data: str, table_start: int, table_end: int
) -> tuple[str, str] | str:
"""Read entire table, handles its parameters.
and insides and translates to html.
"""
if table_start == -1 or table_end == -1:
return html_string, data
tabular_begin_string = r"\begin{tabular}"
parameters_start_index = data.find("{", table_start + len(tabular_begin_string))
parameters_end_index = data.find("}", parameters_start_index)
parameters_string = data[parameters_start_index : parameters_end_index + 1]
parameters_array = tabular_required_parameters(parameters_string)
inside_table = data[parameters_end_index + 1 : table_end]
html_string += translate_inside_to_html(inside_table, parameters_array)
return html_string
def handle_insides(html_string: str, data: str) -> tuple[str, str]:
"""Find inside of table and translates them to html."""
tabular_index = 0
tabular_end_string = r"\end{tabular}"
while tabular_index != -1:
tabular_index_start = data.find(r"\begin{tabular}", 0)
tabular_index_end = data.find(tabular_end_string, tabular_index_start)
if tabular_index_start == -1 or tabular_index_end == -1:
return html_string, data
html_string += data[0:tabular_index_start]
html_string = handle_table_whole(
html_string, data, tabular_index_start, tabular_index_end
)
data = data[tabular_index_end + len(tabular_end_string) : len(data)]
tabular_index = tabular_index_end
return html_string, data
def main_function(tex_filename: str) -> str:
"""Ran by default by program,.
gets latex file and translates to html.
"""
data = read_file(tex_filename)
html_string = ""
html_string, data = read_start(html_string, data)
if html_string == "Error!":
logger.error(
"main_function error!\n when reading documentclass or begin document"
)
return "Error"
html_string, data = handle_insides(html_string, data)
return html_string.replace("}", "")
def handle_arguments() -> str:
"""Parse the command line and return the .tex filename to translate."""
parser = argparse.ArgumentParser(description="Specify tex filename and path")
parser.add_argument(
"--filename",
"-f",
help="Specify tex filename, include path if needed ",
required=False,
default="texfile.tex",
)
args = parser.parse_args()
return args.filename
if __name__ == "__main__":
TEX_FILENAME = handle_arguments()
FINAL_HTML = main_function(TEX_FILENAME)
Path(TEX_FILENAME + ".html").write_text(FINAL_HTML, encoding="UTF-8")