diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index ebcc9908..e718ea4b 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -29,6 +29,7 @@ jobs: python -m pip install --upgrade pip pip install mypy ruff pytest if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + sudo apt-get install pandoc tidy ghostscript python3 texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic - name: Lint with ruff run: ruff check --output-format=github - name: Check ruff formatting diff --git a/Dockerfile b/Dockerfile index e9787418..02a400a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,9 @@ RUN apt-get update && \ libgmp10 \ libgmpxx4ldbl \ openjdk-8-jdk \ + pandoc \ python3-minimal \ + python-nh3 \ python3-pip \ python3-plastex \ python3-yaml \ diff --git a/README.md b/README.md index 35c6f6bf..5b7e2a1a 100644 --- a/README.md +++ b/README.md @@ -205,17 +205,17 @@ 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 python3 texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy dvisvgm + sudo apt install ghostscript pandoc python3 texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy dvisvgm ### 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 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: - pip3 install --user plastex + pip3 install --user plastex nh3 ### Arch Package is available on the AUR [kattis-problemtools-git](https://aur.archlinux.org/packages/kattis-problemtools-git). Use your favorite AUR helper or follow the installation instructions found [here](https://wiki.archlinux.org/title/Arch_User_Repository#Installing_and_upgrading_packages). diff --git a/admin/docker/Dockerfile.build b/admin/docker/Dockerfile.build index e2f7a3bf..e7041fb9 100644 --- a/admin/docker/Dockerfile.build +++ b/admin/docker/Dockerfile.build @@ -25,6 +25,7 @@ RUN apt update && \ libgmp-dev \ libgmp10 \ libgmpxx4ldbl \ + pandoc \ python3 \ python3-pytest \ python3-setuptools \ diff --git a/admin/docker/Dockerfile.full b/admin/docker/Dockerfile.full index 40580dd6..9fb5196a 100644 --- a/admin/docker/Dockerfile.full +++ b/admin/docker/Dockerfile.full @@ -23,6 +23,7 @@ RUN apt-get update && \ mono-complete \ nodejs \ ocaml-nox \ + pandoc \ php-cli \ pypy \ rustc \ diff --git a/admin/docker/Dockerfile.minimal b/admin/docker/Dockerfile.minimal index 534e661f..886d1a2d 100644 --- a/admin/docker/Dockerfile.minimal +++ b/admin/docker/Dockerfile.minimal @@ -20,6 +20,7 @@ RUN apt update && \ apt install -y \ ghostscript \ libgmpxx4ldbl \ + pandoc \ python-pkg-resources \ python3-minimal \ python3-yaml \ diff --git a/debian/control b/debian/control index 4ba8bd76..f1b5e70d 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}, ${misc:Depends}, python3, texlive-plain-generic, texlive-fonts-recommended, texlive-latex-extra, texlive-lang-cyrillic, tidy, ghostscript, dvisvgm +Depends: ${shlibs:Depends}, ${misc:Depends}, pandoc, python3, 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..c67e0aa3 100644 --- a/examples/different/problem.yaml +++ b/examples/different/problem.yaml @@ -5,6 +5,8 @@ ## Author of the problem (default: null) # author: +name: 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..877468d4 100644 --- a/examples/guess/problem.yaml +++ b/examples/guess/problem.yaml @@ -2,6 +2,7 @@ source: Kattis license: cc by-sa validation: custom interactive +name: 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..bc12a981 100644 --- a/examples/hello/problem.yaml +++ b/examples/hello/problem.yaml @@ -1,5 +1,6 @@ source: Kattis license: public domain +name: 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..2129dd93 100644 --- a/examples/oddecho/problem.yaml +++ b/examples/oddecho/problem.yaml @@ -2,6 +2,6 @@ license: cc by-sa author: Johan Sannemo source: Principles of Algorithmic Problem Solving type: scoring -name: Echo +name: Odd Echo grading: show_test_data_groups: true diff --git a/examples/oddecho/problem_statement/cave.jpg b/examples/oddecho/problem_statement/cave.jpg new file mode 100644 index 00000000..670bbeda Binary files /dev/null and b/examples/oddecho/problem_statement/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..55f9806f --- /dev/null +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -0,0 +1,33 @@ +**EKO! Eko! Ek...** + +![CC-BY-SA 2.0 By William Craig on wikimedia.org](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..a9c67152 --- /dev/null +++ b/problemtools/md2html.py @@ -0,0 +1,169 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +import argparse +import html +import os +from pathlib import Path +import re +import shutil +import string +import subprocess + +import nh3 + +from . import statement_util + + +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_util.find_statement(problem, extension='md', language=options.language) + + if statement_path is None: + raise FileNotFoundError('No markdown statement found') + + if not os.path.isfile(statement_path): + raise FileNotFoundError(f'Error! {statement_path} does not exist') + + command = ['pandoc', statement_path, '-t', 'html', '--mathjax'] + statement_html = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout + + statement_html = sanitize_html(problem, statement_html) + + templatepaths = [ + 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 FileNotFoundError('Could not find directory with markdown templates') + + with open(Path(templatepath) / 'default-layout.html', 'r', encoding='utf-8') as template_file: + template = template_file.read() + + problem_name = statement_util.get_yaml_problem_name(problem, options.language) + substitution_params = { + 'statement_html': statement_html, + 'language': options.language, + 'title': html.escape(problem_name) if problem_name else 'Missing problem name', + 'problemid': html.escape(problembase), + } + + statement_html = template % substitution_params + + samples = statement_util.format_samples(problem) + # Insert samples at {{nextsample}} and {{remainingsamples}} + statement_html, remaining_samples = statement_util.inject_samples(statement_html, samples) + + # Insert the remaining samples at the bottom + # However, footnotes should be below samples + sample_insertion_position = statement_util.find_footnotes(statement_html) + if sample_insertion_position is None: + # No footnotes, so insert at the end + sample_insertion_position = statement_html.rfind('') + statement_html = ( + statement_html[:sample_insertion_position] + ''.join(remaining_samples) + statement_html[sample_insertion_position:] + ) + + with open(destfile, 'w', encoding='utf-8', errors='xmlcharrefreplace') as output_file: + output_file.write(statement_html) + + if options.css: + shutil.copyfile(os.path.join(templatepath, 'problem.css'), 'problem.css') + + return True + + +def sanitize_html(problem: str, statement_html: str): + # Allow footnote ids (the anchor points you jump to) + def is_fn_id(s): + pattern_id_top = r'^fn\d+$' + pattern_id_bottom = r'^fnref\d+$' + return bool(re.fullmatch(pattern_id_top, s)) or bool(re.fullmatch(pattern_id_bottom, s)) + + allowed_classes = ('sample', 'problemheader', 'problembody', 'sampleinteractionwrite', 'sampleinteractionread') + + def is_image_valid(problem_root: str, img_src: str) -> str | None: + # Check that the image exists and uses an allowed extension + extension = Path(img_src).suffix + # TODO: fix svg sanitization and allow svg + if extension not in statement_util.ALLOWED_IMAGE_EXTENSIONS: + return f'Unsupported image extension {extension} for image {img_src}' + + source_file = Path(problem_root) / 'statement' / img_src + if not source_file.exists(): + return f'Resource file {img_src} not found in statement' + return None + + # Annoying: nh3 will ignore exceptions in attribute_filter + image_fail_reason: str | None = None + + def attribute_filter(tag, attribute, value): + if attribute == 'class' and value in allowed_classes: + return value + # Never versions of Pandoc will give class="footnotes footnotes-end-of-document" + # We don't want to blindly allow any class with footnotes in it, so only allow footnotes + if attribute == 'class' and 'footnotes' in value: + return 'footnotes' + if tag == 'a' and attribute == 'href': + return value + if tag in ('li', 'a') and attribute == 'id' and is_fn_id(value): + return value + if tag == 'img' and attribute == 'src': + fail = is_image_valid(problem, value) + if fail: + nonlocal image_fail_reason + image_fail_reason = fail + return None + copy_image(problem, value) + return value + return None + + statement_html = nh3.clean( + statement_html, + link_rel='noopener nofollow noreferrer', + attribute_filter=attribute_filter, + tags=nh3.ALLOWED_TAGS | {'img', 'a', 'section'}, + attributes={ + 'table': {'class'}, + 'aside': {'class'}, + 'div': {'class'}, + 'section': {'class'}, + 'img': {'src'}, + 'a': {'href', 'id'}, + 'li': {'id'}, + }, + ) + + if image_fail_reason: + assert isinstance(image_fail_reason, str) + if 'Unsupported' in image_fail_reason: + raise ValueError(image_fail_reason) + raise FileNotFoundError(image_fail_reason) + + return statement_html + + +def copy_image(problem_root: str, img_src: str) -> None: + """Copy image to output directory + + Args: + problem_root: the root of the problem directory + img_src: the image source as in the Markdown statement + """ + + source_name = os.path.join(problem_root, 'statement', img_src) + + if os.path.isfile(img_src): # already copied + return + shutil.copyfile(source_name, img_src) diff --git a/problemtools/metadata.py b/problemtools/metadata.py index e5144701..a02b341c 100644 --- a/problemtools/metadata.py +++ b/problemtools/metadata.py @@ -113,7 +113,7 @@ class Metadata2023_07(BaseModel): problem_format_version: str name: dict[str, str] | str - uuid: UUID + uuid: UUID | None = None # UUID *is* mandatory, but we deal with that in verifyproblem for better UX type: list[ProblemType] | ProblemType = ProblemType.PASS_FAIL version: str | None = None credits: dict | str | None = None diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 286ef298..677909fc 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -4,62 +4,22 @@ import os.path import string import argparse -import logging import subprocess -from . import template +from . import tex2html +from . import md2html +from . import statement_util 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 - - 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: @@ -70,12 +30,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_util.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 dc26837e..a2ee466d 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -1,20 +1,109 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- +import argparse import os.path +import re import shutil import string -import argparse import subprocess +import tempfile +from pathlib import Path + from . import template +from . import statement_util 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_util.find_statement_extension(problem_root, language=options.language) == 'md': + return md2pdf(options) + else: + return latex2pdf(options) + + +def md2pdf(options: argparse.Namespace) -> bool: + """Renders a Markdown document to pdf. Uses pandoc md -> tex, then + reuses the normal tex -> pdf pipeline + """ + problem_root = os.path.realpath(options.problem) + statement_path = statement_util.find_statement(problem_root, extension='md', language=options.language) + + if not statement_path or not os.path.isfile(statement_path): + raise FileNotFoundError(f'Error! {statement_path} does not exist') + + statement_util.assert_images_are_valid_md(statement_path) + + language = options.language + if not language: + language = 'en' + temp_tex_file = Path(statement_path).parent / f'problem.{language}.tex' + command = ['pandoc', statement_path, '-o', str(temp_tex_file)] + try: + subprocess.run(command, capture_output=True, text=True, shell=False, check=True) + except subprocess.CalledProcessError as e: + print(f'Error compiling Markdown to pdf: {e.stderr}') + return False + + try: + with open(temp_tex_file, 'r', encoding='utf-8') as f: + tex = f.read() + + def format_latex_tables(latex_doc): + # Match table environments produced by pandoc + pattern = r""" + (\\begin\{longtable\}\[\]\{@\{\}) + ([a-z]) + ([a-z]*) + (@\{\}\}) + """ + + def replacer(match): + prefix = match.group(1)[:-3] + first_col = match.group(2) + other_cols = match.group(3) + suffix = match.group(4)[3:] + + # Combine columns with | separators + cols = [first_col] + list(other_cols) + return f'{prefix}|{"|".join(cols)}|{suffix} \\hline' + + return re.sub(pattern, replacer, latex_doc, flags=re.VERBOSE) + + # Add solid outline to tables + tex = format_latex_tables(tex) + tex = tex.replace(r'\toprule', '') + tex = tex.replace(r'\midrule', '') + tex = tex.replace(r'\endhead', '') + tex = tex.replace(r'\bottomrule', '') + tex = tex.replace(r'\tabularnewline', r'\\ \hline') + + # Fix sample inclusions commands + # Currently does not work, as normal problemtools tex -> pdf does not support it + tex = tex.replace(r'\{\{nextsample\}\}', r'\nextsample') + tex = tex.replace(r'\{\{remainingsamples\}\}', r'\remainingsamples') + + problem_name = statement_util.get_yaml_problem_name(problem_root, options.language) + tex = r'\problemname{' + problem_name + '}\n' + tex + with open(temp_tex_file, 'w', encoding='utf-8') as f: + f.write(tex) + + status = latex2pdf(options) + if status != 0: + return False + finally: + temp_tex_file.unlink() + + return status == 0 + + +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() @@ -41,7 +130,32 @@ def convert(options: argparse.Namespace) -> bool: if status == 0 and not options.nopdf: shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) - return status == 0 + if status: + return False + + # We only sanitize if a PDF was created + if not options.nopdf: + try: + with tempfile.NamedTemporaryFile(suffix='.pdf') as f: + command = [ + 'gs', + '-q', + '-dBATCH', + '-sDEVICE=pdfwrite', + '-dNOPAUSE', + '-dCompatibilityLevel=1.7', + f'-sOutputFile={f.name}', + destfile, + ] + gs_status = subprocess.run(command, capture_output=True, text=True, shell=False, check=True) + if gs_status.returncode != 0: + return False + shutil.copy(f.name, destfile) + except subprocess.CalledProcessError as e: + print(f'Error sanitizing PDF: {e} {e.stderr}') + raise + + return True def get_parser() -> argparse.ArgumentParser: diff --git a/problemtools/statement_util.py b/problemtools/statement_util.py new file mode 100644 index 00000000..e7f8513b --- /dev/null +++ b/problemtools/statement_util.py @@ -0,0 +1,263 @@ +import os +from typing import Optional, List, Tuple +import html +import json +import re +import subprocess +import tempfile +from pathlib import Path + +from . import formatversion +from . import verifyproblem + +ALLOWED_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg') # ".svg" +FOOTNOTES_STRINGS = ['
', '