Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion mypy.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[mypy]
ignore_missing_imports = True
ignore_missing_imports = False
follow_untyped_imports = True
install_types = True
check_untyped_defs = True
ignore_errors = False
Expand Down
1 change: 1 addition & 0 deletions problemtools/ProblemPlasTeX/ProblemsetMacros.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def invoke(self, tex):
res = super().invoke(tex)

# Overcome plasTeX bug by looking for love in the right place
assert self.ownerDocument is not None # Keep mypy happy
basetex = self.ownerDocument.userdata['base_tex_instance']
f = self.attributes['file']
ext = self.ownerDocument.userdata.getPath('packages/graphicx/extensions', ['.png', '.jpg', '.jpeg', '.gif', '.pdf'])
Expand Down
2 changes: 1 addition & 1 deletion problemtools/ProblemPlasTeX/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ class ProblemRenderer(Renderer):
imageTypes = ['.png', '.jpg', '.jpeg', '.gif']
vectorImageTypes = ['.svg']

def render(self, document):
def render(self, document, postProcess=None):
templatepaths = [
os.path.join(os.path.dirname(__file__), '../templates/html'),
os.path.join(os.path.dirname(__file__), '../../templates/html'),
Expand Down
2 changes: 1 addition & 1 deletion problemtools/ProblemPlasTeX/graphicx.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import plasTeX.Packages.graphics as graphics
from ProblemsetMacros import _graphics_command, clean_width
from problemtools.ProblemPlasTeX.ProblemsetMacros import _graphics_command, clean_width

# Reimplementation of graphicx package because plasTeX is broken and
# annoying.
Expand Down
1 change: 1 addition & 0 deletions problemtools/ProblemPlasTeX/listingsutf8.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ def read_file(self, filename) -> str:

def invoke(self, tex) -> None:
super().invoke(tex)
assert self.ownerDocument is not None # Keep mypy happy
basetex = self.ownerDocument.userdata['base_tex_instance']
f = self.attributes['file']
# Maybe more paths to look in?
Expand Down
54 changes: 16 additions & 38 deletions problemtools/md2html.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,19 @@
from . import statement_util


def convert(problem: str, options: argparse.Namespace) -> bool:
def convert(problem_root: Path, options: argparse.Namespace, statement_file: Path) -> bool:
"""Convert a Markdown statement to HTML
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should document that output will be written to current working directory?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will it? I'm pretty sure it'll be written to destfile.

Copy link
Contributor

@Matistjati Matistjati May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I meant from the perspective of this code, it's the current working directory. copy_image will dump images to the working directory (destdir). When I was reading this code by itself, I missed that "we are in output directory" is a precondition.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Feels like that also goes into the follow-up that deals with the other issue you raised w.r.t. copy_image.


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)
destfile = string.Template(options.destfile).safe_substitute(problem=problem_root.name)

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']
command = ['pandoc', str(statement_file), '-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)
statement_html = sanitize_html(statement_file.parent, statement_html)

templatepaths = [
os.path.join(os.path.dirname(__file__), 'templates/markdown_html'),
Expand All @@ -51,17 +42,17 @@ def convert(problem: str, options: argparse.Namespace) -> bool:
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)
problem_name = statement_util.get_yaml_problem_name(problem_root, 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),
'problemid': html.escape(problem_root.name),
}

statement_html = template % substitution_params

samples = statement_util.format_samples(problem)
samples = statement_util.format_samples(problem_root)
# Insert samples at {{nextsample}} and {{remainingsamples}}
statement_html, remaining_samples = statement_util.inject_samples(statement_html, samples)

Expand All @@ -84,7 +75,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool:
return True


def sanitize_html(problem: str, statement_html: str):
def sanitize_html(statement_dir: Path, statement_html: str) -> str:
# Allow footnote ids (the anchor points you jump to)
def is_fn_id(s):
pattern_id_top = r'^fn\d+$'
Expand All @@ -93,18 +84,6 @@ def is_fn_id(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

Expand All @@ -120,12 +99,13 @@ def attribute_filter(tag, attribute, 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:
try:
statement_util.assert_image_is_valid(statement_dir, value)
except Exception as e:
nonlocal image_fail_reason
image_fail_reason = fail
image_fail_reason = str(e)
return None
copy_image(problem, value)
copy_image(statement_dir, value)
return value
return None

Expand Down Expand Up @@ -154,16 +134,14 @@ def attribute_filter(tag, attribute, value):
return statement_html


def copy_image(problem_root: str, img_src: str) -> None:
def copy_image(statement_dir: Path, img_src: str) -> None:
"""Copy image to output directory

Args:
problem_root: the root of the problem directory
statement_dir: the directory with problem statement files
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)
shutil.copyfile(statement_dir / img_src, img_src)
Copy link
Contributor

@Matistjati Matistjati May 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not your fault, but does this actually work as we expect it to? When I read the spec, I don't see subfolders being disallowed. https://www.kattis.com/problem-package-format/spec/2023-07-draft.html#problem-statements
I.e., what happens if img_src points to a subfolder in statement?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! I'll leave that for a follow-up

45 changes: 26 additions & 19 deletions problemtools/problem2html.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,29 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
import re
import argparse
import os.path
import re
import string
import argparse
import subprocess
import sys
from pathlib import Path

from . import tex2html
from . import md2html
from . import statement_util


def convert(options: argparse.Namespace) -> None:
problem = os.path.realpath(options.problem)
def convert(options: argparse.Namespace, force_statement_file: Path | None = None) -> None:
problem_root = Path(options.problem).resolve(strict=True)

if not os.path.isdir(problem):
raise Exception(f'Problem does not exist: {problem}')
if force_statement_file: # Used by verifyproblem to test rendering even if there are multiple statements in a language
statement_file = force_statement_file
else:
statement_file = statement_util.find_statement(problem_root, options.language)

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)
destdir = string.Template(options.destdir).safe_substitute(problem=problem_root.name)
destfile = string.Template(options.destfile).safe_substitute(problem=problem_root.name)
origcwd = os.getcwd()

# Go to destdir
if destdir:
Expand All @@ -30,13 +34,13 @@ def convert(options: argparse.Namespace) -> None:
try:
if not options.quiet:
print('Rendering!')

origcwd = os.getcwd()

if statement_util.find_statement_extension(problem, options.language) == 'tex':
tex2html.convert(problem, options)
else:
md2html.convert(problem, options)
match statement_file.suffix:
case '.md':
md2html.convert(problem_root, options, statement_file)
case '.tex':
tex2html.convert(problem_root, options, statement_file)
case _:
raise NotImplementedError('Unsupported file type, expected md or tex: {statement_file.name}')

if options.tidy:
with open(os.devnull, 'w') as devnull:
Expand Down Expand Up @@ -88,13 +92,12 @@ def get_parser() -> argparse.ArgumentParser:
)
parser.add_argument('-d', '--dest-dir', dest='destdir', help='output directory', default='${problem}_html')
parser.add_argument('-f', '--dest-file', dest='destfile', help='output file name', default='index.html')
parser.add_argument('-l', '--language', dest='language', help='choose alternate language (2-letter code)', default=None)
parser.add_argument('-l', '--language', dest='language', help='choose language (2-letter code)', default='en')
parser.add_argument(
'-L', '--log-level', dest='loglevel', help='set log level (debug, info, warning, error, critical)', default='warning'
)
parser.add_argument('-q', '--quiet', dest='quiet', action='store_true', help='quiet', default=False)
parser.add_argument('-i', '--imgbasedir', dest='imgbasedir', default='')
parser.add_argument('-v', '--format-version', dest='format_version', help='choose format version', default='automatic')
parser.add_argument('problem', help='the problem to convert')

return parser
Expand All @@ -103,7 +106,11 @@ def get_parser() -> argparse.ArgumentParser:
def main() -> None:
parser = get_parser()
options = parser.parse_args()
convert(options)
try:
convert(options)
except Exception as e:
print(e)
sys.exit(1)


if __name__ == '__main__':
Expand Down
Loading