From 65cc1121274ab1e9d4ad124833558a3684d27c83 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Fri, 22 Aug 2025 23:17:44 +0200 Subject: [PATCH 1/2] Multipass sample rendering --- .../ProblemPlasTeX/ProblemsetMacros.py | 95 ++++++++++++++----- problemtools/statement_util.py | 32 +++++-- .../templates/html/Themes/default/problem.css | 4 + .../templates/html/problemsetmacros.zpts | 36 ++++++- problemtools/tex2html.py | 5 + problemtools/verifyproblem.py | 8 +- 6 files changed, 139 insertions(+), 41 deletions(-) diff --git a/problemtools/ProblemPlasTeX/ProblemsetMacros.py b/problemtools/ProblemPlasTeX/ProblemsetMacros.py index b5bd4f41..a3b3ef33 100644 --- a/problemtools/ProblemPlasTeX/ProblemsetMacros.py +++ b/problemtools/ProblemPlasTeX/ProblemsetMacros.py @@ -1,6 +1,7 @@ import sys import os import os.path +from pathlib import Path from plasTeX.DOM import Node from plasTeX.Base import Command from plasTeX.Base import DimenCommand @@ -64,35 +65,83 @@ def invoke(self, tex): class sampletableinteractive(Command): args = 'header read write file:str' - def read_sample_interaction(self, filename): - data = open(filename, 'r', encoding='utf-8').read() - messages = [] - cur_msg: list[str] = [] - cur_mode = None - for line in data.split('\n'): - if not line: - continue - if line[0] == '<': - mode = 'read' - elif line[0] == '>': - mode = 'write' + def split_multipass(self, lines: list[str]) -> list[list[str]]: + multipass_passes = [] + curr_pass: list[str] = [] + for line in lines: + if line.startswith('---'): + multipass_passes.append(curr_pass) + curr_pass = [] else: - continue - line = line[1:] - if mode != cur_mode: - if cur_mode: - messages.append({'mode': cur_mode, 'data': '\n'.join(cur_msg)}) - cur_msg = [] - cur_msg.append(line) - cur_mode = mode - if cur_mode: - messages.append({'mode': cur_mode, 'data': '\n'.join(cur_msg)}) + curr_pass.append(line) + + if curr_pass: + multipass_passes.append(curr_pass) + return multipass_passes + + def format_pass_content(self, block: list[str]) -> list[dict]: + sections = [] + + if self.attributes['is_interactive']: + cur_msg: list[str] = [] + cur_mode = None + + def format_message(cur_mode: str, cur_msg: list[str]) -> dict: + return {'mode': cur_mode, 'data': ''.join(cur_msg)} + + for line in block: + if not line: + continue + if line[0] not in ('<', '>'): + log.warning(f'Interaction had unknown prefix {line[0]}') + continue + + if line[0] == '<': + mode = 'read' + elif line[0] == '>': + mode = 'write' + + if mode != cur_mode: + if cur_mode: + sections.append(format_message(cur_mode, cur_msg)) + cur_msg = [] + cur_mode = mode + cur_msg.append(line[1:]) + if cur_mode: + sections.append(format_message(cur_mode, cur_msg)) + else: + in_data = ''.join(line[1:] for line in block if line[0] == '>') + out_data = ''.join(line[1:] for line in block if line[0] == '<') + sections.append({'mode': 'batch_sample', 'in_data': in_data, 'out_data': out_data}) + return sections + + def read_sample_interaction(self, filename: Path) -> list[dict]: + with open(filename, 'r', encoding='utf-8') as f: + data = self.split_multipass(f.readlines()) + + messages = [] + for index, block in enumerate(data): + if self.ownerDocument and self.ownerDocument.userdata['is_multi_pass']: + messages.append({'mode': 'newpass', 'data': str(index + 1)}) + messages.extend(self.format_pass_content(block)) return messages def invoke(self, tex): super().invoke(tex) dir = os.path.dirname(tex.filename) - file = os.path.join(dir, self.attributes['file']) + file = Path(dir) / self.attributes['file'] + if self.ownerDocument: + self.attributes['is_multi_pass'] = self.ownerDocument.userdata['is_multi_pass'] + self.attributes['is_interactive'] = self.ownerDocument.userdata['is_interactive'] + else: # Don't think this can happen, but let's make mypy happy + self.attributes['is_multi_pass'] = False + self.attributes['is_interactive'] = False + + if not self.attributes['is_interactive']: + self.attributes['read'] = 'Sample Input' + self.attributes['write'] = 'Sample Output' + self.attributes['header'] = f'Sample Case {self.attributes["header"][2]}' + try: status.info(' ( sampletableinteractive %s ' % file) self.attributes['messages'] = self.read_sample_interaction(file) diff --git a/problemtools/statement_util.py b/problemtools/statement_util.py index 9eeb1d27..46eda650 100644 --- a/problemtools/statement_util.py +++ b/problemtools/statement_util.py @@ -282,17 +282,33 @@ def make_pass_header(curr_pass: int) -> str: def format_pass_content(content: list[str]) -> str: block = [] if is_interactive: - for interaction in content: - line_type = '' - if interaction[0] == '>': + message_type = '$' + message: list[str] = [] + + def format_message(message_type: str, message: list[str]) -> str: + if message_type == '>': line_type = 'sampleinteractionwrite' - elif interaction[0] == '<': + elif message_type == '<': line_type = 'sampleinteractionread' - else: + return f'
{"".join(message)}
' + + for interaction in content: + if len(interaction) == 0: + continue + if interaction[0] not in ('<', '>'): log.warning(f'Interaction had unknown prefix {interaction[0]}') + continue + + if interaction[0] != message_type and message_type != '$': + block.append(format_message(message_type, message)) + message = [] + + message_type = interaction[0] data = html.escape(interaction[1:]) + message.append(data) - block.append(f'
{data}
') + if message: + block.append(format_message(message_type, message)) else: input_lines = [html.escape(line[1:]) for line in content if line.startswith('<')] output_lines = [html.escape(line[1:]) for line in content if line.startswith('>')] @@ -315,8 +331,8 @@ def format_pass_content(content: list[str]) -> str: if interaction.startswith('---'): passes.append(curr_pass) curr_pass = [] - continue - curr_pass.append(interaction) + else: + curr_pass.append(interaction) if len(curr_pass): passes.append(curr_pass) diff --git a/problemtools/templates/html/Themes/default/problem.css b/problemtools/templates/html/Themes/default/problem.css index 3ce6869d..ed02ab78 100644 --- a/problemtools/templates/html/Themes/default/problem.css +++ b/problemtools/templates/html/Themes/default/problem.css @@ -63,6 +63,10 @@ table.sample th { width: 50%; } +table.sample pre { + margin: 0; +} + table.sample td { border: 1px solid black; } diff --git a/problemtools/templates/html/problemsetmacros.zpts b/problemtools/templates/html/problemsetmacros.zpts index 9eaee400..2a059d95 100644 --- a/problemtools/templates/html/problemsetmacros.zpts +++ b/problemtools/templates/html/problemsetmacros.zpts @@ -24,17 +24,47 @@ name: sampletable name: sampletableinteractive - + - +
+ + -
+ + + + + + + +
+
+ +

    
+ +
+ + + + + + +
+

+            
+

+            
+
+ + name: illustration
diff --git a/problemtools/tex2html.py b/problemtools/tex2html.py index 571a7b4d..873a53b3 100644 --- a/problemtools/tex2html.py +++ b/problemtools/tex2html.py @@ -4,6 +4,7 @@ import argparse from pathlib import Path +from . import metadata from . import template @@ -32,6 +33,10 @@ def convert(problem_root: Path, options: argparse.Namespace, statement_file: Pat ProblemsetMacros.init(tex) + problem_metadata, _ = metadata.load_metadata(problem_root) + tex.ownerDocument.userdata['is_multi_pass'] = problem_metadata.is_multi_pass() + tex.ownerDocument.userdata['is_interactive'] = problem_metadata.is_interactive() + tex.ownerDocument.config['general']['copy-theme-extras'] = options.css if not options.headers: tex.ownerDocument.userdata['noheaders'] = True diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 99b66b2e..0e40a5f2 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1444,13 +1444,7 @@ def _parse_validator_results(self, val, status: int, feedbackdir, testcase: Test except Exception as e: return SubmissionResult('JE', reason=f'failed to parse validator score: {e}') else: - # If we're running multipass, we do not need to output a score after every pass - # We accept the small risk of allowing a non-multipass output validator to not output score.txt - # if it produces a file called nextpass.in - if (Path(feedbackdir) / 'nextpass.in').exists(): - score = 0 - else: - return SubmissionResult('JE', reason='problem has custom scoring but validator did not produce "score.txt"') + return SubmissionResult('JE', reason='problem has custom scoring but validator did not produce "score.txt"') return SubmissionResult('AC', score=score) From 01c392065e9fd760091e5cd1e97aaa7c1161da2c Mon Sep 17 00:00:00 2001 From: Matistjati Date: Fri, 22 Aug 2025 23:46:05 +0200 Subject: [PATCH 2/2] Figure out if we are multipass locally --- problemtools/ProblemPlasTeX/ProblemsetMacros.py | 15 ++++++++------- .../templates/html/Themes/default/problem.css | 1 + problemtools/templates/markdown_html/problem.css | 1 + problemtools/tex2html.py | 5 ----- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/problemtools/ProblemPlasTeX/ProblemsetMacros.py b/problemtools/ProblemPlasTeX/ProblemsetMacros.py index a3b3ef33..ce73b29f 100644 --- a/problemtools/ProblemPlasTeX/ProblemsetMacros.py +++ b/problemtools/ProblemPlasTeX/ProblemsetMacros.py @@ -7,6 +7,8 @@ from plasTeX.Base import DimenCommand from plasTeX.Logging import getLogger +from problemtools import metadata + log = getLogger() status = getLogger('status') @@ -121,7 +123,7 @@ def read_sample_interaction(self, filename: Path) -> list[dict]: messages = [] for index, block in enumerate(data): - if self.ownerDocument and self.ownerDocument.userdata['is_multi_pass']: + if self.attributes['is_multi_pass']: messages.append({'mode': 'newpass', 'data': str(index + 1)}) messages.extend(self.format_pass_content(block)) return messages @@ -130,12 +132,11 @@ def invoke(self, tex): super().invoke(tex) dir = os.path.dirname(tex.filename) file = Path(dir) / self.attributes['file'] - if self.ownerDocument: - self.attributes['is_multi_pass'] = self.ownerDocument.userdata['is_multi_pass'] - self.attributes['is_interactive'] = self.ownerDocument.userdata['is_interactive'] - else: # Don't think this can happen, but let's make mypy happy - self.attributes['is_multi_pass'] = False - self.attributes['is_interactive'] = False + # A slightly messy way of finding out whether we're multipass and/or interactive + problem_root = file.parent.parent.parent + problem_metadata, _ = metadata.load_metadata(problem_root) + self.attributes['is_multi_pass'] = problem_metadata.is_multi_pass() + self.attributes['is_interactive'] = problem_metadata.is_interactive() if not self.attributes['is_interactive']: self.attributes['read'] = 'Sample Input' diff --git a/problemtools/templates/html/Themes/default/problem.css b/problemtools/templates/html/Themes/default/problem.css index ed02ab78..81b60664 100644 --- a/problemtools/templates/html/Themes/default/problem.css +++ b/problemtools/templates/html/Themes/default/problem.css @@ -69,6 +69,7 @@ table.sample pre { table.sample td { border: 1px solid black; + width: 50%; } div.sampleinteractionread { diff --git a/problemtools/templates/markdown_html/problem.css b/problemtools/templates/markdown_html/problem.css index 8fa307b5..0cadfe83 100644 --- a/problemtools/templates/markdown_html/problem.css +++ b/problemtools/templates/markdown_html/problem.css @@ -55,6 +55,7 @@ table:not(.sample) td { .sample td { vertical-align: top; border: 1px solid black; + width: 50%; } .sample pre { diff --git a/problemtools/tex2html.py b/problemtools/tex2html.py index 873a53b3..571a7b4d 100644 --- a/problemtools/tex2html.py +++ b/problemtools/tex2html.py @@ -4,7 +4,6 @@ import argparse from pathlib import Path -from . import metadata from . import template @@ -33,10 +32,6 @@ def convert(problem_root: Path, options: argparse.Namespace, statement_file: Pat ProblemsetMacros.init(tex) - problem_metadata, _ = metadata.load_metadata(problem_root) - tex.ownerDocument.userdata['is_multi_pass'] = problem_metadata.is_multi_pass() - tex.ownerDocument.userdata['is_interactive'] = problem_metadata.is_interactive() - tex.ownerDocument.config['general']['copy-theme-extras'] = options.css if not options.headers: tex.ownerDocument.userdata['noheaders'] = True