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...**
+
+
+
+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 = '
") + 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)d | +Sample 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""" +
Read | +Sample 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 @@ + + +
+ +
+ + + + + + +
+