diff --git a/Dockerfile b/Dockerfile index e9787418..cff647c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,10 +14,12 @@ RUN apt-get update && \ libgmp10 \ libgmpxx4ldbl \ openjdk-8-jdk \ + pandoc \ python3-minimal \ python3-pip \ python3-plastex \ python3-yaml \ + rsvg-convert \ sudo \ texlive-fonts-recommended \ texlive-lang-cyrillic \ diff --git a/README.md b/README.md index 601de517..96758f52 100644 --- a/README.md +++ b/README.md @@ -207,13 +207,13 @@ The dependencies needed to *build/install* problemtools can be installed with: And the dependencies needed to *run* problemtools can be installed with: - sudo apt install ghostscript libgmpxx4ldbl python3-minimal python-pkg-resources python3-plastex python3-yaml texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy + sudo apt install ghostscript libgmpxx4ldbl pandoc python3-minimal python-pkg-resources python3-plastex python3-yaml rsvg-convert texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy ### Fedora On Fedora, these dependencies can be installed with: - sudo dnf install boost-regex gcc gmp-devel gmp-c++ python3 python3-pyyaml texlive-latex texlive-collection-fontsrecommended texlive-fancyhdr texlive-subfigure texlive-wrapfig texlive-import texlive-ulem texlive-xifthen texlive-overpic texlive-pbox tidy ghostscript + sudo dnf install boost-regex gcc gmp-devel gmp-c++ pandoc python3 python3-pyyaml rsvg-convert texlive-latex texlive-collection-fontsrecommended texlive-fancyhdr texlive-subfigure texlive-wrapfig texlive-import texlive-ulem texlive-xifthen texlive-overpic texlive-pbox tidy ghostscript Followed by: diff --git a/admin/docker/Dockerfile.minimal b/admin/docker/Dockerfile.minimal index 534e661f..340f0b20 100644 --- a/admin/docker/Dockerfile.minimal +++ b/admin/docker/Dockerfile.minimal @@ -20,10 +20,12 @@ RUN apt update && \ apt install -y \ ghostscript \ libgmpxx4ldbl \ + pandoc \ python-pkg-resources \ python3-minimal \ python3-yaml \ python3-plastex \ + rsvg-convert \ texlive-fonts-recommended \ texlive-lang-cyrillic \ texlive-latex-extra \ diff --git a/debian/control b/debian/control index 42797c8b..d1bf4179 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Homepage: https://github.com/Kattis/problemtools Package: kattis-problemtools Architecture: any -Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, python3-plastex, python3-pkg-resources, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript, dvisvgm +Depends: ${shlibs:Depends}, ${python3:Depends}, ${misc:Depends}, pandoc, python3-plastex, python3-pkg-resources, rsvg-convert, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript, dvisvgm Recommends: gcc, g++ Description: Kattis Problem Tools These are tools to manage and verify problem packages in the diff --git a/examples/README.md b/examples/README.md index 2f6107a3..9d7f9ee5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,4 +24,5 @@ more than one language. ## oddecho This is an example of a *scoring* problem where submissions can get -different scores depending on which test groups they solve. It also demonstrates how an input validator might check different constraints for different test groups. +different scores depending on which test groups they solve. It also demonstrates how an input validator might check different constraints for different test groups. The swedish statement showcases how to use images, footnotes +and tables in Markdown. diff --git a/examples/different/problem.yaml b/examples/different/problem.yaml index 279a8acb..a7652c2e 100644 --- a/examples/different/problem.yaml +++ b/examples/different/problem.yaml @@ -5,6 +5,11 @@ ## Author of the problem (default: null) # author: +# The problem name +# En may be omitted, as there is only one language +name: + en: A Different Problem + ## Where the problem was first used (default: null) source: Kattis # source_url: diff --git a/examples/guess/problem.yaml b/examples/guess/problem.yaml index fcb51934..bf832bb2 100644 --- a/examples/guess/problem.yaml +++ b/examples/guess/problem.yaml @@ -2,6 +2,9 @@ source: Kattis license: cc by-sa validation: custom interactive +name: + sv: Gissa talet + en: Guess the Number # Override standard limits: say that the TLE solutions provided should # be at least 4 times above the time limit in order for us to be diff --git a/examples/guess/problem_statement/problem.sv.md b/examples/guess/problem_statement/problem.sv.md new file mode 100644 index 00000000..c1edbd67 --- /dev/null +++ b/examples/guess/problem_statement/problem.sv.md @@ -0,0 +1,20 @@ +Jag tänker på ett hemligt tal mellan $1$ and $100$, kan du gissa vilket? +Givet en gissning kommer jag att berätta om din gissning +var för stor, för liten eller rätt. Du får bara $10$ gissningar, använd +dem klokt! + + +## Interaktion +Ditt program ska skriva ut gissningar om talet. +En gissning är en rad som enbart innehåller ett heltal mellan $1$ och $1000$. +Efter varje gissning måste du flusha standard out. + +Efter varje gissning kan du läs svaret på standard in. +Detta svar är ett av tre ord: + +- `lower` om talet jag tänker på är lägre än din gissning, +- `higher` om talet jag tänker på är högre än din gissning, eller +- `correct` om din gissning är korrekt. + +Efter att ha gissat rätt ska du avsluta ditt program. +Om du gissar fel $10$ gånger får du inga fler chanser och ditt program kommer avbrytas. diff --git a/examples/hello/problem.yaml b/examples/hello/problem.yaml index 194b060f..6c6f791e 100644 --- a/examples/hello/problem.yaml +++ b/examples/hello/problem.yaml @@ -1,5 +1,8 @@ source: Kattis license: public domain +name: + sv: Hej Världen! + en: Hello World! # Fix memory limit at 512 MB. (Note that for most problems, this # should not be done. It is only done in this case because we include diff --git a/examples/oddecho/input_format_validators/validator/validator.cpp b/examples/oddecho/input_validators/validator/validator.cpp similarity index 100% rename from examples/oddecho/input_format_validators/validator/validator.cpp rename to examples/oddecho/input_validators/validator/validator.cpp diff --git a/examples/oddecho/input_format_validators/validator/validator.h b/examples/oddecho/input_validators/validator/validator.h similarity index 100% rename from examples/oddecho/input_format_validators/validator/validator.h rename to examples/oddecho/input_validators/validator/validator.h diff --git a/examples/oddecho/problem.yaml b/examples/oddecho/problem.yaml index 1fcd5e21..3a918455 100644 --- a/examples/oddecho/problem.yaml +++ b/examples/oddecho/problem.yaml @@ -2,6 +2,8 @@ license: cc by-sa author: Johan Sannemo source: Principles of Algorithmic Problem Solving type: scoring -name: Echo +name: + en: Odd Echo + sv: Udda Eko grading: show_test_data_groups: true diff --git a/examples/oddecho/problem_statement/echo_cave.jpg b/examples/oddecho/problem_statement/echo_cave.jpg new file mode 100644 index 00000000..e197bf1b Binary files /dev/null and b/examples/oddecho/problem_statement/echo_cave.jpg differ diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md new file mode 100644 index 00000000..4ffd89cf --- /dev/null +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -0,0 +1,33 @@ +**EKO! Eko! Ek...** + +![](echo_cave.jpg) + +Du älskar att skrika i grottor för att höra dina ord ekade tillbaka till dig. Tyvärr, som en hårt arbetande mjukvaruingenjör, har du +inte tid för att komma ut och skrika i grottor så ofta. Istället skulle du vilja implementera ett program som fungerar som en ersättning för en grotta. + +Ibland vill du mata in några ord i programmet och få dem ekade tillbaka till dig. Men, som det är välkänt, om du skriker för snabbt i en grotta kan ekot störa de nya ord du säger. [^1] Mer specifikt, vartannat ord du säger kommer att störa ekot av ditt tidigare ord. Därför kommer endast det första, tredje, femte och så vidare ordet faktiskt att producera ett eko. + +Din uppgift är att skriva ett program som simulerar detta beteende. + +## Indata + +Den första raden av indata innehåller ett heltal $N$ ($1 \le N \le 10$). + +De följande $N$ raderna innehåller vardera ett ord. Varje ord är högst $100$ bokstäver långt och innehåller endast bokstäverna `a-z`. + +## Utdata + +Skriv ut de ord som har udda index (dvs. första, tredje, femte och så vidare) i inmatningen. + + +## Poängsättning + +Din lösning kommer att testas på en mängd testfallsgrupper. +För att få poäng för en grupp så måste du klara alla testfall i gruppen. + +| Grupp | Poäng | Begränsningar | +|-------|-------|--------------------------| +| 1 | 1 | $N$ är alltid $5$ | +| 2 | 1 | Inga ytterligare begränsningar | + +[^1]: [https://sv.wikipedia.org/wiki/Interferens](https://sv.wikipedia.org/wiki/Interferens) diff --git a/problemtools/md2html.py b/problemtools/md2html.py new file mode 100644 index 00000000..3d729e72 --- /dev/null +++ b/problemtools/md2html.py @@ -0,0 +1,140 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +import os.path +import string +import argparse +import json +import subprocess + +from . import statement_common + + +FOOTNOTES_STRING = '
' + +def convert(problem: str, options: argparse.Namespace) -> bool: + """Convert a Markdown statement to HTML + + Args: + problem: path to problem directory + options: command-line arguments. See problem2html.py + """ + problembase = os.path.splitext(os.path.basename(problem))[0] + destfile = string.Template(options.destfile).safe_substitute(problem=problembase) + + statement_path = statement_common.find_statement(problem, extension="md", language=options.language) + + if statement_path is None: + raise Exception('No markdown statement found') + + if not os.path.isfile(statement_path): + raise Exception(f"Error! {statement_path} is not a file") + + + _copy_images(statement_path, + lambda img_name: handle_image(os.path.join(problem, "problem_statement", img_name))) + command = ["pandoc", statement_path, "-t" , "html"] + statement_html = subprocess.run(command, capture_output=True, text=True, + shell=False, check=True).stdout + + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_html'), + os.path.join(os.path.dirname(__file__), '../templates/markdown_html'), + '/usr/lib/problemtools/templates/markdown_html'] + templatepath = next((p for p in templatepaths + if os.path.isdir(p) and os.path.isfile(os.path.join(p, "default-layout.html"))), + None) + + if templatepath is None: + raise Exception('Could not find directory with markdown templates') + + problem_name = statement_common.get_problem_name(problem, options.language) + + html_template = _substitute_template(templatepath, "default-layout.html", + statement_html=statement_html, + language=options.language, + title=problem_name or "Missing problem name", + problemid=problembase) + + samples = "".join(statement_common.format_samples(problem, to_pdf=False)) + + html_template = inject_samples(html_template, samples) + html_template = replace_hr_in_footnotes(html_template) + + with open(destfile, "w", encoding="utf-8", errors="xmlcharrefreplace") as output_file: + output_file.write(html_template) + + if options.css: + with open("problem.css", "w") as output_file: + with open(os.path.join(templatepath, "problem.css"), "r") as input_file: + output_file.write(input_file.read()) + + return True + + +def handle_image(src: str) -> None: + """This is called for every image in the statement + Copies the image from the statement to the output directory + + Args: + src: full file path to the image + """ + file_name = os.path.basename(src) + + if not os.path.isfile(src): + raise Exception(f"File {file_name} not found in problem_statement") + if os.path.isfile(file_name): + return + with open(src, "rb") as img: + with open(file_name, "wb") as out: + out.write(img.read()) + + +def json_dfs(data, callback) -> None: + """Traverse all items in a JSON tree, find all images, and call callback for each one""" + if isinstance(data, dict): + for key, value in data.items(): + # Markdown-style images + if key == 't' and value == 'Image': + callback(data['c'][2][0]) + else: + json_dfs(value, callback) + + elif isinstance(data, list): + for item in data: + json_dfs(item, callback) + + +def _copy_images(statement_path, callback): + command = ["pandoc", statement_path, "-t" , "json"] + statement_json = subprocess.run(command, capture_output=True, + text=True, shell=False, check=True).stdout + json_dfs(json.loads(statement_json), callback) + + +def inject_samples(html, samples): + if FOOTNOTES_STRING in html: + pos = html.find(FOOTNOTES_STRING) + else: + pos = html.find("") + html = html[:pos] + samples + html[pos:] + return html + + +def replace_hr_in_footnotes(html_content): + if not FOOTNOTES_STRING in html_content: + return html_content + footnotes = html_content.find(FOOTNOTES_STRING) + hr_pos = html_content.find("
", footnotes) + return html_content[:hr_pos] + """ +

+ Footnotes +

+""" + html_content[6 + hr_pos:] + + +def _substitute_template(templatepath: str, templatefile: str, **params) -> str: + """Read the markdown template and substitute in things such as problem name, + statement etc using python's format syntax. + """ + with open(os.path.join(templatepath, templatefile), "r", encoding="utf-8") as template_file: + html_template = template_file.read() % params + return html_template diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 6bf56192..c9ffe221 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -4,67 +4,21 @@ import os.path import string import argparse -import logging import subprocess -from . import template +from . import tex2html +from . import md2html +from . import statement_common def convert(options: argparse.Namespace) -> None: - # PlasTeX.Logging statically overwrites logging and formatting, so delay loading - import plasTeX.TeX - import plasTeX.Logging - from .ProblemPlasTeX import ProblemRenderer - from .ProblemPlasTeX import ProblemsetMacros - problem = os.path.realpath(options.problem) + if not os.path.isdir(problem): + raise Exception(f"Problem does not exist: {problem}") + problembase = os.path.splitext(os.path.basename(problem))[0] destdir = string.Template(options.destdir).safe_substitute(problem=problembase) destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - imgbasedir = string.Template(options.imgbasedir).safe_substitute(problem=problembase) - - if options.quiet: - plasTeX.Logging.disableLogging() - else: - plasTeX.Logging.getLogger().setLevel(getattr(logging, options.loglevel.upper())) - plasTeX.Logging.getLogger('status').setLevel(getattr(logging, options.loglevel.upper())) - - texfile = problem - # Set up template if necessary - with template.Template(problem, language=options.language) as templ: - texfile = open(templ.get_file_name(), 'r') - - origcwd = os.getcwd() - - # Setup parser and renderer etc - - # plasTeX version 3 changed the name of this argument (and guarding against this - # by checking plasTeX.__version__ fails on plastex v3.0 which failed to update - # __version__) - try: - tex = plasTeX.TeX.TeX(myfile=texfile) - except Exception: - tex = plasTeX.TeX.TeX(file=texfile) - - ProblemsetMacros.init(tex) - - tex.ownerDocument.config['general']['copy-theme-extras'] = options.css - if not options.headers: - tex.ownerDocument.userdata['noheaders'] = True - tex.ownerDocument.config['files']['filename'] = destfile - tex.ownerDocument.config['images']['filenames'] = 'img-$num(4)' - tex.ownerDocument.config['images']['enabled'] = False - tex.ownerDocument.config['images']['imager'] = 'none' - tex.ownerDocument.config['images']['base-url'] = imgbasedir - # tell plasTeX where to search for problemtools' built-in packages - tex.ownerDocument.config['general']['packages-dirs'] = [os.path.join(os.path.dirname(__file__), 'ProblemPlasTeX')] - - renderer = ProblemRenderer() - - if not options.quiet: - print('Parsing TeX source...') - doc = tex.parse() - texfile.close() # Go to destdir if destdir: @@ -75,12 +29,13 @@ def convert(options: argparse.Namespace) -> None: try: if not options.quiet: print('Rendering!') - renderer.render(doc) - # Annoying: I have not figured out any way of stopping the plasTeX - # renderer from generating a .paux file - if os.path.isfile('.paux'): - os.remove('.paux') + origcwd = os.getcwd() + + if statement_common.find_statement_extension(problem, options.language) == "tex": + tex2html.convert(problem, options) + else: + md2html.convert(problem, options) if options.tidy: with open(os.devnull, 'w') as devnull: diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 0f6fc452..62d40dbe 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -5,16 +5,74 @@ import string import argparse import subprocess -from . import template +import tempfile +from . import template +from . import statement_common def convert(options: argparse.Namespace) -> bool: - problem = os.path.realpath(options.problem) - problembase = os.path.splitext(os.path.basename(problem))[0] + problem_root = os.path.realpath(options.problem) + + if statement_common.find_statement_extension(problem_root, language=options.language) == "md": + return md2pdf(options) + else: + return latex2pdf(options) + + +def md2pdf(options: argparse.Namespace) -> bool: + problem_root = os.path.realpath(options.problem) + problembase = os.path.splitext(os.path.basename(problem_root))[0] + destfile = string.Template(options.destfile).safe_substitute(problem=problembase) + + statement_path = statement_common.find_statement(problem_root, extension="md", language=options.language) + + if not os.path.isfile(statement_path): + raise Exception(f"Error! {statement_path} is not a file") + + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_pdf'), + os.path.join(os.path.dirname(__file__), '../templates/markdown_pdf'), + '/usr/lib/problemtools/templates/markdown_pdf'] + templatepath = next((p for p in templatepaths + if os.path.isdir(p) and os.path.isfile(os.path.join(p, "fix_tables.md"))), + None) + table_fix_path = os.path.join(templatepath, "fix_tables.md") + if not os.path.isfile(table_fix_path): + raise Exception("Could not find markdown pdf template") + + with open(table_fix_path, "r") as file: + table_fix = file.read() + + statement_dir = os.path.join(problem_root, "problem_statement") + with open(statement_path, "r") as file: + statement_md = file.read() + + problem_name = statement_common.get_problem_name(problem_root, options.language) + + problem_id = os.path.basename(problem_root) + # Add code that adds vertical and horizontal lines to all tables + statement_md = r'\centerline{\large %s}' % f"Problem id: {problem_id}" + statement_md + statement_md = r'\centerline{\huge %s}' % problem_name + statement_md + statement_md = table_fix + statement_md + + samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) + + # If we don't add newline, the topmost table might get attached to a footnote + statement_md += "\n" + samples + + with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: + temp_file.write(statement_md) + temp_file.flush() + command = ["pandoc", temp_file.name, "-o", destfile, f"--resource-path={statement_dir}"] + return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) + + +def latex2pdf(options: argparse.Namespace) -> bool: + problem_root = os.path.realpath(options.problem) + problembase = os.path.splitext(os.path.basename(problem_root))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) # Set up template if necessary - with template.Template(problem, language=options.language) as templ: + with template.Template(problem_root, language=options.language) as templ: texfile = templ.get_file_name() origcwd = os.getcwd() @@ -43,6 +101,7 @@ def convert(options: argparse.Namespace) -> bool: return status == 0 + def get_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py new file mode 100644 index 00000000..66a6c673 --- /dev/null +++ b/problemtools/statement_common.py @@ -0,0 +1,209 @@ +import os +from typing import Optional, List +import html +import tempfile +import subprocess + +from . import verifyproblem + +SUPPORTED_EXTENSIONS = ("tex", "md") + +def find_statement(problem_root: str, extension: str, language: Optional[str]) -> Optional[str]: + """Finds the "best" statement for given language and extension""" + if language is None: + statement_path = os.path.join(problem_root, f"problem_statement/problem.en.{extension}") + if os.path.isfile(statement_path): + return statement_path + statement_path = os.path.join(problem_root, f"problem_statement/problem.{extension}") + if os.path.isfile(statement_path): + return statement_path + return None + statement_path = os.path.join(problem_root, f"problem_statement/problem.{language}.{extension}") + if os.path.isfile(statement_path): + return statement_path + return None + + +def find_statement_extension(problem_root: str, language: Optional[str]) -> str: + """Given a language, find whether the extension is tex or md + + Args: + problem_root: path to problem root + """ + extensions = [] + for ext in SUPPORTED_EXTENSIONS: + if find_statement(problem_root, ext, language) is not None: + extensions.append(ext) + # At most one extension per language to avoid arbitrary/hidden priorities + if len(extensions) > 1: + raise Exception(f"""Found more than one type of statement ({' and '.join(extensions)}) + for language {language or 'en'}""") + if len(extensions) == 1: + return extensions[0] + raise Exception(f"No statement found for language {language or 'en'}") + + + +def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: + """Load problem.yaml to get problem name""" + if language is None: + language = "en" + with verifyproblem.Problem(problem) as prob: + config = verifyproblem.ProblemConfig(prob) + if not config.check(None): + raise Exception("Invalid problem.yaml") + names = config.get("name") + # If there is only one language, per the spec that is the one we want + if len(names) == 1: + return next(iter(names.values())) + + if language not in names: + raise Exception(f"No problem name defined for language {language or 'en'}") + return names[language] + + +def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: + """Read all samples from the problem directory and convert them to pandoc-valid markdown + + Args: + problem_root: path to root of problem + to_pdf: whether the outputted samples should be valid for for html or pdf + + Returns: + List[str]: All samples, converted to a format appropriate to be pasted into + a markdown file. Ordered lexicographically by file names + """ + + sample_path = os.path.join(problem_root, "data", "sample") + if not os.path.isdir(sample_path): + print("WARNING!! no sample folder") + return [] + samples = [] + casenum = 1 + for sample in sorted(os.listdir(sample_path)): + if sample.endswith(".interaction"): + samples.append(format_interactive_sample(sample_path, sample, casenum, to_pdf)) + casenum += 1 + continue + + if not sample.endswith(".in"): + continue + sample_name = sample[:-3] + outpath = os.path.join(sample_path, sample_name + ".ans") + if not os.path.isfile(outpath): + continue + + samples.append(format_normal_sample(sample_path, sample, casenum, to_pdf)) + casenum += 1 + + return samples + + +def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bool) -> str: + """ + + Args: + sample_root: root of the sample folder + sample: file name of the sample + casenum: which sample is this? (1, 2, 3...) + to_pdf: do we target pdf or html output + + Returns: + str: the sample, ready to be pasted into a markdown doc and fed to pandoc + """ + + with open(os.path.join(sample_root, sample), "r", encoding="utf-8") as infile: + sample_input = infile.read() + sample_name = sample[:-3] + outpath = os.path.join(sample_root, sample_name + ".ans") + with open(outpath, "r", encoding="utf-8") as outfile: + sample_output = outfile.read() + + sample = """ + + + + + + + + + + + +
Sample Input %(case)dSample Output %(case)d
%(input)s
%(output)s
""" % ({"case": casenum, "input": html.escape(sample_input), + "output": html.escape(sample_output)}) + + if to_pdf: + # If pdf, convert to markdown + with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: + temp_file.write(sample) + temp_file.flush() + command = ["pandoc", temp_file.name, "-t" , "markdown"] + return subprocess.run(command, capture_output=True, text=True, + shell=False, check=True).stdout + else: + return sample + + +def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pdf: bool) -> str: + """ + + Args: + sample_root: root of the sample folder + sample: file name of the sample + casenum: which sample is this? (1, 2, 3...) + to_pdf: do we target pdf or html output + + Returns: + str: the sample, ready to be pasted into a markdown doc and fed to pandoc + """ + if to_pdf: + line = r"""\begin{tabular}{p{0.3\textwidth} p{0.5\textwidth} p{0.0\textwidth}} +\textbf{Read} & \textbf{Sample Interaction %i} & \textbf{Write} \\ +\end{tabular}""" % casenum + else: + line = f""" + + + + + + +
ReadSample Interaction {casenum}Write
""" + + with open(os.path.join(sample_root, sample), "r", encoding="utf-8") as infile: + sample_interaction = infile.readlines() + lines = [] + for interaction in sample_interaction: + data = interaction[1:] + if to_pdf: + if interaction[0] == '>': + left = True + elif interaction[0] == '<': + left = False + else: + print(f"Warning: Interaction had unknown prefix {interaction[0]}") + lines.append(r""" + \begin{table}[H] + %(justify)s\begin{tabular}{|p{0.6\textwidth}|} + \hline + %(text)s \\ + \hline + \end{tabular} + \end{table}""" % {"justify": "" if left else "\\hspace*{\\fill}\n", + "text": data}) + else: + line_type = "" + if interaction[0] == '>': + line_type = "sampleinteractionwrite" + elif interaction[0] == '<': + line_type = "sampleinteractionread" + else: + print(f"Warning: Interaction had unknown prefix {interaction[0]}") + lines.append(f"""
{data}
""") + + if to_pdf: + return line + '\\vspace{-15pt}'.join(lines) + else: + return line + ''.join(lines) diff --git a/problemtools/templates/markdown_html/default-layout.html b/problemtools/templates/markdown_html/default-layout.html new file mode 100644 index 00000000..814324c1 --- /dev/null +++ b/problemtools/templates/markdown_html/default-layout.html @@ -0,0 +1,36 @@ + + + + +%(title)s + + + + + + + + +
+

%(title)s

+

Problem ID: %(problemid)s

+
+
+ %(statement_html)s +
+ + + diff --git a/problemtools/templates/markdown_html/problem.css b/problemtools/templates/markdown_html/problem.css new file mode 100644 index 00000000..c38a4d97 --- /dev/null +++ b/problemtools/templates/markdown_html/problem.css @@ -0,0 +1,108 @@ +.problemheader { + text-align: center; +} + +.problembody { + font-family: 'Times New Roman', Georgia, serif; + font-size: 1.1em; + text-align: justify; + padding-top: 1.5em; +} + +.problembody h2, .problembody h3, .problembody table.sample th { + font-family: Arial, Helvetica, sans-serif; +} + +/*Style all tables except sample*/ +table:not(.sample) { + border-collapse: collapse; +} + +table:not(.sample) td, table:not(.sample) th { + border-top-style: solid; + border-top-color: black; + border-top-width: 1px; + text-align: left; + border-right: 1px solid black; + border-left: 1px solid black; + border-bottom: 1px solid black; +} + +table:not(.sample) td { + margin: 0px; +} + +/*Style sample in its own way*/ +.sample { + font-family: Arial, Helvetica, sans-serif; + width: 100%; +} + +.sample th { + padding: 0px; + border: 0px; + background-color: #ffffff; + text-align: left; + width: 50%; + font-size: 16px; + font-family: Arial, Helvetica, sans-serif; +} + +.sample td { + border: 1px solid black; +} + +div.minipage { + display: inline-block; +} + +div.illustration { + float: right; + padding-left: 20px; +} + +img.illustration { + width: 100%; +} + +div.figure { + display: block; + float: none; + margin-left: auto; + margin-right: auto; +} + +.illustration div.description { + font-size: 8pt; + text-align: right; +} + +.problembody p { + text-align: justify; +} + +td { + vertical-align:top; +} + +div.sampleinteractionread { + border: 1px solid black; + width: 60%; + float: left; + margin: 3px 0px; +} + +.sampleinteractionread pre { + margin: 1px 5px; +} + +div.sampleinteractionwrite { + border: 1px solid black; + width: 60%; + float: right; + margin: 3px 0px; +} + +.sampleinteractionwrite pre { + margin: 1px 5px; +} diff --git a/problemtools/templates/markdown_pdf/fix_tables.md b/problemtools/templates/markdown_pdf/fix_tables.md new file mode 100644 index 00000000..1b04614f --- /dev/null +++ b/problemtools/templates/markdown_pdf/fix_tables.md @@ -0,0 +1,14 @@ +--- +header-includes: + - '\usepackage{float}' + - '\usepackage{booktabs}' + - '\usepackage{xstring}' + - '\setlength{\aboverulesep}{0pt}' + - '\setlength{\belowrulesep}{0pt}' + - '\renewcommand{\arraystretch}{1.3}' + - '\makeatletter' + - '\patchcmd{\LT@array}{\@mkpream{#2}}{\StrGobbleLeft{#2}{2}[\pream]\StrGobbleRight{\pream}{2}[\pream]\StrSubstitute{\pream}{l}{|l}[\pream]\@mkpream{@{}\pream|@{}}}{}{}' + - '\def\midrule{}' + - '\apptocmd{\LT@tabularcr}{\hline}{}{}' + - '\makeatother' +--- diff --git a/problemtools/tests/hello/problem.yaml b/problemtools/tests/hello/problem.yaml index 194b060f..6c6f791e 100644 --- a/problemtools/tests/hello/problem.yaml +++ b/problemtools/tests/hello/problem.yaml @@ -1,5 +1,8 @@ source: Kattis license: public domain +name: + sv: Hej Världen! + en: Hello World! # Fix memory limit at 512 MB. (Note that for most problems, this # should not be done. It is only done in this case because we include diff --git a/problemtools/tex2html.py b/problemtools/tex2html.py new file mode 100644 index 00000000..49c88c78 --- /dev/null +++ b/problemtools/tex2html.py @@ -0,0 +1,67 @@ +import os +import logging +import string +import argparse + +from . import template + + +def convert(problem: str, options: argparse.Namespace) -> None: + # PlasTeX.Logging statically overwrites logging and formatting, so delay loading + import plasTeX.TeX + import plasTeX.Logging + from .ProblemPlasTeX import ProblemRenderer + from .ProblemPlasTeX import ProblemsetMacros + + problembase = os.path.splitext(os.path.basename(problem))[0] + if options.quiet: + plasTeX.Logging.disableLogging() + else: + plasTeX.Logging.getLogger().setLevel(getattr(logging, options.loglevel.upper())) + plasTeX.Logging.getLogger('status').setLevel(getattr(logging, options.loglevel.upper())) + + destfile = string.Template(options.destfile).safe_substitute(problem=problembase) + imgbasedir = string.Template(options.imgbasedir).safe_substitute(problem=problembase) + + texfile = problem + # Set up template if necessary + with template.Template(problem, language=options.language) as templ: + texfile = open(templ.get_file_name(), 'r') + + # Setup parser and renderer etc + + # plasTeX version 3 changed the name of this argument (and guarding against this + # by checking plasTeX.__version__ fails on plastex v3.0 which failed to update + # __version__) + try: + tex = plasTeX.TeX.TeX(myfile=texfile) + except Exception: + tex = plasTeX.TeX.TeX(file=texfile) + + ProblemsetMacros.init(tex) + + tex.ownerDocument.config['general']['copy-theme-extras'] = options.css + if not options.headers: + tex.ownerDocument.userdata['noheaders'] = True + tex.ownerDocument.config['files']['filename'] = destfile + tex.ownerDocument.config['images']['filenames'] = 'img-$num(4)' + tex.ownerDocument.config['images']['enabled'] = False + tex.ownerDocument.config['images']['imager'] = 'none' + tex.ownerDocument.config['images']['base-url'] = imgbasedir + # tell plasTeX where to search for problemtools' built-in packages + tex.ownerDocument.config['general']['packages-dirs'] = [os.path.join(os.path.dirname(__file__), 'ProblemPlasTeX')] + + renderer = ProblemRenderer() + + if not options.quiet: + print('Parsing TeX source...') + doc = tex.parse() + texfile.close() + + + renderer.render(doc) + + # Annoying: I have not figured out any way of stopping the plasTeX + # renderer from generating a .paux file + if os.path.isfile('.paux'): + os.remove('.paux') diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index a45cbf9d..8be12f67 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -28,6 +28,7 @@ from . import problem2pdf from . import problem2html +from . import statement_common from . import config from . import languages @@ -1119,12 +1120,14 @@ def __init__(self, problem: Problem): self._problem = problem self.languages = [] glob_path = os.path.join(problem.probdir, 'problem_statement', 'problem.') - if glob.glob(glob_path + 'tex'): - self.languages.append('') - for f in glob.glob(glob_path + '[a-z][a-z].tex'): - m = re.search("problem.([a-z][a-z]).tex$", f) - assert m - self.languages.append(m.group(1)) + for extension in statement_common.SUPPORTED_EXTENSIONS: + if glob.glob(glob_path + extension): + self.languages.append('') + for f in glob.glob(glob_path + '[a-z][a-z].%s' % extension): + lang = re.search("problem.([a-z][a-z]).%s$" % extension, f).group(1) + if lang in self.languages: + self.error('Language %s has several statement formats' % lang) + self.languages.append(lang) def check(self, context: Context) -> bool: if self._check_res is not None: @@ -1132,9 +1135,9 @@ def check(self, context: Context) -> bool: self._check_res = True if not self.languages: - self.error('No problem statements found (expected problem.tex or problem.[a-z][a-z].tex in problem_statement directory)') + self.error('No problem statements found (expected problem.{tex,md} or problem.[a-z][a-z].{tex,md} in problem_statement directory)') if '' in self.languages and 'en' in self.languages: - self.error("Can't supply both problem.tex and problem.en.tex") + self.error("Can't supply both problem.{tex,md} and problem.en.{tex,md}") for lang in self.languages: try: @@ -1165,21 +1168,24 @@ def __str__(self) -> str: def get_config(self) -> dict[str, dict[str, str]]: ret: dict[str, dict[str, str]] = {} - for lang in self.languages: - filename = f'problem.{lang}.tex' if lang != '' else 'problem.tex' - stmt = open(os.path.join(self._problem.probdir, 'problem_statement', filename)).read() - patterns = [ - (r'\\problemname{(.*)}', 'name'), - (r'^%%\s*plainproblemname:(.*)$', 'name'), - ] - for tup in patterns: - pattern = tup[0] - dest = tup[1] - hit = re.search(pattern, stmt, re.MULTILINE) - if hit: - if not dest in ret: - ret[dest] = {} - ret[dest][lang] = hit.group(1).strip() + for extension in statement_common.SUPPORTED_EXTENSIONS: + for lang in self.languages: + filename = f'problem.{lang}.{extension}' if lang != '' else 'problem.{extension}' + if not os.path.isfile(filename): + continue + stmt = open(os.path.join(self._problem.probdir, 'problem_statement', filename)).read() + patterns = [ + (r'\\problemname{(.*)}', 'name'), + (r'^%%\s*plainproblemname:(.*)$', 'name'), + ] + for tup in patterns: + pattern = tup[0] + dest = tup[1] + hit = re.search(pattern, stmt, re.MULTILINE) + if hit: + if not dest in ret: + ret[dest] = {} + ret[dest][lang] = hit.group(1).strip() return ret