From cb3ea100392775314e263b3e9c89b91b4db6088d Mon Sep 17 00:00:00 2001 From: matistjati Date: Thu, 8 Aug 2024 00:54:41 +0200 Subject: [PATCH 01/53] Add markdown support --- examples/README.md | 5 + examples/bplusa/data/sample/1.ans | 1 + examples/bplusa/data/sample/1.in | 1 + examples/bplusa/data/secret/1.ans | 1 + examples/bplusa/data/secret/1.in | 1 + examples/bplusa/data/secret/2.ans | 1 + examples/bplusa/data/secret/2.in | 1 + examples/bplusa/data/secret/3.ans | 1 + examples/bplusa/data/secret/3.in | 1 + .../input_validators/validator/validator.cpp | 8 + .../input_validators}/validator/validator.h | 0 .../output_validators/validator/validate.cc | 64 ++++ .../output_validators/validator/validate.h | 153 ++++++++ examples/bplusa/problem.yaml | 4 + .../bplusa/problem_statement/problem.en.md | 8 + .../bplusa/submissions/accepted/cplus1.cpp | 10 + examples/bplusa/submissions/accepted/zero.cpp | 10 + .../validator/validator.cpp | 0 .../input_validators/validator/validator.h | 356 ++++++++++++++++++ problemtools/md2html.py | 162 ++++++++ problemtools/problem2html.py | 98 +++-- problemtools/problem2pdf.py | 6 +- .../templates/markdown/default-layout.html | 35 ++ problemtools/templates/markdown/problem.css | 90 +++++ problemtools/tex2html.py | 67 ++++ problemtools/verifyproblem.py | 53 +-- 26 files changed, 1056 insertions(+), 81 deletions(-) create mode 100644 examples/bplusa/data/sample/1.ans create mode 100644 examples/bplusa/data/sample/1.in create mode 100644 examples/bplusa/data/secret/1.ans create mode 100644 examples/bplusa/data/secret/1.in create mode 100644 examples/bplusa/data/secret/2.ans create mode 100644 examples/bplusa/data/secret/2.in create mode 100644 examples/bplusa/data/secret/3.ans create mode 100644 examples/bplusa/data/secret/3.in create mode 100644 examples/bplusa/input_validators/validator/validator.cpp rename examples/{oddecho/input_format_validators => bplusa/input_validators}/validator/validator.h (100%) create mode 100644 examples/bplusa/output_validators/validator/validate.cc create mode 100644 examples/bplusa/output_validators/validator/validate.h create mode 100644 examples/bplusa/problem.yaml create mode 100644 examples/bplusa/problem_statement/problem.en.md create mode 100644 examples/bplusa/submissions/accepted/cplus1.cpp create mode 100644 examples/bplusa/submissions/accepted/zero.cpp rename examples/oddecho/{input_format_validators => input_validators}/validator/validator.cpp (100%) create mode 100644 examples/oddecho/input_validators/validator/validator.h create mode 100644 problemtools/md2html.py create mode 100644 problemtools/templates/markdown/default-layout.html create mode 100644 problemtools/templates/markdown/problem.css create mode 100644 problemtools/tex2html.py diff --git a/examples/README.md b/examples/README.md index 2f6107a3..9665f8a7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -25,3 +25,8 @@ more than one language. 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. + +# bplusa + +This is an example of a problem using an output validator, showcasing different things to keep in mind +when using output validator. It also demonstrates using Markdown as a statement language. diff --git a/examples/bplusa/data/sample/1.ans b/examples/bplusa/data/sample/1.ans new file mode 100644 index 00000000..654d5269 --- /dev/null +++ b/examples/bplusa/data/sample/1.ans @@ -0,0 +1 @@ +2 3 diff --git a/examples/bplusa/data/sample/1.in b/examples/bplusa/data/sample/1.in new file mode 100644 index 00000000..7ed6ff82 --- /dev/null +++ b/examples/bplusa/data/sample/1.in @@ -0,0 +1 @@ +5 diff --git a/examples/bplusa/data/secret/1.ans b/examples/bplusa/data/secret/1.ans new file mode 100644 index 00000000..1790e253 --- /dev/null +++ b/examples/bplusa/data/secret/1.ans @@ -0,0 +1 @@ +123 0 diff --git a/examples/bplusa/data/secret/1.in b/examples/bplusa/data/secret/1.in new file mode 100644 index 00000000..190a1803 --- /dev/null +++ b/examples/bplusa/data/secret/1.in @@ -0,0 +1 @@ +123 diff --git a/examples/bplusa/data/secret/2.ans b/examples/bplusa/data/secret/2.ans new file mode 100644 index 00000000..93fd4034 --- /dev/null +++ b/examples/bplusa/data/secret/2.ans @@ -0,0 +1 @@ +992 0 diff --git a/examples/bplusa/data/secret/2.in b/examples/bplusa/data/secret/2.in new file mode 100644 index 00000000..7f9d7e97 --- /dev/null +++ b/examples/bplusa/data/secret/2.in @@ -0,0 +1 @@ +992 diff --git a/examples/bplusa/data/secret/3.ans b/examples/bplusa/data/secret/3.ans new file mode 100644 index 00000000..80c0cc79 --- /dev/null +++ b/examples/bplusa/data/secret/3.ans @@ -0,0 +1 @@ +1 0 diff --git a/examples/bplusa/data/secret/3.in b/examples/bplusa/data/secret/3.in new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/bplusa/data/secret/3.in @@ -0,0 +1 @@ +1 diff --git a/examples/bplusa/input_validators/validator/validator.cpp b/examples/bplusa/input_validators/validator/validator.cpp new file mode 100644 index 00000000..0ecff521 --- /dev/null +++ b/examples/bplusa/input_validators/validator/validator.cpp @@ -0,0 +1,8 @@ +#include "validator.h" + + +void run() { + Int(1, 1000); + Endl(); + Eof(); +} diff --git a/examples/oddecho/input_format_validators/validator/validator.h b/examples/bplusa/input_validators/validator/validator.h similarity index 100% rename from examples/oddecho/input_format_validators/validator/validator.h rename to examples/bplusa/input_validators/validator/validator.h diff --git a/examples/bplusa/output_validators/validator/validate.cc b/examples/bplusa/output_validators/validator/validate.cc new file mode 100644 index 00000000..61eabfc2 --- /dev/null +++ b/examples/bplusa/output_validators/validator/validate.cc @@ -0,0 +1,64 @@ +#include "validate.h" + +#include +using namespace std; + +#define rep(i, a, b) for(int i = a; i < (b); ++i) +#define all(x) begin(x), end(x) +#define sz(x) (int)(x).size() +typedef long long ll; +typedef pair pii; +typedef vector vi; +typedef vector vvi; +typedef long double ld; + +#define repe(i, container) for (auto& i : container) + +void check_isvalid(int a, int b, int c, feedback_function feedback) +{ + if (a==b) feedback("a is equal to b"); + if (a+b!=c) feedback("b+a!=c"); +} + +const int HUNDRED_THOUSAND = int(1e5); +int main(int argc, char **argv) { + init_io(argc, argv); + + // Read the testcase input + int c; + judge_in >> c; + + auto check = [&](istream& sol, feedback_function feedback) { + int a, b; + // Don't get stuck waiting for output from solution + if(!(sol >> a >> b)) feedback("Expected more output"); + // Validate constraints + if (a < -HUNDRED_THOUSAND || a > HUNDRED_THOUSAND) feedback("a is too big or large"); + if (b < -HUNDRED_THOUSAND || b > HUNDRED_THOUSAND) feedback("b is too big or large"); + + // Check that they actually solved the task + check_isvalid(a, b, c, feedback); + + // Disallow trailing output + string trailing; + if(sol >> trailing) feedback("Trailing output"); + return true; + }; + + // Check both the judge's and contestants' output + // It is good practice to not assume that the judge is correct/optimal + bool judge_found_sol = check(judge_ans, judge_error); + bool author_found_sol = check(author_out, wrong_answer); + + // In this problem, having a return value from check is unnecessary + // However, if there isn't always a solution, we will get a nice + // judge error if the judge solution claims no solution exists, while + // a contestant finds one + if(!judge_found_sol) + judge_error("NO! Judge did not find valid solution"); + + if(!author_found_sol) + wrong_answer("Contestant did not find valid solution"); + + accept(); +} diff --git a/examples/bplusa/output_validators/validator/validate.h b/examples/bplusa/output_validators/validator/validate.h new file mode 100644 index 00000000..c59c5fdb --- /dev/null +++ b/examples/bplusa/output_validators/validator/validate.h @@ -0,0 +1,153 @@ +/* Utility functions for writing output validators for the Kattis + * problem format. + * + * The primary functions and variables available are the following. + * In many cases, the only functions needed are "init_io", + * "wrong_answer", and "accept". + * + * - init_io(argc, argv): + * initialization + * + * - judge_in, judge_ans, author_out: + * std::istream objects for judge input file, judge answer + * file, and submission output file. + * + * - accept(): + * exit and give Accepted! + * + * - accept_with_score(double score): + * exit with Accepted and give a score (for scoring problems) + * + * - judge_message(std::string msg, ...): + * printf-style function for emitting a judge message (a + * message that gets displayed to a privileged user with access + * to secret data etc). + * + * - wrong_answer(std::string msg, ...): + * printf-style function for exitting and giving Wrong Answer, + * and emitting a judge message (which would typically explain + * the cause of the Wrong Answer) + * + * - judge_error(std::string msg, ...): + * printf-style function for exitting and giving Judge Error, + * and emitting a judge message (which would typically explain + * the cause of the Judge Error) + * + * - author_message(std::string msg, ...): + * printf-style function for emitting an author message (a + * message that gets displayed to the author of the + * submission). (Use with caution, and be careful not to let + * it leak information!) + * + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +typedef void (*feedback_function)(const char*, ...); + +const int EXITCODE_AC = 42; +const int EXITCODE_WA = 43; +const char* FILENAME_AUTHOR_MESSAGE = "teammessage.txt"; +const char* FILENAME_JUDGE_MESSAGE = "judgemessage.txt"; +const char* FILENAME_JUDGE_ERROR = "judgeerror.txt"; +const char* FILENAME_SCORE = "score.txt"; + +#define USAGE "%s: judge_in judge_ans feedback_dir < author_out\n" + +std::ifstream judge_in, judge_ans; +std::istream author_out(std::cin.rdbuf()); + +char *feedbackdir = NULL; + +void vreport_feedback(const char* category, + const char* msg, + va_list pvar) { + std::ostringstream fname; + if (feedbackdir) + fname << feedbackdir << '/'; + fname << category; + FILE *f = fopen(fname.str().c_str(), "a"); + assert(f); + vfprintf(f, msg, pvar); + fclose(f); +} + +void report_feedback(const char* category, const char* msg, ...) { + va_list pvar; + va_start(pvar, msg); + vreport_feedback(category, msg, pvar); +} + +void author_message(const char* msg, ...) { + va_list pvar; + va_start(pvar, msg); + vreport_feedback(FILENAME_AUTHOR_MESSAGE, msg, pvar); +} + +void judge_message(const char* msg, ...) { + va_list pvar; + va_start(pvar, msg); + vreport_feedback(FILENAME_JUDGE_MESSAGE, msg, pvar); +} + +void wrong_answer(const char* msg, ...) { + va_list pvar; + va_start(pvar, msg); + vreport_feedback(FILENAME_JUDGE_MESSAGE, msg, pvar); + exit(EXITCODE_WA); +} + +void judge_error(const char* msg, ...) { + va_list pvar; + va_start(pvar, msg); + vreport_feedback(FILENAME_JUDGE_ERROR, msg, pvar); + assert(0); +} + +void accept() { + exit(EXITCODE_AC); +} + +void accept_with_score(double scorevalue) { + report_feedback(FILENAME_SCORE, "%.9le", scorevalue); + exit(EXITCODE_AC); +} + + +bool is_directory(const char *path) { + struct stat entry; + return stat(path, &entry) == 0 && S_ISDIR(entry.st_mode); +} + +void init_io(int argc, char **argv) { + if(argc < 4) { + fprintf(stderr, USAGE, argv[0]); + judge_error("Usage: %s judgein judgeans feedbackdir [opts] < userout", argv[0]); + } + + // Set up feedbackdir first, as that allows us to produce feedback + // files for errors in the other parameters. + if (!is_directory(argv[3])) { + judge_error("%s: %s is not a directory\n", argv[0], argv[3]); + } + feedbackdir = argv[3]; + + judge_in.open(argv[1], std::ios_base::in); + if (judge_in.fail()) { + judge_error("%s: failed to open %s\n", argv[0], argv[1]); + } + + judge_ans.open(argv[2], std::ios_base::in); + if (judge_ans.fail()) { + judge_error("%s: failed to open %s\n", argv[0], argv[2]); + } + + author_out.rdbuf(std::cin.rdbuf()); +} diff --git a/examples/bplusa/problem.yaml b/examples/bplusa/problem.yaml new file mode 100644 index 00000000..d59b82ec --- /dev/null +++ b/examples/bplusa/problem.yaml @@ -0,0 +1,4 @@ +source: Kattis +license: public domain +name: B plus A +validation: custom diff --git a/examples/bplusa/problem_statement/problem.en.md b/examples/bplusa/problem_statement/problem.en.md new file mode 100644 index 00000000..d5060a86 --- /dev/null +++ b/examples/bplusa/problem_statement/problem.en.md @@ -0,0 +1,8 @@ +Given the integer $c$, find any pair of integers $b$ and $a$ satisfying $b+a=c$ and $a \neq b$. + +## Input +Input consists of the integer $C$ ($1 \le C \le 1000$). + +## Output +Output $b$ and $a$, separated by a space. Any $b$, $a$ satisfying above constraints and $-10^5 \leq a,b \leq 10^5$ +will be accepted. diff --git a/examples/bplusa/submissions/accepted/cplus1.cpp b/examples/bplusa/submissions/accepted/cplus1.cpp new file mode 100644 index 00000000..946facb7 --- /dev/null +++ b/examples/bplusa/submissions/accepted/cplus1.cpp @@ -0,0 +1,10 @@ +#include +using namespace std; + +int main() +{ + int c; + cin >> c; + cout << c+1 << " " << -1 << endl; + return 0; +} diff --git a/examples/bplusa/submissions/accepted/zero.cpp b/examples/bplusa/submissions/accepted/zero.cpp new file mode 100644 index 00000000..2f4c748a --- /dev/null +++ b/examples/bplusa/submissions/accepted/zero.cpp @@ -0,0 +1,10 @@ +#include +using namespace std; + +int main() +{ + int c; + cin >> c; + cout << c << " " << 0 << endl; + return 0; +} 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_validators/validator/validator.h b/examples/oddecho/input_validators/validator/validator.h new file mode 100644 index 00000000..f42bc2d7 --- /dev/null +++ b/examples/oddecho/input_validators/validator/validator.h @@ -0,0 +1,356 @@ +#ifdef NDEBUG +#error Asserts must be enabled! Do not set NDEBUG. +#endif +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +using namespace std; + +// Implemented by you! +void run(); + +// PUBLIC API +// (extend if you need to) + +[[noreturn]] +void die(const string& msg); +[[noreturn]] +void die_line(const string& msg); + +struct ArgType { + string _name, _x; + ArgType(const string& name, const string& x) : _name(name), _x(x) {} + operator string() const { return _x; } + operator long long() const; + operator bool() const; + operator int() const; +}; + +struct IntType { + long long _x; + IntType(long long x) : _x(x) {} + operator long long() const { return _x; } + operator int() const; + operator bool() const; +}; + +ArgType Arg(const string& name); + +ArgType Arg(const string& name, long long _default); + +string Arg(const string& name, const string& _default); + +template +void AssertUnique(const Vec& v); + +namespace IO { + IntType Int(long long lo, long long hi); + double Float(double lo, double hi, bool strict = true); + template + vector SpacedInts(long long count, T lo, T hi); + vector SpacedFloats(long long count, double lo, double hi); + void Char(char expected); + char Char(); + string Line(); + void Endl() { Char('\n'); } + void Space() { Char(' '); } + void Eof() { Char(-1); } +}; +using namespace IO; + +// INTERNALS + +bool _validator_initialized; +struct _validator { + map params; + set used_params; + + void construct(int argc, char** argv) { + _validator_initialized = true; + for (int i = 1; i < argc; i++) { + string s = argv[i]; + size_t ind = s.find('='); + if (ind == string::npos) continue; + auto before = s.substr(0, ind), after = s.substr(ind + 1); + if (params.count(before)) + die("Duplicate parameter " + before); + params[before] = after; + } + } + + void destroy() { + assert(_validator_initialized); + if (!params.empty()) { + string name = params.begin()->first; + die("Unused parameter " + name); + } + IO::Eof(); + _Exit(42); + } + + bool has_var(const string& name) { + if (!_validator_initialized) die("Must not read variables before main"); + return params.count(name) || used_params.count(name); + } + + string get_var(const string& name) { + if (!_validator_initialized) die("Must not read variables before main"); + if (used_params.count(name)) die("Must not read parameter " + name + " twice (either typo or slow)"); + if (!params.count(name)) die("No parameter " + name); + string res = params.at(name); + params.erase(name); + used_params.insert(name); + return res; + } +} _validator_inst; + +void die(const string& msg) { + cerr << msg << endl; + ofstream fout("/tmp/input_validator_msg", ios::app); + fout << msg << endl; + fout.close(); + _Exit(43); +} + +ArgType::operator long long() const { + string dummy; + { + long long num; + istringstream iss(_x); + iss >> num; + if (iss && !(iss >> dummy)) return num; + } + { + // We also allow scientific notation, for clarity + long double num; + istringstream iss(_x); + iss >> num; + if (iss && !(iss >> dummy)) return (long long)num; + } + die("Unable to parse value " + _x + " for parameter " + _name); +} + +ArgType::operator int() const { + long long val = (long long)*this; + if (val < INT_MIN || val > INT_MAX) + die("number " + to_string(val) + " is too large for an int for parameter " + _name); + return (int)val; +} + +ArgType::operator bool() const { + long long val = (long long)*this; + if (val < 0 || val > 1) + die("number " + to_string(val) + " is not boolean (0/1), for parameter " + _name); + return (bool)val; +} + +IntType::operator int() const { + long long val = (long long)*this; + if (val < INT_MIN || val > INT_MAX) + die_line("number " + to_string(val) + " is too large for an int"); + return (int)val; +} + +IntType::operator bool() const { + long long val = (long long)*this; + if (val < 0 || val > 1) + die_line("number " + to_string(val) + " is not boolean (0/1)"); + return (bool)val; +} + +ArgType Arg(const string& name) { + return {name, _validator_inst.get_var(name)}; +} + +ArgType Arg(const string& name, long long _default) { + if (!_validator_inst.has_var(name)) + return {name, to_string(_default)}; + ArgType ret = Arg(name); + (void)(long long)ret; + return ret; +} + +string Arg(const string& name, const string& _default) { + if (!_validator_inst.has_var(name)) + return _default; + return (string)Arg(name); +} + +static int _lineno = 1, _consumed_lineno = -1, _hit_char_error = 0; +char _peek1(); +void die_line(const string& msg) { + if (!_hit_char_error && _peek1() == -1) die(msg); + else if (_consumed_lineno == -1) die(msg + " (before reading any input)"); + else die(msg + " on line " + to_string(_consumed_lineno)); +} + +static char _buffer = -2; // -2 = none, -1 = eof, other = that char +char _peek1() { + if (_buffer != -2) return _buffer; + int val = getchar_unlocked(); + static_assert(EOF == -1, ""); + static_assert(CHAR_MIN == -128, ""); + if (val == -2 || val < CHAR_MIN || val >= CHAR_MAX) { + _hit_char_error = 1; + die_line("Unable to process byte " + to_string(val)); + } + _buffer = (char)val; + return _buffer; +} +void _use_peek(char ch) { + _buffer = -2; + if (ch == '\n') _lineno++; + else _consumed_lineno = _lineno; +} +char _read1() { + char ret = _peek1(); + _use_peek(ret); + return ret; +} +string _token() { + string ret; + for (;;) { + char ch = _peek1(); + if (ch == ' ' || ch == '\n' || ch == -1) { + break; + } + _use_peek(ch); + ret += ch; + } + return ret; +} +string _describe(char ch) { + assert(ch != -2); + if (ch == -1) return "EOF"; + if (ch == ' ') return "SPACE"; + if (ch == '\r') return "CARRIAGE RETURN"; + if (ch == '\n') return "NEWLINE"; + if (ch == '\t') return "TAB"; + if (ch == '\'') return "\"'\""; + return string("'") + ch + "'"; +} + +IntType IO::Int(long long lo, long long hi) { + string s = _token(); + if (s.empty()) die_line("Expected number, saw " + _describe(_peek1())); + try { + long long mul = 1; + int ind = 0; + if (s[0] == '-') { + mul = -1; + ind = 1; + } + if (ind == (int)s.size()) throw false; + char ch = s[ind++]; + if (ch < '0' || ch > '9') throw false; + if (ch == '0' && ind != (int)s.size()) throw false; + long long ret = ch - '0'; + while (ind < (int)s.size()) { + if (ret > LLONG_MAX / 10 - 20 || ret < LLONG_MIN / 10 + 20) + throw false; + ret *= 10; + ch = s[ind++]; + if (ch < '0' || ch > '9') throw false; + ret += ch - '0'; + } + ret *= mul; + if (ret < lo || ret > hi) die_line("Number " + s + " is out of range [" + to_string(lo) + ", " + to_string(hi) + "]"); + return {ret}; + } catch (bool) { + die_line("Unable to parse \"" + s + "\" as integer"); + } +} + +template +vector IO::SpacedInts(long long count, T lo, T hi) { + vector res; + res.reserve(count); + for (int i = 0; i < count; i++) { + if (i != 0) IO::Space(); + res.emplace_back((T)IO::Int(lo, hi)); + } + IO::Endl(); + return res; +} + +vector IO::SpacedFloats(long long count, double lo, double hi) { + vector res; + res.reserve(count); + for (int i = 0; i < count; i++) { + if (i != 0) IO::Space(); + res.emplace_back(IO::Float(lo, hi)); + } + IO::Endl(); + return res; +} + +double IO::Float(double lo, double hi, bool strict) { + string s = _token(); + if (s.empty()) die_line("Expected floating point number, saw " + _describe(_peek1())); + istringstream iss(s); + double res; + string dummy; + iss >> res; + if (!iss || iss >> dummy) die_line("Unable to parse " + s + " as a float"); + if (res < lo || res > hi) die_line("Floating-point number " + s + " is out of range [" + to_string(lo) + ", " + to_string(hi) + "]"); + if (res != res) die_line("Floating-point number " + s + " is NaN"); + if (strict) { + if (s.find('.') != string::npos && s.back() == '0' && s.substr(s.size() - 2) != ".0") + die_line("Number " + s + " has unnecessary trailing zeroes"); + if (s[0] == '0' && s.size() > 1 && s[1] == '0') + die_line("Number " + s + " has unnecessary leading zeroes"); + } + return res; +} + +char IO::Char() { + char ret = _read1(); + if (ret == -1) die_line("Expected character, saw EOF"); + return ret; +} + +void IO::Char(char expected) { + char ret = _peek1(); + if (ret != expected) die_line("Expected " + _describe(expected) + ", saw " + _describe(ret)); + _use_peek(ret); +} + +string IO::Line() { + string ret; + for (;;) { + char ch = IO::Char(); + if (ch == '\n') break; + ret += ch; + } + return ret; +} + +template +void AssertUnique(const Vec& v_) { + Vec v = v_; + auto beg = v.begin(), end = v.end(); + sort(beg, end); + int size = (int)(end - beg); + for (int i = 0; i < size - 1; i++) { + if (v[i] == v[i+1]) { + ostringstream oss; + oss << "Vector contains duplicate value " << v[i]; + die_line(oss.str()); + } + } +} + +int main(int argc, char** argv) { + _validator_inst.construct(argc, argv); + run(); + _validator_inst.destroy(); +} + diff --git a/problemtools/md2html.py b/problemtools/md2html.py new file mode 100644 index 00000000..63b1f6f4 --- /dev/null +++ b/problemtools/md2html.py @@ -0,0 +1,162 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- +import html +import os.path +import string +import argparse +from typing import Optional + +import markdown +from markdown.inlinepatterns import InlineProcessor +from markdown.extensions import Extension +import xml.etree.ElementTree as etree + +from . import verifyproblem +from . import problem2html + +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 + + +def _get_problem_name(problem: str, language: str = "en") -> Optional[str]: + """Load problem.yaml to get problem name""" + with verifyproblem.Problem(problem) as prob: + config = verifyproblem.ProblemConfig(prob) + if not config.check(None): + print("Please add problem name to problem.yaml when using markdown") + return None + 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}") + return names[language] + + +def _samples_to_html(problem: str) -> str: + """Read all samples from the problem directory and convert them to HTML""" + samples_html = "" + sample_path = os.path.join(problem, "data", "sample") + interactive_samples = [] + samples = [] + casenum = 1 + for sample in sorted(os.listdir(sample_path)): + if sample.endswith(".interaction"): + lines = [""" + + + + + +
ReadSample Interaction {}Write
""".format(casenum)] + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: + sample_interaction = infile.readlines() + for interaction in sample_interaction: + data = interaction[1:] + 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}
""") + + interactive_samples.append(''.join(lines)) + 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 + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: + sample_input = infile.read() + with open(outpath, "r", encoding="utf-8") as outfile: + sample_output = outfile.read() + + samples.append(f""" + + Sample Input %(case)d + Sample Output %(case)d + + +
%(input)s
+
%(output)s
+ """ % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) + casenum += 1 + + if interactive_samples: + samples_html += ''.join(interactive_samples) + if samples: + samples_html += """ + + + %(samples)s + +
+ """ % {"samples": ''.join(samples)} + return samples_html + + +def convert(problem: str, options: argparse.Namespace) -> None: + problembase = os.path.splitext(os.path.basename(problem))[0] + destfile = string.Template(options.destfile).safe_substitute(problem=problembase) + + statement_path = problem2html._find_statement(problem, extension="md", language=options.language) + + if statement_path is None: + raise Exception('No markdown statement found') + + with open(statement_path, "r", encoding="utf-8") as input_file: + text = input_file.read() + statement_html = markdown.markdown(text, extensions=[InlineMathExtension(), "tables"]) + + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), + os.path.join(os.path.dirname(__file__), '../templates/markdown'), + '/usr/lib/problemtools/templates/markdown'] + 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 = _get_problem_name(problem) + + html_template = _substitute_template(templatepath, "default-layout.html", + statement_html=statement_html, + language=options.language, + title=problem_name or "Missing problem name", + problemid=problembase) + + html_template += _samples_to_html(problem) + + 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()) + + +class InlineMathProcessor(InlineProcessor): + def handleMatch(self, m, data): + el = etree.Element('span') + el.attrib['class'] = 'tex2jax_process' + el.text = "$" + m.group(1) + "$" + return el, m.start(0), m.end(0) + +class InlineMathExtension(Extension): + def extendMarkdown(self, md): + MATH_PATTERN = r'\$(.*?)\$' # like $1 + 2$ + md.inlinePatterns.register(InlineMathProcessor(MATH_PATTERN, md), 'inline-math', 200) \ No newline at end of file diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 6bf56192..f0224e89 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -6,65 +6,50 @@ import argparse import logging import subprocess +from typing import Optional + +from . import tex2html +from . import md2html + +SUPPORTED_EXTENSIONS = ("tex", "md") + +def _find_statement(problem: 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, f"problem_statement/problem.en.{extension}") + if os.path.isfile(statement_path): + return statement_path + statement_path = os.path.join(problem, f"problem_statement/problem.{extension}") + if os.path.isfile(statement_path): + return statement_path + return None + statement_path = os.path.join(problem, f"problem_statement/problem.{language}.{extension}") + if os.path.isfile(statement_path): + return statement_path + return None + + +def _find_statement_extension(problem: str, language: Optional[str]) -> str: + """Given a language, find whether the extension is tex or md""" + extensions = [] + for ext in SUPPORTED_EXTENSIONS: + if _find_statement(problem, 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'}") -from . import template 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) 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 +60,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 _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..ac119d05 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -8,11 +8,15 @@ from . import template -def convert(options: argparse.Namespace) -> bool: +def convert(options: argparse.Namespace, ignore_markdown: bool = False) -> bool: problem = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) + # We skip PDF check when verifying problems with markdown statements + if os.path.isfile(os.path.join(problem, "problem_statement", "problem.%s.md" % options.language)) and ignore_markdown: + return True + # Set up template if necessary with template.Template(problem, language=options.language) as templ: texfile = templ.get_file_name() diff --git a/problemtools/templates/markdown/default-layout.html b/problemtools/templates/markdown/default-layout.html new file mode 100644 index 00000000..a7177fc3 --- /dev/null +++ b/problemtools/templates/markdown/default-layout.html @@ -0,0 +1,35 @@ + + + + +%(title)s + + + + + + + + +
+

%(title)s

+

Problem ID: %(problemid)s

+
+
+ %(statement_html)s +
+ + + \ No newline at end of file diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown/problem.css new file mode 100644 index 00000000..20448219 --- /dev/null +++ b/problemtools/templates/markdown/problem.css @@ -0,0 +1,90 @@ +.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; +} + +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; +} + +table, table td { + border: 0; +} + +table.tabular p { + margin: 0; +} + +table.sample { + width: 100%; +} + +table.sample th { + text-align: left; + width: 50%; +} + +table.sample td { + border: 1px solid black; +} + +div.sampleinteractionread { + border: 1px solid black; + width: 60%; + float: left; + margin: 3px 0px 3px 0px; +} + +.sampleinteractionread pre { + margin: 1px 5px 1px 5px; +} + +div.sampleinteractionwrite { + border: 1px solid black; + width: 60%; + float: right; + margin: 3px 0px 3px 0px; +} + +.sampleinteractionwrite pre { + margin: 1px 5px 1px 5px; +} \ No newline at end of file diff --git a/problemtools/tex2html.py b/problemtools/tex2html.py new file mode 100644 index 00000000..8281f804 --- /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..1db4a6e6 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -1119,12 +1119,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 problem2html.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 +1134,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: @@ -1143,7 +1145,7 @@ def check(self, context: Context) -> bool: options.language = lang options.nopdf = True options.quiet = True - if not problem2pdf.convert(options): + if not problem2pdf.convert(options, ignore_markdown=True): langparam = f' --language {lang}' if lang != '' else '' self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf{langparam} on the problem to diagnose.') except Exception as e: @@ -1165,21 +1167,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 problem2html.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 From 868eb39d990a6ebeba8cebbfc22cd587a38ce978 Mon Sep 17 00:00:00 2001 From: matistjati Date: Thu, 8 Aug 2024 01:10:58 +0200 Subject: [PATCH 02/53] Added display math --- examples/README.md | 5 +++-- problemtools/md2html.py | 19 +++++++++++++++---- .../templates/markdown/default-layout.html | 3 ++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/examples/README.md b/examples/README.md index 9665f8a7..d646a86d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -28,5 +28,6 @@ different scores depending on which test groups they solve. It also demonstrates # bplusa -This is an example of a problem using an output validator, showcasing different things to keep in mind -when using output validator. It also demonstrates using Markdown as a statement language. +This is an example of a problem using an output validator, where there are multiple valid answers. +The output validator is written pretty generally, guarding against the most common mistakes when using +output validators. It also demonstrates using Markdown as a statement language. diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 63b1f6f4..1349b206 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -118,7 +118,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: with open(statement_path, "r", encoding="utf-8") as input_file: text = input_file.read() - statement_html = markdown.markdown(text, extensions=[InlineMathExtension(), "tables"]) + statement_html = markdown.markdown(text, extensions=[MathExtension(), "tables"]) templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -156,7 +156,18 @@ def handleMatch(self, m, data): el.text = "$" + m.group(1) + "$" return el, m.start(0), m.end(0) -class InlineMathExtension(Extension): +class DisplayMathProcessor(InlineProcessor): + def handleMatch(self, m, data): + el = etree.Element('div') + el.attrib['class'] = 'tex2jax_process' + el.text = "$$" + m.group(1) + "$$" + return el, m.start(0), m.end(0) + +class MathExtension(Extension): def extendMarkdown(self, md): - MATH_PATTERN = r'\$(.*?)\$' # like $1 + 2$ - md.inlinePatterns.register(InlineMathProcessor(MATH_PATTERN, md), 'inline-math', 200) \ No newline at end of file + # Regex magic so that both $ $ and $$ $$ can coexist + INLINE_MATH_PATTERN = r'(? Date: Thu, 8 Aug 2024 01:32:01 +0200 Subject: [PATCH 03/53] Add dependencies for markdown --- Dockerfile | 1 + README.md | 4 ++-- admin/docker/Dockerfile.minimal | 1 + debian/control | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index e9787418..daa50dde 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ RUN apt-get update && \ python3-minimal \ python3-pip \ python3-plastex \ + python3-markdown \ python3-yaml \ sudo \ texlive-fonts-recommended \ diff --git a/README.md b/README.md index 601de517..499fe610 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 python3-minimal python-pkg-resources python3-plastex python3-markdown python3-yaml 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++ python3 python3-pyyaml python3-markdown 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..a44811f5 100644 --- a/admin/docker/Dockerfile.minimal +++ b/admin/docker/Dockerfile.minimal @@ -24,6 +24,7 @@ RUN apt update && \ python3-minimal \ python3-yaml \ python3-plastex \ + python3-markdown \ texlive-fonts-recommended \ texlive-lang-cyrillic \ texlive-latex-extra \ diff --git a/debian/control b/debian/control index 42797c8b..43410292 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}, python3-plastex, python3-markdown, python3-pkg-resources, 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 From 05f6372cf850ef128e52b746687e1cc98c2b8ae0 Mon Sep 17 00:00:00 2001 From: matistjati Date: Thu, 8 Aug 2024 02:12:18 +0200 Subject: [PATCH 04/53] Style markdown tables --- examples/guess/problem.yaml | 2 + .../guess/problem_statement/problem.sv.md | 20 ++ examples/oddecho/problem.yaml | 4 +- .../oddecho/problem_statement/problem.sv.md | 27 ++ examples/problemset.cls | 257 ++++++++++++++++++ examples/tmpe5kbz3qn.tex | 6 + oddecho_html/index.html | 136 +++++++++ oddecho_html/problem.css | 105 +++++++ problemtools/md2html.py | 19 +- .../templates/markdown/default-layout.html | 2 +- problemtools/templates/markdown/problem.css | 15 + 11 files changed, 589 insertions(+), 4 deletions(-) create mode 100644 examples/guess/problem_statement/problem.sv.md create mode 100644 examples/oddecho/problem_statement/problem.sv.md create mode 100644 examples/problemset.cls create mode 100644 examples/tmpe5kbz3qn.tex create mode 100644 oddecho_html/index.html create mode 100644 oddecho_html/problem.css diff --git a/examples/guess/problem.yaml b/examples/guess/problem.yaml index fcb51934..c1e29500 100644 --- a/examples/guess/problem.yaml +++ b/examples/guess/problem.yaml @@ -2,6 +2,8 @@ source: Kattis license: cc by-sa validation: custom interactive +name: + sv: Gissa talet # 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/oddecho/problem.yaml b/examples/oddecho/problem.yaml index 1fcd5e21..f213fbd9 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: Echo + sv: Eko grading: show_test_data_groups: true diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md new file mode 100644 index 00000000..e0af2eea --- /dev/null +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -0,0 +1,27 @@ +**ECHO! Echo! Ech...** + +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, är du inte tillräckligt lycklig 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. Mer specifikt, varje annat 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. + +## Inmatning + +Den första raden av inmatningen 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`. + +## Utmatning + +Skriv ut de ord som har udda index (dvs. första, tredje, femte och så vidare) i inmatningen. + +## Bedömning + +Din lösning kommer att testas på en uppsättning testgrupper, där varje grupp är värd ett antal poäng. För att få poängen för en testgrupp måste du lösa alla testfall i den testgruppen. + +| Grupp | Poäng | Begränsningar | +|-------|-------|--------------------------| +| 1 | 1 | $N$ är alltid $5$ | +| 2 | 1 | Inga ytterligare begränsningar | + diff --git a/examples/problemset.cls b/examples/problemset.cls new file mode 100644 index 00000000..8501dea1 --- /dev/null +++ b/examples/problemset.cls @@ -0,0 +1,257 @@ +\NeedsTeXFormat{LaTeX2e} +\ProvidesClass{problemset}[2011/08/26 Problem Set For ACM-Style Programming Contests] + + +% Options to add: +% noproblemnumbers +% nosamplenumbers +% nopagenumbers +% nofooter +% noheader + +\newif\ifplastex +\plastexfalse + +\newif\if@footer\@footertrue +\DeclareOption{nofooter}{\@footerfalse} + +\newif\if@problemnumbers\@problemnumberstrue +\DeclareOption{noproblemnumbers}{\@problemnumbersfalse} + +\newif\if@clearevenpages\@clearevenpagestrue + +\DeclareOption{plainproblems}{ + \@footerfalse + \@problemnumbersfalse + \@clearevenpagesfalse +} + +%\DeclareOption{noproblemnumbers}{...} + +\DeclareOption*{\PassOptionsToClass{\CurrentOption}{article}} +\ProcessOptions\relax + +\LoadClass{article} + +\RequirePackage{times} % Font choice +\RequirePackage{amsmath} % AMS +\RequirePackage{amssymb} % AMS +\RequirePackage[OT2,T1]{fontenc} % Cyrillic and standard % TODO: make alphabet options more general +\RequirePackage[utf8]{inputenc} % UTF-8 support +\RequirePackage{fancyhdr} % Headers +\RequirePackage{graphicx} % Graphics +\RequirePackage{subfigure} % Subfigures +\RequirePackage{wrapfig} % Illustrations +\RequirePackage{import} % Proper file inclusion +\RequirePackage{verbatim} % For samples +\RequirePackage{fullpage} % Set up margins for full page +\RequirePackage{url} % Urls +\RequirePackage[colorlinks=true]{hyperref} +\RequirePackage{ulem} % \sout + + +%% Commands used to set name, logo, etc of contest +\newcommand*{\contestname}[1]{\def\@contestname{#1}} +\newcommand*{\contestshortname}[1]{\def\@contestshortname{#1}} +\newcommand*{\contestlogo}[1]{\def\@contestlogo{#1}} +\newcommand*{\headerlogo}[1]{\def\@headerlogo{#1}} +\newcommand*{\location}[1]{\def\@location{#1}} +\newcommand*{\licenseblurb}[1]{\def\@licenseblurb{#1}} +\newcommand*{\problemlanguage}[1]{\def\@problemlanguage{#1}} +\contestname{} +\contestshortname{} +\contestlogo{} +\headerlogo{} +\location{} +\licenseblurb{} +\problemlanguage{} + + + +% Typesetting sections in a problem + +\renewcommand\section{\@startsection{section}{1}{\z@}% + {-3.5ex \@plus -1ex \@minus -.2ex}% + {2.3ex \@plus.2ex}% + {\normalfont\large\sf\bfseries}} + +\renewcommand\subsection{\@startsection{subsection}{2}{\z@}% + {-3.25ex\@plus -1ex \@minus -.2ex}% + {1.5ex \@plus .2ex}% + {\normalfont\normalsize\sf\bfseries}} + +\renewcommand{\contentsname}{Problems} + + +% TODO: make last command of illustration/figure optional + +\newcommand{\illustration}[3]{ + \begin{wrapfigure}{r}{#1\textwidth} + \includegraphics[width=#1\textwidth]{#2} + \begin{flushright} + \vspace{-9pt} + \tiny #3 + \end{flushright} + \vspace{-15pt} + \end{wrapfigure} + \par + \noindent +} + + +%% Redefine cleardoublepage to put a text on even-numbered empty +%% pages. +\newcommand{\makeemptypage}{ + ~\thispagestyle{empty} + \vfill + \centerline{\Large \textsf{ This page is intentionally left (almost) blank.}} + \vfill + \clearpage +} +\renewcommand{\cleardoublepage}{ + \clearpage% + \ifodd\value{page}\else\makeemptypage\fi% +} + +\newcommand{\clearproblemsetpage}{ + \if@clearevenpages + \cleardoublepage + \else + \clearpage + \fi +} + + +%% Set up a problem counter and number problems A B C ... +\newcounter{problemcount} +\setcounter{problemcount}{0} +\newcommand{\problemnumber}{\Alph{problemcount}} + +%% Number figures as A.1 A.2... B.1 B.2... (except if we're converting to HTML) +\ifplastex\else +\renewcommand{\thefigure}{\problemnumber.\arabic{figure}} +\fi + + +%% Command for starting new problem + +%% Problem inclusion +\newcommand{\includeproblem}[3]{ + \startproblem{#1}{#2}{#3} + \import{#1/problem_statement/}{problem\@problemlanguage.tex} +} + +\newcommand{\startproblem}[3]{ + \clearproblemsetpage + \refstepcounter{problemcount} + \setcounter{samplenum}{0} + \setcounter{figure}{0}% + \def\@problemid{#1} + \def\@problemname{#2} + \def\@timelimit{#3} + \problemheader{\@problemname}{\@problemid} +} + +\newcommand{\problemheader}[2]{ + \begin{center} + \textsf{ + {\huge #1\\} + {\Large Problem ID: #2\\} + } + \end{center} +} + +%% Commands related to sample data + +%% Sample counter +\newcounter{samplenum} +\newcommand{\sampleid}{\arabic{samplenum}} + +%% Define the command used to give sample data +%% Takes filename as parameter + +\newcommand{\includesample}[1]{ + \displaysample{\@problemid/data/sample/#1} +} + +\newcommand{\displaysample}[1]{ + \IfFileExists{#1.in}{}{\ClassError{problemset}{Can't find file '#1.in'}{}} + \IfFileExists{#1.ans}{}{\ClassError{problemset}{Can't find file '#1.ans'}{}} + \refstepcounter{samplenum} + \par + \vspace{0.4cm} + \noindent + \sampletable + {Sample Input \sampleid}{#1.in} + {Sample Output \sampleid}{#1.ans} +} + +\newcommand{\sampletable}[4]{ + \begin{tabular}{|l|l|} + \multicolumn{1}{l}{\textsf{\textbf{#1}}} & + \multicolumn{1}{l}{\textsf{\textbf{#3}}} \\ + \hline + \parbox[t]{0.475\textwidth}{\vspace{-0.5cm}\verbatiminput{#2}} + & + \parbox[t]{0.475\textwidth}{\vspace{-0.5cm}\verbatiminput{#4}} + \\ + \hline + \end{tabular} +} + + +% Remaining part of file is headers and toc, not tested with plasTeX +% and should not be used in plastex mode +\ifplastex\else + + +%% Set up headers +\fancypagestyle{problem}{ + \fancyhf{} % Clear old junk +% \ifx \@headerlogo \@empty\relax \else +% \fancyhead[C]{ +% \includegraphics[scale=0.3]{\@headerlogo} +% } +% \fi + \if@footer + \fancyhead[L]{ + \emph{ + \@contestshortname{} + \if@problemnumbers Problem \problemnumber:{} \fi + \@problemname + } + } + \fancyhead[R]{\thepage} + \fancyfoot[L]{ + \emph{\@licenseblurb} + } +% \fancyfoot[R]{\includegraphics[scale=0.5]{cc-by-sa} } + \fi +} +\renewcommand{\headrulewidth}{0pt} +\pagestyle{problem} + +\AtBeginDocument{ + % FIXME: Figure out how to do this in a header-indep. way. +% \ifx \@headerlogo \@empty \relax\else + \addtolength{\headheight}{12pt} + \addtolength{\topmargin}{-30pt} + \addtolength{\textheight}{18pt} +% \fi + \setlength{\headsep}{25pt} +} + + +% Set up table of contents for cover page +\AtBeginDocument{ + \addtocontents{toc}{\protect\begin{tabular}{cl}} +} +\AtEndDocument{ + \clearproblemsetpage + % Annoyingly enough addtocontents won't work at end of doc + \immediate\write\@auxout{% + \string\@writefile{toc}{\string\end{tabular}}% + } +} + +\fi diff --git a/examples/tmpe5kbz3qn.tex b/examples/tmpe5kbz3qn.tex new file mode 100644 index 00000000..ba1c2e5b --- /dev/null +++ b/examples/tmpe5kbz3qn.tex @@ -0,0 +1,6 @@ +\documentclass[plainproblems]{problemset} + +\problemlanguage{.sv} + +\begin{document} + diff --git a/oddecho_html/index.html b/oddecho_html/index.html new file mode 100644 index 00000000..3e88327f --- /dev/null +++ b/oddecho_html/index.html @@ -0,0 +1,136 @@ + + + + + Echo + + + + + +
+

Echo

+

Problem ID: oddecho

+
+
+

ECHO! Echo! Ech...

+

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, är du inte tillräckligt lycklig 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. Mer + specifikt, varje annat 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.

+

Inmatning

+

Den första raden av inmatningen 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.

+

Utmatning

+

Skriv ut de ord som har udda index (dvs. första, tredje, + femte och så vidare) i inmatningen.

+

Bedömning

+

Din lösning kommer att testas på en uppsättning testgrupper, + där varje grupp är värd ett antal poäng. För att få poängen för + en testgrupp måste du lösa alla testfall i den testgruppen.

+ + + + + + + + + + + + + + + + + + + + +
GruppPoängBegränsningar
11$N$ är alltid + $5$
21Inga ytterligare begränsningar
+
+ + + + + + + + + + + + + + + + + + + +
Sample Input 1Sample Output 1
+
5
+hello
+i
+am
+an
+echo
+
+
+
hello
+am
+echo
+
+
Sample Input 2Sample Output 2
+
10
+only
+if
+these
+oddindexed
+words
+appear
+are
+you
+correct
+output
+
+
+
only
+these
+words
+are
+correct
+
+
+ + diff --git a/oddecho_html/problem.css b/oddecho_html/problem.css new file mode 100644 index 00000000..0b5be150 --- /dev/null +++ b/oddecho_html/problem.css @@ -0,0 +1,105 @@ +.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; +} + +.markdown-table { + border-collapse: collapse; + width: 100%; +} + +.markdown-table th, .markdown-table td { + border: 1px solid black; + padding: 8px; + text-align: left; +} + +.markdown-table th { + background-color: #f2f2f2; +} + +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; +} + +table, table td { + border: 0; +} + +table.tabular p { + margin: 0; +} + +table.sample { + width: 100%; +} + +table.sample th { + text-align: left; + width: 50%; +} + +table.sample td { + border: 1px solid black; +} + +div.sampleinteractionread { + border: 1px solid black; + width: 60%; + float: left; + margin: 3px 0px 3px 0px; +} + +.sampleinteractionread pre { + margin: 1px 5px 1px 5px; +} + +div.sampleinteractionwrite { + border: 1px solid black; + width: 60%; + float: right; + margin: 3px 0px 3px 0px; +} + +.sampleinteractionwrite pre { + margin: 1px 5px 1px 5px; +} \ No newline at end of file diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 1349b206..6140d607 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -7,6 +7,7 @@ from typing import Optional import markdown +from markdown.treeprocessors import Treeprocessor from markdown.inlinepatterns import InlineProcessor from markdown.extensions import Extension import xml.etree.ElementTree as etree @@ -118,7 +119,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: with open(statement_path, "r", encoding="utf-8") as input_file: text = input_file.read() - statement_html = markdown.markdown(text, extensions=[MathExtension(), "tables"]) + statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables"]) templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -148,7 +149,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: with open(os.path.join(templatepath, "problem.css"), "r") as input_file: output_file.write(input_file.read()) - +# Parse inline math $a+b$ class InlineMathProcessor(InlineProcessor): def handleMatch(self, m, data): el = etree.Element('span') @@ -156,6 +157,7 @@ def handleMatch(self, m, data): el.text = "$" + m.group(1) + "$" return el, m.start(0), m.end(0) +# Parse display math $$a+b$$ class DisplayMathProcessor(InlineProcessor): def handleMatch(self, m, data): el = etree.Element('div') @@ -163,6 +165,7 @@ def handleMatch(self, m, data): el.text = "$$" + m.group(1) + "$$" return el, m.start(0), m.end(0) +# Add the display+inline math class MathExtension(Extension): def extendMarkdown(self, md): # Regex magic so that both $ $ and $$ $$ can coexist @@ -171,3 +174,15 @@ def extendMarkdown(self, md): md.inlinePatterns.register(DisplayMathProcessor(DISPLAY_MATH_PATTERN, md), 'display-math', 200) md.inlinePatterns.register(InlineMathProcessor(INLINE_MATH_PATTERN, md), 'inline-math', 201) + +# Add class markdown-table to all tables for easier styling +# (Otherwise, we will end up styling sample tables) +class AddClassTreeprocessor(Treeprocessor): + def run(self, root): + for table in root.findall(".//table"): + if 'class' not in table.attrib: + table.set('class', 'markdown-table') # Replace 'my-custom-class' with your desired class name + +class AddClassExtension(Extension): + def extendMarkdown(self, md): + md.treeprocessors.register(AddClassTreeprocessor(md), 'add_class', 15) diff --git a/problemtools/templates/markdown/default-layout.html b/problemtools/templates/markdown/default-layout.html index 0285e9d4..814324c1 100644 --- a/problemtools/templates/markdown/default-layout.html +++ b/problemtools/templates/markdown/default-layout.html @@ -33,4 +33,4 @@

Problem ID: %(problemid)s

- \ No newline at end of file + diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown/problem.css index 20448219..0b5be150 100644 --- a/problemtools/templates/markdown/problem.css +++ b/problemtools/templates/markdown/problem.css @@ -13,6 +13,21 @@ font-family: Arial, Helvetica, sans-serif; } +.markdown-table { + border-collapse: collapse; + width: 100%; +} + +.markdown-table th, .markdown-table td { + border: 1px solid black; + padding: 8px; + text-align: left; +} + +.markdown-table th { + background-color: #f2f2f2; +} + div.minipage { display: inline-block; } From 673773e4363d40b80bbf62feb149093d2ffaacfa Mon Sep 17 00:00:00 2001 From: matistjati Date: Thu, 8 Aug 2024 02:13:41 +0200 Subject: [PATCH 05/53] Remove temp files --- examples/problemset.cls | 257 --------------------------------------- examples/tmpe5kbz3qn.tex | 6 - oddecho_html/index.html | 136 --------------------- oddecho_html/problem.css | 105 ---------------- 4 files changed, 504 deletions(-) delete mode 100644 examples/problemset.cls delete mode 100644 examples/tmpe5kbz3qn.tex delete mode 100644 oddecho_html/index.html delete mode 100644 oddecho_html/problem.css diff --git a/examples/problemset.cls b/examples/problemset.cls deleted file mode 100644 index 8501dea1..00000000 --- a/examples/problemset.cls +++ /dev/null @@ -1,257 +0,0 @@ -\NeedsTeXFormat{LaTeX2e} -\ProvidesClass{problemset}[2011/08/26 Problem Set For ACM-Style Programming Contests] - - -% Options to add: -% noproblemnumbers -% nosamplenumbers -% nopagenumbers -% nofooter -% noheader - -\newif\ifplastex -\plastexfalse - -\newif\if@footer\@footertrue -\DeclareOption{nofooter}{\@footerfalse} - -\newif\if@problemnumbers\@problemnumberstrue -\DeclareOption{noproblemnumbers}{\@problemnumbersfalse} - -\newif\if@clearevenpages\@clearevenpagestrue - -\DeclareOption{plainproblems}{ - \@footerfalse - \@problemnumbersfalse - \@clearevenpagesfalse -} - -%\DeclareOption{noproblemnumbers}{...} - -\DeclareOption*{\PassOptionsToClass{\CurrentOption}{article}} -\ProcessOptions\relax - -\LoadClass{article} - -\RequirePackage{times} % Font choice -\RequirePackage{amsmath} % AMS -\RequirePackage{amssymb} % AMS -\RequirePackage[OT2,T1]{fontenc} % Cyrillic and standard % TODO: make alphabet options more general -\RequirePackage[utf8]{inputenc} % UTF-8 support -\RequirePackage{fancyhdr} % Headers -\RequirePackage{graphicx} % Graphics -\RequirePackage{subfigure} % Subfigures -\RequirePackage{wrapfig} % Illustrations -\RequirePackage{import} % Proper file inclusion -\RequirePackage{verbatim} % For samples -\RequirePackage{fullpage} % Set up margins for full page -\RequirePackage{url} % Urls -\RequirePackage[colorlinks=true]{hyperref} -\RequirePackage{ulem} % \sout - - -%% Commands used to set name, logo, etc of contest -\newcommand*{\contestname}[1]{\def\@contestname{#1}} -\newcommand*{\contestshortname}[1]{\def\@contestshortname{#1}} -\newcommand*{\contestlogo}[1]{\def\@contestlogo{#1}} -\newcommand*{\headerlogo}[1]{\def\@headerlogo{#1}} -\newcommand*{\location}[1]{\def\@location{#1}} -\newcommand*{\licenseblurb}[1]{\def\@licenseblurb{#1}} -\newcommand*{\problemlanguage}[1]{\def\@problemlanguage{#1}} -\contestname{} -\contestshortname{} -\contestlogo{} -\headerlogo{} -\location{} -\licenseblurb{} -\problemlanguage{} - - - -% Typesetting sections in a problem - -\renewcommand\section{\@startsection{section}{1}{\z@}% - {-3.5ex \@plus -1ex \@minus -.2ex}% - {2.3ex \@plus.2ex}% - {\normalfont\large\sf\bfseries}} - -\renewcommand\subsection{\@startsection{subsection}{2}{\z@}% - {-3.25ex\@plus -1ex \@minus -.2ex}% - {1.5ex \@plus .2ex}% - {\normalfont\normalsize\sf\bfseries}} - -\renewcommand{\contentsname}{Problems} - - -% TODO: make last command of illustration/figure optional - -\newcommand{\illustration}[3]{ - \begin{wrapfigure}{r}{#1\textwidth} - \includegraphics[width=#1\textwidth]{#2} - \begin{flushright} - \vspace{-9pt} - \tiny #3 - \end{flushright} - \vspace{-15pt} - \end{wrapfigure} - \par - \noindent -} - - -%% Redefine cleardoublepage to put a text on even-numbered empty -%% pages. -\newcommand{\makeemptypage}{ - ~\thispagestyle{empty} - \vfill - \centerline{\Large \textsf{ This page is intentionally left (almost) blank.}} - \vfill - \clearpage -} -\renewcommand{\cleardoublepage}{ - \clearpage% - \ifodd\value{page}\else\makeemptypage\fi% -} - -\newcommand{\clearproblemsetpage}{ - \if@clearevenpages - \cleardoublepage - \else - \clearpage - \fi -} - - -%% Set up a problem counter and number problems A B C ... -\newcounter{problemcount} -\setcounter{problemcount}{0} -\newcommand{\problemnumber}{\Alph{problemcount}} - -%% Number figures as A.1 A.2... B.1 B.2... (except if we're converting to HTML) -\ifplastex\else -\renewcommand{\thefigure}{\problemnumber.\arabic{figure}} -\fi - - -%% Command for starting new problem - -%% Problem inclusion -\newcommand{\includeproblem}[3]{ - \startproblem{#1}{#2}{#3} - \import{#1/problem_statement/}{problem\@problemlanguage.tex} -} - -\newcommand{\startproblem}[3]{ - \clearproblemsetpage - \refstepcounter{problemcount} - \setcounter{samplenum}{0} - \setcounter{figure}{0}% - \def\@problemid{#1} - \def\@problemname{#2} - \def\@timelimit{#3} - \problemheader{\@problemname}{\@problemid} -} - -\newcommand{\problemheader}[2]{ - \begin{center} - \textsf{ - {\huge #1\\} - {\Large Problem ID: #2\\} - } - \end{center} -} - -%% Commands related to sample data - -%% Sample counter -\newcounter{samplenum} -\newcommand{\sampleid}{\arabic{samplenum}} - -%% Define the command used to give sample data -%% Takes filename as parameter - -\newcommand{\includesample}[1]{ - \displaysample{\@problemid/data/sample/#1} -} - -\newcommand{\displaysample}[1]{ - \IfFileExists{#1.in}{}{\ClassError{problemset}{Can't find file '#1.in'}{}} - \IfFileExists{#1.ans}{}{\ClassError{problemset}{Can't find file '#1.ans'}{}} - \refstepcounter{samplenum} - \par - \vspace{0.4cm} - \noindent - \sampletable - {Sample Input \sampleid}{#1.in} - {Sample Output \sampleid}{#1.ans} -} - -\newcommand{\sampletable}[4]{ - \begin{tabular}{|l|l|} - \multicolumn{1}{l}{\textsf{\textbf{#1}}} & - \multicolumn{1}{l}{\textsf{\textbf{#3}}} \\ - \hline - \parbox[t]{0.475\textwidth}{\vspace{-0.5cm}\verbatiminput{#2}} - & - \parbox[t]{0.475\textwidth}{\vspace{-0.5cm}\verbatiminput{#4}} - \\ - \hline - \end{tabular} -} - - -% Remaining part of file is headers and toc, not tested with plasTeX -% and should not be used in plastex mode -\ifplastex\else - - -%% Set up headers -\fancypagestyle{problem}{ - \fancyhf{} % Clear old junk -% \ifx \@headerlogo \@empty\relax \else -% \fancyhead[C]{ -% \includegraphics[scale=0.3]{\@headerlogo} -% } -% \fi - \if@footer - \fancyhead[L]{ - \emph{ - \@contestshortname{} - \if@problemnumbers Problem \problemnumber:{} \fi - \@problemname - } - } - \fancyhead[R]{\thepage} - \fancyfoot[L]{ - \emph{\@licenseblurb} - } -% \fancyfoot[R]{\includegraphics[scale=0.5]{cc-by-sa} } - \fi -} -\renewcommand{\headrulewidth}{0pt} -\pagestyle{problem} - -\AtBeginDocument{ - % FIXME: Figure out how to do this in a header-indep. way. -% \ifx \@headerlogo \@empty \relax\else - \addtolength{\headheight}{12pt} - \addtolength{\topmargin}{-30pt} - \addtolength{\textheight}{18pt} -% \fi - \setlength{\headsep}{25pt} -} - - -% Set up table of contents for cover page -\AtBeginDocument{ - \addtocontents{toc}{\protect\begin{tabular}{cl}} -} -\AtEndDocument{ - \clearproblemsetpage - % Annoyingly enough addtocontents won't work at end of doc - \immediate\write\@auxout{% - \string\@writefile{toc}{\string\end{tabular}}% - } -} - -\fi diff --git a/examples/tmpe5kbz3qn.tex b/examples/tmpe5kbz3qn.tex deleted file mode 100644 index ba1c2e5b..00000000 --- a/examples/tmpe5kbz3qn.tex +++ /dev/null @@ -1,6 +0,0 @@ -\documentclass[plainproblems]{problemset} - -\problemlanguage{.sv} - -\begin{document} - diff --git a/oddecho_html/index.html b/oddecho_html/index.html deleted file mode 100644 index 3e88327f..00000000 --- a/oddecho_html/index.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - Echo - - - - - -
-

Echo

-

Problem ID: oddecho

-
-
-

ECHO! Echo! Ech...

-

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, är du inte tillräckligt lycklig 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. Mer - specifikt, varje annat 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.

-

Inmatning

-

Den första raden av inmatningen 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.

-

Utmatning

-

Skriv ut de ord som har udda index (dvs. första, tredje, - femte och så vidare) i inmatningen.

-

Bedömning

-

Din lösning kommer att testas på en uppsättning testgrupper, - där varje grupp är värd ett antal poäng. För att få poängen för - en testgrupp måste du lösa alla testfall i den testgruppen.

- - - - - - - - - - - - - - - - - - - - -
GruppPoängBegränsningar
11$N$ är alltid - $5$
21Inga ytterligare begränsningar
-
- - - - - - - - - - - - - - - - - - - -
Sample Input 1Sample Output 1
-
5
-hello
-i
-am
-an
-echo
-
-
-
hello
-am
-echo
-
-
Sample Input 2Sample Output 2
-
10
-only
-if
-these
-oddindexed
-words
-appear
-are
-you
-correct
-output
-
-
-
only
-these
-words
-are
-correct
-
-
- - diff --git a/oddecho_html/problem.css b/oddecho_html/problem.css deleted file mode 100644 index 0b5be150..00000000 --- a/oddecho_html/problem.css +++ /dev/null @@ -1,105 +0,0 @@ -.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; -} - -.markdown-table { - border-collapse: collapse; - width: 100%; -} - -.markdown-table th, .markdown-table td { - border: 1px solid black; - padding: 8px; - text-align: left; -} - -.markdown-table th { - background-color: #f2f2f2; -} - -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; -} - -table, table td { - border: 0; -} - -table.tabular p { - margin: 0; -} - -table.sample { - width: 100%; -} - -table.sample th { - text-align: left; - width: 50%; -} - -table.sample td { - border: 1px solid black; -} - -div.sampleinteractionread { - border: 1px solid black; - width: 60%; - float: left; - margin: 3px 0px 3px 0px; -} - -.sampleinteractionread pre { - margin: 1px 5px 1px 5px; -} - -div.sampleinteractionwrite { - border: 1px solid black; - width: 60%; - float: right; - margin: 3px 0px 3px 0px; -} - -.sampleinteractionwrite pre { - margin: 1px 5px 1px 5px; -} \ No newline at end of file From 1c64085aafc4b5b35e1d24d4685e17ba15720917 Mon Sep 17 00:00:00 2001 From: matistjati Date: Thu, 8 Aug 2024 02:37:33 +0200 Subject: [PATCH 06/53] Statement fix --- .../oddecho/problem_statement/problem.sv.md | 17 ++++++++++------- problemtools/md2html.py | 4 ++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index e0af2eea..52ebedf7 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -1,24 +1,27 @@ **ECHO! Echo! Ech...** -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, är du inte tillräckligt lycklig 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. +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. Mer specifikt, varje annat 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. +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. 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. -## Inmatning +## Indata -Den första raden av inmatningen innehåller ett heltal $N$ ($1 \le N \le 10$). +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`. -## Utmatning +## Utdata Skriv ut de ord som har udda index (dvs. första, tredje, femte och så vidare) i inmatningen. -## Bedömning -Din lösning kommer att testas på en uppsättning testgrupper, där varje grupp är värd ett antal poäng. För att få poängen för en testgrupp måste du lösa alla testfall i den testgruppen. +## 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 | |-------|-------|--------------------------| diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 6140d607..bc7ccc5c 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -168,7 +168,7 @@ def handleMatch(self, m, data): # Add the display+inline math class MathExtension(Extension): def extendMarkdown(self, md): - # Regex magic so that both $ $ and $$ $$ can coexist + # Regex magic so that both $ $ and $$ $$ can coexist. Written by a wizard (ChatGPT) INLINE_MATH_PATTERN = r'(? Date: Thu, 8 Aug 2024 02:59:22 +0200 Subject: [PATCH 07/53] Some refactoring --- problemtools/md2html.py | 125 +++++++++++++++++++---------------- problemtools/problem2html.py | 1 - problemtools/tex2html.py | 2 +- 3 files changed, 70 insertions(+), 58 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index bc7ccc5c..5db0d727 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -6,18 +6,67 @@ import argparse from typing import Optional +import xml.etree.ElementTree as etree import markdown from markdown.treeprocessors import Treeprocessor from markdown.inlinepatterns import InlineProcessor from markdown.extensions import Extension -import xml.etree.ElementTree as etree from . import verifyproblem from . import problem2html + +def convert(problem: str, options: argparse.Namespace) -> None: + """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 = problem2html._find_statement(problem, extension="md", language=options.language) + + if statement_path is None: + raise Exception('No markdown statement found') + + with open(statement_path, "r", encoding="utf-8") as input_file: + text = input_file.read() + statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables"]) + + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), + os.path.join(os.path.dirname(__file__), '../templates/markdown'), + '/usr/lib/problemtools/templates/markdown'] + 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 = _get_problem_name(problem) + + html_template = _substitute_template(templatepath, "default-layout.html", + statement_html=statement_html, + language=options.language, + title=problem_name or "Missing problem name", + problemid=problembase) + + html_template += _samples_to_html(problem) + + 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()) + + 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. + 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 @@ -35,7 +84,7 @@ def _get_problem_name(problem: str, language: str = "en") -> Optional[str]: # 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}") return names[language] @@ -50,13 +99,13 @@ def _samples_to_html(problem: str) -> str: casenum = 1 for sample in sorted(os.listdir(sample_path)): if sample.endswith(".interaction"): - lines = [""" + lines = [f"""
- + -
ReadSample Interaction {}Sample Interaction {casenum} Write
""".format(casenum)] + """] with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: sample_interaction = infile.readlines() for interaction in sample_interaction: @@ -84,7 +133,7 @@ def _samples_to_html(problem: str) -> str: with open(outpath, "r", encoding="utf-8") as outfile: sample_output = outfile.read() - samples.append(f""" + samples.append(""" Sample Input %(case)d Sample Output %(case)d @@ -92,63 +141,23 @@ def _samples_to_html(problem: str) -> str:
%(input)s
%(output)s
- """ % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) + """ + % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) casenum += 1 if interactive_samples: samples_html += ''.join(interactive_samples) if samples: - samples_html += """ + samples_html += f""" - %(samples)s + {''.join(samples)}
- """ % {"samples": ''.join(samples)} + """ return samples_html -def convert(problem: str, options: argparse.Namespace) -> None: - problembase = os.path.splitext(os.path.basename(problem))[0] - destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - - statement_path = problem2html._find_statement(problem, extension="md", language=options.language) - - if statement_path is None: - raise Exception('No markdown statement found') - - with open(statement_path, "r", encoding="utf-8") as input_file: - text = input_file.read() - statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables"]) - - templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), - os.path.join(os.path.dirname(__file__), '../templates/markdown'), - '/usr/lib/problemtools/templates/markdown'] - 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 = _get_problem_name(problem) - - html_template = _substitute_template(templatepath, "default-layout.html", - statement_html=statement_html, - language=options.language, - title=problem_name or "Missing problem name", - problemid=problembase) - - html_template += _samples_to_html(problem) - - 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()) - # Parse inline math $a+b$ class InlineMathProcessor(InlineProcessor): def handleMatch(self, m, data): @@ -157,6 +166,7 @@ def handleMatch(self, m, data): el.text = "$" + m.group(1) + "$" return el, m.start(0), m.end(0) + # Parse display math $$a+b$$ class DisplayMathProcessor(InlineProcessor): def handleMatch(self, m, data): @@ -165,15 +175,17 @@ def handleMatch(self, m, data): el.text = "$$" + m.group(1) + "$$" return el, m.start(0), m.end(0) + # Add the display+inline math class MathExtension(Extension): def extendMarkdown(self, md): # Regex magic so that both $ $ and $$ $$ can coexist. Written by a wizard (ChatGPT) - INLINE_MATH_PATTERN = r'(? None: doc = tex.parse() texfile.close() - + renderer.render(doc) # Annoying: I have not figured out any way of stopping the plasTeX From 08645f58cacf8c80d9d994aa3b18d6863cc80230 Mon Sep 17 00:00:00 2001 From: matistjati Date: Fri, 9 Aug 2024 15:02:30 +0200 Subject: [PATCH 08/53] Added image support in markdown --- examples/README.md | 2 +- .../oddecho/problem_statement/echo_cave.webp | Bin 0 -> 19340 bytes .../oddecho/problem_statement/problem.en.tex | 2 + .../oddecho/problem_statement/problem.sv.md | 7 ++ problemtools/md2html.py | 89 ++++++++++++++++-- 5 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 examples/oddecho/problem_statement/echo_cave.webp diff --git a/examples/README.md b/examples/README.md index d646a86d..1aa6f8a2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +24,7 @@ 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 showcses how to use images in Markdown. # bplusa diff --git a/examples/oddecho/problem_statement/echo_cave.webp b/examples/oddecho/problem_statement/echo_cave.webp new file mode 100644 index 0000000000000000000000000000000000000000..8e79d2bc1596bf2cfcb1c50cf1c8b731beb99aea GIT binary patch literal 19340 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 LA=f6700000Kr61t literal 0 HcmV?d00001 diff --git a/examples/oddecho/problem_statement/problem.en.tex b/examples/oddecho/problem_statement/problem.en.tex index e4b03ab8..2505bb0e 100644 --- a/examples/oddecho/problem_statement/problem.en.tex +++ b/examples/oddecho/problem_statement/problem.en.tex @@ -12,6 +12,8 @@ Your task is to write a program that simulates this behavior. +\includegraphics[]{image.jpg} + \section*{Input} The first line of the input contains an integer \(N\) (\(1 \le N \le 10\)). diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index 52ebedf7..d51c5e13 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -1,5 +1,12 @@ **ECHO! Echo! Ech...** + +A cave + + 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. diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 5db0d727..b73ff4d9 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -4,6 +4,7 @@ import os.path import string import argparse +import re from typing import Optional import xml.etree.ElementTree as etree @@ -31,9 +32,14 @@ def convert(problem: str, options: argparse.Namespace) -> None: if statement_path is None: raise Exception('No markdown statement found') + seen_images = set() + call_handle = lambda src: _handle_image(os.path.join(problem, "problem_statement", src), seen_images) with open(statement_path, "r", encoding="utf-8") as input_file: text = input_file.read() - statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables"]) + statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables", + FixImageLinksExtension(call_handle)]) + + templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -64,6 +70,17 @@ def convert(problem: str, options: argparse.Namespace) -> None: output_file.write(input_file.read()) +def _handle_image(src, seen_images): + if src in seen_images: + return + if not os.path.isfile(src): + raise Exception(f"Could not find image {src} in problem_statement folder") + file_name = os.path.basename(src) + with open(src, "rb") as img: + with open(file_name, "wb") as out: + out.write(img.read()) + + 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. @@ -158,8 +175,8 @@ def _samples_to_html(problem: str) -> str: return samples_html -# Parse inline math $a+b$ class InlineMathProcessor(InlineProcessor): + """Tell mathjax to process all $a+b$""" def handleMatch(self, m, data): el = etree.Element('span') el.attrib['class'] = 'tex2jax_process' @@ -167,8 +184,8 @@ def handleMatch(self, m, data): return el, m.start(0), m.end(0) -# Parse display math $$a+b$$ class DisplayMathProcessor(InlineProcessor): + """Tell mathjax to process all $$a+b$$""" def handleMatch(self, m, data): el = etree.Element('div') el.attrib['class'] = 'tex2jax_process' @@ -176,8 +193,8 @@ def handleMatch(self, m, data): return el, m.start(0), m.end(0) -# Add the display+inline math class MathExtension(Extension): + """Add $a+b$ and $$a+b$$""" def extendMarkdown(self, md): # Regex magic so that both $ $ and $$ $$ can coexist. Written by a wizard (ChatGPT) inline_math_pattern = r'(? + + Implementation details: python-markdown seems to put both of these inside + html nodes' text, not as their own nodes. Therefore, we do a dfs and + use regex to extract them. + + """ + def __init__(self, md, callback): + super().__init__(md) + self.callback = callback + + def find_images(self, text: str) -> None: + """Find all images in a string and call the callback on each""" + if not text: + return + + # Find html-style images + html_img_pattern = re.compile(r']*?)>', re.IGNORECASE) + + html_src_pattern = re.compile(r'src\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE) + for match in html_img_pattern.finditer(text): + img_attrs = match.group(1) + + src_match = html_src_pattern.search(img_attrs) + if src_match: + src_value = src_match.group(1) + self.callback(src_value) + + # Find markdown-style images + markdown_pattern = re.compile(r'!\[(.*?)\]\((.*?)\s*(?:"(.*?)")?\)') + + for match in markdown_pattern.finditer(text): + alt_text, src, title = match.groups() + self.callback(src) + + def dfs(self, element): + """Visit every html node and find any images contained in it""" + self.find_images(element.text) + for child in element: + self.dfs(child) + + def run(self, root): + self.dfs(root) + +class FixImageLinksExtension(Extension): + """Add FixImageLinks extension""" + def __init__(self, callback): + super().__init__() + self.callback = callback + + def extendMarkdown(self, md): + md.treeprocessors.register(FixImageLinks(md, self.callback), 'find_images', 200) From a6a19330156ca6ad100d71d55d841dfe02225957 Mon Sep 17 00:00:00 2001 From: matistjati Date: Fri, 9 Aug 2024 15:16:26 +0200 Subject: [PATCH 09/53] Added footnote support --- examples/README.md | 3 ++- examples/oddecho/problem_statement/problem.sv.md | 3 ++- problemtools/md2html.py | 5 +++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/README.md b/examples/README.md index 1aa6f8a2..d1076a7e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +24,8 @@ 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. The swedish statement showcses how to use images in Markdown. +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. # bplusa diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index d51c5e13..09d4cea0 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -10,7 +10,7 @@ Alternatively, 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. 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. +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. @@ -35,3 +35,4 @@ För att få poäng för en grupp så måste du klara alla testfall i gruppen. | 1 | 1 | $N$ är alltid $5$ | | 2 | 1 | Inga ytterligare begränsningar | +[^1]: [https://sv.wikipedia.org/wiki/Interferens_(v%C3%A5gr%C3%B6relse)](https://sv.wikipedia.org/wiki/Interferens_(v%C3%A5gr%C3%B6relse)) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index b73ff4d9..b7aad19a 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -36,8 +36,9 @@ def convert(problem: str, options: argparse.Namespace) -> None: call_handle = lambda src: _handle_image(os.path.join(problem, "problem_statement", src), seen_images) with open(statement_path, "r", encoding="utf-8") as input_file: text = input_file.read() - statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), "tables", - FixImageLinksExtension(call_handle)]) + statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), + FixImageLinksExtension(call_handle), + 'footnotes', "tables"]) From 7627c58bc98f3e606065f9d30ba751718dc2b660 Mon Sep 17 00:00:00 2001 From: matistjati Date: Fri, 9 Aug 2024 15:26:52 +0200 Subject: [PATCH 10/53] Code cleanup --- problemtools/md2html.py | 35 +++++++++++++++++++++-------------- problemtools/problem2html.py | 4 ++-- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index b7aad19a..334bbea1 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -27,21 +27,20 @@ def convert(problem: str, options: argparse.Namespace) -> None: problembase = os.path.splitext(os.path.basename(problem))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - statement_path = problem2html._find_statement(problem, extension="md", language=options.language) + statement_path = problem2html.find_statement(problem, extension="md", language=options.language) if statement_path is None: raise Exception('No markdown statement found') - seen_images = set() - call_handle = lambda src: _handle_image(os.path.join(problem, "problem_statement", src), seen_images) + # The extension will only call _handle_image with the image name. We also need the path + # to the statement folder. We capture that with this lambda + call_handle = lambda src: _copy_image(os.path.join(problem, "problem_statement", src)) with open(statement_path, "r", encoding="utf-8") as input_file: text = input_file.read() statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), FixImageLinksExtension(call_handle), 'footnotes', "tables"]) - - templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), '/usr/lib/problemtools/templates/markdown'] @@ -71,12 +70,20 @@ def convert(problem: str, options: argparse.Namespace) -> None: output_file.write(input_file.read()) -def _handle_image(src, seen_images): - if src in seen_images: - return +def _copy_image(src: str) -> None: + """This is called for every image in the statement + Copies the image to the output directory from the statement + + Args: + src: full file path to the image + """ + if not os.path.isfile(src): raise Exception(f"Could not find image {src} in problem_statement folder") file_name = os.path.basename(src) + # No point in copying it twice + if os.path.isfile(file_name): + return with open(src, "rb") as img: with open(file_name, "wb") as out: out.write(img.read()) @@ -226,11 +233,11 @@ class FixImageLinks(Treeprocessor): If your image name is image.jpg, we consider the following to be reasonable ![Alt](image.jpg) - + Implementation details: python-markdown seems to put both of these inside html nodes' text, not as their own nodes. Therefore, we do a dfs and use regex to extract them. - + """ def __init__(self, md, callback): super().__init__(md) @@ -240,24 +247,24 @@ def find_images(self, text: str) -> None: """Find all images in a string and call the callback on each""" if not text: return - + # Find html-style images html_img_pattern = re.compile(r']*?)>', re.IGNORECASE) html_src_pattern = re.compile(r'src\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE) for match in html_img_pattern.finditer(text): img_attrs = match.group(1) - + src_match = html_src_pattern.search(img_attrs) if src_match: src_value = src_match.group(1) self.callback(src_value) - + # Find markdown-style images markdown_pattern = re.compile(r'!\[(.*?)\]\((.*?)\s*(?:"(.*?)")?\)') for match in markdown_pattern.finditer(text): - alt_text, src, title = match.groups() + _, src, __ = match.groups() self.callback(src) def dfs(self, element): diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index e1380e64..4c084613 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -12,7 +12,7 @@ SUPPORTED_EXTENSIONS = ("tex", "md") -def _find_statement(problem: str, extension: str, language: Optional[str]) -> Optional[str]: +def find_statement(problem: 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, f"problem_statement/problem.en.{extension}") @@ -32,7 +32,7 @@ def _find_statement_extension(problem: str, language: Optional[str]) -> str: """Given a language, find whether the extension is tex or md""" extensions = [] for ext in SUPPORTED_EXTENSIONS: - if _find_statement(problem, ext, language) is not None: + if find_statement(problem, ext, language) is not None: extensions.append(ext) # At most one extension per language to avoid arbitrary/hidden priorities if len(extensions) > 1: From 1b222ac332d1f4be06a6b2821a3dae950689808b Mon Sep 17 00:00:00 2001 From: matistjati Date: Tue, 13 Aug 2024 13:19:37 +0200 Subject: [PATCH 11/53] md -> html works --- .../oddecho/problem_statement/echo_cave.jpg | Bin 0 -> 35667 bytes .../oddecho/problem_statement/echo_cave.webp | Bin 19340 -> 0 bytes .../oddecho/problem_statement/problem.en.tex | 2 - .../oddecho/problem_statement/problem.sv.md | 11 +- problemtools/md2html.py | 187 ++++++------------ problemtools/templates/markdown/problem.css | 54 ++--- 6 files changed, 101 insertions(+), 153 deletions(-) create mode 100644 examples/oddecho/problem_statement/echo_cave.jpg delete mode 100644 examples/oddecho/problem_statement/echo_cave.webp diff --git a/examples/oddecho/problem_statement/echo_cave.jpg b/examples/oddecho/problem_statement/echo_cave.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e197bf1b4a2e9d2d2782e29680869536a27684b1 GIT binary patch literal 35667 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 literal 0 HcmV?d00001 diff --git a/examples/oddecho/problem_statement/echo_cave.webp b/examples/oddecho/problem_statement/echo_cave.webp deleted file mode 100644 index 8e79d2bc1596bf2cfcb1c50cf1c8b731beb99aea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19340 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 LA=f6700000Kr61t diff --git a/examples/oddecho/problem_statement/problem.en.tex b/examples/oddecho/problem_statement/problem.en.tex index 2505bb0e..e4b03ab8 100644 --- a/examples/oddecho/problem_statement/problem.en.tex +++ b/examples/oddecho/problem_statement/problem.en.tex @@ -12,8 +12,6 @@ Your task is to write a program that simulates this behavior. -\includegraphics[]{image.jpg} - \section*{Input} The first line of the input contains an integer \(N\) (\(1 \le N \le 10\)). diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index 09d4cea0..9d2e236c 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -1,10 +1,9 @@ **ECHO! Echo! Ech...** - -A cave - 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 @@ -35,4 +34,4 @@ För att få poäng för en grupp så måste du klara alla testfall i gruppen. | 1 | 1 | $N$ är alltid $5$ | | 2 | 1 | Inga ytterligare begränsningar | -[^1]: [https://sv.wikipedia.org/wiki/Interferens_(v%C3%A5gr%C3%B6relse)](https://sv.wikipedia.org/wiki/Interferens_(v%C3%A5gr%C3%B6relse)) +[^1]: [https://sv.wikipedia.org/wiki/Interferens](https://sv.wikipedia.org/wiki/Interferens) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 334bbea1..8505704c 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -5,18 +5,15 @@ import string import argparse import re +import json from typing import Optional -import xml.etree.ElementTree as etree -import markdown -from markdown.treeprocessors import Treeprocessor -from markdown.inlinepatterns import InlineProcessor -from markdown.extensions import Extension - from . import verifyproblem from . import problem2html +FOOTNOTES_STRING = '
' + def convert(problem: str, options: argparse.Namespace) -> None: """Convert a Markdown statement to HTML @@ -32,14 +29,13 @@ def convert(problem: str, options: argparse.Namespace) -> None: if statement_path is None: raise Exception('No markdown statement found') - # The extension will only call _handle_image with the image name. We also need the path - # to the statement folder. We capture that with this lambda - call_handle = lambda src: _copy_image(os.path.join(problem, "problem_statement", src)) - with open(statement_path, "r", encoding="utf-8") as input_file: - text = input_file.read() - statement_html = markdown.markdown(text, extensions=[MathExtension(), AddClassExtension(), - FixImageLinksExtension(call_handle), - 'footnotes', "tables"]) + 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))) + statement_html = os.popen(f"pandoc {statement_path} -t html").read() templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -59,7 +55,10 @@ def convert(problem: str, options: argparse.Namespace) -> None: title=problem_name or "Missing problem name", problemid=problembase) - html_template += _samples_to_html(problem) + samples = _samples_to_html(problem) + + 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) @@ -70,18 +69,17 @@ def convert(problem: str, options: argparse.Namespace) -> None: output_file.write(input_file.read()) -def _copy_image(src: str) -> None: +def handle_image(src: str) -> None: """This is called for every image in the statement - Copies the image to the output directory from 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"Could not find image {src} in problem_statement folder") - file_name = os.path.basename(src) - # No point in copying it twice + raise Exception(f"File {file_name} not found in problem_statement") if os.path.isfile(file_name): return with open(src, "rb") as img: @@ -89,6 +87,53 @@ def _copy_image(src: str) -> None: out.write(img.read()) +def json_dfs(data, callback) -> None: + 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) + + # HTML-style images + if key == "t" and value == "RawInline": + image_string = data["c"][1] + src = re.search(r'src=["\'](.*?)["\']', image_string) + if src: + callback(src.group(1)) + + elif isinstance(data, list): + for item in data: + json_dfs(item, callback) + + +def _copy_images(statement_path, callback): + statement_json = os.popen(f"pandoc {statement_path} -t json").read() + 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. @@ -182,105 +227,3 @@ def _samples_to_html(problem: str) -> str: """ return samples_html - -class InlineMathProcessor(InlineProcessor): - """Tell mathjax to process all $a+b$""" - def handleMatch(self, m, data): - el = etree.Element('span') - el.attrib['class'] = 'tex2jax_process' - el.text = "$" + m.group(1) + "$" - return el, m.start(0), m.end(0) - - -class DisplayMathProcessor(InlineProcessor): - """Tell mathjax to process all $$a+b$$""" - def handleMatch(self, m, data): - el = etree.Element('div') - el.attrib['class'] = 'tex2jax_process' - el.text = "$$" + m.group(1) + "$$" - return el, m.start(0), m.end(0) - - -class MathExtension(Extension): - """Add $a+b$ and $$a+b$$""" - def extendMarkdown(self, md): - # Regex magic so that both $ $ and $$ $$ can coexist. Written by a wizard (ChatGPT) - inline_math_pattern = r'(? - - Implementation details: python-markdown seems to put both of these inside - html nodes' text, not as their own nodes. Therefore, we do a dfs and - use regex to extract them. - - """ - def __init__(self, md, callback): - super().__init__(md) - self.callback = callback - - def find_images(self, text: str) -> None: - """Find all images in a string and call the callback on each""" - if not text: - return - - # Find html-style images - html_img_pattern = re.compile(r']*?)>', re.IGNORECASE) - - html_src_pattern = re.compile(r'src\s*=\s*["\']([^"\']+)["\']', re.IGNORECASE) - for match in html_img_pattern.finditer(text): - img_attrs = match.group(1) - - src_match = html_src_pattern.search(img_attrs) - if src_match: - src_value = src_match.group(1) - self.callback(src_value) - - # Find markdown-style images - markdown_pattern = re.compile(r'!\[(.*?)\]\((.*?)\s*(?:"(.*?)")?\)') - - for match in markdown_pattern.finditer(text): - _, src, __ = match.groups() - self.callback(src) - - def dfs(self, element): - """Visit every html node and find any images contained in it""" - self.find_images(element.text) - for child in element: - self.dfs(child) - - def run(self, root): - self.dfs(root) - -class FixImageLinksExtension(Extension): - """Add FixImageLinks extension""" - def __init__(self, callback): - super().__init__() - self.callback = callback - - def extendMarkdown(self, md): - md.treeprocessors.register(FixImageLinks(md, self.callback), 'find_images', 200) diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown/problem.css index 0b5be150..66c5dc95 100644 --- a/problemtools/templates/markdown/problem.css +++ b/problemtools/templates/markdown/problem.css @@ -13,23 +13,50 @@ font-family: Arial, Helvetica, sans-serif; } -.markdown-table { +table { border-collapse: collapse; width: 100%; } -.markdown-table th, .markdown-table td { +table th, table td { border: 1px solid black; padding: 8px; text-align: left; } -.markdown-table th { +table th { background-color: #f2f2f2; } +.sample { + font-family: Arial, Helvetica, sans-serif; +} + +.sample td { + font-size: 13px; +} + +.sample { + border-collapse: separate; + 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; + display: inline-block; } div.illustration { @@ -61,26 +88,7 @@ td { vertical-align:top; } -table, table td { - border: 0; -} -table.tabular p { - margin: 0; -} - -table.sample { - width: 100%; -} - -table.sample th { - text-align: left; - width: 50%; -} - -table.sample td { - border: 1px solid black; -} div.sampleinteractionread { border: 1px solid black; From 712ce3edec968a25ec3196f6f84ef19eea1626f4 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sat, 17 Aug 2024 01:18:41 +0200 Subject: [PATCH 12/53] Make md styling more constistent with latex --- problemtools/templates/markdown/problem.css | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown/problem.css index 66c5dc95..8d354eca 100644 --- a/problemtools/templates/markdown/problem.css +++ b/problemtools/templates/markdown/problem.css @@ -13,31 +13,31 @@ font-family: Arial, Helvetica, sans-serif; } -table { +/*Style all tables except sample*/ +table:not(.sample) { border-collapse: collapse; - width: 100%; } -table th, table td { - border: 1px solid black; - padding: 8px; +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 th { - background-color: #f2f2f2; +table:not(.sample) td { + margin: 0px; } +/*Style sample in its own way*/ .sample { font-family: Arial, Helvetica, sans-serif; } -.sample td { - font-size: 13px; -} - .sample { - border-collapse: separate; width: 100%; } From 11a2e4c1cafe6911cffd9275e27a8decd41a1c79 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sat, 17 Aug 2024 17:12:02 +0200 Subject: [PATCH 13/53] md->pdf and Reorganize code --- .../oddecho/problem_statement/problem.sv.md | 6 +- problemtools/md2html.py | 102 +------------- problemtools/problem2html.py | 37 +---- problemtools/problem2pdf.py | 85 +++++++----- problemtools/statement_common.py | 130 ++++++++++++++++++ problemtools/verifyproblem.py | 7 +- 6 files changed, 196 insertions(+), 171 deletions(-) create mode 100644 problemtools/statement_common.py diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index 9d2e236c..4ffd89cf 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -1,10 +1,6 @@ -**ECHO! Echo! Ech...** +**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. diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 8505704c..68b967ae 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -8,8 +8,7 @@ import json from typing import Optional -from . import verifyproblem -from . import problem2html +from . import statement_common FOOTNOTES_STRING = '
' @@ -24,7 +23,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: problembase = os.path.splitext(os.path.basename(problem))[0] destfile = string.Template(options.destfile).safe_substitute(problem=problembase) - statement_path = problem2html.find_statement(problem, extension="md", language=options.language) + statement_path = statement_common.find_statement(problem, extension="md", language=options.language) if statement_path is None: raise Exception('No markdown statement found') @@ -47,7 +46,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: if templatepath is None: raise Exception('Could not find directory with markdown templates') - problem_name = _get_problem_name(problem) + problem_name = statement_common.get_problem_name(problem, options.language) html_template = _substitute_template(templatepath, "default-layout.html", statement_html=statement_html, @@ -55,7 +54,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: title=problem_name or "Missing problem name", problemid=problembase) - samples = _samples_to_html(problem) + samples = statement_common.samples_to_html(problem) html_template = inject_samples(html_template, samples) html_template = replace_hr_in_footnotes(html_template) @@ -88,6 +87,7 @@ def handle_image(src: str) -> None: 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 @@ -96,13 +96,6 @@ def json_dfs(data, callback) -> None: else: json_dfs(value, callback) - # HTML-style images - if key == "t" and value == "RawInline": - image_string = data["c"][1] - src = re.search(r'src=["\'](.*?)["\']', image_string) - if src: - callback(src.group(1)) - elif isinstance(data, list): for item in data: json_dfs(item, callback) @@ -142,88 +135,3 @@ def _substitute_template(templatepath: str, templatefile: str, **params) -> str: html_template = template_file.read() % params return html_template - -def _get_problem_name(problem: str, language: str = "en") -> Optional[str]: - """Load problem.yaml to get problem name""" - with verifyproblem.Problem(problem) as prob: - config = verifyproblem.ProblemConfig(prob) - if not config.check(None): - print("Please add problem name to problem.yaml when using markdown") - return None - 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}") - return names[language] - - -def _samples_to_html(problem: str) -> str: - """Read all samples from the problem directory and convert them to HTML""" - samples_html = "" - sample_path = os.path.join(problem, "data", "sample") - interactive_samples = [] - samples = [] - casenum = 1 - for sample in sorted(os.listdir(sample_path)): - if sample.endswith(".interaction"): - lines = [f""" - - - - - -
ReadSample Interaction {casenum}Write
"""] - with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: - sample_interaction = infile.readlines() - for interaction in sample_interaction: - data = interaction[1:] - 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}
""") - - interactive_samples.append(''.join(lines)) - 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 - with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: - sample_input = infile.read() - with open(outpath, "r", encoding="utf-8") as outfile: - sample_output = outfile.read() - - samples.append(""" - - Sample Input %(case)d - Sample Output %(case)d - - -
%(input)s
-
%(output)s
- """ - % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) - casenum += 1 - - if interactive_samples: - samples_html += ''.join(interactive_samples) - if samples: - samples_html += f""" - - - {''.join(samples)} - -
- """ - return samples_html - diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 4c084613..9536da38 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -5,43 +5,10 @@ import string import argparse import subprocess -from typing import Optional from . import tex2html from . import md2html - -SUPPORTED_EXTENSIONS = ("tex", "md") - -def find_statement(problem: 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, f"problem_statement/problem.en.{extension}") - if os.path.isfile(statement_path): - return statement_path - statement_path = os.path.join(problem, f"problem_statement/problem.{extension}") - if os.path.isfile(statement_path): - return statement_path - return None - statement_path = os.path.join(problem, f"problem_statement/problem.{language}.{extension}") - if os.path.isfile(statement_path): - return statement_path - return None - - -def _find_statement_extension(problem: str, language: Optional[str]) -> str: - """Given a language, find whether the extension is tex or md""" - extensions = [] - for ext in SUPPORTED_EXTENSIONS: - if find_statement(problem, 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'}") - +from . import statement_common def convert(options: argparse.Namespace) -> None: problem = os.path.realpath(options.problem) @@ -62,7 +29,7 @@ def convert(options: argparse.Namespace) -> None: origcwd = os.getcwd() - if _find_statement_extension(problem, options.language) == "tex": + if statement_common.find_statement_extension(problem, options.language) == "tex": tex2html.convert(problem, options) else: md2html.convert(problem, options) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index ac119d05..ef1784d4 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -5,47 +5,70 @@ import string import argparse import subprocess -from . import template +import tempfile +from . import template +from . import statement_common -def convert(options: argparse.Namespace, ignore_markdown: bool = False) -> bool: - problem = os.path.realpath(options.problem) - problembase = os.path.splitext(os.path.basename(problem))[0] +def convert(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) - # We skip PDF check when verifying problems with markdown statements - if os.path.isfile(os.path.join(problem, "problem_statement", "problem.%s.md" % options.language)) and ignore_markdown: - return True - - # Set up template if necessary - with template.Template(problem, language=options.language) as templ: - texfile = templ.get_file_name() - - origcwd = os.getcwd() - - os.chdir(os.path.dirname(texfile)) - params = ['pdflatex', '-interaction=nonstopmode'] - output = None - if options.quiet: - output = open(os.devnull, 'w') - if options.nopdf: - params.append('-draftmode') - - params.append(texfile) + if statement_common.find_statement_extension(problem_root, language=options.language) == "md": + 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") + + statement_dir = os.path.join(problem_root, "problem_statement") + with open(statement_path, "r") as f: + statement_md = f.read() + + # Hacky: html samples -> md. Then we append to the markdown document + samples = statement_common._samples_to_html(problem_root) + with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: + temp_file.write(samples) + temp_file.flush() + samples_md = os.popen(f"pandoc {temp_file.name} -t markdown").read() + + statement_md += samples_md + with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: + temp_file.write(statement_md) + temp_file.flush() + # Do .read so that the file isn't deleted until pandoc is done + os.popen(f"pandoc {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() + + else: + # Set up template if necessary + with template.Template(problem_root, language=options.language) as templ: + texfile = templ.get_file_name() + + origcwd = os.getcwd() + + os.chdir(os.path.dirname(texfile)) + params = ['pdflatex', '-interaction=nonstopmode'] + output = None + if options.quiet: + output = open(os.devnull, 'w') + if options.nopdf: + params.append('-draftmode') + + params.append(texfile) - status = subprocess.call(params, stdout=output) - if status == 0: status = subprocess.call(params, stdout=output) + if status == 0: + status = subprocess.call(params, stdout=output) - if output is not None: - output.close() + if output is not None: + output.close() - os.chdir(origcwd) + os.chdir(origcwd) - if status == 0 and not options.nopdf: - shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) + if status == 0 and not options.nopdf: + shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) - return status == 0 + 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..e8130c93 --- /dev/null +++ b/problemtools/statement_common.py @@ -0,0 +1,130 @@ +import os +from typing import Optional +import html + +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): + print("Please add problem name to problem.yaml when using markdown") + return None + 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 _samples_to_html(problem: str) -> str: + """Read all samples from the problem directory and convert them to HTML""" + samples_html = "" + sample_path = os.path.join(problem, "data", "sample") + interactive_samples = [] + samples = [] + casenum = 1 + for sample in sorted(os.listdir(sample_path)): + if sample.endswith(".interaction"): + lines = [f""" + + + + + +
ReadSample Interaction {casenum}Write
"""] + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: + sample_interaction = infile.readlines() + for interaction in sample_interaction: + data = interaction[1:] + 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}
""") + + interactive_samples.append(''.join(lines)) + 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 + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: + sample_input = infile.read() + with open(outpath, "r", encoding="utf-8") as outfile: + sample_output = outfile.read() + + samples.append(""" + + Sample Input %(case)d + Sample Output %(case)d + + +
%(input)s
+
%(output)s
+ """ + % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) + casenum += 1 + + if interactive_samples: + samples_html += ''.join(interactive_samples) + if samples: + samples_html += f""" + + + {''.join(samples)} + +
+ """ + return samples_html + diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 1db4a6e6..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,7 +1120,7 @@ def __init__(self, problem: Problem): self._problem = problem self.languages = [] glob_path = os.path.join(problem.probdir, 'problem_statement', 'problem.') - for extension in problem2html.SUPPORTED_EXTENSIONS: + 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): @@ -1145,7 +1146,7 @@ def check(self, context: Context) -> bool: options.language = lang options.nopdf = True options.quiet = True - if not problem2pdf.convert(options, ignore_markdown=True): + if not problem2pdf.convert(options): langparam = f' --language {lang}' if lang != '' else '' self.error(f'Could not compile problem statement for language "{lang}". Run problem2pdf{langparam} on the problem to diagnose.') except Exception as e: @@ -1167,7 +1168,7 @@ def __str__(self) -> str: def get_config(self) -> dict[str, dict[str, str]]: ret: dict[str, dict[str, str]] = {} - for extension in problem2html.SUPPORTED_EXTENSIONS: + 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): From 480e0ea9885b6e6d871656fa1b9ca51669fd7e5c Mon Sep 17 00:00:00 2001 From: matistjati Date: Sat, 17 Aug 2024 19:39:39 +0200 Subject: [PATCH 14/53] Better md->pdf tables --- problemtools/md2html.py | 2 +- problemtools/problem2pdf.py | 24 +++++++-- problemtools/statement_common.py | 52 +++++++++---------- .../templates/markdown_pdf/fix_tables.md | 12 +++++ 4 files changed, 59 insertions(+), 31 deletions(-) create mode 100644 problemtools/templates/markdown_pdf/fix_tables.md diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 68b967ae..f9190ffe 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -54,7 +54,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: title=problem_name or "Missing problem name", problemid=problembase) - samples = statement_common.samples_to_html(problem) + samples = "".join(statement_common.samples_to_html(problem)) html_template = inject_samples(html_template, samples) html_template = replace_hr_in_footnotes(html_template) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index ef1784d4..d65ea432 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -21,23 +21,39 @@ def convert(options: argparse.Namespace) -> bool: 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 f: + table_fix = f.read() + statement_dir = os.path.join(problem_root, "problem_statement") with open(statement_path, "r") as f: statement_md = f.read() + statement_md = table_fix + statement_md + # Hacky: html samples -> md. Then we append to the markdown document - samples = statement_common._samples_to_html(problem_root) + samples = "".join(statement_common.samples_to_html(problem_root)) with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: temp_file.write(samples) temp_file.flush() - samples_md = os.popen(f"pandoc {temp_file.name} -t markdown").read() - + samples_md = os.popen(f"pandoc {temp_file.name} -t latex").read() statement_md += samples_md + + #statement_md += samples_md with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: temp_file.write(statement_md) temp_file.flush() # Do .read so that the file isn't deleted until pandoc is done - os.popen(f"pandoc {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() + os.popen(f"pandoc --verbose {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() else: # Set up template if necessary diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index e8130c93..0667e928 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -1,5 +1,5 @@ import os -from typing import Optional +from typing import Optional, List import html from . import verifyproblem @@ -61,11 +61,17 @@ def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: return names[language] -def _samples_to_html(problem: str) -> str: - """Read all samples from the problem directory and convert them to HTML""" - samples_html = "" - sample_path = os.path.join(problem, "data", "sample") - interactive_samples = [] +def samples_to_html(problem_root: str) -> List[str]: + """Read all samples from the problem directory and convert them to HTML + + Args: + problem_root: path to root of problem + + Returns: + List[str]: All samples, converted to html. Ordered lexicographically by file names + """ + + sample_path = os.path.join(problem_root, "data", "sample") samples = [] casenum = 1 for sample in sorted(os.listdir(sample_path)): @@ -90,7 +96,7 @@ def _samples_to_html(problem: str) -> str: print(f"Warning: Interaction had unknown prefix {interaction[0]}") lines.append(f"""
{data}
""") - interactive_samples.append(''.join(lines)) + samples.append(''.join(lines)) casenum += 1 continue if not sample.endswith(".in"): @@ -105,26 +111,20 @@ def _samples_to_html(problem: str) -> str: sample_output = outfile.read() samples.append(""" - - Sample Input %(case)d - Sample Output %(case)d - - -
%(input)s
-
%(output)s
- """ + + + + + + + + + + + +
Sample Input %(case)dSample Output %(case)d
%(input)s
%(output)s
""" % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) casenum += 1 - if interactive_samples: - samples_html += ''.join(interactive_samples) - if samples: - samples_html += f""" - - - {''.join(samples)} - -
- """ - return samples_html + return samples diff --git a/problemtools/templates/markdown_pdf/fix_tables.md b/problemtools/templates/markdown_pdf/fix_tables.md new file mode 100644 index 00000000..fd597724 --- /dev/null +++ b/problemtools/templates/markdown_pdf/fix_tables.md @@ -0,0 +1,12 @@ +--- +header-includes: + - '\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' +--- From e9b3f8ed43faeed909cac3f95338232bb0bf30c7 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 02:00:47 +0200 Subject: [PATCH 15/53] Interactive samples for pdf --- problemtools/md2html.py | 2 +- problemtools/problem2pdf.py | 16 ++-- problemtools/statement_common.py | 74 +++++++++++++++---- .../templates/markdown_pdf/fix_tables.md | 2 + 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index f9190ffe..d764b4f4 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -54,7 +54,7 @@ def convert(problem: str, options: argparse.Namespace) -> None: title=problem_name or "Missing problem name", problemid=problembase) - samples = "".join(statement_common.samples_to_html(problem)) + 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) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index d65ea432..77081f8a 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -38,22 +38,20 @@ def convert(options: argparse.Namespace) -> bool: with open(statement_path, "r") as f: statement_md = f.read() + # Add code that adds vertical and horizontal lines to all tables statement_md = table_fix + statement_md # Hacky: html samples -> md. Then we append to the markdown document - samples = "".join(statement_common.samples_to_html(problem_root)) - with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: - temp_file.write(samples) - temp_file.flush() - samples_md = os.popen(f"pandoc {temp_file.name} -t latex").read() - statement_md += samples_md + samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) + + # If we don't add newline, the table might get attached to a footnote + statement_md += "\n" + samples - #statement_md += samples_md with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: temp_file.write(statement_md) temp_file.flush() - # Do .read so that the file isn't deleted until pandoc is done - os.popen(f"pandoc --verbose {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() + # Do .read so that the temp file isn't deleted until pandoc is done + os.popen(f"pandoc {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() else: # Set up template if necessary diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 0667e928..cb6960f0 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -1,6 +1,7 @@ import os from typing import Optional, List import html +import tempfile from . import verifyproblem @@ -49,8 +50,7 @@ def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: with verifyproblem.Problem(problem) as prob: config = verifyproblem.ProblemConfig(prob) if not config.check(None): - print("Please add problem name to problem.yaml when using markdown") - return None + raise Exception(f"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: @@ -61,44 +61,78 @@ def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: return names[language] -def samples_to_html(problem_root: str) -> List[str]: - """Read all samples from the problem directory and convert them to HTML +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 html. Ordered lexicographically by file names + 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"): - lines = [f""" + 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_path, sample), "r", encoding="utf-8") as infile: sample_interaction = infile.readlines() + lines = [] for interaction in sample_interaction: data = interaction[1:] - line_type = "" - if interaction[0] == '>': - line_type = "sampleinteractionwrite" - elif interaction[0] == '<': - line_type = "sampleinteractionread" + 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: - print(f"Warning: Interaction had unknown prefix {interaction[0]}") - lines.append(f"""
{data}
""") - - samples.append(''.join(lines)) + 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: + samples.append(line + '\\vspace{-15pt}'.join(lines)) + else: + samples.append(line + ''.join(lines)) casenum += 1 continue + if not sample.endswith(".in"): continue sample_name = sample[:-3] @@ -124,6 +158,14 @@ def samples_to_html(problem_root: str) -> List[str]: """ % ({"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(samples[-1]) + temp_file.flush() + samples[-1] = os.popen(f"pandoc {temp_file.name} -t markdown").read() + casenum += 1 return samples diff --git a/problemtools/templates/markdown_pdf/fix_tables.md b/problemtools/templates/markdown_pdf/fix_tables.md index fd597724..1b04614f 100644 --- a/problemtools/templates/markdown_pdf/fix_tables.md +++ b/problemtools/templates/markdown_pdf/fix_tables.md @@ -1,5 +1,7 @@ --- header-includes: + - '\usepackage{float}' + - '\usepackage{booktabs}' - '\usepackage{xstring}' - '\setlength{\aboverulesep}{0pt}' - '\setlength{\belowrulesep}{0pt}' From ad3e801c453a19bd174750b4e3107f4f50d18f62 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 02:14:57 +0200 Subject: [PATCH 16/53] Remove bplusa --- examples/bplusa/data/sample/1.ans | 1 - examples/bplusa/data/sample/1.in | 1 - examples/bplusa/data/secret/1.ans | 1 - examples/bplusa/data/secret/1.in | 1 - examples/bplusa/data/secret/2.ans | 1 - examples/bplusa/data/secret/2.in | 1 - examples/bplusa/data/secret/3.ans | 1 - examples/bplusa/data/secret/3.in | 1 - .../input_validators/validator/validator.cpp | 8 - .../input_validators/validator/validator.h | 356 ------------------ .../output_validators/validator/validate.cc | 64 ---- .../output_validators/validator/validate.h | 153 -------- examples/bplusa/problem.yaml | 4 - .../bplusa/problem_statement/problem.en.md | 8 - .../bplusa/submissions/accepted/cplus1.cpp | 10 - examples/bplusa/submissions/accepted/zero.cpp | 10 - 16 files changed, 621 deletions(-) delete mode 100644 examples/bplusa/data/sample/1.ans delete mode 100644 examples/bplusa/data/sample/1.in delete mode 100644 examples/bplusa/data/secret/1.ans delete mode 100644 examples/bplusa/data/secret/1.in delete mode 100644 examples/bplusa/data/secret/2.ans delete mode 100644 examples/bplusa/data/secret/2.in delete mode 100644 examples/bplusa/data/secret/3.ans delete mode 100644 examples/bplusa/data/secret/3.in delete mode 100644 examples/bplusa/input_validators/validator/validator.cpp delete mode 100644 examples/bplusa/input_validators/validator/validator.h delete mode 100644 examples/bplusa/output_validators/validator/validate.cc delete mode 100644 examples/bplusa/output_validators/validator/validate.h delete mode 100644 examples/bplusa/problem.yaml delete mode 100644 examples/bplusa/problem_statement/problem.en.md delete mode 100644 examples/bplusa/submissions/accepted/cplus1.cpp delete mode 100644 examples/bplusa/submissions/accepted/zero.cpp diff --git a/examples/bplusa/data/sample/1.ans b/examples/bplusa/data/sample/1.ans deleted file mode 100644 index 654d5269..00000000 --- a/examples/bplusa/data/sample/1.ans +++ /dev/null @@ -1 +0,0 @@ -2 3 diff --git a/examples/bplusa/data/sample/1.in b/examples/bplusa/data/sample/1.in deleted file mode 100644 index 7ed6ff82..00000000 --- a/examples/bplusa/data/sample/1.in +++ /dev/null @@ -1 +0,0 @@ -5 diff --git a/examples/bplusa/data/secret/1.ans b/examples/bplusa/data/secret/1.ans deleted file mode 100644 index 1790e253..00000000 --- a/examples/bplusa/data/secret/1.ans +++ /dev/null @@ -1 +0,0 @@ -123 0 diff --git a/examples/bplusa/data/secret/1.in b/examples/bplusa/data/secret/1.in deleted file mode 100644 index 190a1803..00000000 --- a/examples/bplusa/data/secret/1.in +++ /dev/null @@ -1 +0,0 @@ -123 diff --git a/examples/bplusa/data/secret/2.ans b/examples/bplusa/data/secret/2.ans deleted file mode 100644 index 93fd4034..00000000 --- a/examples/bplusa/data/secret/2.ans +++ /dev/null @@ -1 +0,0 @@ -992 0 diff --git a/examples/bplusa/data/secret/2.in b/examples/bplusa/data/secret/2.in deleted file mode 100644 index 7f9d7e97..00000000 --- a/examples/bplusa/data/secret/2.in +++ /dev/null @@ -1 +0,0 @@ -992 diff --git a/examples/bplusa/data/secret/3.ans b/examples/bplusa/data/secret/3.ans deleted file mode 100644 index 80c0cc79..00000000 --- a/examples/bplusa/data/secret/3.ans +++ /dev/null @@ -1 +0,0 @@ -1 0 diff --git a/examples/bplusa/data/secret/3.in b/examples/bplusa/data/secret/3.in deleted file mode 100644 index d00491fd..00000000 --- a/examples/bplusa/data/secret/3.in +++ /dev/null @@ -1 +0,0 @@ -1 diff --git a/examples/bplusa/input_validators/validator/validator.cpp b/examples/bplusa/input_validators/validator/validator.cpp deleted file mode 100644 index 0ecff521..00000000 --- a/examples/bplusa/input_validators/validator/validator.cpp +++ /dev/null @@ -1,8 +0,0 @@ -#include "validator.h" - - -void run() { - Int(1, 1000); - Endl(); - Eof(); -} diff --git a/examples/bplusa/input_validators/validator/validator.h b/examples/bplusa/input_validators/validator/validator.h deleted file mode 100644 index f42bc2d7..00000000 --- a/examples/bplusa/input_validators/validator/validator.h +++ /dev/null @@ -1,356 +0,0 @@ -#ifdef NDEBUG -#error Asserts must be enabled! Do not set NDEBUG. -#endif -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -using namespace std; - -// Implemented by you! -void run(); - -// PUBLIC API -// (extend if you need to) - -[[noreturn]] -void die(const string& msg); -[[noreturn]] -void die_line(const string& msg); - -struct ArgType { - string _name, _x; - ArgType(const string& name, const string& x) : _name(name), _x(x) {} - operator string() const { return _x; } - operator long long() const; - operator bool() const; - operator int() const; -}; - -struct IntType { - long long _x; - IntType(long long x) : _x(x) {} - operator long long() const { return _x; } - operator int() const; - operator bool() const; -}; - -ArgType Arg(const string& name); - -ArgType Arg(const string& name, long long _default); - -string Arg(const string& name, const string& _default); - -template -void AssertUnique(const Vec& v); - -namespace IO { - IntType Int(long long lo, long long hi); - double Float(double lo, double hi, bool strict = true); - template - vector SpacedInts(long long count, T lo, T hi); - vector SpacedFloats(long long count, double lo, double hi); - void Char(char expected); - char Char(); - string Line(); - void Endl() { Char('\n'); } - void Space() { Char(' '); } - void Eof() { Char(-1); } -}; -using namespace IO; - -// INTERNALS - -bool _validator_initialized; -struct _validator { - map params; - set used_params; - - void construct(int argc, char** argv) { - _validator_initialized = true; - for (int i = 1; i < argc; i++) { - string s = argv[i]; - size_t ind = s.find('='); - if (ind == string::npos) continue; - auto before = s.substr(0, ind), after = s.substr(ind + 1); - if (params.count(before)) - die("Duplicate parameter " + before); - params[before] = after; - } - } - - void destroy() { - assert(_validator_initialized); - if (!params.empty()) { - string name = params.begin()->first; - die("Unused parameter " + name); - } - IO::Eof(); - _Exit(42); - } - - bool has_var(const string& name) { - if (!_validator_initialized) die("Must not read variables before main"); - return params.count(name) || used_params.count(name); - } - - string get_var(const string& name) { - if (!_validator_initialized) die("Must not read variables before main"); - if (used_params.count(name)) die("Must not read parameter " + name + " twice (either typo or slow)"); - if (!params.count(name)) die("No parameter " + name); - string res = params.at(name); - params.erase(name); - used_params.insert(name); - return res; - } -} _validator_inst; - -void die(const string& msg) { - cerr << msg << endl; - ofstream fout("/tmp/input_validator_msg", ios::app); - fout << msg << endl; - fout.close(); - _Exit(43); -} - -ArgType::operator long long() const { - string dummy; - { - long long num; - istringstream iss(_x); - iss >> num; - if (iss && !(iss >> dummy)) return num; - } - { - // We also allow scientific notation, for clarity - long double num; - istringstream iss(_x); - iss >> num; - if (iss && !(iss >> dummy)) return (long long)num; - } - die("Unable to parse value " + _x + " for parameter " + _name); -} - -ArgType::operator int() const { - long long val = (long long)*this; - if (val < INT_MIN || val > INT_MAX) - die("number " + to_string(val) + " is too large for an int for parameter " + _name); - return (int)val; -} - -ArgType::operator bool() const { - long long val = (long long)*this; - if (val < 0 || val > 1) - die("number " + to_string(val) + " is not boolean (0/1), for parameter " + _name); - return (bool)val; -} - -IntType::operator int() const { - long long val = (long long)*this; - if (val < INT_MIN || val > INT_MAX) - die_line("number " + to_string(val) + " is too large for an int"); - return (int)val; -} - -IntType::operator bool() const { - long long val = (long long)*this; - if (val < 0 || val > 1) - die_line("number " + to_string(val) + " is not boolean (0/1)"); - return (bool)val; -} - -ArgType Arg(const string& name) { - return {name, _validator_inst.get_var(name)}; -} - -ArgType Arg(const string& name, long long _default) { - if (!_validator_inst.has_var(name)) - return {name, to_string(_default)}; - ArgType ret = Arg(name); - (void)(long long)ret; - return ret; -} - -string Arg(const string& name, const string& _default) { - if (!_validator_inst.has_var(name)) - return _default; - return (string)Arg(name); -} - -static int _lineno = 1, _consumed_lineno = -1, _hit_char_error = 0; -char _peek1(); -void die_line(const string& msg) { - if (!_hit_char_error && _peek1() == -1) die(msg); - else if (_consumed_lineno == -1) die(msg + " (before reading any input)"); - else die(msg + " on line " + to_string(_consumed_lineno)); -} - -static char _buffer = -2; // -2 = none, -1 = eof, other = that char -char _peek1() { - if (_buffer != -2) return _buffer; - int val = getchar_unlocked(); - static_assert(EOF == -1, ""); - static_assert(CHAR_MIN == -128, ""); - if (val == -2 || val < CHAR_MIN || val >= CHAR_MAX) { - _hit_char_error = 1; - die_line("Unable to process byte " + to_string(val)); - } - _buffer = (char)val; - return _buffer; -} -void _use_peek(char ch) { - _buffer = -2; - if (ch == '\n') _lineno++; - else _consumed_lineno = _lineno; -} -char _read1() { - char ret = _peek1(); - _use_peek(ret); - return ret; -} -string _token() { - string ret; - for (;;) { - char ch = _peek1(); - if (ch == ' ' || ch == '\n' || ch == -1) { - break; - } - _use_peek(ch); - ret += ch; - } - return ret; -} -string _describe(char ch) { - assert(ch != -2); - if (ch == -1) return "EOF"; - if (ch == ' ') return "SPACE"; - if (ch == '\r') return "CARRIAGE RETURN"; - if (ch == '\n') return "NEWLINE"; - if (ch == '\t') return "TAB"; - if (ch == '\'') return "\"'\""; - return string("'") + ch + "'"; -} - -IntType IO::Int(long long lo, long long hi) { - string s = _token(); - if (s.empty()) die_line("Expected number, saw " + _describe(_peek1())); - try { - long long mul = 1; - int ind = 0; - if (s[0] == '-') { - mul = -1; - ind = 1; - } - if (ind == (int)s.size()) throw false; - char ch = s[ind++]; - if (ch < '0' || ch > '9') throw false; - if (ch == '0' && ind != (int)s.size()) throw false; - long long ret = ch - '0'; - while (ind < (int)s.size()) { - if (ret > LLONG_MAX / 10 - 20 || ret < LLONG_MIN / 10 + 20) - throw false; - ret *= 10; - ch = s[ind++]; - if (ch < '0' || ch > '9') throw false; - ret += ch - '0'; - } - ret *= mul; - if (ret < lo || ret > hi) die_line("Number " + s + " is out of range [" + to_string(lo) + ", " + to_string(hi) + "]"); - return {ret}; - } catch (bool) { - die_line("Unable to parse \"" + s + "\" as integer"); - } -} - -template -vector IO::SpacedInts(long long count, T lo, T hi) { - vector res; - res.reserve(count); - for (int i = 0; i < count; i++) { - if (i != 0) IO::Space(); - res.emplace_back((T)IO::Int(lo, hi)); - } - IO::Endl(); - return res; -} - -vector IO::SpacedFloats(long long count, double lo, double hi) { - vector res; - res.reserve(count); - for (int i = 0; i < count; i++) { - if (i != 0) IO::Space(); - res.emplace_back(IO::Float(lo, hi)); - } - IO::Endl(); - return res; -} - -double IO::Float(double lo, double hi, bool strict) { - string s = _token(); - if (s.empty()) die_line("Expected floating point number, saw " + _describe(_peek1())); - istringstream iss(s); - double res; - string dummy; - iss >> res; - if (!iss || iss >> dummy) die_line("Unable to parse " + s + " as a float"); - if (res < lo || res > hi) die_line("Floating-point number " + s + " is out of range [" + to_string(lo) + ", " + to_string(hi) + "]"); - if (res != res) die_line("Floating-point number " + s + " is NaN"); - if (strict) { - if (s.find('.') != string::npos && s.back() == '0' && s.substr(s.size() - 2) != ".0") - die_line("Number " + s + " has unnecessary trailing zeroes"); - if (s[0] == '0' && s.size() > 1 && s[1] == '0') - die_line("Number " + s + " has unnecessary leading zeroes"); - } - return res; -} - -char IO::Char() { - char ret = _read1(); - if (ret == -1) die_line("Expected character, saw EOF"); - return ret; -} - -void IO::Char(char expected) { - char ret = _peek1(); - if (ret != expected) die_line("Expected " + _describe(expected) + ", saw " + _describe(ret)); - _use_peek(ret); -} - -string IO::Line() { - string ret; - for (;;) { - char ch = IO::Char(); - if (ch == '\n') break; - ret += ch; - } - return ret; -} - -template -void AssertUnique(const Vec& v_) { - Vec v = v_; - auto beg = v.begin(), end = v.end(); - sort(beg, end); - int size = (int)(end - beg); - for (int i = 0; i < size - 1; i++) { - if (v[i] == v[i+1]) { - ostringstream oss; - oss << "Vector contains duplicate value " << v[i]; - die_line(oss.str()); - } - } -} - -int main(int argc, char** argv) { - _validator_inst.construct(argc, argv); - run(); - _validator_inst.destroy(); -} - diff --git a/examples/bplusa/output_validators/validator/validate.cc b/examples/bplusa/output_validators/validator/validate.cc deleted file mode 100644 index 61eabfc2..00000000 --- a/examples/bplusa/output_validators/validator/validate.cc +++ /dev/null @@ -1,64 +0,0 @@ -#include "validate.h" - -#include -using namespace std; - -#define rep(i, a, b) for(int i = a; i < (b); ++i) -#define all(x) begin(x), end(x) -#define sz(x) (int)(x).size() -typedef long long ll; -typedef pair pii; -typedef vector vi; -typedef vector vvi; -typedef long double ld; - -#define repe(i, container) for (auto& i : container) - -void check_isvalid(int a, int b, int c, feedback_function feedback) -{ - if (a==b) feedback("a is equal to b"); - if (a+b!=c) feedback("b+a!=c"); -} - -const int HUNDRED_THOUSAND = int(1e5); -int main(int argc, char **argv) { - init_io(argc, argv); - - // Read the testcase input - int c; - judge_in >> c; - - auto check = [&](istream& sol, feedback_function feedback) { - int a, b; - // Don't get stuck waiting for output from solution - if(!(sol >> a >> b)) feedback("Expected more output"); - // Validate constraints - if (a < -HUNDRED_THOUSAND || a > HUNDRED_THOUSAND) feedback("a is too big or large"); - if (b < -HUNDRED_THOUSAND || b > HUNDRED_THOUSAND) feedback("b is too big or large"); - - // Check that they actually solved the task - check_isvalid(a, b, c, feedback); - - // Disallow trailing output - string trailing; - if(sol >> trailing) feedback("Trailing output"); - return true; - }; - - // Check both the judge's and contestants' output - // It is good practice to not assume that the judge is correct/optimal - bool judge_found_sol = check(judge_ans, judge_error); - bool author_found_sol = check(author_out, wrong_answer); - - // In this problem, having a return value from check is unnecessary - // However, if there isn't always a solution, we will get a nice - // judge error if the judge solution claims no solution exists, while - // a contestant finds one - if(!judge_found_sol) - judge_error("NO! Judge did not find valid solution"); - - if(!author_found_sol) - wrong_answer("Contestant did not find valid solution"); - - accept(); -} diff --git a/examples/bplusa/output_validators/validator/validate.h b/examples/bplusa/output_validators/validator/validate.h deleted file mode 100644 index c59c5fdb..00000000 --- a/examples/bplusa/output_validators/validator/validate.h +++ /dev/null @@ -1,153 +0,0 @@ -/* Utility functions for writing output validators for the Kattis - * problem format. - * - * The primary functions and variables available are the following. - * In many cases, the only functions needed are "init_io", - * "wrong_answer", and "accept". - * - * - init_io(argc, argv): - * initialization - * - * - judge_in, judge_ans, author_out: - * std::istream objects for judge input file, judge answer - * file, and submission output file. - * - * - accept(): - * exit and give Accepted! - * - * - accept_with_score(double score): - * exit with Accepted and give a score (for scoring problems) - * - * - judge_message(std::string msg, ...): - * printf-style function for emitting a judge message (a - * message that gets displayed to a privileged user with access - * to secret data etc). - * - * - wrong_answer(std::string msg, ...): - * printf-style function for exitting and giving Wrong Answer, - * and emitting a judge message (which would typically explain - * the cause of the Wrong Answer) - * - * - judge_error(std::string msg, ...): - * printf-style function for exitting and giving Judge Error, - * and emitting a judge message (which would typically explain - * the cause of the Judge Error) - * - * - author_message(std::string msg, ...): - * printf-style function for emitting an author message (a - * message that gets displayed to the author of the - * submission). (Use with caution, and be careful not to let - * it leak information!) - * - */ -#pragma once - -#include -#include -#include -#include -#include -#include -#include - -typedef void (*feedback_function)(const char*, ...); - -const int EXITCODE_AC = 42; -const int EXITCODE_WA = 43; -const char* FILENAME_AUTHOR_MESSAGE = "teammessage.txt"; -const char* FILENAME_JUDGE_MESSAGE = "judgemessage.txt"; -const char* FILENAME_JUDGE_ERROR = "judgeerror.txt"; -const char* FILENAME_SCORE = "score.txt"; - -#define USAGE "%s: judge_in judge_ans feedback_dir < author_out\n" - -std::ifstream judge_in, judge_ans; -std::istream author_out(std::cin.rdbuf()); - -char *feedbackdir = NULL; - -void vreport_feedback(const char* category, - const char* msg, - va_list pvar) { - std::ostringstream fname; - if (feedbackdir) - fname << feedbackdir << '/'; - fname << category; - FILE *f = fopen(fname.str().c_str(), "a"); - assert(f); - vfprintf(f, msg, pvar); - fclose(f); -} - -void report_feedback(const char* category, const char* msg, ...) { - va_list pvar; - va_start(pvar, msg); - vreport_feedback(category, msg, pvar); -} - -void author_message(const char* msg, ...) { - va_list pvar; - va_start(pvar, msg); - vreport_feedback(FILENAME_AUTHOR_MESSAGE, msg, pvar); -} - -void judge_message(const char* msg, ...) { - va_list pvar; - va_start(pvar, msg); - vreport_feedback(FILENAME_JUDGE_MESSAGE, msg, pvar); -} - -void wrong_answer(const char* msg, ...) { - va_list pvar; - va_start(pvar, msg); - vreport_feedback(FILENAME_JUDGE_MESSAGE, msg, pvar); - exit(EXITCODE_WA); -} - -void judge_error(const char* msg, ...) { - va_list pvar; - va_start(pvar, msg); - vreport_feedback(FILENAME_JUDGE_ERROR, msg, pvar); - assert(0); -} - -void accept() { - exit(EXITCODE_AC); -} - -void accept_with_score(double scorevalue) { - report_feedback(FILENAME_SCORE, "%.9le", scorevalue); - exit(EXITCODE_AC); -} - - -bool is_directory(const char *path) { - struct stat entry; - return stat(path, &entry) == 0 && S_ISDIR(entry.st_mode); -} - -void init_io(int argc, char **argv) { - if(argc < 4) { - fprintf(stderr, USAGE, argv[0]); - judge_error("Usage: %s judgein judgeans feedbackdir [opts] < userout", argv[0]); - } - - // Set up feedbackdir first, as that allows us to produce feedback - // files for errors in the other parameters. - if (!is_directory(argv[3])) { - judge_error("%s: %s is not a directory\n", argv[0], argv[3]); - } - feedbackdir = argv[3]; - - judge_in.open(argv[1], std::ios_base::in); - if (judge_in.fail()) { - judge_error("%s: failed to open %s\n", argv[0], argv[1]); - } - - judge_ans.open(argv[2], std::ios_base::in); - if (judge_ans.fail()) { - judge_error("%s: failed to open %s\n", argv[0], argv[2]); - } - - author_out.rdbuf(std::cin.rdbuf()); -} diff --git a/examples/bplusa/problem.yaml b/examples/bplusa/problem.yaml deleted file mode 100644 index d59b82ec..00000000 --- a/examples/bplusa/problem.yaml +++ /dev/null @@ -1,4 +0,0 @@ -source: Kattis -license: public domain -name: B plus A -validation: custom diff --git a/examples/bplusa/problem_statement/problem.en.md b/examples/bplusa/problem_statement/problem.en.md deleted file mode 100644 index d5060a86..00000000 --- a/examples/bplusa/problem_statement/problem.en.md +++ /dev/null @@ -1,8 +0,0 @@ -Given the integer $c$, find any pair of integers $b$ and $a$ satisfying $b+a=c$ and $a \neq b$. - -## Input -Input consists of the integer $C$ ($1 \le C \le 1000$). - -## Output -Output $b$ and $a$, separated by a space. Any $b$, $a$ satisfying above constraints and $-10^5 \leq a,b \leq 10^5$ -will be accepted. diff --git a/examples/bplusa/submissions/accepted/cplus1.cpp b/examples/bplusa/submissions/accepted/cplus1.cpp deleted file mode 100644 index 946facb7..00000000 --- a/examples/bplusa/submissions/accepted/cplus1.cpp +++ /dev/null @@ -1,10 +0,0 @@ -#include -using namespace std; - -int main() -{ - int c; - cin >> c; - cout << c+1 << " " << -1 << endl; - return 0; -} diff --git a/examples/bplusa/submissions/accepted/zero.cpp b/examples/bplusa/submissions/accepted/zero.cpp deleted file mode 100644 index 2f4c748a..00000000 --- a/examples/bplusa/submissions/accepted/zero.cpp +++ /dev/null @@ -1,10 +0,0 @@ -#include -using namespace std; - -int main() -{ - int c; - cin >> c; - cout << c << " " << 0 << endl; - return 0; -} From 30d9603e1d7aa505cb8ebce05021ce4a5329235e Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 02:47:41 +0200 Subject: [PATCH 17/53] PDF problem name --- problemtools/problem2html.py | 3 +++ problemtools/problem2pdf.py | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/problemtools/problem2html.py b/problemtools/problem2html.py index 9536da38..c9ffe221 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -13,6 +13,9 @@ def convert(options: argparse.Namespace) -> None: 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) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 77081f8a..63c1a1cd 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -38,9 +38,12 @@ def convert(options: argparse.Namespace) -> bool: with open(statement_path, "r") as f: statement_md = f.read() + problem_name = statement_common.get_problem_name(problem_root, options.language) + # Add code that adds vertical and horizontal lines to all tables + statement_md = r'\centerline{\huge %s}' % problem_name + statement_md statement_md = table_fix + statement_md - + # Hacky: html samples -> md. Then we append to the markdown document samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) From efc5c9e6b666839e9ec96502a079115dbf6446d9 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 10:46:43 +0200 Subject: [PATCH 18/53] Add dependencies --- Dockerfile | 3 ++- README.md | 4 ++-- admin/docker/Dockerfile.minimal | 3 ++- debian/control | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index daa50dde..cff647c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,11 +14,12 @@ RUN apt-get update && \ libgmp10 \ libgmpxx4ldbl \ openjdk-8-jdk \ + pandoc \ python3-minimal \ python3-pip \ python3-plastex \ - python3-markdown \ python3-yaml \ + rsvg-convert \ sudo \ texlive-fonts-recommended \ texlive-lang-cyrillic \ diff --git a/README.md b/README.md index 499fe610..4e708b1a 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-markdown 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-markdown 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 python3-markdown 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 a44811f5..340f0b20 100644 --- a/admin/docker/Dockerfile.minimal +++ b/admin/docker/Dockerfile.minimal @@ -20,11 +20,12 @@ RUN apt update && \ apt install -y \ ghostscript \ libgmpxx4ldbl \ + pandoc \ python-pkg-resources \ python3-minimal \ python3-yaml \ python3-plastex \ - python3-markdown \ + rsvg-convert \ texlive-fonts-recommended \ texlive-lang-cyrillic \ texlive-latex-extra \ diff --git a/debian/control b/debian/control index 43410292..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-markdown, 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 From 762599f9f70364787257b61825a19e3134da30ce Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 10:57:15 +0200 Subject: [PATCH 19/53] Add problem names --- examples/different/problem.yaml | 5 +++++ examples/guess/problem.yaml | 1 + examples/hello/problem.yaml | 3 +++ examples/oddecho/problem.yaml | 4 ++-- 4 files changed, 11 insertions(+), 2 deletions(-) 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 c1e29500..bf832bb2 100644 --- a/examples/guess/problem.yaml +++ b/examples/guess/problem.yaml @@ -4,6 +4,7 @@ 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/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/problem.yaml b/examples/oddecho/problem.yaml index f213fbd9..3a918455 100644 --- a/examples/oddecho/problem.yaml +++ b/examples/oddecho/problem.yaml @@ -3,7 +3,7 @@ author: Johan Sannemo source: Principles of Algorithmic Problem Solving type: scoring name: - en: Echo - sv: Eko + en: Odd Echo + sv: Udda Eko grading: show_test_data_groups: true From 2bba9d4c935c6a51690bcdf9fbff9f731a2f2002 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 11:04:22 +0200 Subject: [PATCH 20/53] Added problem name to test hello package --- problemtools/tests/hello/problem.yaml | 3 +++ 1 file changed, 3 insertions(+) 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 From cdd1804a06605db2c823e288834c48742486127a Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 11:21:34 +0200 Subject: [PATCH 21/53] Improve security by running pandoc without shell capabilities --- problemtools/md2html.py | 9 +++++---- problemtools/problem2pdf.py | 4 ++-- problemtools/statement_common.py | 4 +++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index d764b4f4..76ccd4e4 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -4,9 +4,8 @@ import os.path import string import argparse -import re import json -from typing import Optional +import subprocess from . import statement_common @@ -34,7 +33,8 @@ def convert(problem: str, options: argparse.Namespace) -> None: _copy_images(statement_path, lambda img_name: handle_image(os.path.join(problem, "problem_statement", img_name))) - statement_html = os.popen(f"pandoc {statement_path} -t html").read() + command = ["pandoc", statement_path, "-t" , "html"] + statement_html = subprocess.run(command, capture_output=True, text=True, shell=False).stdout templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -102,7 +102,8 @@ def json_dfs(data, callback) -> None: def _copy_images(statement_path, callback): - statement_json = os.popen(f"pandoc {statement_path} -t json").read() + command = ["pandoc", statement_path, "-t" , "json"] + statement_json = subprocess.run(command, capture_output=True, text=True, shell=False).stdout json_dfs(json.loads(statement_json), callback) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 63c1a1cd..4eeeea0f 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -53,8 +53,8 @@ def convert(options: argparse.Namespace) -> bool: with tempfile.NamedTemporaryFile(mode='w', suffix=".md") as temp_file: temp_file.write(statement_md) temp_file.flush() - # Do .read so that the temp file isn't deleted until pandoc is done - os.popen(f"pandoc {temp_file.name} -o {problembase}.pdf --resource-path={statement_dir}").read() + command = ["pandoc", temp_file.name, "-o", f"{problembase}.pdf", f"--resource-path={statement_dir}"] + subprocess.run(command, capture_output=True, text=True, shell=False) else: # Set up template if necessary diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index cb6960f0..5b2bd29f 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -2,6 +2,7 @@ from typing import Optional, List import html import tempfile +import subprocess from . import verifyproblem @@ -164,7 +165,8 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: temp_file.write(samples[-1]) temp_file.flush() - samples[-1] = os.popen(f"pandoc {temp_file.name} -t markdown").read() + command = ["pandoc", temp_file.name, "-t" , "markdown"] + samples[-1] = subprocess.run(command, capture_output=True, text=True, shell=False).stdout casenum += 1 From 194c7b1a11e1af2bbe16b95232c8b7c3c8ea6727 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 11:33:09 +0200 Subject: [PATCH 22/53] Refactoring --- problemtools/md2html.py | 16 +++++++++------- problemtools/problem2pdf.py | 18 +++++++++--------- problemtools/statement_common.py | 16 ++++++++-------- problemtools/templates/markdown/problem.css | 13 +++++-------- 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 76ccd4e4..62a8e153 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -1,6 +1,5 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- -import html import os.path import string import argparse @@ -12,9 +11,9 @@ FOOTNOTES_STRING = '
' -def convert(problem: str, options: argparse.Namespace) -> None: +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 @@ -34,7 +33,8 @@ def convert(problem: str, options: argparse.Namespace) -> None: _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).stdout + 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'), os.path.join(os.path.dirname(__file__), '../templates/markdown'), @@ -66,11 +66,13 @@ def convert(problem: str, options: argparse.Namespace) -> None: 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 + Copies the image from the statement to the output directory Args: src: full file path to the image @@ -103,7 +105,8 @@ def json_dfs(data, callback) -> None: def _copy_images(statement_path, callback): command = ["pandoc", statement_path, "-t" , "json"] - statement_json = subprocess.run(command, capture_output=True, text=True, shell=False).stdout + statement_json = subprocess.run(command, capture_output=True, + text=True, shell=False, check=True).stdout json_dfs(json.loads(statement_json), callback) @@ -135,4 +138,3 @@ def _substitute_template(templatepath: str, templatefile: str, **params) -> str: 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/problem2pdf.py b/problemtools/problem2pdf.py index 4eeeea0f..911c5cb6 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -20,7 +20,7 @@ def convert(options: argparse.Namespace) -> bool: 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'] @@ -30,20 +30,20 @@ def convert(options: argparse.Namespace) -> bool: 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 f: - table_fix = f.read() + + 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 f: - statement_md = f.read() - + with open(statement_path, "r") as file: + statement_md = file.read() + problem_name = statement_common.get_problem_name(problem_root, options.language) # Add code that adds vertical and horizontal lines to all tables statement_md = r'\centerline{\huge %s}' % problem_name + statement_md statement_md = table_fix + statement_md - + # Hacky: html samples -> md. Then we append to the markdown document samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) @@ -54,7 +54,7 @@ def convert(options: argparse.Namespace) -> bool: temp_file.write(statement_md) temp_file.flush() command = ["pandoc", temp_file.name, "-o", f"{problembase}.pdf", f"--resource-path={statement_dir}"] - subprocess.run(command, capture_output=True, text=True, shell=False) + return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) else: # Set up template if necessary diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 5b2bd29f..97b71170 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -26,7 +26,7 @@ def find_statement(problem_root: str, extension: str, language: Optional[str]) - 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 """ @@ -51,7 +51,7 @@ def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: with verifyproblem.Problem(problem) as prob: config = verifyproblem.ProblemConfig(prob) if not config.check(None): - raise Exception(f"Invalid problem.yaml") + 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: @@ -64,7 +64,7 @@ def get_problem_name(problem: str, language: Optional[str]) -> Optional[str]: 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 @@ -73,7 +73,7 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: 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") @@ -95,7 +95,7 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: Write """ - + with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: sample_interaction = infile.readlines() lines = [] @@ -159,16 +159,16 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: """ % ({"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(samples[-1]) temp_file.flush() command = ["pandoc", temp_file.name, "-t" , "markdown"] - samples[-1] = subprocess.run(command, capture_output=True, text=True, shell=False).stdout + samples[-1] = subprocess.run(command, capture_output=True, text=True, + shell=False, check=True).stdout casenum += 1 return samples - diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown/problem.css index 8d354eca..ca6e72ed 100644 --- a/problemtools/templates/markdown/problem.css +++ b/problemtools/templates/markdown/problem.css @@ -35,9 +35,6 @@ table:not(.sample) td { /*Style sample in its own way*/ .sample { font-family: Arial, Helvetica, sans-serif; -} - -.sample { width: 100%; } @@ -94,20 +91,20 @@ div.sampleinteractionread { border: 1px solid black; width: 60%; float: left; - margin: 3px 0px 3px 0px; + margin: 3px 0px; } .sampleinteractionread pre { - margin: 1px 5px 1px 5px; + margin: 1px 5px; } div.sampleinteractionwrite { border: 1px solid black; width: 60%; float: right; - margin: 3px 0px 3px 0px; + margin: 3px 0px; } .sampleinteractionwrite pre { - margin: 1px 5px 1px 5px; -} \ No newline at end of file + margin: 1px 5px; +} From 554892a122a7e84516486d78888d18a92f8f81ee Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 11:59:18 +0200 Subject: [PATCH 23/53] Even more refactoring --- problemtools/md2html.py | 2 +- problemtools/problem2pdf.py | 117 +++++++++++--------- problemtools/statement_common.py | 181 ++++++++++++++++++------------- 3 files changed, 174 insertions(+), 126 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 62a8e153..7b834d22 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -66,7 +66,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: 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 diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 911c5cb6..09ed5962 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -12,80 +12,93 @@ def convert(options: argparse.Namespace) -> bool: 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) - if statement_common.find_statement_extension(problem_root, language=options.language) == "md": - statement_path = statement_common.find_statement(problem_root, extension="md", language=options.language) + 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") + 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") + 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() + 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() + 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_name = statement_common.get_problem_name(problem_root, options.language) - # Add code that adds vertical and horizontal lines to all tables - statement_md = r'\centerline{\huge %s}' % problem_name + statement_md - statement_md = table_fix + statement_md + # Add code that adds vertical and horizontal lines to all tables + statement_md = r'\centerline{\huge %s}' % problem_name + statement_md + statement_md = table_fix + statement_md - # Hacky: html samples -> md. Then we append to the markdown document - samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) + samples = "\n".join(statement_common.format_samples(problem_root, to_pdf=True)) - # If we don't add newline, the table might get attached to a footnote - statement_md += "\n" + samples + # 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", f"{problembase}.pdf", f"--resource-path={statement_dir}"] - return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) + 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) - else: - # Set up template if necessary - with template.Template(problem_root, language=options.language) as templ: - texfile = templ.get_file_name() - origcwd = os.getcwd() +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) - os.chdir(os.path.dirname(texfile)) - params = ['pdflatex', '-interaction=nonstopmode'] - output = None - if options.quiet: - output = open(os.devnull, 'w') - if options.nopdf: - params.append('-draftmode') + # Set up template if necessary + with template.Template(problem_root, language=options.language) as templ: + texfile = templ.get_file_name() - params.append(texfile) + origcwd = os.getcwd() + os.chdir(os.path.dirname(texfile)) + params = ['pdflatex', '-interaction=nonstopmode'] + output = None + if options.quiet: + output = open(os.devnull, 'w') + if options.nopdf: + params.append('-draftmode') + + params.append(texfile) + + status = subprocess.call(params, stdout=output) + if status == 0: status = subprocess.call(params, stdout=output) - if status == 0: - status = subprocess.call(params, stdout=output) - if output is not None: - output.close() + if output is not None: + output.close() + + os.chdir(origcwd) - os.chdir(origcwd) + if status == 0 and not options.nopdf: + shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) - if status == 0 and not options.nopdf: - shutil.move(os.path.splitext(texfile)[0] + '.pdf', destfile) + return status == 0 - 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 index 97b71170..66a6c673 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -82,55 +82,7 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: casenum = 1 for sample in sorted(os.listdir(sample_path)): if sample.endswith(".interaction"): - 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_path, 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: - samples.append(line + '\\vspace{-15pt}'.join(lines)) - else: - samples.append(line + ''.join(lines)) + samples.append(format_interactive_sample(sample_path, sample, casenum, to_pdf)) casenum += 1 continue @@ -140,35 +92,118 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: outpath = os.path.join(sample_path, sample_name + ".ans") if not os.path.isfile(outpath): continue - with open(os.path.join(sample_path, sample), "r", encoding="utf-8") as infile: - sample_input = infile.read() - with open(outpath, "r", encoding="utf-8") as outfile: - sample_output = outfile.read() - samples.append(""" + 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""" - - - + + + - - - - -
Sample Input %(case)dSample Output %(case)dReadSample Interaction {casenum}Write
%(input)s
%(output)s
""" - % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)})) + 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 pdf, convert to markdown - with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: - temp_file.write(samples[-1]) - temp_file.flush() - command = ["pandoc", temp_file.name, "-t" , "markdown"] - samples[-1] = subprocess.run(command, capture_output=True, text=True, - shell=False, check=True).stdout - - casenum += 1 + 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}
""") - return samples + if to_pdf: + return line + '\\vspace{-15pt}'.join(lines) + else: + return line + ''.join(lines) From d8a4c3e79c91776c924edcee560271fcc94cd8f3 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 12:12:25 +0200 Subject: [PATCH 24/53] Remove python3-markdown dependency --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e708b1a..96758f52 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ 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 pandoc python3-minimal python-pkg-resources python3-plastex python3-markdown python3-yaml rsvg-convert 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 From 7390fb815d4cc72fe7a3a847389d37b1c3e31434 Mon Sep 17 00:00:00 2001 From: matistjati Date: Sun, 18 Aug 2024 12:55:15 +0200 Subject: [PATCH 25/53] Add problem id to pdf and small fixes --- examples/README.md | 6 ------ problemtools/md2html.py | 6 +++--- problemtools/problem2pdf.py | 2 ++ .../{markdown => markdown_html}/default-layout.html | 0 .../templates/{markdown => markdown_html}/problem.css | 2 -- 5 files changed, 5 insertions(+), 11 deletions(-) rename problemtools/templates/{markdown => markdown_html}/default-layout.html (100%) rename problemtools/templates/{markdown => markdown_html}/problem.css (99%) diff --git a/examples/README.md b/examples/README.md index d1076a7e..9d7f9ee5 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,9 +26,3 @@ more than one language. 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. The swedish statement showcases how to use images, footnotes and tables in Markdown. - -# bplusa - -This is an example of a problem using an output validator, where there are multiple valid answers. -The output validator is written pretty generally, guarding against the most common mistakes when using -output validators. It also demonstrates using Markdown as a statement language. diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 7b834d22..3d729e72 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -36,9 +36,9 @@ def convert(problem: str, options: argparse.Namespace) -> bool: 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'), - os.path.join(os.path.dirname(__file__), '../templates/markdown'), - '/usr/lib/problemtools/templates/markdown'] + 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) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 09ed5962..62d40dbe 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -48,7 +48,9 @@ def md2pdf(options: argparse.Namespace) -> bool: 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 diff --git a/problemtools/templates/markdown/default-layout.html b/problemtools/templates/markdown_html/default-layout.html similarity index 100% rename from problemtools/templates/markdown/default-layout.html rename to problemtools/templates/markdown_html/default-layout.html diff --git a/problemtools/templates/markdown/problem.css b/problemtools/templates/markdown_html/problem.css similarity index 99% rename from problemtools/templates/markdown/problem.css rename to problemtools/templates/markdown_html/problem.css index ca6e72ed..c38a4d97 100644 --- a/problemtools/templates/markdown/problem.css +++ b/problemtools/templates/markdown_html/problem.css @@ -85,8 +85,6 @@ td { vertical-align:top; } - - div.sampleinteractionread { border: 1px solid black; width: 60%; From 46a700352fd0f053bb352adc7b215850c89fee2c Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 12 Mar 2025 00:24:10 +0100 Subject: [PATCH 26/53] Disable html --- problemtools/md2html.py | 4 ++-- problemtools/problem2pdf.py | 2 +- problemtools/statement_common.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 3d729e72..7b398369 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -32,7 +32,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: _copy_images(statement_path, lambda img_name: handle_image(os.path.join(problem, "problem_statement", img_name))) - command = ["pandoc", statement_path, "-t" , "html"] + command = ["pandoc", statement_path, "-t" , "html", "-f", "markdown-raw_html"] statement_html = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout @@ -104,7 +104,7 @@ def json_dfs(data, callback) -> None: def _copy_images(statement_path, callback): - command = ["pandoc", statement_path, "-t" , "json"] + command = ["pandoc", statement_path, "-t" , "json", "-f", "markdown-raw_html"] statement_json = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout json_dfs(json.loads(statement_json), callback) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 62d40dbe..2b686500 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -62,7 +62,7 @@ def md2pdf(options: argparse.Namespace) -> bool: 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}"] + command = ["pandoc", temp_file.name, "-o", destfile, f"--resource-path={statement_dir}", "-f", "markdown-raw_html"] return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 66a6c673..2d6f75ad 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -139,7 +139,7 @@ def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bo with tempfile.NamedTemporaryFile(mode='w', suffix=".html") as temp_file: temp_file.write(sample) temp_file.flush() - command = ["pandoc", temp_file.name, "-t" , "markdown"] + command = ["pandoc", temp_file.name, "-t" , "markdown", "-f", "markdown-raw_html"] return subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout else: From 770d5da95adf61d49e90726a50920726c332b729 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 12 Mar 2025 02:07:35 +0100 Subject: [PATCH 27/53] Change to wikimedia example image --- examples/different/problem.yaml | 2 +- examples/oddecho/problem_statement/cave.jpg | Bin 0 -> 104326 bytes .../oddecho/problem_statement/echo_cave.jpg | Bin 35667 -> 0 bytes .../oddecho/problem_statement/problem.sv.md | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 examples/oddecho/problem_statement/cave.jpg delete mode 100644 examples/oddecho/problem_statement/echo_cave.jpg diff --git a/examples/different/problem.yaml b/examples/different/problem.yaml index a7652c2e..64f5357a 100644 --- a/examples/different/problem.yaml +++ b/examples/different/problem.yaml @@ -6,7 +6,7 @@ # author: # The problem name -# En may be omitted, as there is only one language +# "en" may be omitted, as there is only one language name: en: A Different Problem diff --git a/examples/oddecho/problem_statement/cave.jpg b/examples/oddecho/problem_statement/cave.jpg new file mode 100644 index 0000000000000000000000000000000000000000..670bbedae2b38a7db351e96b7a751cb536a48d50 GIT binary patch literal 104326 zcmb4q1xy@a*X~lZltL-)wiIvC;tmUo%i>ntU5aa=xGb=^%fe#CtxzbkNQ*7fB1IOb zK%uzx=gZB_{geOZCil(co#dR!dCyET?>y%`&;0xGZwVj|z{14*&w+)Bg^h)Ujf0Pi z^EjSA#mB=Zdj8@C(Q_g~ViHPH;+GUJ35m#P$tb9(XlQ6&kkT>GQ8Q3d(@_5>2nH55 zHV!rp0WK~9H8Bw}_5b(y*9RcR!&rL~j)}nxctVVUNsRGt5I_e2Jb8kNfq?<|-vb*5 z3m5PN6A$C*qg&wx;0XpM<`b;Pyq{oWVLZXbc=C7`#DJGfID90s+Loluo)NhGX(ePV za*h2u(?9{M^*`iZktcZatWXL;-Kf&0$0*bofd4GQG@Ji);F z&-|FlV;UGwh%uR7^2uV6Fl+N$dPc~lk+PJm>sU4R|62qQJYMqXCk99Z^zy1~Yx5^+ zAyLu&I|uco@5OTH6>Lw4^D4+?7$r4U#LPZl1$&Wfp; z^}T0%D(};qv<7_Qk#lm}IM%jTW(9-es-V(0S>EEFhc{@Q-)BwPiHO?*!zU8AYu2Fp zKi|oM&r0K@sCIxNj2CWvMQ<*si&(k+QX*bpkH%>XEc3afhMX@+YgB%*j8FPa*-{sw z2ay7yrGhVSccry`u+`^{7UR1Fzg|UI`{9co?yRcMWw;yA**s-5Tz*RC9kVPjc95m1 zXy#nq$?fUCO{Pnn10d88DDsk?=wu;sJ(bDLu;^2%j1glYXiZbp)sPTb)Df3vBzXAj zI1I1h@$suHFO9^1FT;}d5zBg{s9tDDaUvk|t?qZy2`B1oI02Y3giw|-(6K8wcQvpO z`qNBSLEmBWZ|N|aaq}1ncL{QyO(ZSR_OXB8=u{X}s!3QoOD!vC=xETkeCmI5P@$xw zt7XDz9bW=O0Ip<-#7}uOk1ILjXh5k)Ck735xN(t+F|HKLM8?@^Yp~-JE<}->Q=ghga^?QMuGwzK>5*OgtB zg+fnXjxx))ga^2!bvEweiaiglPwp2b`m0^_GxEoEiuc2>0Y&y^Plp^p ztr_Y?pAmy`ypE-s9A@58GKowzK%b4_$>`;zB8PL|%#B!1NZdWVBQ7SX zTU^O09qa47!0r9ongt&))}b=UE9&Yn3ijf_v*aY0PGjsK@U+vis8{o_-Zy#2wSef& zeX#ari7`z9A|948)5*^dxE}Iq<`F8vaESJ9_ZgB+%;;_2 ztus9tN9n<6GL=8zRqs{WB&O%r5GUTE#kzl=SGAR@hTr0;4}8Ro{Bk+x(+bs>80UxS z_j|9Vf`}FroZe`&cJiDZGXDeImnuoipHGcLb^S7@cjej08vWM!x*(8eKCfPlW6-oI zi!#2uZzWuzvr+q6AGZM|g6Y#moJ|(Fis}3;&LcVG`$owNK#Oed-0seyEA}07V@e&+ zA;PtJI)lZS zw6;f9ug7saT8ESsj92{7F(Ksm!z#Wh$G$A3qDu!Vkh!(e^lMkK?v*$Hh%R;nUEjrm ziQ4S9!;{(l4O>nb+6(lZQ_fytUG--@azvqbD{cOT{pnnRQ+#P|C5!&#N2)$5O3CC7 zdt6-gX|`k{xSA)Tyre@%(4~PEs~W1!xJ*iqyL36FvMB2kvG3@VIOYG*Df~S)!;aFI z7`L+5tSMi;1dxRtkf|9YhCB;*d?!wTh!A zf;JsbL8KiQ+^|E*c;VN1kj$v7IvR#pThsK!qg!i~i7skVk=kEdNR5DOE^_VlZ_kv_ z8#j2sYtkmNB)|5t-^i{Td*gVqeMgQ=W?8JpwSsF~$i(#C@?J^7$+bT=qQxMSe}4I3 z8wzEiO04J)31=VaNQD19ZdQ8J%{K5=v81zwU z&8F3?t@5hG&-56nfUY-DHRH@M@i-?9p2kp+QN5?$REGsYK_66L(*1AsN%^w7R{|6F z4nuYT(cSrilL?l$?!;#OtZP%GrZ@7qKv^Xt;DHcx{gWvUOFP41J@|VM6W?uG@{hN8 zQ2ZFCDUrXX>!D>zhkBiKw-92UTcx6eVbPhp-D( zJwc9OfbDyFP>X$CuAwLd7KhE)hPS#EE@e8*|81agvJzK}OIk;MBioSiX@;V?EE~%m zol^G_vu(UyESDMvcTXm%7jEYyo9b+ux}^N*%P^o#8;D;bCPeTw3mIoLtQCJMtg9!h zm1jU8+lL`~Io#GIPup@p&20&ZVJG@t)NnQXOkDmm5s@QX3@$a32`RQc_-D-ePZKPt z$PSpwz%H@o6RG7EBBy$1AofCTrYIwLU~=|h@mlTt<>!oFFL;REapWg7G#jcH^xB7+ zl9;fWK46X>MkVfi`v>UF`8_}K6KI?-Kxj&My|ab8ByzK(rw-mNR0g?fo6QHyf%FV1 zFh1+<0CO;-_IAYql4oM$KA8z4zt@=hss7HTyl?Mks#aDDj->2d<9k^0Nl~0R(S>|f z+t%e+YI%)kzLf%BEnIZy>ZVu>4}@I`62}zEjG#Fs@eT_J>h`HGv%J+<5*ih8agVFr zvD}TV$G9!ugKD;aGPH;K65h)5qtx#vEVj&g)eHXt%zZ4|SO#-{#a)=gBTbcFeMd+4 z4@;i?baAjrBe#>Qh`>)67BbXcB8OFR9VTsD<7_{atV&%^UsUlPvv0m#ehCMLwKa?t zFdHYA{%$TeRQPJ_{r3$9uJu8B|G!;jYQ)>Qv@GehAE<<~E& zvR(Rg(}!gatEc9v?B3TW(J?0S7hATp{U*5i<5P7R=t&|=yX_lcgaAHEs=$%C2j2_Q z)Vxlx>N4IY^6h?i=ZbkQ_03pm;)z;grrn}hEUO8<1?S&$R7ShQLERrE z&R!?S!N&E7Wx4!tTsMH&u7>_YwDB{VL=uhIgnxjSFFsj`>_ke33N0#CS0>Br9Iq2# zVf{UyQPBsRtW}VZ_t~5%5s~)HdbF9*L+_3uCZ=346T)fFJTZA(GDJfBY zC`npJt6$#FBXRPHjIZGojT^I+mVN*yOz-Kj?GlB?2pVp?6Z)oPcu#nVUks;zk6m$? zFQqh2MidRnQl&FB;Sh%4fNz~E>fWqy&o(H?xAI^4zQDn@u{g)r=|8SGYw|DO_({Hm z`olj-AWYo_uc^}El8{RqpfqCf5Lp(-81`-L9hXGp;@QJQ9JhlNz*wc3EYHt(=x?xD zue3fxRVf3EWKK-iUTV59xDwYSUOn?|*CnCj$40~mWNmsVv$Y-Hq0KYB{oIEbUax>) z-AwKJnp_z~FY7fiQ}R-n%8Bqsl36U0`iT>hO@J8pf8m9&)+w`S61_A%ZT`%(z76^y z-t!5;@7VD%Kbbk=$Tymv4_3<=`Hwt)#ja*gt#s?6L#NfI&0&k((<(AJrUJp`QIViL zF|po4v=2P@XPYLS2?3$!s)y7^oxrF+{{ZOU{C5(K$OuL#Z?~C|hDPuw9HK8`T6aXS zY*T0Zh;L~FTGKT|{JP0faMK)KP@XM|jvGqXas z8A3utk&$^=8Np2N!sc3IFGlCQ{VHN8dtc*i#YtFG#zRDDkakisuE&*9dA&S}m?req z?-HEKS!WN8_?4;7cG}yzXNFGRCd>&!S(Q6L`{jxJU@DP*$>H@II=@#Tg+ zg2FTsC6ypYj7&!qQ>j%St#IlHT1;twTG7PY_(KeL8D~rOKr=v~!{M<0C*}}CoOYAh zuvmUU872X5@D*0r$;{Bj)Td$2k}AJS?G3t}e0xgiyT*$JqK?@`bD6T{MBf*3Qv=jV zV^N;;uKE7}1-7oLvt^(NFH&;TJ7keMW+6g^)vq|I9Qr25Kw`7XEwnhvr)3spR#Mx+ z1cmh$1*8#rXz=6iRRJj8}R;O3-~3#Dyf;zZq&y=L2g?L|rq4P8qTNNMFsZi%9M z<6Hh&t8_`boMTMD8>-13;yDaAJvt|QF+}S65vEC~ydwO5CxtP9_FgaxwE_mg&SH|~ zQb5C0XkyDek;O5kMhldp{u*>aUfn=Tp)L*}5qr|^Mev>turpQQF-`*0uR4W4wf#O? zoatJPu7ZDfvSj*#U&=J!W}kA`(ebF|oUFo}Ls+>nB^yflM7o_?SaJ4ZvBV>$yj=4c ze+|9zvxr+L?m@*-&wfoDqILS`K7sF9FOPh-jOfQ?%}PRP^}t`bC!AUKHF%?-9I2W= z8^>PB)IZV2oCvK+k}$a)k#|a>=yq6j&|%Xj2&?3Li8kbe&5^FV%l^j4lit#Je3Dt8 z27)9kFt98$Tig?whB;|s>U>D2tLV|8tu+KB+>b5?h-2&U<9a;Zbe>9Kcmft{(<|U{ zZPks?#P(7*ox#QL5H~;_)X2lD_X(Pba($w(6TC?27fT&~aY|Lx!}Vi0IUTlgRN5BO z!te4ve+p~wt*6iJRrKDw`Ui+KwKXpKKnD2sw%KL9`(k1vgjZ<$rdGVQn{`xiM*dLU zR8#qDp#mGGpU5OiHFJS1%^K?;K;RSINh$fF%3{2~bnI6aD1`g%gC3^j$X~RIfs@m1 z$T)8oG4SKp_>xaVV}!Dg6)B%auuCX$Mjb8JQS%^rFyqil7PFI@#jP8uMXsC$q{ViV znU`#OHm3Z*nUm${(QE336RnHlNg0MZ6}>E=inH!NELt*i;%ZiEiI~BwoBSQR z`3O&gate}X;@JZw1QeKzr=5Rie>yoU3Yntb#J&6n7(t8ZtTdnfoqqEnA}TjF3REE{ zA_`2~4Ex*LJA(Y0EA}C-S|w9&2Y6Ey>Qh%8EZ3yk&9r#T<6{GdsvU}}Bzkx*fi?{1 zi*^0SVwG5?9C2c%cqjtn1Ibf|^n(pqAe1Szp_T_DC^N5Gzkh&)IGX^HIFnmzx^w+9 zW3hEs=-WsS9^v6m&|*FS?+h)i<2&;g^y=qxt~d$V#AG8A$;Sd@La@QHD`&;HR0JI- z{KAxWr=4XTB-|+tKdjhNH=5+|40^g)O`rUeFLQvO6vA_pe(T-v zg}S9Bt)B*#T5X?d&$=YpbJl8PK90#Xa?MXZ93Zu7rc~NRxkN%>37T`)Jw_a}SbAqV z9GlvMN&B^XGP3yWqbVh`3ETMq7WLc zOn(Y0`-%mnyhKWS1-DE&T{W^G-HgQ&7Mc9bg+LD6HpoE5H@%J)0SdsR%ayyGZ__po&!xbWeN zI(>0hZN++lM=f9a^1Y-G3g>8wZixI4v6-bJSjg8>Nag)w-w&~5rl$@o8!Gqsqp~XK zJc9fOFmB{&7kQoflw*|EtCknkgEtCxa2PuqU3^5T8d{H&QLXhORJChN{11>F7I;Lk z&XoVdEX`jJLzrMILO!VrewwctWyT&!eIlO1EU;BFKY1@Mi5XY=h$o#R1>f+KtSe|x zI7nEtvFXR{?Yc4GyYW#%MccM0?Ww4SbKk5yb?W-@U6S;bl^^4~e#6v(PI_xoe+pLX zPOj3fgV4whh{-;eRfID-n+u32;vNhxJ5JJTKT4@-24%uVIjfHZQ3VlCIU9eU-aus+ zntnsbU`y&e^=pjPk;%M_wv?Qbl7B>I%odm2k}n()Xqb*dod#9FiLgD^bvV(kP)}FZ zyX_f??wR&yRq1P%k;Yv;rB$TYYC%Lyai@$s2T6wdp&jrq)Oo%)g*2`iFb8OBW&%f#}yA%ccRL#$A!NC18i`b_0l4f##^FV z((cWT{>~YXz0e?A$dVOA%`s%A`)zI9HRmEUbK^sa`-^in;Su_olB z;YLKRMeuD@yW{) zVjRCJ3;K9^+TSoH=@Q!G&3+&J^o8K?AAm4q0FzDRm!yU#f~i5^YXgBs+^2Ov`^?{<;L(MjZ)Q+4>*UpxWv;A~?s5{X z@O_RwK)_pL#OQZZ#5AJ$Q_2vwY4s+0)f3Cn>mz*c_cL9du0p}w%98xwDNa$V5eE$g z((}TkmEl*f+Bh&4qdodOBhHaUg@p)a)$`Be3f2tIU}>tMV9#x-q?F#MYtWY;C*93s ztFgk@)m^{ZI3uk}(=NKLZ#2J0?PE-j0Gaezp~4@28?ing-WJh5JDlu={{viO%$#ef zlIgMXDrPh~zOdh3#QnaN=qFXa7gp}_x=pCf!Von-_hncf==zzzxKDmBQV^=0ND_*{ zQGN*1lh&v`9ar>;)kQFu0q^6i0E+{tj+uEXzGH8>`!;OT)jRh4QTHyDs4UDuf%VO?5MYCef_l=L=tjOpQ=dSP^Fch+NY1 z3tZiVAZO`1W`$yzw%QvnHP(VQiT6cjZ`He~Tatd+{qp#sy-J^(z9HXk&5RV_7)c+w z_B<}%dbOKHHQDowLYxQNbb1b{GaJKGG3c6^O7I;1TWD-J^OX@JJY1D+P<%N2oro`z zZ59w_=G#4e<@+f&vT?BIYs!S@rr;>bZ{4#7?%!S^-bOPZZUnhR*5h=YY(gESatw#H zxk4Kv*X=ruIe{gxc$lGCgu)7q>X(S_)szJ_ZS6OPXG0RC4vyuE4N?y>I)ifuAFD@)!OBUjEKvea`#1hsSRni73Ig2dlBBZ zXfv<)jCiEVq=e6RSViFE+Dvay2z%bZ_0oV{f1i0sa7tx`uX@{lkI|hJbhl+YEgz}# zbUa*U6iE{E*^9HB8^#I+hW{8)a7_?-HzlHd*;K-k)z^$R7o-eChOthvs}U z$=-7>OoHIg`;v(M0V;6|d40p?eAJiV4M)$dHC3J2vA%Vy9Wb}eS^h{%+;pBOu-9KCduhhRD?BI+{_V$NXD-n(#W&2swG5c3%ujXCsbmY)SN$xuWT`?DCt?G zPd;vTQr)14tmONPn^**A=_TyUf+KOiGa@(koX)ZIUO zK1`;PYY|(TmP;YPhcE0ww5rF`UP3+uyj&b>Ag2y^KdwB0#UzTwVCp?TTV)n#$d`%FHCGY# zbxj(z^Zx+&>!i`n<8OQz9^iCW5(kx9f4my<8ttHsws1o-Q|u5M$Z9@*%fQWfWlJkLbtziVtSIu-upQ z9gqml`Z++}bIAS|ECTUSeWrT7;nS`yI1lfkmrsta6nM8^|Lt5niZ6pZb zS=5`yA(k7cK8?!GAR7>fNR@gB+<=jHDcDAUy{w&=fFw9E6*7l{Im4oNWvEq!wdxpH zHZW0!jix)rvSQrl*vH&X3E;+WOjC=MmlTRR zn`Vz*UmgEAkgUtN?6m+ARaa~wvc(a$+Ci4xJMK#Rcn6wgSEA+6 zyS6en%?1wgeXM=nxW5Km{=Jx@u2Gm^F`T7X-xOuRl&7s22~d}cZKM2E@f3mYL~?p4 z{TfuZeE7gBle75>xbzt#HT0tym&5!hXl6*u$SCsjz-S!h@o`D(dHb`F;TRZuw1Qr7 z6YuXYZ$&TG{?>qAdreKpXI2xx%)z#gZLEKP^efa#;JyPb@9eTW*8s6Sc?QnZ`q6jx zuo#fcZUe0=15_mpW+E})4VxTR7#;e4GT#U4={hU76X|=drl^}gADRha(tpiA{T6EM zR0NwQJVeEIWg@XXB!evaqCcy?aaYPG0ICHwt2hXS&}iU z)z51eY?Gf)TL^ww%(LImjYdS~d>wAew-;rTmG_D3DEVbA$%X1x?dY~_EW9iFBWMi$ zOEXr5!epW`DIDLs*;OA@bMT6vA*Mg~Mehn!#8YuORp=5hj&{ICSb*D}^=Gk+K$8Md99tnJ(PAERg1ktI_SzCWbk|;6RC802q{aH|&qzgAj>n{ad z>6Z`M1k_0%c{aOVE?liKS=|wqNeiDXg8vtg|6ln2&+??ElAi^!%@uy67AHYQs4|PR zIz>%e9lPNV$YFl__y`(sG;$afRWE$YeRe=w_i7fNA4ejJ%{M^gL3CVtMhCJ@;@GLe zI;Cp!PPC>vAMV^l$G=UTHeJEtxlHiWi9-;i)v}kAyEakDY=)Mp^n@rl(X+wp0?e2_ zpE31|S`LT!&-ds~9|y@BxrE_2QM^>*jC{YgmKc!lAxPgeIXl~|!=jz#PdldTp&?E@ z*`8H3AEU(6B3KMd7bta)GkUM0nxI#qhBxr?6~8hidiG|oxh9bD?#aq7YXCy8DEMW; z4>t{wO_C(Yy^^MH;l3N>oMdsEuOYT$+`3~}YOy=)dCl)ua)0YDEAL=`sB*0B8Dn@7 z*8);877QGADhudCs(W(B5bSGUVkcn^E#Cf*bUn$HstS!OSOnY3))BU%?)xm0OQN^W zjaGC~dZa=I_%$Jnk;0rt1r!df{{W(Y3Hs7XrhIyM)Q7AS<6!k~Nx-RCVdD}Qdk;4o zxo_x5E073i%ob_d(8jDH+s9Urmoiqp1X8*!w@#fH?iBttnS)~R2Ry^{RH|?>i9v2g zYt$m3X%5^3RfN385a~0gyc`a?ahylVyGPPQ3r!@KvLl`hw|Wk%uyY*j?G$h&Lf(wr zRj`5D#vulRI;XilDbBc)Jl#bLAcq@2lQSXRO zn5*&?fgFrjagk~5m?Nn{yFE?p4ezz?MR{|b{NkKm?C^0|cH&43i%cPtz^j(&js^muAw)_WstyFNRpHX7UBTd^7O7T@|&pvz`)NH*@SlIZDFULCd& zo3^&U9#MzR)BG-I6Z4^I^l?WPLuK|>8~lXR7|r27F*Wp5tB?MDz^$`qhFRQMghebJ zD`}6U5MKh4|9Mrp{Q%Wn-%jDC^$yg<=G zt$ew(t{=l+9><4dVL4-1f|#}~2AY=^ZLB#mSHEQCbE#=t8*mGXzS})v` zvpdn&0oPofZ=G4CtXSS@7d}&;mNl5y|`Drg9fm#>%SG!?KFZd^Eb-`sZ z^B^JNffNkby-am;x+Lc?$b9>+8QkVnopadn!zVaYFdh9Ap)qY5|cz1 z-0KK`;C+g#M{YzYeWZ$5@p+zcc4;!cz2C9*rqoT$Yl#BM*aZq1F$8{1(geYDmkffh z1D$?Y>6TZWEY|Am)TK^-yw&7JV?Yy&^iDaf405e6{H&`xvI)V~^R)tar&3~WMU058 z&-O^FI(|O8pIPOe)t+;YEM;GKlplH2N(!_e*SAJ;n9vmLT6byp6#kx@p2)=fde!o7 zy}Rmg52hovcrc%lMnlk}D@!`HFjK({UgyQL60sv7BEb0*X6net;*G7AikL7brcT;% zn9yWdW!o$5QP;JgkTsb3ZLKl?!`O7R!3Z>O|J$xA==ZHqthG&TY1F# zRk@d+jV#bBaq=(b$rl;r_WbQ7EsIdci=l+Ok9;2{1Ye-uS!@oD-NbpCy0+@g>W@ou zy`F_6zsrxY9>+YM@WHu7xlap>ug`q`Bw(Yab(GjpawZmi735V{-mA!-7v$!sra-4dqXGuBVjblxbOA@nYnY zYWp_j;??=u&OuE==|2Ez?TQsAdksM$hYLdedX{)68O@U3z=`y&fbPBrMdsR#GfHs$ zCiGc#Z(PutIM*Mg$ItcUHfHoPpuPX(b8cni>bqE2^vTq|^}Ct?SLP=dZ1bfFC86_mM9Hf;IggU<*ArAE(ete$`Y9 zW^{iiDzTGxb1*TfD)AaWJyqaH2=8u5t)-VkFbMO+Yxcq|zhIC0k?l;u-ii);wH|32 zQF$`!u{hYjKc+-Qt~7sG!b1PbcW_Qz2LaRa@KSJ2^Hf z$7|j%dPsJnqLY{0$|{|@O!!TM*-gH3yprJi2LNNfSvtLa&g@gL;no><)T#ljs}j)B z)cJm@R)+E98cNBKH+$=JvzwP4!Bag;>R}u|c&5%@&yu!)O-p|O6}S}JLz1H0M`@EQ z^zjnP4-4eo3dOp{RBE2vjF6Kj;a-Vb($ktcMTY$5vQf3;!N-y;(JmCn*Dp|XyCXed?ZiwJ<(BPX~wF; z6Te@HMy+AJYR&pvppCV;8>Fnh_ZwOmazkHdkh>^?4fJ_JokhvjA;2_BDRL8)vI6D1Ro&lY{IAvEqxdwP2t5y={IZu*ZW5htcRBz zJ;qC-vZO&sS@rNs{(L6)95-f*Db+G7A|Gv`u4g(kJTg=w8*{lpqd$7$xG08HKRG)c z&`E2tEAiu4_t)qP(L5~46r5`j@@Q4S(s|r8>6OGOwz-v7+aC@E@pA)oSU79lsYd#b zBqh%moy&z{L}G>wTt+l8F~f;I(HlFc_fP5j*Nrf!x)>k0nkhxj`W%9EpCkQrK#weR z8#Gw#CbTq?9g*n()R`G){F+}lc>U7*=D7*qIGqax<;s`sVR9ihtiQLODGRD?H_98; zku=GxIQ&0BR5ImcE9Zcs%C1H*$nz%T=M>y&;~BhJ+iWu#r&{m?BJP%P%B%bjaG*Ty z3~ydJA{XyER#7RV1--qN3LEhVQDUQOS<&>2k1wsvh)LsEdQoNiA<09a=$-7s_)6+I zu9m8alW(|Mllwf`}xlHscOG+Z*pEInfF6Rpz zQPoz4+m+SThO9nL(aTTK--A1`5}mG2z%}mIEIOK&oEmi{or>6W68(6Z=^k4tY13GB zk-sEl@pC@m9Id`7OxtXqH)CEUC8o|8oIS*M@yHR+TK0~(xppX0AJREh$?PyzAWCPk zQ{bAQIoPTwq+%Tdl3P62TJWznc6P|IwhLG$!#asrWst3Y3ue-4f1&ifPbVm>b2jj6 z`e4TIOnOKi(d zKBtxaV&ZNI*jj&ZmlpIO=QBUQ|0Bz@3NFq zeV4e0qG^33HV~%&gU$HwB*$ov-iCpWNIVC&!~lMUcR0lFVq|{)H8i(vmL(gdBN!&o z%pfp&bG;YZyPYvR%Ri)Nq|(GSVwIkmm^DBqy*JV~9Vial_b>K>rjxvD$p`dIC@o04 zdUVcqNW}y9r=|PrlATy1J6JRh=r`};CdhghXD$iG^-R=F6*5yB>EGO}QsM4~P4q5R zGgTL>CeD2l4mV14Sjv7w2_$+Q7V@HT$$@KsJGT;1GsL(>?A_yeyhIx!- z59>agQ=10;G0+(dwzY}O*$CM4uIln?BP^ic6tN8?m7^+={ubwbn$N(L#C^oqQA4F0 zg#D`qxVPkF%5ySmF!-Je}L+jX+$YcbE*tJALOv&TUxf39au_u zw_~>&_a(Y5A$lgcY50H+!XMSd7yj)Vm_Euh(CQyR^U5`}wxUB`tL3lKpVPIqZZ~Yh z8;;|`yWOPH!Y$tfWZaK$G#<|n@;whmra7uPzmvukPcL*sI*7!MQ%A$q!pwi14QG*q z!NHk0c|p|70`uoKWShm&%up%=AeMX#7UmDA_`To`ge4=JW1rz{Uc8l+hwh!9QK`ejdrh;Q3uI-+O-B6qZ&flif~|{( zOo$A-+Sb}`67(RXdIHFe=(r}+!nO3vwLOFCy?O)eicW|UwH|jb4XFqplrKM^9ycVo zs*X&1loo&fpca6~W(q@6PNs8EQ70J$)YsQs;V=5C_|LJNVBRgi^a|a4xIq_&YQfh+ zIi5+%y1}k_Oy_FAqH$0KnQqo=Lvop4Mo3q8d*?bE-HLinV;5zpt0i>Wau(TfvE%qC zCU?&ey6CKcQI~0xPC$q^WrxFL>;1aV^_W#P@$-9eWVZNatt9{8-R@;o!Ps?;Z7SOx zge7N#O_z%Lv}U-S1~`X8&BH=NgiI}}xT@bU5b+sKeM)M}cSDP?90^(X5P#N&Wb40) z`@s-oo%H#z|9m)V(z2#gr+m)C-+RztXyx>XEt68>;>fp=y>&!*6xhvVxSvDX9tV`E zP9!>iA~;woA{e6UHL+v^>FN`{Ti$-cOA&(}=X?G+#?l>0TSy^)BAsGfyqD&M(S zn)0YmRkD~O-^fh#JLL1v7|31UArX;@4I|K$)!3g38_yVqSET$;%;0uR&cpmgPqt7;EZti=eMYW=2X%_6{2`t;j>^pvDXKQ`zazyCs3UX>v^! zt0-**tLVr3%gZ^?l>Sl|Y{#n{4y%7^WmFnR@i7>Csc32+*}CHJ+E;(8&~Ux{&uky$ zrA>k7#>b_Lo zB>rXLF^`bO?u+@=*aENdIemnQ{YN38&JX9i2#^g&w0824(vh#h1NW+P7c{x9L@%PU zk{4BK8*1GzU3^?MzG?inTjI-n`&Nf9*>rIjxhQLY@ShM7G%3I%O3?1h@o4a@lT|F6cdX#d@GqP~sdYYD+#h^R?HN1#6QD z;OPC$Kazc&gk_BGi|FFsiV4lrEIDzuxe7CzvU4JFpECqRDFdL~*9b+H#Y`@H| z*!t}IJ8^R97zb(+D@SK-iQ|s-ULBv$nTWhE@&vjA_p(D-n-|L~s^DZATd^aHn4Y8e z=i_i-`M#*LF&bw$2{(p?5^^>=%lB2=U9p0dRq2`Sk43Z1_-u{gS1IYy*fF9DOX^lj zoa>Pn_LcNc`NX`FR`RIZYUzlaf@BsIW`xseUo59R2w9kyR`#xx3jVHJA7o-Rz%T-% zmxK0`4CAgT4vqxp-cQm{m7hRx77aEhC7gDnPH>W^-Rc7gz@qWW)omBFiPE#4H%^`9 zdsohCEtPCnA86W&Cm)4BLMZ8v-u$Cxf<@zC3gWbapH#H}07>rd&|)vROZz{-GdEa} z9x3Sphb<|9^n^u6F8FWvU}UX_fM z^u^eguC!_fo)M*Afe9=73f-(+ zrTytqg5=#jZ=hsMOzS&aNn2Vl{~%21%u#I&HRiY!E6-aZ(2Z&#ygs8}{Oj9?`sM!B zFiB94w7Z-pn_w91nSR`ji!x{cP3amKR~hY=CM&;HhEmXs>I2_hB1J}L6o2YXXNfCz zQa8SaRP5O+{+hw);@|ercsXbzwDMD4iY)tF)!&{VeW*;s^hC1 zW1Y(LEBhUz#pSNA9Ssm-pp^yv=I8TIU9-gKzH1s%NEGd|+)F70IcG_+7TxeX4GOtt z57L>2G%HF^GDqS{w&0w|WAj+)ASv!hh;!bre+2H(hZhis=6pW;zNzGomrAzWTs`(_ zCv_zjEiYEz{D6P@W59R~e|ZDzUxvw5)RV1-&vQwSUus#1vn_CjeIPvNcNYuon-p`DTM%dl?(KD`aQL zv-kY1o$^n-F?#RAIw)tH!8PEu0b4F4LPA?aJmIxxJ4&)+dW?!XnNr<^{3Lu)Ad+98 zL~qvjNw!+OE_ALve$u9D8| z?9|Kpj^$@5?)Cl)PJw)aa>|=7ua%Ls0Xqk^CuG`{In-K2u_nuY$khTL^vH~gIX;p( zWvc{)-2lJSsB6-j?TWR2Dfc-MCMw>0jhxzp*G?(Q6%E=rpzR1}$hUbrC+UMeUd=D$ zIWL&)1;!+Mu*G8=WE15~1mFAfoKY*@nSN2*lVP&|3Hf~*+@T>td4ZO$>~RV3oRF~m zK|4D)lz4%tF6o$=SCZhBX>8v%9Uh|)4}FyXnsHUa0v)$2VH}*)_T^kOSm0+ik>H1X zhS}%(lG1ahS@Dbez@=7Ym!p1Kdz6?}6|^3u(z%jH=h99K)?Dy0A(3@*@UW9F#IP1r+ zPg`Z5A75_n_O!+A@sJ6inpA0N^d0h(`rJg2U);SXA}ec3lR{qV*Hd2s{i4n2K9hWg zVj#Cf&i>BUKuAo=;^Xvm7>hq9-gT@shln3Ujk`CZ!2C7-zAk-?n;(b6ybSLja?ws5 z1AYNmFC`*|2la5nHGxHKnde}KEP658I#(sgwtNvK>}ShOzP-ABZZsSbQ(8be=*>fiRLzT3gMk1s|L8kBOfJ>K_$V zDANC4V*EFGm4vT%%#wde>Q8jlijY{Dqzt9Ogb5}}C36-uWLI5wLX7#nI{^3s4-g*Q ziKO;!T-hVYgk8pnWh6C{PM+4|jZmJ?=zXm`^fDf!`TQc+7GVpP5BsJ_MWD2h($;BH zZ3Ox4u$wD3QCNof2Z*h7a$0=HW;HbT^xfO^?H?0LE_81voaQ%9re}wzAa)!tSr?6Y zamQ*l`IfIQX5K6NoV|A|s_mH3c%XH&4)j8A>oLXA_e9+8l^!|0Tvxv69C1JmDqnh& zejo@B`v+hYK`muEXv7LDM>nDW0S?c}p3xb1*BmlR_-}>?JPQBk)V-n0Q}V{8oCsg+ zSFst3Vf&{*zp1=UolzIn52v{PBayXl@S|`CL{3NhZugZmb8|cB@rAQ2VSRn?cO1O^`up%q9oD3N9$M@NZokl^8ii&YywPWzhua!+~ zz$7%jz)mCxl>LQLOzzsFU3CSN{QaI9XZSo6x<~ z)z<1p)@r8*lPK%v&;HrT!uU7k#~v#y*%0=tmFDRC{lHiC^`o}Fx%KRefeZ}W%bnb^ z?(e#IQ2K$lpDH^bKcULyi504&OwXRnDlV~0!MD*1)Op&1BaH{81JSrmO-z3~w&|WIh40d)#iyeUd$;hthSNmSR_O)i{egxkv20-08;$QNf_oK zE__4c_Pzfpjm*kFfWES{d_@;R&n-{To!grqG)lvH`orz}@siXcIzvi-+^cFgPTr%U z%a^JIUL8{pHf3L7m;3NU8E|>)_^h^<4K7x=g{`ijhm(OIp?OK0`K#jsd0d&$uBuaq z%`@0n$J$k_&ZzWbkA^5a<{TH7!OUPKM3Zb{m$QoVyDU^BSVu#?CEzfHzvBk2nibQ6 zwP#|fw119(Cl@+o^?S)O29KZJMAiS*QF7(pHJh=_$a?HB%~k98}Etw2_*Eh0HayQr<-ZXA1=!24*BXZqswl-*p-P@X>~I@y#`$ytU2hq@P-7*g8xz z3lJKf;_Zw-ENbk97OEhMn!?><*JtSJJju0 zFmcUIu-Deft|KV3O{F`ON?%u}W%g{l2`as#%i_IW?DbQ7nKn$bbH+7(eopt!hC#xH zN-dlK3Z}LHEfr;6jsM+lLSC%OfzJQ=FepUa8ZvalDfVoIMms`XL1mWOr%(Fr?-KgF zIP?$ahB<3>rjv@wk8g38w;!t+{!e0$(rylu$pZ1#@h1){r6z>Nkj`fUJyfl zt$1JZ{ifdtkN({|2l+oW zR6IX*&&jrZl9gRsNwk4~m=cRv^;tZS64jPPUtkqy(njjeZGys-RwpcuFv`ojYP>BP zNu+gsh`gq{(d-OZV=P0Em$Tf`p9%bxVWEKA7Yj0%70hgSaT!;BFMlOIwvyReL`}Alpc)$p=(2o*LOu&v*&qQsI znIIXgADfFG{_&$Dp~sjggXu|m&1Y4O{{rGb9lz4=RDvb>a7K6ou|B8IOV)yU+wm@v zDr>aJtC?~R;r^U)qzM$CeOP^23)S5l$IpuAbC%MbeYxqzcKoE{w{3bjbp)Bik$kKM z6oa2VeA&r7=Tcn(d@p71u5uhV9OP@Dl^l*h&b}jL;OnfQC){iRpSHeI$%g=*`UWp6CGx89m4bqig#_-XvXA+Q zeX-l?sm9ACvYCDgfU`rrrDnkSas9^{s=9lq?wvDN9YwZsm^mnUQ}r6MsDBnxwz1>9 z2MzG@jVGW{Pe1-&9XO5NmD0LJy(n2m?H*KSCvvoBOZEvv{%g?(kcL=3N6M= zxz0R?KYdeGy<0ss?t6urc+9W31;*tne2DYLp`O3JFmdDVmB=PSyo`APrdu;drl*Rg z=@f}13fFsNL_%rIpiNa0i8}uqeY&Gv~^m~LrZzJAd(53iP)xa zUIUuRg{HxbMp)jLI!<~r*02Sf5}+y)HL+XZ! za1YRPu1|9lp9cOzHlIvku|pK^!3VGEwEL1Z%o{y?gSlE)@bPZ zf|#EYOKofck<`B8!Abn*+t)?9VurX%71*?kQQ?JW199>yJcn*|%bE>&8Mwt+Ze0@9 z7mbNGJft7KIL4sEL}02`iMA@7xRYF=rWFtssX`ty32x($PhE1JneGwdo^7m*c3^q? z>&TR?ky$A{65gUJs=h#IiTPL`x&5?RX!8Jc8)5-QTb?`pv?9>Z#Vi!Hk(4p+R?ivE zIrs1T>8hbtNLdu}pD5ZsRr`6J8cVXc*ConIm?}EI4o0MoTr7>2A+xmyaqX@b+B%xU z8e6Jfo(-U5IN%Zbb~=X@G_cmq2`Xa*Gd}Kf>7rFrl98cC+6H+407&)FX|IxEN+~cx zG}P76MGMMc1)0#X+nj@sVa74(t*#OO0O|=1Oz`G7u(2lJl(=Jz4@`Ic`{<1tH18;% z03F1!I0KCL{l5B_4l61mN_JF8_J#}g&aaEqN)k=2oc`K`uFC2zmy(J~+Q|_3&Z0+F zZK|%Ueqryockhn+WVf{PQmio+WZKd*9(ICA$LKWQ)Dc_jExRU<=Vn}_XUGyjC*M5k zH4vpG))?7P;B5oB8ts>i{{%{)@V4(Sf_yyI?1`)J*=-ib8s;Ixw9Fb68H zSQ?qh2W4rdxmtan}hMty|g0`}ceI+v0Z3s9y`GybbG)Jjz6kSP82ZbKW zrDEPHp6CADcl6K-i$#4Eu_)s3>VS}MNeg`ZXC(c4h9>oAN$n$FbC^>Xo~S zI;uL4oFfJV0tpz$KSTRzWVp(jH8l1=(_Cb%F_Op~fGh#+#!1s(PxVNlxzj~7%@s4S zMH>kooxMH$wNzEZafXYMRCtCVb<`UB>3rdnSfmXexTqY~7rfXBICUUi_<9)UY& zqhpa~MR?dcFvuX}9dD&xjlcf@7p9UE7(-3~QU(JLZ8dAV_#Q6Nn$2djSYtJs&1SKT z)@wDI#xq&0)@vB>9aSvdIdPFHXy3q%$bzMT8$mevvBzQnCrq6s)OLD{JzN*s101h6 zgeu7nVi*~HzS++jw5aXWcMCmCH<~{limC%l{vpogBO{+83hJu6{v3*Et0}1e0GC*i zBf{=jB&w(8Vf6$8Jh84B#&FsyoLVJ*5!w>%X4wUQ!_OI#DP#;zL2aRk?S}NxtG7r9 zZxYd7R%NECmu)RTljaT(cIUsR{k2YXz4rOg*Q(gjQJBx+%ClrgDBc-Y9#}ql^abXy zUFmK2+M3v8x0y37A#Jj#d=)AFtP_m-4C|^5IPxWK=qJ#ZMe1vHeTwNz;Lc(2T6Y;T zry(Dn_$WBit)Bf$(w#|lvrjVCr5uQ3l}F1WD9K-@8#waDiCF~J+q5-tPW5!J8IE#; zED(ah!SeFeaeXyQTU%WmFs$_K85bkEfx#Ym1N-S*_8QS_Ro#=R8d^$e=_z5Xr*K+E zET?j|+#LL)omRE|7g1*Et9o3esFhf}bujD>Q*K7#>*ejp(5{TUQ`SjOP1Hf*@!?|# zgkx%hoPv1H+~8`-{4eUi{ZrD_5#6Dds-`x~aLA#HCP@6p*?HGa33iz|r>1$IsJdc? zfohFCeLZRvYKm;H^CRG7W7i+&MLHI`d1+dTphkizw?gRqRaKYrU(EN zRP_xNNu#c=5z9`L^6o0Cer?J}E5=4S=RwwMtp)D73ynl|Gt|z%@rx$qTwt6ZAbT9? zGA<8`8avt?Z9PgUtE+4^kV*V49VLyap#+?9wNJ0g4t@T*fq%8fY_F=Vqcg-RCoNAp zF+V#l;-x>R9CyclG}&gMp^o0KRZRXke~2*c`NLy6s}Xp6%9kfTe@n<15AYh2twyR**F;Y)e}z|wQTgt!)jC#su+N9 z4{t+_UKE`(4JC%Uww`YmFuNLKAn^k*`BxrUIOKZgRDW38rm@`Prle+$DPuE8}t){U30ksSs1t zp+|gelY)K6{(5K)`>829C-D+niq@<(6-JqZPtnhNp3rUAc^bl{r57{YIY-M~U%%M}*hn%fqMo;+w8%s_oR6 zr#naUAz>tPp*)o%{{TDcmopxxzTGBDiAvE*wCTUQqP7ctbDbxxD}D|2)RWroGRbMG zqJ7Ka5+cASY5K4jBg;Bu=;nzXq>)k=dTrGufeDu(xccNA>#|b!noQ`W9)na?+nq(? zYYM<6f@J_mPceoYf%~p(p2?tG76i8G&_bH1*2&2QAc1wQJ=oJ(@#-m>pNw7 zPU4Q>(!n8f<~Hp=UUR0u#4C+G6}q|NfRbo67>_aj3^2DMm;;};jH4W3w>>MzwI_j&pJG*0C!sHXCF{mJp-0SXf+gXfruYrzmduZ4wEl*b7 z0naSGy~ep2cM@NZPBjuHDs$!LG)5@Z`{Ub66Kj0_48)EJ_4LtcX=JKnB{W-DaOXU8 zp)CdoAdHPjYWydJ#Hs$GKdVJE5*DU72ZvUSSPT%PV^ScjL63$7B*r*89u^9R0a$_O z#!kK@W}ZSoBo5xXz_ErzhU#Eis<|F+7cvja{{TojvwC-=?eRqslA^0{$c>elxNLA# zcG?K%>8f&_B%RbF5ClnzXzy~Ti4vHpL$=aYhSGiUlcrv54*VZ)9Vacy ze;(UgH17)RUl8YU=k4EIIwGT}ZFPp*ELA&WVq$VKp~2%mgTK>O6}|>ZyjoEiPn$8H z$`})jpI%0Y*|AE?)D+>9#V(UX>y(Qit1$H$J^l5}>NeR}+vv6|H!Ay8997htKxM#X zC7F~rf1v=KM(bIvok?Ie;8Hr7(Ej&qL6rya{+Wqk5ASAHkhr*5YHN*@Z9 z*C5_GC^n?^0OVV_7{{RxU`<(NlNfAxEY66TD$9V8I=g9NlT+;Nuq_|lv7w(l0Z@oPzj;fk46v)f9vcv4zK41Z=>!muG>!EWpDBAx3 z@ed@e@|GYd0DI);=r!q@pTlUYYG8h#k2M%O*;I@xADAwCo!pft@5wuN zZ5?GblU5;&#_{dW0l@Uf_t6O^Xg8F{iVilfU=IV0E86QBT5u&;+LZWsQ@KlH*VyW6 z!!1H=@qepxF+Kje>{@DttKAOtl`zyQw7bg>mvA{b{{U@mQ%g@q<&82+9x_Wee{ys( z*;HvjgD9}Zd0+;nUFsruapIMyAd}`u^wlLQ+(X9b6(vG7NBlAQGuV0heY9z0hFW~i z;W9A5j`~`nmvq>P2#@+0`DnQEfab~y!?w;E*mBbs>ScS96bx(e9-8lqCMOSqRT za8Ix6+e!6y$n2%wmYv9qkB~9I{{TtPnK{WiHkC2XQYd93Bjx~kd+KzNB!xpbE(SB@ zkNn2343bKlG-r`Sk4@v(Qi75;+hGM;*qTzYo*+Wqec5 zP$#F4AhUN|=S>+nON%3XA7$CB*EnX5q9m3FStD_kWjO?Ern*a+tk!EajApZ0tkyA_ z&1SP$$B-?zX|1%=S!(8~s(9TVjL1&b8NrB=z~JN`u9@ms-)P-v2gQC;p3?&s$-fOGO|wF{}j=TwtuDvvJ>5-(B>4&{{u=$-KnjoVUV%&k=wH$q0wuHPLV*- z#Z;oV!rHDsD=9sj+z&lg^ba~-Y2K3LEtK?)9(|7cVl`|2^t7RGVnjit3v+$m%|J0zkxBob0p5Emo3 z9C5}-Ip;`K7IV}PRny$oH&OB*3}mXN7dh{=oy2{0%3iOy%Mi3%B5#Efxl~*x%!8hI z8OItEYwH>sl&F%PS!m5O6ku0r1GX@Hy!FqMEc;RZ43N8d{22*C$z@Zy3YgF^1@caQ zy~pjM(?GTxLr_XWya+H>5C2e)sW3G`8r#dFC^=}ok)qEtXgcU22$98d_E;PXvlA_U7BB`0< zZNy2i9r)uI@AT2wo)oml99!d&Fv0hJVaOxg{{X&^Ta?h%q}7luWlSt{1Lq|h*Bl&Y zw|_l#dw9J@Jt`F{djO@TitSAmHP$dhZM!Y#7Z@kgjA|uCeGMhntK+LDqN1Jlin!Os zSd{I-+32|K@2OpKrU;>_wT2KrSynJfE#|+rMwTq{Q{2r$1bx!Fi4}*LADDXo0NY-7 zxs%emu8Oqy(k-=$V@+(TO8b54+|wBrdPBcqUz8oEvH6oenfB41xU(z{tifIauom*$7qPtmOwnmg`TJ5kfB_oj(5yznif$i_A(zXg~ zefq2xW=Bp6ByIm`gLn68SXUBS!F}FZWTixf0*i=zgH1>rk%>m z8_M%F_uP1WG9#4OBYUF0JZ>7X*p7l*%a7(RZwq9(yJ zMli@bsnkN)`9~hQpDeJi3PYb!s88P`wwVIXYm9TOK3dt6%xf@sEOm`$M%d$0A$LZ? zoQ*}=dyd0fMJCqgoe-10DChZu^w(I|$IFg!&bT^mT#aoSl>Db2dK6=$tHjw{jQVT5 zHPR_k{{S#kz&hR;7F8=ZC)E1ub4IR&#&SoNl^Arf&qHv7#!!(>yJIV06ZF#^y{Tv} zG_5qA5RP!O6d5Xw!NBw2>YowG8v+Sle6(uQa;T1G61b6=Z4DJB zZ+C{(6H3g@lG5N~01g-Fr*4Yt`iko%1XZxp7-wnXc*$TCkP4r<2aS2^a*#V3g0F&e zwD;2|MD>g|`pBs+p#{C%<(On9agu(TmkV1grB6q?I!f^zy*oiEogs z;GefTvn;loeU#G~L4gzkXGh6njz^|jIzzDZ<548<9EDw+F)sX++(1%(o$=i1hCAdC zM@34cm>jo+Fr zE#m!SrUI0-GEE$ZG9W5|?jG3Gy+Ud)fN{6vtLW_%_d5+m2!&;?Qe>v_$&E=F+!UVR zg1=65n|hUO&`&k)B(~k+x2SLB;wg8P0P)$7x90kdTNg6*X+&)zoocDcDIoxWz3@Nj zI6b{ayLB%?H&OJZ&Whnj@e{-msN`H1AduTv*XGH=?Z%^rFS#H80D-Tvhp}GerCF$M zl&>7sGMOTh2{>$k1_zgH{qEabp-nzJl!L+Jtda8tJ-Oj6^25=!mB0Ob9AwGxW^Y^aS`$(iI-2O#5*Q})yfYt5d%3Fzs|ND8tR2ZjgJ z@2dXuZ@bzmP?ZqF;vKRh9N?Y@)0}5sRB)_~r)XzUS}s~DqLM9IH;E8%%eVTy2D)4U zj0dPIQ6qp^!|n=6&wp?6q)wW;RmVnxKm<|0hETZ9>;v1r0Q2pqN*ai+3*g8|r&IGU zU`Dy;gM_%UFq@~z18X6stF}v3ME)G4F;s~_C+Yh4)GI}NNkcKK1hPo6;AhN%!SgxP zg|wP_=++vxNTuGes;_APP^6zu4hOi_rQ@Qui3Dp<;zbD(o=GRZe!Tq#yHq-Bc2P=| zqiZe7O7*BRGSWsIff*SojycYl?5_;*G%{vTyom>WS612LsFW=#sHI}UNm+mkw~(ZJ zk&OH3-88k-P!@$6IX4aEPv-Q;r_(}FS3{{rLxI^%4ADXuI3VsT+dSw3Qq;{>j`8nV zF`jwHwv50-patBL z1Mn~pZ2p?oni;5R8cN*HEP#*{{!{hW3K_~|Nh*&FW+AsF@Nt3v0DTO*NpX@gz4|(sSGa=b%+< zcm_SRF3~(fSvCWhQyi16>uYNvp|{k~&>x2gKbq2f!My$ZYp)b!`zG|p8aONIljBWE zmM_U37a0S;9^C2f!FQ&ojJ;SW@^h2qJ9gD!OVtoj-64m@jztJRmRpSZXsu;PtdGPp zn6t|SJ^e@D_Ry3XdnV$alJe=>{WT48GR*H!BJC)rfJyJ^t1(zIf~O#igRd)CE87u@ zd6>vf7kS_hZC(8#*Fx*y(pSbKkd28T>`3+W)v?JX#fA;7G4olh)@tR9)@wDI#xq&0 z)@vB@r{YB_TPvfE+`HDfz9-?-h1gex&!*wdbw8)9m0dwhl`%6@yi4K4vo^$bBd|Va zk`LEHBd>qNi=-DDa7J}g42N(7YA7v(+rO`<8jDv$cA%_=nz5>#VXKxXBBwtrQZVNv z9(x0+*7k=^wPPJzfi2R}OGyPa-Wrn%%8SOuQn(}@Nnwp9#I+UFbud;(w81DOMnU;= z+-ctTVUnX|)|!S&8hUzA?JLWkVSn7k85-0J&(6+ zeMX&b6htKxgX7I>XPTNdT=bS8e{GIQhPIkD3Y#k9biK9vCs-}ps*vR8>0lOaA@BQ@LZFF}Ehk^*CCY@BuW!lAw zVt!uyk&%r>w)G8~<~6HM-+EERFOplXrOqkcM=E$amIZ9gG%dQyHnC{${4D4?V2~; z85!X1_2*W9T~o<*u+Fe3WX4N|?Ai44>@{6^phB~6YR-Rf_C=F=k(KMlKXGFSm(FNV2Ywy=kcPi zXoEap3^Jcnk1u@$>l+hvl~lJ&eJxGGg0)&vu`=-E8tbF@h#~i35jLWh2DO`C)Q# zq~53L$(FuMy(_{qEgel6`D$Wj!jtIUM0@+_lr)m`y_O0}E3EgMTgLSSO@JOf#0eul zyLlu1^hW(@j^}H&&0|wa4LkfmBVsJ88!>P@{J|7`wa4m|Wy$n3BdU7<^y|w?%|len zSK_&7O9T9%Fmd-heYDjzZCq<8s@)Y!?2R2T*}>hAbMq1oaCz)C6Y%4xXf6K$2YR^B zR8}P_si}r2gUY8IIA1U`k_S39_>t5$o~5XPtaQ@T#>0P($>5OThs}=so;mc^#FQYD zW3}`~h`r9*Xs3UcAmJt3jneUh><>Bg(yVb!YL04#jw)KHq-8Ox;|c%+A93H?MmlEn zvPvztgb^H>RzNnmC$aY$=hwE{`KcqhT4}s#7km{6IoyL7U_8${j(Dl7MIo&+#_20u zJ!fe@8nJlL?eL^^&&|euZsVU#EZX`|+V1u8P)8Egy+W;Yd-jBoFk%P&L?5B!Nq>i( zXL^QNX=$m<6_jH#FBBp^la-g#Q4^5W1|JSGuzWeKvCqmriONqQi-YHx*@V<99ASy++SkjM1Zaa%(5R*QrYN8*Vgb?2}pNgvx%>76m? z&Z>}DYNM9dPP4=mNZ}hKD)7ube2?EtkgLO2n0!cv zoB6o(I6MzdLC|YV(mMP;XC zPP|qbBUse%taFb)O#+^nuue%Qlcvt9x6;tn!%0fTrvRB;oPu-z0N#TYF}t+DGcN>V z8t6n=C{VKu@^#)yU9s&Ww>Z%~fDOyQBOq%=P3T+8&z)-p0OU{?_SZZEg4qKampWmN zJ7-+XYvGJ*h*e=LBT^J+&fQAtRI<03NzDY8IAEx6DZM)VN?tqLH@{>ORL(D=l6d zFjJDf{<<1`L~ybO0dfvI=>lB8gMW(O`WoX672p-1cfz^JSdVOwKBu2^rW1Xn9eH@G z=^Fn4grIwbm=wh042Tn!!y>?%&E^#1@$-7Ij{$4PmXCW=^O@q9TqY+yb@bNwTndF1Hjci|HC zq*dwCn}bo|scIF1q|QEVzFFr0r@W~GYRPeoBsBX;>vFm2rD=HuzBccr@W=g?P} zuC|pIXqpm@PVyWoZUm4#j&O75omC}UB>T}C+ev+%i^A$kYN}eANhgX5iHso14QYLcS$ zGg8tTN@_EF5gYhI(EmJf%zMI=ic_>Ogu;4jPa zLInz%Zig>Te!f|+B9N@itK zxDb0Ts&2fmtg5L*5s?f|T@-=2517y2kEXfwe^cBco)vkW<6?Pr&u=fUeNQYK+yL$9 zvFL3gkg_7POvs?KV{sqsFh4=28cJlRPYI3!jOYDF8egotnN|sD*@7kIKOK#{y;FMvmc zoR?q!08F7Bv%jBCG)CW8@JABJj>U%WoR64!^X1=K-0x9Q!4wrajms%gIr&HB!S=?d z8GCm}FCJ3jgsWw=8mXb8WvGfLZxs{}&9vtL_U?JpXHe8F4XQK|6@S-eeRLfWD2hF_ zeC~Whx46k1XWt~_+g)#U3)FEn6tyk5O0Ik{>_7nd@(9cd`%wN z%w@LV0rHYQgU?bRt(u;=#VBa^uMxjHMm*2>((g}O=CxOsl0{gM6T_C^56#cMnXXV8 z&k{(8W@DY+Ty`268jU}(`;LJX_Onqbo|b7LcwciW46Nfh!9Lj6D665YxUEXa##T~e zry~IlIQwT^BWd7o<*P{|=Zx|0apkU*?L|{EkNGtK1!6`x{{XSZHGET5#iN~0P<0^h zOJ9vasTEF)7cBl`k1v0wm+qBx_e!A~O%z3j=7kO!Wni$yaEQTTGJklClk_o-ylmLsCEC%bnYh_Br$C`)jWxc}Bvt6Y0B*G*wZ>Q@LbCI5K>o zA0hYEmt?wpJ!aM*6~lrDpM6$W>m$!jl`_;gXa;hi2HZ%=_wv-LXsc{@nrdmT3o=#! zV`)wfF_NeLp{_QqTwc%|Z)o-frs+?1q)F*y4l#xvVEgKRst$_2Jqo57c79f0A~kGN zR3%g?EJP5%9{s(w(T-|r#*4GQvstXw1~XZ#)@vB>?Un|!wZq}cr43xZE7KBGrsKvv z4;{X`b*;EXa<`gb(xqo<`%r8Q|(%YqE=p+M4|>1(xG& z5z zc+8T*ui=y*oq_F~chR~Vmik(lV4j**nlQ*v?m%B_9jCTA$Jpy#Eg1_pUZk(;1)*z{ zNF!KQLKR5PMsQoW;{)ghnBy?gbj5XT2I;G(EUid`hETwj`vQMWA=F%adRnDv1vAGh zMiEE@8&1-F0X%0!=_x8Dq*~fJ8D&)fQ-r2ZSZ z(bFNBxhFoS2On(*pQ#?6N}F5O$Ciw5jn^TSxcR;LJZR^SEE9-n=Y<658YNMafY~db zCOdJ@mZ|%@%J;gtXGV{6Ng0Yx3}6)`5#J=^1oi;y&zxrEcIbI1#a)ij)e2g#0+cie zpCBuccs>65FL0=|-6-N$j#(-(;lvDiRKWc0>Iv=l&XaA|7%x=LZLadv!z!v}h7J$S z_Qx6(KUpn~>abKnQBf5~^D_y^i02!Anb)U%P{GvQ846NO1Pf5C zfhGhJLC(^94%p70dLO9ei=(17^&ndVG;qesRzs3=-?;w(Z3|PoE4YE+>S|l6&2OVw zAdXZnW;wvn-)@xjdEiz{wfzbXTJxf_|W__&PCN z8@AZiLUzRy$&!S60&;QZqSq=KnLwkOaMIJ%Y*^40I2q5e`uk`&`)gi8pEqLV>vZs2 zkzif2)I2{i?l4a$%dyUoFCT_9T}9PVJ*uqGOw`QnRTFNFTQ~ui&pp2SvwDt-s*1YL zMKrObk4R*Uk&@VAq~vfjz|u!eTx76b>%JkSh)&>uM&TDfo7Ww`T_X7Q2JMRX-k-nH z+3DzEsgk*ALnn+0Uj&B%)Ewh)I%4TNjaaa?yQr=;GgVc_#57@c_&n|&qk*^XbmMgD zYg~U6wQxo#(ih$oj7cF}{jxy)29>S0DDUxq5Tt6DE0{2jFxyqY<&%yxfKGMID8@3c zvdV3b6jW67a#P$bZHhXGznHn@2w~gM<2>ubBt$?eS8Ucpr(qNXcAZ=4H7cB zC8EGl`rz<;aj2DY^><8k@uZ%Wg)x{SNOEKV<0ssJIP}l9x^&Ij0w}6#ZX&WWg`QC`Q=S9I(f)Bk7%6w*IL%NpCdqN8y_1NhxDb z>mZOuJ7j=0p~gDBi^iUSRg@IhIxD@Z;VMBLL`^7cJAj8H*Nq3h%P)xST1FYSBQD>VDDw3EG%uy9=&YSpME7`eW3KTb zVn6*BAOgea4u0CFF5OMkofShNrj@IptCa7St#`UM0Q(IQ=pV#*FZEP5?xb)fLn?>b z0>0(_j~LO%2V9Lgr=rZSnk8zwnPhcH-dMsBg?1cg9-WS;YsA#_HLp9ls4D*e@|5>F zv+jLSM{=e3NhVRgMnq=$L+!!u>x0j(HB?qoB-aNhr(|O%XvqUSpWjx*ypx;N1LLHU zu5(vcku2_V82(@U$kb^`mR1EvC!Bd`_>03FgO()o-$O)S%wQMhE&J-}k)VB4L3fd9dYg4V@!ffcyIv&8k25u6+mJ)VD|OaC=VJDle=0tFt+sL(@bH6Uwk=K z$pdWz7!86nKCb4>NJr%62l~)O-ikfoffZ2P|{s2)Y44r zB}9OURnIxe=TR;u&m=kNn20iW{WYNh7VS zR7YXpe53N7eqryGNr=*ITh8WOyWq#w133GWh^JkDnd6(Vmj4 zlIu$X(>qgCM;_0H9PyK%@y4wFhP74SV-`eMrHY~2c}a{Z5+K0mxEN!`hn)p)va7ju z1;W*SilU2T0L*v4JJH2A#rg{gYdY0WsA4b>KTh`fA7^$9S-e%5N zWF9vipz-F%Q29x0rA2KWy1%OFsDBS>X{_={k1|P48dL`@6ri(v{M-*>bwpjOYU}?1 zhT1H3Kg1xRVyIIPhl?eN+vm=4oS)ZLHGe=|Y?RjvOva|?Y^G`DrjTI#lD-)7aybM7 zeFmxtD=IAlvV{`@Rw@aMJFo+fo4E7s>#Ye>G}q9{X{DNujw_8TrAnB7DpCd+7;aXJ z+zcu8=T;;ZTAr-1OBsln8kkKCY5>ZhB!E4BVV|z5nk$_+sHmW%nsuj{w^GZu<^cKQ z_S45sOijjfYpep7#D~U1Hr>GfY&5df9Pul+59&aUgF@`~GhrD>t1y2563N@k2PRRnEjVlcy#`g`dw z@e`vhmit{dT6E*!^vWJD}Tjy~M_014;YLnKpFPTG1OV5!>}R#lcIk8}!1 z$zLq~g#9(yW1+b~vNCvk2j&Af!9SFFa&=j)##?31lf%uz)XJeJK-=S12a-QbfvKH2 zc&@otsRh!MxiJRdZS~_`Hcv@BZqp|vCo4>7xX6^1tsI}rL4be^4?&-|f3Ch$K#|p# zjsPXbcL9(+{{ZvfMJDhR#3n>`PII@<*FL)FhDv$Fu>hlP7!!ek{(swClaqQgT&s|| zajBA)8Ddl5v-x)_4tt-mI-O^pwNf}|a_YcuA8%i_rd(yFta*1vzYGQ$lyW@B-@deh zH%XCTVX%b`+5D#;`S;U%mlTJMLsoIN+>)Z{Jv%CfCk24*q1{DlsG~W8ptHK2oaB-Y zKe@(<+u?z(UMBHOkt?bM9o+I*{jfDA<%3xWsa0sg29ei-NjU@c!Pi`69+9UNDB2cn zwKdQ*pA#8qiftYo?CHKlYHp-~?X<}(EiCfzVk3}q=dM@FbKWANuAz=O{0W&FBge=9 z<9Cq+XYHsp;AAM04d+jY$ zHAzGyjVP3#2#oH@;E%7Cj&#>t)7MlLXeEM3h}>Cmfv!aynC96DK0Bk+)OC>8z_U|G z9S`a~j-=|Nvh_UF@+`LZ6T;-=XSwnkulViXBFPL7J+)?W4PhkVa7KUj_Rf6t zFQ?Y7Q*d{M6%4y!8QK{@?!LT?ef84Sb*QGAYh}VI5}ndYaEmt0#j~^=_Q5>*=^SfJ zbkC@OQpryYjZohZMLI^Ihs+3WKKhqUYPnhM)p1fth=xXZ;AF$?^bOb_rk1S2Wx4F4 zEG9riAh`rKFnRv~I$(~vxWs~@tOyGLVwI1Iq_^i^P%v}po;0cy7XE>?6%fa3sxm!kQj$RIA#*iUe-S0VaWy*34Mbqz?(L9! z;O9Edq@=cWbkxw@Y2~K4JPICAy8;5(4$L_qV2wxqDN19iG9%`qKF zk)kygev`OKLGW%$)Iyt#VE+Isu_XSPIn+D#OH&D|7GLE|rIlbf2zKy(o>@y~3ULTRLR3$eEbC4u9y zCtq*&8>OPbRH9KW69BNt4jET>1dn{30HV7x(M1t6Naj>rZW|d?4cR`YIL?h-y6T!M zbT+v_ta;0@4gUb?D8b|CK|K0ud0bMtbQST~CdGWJj<|5(4l$2^O)=X#zJ@TB z6*4RktM6umlfNEo>DX(TX2(4y1tlKBU2Q{gm-ux=DT%4o)@0AfsxVgrzE3&QeC=Fb z8Rn!klEB##{$+8ufPYM!>5?fbx`x?ysiwNo#}z0>NTy=5HgHbS{aw%PqrduoS*#M% z%HpKGJ2FTPK5TF^?%KCeyD6fgx+Nvm;}yB3b1O`d0e}F(`Hx~V+d6stKe|%W)X^h6 zf&)sfv49&pn4GWMBS^hRH}KjdlC2O`&rSw5^2Y=f^x%W7IyG?0TsY7gW=oO!PGt}{Z zt+Fvhf(8EomdZ)N9q>Usd1HUJ`q$jCkT$jAL!XoK+5 zJ2k?RvU<^9Z(=h|7~0XkGnQ=fF`hWrQj3yIM&h1?OXo;UZK{Ij1w<7zH)JMhz1}Np0!_1<38(bE_qKLd#iipZHmlj7Ph}SzpAA zGaL=W$$dcV4wAZCboYj7CQ2%*cW9*9lB#=<7C~D3W_IWo*7g`@=3RA5HpRSll8_&eRjJoy>Ve0@%U-uedHS9PhTrIDeuU1`aeYX=D& zqYt;+AQArnyGJ%THl+{r82--^{wLgpo2BQCRT5biB~8haWG5#%_6O;WRaQEP?%+Sb z!xc?F0vMMWW1pD)HFka{SeCA)Yh6(lZ$nW%J4hEB%x+^02e@C~Rh=(SrHbelft>h= z0WZK_`8uXFntC2uXccp?Rm!mgG|{XLxpH{iPjRF6zMYzx3RO&AqEWTj4$I%2Fw|1b zQ$iT+du9T?}ZrC2>XM)U?YZ;28#Dpry$C%ik zA?wDIDa*%G;w}kM!S9o%nJz7JW`<+FQq3cAjl|~%?VT)77lhKO7-a4N)=KmWIL}b4 z84-tPW-LdL&}x=$?4)G zHrj|*KL%bIPs)9DHnyH6k9s)Id}sK;btf#@{XK_wMNa1nqfzPm*7 zKt56kAm`gq?XoI6i7|uQBSo9x!as)UuaC-4eJvvc>SNl7>cT?9Jb7xv{6D>0BDT*( zdz2&0;3EV8SJwrxz%)`m8lv#g#|;L#>9Z< z1Cj?h8q$lYnl>#}6US8XbCtrW2Py~H_s|}gw?}=3aXhajKyacwo(40Y{PfdAG-5i+ zc&pUBuBJ3nxyobv`{|Oj4u;;I>Izybr5v$Tv<0Kcka4@Y=3x z0O~*$zhzxQ?Sg8So&zML-jpH?t;3Tt!I*X&A>*hB86ZZ zX-pqAR%avF4Cs9=jyvW0<7aBm4ZIgF-Y8Qj9LIuqB$1F0kUpAd%jKXo@z}-Fe};3` z(cNc`>p}kj?l4Mem*K$PHv+J!+GuH|MYwaFpO<%` z1D@Jrv^_oE-j#IQT4NRokwF;GBR*Nj?VvR@vDRD+kkl+UT5#SJfv~cv3%oR8N@ol8r1 zrl^vZ=#dcUB&DkG&KPaZGw+^#^{-1O#>-NWhJr?f2HE*kBLbrY@O}Npp_9_an_$Vw z(n0FWaG+UfmDz+$Hy3OYamy3i*F&v05Y?h8*~~Q3BeN;;C_Yy|UO#;_y$nxAl{Iq4 zvc|p~gtu{?6#a9hs)?znuT(M!fRT2@`5VWF;%AT3pRTxYV@~3ok*|YsHKwYkucz9z zND526nfm}VDh`-fZS^%Y>EH)q@xqKw7z!Z8@tKy z{u+pfiGjilF#iC3Z)Krch}+|dbx;DSIRoqKrK`2ctM08dQIe64er%lme%dDwP)0sl zc>EF@Knvs@I9aU-zjIifu zAJO;EIuS*5c5@{|gk0p{a=AWV@1qsaEix>S$Tzx;ht!eC`~LuqBgr^+!x+W(M31NK z>EgW`8g`Tvkbntn`GMuM1CO?WR@o(mVvui?qi$~uVVO^2->*6)M^RBFNmYn55}}+7 zfITzy(W8o#mtzFT;jw^MJOTAQ>!s(Gn-Rg@i1sI%-$fJ2yarsdo&f&a<0~lU1**?97o%%lg4ILWN)=Hv0 z21#>~$Q||Biz9lF(QRn)r0rK=NYZNKB26hpjyA{uC^6Kl92!com6~;lk4a-yA>l;Lah{Nrk*q6 zJbW<#j&M(NuFImNuq$n~6xHEG9|+(`WMDueKPzL`2U6cjjyXY)71f!C5r<$Hua(4+2%n8BB{ctnsqA~Rq>Pk~VBeYaARJ+K$4CLqL zAaXz3SyN~K0Enh|>Jp)(JJgl{lpG#Cv-%B3Kd5+POR{opD_c5E98#odS09HM+Kh?) zKpsv`w3gm#Hc3|CMHt;2g99u-C_cUa070Sl>Ux-}L%UBl2^$!2p@-)EwCQfO?+o%s z+p#B}7q_7E*O`;n=1Qx-qH%d!+5%ebuSMfSQsBwBY$+hvzz#4mpcjsjuew$W>M4>S zqvnm6GL`cO9{J-)zXg+tBBcOhkW|^ch&La zSs;^pWzz8>GM2jWYPVET^$oA$WmwFfI^r_Cj3B`52O146-ZW{YhsCC&Y?!2xHzGd6 z@^Rk>UXrnX8YOqd#Wi@Qscph14dWpoXTNSUrD?i;D3(eay)<V z03CKTl9rf(&=bvdnp$>Ok-}{Fg&ROC^A7#_>)&5%5SAGVee zYFFT-VxtwbxfyD#=ww@ktV= zD9A$WC(o0N`RKplZ&gP15XV;yH8fB_vA{}UoL~$yXTEtj^Vbi>t9>%{XHQL2CfjV4 zOea{5=E)i3*p)hLk&KdE*+`q7om($Rt;(9=OIRwR6RaL4)=4-dDCc+szimivv3u>V zw%1V!hBp?G*sN(!M@9`$rtqleCjupMOQ`3b-0HWmOH)xt z9MRM{UCLxa_!#p3{k6sFGXDU@r4GE4bES1<+tF95NerlJmMDQ`VKZcG_a0{_lkcF@ zeD|)R_(;K`SA-c5VO937K>GLl>IJT@ijv$NVc1SUXXRXO=UgqlV?q2zv^qGXtg8x2 z_AH<>=I_0=JnWt!fDbsIw8;yjR!w5_hRh$fM)6Zn8dwRdg7UDnq0Qp~Kx!NtO*WD#a66I z2bOi>+JD4EX`SvAgDj^Z8!Ati9=h#}c<9bovc$S|jUPMsW;e~ zL7)9+Q6lDOfT&Uoa-rH++uvPBS^oegZx)s=03WaSqX{A z&QI4+BvPk7dMF1}f9ER!_SC11rIauv<4|I3O00h_bZR6Dz)8mL26Rxz)mM@uDv!!> zsC8RHj4&;YPMp0wPO%^A&NLr$yrHBS8RtffH+~yjAh-1giCG)GZxKLO`bi_7(>k)R zFH0}MRK**Yb}Wto&fJ`gd1|G#)ulCDGpZLz&ngZMnVYL)lt_%zGd2WEv;e&N>FUHS zS{llUC}oO1R!Ed0u;F>hI+tj-K^iafL|zL7Wq!mH&(|O4OWiwLG=;omX*Wa}erym2 zr}@)tQAsR;D}oqm22_U5&^rNVOsfwQ$l z@xxT~)yj+GNgmwEjFeaGK*%I_#+05z98gI}SV+js5r5DQ4?agtGhXj~LDN>dJzZpS zTPbHprA>-~8C7FZz|U?7KBVbXQ0b1g&akDcr-d$xV~&bUBZo}F{?|oY+!J?ADK=;=hXUYY!opX zvS4aznP}+cosdY|Wsi0^9s4(LQKY%vRhA&s##b>kQW(broUSq3f%<1h?YA1b$YEEE z$5lwO#IF(sQh|3UX8xjb{{Z-O)yNmbT)V7yh-m~WB&wt=!I^+DUSKuX@8RevnAl*-e8?lAXI5-4nN>wQ}EbT2R zXj5Y`r*V>_C63=vbT|#j=~Yb|EH$yxyCblMSc3)d`;dO$=TNBnF5Odeto|WL+iD=f zvPegV^JK6+js`}G+B%+y6!9ro!I-6VB(gCF4WFpU`{|yN??EEHOV135krk$mBP+F? zxlVXIFh6Z9l>(ys6l}Lfv3S<0>LsS7q*YngQ-%Ps3UlZ3k*?iMdZ(~iVXN@ou9B)4 z=8j+p+q@Hx&;#wKic4*lvf?+#;!{WhO*7*K7izB^_iiNf#yjatt~z)4GSg7d%^gK2 zh903Cvc{$N0KP+m+t*xqaj5*5$|<@SI%BC|>rR&uUS*iZ;g*&_0Cg$AIQAeNCUp## zPLsL#i1gKQ*48YMNRRUsA&i_KB2+h^&bf5`{Pd;%mU(F*tF^-vt5F1vfwyi+{{Y>V z7{_ysHP*{P)oFBVWG}MKD=j>Le9ajA!`y&bPpKM|TAVt*%{Wt_bJjKDdz4iF03>9@ z@x;yl05kwW!14#>AfHVs*WPO_74mqhO4CWQI3JAv0Ho*Xjy<&f)4~}dhN>?OU5QXB z(oh@aXBce%080i=JoBP=VQ`_n$o9vPfS1LPP5@T&;0_Kxai2V7_tBm{Z+<|g{{Ru~ zZ;)Ag>se}}u4NIT_@E<&)*~&`L^qDXJZnM!<@xyB{|hB^!w9O3kRk0HHu25WT&zp!b@Y(UqiN zV;_(x$II*YJ+-B(MO0HW?H*vB+M=51l_sDq;kZ;cx~|pT9cN>t>dQ zIAx}SQ#83P;1U4OU`MC6j`a09LqPR6OM^)QbKvg5Qhf2}!2R`TbY%xfRL1oSS0zDJ#Qnf_V)X0*li-2L1 zE%Riac;ijIbzr#I=_5)w>1yMin{-k`84Kbm2b1T3Kt8z|tSMrvaV@&%362@iF(hsg zxoqt!eD!52dNmInM>RG2rna`4kWkThA=)ZtEU|?EXV=f4u7-3&PbEt&WM_18tYOOJ zZ8!x%KcUB_g>?N5-=-tl=1Ti?!0eT>9iu#Ck;XIReKlpxWn*Noj-67Txl?5{U`rsXr@pH=8aV4I?4Bu>Is8qv6-}{526nR(*aCDyTa(oE?b;e> zg(MQ!Dg_g>IwFo#1MD-OGI6fFviPkAJ$77_-A@NhbPQD91ZjDvVH7|vQTH|rl01kc z^X4?Q)ie_{+xmO=o|G7MbD?~*6E@&^HaMS!zgAWaS9mY zZ_%sp{YfBn9GRd}n|=^UoSwnJ!ADL&iGj?@MvpM8&`n zB&YeTIpKgSx6ET0`|7v+LF-y%t(uY=7n&%g%QL9jr%kr#w<34B4w;o!1qUeg5s4Ea=0ZS$!Vo%O? zleZtHPoUC;zM+=7I%>OPn3dHf5x&sa1c9GE+2@^k(sPozBay~Ke@+GtV4({dLXHB3~J81~Z&j8VL1|PTX!wnf5tc> zK*J~WKem^RLR*8iJ2%4`hnheTsa4zgm>$C?8h`vj{5_=jqiD9D2BHKFN?2|sxH}2^ z_w?0sJBsU4L0YX9X;{pQ5eV_h4=c|NfO+!$HRR>=?-?iQqTeVCb(QkZZl`KT1``-( zi09_WTm$Rt_tZP}ou;OzmJ*LkB$9ZBAI*XQ+}Qdb)M$Nkm(a7nCWN44b4{U}i2iIShk_J*y*JG~?xa1!(`s?*h8cMaaLk*>tl`5$tk~)=B zI>R)8;h8%O73?_38t_l!KmP!a{X_UkH9cL%bh_H>C3w=l6d-t`lCC+yImq?b(PL7J zsWOhK6$~Th`MddPlF+(x^5+}r=FeWF`Q*EaC4s7*F_0O z3xqxaR>2NQ1bb^mF40REk7}?T`{PEXppBoxK+;RwC!X4kcczYlfy%aLXH*`;oc{og zZw8@Wc$an~#zvzWnKD5+*6$mG$@164r!AvFHjcER%P4W^btqTIx+aHpJc-WbK6-{= z&*j%upe06l&V>nN%9^&RF8(ocd1BqpqeUBdfdSlyTO|vqViOg86&=MJ+#$EI86{!l|EW2M8%ykt9!{oZm!)W4Pu|3Hzf&)+tcM->hV$k03ll{`l?!+O;L`f z7lvq?5=SE7kEqvsRG%GlrhuqBpK(7ypYf*(s~lIEk{i!a(n)a<5gEZefJjz;#C!XV zE7@R3E|E&7FAyhh+y43-c(Zsdt34W1$qAL^kce`?9DK(($Lpp!?vcd|as~N+CxpP9 za7X?P2DfzmeDo1iijfi69YYTx&Oc6dJ=QvCX&$9~N00|+AJPvX`|+icr?Vin9aR~e z%2Ae;quDa^k1P@O#+$4L6BmP78_puA-T)hS$Ghemy-4HlrOR}PBxa|=_=Qk03-T`< z^XPrGVba?W!x5sWj;1hM#f(TYGB>yQgZZjZa9>%WSsQQmhieFY?q# ztPVnga0VCm(kwSxdRi#VsHsyUf=H0(8+&80=LbxES7)eNBURzltWnG}vMs@Z=fB%H z(g%SNibe5+jARmS0C1-#pF`!1Xh}aIYryM4)im@nMdQlOT3yJUxgZWt)3%%GZdsQR zz!DV?Hsx;*Ab0!sAbIxHK@Y=0G&J`HXk)01_)?Mij&P*<9{&Jgr!JDaO7{tE)v?L& zz8OKYxkty$K7?TCN>#LEtTTV7f{x*GmMZFbA`Gr6qhS94%XJuVe2;98Ekd=)K`qW2 z%juBQ#TrwRoRFNTYiv3*7#VB@&}pVG4G9Z zwbfFohMKx$j+x~yvNt#-zcC)zBlOp)I&S&XeQD8k7Fn&aw3PIl8q$ipwuQo&gLmNY z-Se*+)X;3Lo|XiWq)6P8AnxNKbLGz&_s*JCHAoA6kCeA7w@=qiH4CHLtGr0%lVEV% zsUz#09QkOVy3<^3br(9?Ayd>Ac%)jmq#II5nRceYIL{o9eCoEeOIJ>4TAAmK5;c0C zp_&h&ym}16jw^<>tc;oNhO|}V{KK~0r1H60JrI*kliXRm1w>*1ehYr zEN5!~4hnV^B4n@x4dnj-P$#~snr@Y}O;PaI zPYS9*OwTjOF}1P=-J{T+J%*kqxp-?WTFh}GZD7nt2aIFS$m82k=C_JkwrKuXBy)$C zoJH7!_9XrE%hW@^vDVQ;+N zclRyZ$ef)$^=+lAr?*NFMMp}m&$#l;>Oz&zY@RXi?WGAVJ`KXAdX-9uon?`^Bg4Sk z+ptmllfdIvjN;z>2`KMJE5()WC0F6ZqNVBR|`dYc@;Zslo7g2)AgPg8AobjiISiLYN znhV(a@=Elle-putgA94(dFtS>UwnE%BVj6^%2@oP-0RF%TF5NX#~pH*otNfkUq3PJ z+g2SPR~X`@ik(CWCh$&p1y}z7KV3&I1y|O%?dVIAV{PhIs$?)i^)5jqhuW*}>FK5DDxE3Jl`ynyyaLVK`hn@`qDJC;eaPZi zbC9ZXc=Gz^UuU%Vu(d>rts<##jOV}CQ*y18i?>ojr%&1JR>&fxW|p>c8Qo-mm5$y| zBkPSuxzsIfW3>c|M~JcFnmllOC)jm-$qWlV1ZTwgvKT8VC-R*&RoS7l z-dcj9k)vQkqX_Z2Fmex*90l#`p;<8aT_arV-rwl=T1 zTvgr^y$if;78eh}@54d_##k?fr-Q)+N3Wuu*yNd+Zn2rAK#JMB1Nbvvi- zDS8xjl+pO96DmlCO_<%>lb>v3+d}H4w#WF1b%Unv&2y)Qo(6NfaPgT=<_vp|e_d(S zHS~QCTUAR#QyjL6u%;IQ!%>9 zk~Z!hhtJSyJFdSE?RJaxiEURHZgABdfl@{BBVmqMb|BzrJXb^E(uyHbBvmr2O0^LO zF2o<^4?g3MEjCfuTE5vqC2R>LH;BFghy-&dn}RtwIT-zQqZGT?tm`L3)IB_gx&%HN z8RLD2Zb;jV{{H}Ncl<52P~Li-t=C3a!^O3OKGQi1&OQ4cJhWb#<93eWS4nVUBcVPJ zSi==o;DNOHeA(yer12c}_gQA6jcMxP2{IU;YpL8bk4$F=>y1uXBM117qieEqTJD_# z*Ob(EfVCFOu38sI4z5Esefufn%Q_kOi_r1f?Q&nLt+jJORZvk7f}z=u$m!+*9!FNi zHIk;WEj>|JEkqGmSUS-dM21r-3iv0GxNXDFzM8uK0H#k~bOiL4>VZjKp=6+{cH7`c ztUUa<1bq&gcp~vqLdPSFuFoD6ZDL~a7#31-^9A(hjZL;y)mtHvsMp7( za2eI#4I2T$K6uH}{5JS{ewMbPqM%k!SyE%Dsb)K3!ylZVOoPDhogc8(#<1_HpTsWg zL$A-6h7KHg1_y!Pk*_{ljWdfol=5llxgzMUr>yDNYaQvJ6VoJxA$&+wVEn4Xf^t3a z_0?B>qO7vr+MZZthjCY3>Qu9l+?-_dt0KYFcM1xrA*-u`u7+pZ9Em3X00KOGxE??P zK*9ck9=J@y4ff96KYc$WmL7KKKCRU8)e2J|{#IRkO}g(Mf)48*Q2hWT>g1 z!32z*-I$T+04JYa3%`CJbp^YvXX*H>X=+|H1zIK|B;L4CcVlTdJb7zdlx@@h0E_8s zj=DN%sW(keTwh?F2;rRh@B;heO@D@0S__|1UhZ&6YeFDMo~&>2m=@f`fASdDHXKk+ zr@qTOrpFs6;eSwc#j4zwn#iv8mD3o6QH(5X%1_EqIpCc8YE7%EZoMgftD%w-o&>1E z5a5K!!ESquAKOR&0QK9_)2+7WZjI!Ld}5|uo*wR3pUf}|Xcdr)%}pId)TviA)3Zn< zAD%tKI4$kK^5;73TrQ%h3A)(TOF?(8>gP2{3)M)9%E$7uCk-skKYH3yC+eFB=@9lF4ccMIazNFa`783V%wfn(}IkN*IR+g#~r zsHyAet1725P`pYKAt4?tt(5~Fz?=_#ccSYNZRy$U&rG7F>RMKwP#HGu&f)YK{d5&& zTI$+3l9?s9&0h*Ui6%Z`bA^7up5J{K;jEFAP_3s5EbgD`n#Yn_6h)2c(Uy@*>NIh|x@$G@C&PaKOmEVnK|8I~-`7{{V$lwKn-yj^Q*{ z#Xra`QVKCrSyb`c=JMxB_lrfw!C!D%iRt5(Iwz)WpnxS*Jyf(ZZi$MWdMp0`${RIAooQyTC2Br8 z&m4QA1m)poR{C;v=Zcte(&XxZqFr4nNY{=@^7GdOhi*6bBiGAZWUZCh7HnYf2bQ@> zQ1YC)$?dEVl#3JsA{j`|NY14H02qiwBau~tjxaO>zdHDM8o;2tSH##nft(e`mZK#? z5>`eV4t%-PFuyOpzU(`4JoG5kZmy9#ZNblDu9MfxQro3+++cfZKMUMrS~st*h)$+v zVS>bubFA^4uHDCPrnrE0w$taMMml)R1~Y(lu5+)VJRW%-dg&ZR`*zVlv4^B?bo6qx zHwYG@nWabA#DSj2IP$^g>8ZEr;vy7C-m^$G>QW~yNf~BKjlc3%?@;vKgsC8gL!OpiMBhnv- zwJ=v&rK7mkqtdKDE6Ct@WDm+u*Gv@@w&%3N1v_q>({^*pFvmYnO;Vi|4NR1VwKCM{ zF3CY}&N0vFtG3nfj}0ADqbjT>7Er$3$@-l&N?Qe?9-_8rVVx%quGvD%*@kib$LXV; z8+)jrsfsFjTgI3jqrO-i4RXC%rO1b1Dq}lA{McUL4=xUMy_sO4hTm47mn<2x$UdV} zlwE8#U(|h7CD)?vMi^ZxBY;SmTrTDI^*=Dqlp~(oRXrm;35K(0ir}tU^W8{4o1Vwm z=xx&DC9c38I+9kRC08nYEWxv zUH7Oz(m+zD^v~a2DyrwAxmD2K$)OR_szW5CvP{K^13pKb{<=$Sewb6KI%*+qOQe80 zNw!y#F@mK*11H}jpKWmbEwSBX>g0-!)U|YzQO_MwpYkHMLe2AWyb_}WxX9Bt$3;^{ zwzb8=-e`msF)Do70QNqhX%@xQRrdRo5m3~$RS-=s$)zVOh~L-cj@caN9B4vQk7zPU zmmTw?=x=>7P?qR|^*>ER5NRSM4AJfRP@e7bspr%kdE)g=aJ5!e-l3tXr=p8!nONjT z>`72~<2XKA^;bZ3m0k2#2;!!yo<~zV!WceyMZC>J zG)#+A^1O(^#$5L0RfbRc{YI@Hnz<8o(N@RSRj>GKOI2gG(k#^rECuFJBwz|im_dR- zY~Y^zxY4epT3)*7io;sx;~=0F6snQ!Y2}Ftb>v`WF~@wUI$Y`bZOcn=ntOZE3_z4< zldOoW3vq+ks_X;T8lBMB=IQkU{=Qq8|>Ha<{%`xC29 zhSMDsHQg~pR_8mlHB55CcOgImu>;8RbM)0|a<^0^)s|QmCL6ZFAjaif?eFK$m$#<5 zvO)82?9Xb4>zyoDyBc*0nW8j`u&_=;080V8@6XpjC~Ps(+@Y(jsESv0O{@D)5tbQJd7TaZ1a=1C_M4c{{X^txpTDDSwx~mX=&k@Y%kb==ky>EsLB?% z;540BR{468U}+{*Pzp3`fcMWhAMdW>;UtsO(bS#H8ykC>90Br=VUAC3`dhHpy>)2# zifQ6`)${9j^ABoN63Ts4%4 zDx^td{Mcd!=gH&`LFMa=`RQ8z^pus9_Svb2g)@bdd*r!2*@^PbKHpt!Xtc{uUo7RM zZQPF&1gOuKr((7T9r~YjM7{z=nHp}yoM(?e>Fhh|c;?|fv12>%S0<)9s;bI) z-bkvXfGLt_M0iE{m=7Q^fKP2p0*bfATYT^|i%;W7u^&DBD|v3f<6Xs*ea7iftx&?I zBMFL4xpGmv=eFMv40`E8)gOg=xj|#5nhF-2D>)%fz_~!%xeJg$_t&+Fsj9AqA0f15 zA`$KqXJ?J)&KZdxU_QrRe}D+5c}uJy!h9(EvvJPgJ-@!W^tI8dXY;1OPjK zj`}-vG1Xf|PVIs~!IBh8d8-Q9P1T%@)<2pF4g`=hu%<_SIQe zV5;fMw2MnoxnzTEZV%_rZ=l8we6{H(suEFSYSYbXs9Gw5vm&PRBK)`qu|E0JwZ679 zMG2*yd@6aD#a1|Dl{ouwbzR=*I+DJYN`#b2BScb2PCqgXWFK5|GpPM7QFWHjJXaWJ zg{GMlZpP;d3uJqIxXAU#p1IrB zsCG+%v7$N0*FE)KbecR=i!DYHBBT=`JQ1&!>h`$YT*W$r;t{xTxd$D*wZP)juLYvM zG{PMv_;*)w>Qps!@>0^&QM1xXQg(?uWDoUYBaJy)AcOGpsUd6pEh&m6nm3KN>bKq6$cO7JG}mw&8>U@kAv{Ao+r4>^{Rze}{Di zE|Jy7+nP=B%%MvZTydOs?V;X-da|^J+fQP)+v1{&#-}oqHAq31oSsy$?f~*WXF5Xb zyKI*zYowrMS*bB1k}{(w0Dpa6oq1a&HNxd-rM8M1I=Z1$#I83KSti=uIbo?L&h#BnN6%v;x_E3wnh&cwK{6L zmXHCs(L@M(P)7{SfLyYF8~JEL8ZMl1A(XC5LZ4RnIhSXpX5I&haM5<7V1UKqEeR z9Bjys3L2&-p!Ya*x0uLSKN9Q%wg{dHv@G*8aMpNzdPYL=dglFn&ojz>uv z8gIyo2IbFul5_UeQ9(5=Earko{{Z(Is@$w4La>PeJBahZ!2LZnY;^o)il&)sNQyaR z!igf^BN8y!9k4xgoA`s%a>LY=S5S&#ohPP&Q3r^pc-zO8SJTr_o-cY^5ymlEKGM!; z;U9?g!1`{-mEjWr^B`LX6f}w(TJn(+H6;(-7Rc4-! z*G~gUA_adP040aW$((V>@9cebmP>u=BMV7N{{RaK@gxyOHml$cN&SbePOHJM=rE-e zf&T!C);O*=8$h`lcwTx5luQtk;x|uEFd4r3Na=e#_Sacy>S|xdcM?amqCiJtPnZCn zH1X9PK-G7;dMb#2n;ZkfMgZV38QOgg2DhP*HE+W_E8w{+Fg!QS#z&WyxiLvR)X~!@ zH;dd}301MlB zAe{iLyGctlRnkQyZ9?1W+>*E;fybw(+~~JZR=-np)dSVkwD&1KX=6+Xh$XN_eUF#l zzOCu9ziq+xiR8yqPr&cfce`cFs4dme)z;7OQ8bkcF$;+qzn8bv4u0Ax)K~gzrA<8Z z(#I8foKj1iZIdm_XSPQJaq`tm(l@lU0BgbchbB2BDY;Q_M&$#$@-gY0>cX}?5rI-V zcCD6t$0Saf3n5afz#i@!1OexxD;*<^6tTznk!Ycg6D0)|3enFQ+aMBntP5^#$+(hF zpBnKW{{WQzEpO=0#%s+dPe8Qxx}=V(6kp1BXK^0Ifj@nFN_uvpzfs1~tvkz28p;UC zAUuGRp6$7NAN1?VpZ;RJL$54Rw4zIdR&cdy2P%ALE0gEP_SS=TlQgSzdGzLGQNS70 zV;B#NeRZ&t5>=3}6bgdCO8hw!oP73E8{Pi0n9l6%b%s4#jzTX#H z2N}V}w5@={AyS(T0mhon+S=&;B)XxI0UN$hq!ISyXf4*A<8k8+tW*V1H~?t)DAGx9 zv3&4#ylx5SU1o?D`8#WpM~3#+HJ0z;{aiI2I`nAjC5_#kbAm%K^wO=dVLd}o)S(tMs990p<=750$JA=sRAp2w8(osRiop~% z$cu@Dl1~2s%j}GRcOHHC$DW$M3mL17bP+`FP&O=ap#0}>JP%*?&~CMornY)%>DW9n zD?5Za+TD(QIM*(PzCEJ&&a9ABLhB_o-X`6y4`KTM0DW>ak!aaxT7a+d3E4{R?}fv&mAj%;pW8!{lb~Bjraukjy!6DCQoVgz z2{%&sME+8&bNBE1>(m`B*0FUJJT&P>`M5mBOx@4NY21 z;GO|-^0p7$AHIwJ7Iids%d;fa>Xh&D%FLO@S19f58^5vFLRt|v*sa$cDO+W`^(|G7 z6D^kCbcR=G&gBZg6%CGXrG`(IMtkW7<$S6D)KxG=+p&f+tfRjkoP+fm^iNdXx_%*k zwO7b$WVk@FRizC70LtC72!Hbx2oq^)1uCgk>}BlCJ&%~wAE?6z z#j{RTwi)S*tX(l(Ni7KQY_SB2+>i(~1rMSk@*HjvMcAhgR zrHQf_V;qc=jCm;a*E<~zbuUra=_qJotgVTpcqt_zqXb4q7@hzClD?ZpxLm#>q8TTO zouGP3l=;M)WLMj}x#VCk7#?HPYoRE#q_%+8jt0ie_ol)J6mNd(Lu4V12y)hes>Aa$9`U)XI-bCiym%!2pfB$R1>H zG3sZmEza^;&8O}OsdZ@ zUURgSKKppaqT71uWsnz&Muil3Sr{RCg8u;W$A#6>8MtL~LZ2dLB=bIMO zq;O=-1oi^3bl1T|VS$}vX#6#Kcw#+Im%g-y3W?2er>AGPC~=qMKRDoh$3BBj6+SvL z{2H&!K2>$&+Zyj3yW*(;oTrG{X_Z@!*b9;6^#1@?wyh6Ro}^k6=7br#Mo6e(6m;`d zlMxciNDCVG82224J@mPFx7AQv;&>{lP*q|%W0pQ*VsZjG?f}NEdi#uUs@v&V<({F` zJ02H2d3GI#>7=WbQ&m;}069$bh^&B57yO{4u2ccsl0IYQuYVp}NyliTRJ1A4*U1!d z#qj$fs5{iT#^as`(2Sq{wJI9;D=HXUsv+@WM|Ok_#Q@;@4C74q9-o35SB9o3DJrSq z;zhyzthb0M(|S6!uMQZ<+}!Y_)WB z{t>8_4<0n%{aEA!=l0cQW~`!`@d~81RF#38cn*IyMoB(=g+F~+Hp)2?rh*iT7^$8x zrO~tCu0a3;$j&~$uB2VMJ_tIdOPy6LbhSnW@WewgmM+A)lgS4>dku3X6s7c0r_$`# zqwWyjDyyyb)`-m=Bg*Q}%8YVX->^E1)Abj6SF5hKRxKP0kBAQBBj@GX%jch=)NjLE zoeXwqt@SpTe~4ATgB59q=fh; zX%)i;BN^cQ$2jAIj`~adCF}b8G}c>W)bdIvkR&rE#RVid`GFZcfODTMJat`Kd{uPG zQ58MJ)4zt1K*Pl>fj(i90m)(N0MqiMh3$?p+irbbJzZte8gWNJ8X8J)cOMxPm6RUD zZm?dIH8kdr#a092*@#~bKybhwEP1RD&3r1Kl z1;!2m^wl+Qb*#H0&f9(>W8wn;04ej%e|&rDZQIcCR;;-H01?X6R7}??+u~rRji?qS z`3kY+*}p@NZF%Z;l6&l_MrD5py7*y1SAH@N)baN^bB6O**4;@JnuYcNjT=-r{IRGl zjOWuhIu%(3zRPf)hS^lrQDI5b;z+ zh9rVAN2nW5KV1;D+kOsfb!EOqpjoOUUGm`!n=ncd-zUEwSQ*d?%cQsJ+S%%AMAg+S zWD+vqxINFHIn+}ji1^+zy=)U8c+Vi7bC28XbQCsg7_K(krN-f1O+zel2B<^fMe>#n z$T;k9aoq5A#8F(LnkRVC;ZP$a$%0ssk%8thocd_xg1*@;bX8XpL{#J|MH$)g8SWU4 z*w1t6p!bWJog}APsiL4t!08Gi>_%bVj(Fq`Z3;*>GtkbnFvF?f_HZR08!7sICpUa+C9w9D|&RZ8eYgKCq`(odE<=gU#YkL9wmpT?1G z8gW-@k|^CEni&993xgwI{Mqb2P<~x@p@u3LV}_!%$x@D5NcIu6bIHi})kxpW4D#n|N03Xf9ANr+>XNc)20B{hQx#n`H30tr zoX+q`zvgL4;4i0t?W#h)YxO#K&{e5JPCEU`vf zFN0wrCLfuk91h+4AMd0(N?B8??_V%6Nm}m{m)qf?Y>+*1o;Amx63a{ZDHNaNgS7!_ z?KGm}6lqN=ZFX*UcESGNu7`YGMLjRVPYpb^ujo6?XqS? z@cepZ&klfcKfaS~cdE)c<+Z)f;Vo5_DjrpXa4Gcfz~uXZsQM^yoTFfywik{`65eX$ zp;=>+eX&alkajm75C@V&1IhIE(=82MMV6+Puv{tYW|pALjU0sdE*yfa0KspU%hx*T z9TlpEg57bfxK!8AacMq2jw!;&usJyOLN_PMKkCn(f%OgQ-C22>pQflIw_2m9W~bhp z8JmLGK0uzs(CgBgT_8j{_j(G?5~{X!sH}|y(8lU^85kdz*aG}(^ZW7BtEB5s#S8tx z*Tae#{tg8H07^sharI!w@2_iCA%d-HU;)h7L{ZNE%1%DR8t_M5TIy{*VSc2pSp@N1 zCk|v7B$p>A>zwH^$9oK(g{^C!eRgFdjAL3lGJr_n_CC7lvNmbB-~x4+BS}g?0evl6|PIyV23A$th8}QAyx- z81JH>GK*|Y5dyi-Iw?m=v7$>aX6=)VYDE>SODvBl`4zi*=!HcV=2A-dcq9yV=Ua^; zZ8i)@O3ldmPV67Hx<|4n!TJ5qt~u1Cj2S{o*Z={XcNy~3{{a0zRad#8Xp?Yr3@7~2FiWFM!th}T;Z((CkL7)Cs29=-HZ zrk3898meOumJtw=DKdPNHV>{wN7qlqHQ2jP^l+yFwg~r9R89i;fJKD_XOhf)H5Tnk z0xrnX6;fAVlf)`}@^n!aqFOrRfV9aA5%TR(wm?8%wxib5xwpva5%CMF&cN^oI3Rfq zHF!v}w~w)AYGp=3PDvhG40q8Lgfsly06g^;L9`W5T}7fvvNU_6nF@v^9F4v6 ztLl|jdrcAX40Ez8scerRasK*1{5q$nlJ8K}F{8+ojPUN~3`iLs_|NOB4x$57MM{7J zH90DHz!>re-&y=%GFHP@`{dP=L}e_q!k!Zy;4cRt27LgrI9=`JT`0>&u>%q(+!eH<(f2RfunRZGS$yP zBRsQI(#R1!c|w#>Knf3-1Ln_`bW&{rIvebDB8o|=>1qqX8IeMgRIYQC8Sk{^bM2#( zU+0cnC2bLRc&Vj|XDCmDmmuf=0FK=)*=gz;l}wbZ)e=@pkxbkx#hy>po(HhUu8zhY zHm`zd2}s=}ol5+q4i6s9kJDW9jcuawegG{|Q){n=<4C7-kXhMyM(zfESRFgo^;N;3 zN$RBW);-D)jAcsTk7Lf3XzpG=PEp&Xh)W5P)sgsV0DZmtSh_0(7O`bPI>x|JvLERnii*8Ez(Ho zvQ7Nwp5XfDL%MQ?3EBc%9wUZb&5jQ_{W5fTcyQ3N5EiOhq)5*Ih{k?wdYt~+@@ZwA z6~i7%T8r&i2cQuPgjteTDW2{K1Hjzbv?fRI#YpU$VB^y;`a_~~tyaaAzg zAW0>THvVAB!h1nwib(2I>dwz8I@IO$%JX$#cDDuP2RCC21+G@}N+w!Ih3N8lNtIGkJTGG`V7$6xm=4N|V!1;vJ=gebs${ z^vLqmDD5&W9F;X@MU~N!kgv)SiNPc3pVM6IGb~hbBmV#`%$Nr!CvWtD>64GK(*diF zr5YJV*xx=PgOGl@@v>%6Z(>wl;77+}~xH`7rlsHrNZ@ZnaHpr-QR@>>`KT60rXMXka(?I2 z-&`7|nu+$#fpCOWa{{W33^wq|`#A>beQD&*zeH}**j_Pr=9@*zsOH;e7_1AXY z3WhC%!lpa>pCCO>lx}uqsG~ILN|bbKRIHM2A>?dil?Ts}k?pT*)B2U_8%Ukl%F@%^ z?pDgu78zkhia`o(W*~vNztjc@_0byosjfDNGD8$~|yF&(}?nO1?kX z&CN8?7K+suPgLFQ_gbkxjndY5CK_}e3WXVG@eeri7{@u##h_`5MzYAJc$icPM{~O) zw>a!hd*fejm#W^Xz0Y;IxHHw}RCSNTR)~D7fyO|=A8mcL)yY#&B`ohK@TM&w0I+Td zRy=Xv13tR$mc~PWLRVT6@f_?UwcH>?hKxZO#Y2)59fu%cbI-o4G1A71s%z>XsA}p4 zb#|tc4D&E0@Bul%$jRf8qz;U+%uOxgzSQ+LIvTQPm;g2qr1Eo+2OoTT=#9gvD`2R& zRNCt0U*az7G?Ed#$Ij3{(}g{Tr6*J}Rx$oDt)?n_QMO2#%!?q2_Y48)^*@`>wy9|< zfD@4a0L+pX*ul2ph{u;Ynba3-P?VX>vxEwVh1#HDTOOImI?}^se~gXTEnG#JSrzg| z06W-YIOjTO#`etLgmJc23JMw;da8z5laI!l+O65_S>@90GIdeYM(qYg16~g07-R6a0}m-l)E0@t-_< zk*BuyBG5ihh)h&5m0QE?T<3NVCm!d%hgU&5Q&NK~iD6KsLuVUuIritU)Ag#B-B(6} z;UheU^G=5`pdbF3QQQ0R#;iJ@!HRl?rb=qXs4ojhu9!w_kbMsw@t)df#=~?__M~~9 zIy$IXMA8rDM%IYVDO2^T|~NjU*+8*Y0;`9mlZMpL*ysXEfl-pvoHnHsYfo zVZiKrbML6lZT|p+o{kw-B?x1F0m9^V9$b&Ejfw`d(m>P&sgH#xh~o|luuv3&eDd7r zr%rU#Jx6=A#L`I}HC&lzj~n>NJ^1cFzKhdCLj-YAw4M_b4dRoQZzKKjjdZq1l#|I6 zkTHojJaeePJm3M3KK}rwk;<6K5c=NJ{{W?K!iG88<3$WfJFGJ7ZxNV-p7|gS-kKFJ zRMylq@T@gbyHkiz9_yAZk_VUs;Q48Wl6zF9Uy6-{i6PxHWS1xF?XQ)T^dUYno~~&o z3y9el0D6Waztc*jjMEWoDrT;sxJPf1kHXI%iXmOXRR{ZS^Yc18WT+8cFba)=vi zykbWpR&4Q*JhPnnV2yBw%{X27SmBcbXGhzV&&pZ6vD|A&WV)?p_-zSlII(EbIR@(A~{eP z5<7BFW9_B1>S~Jm)`_1mWmtH}EH?b1yJH}FYGq86)$-BSLQOfRZP2MDNd)uyWPo;`sK?t* zdm5IUhlImTK4WPx6qdjvX+Gowj@i;olf2W@2~5+)NTL`K$=a*9{{VBIH6{BfwoJIv zJa-CKpprneyWsI0a_J^WJo*u&>U!I3cGhazS!%CIR0@*&V%z;ow0V*^`)R(mR;{N~ zSu5f#uH^@Cq#kkHWBlni{{T;zs!Xy-s-@(RESUwE5^#Mn$3EJWYBT+rt3+z0mWJap zsCX6~%;dgC#&O*J`PHpY55#^RGf7`YwXj@Treq&HtY2z}?b~TQcE+nH0#*EFVnV{5 zlWHqxB|+eyUUW{xwG=gi-BnQV%F?>Y9DsnnV>$gbeuY~bX0>MB4P{Njl#@*%j+s9` z)L^F`j8uc~jd>sc0LjZ$ES+a+ywF5rj^RWWWdjN%i0v5v0OO47)PD&8sfOK6yTudA z>>S{bLf`}D2R#1Tr$79Lv&j7&38tobXQjDD=oJb19bH)e0Q^9Hqd|T_dx_-J7W28& z`oC>JsU)!;S>v4)sF{*Sj$CDO1~eu`{{XhCyCiQ(IH#Q*z&O#^Yh#%_G+)lYHam?j zI^YgD*IH>Mr*R@=5!mU4YQpp<5t0{Z41D!13;aPbut@g?3Qu#WHlw|yj#5>zkUe!e zNXS6ZoDjqG)t7;ipoCQ~yiK+ZpHai&2iiCM5d zW4MnkXsoStV$%prK!w4?8lBiZIxMSRCWYFayz$>wE&FXbGNq)$%&3(>} zWTTo$W}c!rghnPm%SHnP~WC7DTf z9$RpE^wyO!c=1G}oWjIz&kVjtQ~6$vbhgQs>F2pg3{pzb;zfvV8-l6e@%PaybPGH+ zFsfFV(6^5yjsg-gKqK_yL1?O)mR5Mi_g7M<%OK}Qq7pUsrjSjto>`2G^5=fy2m?N# z_tVBsHTy4$3nG&8fSwKsX(QT%pRmq{*1_SF+N5sI2cC_|DUx>~Okg%PpG^S$s7clo>L#qxsCuC8OOQrrdnF4?sU;s)J^dchRU-p&5S#C_EW}_Ez%`LHB3{x zHtdv8GD~{r$ZM^(rbM{QMD4mMhQ-SO8MZegJ-G8dw8^?PBFu*1%M1dlrITdaC2@$pi)Om6>1q(fgdAa;|GK8IX`_T+=5o5QBN{Pu{d~qPn#n@mmgf6 zO1Il*r$IDTD_II5W@O-(Bb= zrJ;^RR%Rg-m1buc+FQ^60KGI_YT&ut0IA;vQp2~yRRC@P75f4G^~N`dLsv~urH<3# z$`c?n1;{73J^Av<)bT}Sjr0nqXD*$380zHPA%?D*_H1BEraKUQ&Ydc$8nb0=iksR% z_#Brw$M(*v_(aQBR|3Ksx)Q3m?#+M|PuC-zSrFY}9W=Cz$|}(jhnD8f2nX8-{{RlT zqmJ`(P5%G`#ippzsdmE}!h!(XV-jRw;e7{~$LpZeK=&tx-&G^i)Cf-bTjtolTn};y z9>0ALsp@jgG!=3D&Am{u%WemDeZF8Z_te&odWfSXPGl+yH)1{U$-!*?n)JB;0FaHL z#`n`Q)>>$8HX1a7hG8AFM(ka@ashX8JB~Q=^3eO7l(2P06-5O-5?e&jR5ct-L}gAA zLPx6=2OdD^&rfxf{WV8jNeV@UuU82oXAD6FhuS^f-MsX@Z=Rl_XQ-=Ri>#=qH4b7> ziwkpw=Zxo*fsZXiCPby#ttGIpQe3F3Zxu7zDPEp8D>Jt7i6sX+cJ=`8IX=MpX+XK~3rguvQr6(6 zsux|`SLV(JK4$X3&u(={)8ZB~V4f=KJ0Pl=KhN@Bqne4vgneajx&DQ2vivIJ+F z4o*3YfEVws%WwW4OFb1TMj=YS7D?oP&bS{c_rMwLps7u9O6lXnd^BL3fyOg{2bOsp zYsvK()a7+_OUzp=bxeT^wgB8mBB01L^_x#?rM?M;U@A=>F2Tj`k!xNv6ut={y-dV zImg?N@1(dohDE7ETTJsKg6asvV|Lx6_tR}Hd{xoMQyeJ~l0FrC#K3l8_R~)d#a~S5 zzD$Vqm9%p*!V4?P$|EBQBQ6gfo%?%e;fbrLB0~_CN0nAnA_+LoeER{dEzr~5BLa7Y zompio896VKLC2Th==a(+_3R>1mmuWJ@%m2XTB-Sg@RhBjh!ZY0%0uN&SFjxO>NQYIYp!+G zE>Kg-NdEwr83LCI(GBI#N4&&rUr=LFhoMS1=V@as33ZZ4VU#5ZT4Ag2# zl0^QbQMsEPxj)-hMW!W7BnFiO%Pg#t%mXtndxpo?`)M*8IHieWsEN`inR71g(dQd@ z`f0j2MLof4=xJ&xDJ53$)-ZWNfxn+zclXzGUlW}@hiPPak)fI1DtJ`vU!9gY6Eo~DQ4l%breuKAtX<@Nm?vOIuprx*)jv>2sPX zmP!dsi1DMae1aQ4G06S1>!Fnu@q>+sT*^E1k&JzDso%rnxzenGx3p+nqWq`1^Yj|k zX0d{rx{~QbO&w52t3VYMvaE~`4yU$HYrnUIUy!VWjuJ9950{) z{ON9{gwnh=I0vQGsIOB5<&e4LzTgZBQ~*424aP2s!XfsKp?)CY_M^!?7cBWOZ@+21kUUVJYL=*dj!Jeso!IPqQy5480Qj@pjZUPXN$s*utn|qff#NA7hQ<#*%ruojHnjMuzMWu-t%W?QH!shI zU^fBg-h)fMNlhuOJL#FJwzB^KF|f%v`B)#W4;nixvRbbG6(vAp;BAz##_q?Drhi=o zxYx=URK*%i5;82MiEYlu^L=^8+e=qQeG{yRQV~@kD-<$pF_}OP$AW!8^ZRS1eH2BS zw&6T6M2htg=X126ju$?>gU_b9+iNyb{4I0}B&L{2AOsd?;DN`M59^{AV)qqUWeX!r zxZ8|!Mo1X#fq}=asNFCmZ4D%Htvy6^EjdXfVJ`AmDFXuo-wpQTTspel9rLFusjbf9 z>QzcJ0fs8uzgy z(;nOnESl&sN0#n?ffTo?Dk>#GLPp=77smEJU>-Dq)W7~a^;N1FCbYnAv8VjSj{(<= z_Uw4qvD(JaR@X%5FrChf<0Jyv(?Xp;zT34#SW8YGl-M(?XIL!EL0EKt^(Pc*>(dS|o~} zho|vD8`PCm{^wk&qlRyus1PxY;POtO#O);B5}oEk2XB2(mX3HBu>oA?13viF+`f(c zoMeQ-D(5I1lDuf`ETyB&xiT|yaoBd$S)b!-3lK7qs-ls=hd zJ+#r5#ZHax5`?NKIV^xNJ;>C1g0WQEyHY?7KAPsLkk1^kO1SHXiO=e3^JBXZ0-C0y5QA2304Dc;7$*4HFUWz zj)0v6Cy|ecMkMDP9{S$PYd7n(VyLL5rd`r0I1TNce6(4b^I9n*m%vb)*}Vt*oj=iA z>FH>eo`qFZI4&J>$KQ9@>UxVPlVR&+L(;-yq(!BLQ*c=D2G9pT-}KhkD*5W+#PTzU z)k^L?fgSPdsog(ls=i*S?Urgat*WaY1Q4_SW-3R}cF)^U>n(E8z>rNIWoXH12c8ZX z4^j?BeYCIarj~`O%S@H3^;9T;vY9+2;|rX2`(*vKC`(q7)A$rE5D{U5wpq^J=N$PH z^v<2=dKx;LU0vc^-8~frIg7-n+FDeNi5bUk>)X|ahS=eT32W|Z#z-kbCK!;*Q#(~t+8i~?^5cLtVg2<9>%K?H3@(28#MsiVqvvQ&4 z*-ce`qM)jrB#FOl49l*xOZW9gaerD}jm#>pX#mv0T~G3BmR1LGP>D{qe!@PoEO03Vl?BqQ`JPmc~ih6kKDd?%BVI;Bu3~q2nalq_(AHFpfuClI_4XVtMv@I(-Iq_wcT|-Jh=O^5q5BS@Ja|BvAybuAOVt>m3lBYg{{CVkq zTG>j?;UXe4@N#}!{{Tt#^5;`+wKV{uREgqwFs_m=0rOn?@yPn?D9LQ-j+HUqYSa}f zOp8d;sgPd|P{{Wr!4zATHrbUwtOo5q!QJiBV zpXmT|sCMh&GE~~GaY+nxvJnim1YvRkARjz{JNlg}Zc=Ij`&H8b0<=Qp({S zHs@$84oB1v-07ld6By-2O%^Gce=OBTf znEUqQ%;;q!P)f$4-4t~7fxn+A8;cW?NawdduBO~xT4ar+Ws;Um09OcOCxSEOoDVMg zS$!rVoyw>!7gN_o8`aar5iSbsMFR`!Pp=2e>QQDMl;`>9Q$ZZ zEHhHmRje_JmUTFb#l}67#IbY&y)T1PGzR44Nr033&@@r*mm*;P;uljrGMg*(l^EYP?9H7 zl8E2>K^zSF9BADYQza;=tEDp2)GHv%;nyRU$9!N6AFedejG>d8L|aN~**eoTRB9Pp z!J;{qApEBMAKw_zOO5V|**}joUIdukjt1kOEjm=wEOvkuj?KJ`U}L{+RsB%9@fY6<9ivnAm)(M}GWj$-AOD zYQwEPHL)UkYIUiQsAUDzu6(k6bQ-a4(O#jOM0^S_Tozh1nqN;QA7A zjbDVOw#0H#+DD)I65-S~I*Fc+!ftcTQjy0~EJ8(7*eZ_v5^MC$#bctlXXPZ5|(%gVn)>*E(q`K-%CAV)ljw4 zI*8hyt{|)$lBS$6E8Ox%3CCmHbEL_}tGfRH!0~qaD;aNdS~{{gAgtV>U4Al{l9*Q+ zVtog%Y-s)7^!-YZ%<_C(bo0C>p@tlvFPQR20~()vzrRscinpYvr>0=Eg(An~{-c~} zekxmNpW)Ves3P$Ly2?Qzi92!h&wW88Qr!v0I8@lzxKtDFTKwn)+1t98QhOAR|BRKpu$0L(YLAi?^cbsL~>7G9d_FXGp_ zdWwnZWl<6=o*%=U;mKd>7&@*c#+14xFVP};n%itJ8l;{nK`zNGMmASC&PVn>+SW>_ z>*-iA424eM*~nqv-x`zD*V>ES(o3{eO;zL67=;_A)<}WM{N1@3`e?-`OPQA6Ur6y) zM_TRU#}Q)D@^YB(*mwOjty?Ins9A=RF;PcN3YDmqHkv+jl?S=>1cUY0OMqk*GuN3` zf{=g2ykzb@f{+i9Bz*zZi&VDys@h2GcG)PTj*TPujQDts0U>$kEJy&J`mXxo8;vDp z9FnH!6L$F7fhfb~IbOt$KTT;aEdul_bp=G1i;W{vA(2n=j>Y_h{!%@$rmF>2eS)+2 zb;6!%DuEbb9OE0*lh}O+@1uW&7OtDU*3T_-RlQzU!iEJgh9l-?_X7iueKbi}ok!Id zI=h?DB^2?QL(~n$6-F0eUUQyL(@pKDuKN);b_SZhzC#s7H4;juAR1kyFkZyAJiF<3 zcm)MpxJcxuc~~6eXP+VLbk|jQg56fO=qpm9f`N>%R4ic{Z~#2xj^OKC^}Vi?^p#W) zq;F8nWetInPD6L)vT??_q>^@r?@WP5eVGzD8l6Zq_T^Acw~psESR-MjS4G zry4zMD|#l`BBfirH8Ru{L{>PLAV>iGney}PqJdt8 zE6S36H4^P#OJ7e_Pqj^aj!W$xAIvxfK?l`GInI&wk_d)A6QzGhD5A86uLm!%v2OS=Q}4^G4O(n~|c7gczvp6gXn zMp{~7H-r>tE41T}uYFB1!Fi?JBvfZ&0)x(Y{{Xg@>{Sax3^K;j7}fH?@s~cnqf;)n z`5xs-J-Vbs?H>72ALYkjG3s!CT~avGR3npa$*=Ji>7z=Z%e#e>Z*>6V`)Y#!0P-8~ zmRkP+530ImC3zV_TwzY`a5la@al!j*(9}05?sX#((gN?A5sDe49gsZSzgkC93CBRP$CEO2XlMcvvz!dX@6kDc3)Rx39zGj!LpBs3mQSSr=^MfXn24 z{PkW+T@!I}*pmt}KZv+Jh$FZooR8B*>8AL&B_|G4a8IYV-#`}#M~(`vI6v>A)a8-_ z^s;4gHmUE=eK|ICjS-~Y21pskc-Lu~G6`i5^576~0s3kr^uj+4)>aM<$5YsAv|ynN zC(V*hmcU17QJHi33C2P1uJc7KaTjyGHXoIIz4gMa!XXh30qx{<+LA*ul%4jWZV#`n zwb;INb6F&YB?dstCy?$Ax03;oU}WI#I@(&pH9v-^3P!t79xzGou29oFvPBB!NgRbh zppwVa{As^{H{p{6g=pg#!tc&N@9(IRqRmq`f-}5`(Lm*o9fqvy58>5a(m@TyqqjpD z#?rgrf#>8tnsu}O7utGq?+nvaNnKkuL;=85Q|3p$Jp8pzo!OFX0n)u2RZD4Q_L1xE@`mmK|d-m2d{u2|u%!0yFYocm{1B@J|PMq%)k1MLx@ZJ?Zi!TM{B+o!23 zX^BUWR7ShdWHtw<*Gng5Qlp3AZI*h@w4j;^WT#|FS{V_x6C%djaD0G0&!%cZEJ6FQ&nFbM2-&cHdAK3^cz&XcW+r8;Ke zR*5O!TiV5k&gW-bWOoPILTLAYw`W-3Lbu@2srh<-Hp-hB%9glqZ`VRV)f10W8 z@ks_k90Qz`U=Kg9Ix12@uL0ZjzK;3Q_K|D&dwt0sZ;41#WIK0&jK{erC$|G2X?|bA zE6>9vMX1b+60w@08z~~-5EOSOpFK~v+$4!2x6A~Tvd&m>mIDM2?b}u-{<5yN)ClQn zUO1{ER%&_p-tFISrtS`XwKw-$5iOOVj;p3*RFugijDX1`hyGwZf#x&osM2*j6kClH ztMP^Qw4@eMo;V$k*H8DpoulfDYr}4w)6}ReW0C$$$(r1n(Zhu^J z{{U@9Dk+DGLFit#3$!t~HFVVoWD`jy*4{>Q#{lDxZCJLssilS*ffLOnumq8dkX)(2 zBo8n%^%|z)sWcY_p|}~LMSMX}9EDPF3b)h%GpAmi>RJnZ%u`JhB&HT{j&P(9K>+=5 zMm0%OLnJj#dTJ`^B=K6NnnKMQGUpO0&u=fMzNXu55nNshdXY}6FdhMs$Oj}2TgwET z{WT`-M{2J8GNP-aN=Q0_o=DoNLdV3Em)fAOZLWlpNBVT2VKrSLdAnPrSn2JPWJJev zZIG&!4eUnY>CTsgD!U0Qt1VSFUao2y+8Nper!vDdOSwQSK75V<9l_I0cS+eVABWVn zP|`Ye6I0Lp=;b6mOhko`XybFJBmvy&MVe@Oy4h-aLo4koO}=R`C1eHsKc)fW&rN+Z z)7C5HiYhriHj1h0Dki9gnxn&Q>On@wvhXqJbojBu95^6(Jl#iss+y{uZt*=Y!$-Dh z80`w9A2Is^e_eI0y4vFrRnW~yk=3lRuZ<{wl`>h*JNIr!m>TK$he>hiexbZQsTQ_* z#8T14G#?|wo_8y9$K_=7<&LNewGaFwRz&i~l6-+&fXd7{PzP>QoDUlR~7hugqxpCgWb z`l_te_Ni;$Yh|r%?cle=sFQhBQG#4{EO2mjZNRY06)VvJ;uKx`0S}c>4?cZ~KIG|T z7|>wjji(|{R@YS1)m7Tom7ZuF5XMH)ASdP_yJN|HcAs4jqLK=ol}U=J>Xe42s`+*t z6yKjd*&e@b8S2QUtXf*RWp44m|R8%%Zr`EthJ-Ro5vjl`>HbEfcVL)*y0O%W?=jb{O>M(@d6TjqUKw8nm>_ zDR9AeZYlu_k6y#>bPD@KXcCsPhzY3cBaEbJ^A+&H4yV^@^Pa>U=#it$w_ zjvA?fA#oW#8mYpsrbh?=0FJCGJMosR{76<&j}3?PoN{sJj14JXESFn-Wkt@I!8|nq z=5Zl0#(}ew_2m7=r-98gl6+o)EY$93X;yh@==?=9MzMd)8@L6JpdI~n8sS>hR~cej zPY~C-wnJp19x_Lv&VTlW_0;NG(QypjRZ5i1imMq4d^uZ;ayZ8whnr|;P$eSLRNN^m zl?z4u_1p7r-N4Us262JM8ttX{u_DmZYpu5r{{W?#QaJXmLE=~Q9>czf)>cBXgsXwq z<_^rfuF!cW)DU}WxwJ`7O#;*_Q`E@B=9v+TsNjY<{q$~Y#Wcw*i3EanQNfb~Y4QWt z9$xv>eVy75HO7|0cRPHkB2-%{3=<;FHw~43G83O}*z?A@&{anbo}NpYd1t8J)0b`E zF(9Zu;4df3wxTDEBobA}6+Ep>M#zlYisa{jJK*!Ka8}1k55vsO6CsrXuI0hTd-foM z#*Cp`=-ljNMq0^4FD!AcLYzo4SInP5gQgkk>F2w=4_GLnoa||yGEqW|E|qfFdSb;3)i`xjFaKMlVW4`Fy*g zyv;uDH!!(9JLff)5%@t9IkJI(D-Op50R^@#2K5xAMU&*FfXl4dx2*sD= zXKZX?#t*-iwnZhRWUXwkXy>S-u7aJ_X&=YTbs!PWMhkwOz4U(h)tB4t9W-|er5g=iSHBH{N@tElR+d(F9wMH^k31h;5u8hMvppSba?)1Qpz#&3cQ9N7 z?Z!L$>TQbOKfnp$4^olNL5n!t19$Ji1J{mDx2vdzMvY!Kk}Ah7B#gkx9_A#HeEW9R zQR5R{#dfKzl9`Pv3R>A5OzMCtE@Av{Xi&W0n~=1D3|(3FkfW z*k{*KEa1ypuq%4c!lGKOQ^_<9$w9{5ytd)26C@t{Tq%SKl zA3Ki0N3rMAN^~~%xWg!glun4qPDUgEd1sDtHS?se@l#p_s=i6-tyGC}_rK^1MX40_XS~wV)2h+$!JX-^)SdLj)rCNROLgi zcI0{vMl`xad;TKbS`p%6rLlrk@yD-o$=3C2Ul6y_)FFwhf~!V|S;=9L7!O`P`i$;T z*wlkmm30#>OHr_R=E|%K86>tq@9oaJRjbrJO$|h`__WF;XiSBrk??;oUn7D5^*R(x z(c3C0R%vNxGlqSD=W3D&KAw5{YF!Nlb-SubmuMYKfa@WRNtK9%0O@0#`u4^?x?0Hxr=ktYJ1sjzu!;7X@f1+2%Nn{C zM+9SN^ztJ*``6W5`f8e*J+QR30LtVXE;-NKoM{iIE^}QVYPvO)i3UQa~lK`V9MPspQ!7F5B*+<605zPMej5#DJlDQW`A4O0IA%X5_@xl%l^ zbAzZ7NM?bm5}-{~Nbu%aGF3DAPv1IqwpQCArGX@E?LN^gWq=4mob6veNc!hGA4|%7TCD9@?!9B}JBBg|3C6l{rQu^A6vAdy@Eu;Tqee zjLS$@!;ONEkYu(?dh`2ZRexW8AID`BbrQ)1Z5?Jzc>{WnK0dr_)5VwER!Ugy9$l7S z5;Cp;Dspq&=TVwsul}V6^B(xst#bT6wpLX%k;?R>E(*u_OM2k;(rxGP{^>y+vs1>^ zG_epKMIV$N*&Y15>ZDv6Rt6?1;$7oo1wFfJI?7YS8`T6!4E%EOR5C?C{Mkkz$f>6%`TlAfW0 z)m4BQtAbfpc738A+DuPPAJNPc%1=RTiJ5)^XPm!=6E(Trf0!9PL! zYU@i!Od{&oQqv?s73Qc#oQ=jje5dQ`eSNjRP*lx$m8)TmDWzf901;TJT#wgK>aBCC zM9~UpomhwN*Ad zBo)=w zZw(}hVp33+JmWlZ`sfst=>!LSa!kt1NFQG3S$zaywA9nM5V=BypT?IQw&8$D@5VoU zG*De$*;Oo6%#|?+)-+ZjfF(`|`*z@r_dNFV8T8XF zmh_!D)76&rtY(6FlA<^r3EJ4kexMR@@2NFIDzT%Y>Q<>k7!;{hIZWhd1pc|v&aHYs z65jODvn+5%XDb=nk^lf3yB))yvC?I$tEeexBYIVkMk*Exu!CzNbG7m3gXOC-F1?!l z)A8DBz7AS=Wbo2Nh%N#r%A|VWcRlm2Y`DF}O^v2i_qsYxp|?~~(5PCG3l%#_GEJO= z>_$)i9YMPE)hAP3mTPTE zLhn9&Z3FBNuB`O-6|^<}4^`E4#oFo9{XGr^aHuf}slc^d|#XXH!~Qm#*Pkb{2}FiYoZnxNYDJ zxjy@`lE89(Gz0Mt8oLc7ek_z!EBr;=$4Cj1ByZ*-x&Ht`$O=E{W2MNNg`lNwnN!)i zdOHot{8nK!N5XY4yALKpp4@|iap*MnWK^E_upbB*M9_lVVM$g4$ei}`{dAuZx7|f` zS()RhAP^7bM*#mo0Kdt=`k(jH=Sfyu<+f12P}RW%bhOgP6T+GCO2`Kz86^995_QKP zo}Xyd8*Yg9`pboN^0uZ)k?pM-EX-U6i*P?E81KP8qZ*M*Pc5pFr6}Z-Lo~8T1gZ*1 zGqf-r)Z--mG{e;06D5-K8YJ+nRY>qWU~vq}H)HM`9Q%zaOIf<#6}Nk>!aANRrp8zS z&QES~Gxa)*qd4{8FIx}MQ&M#$D28B4JS`*v2~y%X{z2`XxIVenZDzYqEkqP`ThgP$ zG|wWBH9QmgFYl%fm%7gbr-ng}5;sPx$}m02_v}8p9e%Ekj`IaYJsLtA`G!?uNp5!o z^ylrYmFk6${{R#`TB!;~k=8~DZbY&2e)^bZsHCLuN_f=j!4B?O!O89IgZhtME?D}e zk?9s1nuMmBe0UI+`IuuTY4aLsYQ66(Mz+9G*GfjItGdA*OUR+ZzUhZ7Mmvu#H9Dw4Swg$u z-6brcBbA*007)e7W6pFgilVMb#KicN@GHqA#j&+UTW)^%`}=FrO2lw);-6u9+PLZG ziW*ARjR-F*N0Q69{zL9S{q?nsR8rJUD+yXWm5omsZMo0rduSwGL2ZtyYNG;b=-kQT zN}nS(M$?YRoa%j}@eo11D`~uP<&{r{0`DVle=ZMyEpKg0u_UR|VQZIEp{S#1;*-XW zm*?4$w|8B-c4MDWE{QY7E1ofm`F4JbO7&%A@zS7|Z-7-k9BoaCN$1n(bbj~vfl6&t+o%vnUr-t{ zigF@T+>`Djpmi5*eVGRr+}vPf2@K`R)1EHUuUAmivYNX;F_j{0h*r1*4s zRTPpi%bzb!-g@)}zP?IN9#wsn1`H48V1mT*J$rd+AE~VMakVvEOpJGeV8J8h${x(b>g7A7(+La5;ZJOF=vHdtD1@G}~IEDhMy(>@T=`c@94M zX;RmbSJ)S*A#2UPI%+ArYMOk#Je#mLDDo%B;E%qTs$QBqL~%h;1w%nChtR2BI3`^i+!(F%6rJM*w>P&pz4> zY$=HKHu@2IpZHx_XjNobTV_jPh~89wKyjRUV?(IAR(h(L8lVINL9&FiG~L7c4EM=wao;CI?2}dh01#Lxchu2G0BVTg zAI;f~%*2k$G5z#6@du_J*LkO}vsR?`>y%O!f?PC;!?p+8Ty1r0JJd@;l{Ao@t56p> zRcwL?9F^dA*E{10rX=#HM7ZLkx7@5=H8FTnBeiMdZQ)pg-Tic@Ff`4wStdXK08qcu$3A`aCcpTN{@-5}vXSbf@UZeK zgmhjBBo03=efb)$M{43U9;^ZX0IFoHsaBSmVL_Pz4a%_|W6bv>-05X1CA!>LS5k(c zE;5~*{M>(hk)|6*O;1~XtoW$*MG;jD%6aqe^v6D$>2T?HrM?AttQ^H3ISYkvrgN^gTinZ4a;S<|Qw=$lO#5=t_Q)Pg z0SAtG`r^3eqN$ou)sSfDYV7sVrE5G^*)n4!@MYb%9@ylcrkm^PsciGnQpQV3G)!fW z#K=-VF9*!$KW#yy>KcBevJo7M@nd^IPO*h0S8Dw@7|*_RlX$FYYs~dgGSmgy6}e?T zLqX$?S*94t#TFeE8vg(dn(Y&TL?jC?;#ZS_$NqLYk6%i)HIc^j@`^p8RVv$n$m9>N zZE)#6u}hVnOKrkHp)ulrFK|*a0s8jFi&4v48qYxmPy8fkWmyVmF^@C!_Vd*qINM_K z*1;7EODGY>lF3xEe9pv;>B?Ym@dFiU`YCC{nz2Dt$;hL9m-|!zo&5nC)jJw$&y^xS|=pr?RYC{IV)1Q zM+_>hJ_80P=LB+3r?4K{dxNB;ps9@|Xe2XEf(J5$m5_pW1t;4X_0U+&9JdM9=?kQ^ zhY`t>w4q=D1b1L_^crruS!{I{C@#>&EcGGKB&TcpDa&G7Y@6PoK{ zCF#<62_%FH>8{9Rj!5Z=879EhzlMD; z)HD?rN-C8vM@o`B68``#oaYV!9q@a3oe$~zMZUot!ipGBPqb_Vo!$OoeDSA>%Ox%5 zJ4;w;QkJP=5!^$$*zz9IDDRdz&yeF$ubmTHXQcSz%TrFw41q>UWFCH+x|@l(rVn~H zk_^F4WSo!b9And3QBYRIQd)!&y8PRHeFmaEV?_r0eN3^;t&#ydTc7+o=?73wVMU!; z;e_nrkC)f~06Ed;`UM>)nWY{W1!W)V91SgAE!8y)NRdk(Km$Hm)7=&7(<|bxAsd5? zpFT833JZO1<%wcB$OjzhmliEQ0zXOGmV8uIM6)`T;l~Vd=cZ}tQkI@Yc#=KAor1mC)GTt!cCVX{FWXW2 zlF3;Mw9wPBEWG64cE^_-amJl3)N4^9%%ncp@VOubT>k)n@uRWQ!v#A*S_Fu|fC0}K z&PH#Jtdm+Yp<57*DSM75gVdkoJupok?Vu20+XfcCa;bf z3JLEtvsAS-HNF86vnuXj$Jc8SpFVNlQ33u3d!e40i9~S>=uW|&MtST&KW#=@dqYQM zjres+-Ac07n5E1mQDNRmNsl2)`v88J)Afeoa7%R>7^3*S@})?cEG$@mokyT41$&kKcCWAflh@~8*CdlBiKQdC`K)YA1WO-E9LPh4V&2ZTT_>nghpd7N>9=r!r9 zvPD0Qbb6UhEQqyKaI|@DK_N)*o=3`keYKU!J2Tfp6m;~R2U2NjY*1Cv-lR1Ym6;); zk&781$6x?&mmWtxLtVOB%}+^DSyqf_YvP(nDzW!c+L0tn2LmAKhOWHHFJ@-&LO zLtz=uubBh)8dt3TCDJzts)8a)@jNlfjBQc~W*&#sY1ZjYONx4{G=PXHs{v#Ka})`*9y_yqzyAOM(hYEG%Ewa- zO)9U1L0q6Eeow1+$ogrMr|tJky_$P;Y_v5y4Dq~tq@@q3?d6ZIf?8#)sI^YFa1yB{ z6Dg44w-P@v{r>=6b50I3x6zwUksGGEZlaZI7U1!$(v@{F{K0@HZ$8{-)3%j5p(W|} zp5?UG+u6lDp$9QY*(&%xUB}y2wZadFv81$dhpvUxcuL%0NO;GV;xc^oS@nstcB``T8iN{ z1wg8bo+OEo3^I~_WA-HTpw^g`=qspWfnxZ@XWyUsAe@f;?ZFz-DN@e$5Y$q~9FocN zH9CJTMaBo)jPs>@S;sfY+cM(c3YBtF%wdV(CRm~yUBCeS@-y|uf>Krl@KjZ_B%qc( zxe}en11sM+VsoZ3Sq!u@)XspjBS|9oc?yK>Jo@9X`s%Ff8tT-om8{T33~`lDh(!Ki zZMg5`50<(i^pNsgmd8G(+HKRMG~Oztk;yuvkl}+5nE7Dkz4_3&s_nv*Q&7>$&lnW# z3gkxcwttvgIo?k{ri9vVQcXjOi6&|ZygGMWZgVji`A>3wT%S%obt&qqO)X+IO2*W& zs0t$5%r|+DGydA>t|vQERFJt{0$T)eIF!dDFpN6jV?WdCJ9DhFQ`cT4l8)a_8ZgJ^ zbAScs{{f`-0^q{+lJF}@KIH;(*}bWv-n>T7pZO6@e$C}@G%obivR zBb`6>9THJi)X_~w(4r`P$?}q?$YY$7<)pi6O=pT3D`D{!-^3FGlO90|rz6h?Bhyoq zV+k?ia^0Smx%9mZLVJ?S9c+VY%DV;+Y?TAB#&M|k4wSEg*#%`C46{KOI~ygIGEau! zd9DG@q{~$dw3TUUXOy%;DG}QxNB}NRW63{2bj?B5u+Z3lhFWMAQn&)a-m?QpdE}K?G1RcG<`p%_NUEAQ}yU3XnI|*SVVD894 z!Oz#zT{^CY)p@+UR*GX3GHm#HWhI@r0hRYVv!$r0=CjgWX)Y5(1gO9yMGHIgjDy$# z=c4{wGWug}J>*RBtcno20E+Ag$9xZe+e9X=o*Gwg2gi&yz$3;{y#e|iKpw)Iy{*r@In;4?GX_>T-%pv6jmI z(?->D%LNP)L}dQ}n5?Bv8M_jABaLfcZ>prZMvluGx)peK^Si!t<;E~H{{VEZtd3Y9 zSmULprm6uHCLR9(DFu&mIpBNf1=ia43#^qAtb198$8m;fGtVA=n)31~$0gYxVM}+g zEY>NhYax4WT+>5WF-D2sb4K0x9-X;5E!AS8WQK}=5-Dh7ebU4OG|PkX*v)mMpgu7)HkCN(M^JOWBISqw4H1K++p_32`on$c#O?CaA~ zS!K3d?lls`?=>2&ERr#db_K&GGm-g5Jn`$G^lk!pI>cNE>79H7Oqqx%UxAO^-Pn(d^DaGYz#JjT=yRS+Jdvz)!m_n@hsBJW_Y%= z1vZla;-|4!*I8?%dx*D3a+uqQ4VhFf3gaMq zX9GT(3t%HWhDzF~sj5}lSyW2%DLG&!Q1cTd-vB1l;T^gDN_{nAk)QC($j#EL_p#;_s#|f zu1}teYok_)U0p#}RMzU4B9=K}jp8{25%Nl=eKFkn=Rzfm;UzVoZKka6&N&CL_T$sj zP83Z`aHFS+RhAJ+QQ}J|lYfjzc}gjv9bU-P54Q6(5l!M#0A!Jld1H)Zz-;Hs&r&UOR8&^oYo(=Z-0MCjDsaR3Q3J6M4!{Hoeyz@-^({ik?@bek zDy3yvp<|7oDaJm%^s7l&-bJ}&k!~~9-6Nh_ioBV_VkRUX$_HSeoO7i=a2T)nb93RPkriYX1UT$aE)eWtZk&mK3sxN0~^mH{Asjw6$Mqo zx$d-riUBJ~vQx1^OahOTH<-tM-0J?)lx>RTOrgJk+HCMpH9b9KwX-()o@c~ha&W8U zILYM_I|fa~!R!FWGwgLG`Tqa_=>WQE{uZLI#zrkuGdJb=aycA+`myZR z$yx(XUsEE)@fcw&3WK{W!##i=nhDdLHkXS9ig>Gk{lHeNGRD{e2^+XQ+vQyPe%iP% z7HZ#trC4D_h@Kup!v;L#9$+5cgIyDYQRGQWbWy!c)2nKUswt5wU}SM5M3Oi8pMEfP zy^5NxsSe1@bx}BJkR8Rq0OKIw5D5PO>}t^JE}M94l`fAQETpt7MoWO%Di_F}JLxN> zuGH4lYkaXIAzaD>eCmE}$`94E?}6#BGb-hyrV&<~HmHJ*T3V%6-pT}386<3Dk3W2O z(|sKFv(i&mNwhqUqBsBw44K9czIo50=S!EXG&g!m+3ixYLm)+y4Yvn_gP#14t~l4v z!i$89)KXDgXxiy(rV<8^ZtZ}BmLngRZgNLGv8wuQMy%Nu!*ulVtd}*n#{?ppBzRrW zv&}zgTO;KIAol+NNE&Xm-DU-JA)8rElJ!OD!4&Nv&CjNoyPQgrgcI8Tyd z^G#7y-7PdXG@h#6P8P3j=BFGvKEw4oh12&DeYVuvu2BN(8sKM<-+}MgkEzv#Gieva6-9B6xyT zP`*Y~9sxMpk1sE7ail(@w^IKA>KSS*W!8?Zf$-!2eC#v8^ams9jW!&d(6N=Hl(h4? z{{V?WKg;sE92O+t9Q}J}g(#@D$sCOAj|_wm&FC@)JjRFIE>hK9BBz1^K4nOfKOhH} z?sd_tZg$uns;-aaigZ+#MBpBBxIaPr>!mLvS#Mb`(A;3ik{yz&zl5qwMYn=CdH(?4 zQg3}bPgJH>m1&G%Ge{I|$! znpi3kmO{G@-J|-4sL23*^sUJ->6~ThPlr`8zEgiAI6mX=u8}#ViPEN-(fmN6^T-)f zkblOH+iq0ViW+!HO`x?ziM4Qe9>XV6JUV*Hd1HSHUP;^zPjm90@1Jq1DO%W$BIU-K zT71b1wDPe$laSo!x2L|5Z&s5ug@ZG+ToetDloj&z)9O;k zTM)Hecg0&yP(SkcW#^oG40iL`&RZ-E`^?mW#QYi&26&@)V@ljBAZ2oNddJQms9oq^; zQ8-$7{8`uH6LBoAoA+beEg8?BCs4YBr)_k7QERuX%qr`mm5F1{=$C3R&;A_;X(M@M zQFyh`^*}IGL+~{ewQo^xu8;m?au!?=-h*n4c^wkBb!GdcE)cg;(@A-xc@~~ps7^D0 z3YSkJ%sFA_jYh4fhTC|O8=D(pT1f-^r*7~IlaF@5z=pq|tW zRZgU=j(%9AVUz^P*^PQ&QK{%_Y3c8b?yo z;gu!YcQ!pZIQ7P$&_fl&FHXwg~Mu_#@o|55TO;(hY^p_6-q>`RF z2@X6>xMd%e*C1o*thC2)x!?MEI*$JU1k`o+CN%W$fWeeRLLgq?9lx9Yp{I|;K9Pay z68l6x4!K}vo;H2nDyH1N2L~8ZPrt60HZ?d?6?Rax)xxT}qS*o&s^TLnAs}oroUlGj z6t*-^s;xA}%_X`CC#y$^(P}2z9I?R-k3)g>(;d?9QEccqt~TpkHDx8*pvPY#CMSwU z8?n3nJ5Z_g@8_+ob~;|8>1eK&{nA~&DdTnxn7lTNA}~*y_sI9u;fbhlU*G>7@&@RKt3jrvhlfBx41WAQ9io7#a50JY%{!r>R>0jpDo1OJ-!_DIq?GkZ8#rMyg6KOS zz{lVHv~q@qrdVa3mVbtkN>nbxw1pnN`0e!9L{)5^h>%iTUI=Z}?<5qo(>}&!+S{D+ zPremLwlS&J@g=a`nh6a`C^5=p8D+=Ha!;mD-(8Zrs*$S=D}OFP3$q=^aXgX!`sqy3 z*I4T4&Jp(_fENHR;>=k2=lp8im>Y7J%c3=wdb&F0p45e7c6FV0JHHKDV&p!VE&URpYow!nY(vM9{vD<2B=p>4!R-K!3wh7KKMt}B^t@?_JqN?dd zHB&QAO6h zL1&n|e6iyz+s};XwUXH#EvAw$5-Azr@mYZ7*p6}ZCr7$-r(n4C1WP?RnlyG2#=~+c z!8suMo^_)el^%yLA-gSt*LM6br>dlu3VA#t(A7x@-enoi(tCE`>Y%z?2W{%QB%E$Q{1EXE^iKvsFtar=n_Y;u8!o8AK2`#us}P9Cpa#@1Q*|d!nSa)JW0I zC48k;LxHhSXD2_PA57`8en3l-E6>CFY5I!b_TPt+lCGrq(fE=D4m^S!api&Kon2O6 z!|tS(;ucq1WHBs?WRv-uQNN`1P!5^%6_-1kAE-Wgh;1+7XOot(x3)FV}t^Pj8;On&D9lS%bueLzWVS=K%N4c|Q2k zK%irGsfnG}!^l}c!z^c>N$h?5>(N!e4b(8ett<{H^_TFgbzRyT zdBoIT1|^A*<$O{(U+w#An$*zUSv8q_T6tH-nwmlYVmQi^&Q3|d^3jdMp`OK_qI(+kow!j}6F}6G#a&P( zDhWnrF{tbS82}8QUPgr5tL)cHr6{DRnzDe?%OFU9ltX%L@0^@tzZzM-Ro`pt zsjc?V`=|?#3UeOZd5}+hg1DC=TDOqZk7LuMg{7=fw$-o}}0OLrN zv>Nbqp6PA1*)A2gdOg(@B~}Wc=V|~43ch6a9+^4|MNcBU@>9YEI-kfvZKnf;C%=6$ zekXMNfBJE0Bn7Ie=7vd?yDJA##ys(z5b7JiwNv!%rr%HCBec&ss8s|>yU6#z9Ny0ba&zWmwA!=PlKX%!Kr2c0GK?HLGw8L-rBOq;k~=3?Th~arc8@bOCvZBASIUs zx)0Qmo&h?S(lxVPtu$>sQO69i09$Y@agm&Pd7T@E?M+1;NRk;+3O3r)H_gES?#FU5 z=j*Dt#VSH_ZS3=hSxX%R^w)bPW20#0mbE2Q8~C7u^7)VnCmww1tETSKs?go)Y7#*t z4Dn8PNKY6Fe7uJr?WWGB>C1i2@lAN49xWWS{K6CDIb)NLBak@v(#3yH$3`M(ASRNS z2c?cCAV%Qlk>okoGK`~RS}V#4D(&~Hqs2T`QbY4Po!F6y2N?1h8kK2oo$A}N$8z|% zZsA#hf$|zm9!`H;dU2*+wzj`cbv?GUN#cuzA)ZmN0;j~l01qr3PUx!@ik|-fCBnWY zh}1*33JQc`Gn0Yof$g7tb3r)d;bj|5ShH#CD*F{3Wv*D^2mzCPY*4Wy$oh8Y&l(|c z>l>xVr(oJoWzobXKTWT)$9};*O zX;H)!?9YtIAB5+gG0r=kQXhy$x{m8tLvIX}C?hT5sR2n`G0E~LAZyYcPgzA3zS(D? z{{V~@?e!wAR?EifTPGfQIpp^TR3}_TbLtwo(uxTsI4@MB_Ej0&4xum3qtHBT@Ayko z1ee~P6-x3`B;~4Kcc0DY=0E`bwdktq>v`$8X(%og5)_|vhL3aZW-QI0V0#ZRHRX<< zLuk8FEg%LeIJ`m-pPb-!@+Tw@(_Vr6DWqVzQR1S&^)gw2_q(Zk@?prcYu&bFV$#B%rIg)ZFa!CV~j(`Qa{G zAd)kmr+==!P1h6_RDtO!DWHn5nBK2ZQ7eqQ}vF8+xFQ3fp>D)WELt15O<|6vpjTY9QH2uy|n!A(e<+q4FaHo!$FsC$4X|dv8@*D6NxG)zaMB zNTH@y1&{!xPIx`J$EI}g{{W}2q|(*=eLQvQBk@^Lm~F?*qda+c$DX}OJ64W$rc)hA zl@u3R`Wt|1dJ0Op%_NccF^qU)-v{MCZ55)TkEl9&y0WQScBX;lBsMmL1CLPLV?MfM z>rR!ax>kje>E)EgAgYx~$s^{@)6WEsM;*0J*e*tj#dWk-y=|_da`8NZEx?_*+Ce$_ zfbZmU>#rsXaOZ!~)Y@t<{gbYCkNq$$_c|zLsE<4MnTQB><8jA)ZsdC7N3L}Ace@Qh zDMdXG#z&}i!X^Pt|y65<3QA+A-C8eS&l5E37Lg#jU@CH3jfOKRPG}JW_ zysTz;!~o;v3IWDPs3#f{mkB4~fh$E-)Rvk0T(PdGj(C7F76S_^XCouNr`viu*VPc! zR#;{5r-l(EF+V-V)=}x4lc^tx(Y1F;+s!c&PJyE9c zPaH*2qL>Hp&UgWQ!RMbNrVDN2E2RD$q+9A|0hyG5l8_GrJ-oQr%YA_>9mMdJX;J)5 zoCH0`9=JMH7J)r1@lr`Ex)SlFK*Iz6pP?AWj6HIj($Pq{HFP)CSu3oy^_A$T^fM+L zqi!+(0MtD)bNY=LiqNw|1!X!DPvQ85ALiN!85*y8ey-|jO$^Y~MI6NgNb>&xD4`>8 zW%I|TpJlh+=qM@aZTAK+Rl?-OEI{K21yK1McKV%qkjk2%>5Khi)6Y!56={`e<%u^+ zsCJ}^JN-^SzPeg&H=D$jHye#9s+|gmoVWo&!i**5{Hfl#}oIcg6FP*vg-- z0M4836rUL!P}58jXeNxLkAkcS8F97_0N$_5{3JcINYAEuqC zZbFu>j^ntq!L`h1g?t_c52)i*J&t)KjLsQRVwCS><=LBWe?g3&uB^Hqqcu{~q>(J5 zBOGeXKskK*d4ae0(@HN?Dm?>!>Nw-K{9Tqfsv5R8rC9`RmsZFTs2|h`C-u@lKtV%8 zeTv@Hl~os3rxV=LW-2MB-obI`La%R3d+HZT^_tN|MqZ?f7-SPhB?OS!B$N6K{k7Ih zf>qqF6*QF)te1J!=aFP8%&5n1-%O3ar@7Elp{qx4#NLLalIQ$t8iuE~U071oOk95| zS0z~E(2%71eA=t&Ex!|Uy7WICO7KbILmBnl7o@53qkW6M-mSiv32(MK(`wLM%6#%VFPV!Vf9sqR83UjG2< zIw^vJABDX|L160!iuo{_uFoX2@yvwfS#XNVr^u-Q4?=b73!lMUE|l|6Ow|xoK*=d( z%BuXWx9&g2yj|7vR`lmn+`6BqL`UQN5nQa9Q6sKcul+e4{q^ZT!cME3tU6)o?t4Lw zG%?PDxjVjGA3T6~_S3gR6KA~sqXWREJ?>>F?d z4}DZ$ig)^(H&S#(RMbsXS}2NDLO5wLYRnpZ>I{4v=B)H$lrws-G&Res2A~zFLOeLeN~En8s0Ba2-eWgTwQq^&&$Ik+#U>-r?O7cw|0D>!ESM zLe+6Ns1dk4my9=tnK=jF9)8+rK2F&PHoR=*QE9c))e1XfnWdWu;%+=K*+KHoPEWQq z&5&AXWs4EZAdyx4KJt~%AGkV^d9(g2bMA1c0Wx`l4^?Xo2$Q5#|u;~QG#Iv+g!)HJHihym<;#NK1W<_ zNmFolp}DzAcbG{OtZ~Q(3P*gL;Qs*IOp{L91Mg_{#h#=|M^8-+J4Uh6!rm_I_EF{Q ztFx!6sOfF=DkNRfF7|HObIB*{b?57CTfG;AGZd(-na6Brf$BSHrhdMZ=Cm@MrJQAzxxSmdkS9NvTx;SZSsys*{Bf^NF zLUGPX@8^#GS`QD_{Rvj<#p9zd;@k>cp;FN{^&kwk0T}f-{k7<-n}WmB(N)h5=M?3VnE=X=F^qZV zx8GiOuBEuxx~$0Z#ZoYA;>v12ci%17r1fCfm% z*GAlxnWUW}s{S=n*Lv716vF8<7}1{uDPlsOLBPg~+a5Nik|^S8dZ`q$m0-n}IXU*^ zpSFPYL_zMA63bCbFpvc^Qj_79P%r~yo;`guU#I1$N=mDxwgDwA4H;Qxs&H}+L69it zIqWfwRvJRR9VLQD9%@!|9FoYmk%n{5Tj+8$Q>Lq$IqBo4hFW`_GZ6I6JM$j6W! z$#K@#~`9G1L!al6JS$MN>42BP`MUr7|)PpO%fIR*UXi?VheB zSE+)wHBju#!Myh1d9gZ3>Pq4(YO4X2lyD)O09S4X+z&kGjaLxdx7^ZEIHif4$qcb5 z3%zrWJh=l*FVwMAt|yLQVu@O3ai0-@B)4rRE?N~T8f|?Ad6?8z#o*J&A_hcRh%DQ9 z81~8Us9i z>Y7qASsSwZeBZy*O_5yZf|;VLjtNTvjl4MA268^w$>$oHYp~Xa@57tL9oESNl+^U} z&kRbEvdBv_5%Xt0ef0Y^MJ=wHs=1sKBnz|=`SF|pKHz=z=RT~nP0?*{StOP`wX+N!Q^w$TivRNseuGaeB5u`-oGLM{+Jn+PQ z^zl(FkJA+OW-5v0rh$xXgcaO-mi8lzd+HE^+Z&Ni^(C@;Ynsh+ktB_#2BJvD=0H~? zf#1n-sa6_VS?Q`OF4f-;;eud@l0=LKc>{sp1K&k2G0;>&55rF=c3jU$$08>G09Nm? z`V44Q>V<98(8Co<)J0PgGcFoIl$&yi*%`S3k7oMErG*Hpj4xIPNYGPWBHy>Wm% z^QrcWq?PX}xXmGjg9?4%v!DQ;{{Yf3q~QH^(Hn}gG^L{auc);P#T-{j0r-)C=)@rf zzHIt&z&_*+N$H-Iu*F$fTT0Jyl2mS8izG3$j6y1Q9kG+i_0<|W+*bTL)XN;z%}lYR zFt!=OfP{b2zwhs+?u@w8(%zx?D~zz2L5@yIOn?CeM|=~l7rRK+M{mdLbTsz+CF0La zn+nSd1#mZB;a62}@z3~M1k>OkID_8go6<_S8T*LORm z9j@Wi@JSs_1P;{-%%HaY!Su~5)f+v=jZyiu9bT4?-H$O_*f^vFHVxn29b zknL)ZMpIktZC7oU-5ng#!{SDb7!1kkLw5&|53Y>R)Xb|i)srlyNl1t&`B8I#JY)j^CNQ`08T!nWA#3|P`1u)cB+aCV@Mj6FlB6v_Xi$%!OptX zS4532MDkZjDqy}bBR4q&d5mD|$mZnI`sl6|Hy6m>_Z?;0pw=|SUL+xrjuZmfBo8hC z*0$?!@b3`TLeIKo07ltXIX~QyrLKkkAZj|6H2(mIrIKzOoRwzVkPoNIex&cYFT;=bom#azk)>nUj+lQ=Z6-es90NlPCAk$nGNxk~q_4p3@@7HBG)}U{pDQjsU>yKBvBrEob2>Q4UO10B3g~ z6XXw|+pAW=Qx(3?1BF7f28;*e=E&QF@11y_w*GNq@bgE07vR)|SFs-;X| z>>H1$=Nr9#+6#5OMMFm|Q^c;xCx;WW56iS32cALx+WB*izDkOv9yY>=^$LUM01`YB zKd23WW7Lf=^<;8fY3QOXsZ$kMmRQ(fl}G~^^5^ygPwD#7rR3$u&QiY!vAT8|db&vG zqK#wlx0&NVPc0Cquadc>5Ftu>W0>8JRiADOk4zkDA4NpTMO3BsZIQONIoe6U`)g&0 zTWaTuG4i{aw|`FJpdLN@>#Nd9$u#^OY54(L*`lu$6>6$s3x!<&0L%^fhp`0fdYfyc z)pXTMDW<7MWS=?k*zkDt)Z3&{6CGL%vH^lS;1iC1`h#@3%$D;r;gT*&=eYc*KXILU zpOBpf`dg@yiVAyF>X6S(4}~m^%49JBsqNV3@2J(1&tG~IEUO$0+1ZZj4;jaP+0;9A zEgjmKsiJ9MYy@L~Rht7D^TstYDkzN6AW34VrfySq6r5m@?0f2zqU=j;P~}S^$w7$= zH!2-RIV;HRq4ji7RzWmKMhNi=Fz(pz_13oe=8A+*3`Q7B5aga(LFM+>ORX`asA{Sa zl{hQ|XXMA`(rJ!_uA+%Qm_oxWK}xDfoFLje4_^NOuBxdl@YBNPniuePNxuXGxZ?xp zKi^kP;)d6m2VA9OyzUN+JTs7UfKNWY zdU3m5rt4X#E%gvvsHUWi>Rw3HDR_Y4K^{PiCXT(tk)qQyWTO$vJ_l_LnH81Jjt}6gPdJW6tB+xy zZVE7LFX#u;ojCpw^?lOSO$9Ag3f7xkUky+40hBCoMmw`P#(jpkbq`D$a?{fVTYXGl z%?vZ)l61Enp66q^r2yza7r(~aJG^m29Ij%J?g*}S;ChXu#9@nTJtPjMQpm&&W}anq^R(m;zr3Jp5toq_18Y5xK(ve zRKq<~p=#3X)yS4rY6Ll52eNEbWqFak;o46h2r3Kcu|S0lJzeF2)$G&N5wsP$;jvhGen zJmI~LHFZ`qN&JvHo~%b~S~_zjOcLQ{`48tf0CC@e+fn4CTT4gcg%d$DNCKV8q=SXe zs5sBi=(V1qZ8X)0%_>UAbYYY+@edi#paYM5Xg$so6e%El)J+X%X`PY$3i^~)WE5Xy0;cSA-pvNNW7_@%OfyWXwUbe&lgR{BR0THPOc6`46x{{A4LoI9% zd9+s3R5L{+Rg_si5tIYJXypgKNXOSg<*4|Ek_m~I;7p8NpXAPQfscJR^#p=@MHMv) z3WlBVu>cOx4tPDs<~h`EspxB+lGAIJoBl29Bk553D?6>s zRM6B~-rhmSlje#_~E9mV;qU#aeJaC{?V!)iK+mVk# z0QbPt

6pcbZ9Mnp&OFM$G)<7yu0L2d1pfvZ1xoQME_#sgk0)SkN?Yw8*LeDm?iE z8TZv^RU*M!W*#Y*e}^nVe<>f)gUC7KNYyz3hjT07i=nwA)RNCa`@Parr|Zy-0E=e`$B zlqo_|d8n1uAe(E+BZ0W%o=-gSf#sz8XsEwlCIwX_X&b^|oDX0=;C=MzPepNRPl2zJ zDOH(VApm#|yx@+-aDJyjFVUY=5m@Q2H5B5sqt(-g5&Yz{f}DfoamUw8HoJ%LKA(!^ zYnh@o6(Tt)%m6!uKQQ$NobkpqwL?W;TYHV6F}xoR79xXyNyg*)AKO-sP4rDG(Nosk zDjk|hO9jaA)dolEai7ynl%B+NW!I`~wtH0aTNT!(rQwy~@R@ux2a<9)7~|^0*HW9R z;JWnd)!StFDdb!njIm%3pV)Jxf5kgIliU*JM^us;mkIFsIW3Qu`~KPu)Axyd0+s9h z^vqE!AC)nX$ByTN&uwW<^c574zqqfE6eHvQ20(x4=DlZ{wt8wGW z8=skEbK58ShH`X`aq0_AhTzg&9Y3rj$pJ1MzCC`c&?j#>0>+8?cU8y=& z6}7y}KaE;Ic0>6Td5(Pe8kI?FO8RWyZES_7>upYi=Ory(89c=@8USUV# zNew+TL{k`ENfXF`30(P-x|Ky@Rg9hWC`tzAFj5$*_xW68pe+$ zEMP?bB$+#gf4@JbjZxE?{6=Vl!m5qAlW4-@7{R=DqAP2Mb(ry%8OM*i4`0h zsnTTH^(xX6$34OVfB20Duu#?4^%Xh0RGFvM=#YmIe z>nST$l$jZ_PVnPt+;Qv4A5AYahMFo`nS#_AorXYVMF8&Zaqp)Js(7jlf+%Hbc2}oX zW#w259G@=7-^)Vn_NsfZt!Zgu@kBlhFDqeKk?Gu=dTU7nEaPUNg1(XpsOC$RJy!3C zr^yWp?ruvD%iB5f-K^?PmYSx=QE(Lu$w`SF2>=8;6c7*nJGtk+huWgJ^gOUzu24-( zm%#oPJA7zZa2x$Ulri?lzL=)!D)qI|NGl;_tEhM$nVDKhp=58FMn5X6AX0mtbqu)F zzinWeQ zd}PdH-0AD#yHc`YiM8KK&7;qwo=pq1n8h` znQ#aIl5^XR`Zr^#o`&5F7s!4fwtP6@e3vS~g5E$7*yGpRP8GDU)+tpa#L>8qD3{o_g{TzCHZ?9|Xp6A?d% z>PjoI$H)&D`f4?*`ERR@j4EY{C1t=v>5=#J*7ZLXdyyI%e}%#fgds>?Ls@h|1y^f+H`{{W_%Nl8!^ zf_9K-Ag5}8auJePv7BSE=gje)Y$1UsTZAstQ?p^C{HG*fFSyX##alsWs!tk6sDvqU zrxS44b+A^y_koS>bu&P*5CW)Mw`UbD@-yNlh%2)iFk0lqlnN%z$y7CDR>k z2oS|3zj{>WH5>)o-^){=_QzYPXr^L0DUI0l`+oYZ?sShG+TSe8EJ8Vh@|hUgz>n1FpQozcN`#5#C{%?) zIRK2{`)R6`uz3qlO-n;|T1iAEAf=hHwB($DpRe}O3uLcHBgL?+atE0FyMmGcC4ckG z>xWcYDi+^vxCMf$IhmP=Z<~%jqvqY;+h1*T)s=LMB0ypk#D<>)ji<10rZM{IS1B3N z8{?~^s;7!0BFvKRiK5xUDC|h|I!WrNl1+CCp$TT%0!D$INak_RfJ-&sB1{Tq$KHC#c;xRz2{$hu0x^ z`)XCjCt7%?Ror6bm>iztX(K}GFGk&alCp+uO*9P@s<>Qx%lGKIwy-fF31QdncUleAfGNXt`)5_mB)*; z39E{(IU03PtcP$yJAJ+N*41&E<0N$TjzJrQWnXIU0G+Ho#&Pu1Wyz@+K{_|X)4Rsf zHV2`|B>5dE*GolA&r?L+Gec60d^5Bo9p5qXBZH^L)zHbdx&YTOilw766p|?lym;=u z-n!P{l2gjjq%}cp-l6dIE&gl46 zc!urXSs(4G<&R0DD0Om9jn&dJfrwkc#$lI_~`F*(k^*(y1 ztf-2js$(<80g=mPvz&jvr29INJtL^TK|J#}h2bcW=YzX|Gwq&#O*vafLdgl8QKN(Fv+Vpdt~So^8+HzrLF;)zvgHC1XUAT;qjgUkytvEJ4RUerN5S z1+v|(w2*CD2wGMQpD8#|-}8Cs{jU1ATFRSc98FCbiQYsUoxqNA$Fc2!+f-E5Sug0S z=M?pRJJc-F&l0Sm+j97YPUboH)C)avo*RudyvIEClQYW}Ad zrlR38d|b?Y76&_sJQnosPCl9fdS2-iOLMr?8W=<-2wo5}lF?61B&MW7xqXM6XR*)ILNDzFid6R5DZ|xKNCi1!Ato~BGBb>~ z0Gwo=_}sd9>6`P|E>I*gpr@G?WmymgVUAe)XZ&gvcT-a>HLQFBycE&0He<|eT}Uj$ z)BrzCLYaSv-l=M5kVMkJlO#)#lmi=Z2L#}A+l@T9_JFUZ6YCr0EoEFwX_je>2o#wz zaNEWRInO+P+EcEMwxKFYFi8ZAvy=mIInGCZcQZIF2EL zR02JYee!)Ye#Z<(nx3ALt=_cEN>cbaKsNE)B)Q{^a0ZuhVTz)4mZF}KE&>5NMs@=c zTzJ4_;k^Lw#+cggPga&_=e`CsOqgc9{N#(cp1{ac=O7! z($lN_<%s!)JunaMeDz~=)kRfts!ED~g{FbF#}h6I$S0pJJ-u~YSSf7wi>1D#(lgyT zZSy|njxx9-9fv15@5ZhOdWqa0)0AJa zN6-xjJ(^0;{Y2mxy>+R${pz6UI0i zMx?)2+h|c>sAY&8GUFpS^ZMwOp02X49D+quvz84bVkm(GZX?qfI-_mau}X&8n#iQK z%|v8aWR^3w7|wE8azOoi=@QXXT-09-?2)`sx&&sy+7ATj)~>GIY@<UEGi zS?QiyDwf3Zh)pLT1P46-0P-EZv{I7m9hw-{D4arRQ*kf5FSza_`vIkEweFUpzDXm1 zs>tdi$Nf3nN%JI}@#&^bbR`TFQ%4flQ;@7@8Qw-VMR@3@c@^T6mi)?D+zv9^us-@ytgn)$>27Ol zNgVMhMLUm&l1p&E>c{P(^w*WHf})O`2gfE6LPH=>~9Pylb z>y33SEj=sD(nP8u@R`eFVjc(w%-{p}(aOsZwnYR{Myim9fu{?f6!t7Vutqd8{{Tx( zEwXA44QeTzsNBoJ#y`*Nq?B7>Qb?0WQ9(gL;x!~B;b8IcmONwv{{ZU74m}39no4+W z>eM4Lg2XEyopFK*C(W{Zd3|)tSkyJ0AuYaIg)kxUIfwvcRReOM$IZ6`KV4Qc%PbQI zqJIR_Mq`y_e3=wBHy>~@rIc$%i?Edpb6hFNoctvCJA%o}d7PiyOHs>Mw+EMOu!e1h zMQoNCB$9MSs82cy#rZ-v^O`V9=NTFHKjTwst!;6kK}=&6Z5U_Tc|UKqvaZH=(bAfg z!ii%=jy9A9A@iL1kN44 zB}bL=fNerQH$UyHiip%a?;^^8kYe!g*<;2usmYn>T&(e_DYHa=ZTne4JOl6P&V07M>=7Wl1f>8L^4Q{pAJ}9_8GxHrh`(^wN+v~RdUhPP*g}z zq-C-R$>+&Ou9^$vD|Ut(Us2C#xte>FO0g;DV&iZlZyx$BOVxDmQ%P^OM&BCIyD@e7 zL~Jk@^wO2bbhNcF)he2`Qq3%ETtA^0^W^9|LE=pfbtJPzP=RCc$8Issb(43u(Y$E2 zE_LZ_pmR|nX$uXYav4W|?VxL0NmUy>4tz`jh8(Ut>Mb0*V8&$rABVa6*HwmG-2^Kf*mlvM03yAQ7a*qo_+H!_Km)FPI5TG{q@@0 zM^#f$tn37l!eba-*c_?-btXE);f0Ek=3vDek^HfbKhsm}qI4(6a*aBUrW(maSZLT2 z$C1d-u8lc@N|r!{u)Gp8-_JpB_9}af&phbqP|uL#$m{KvtHzkqTqP9V6u1&F&c!2) zLQU=!I1*1e}6_z$~ZJPs+Ozz`Y@MTDuof*`=8yd8lNL6C&@!{LBb% zFHgABe?Z%OJ8Ty!DweIcRa;EbMKK$iU@{P>_tMo(mY$wLJrEH9r!EKU_T*|My5m#Q z^>C#*F##JGm>v}W08qzoJp}imybje5S!JcQRnI7rhVD`7l2uh`<|pP<9n>h`{f4cc znx15)siv-Fn54w3DclMk!#}9|YKyj9XzMPtm0d)V*V3nm1bO2uJwCjR9I|y>eK{r6 zQ>>CED&!F~T#ba}5$Xr{)m?h0Mz2?r zR)i2!b~xR;{P+9n)%;4fQ`x$zoh_dS9k+gFIXU}`9U@%l%+(OGp!tIhr_7K! z{k2#+LbL;T9gxRTMlraiax?Y%X=d?nF%l4L+0J(PeRSluc_?Pc0IoR&vyMAzME2#m z4+b?Pgx`R@K}IdCWSNhHQNEs3Zu>~aN6`NOZA*-!BFR$FwIr`gREgs< z$`@(b$2j-$#*QsxOG{AIVJB6Q2F}E;3CaCO>!GsSWQw{-82GMOL>JGua(z3T>!hrZ zNl~>tc%C(hPa`-0ZYSynx;Bx|87@t;X_6V_ntC~Qj|c*X^CKVo1E4nq@M-Cqa8&JV zf;*DO{9{G|@V3WG7mDb`q>LUz#G>N0rmqx3NRQb!DB6+#%xir!>m4yZCr*+kpUQ^y5mW_7<$O?X|SkcGiNPIH8m- z;m6ECJ+tI>(u(~{Xs&{~XCf$ajJO2n{PHzaPjxUkM|n`Oz;9FZ(FiVk-@%bXuuxq| z=f4L>o9qfGurD1!CF12#R^P~{m~4oj&PL&uKE6X9+H;wxr73l&o-|ldRK)L<8%aBV z_h5o?>+Pyis=0n9qN0%+IU%Ghfyr&4E=yZ1orDZ)Bu*6Rzvfz9vI|+Prfsc zO+Gn%Sh9xn8@gOto5h*s5!F{qycEN2KynA`=k1~mQ7!5UdV8GjEV0Hu94maF_8A!G zgO9kNAR14_W= zSg9mvKZbbIaG>Pm^W?tx@9(W`br4t8&eaf&ti*_j{Gg7=BLPQlan6zIZZx+DD`}!x>Z6c&Wf1L* zI}8K6zXK;8c+k3wwMA@DK~mGJ!2uDSz#$-ceSI~;itR~ek*O+a9!cgcA!TA15HW`G z9G@@osolM?lxYtgPfbl9PFwA^=@3&>!qDyvsv1HtqmJ)@c=z|vo|(1(008SMIm}N2 z$1?z%w|H&gk@>v-Vl*}G3nZ5K?obJ!rk{982~e!S07+inMB~#MeCgQfR*DyvD3YUb zEAtk>_Xn}#KXawJ*^%s@NE8 z%EyDijXgl)Cp_mwAnF*YVUCWbNZ}f}15PGQT5LQoR*zuD)a7$kDshD9He1uG974>d?2(EY`^&o-r&y z1Tm_Wa0mxJm;m|r)lo})p}F-&5^AYq&xo_v$WbooPf0c4m|?~T<{%$Jm3Zmg=PyvZXZ7 zEFxkC@yS3i3Dmmiqp6CTTA<>FP3a&`;m2e8aiMl%B)K!n<{uVJyu@*|4&Z-{T{}Yb z#z`saN=XF&0M1p)tfeqrNE(493{?>c46jdBxT1*|wp4yp1IvNm{;X=*YKl070HkIy z4+_{0M;O5R9O(-G06j9$)ii3sJVW3RIow8aPup2^2L@uNzMowfkjc8 zb5AUJjo-wAUAWsKj!{piJpDCb+!}#&-PK{@EV-)y10ZH%uvRld?*x8zMr9!jL?_P&FYgX){wWh>0Om88d)L?Vr<7 zt0?27N}7mOk|2Uc+sP~3pV#|q)3m1Ct}ejs=DIgevY2g z1TYAVN=#HJZLR?TXUOxsM73Au=&$L`)9(KR?rq>MPsUqH|XXDFlp-g*eYR zrt;6;BjFa|vP zYQeE|oD#(H+~S5U(SU)QC6{k4?07n7TWu{!N~uv}Ko<|517HBZU_X5V>U*^vER#z? zgAs$mIp7nVU9a`Hz_=Iu%P}U4wjBWJcOiq+ASx>8>k01k8v5-lGh7Ni4h-Jbt?REwqIkR7v5NKPsyZGBJ{VxYTa+HET!OAu1DsOp%~Fg7Bb^ zKTR~z)VwJZ2VV$=QhQ~wrKoQ8HukEdx6E3evJnKVQ~qL%AGbfT)VOLz9XttC=`bw zjF>uxrGQ4r+`f47)>J@UWM!LTFl;W|<2s02!6>e*1_|Ac2Rhyf9z}07DnSR=*G#s6 z`v=h9Cbw5n!#2#y&R91An;dC|D$n6Fyz$K}ama+2fzBH|1NxmP-sr|gnW3U|j#j)JzH?0LPvEQd_UMXrPr=Ke0aPb?v0tPedqE;7ZWT}!` zW@zdhyUfjynBe`q^xCzf+j$4?pVwB}&=oN=rmpK#TG7>K!7My7dD_Fan`&ywsfG-R zS-+O?gUIAy{WVbhRF+Emw1k*}e1!A|%T`ZM*GE@MCS_wOk2{wGJ^TCWl3KA-*w`s! z6!M3R%M%T|6#I7m`WabjJ{*xY85{C~K4{Oc(^8(UKZOJjmA0M^7##lq-&rioXw6j_ z4i}TV%{e-n>~&(5zG)?e;+jthl}F0MA3EhuJ;4J^30AXN(V0U78N&gBNgdDMOV@a? zOF0}62y$%@S^|^UO3XT zl(|fm5e8LJgwCY(Em@zaS~vk}dQc+3>|48j=l0jiG9y3Ah%aKmd26Q}ZCN>MM>?3= zBu8}tfx%)j2-FW2RcT(CS~ou{$(#|M_&$eBgZYYMlw&-PPi;ktJVyTj6A=e#R#GxM z;2j-UqQ`+GMn$Vts0<4s?BBQkaiz-Vih`~QC5v=&IgR8Q;~?XYU;F8*#hV5G<_PS$4nc8E{;0W{2 zo_qcDANWA4N2Zw--3bgq#|Pi+G!B~L)b}$Pe89sf?f$Ir55E}u>8`S}jeiByLO3pU%sk$3Ndkf_q|git_VAQ%s~QBvLXe_{`jp3Cw5id;PQ)SCl}J)UI4- zgT_xRc^&%?)N14FE{0ev6jk@7sE*lkj!9^xr(nW1VmvV3g@+`2eRWRMQ(Wx$QqjCo zKi7Yu$Dz_r5`(64%`VY4jw*TLdCo${x#0I~fIitjwz%Bu1oWX1mPRZKF+7hfYmFts zj-n|djSW1kyReOgBoIi!?XGPkQb)Dp&IgwSY7RTlt=gu~O935xQYVJSRUn1~C$Y#U z^vB;#7I@*XQ^gzRSlbfGk%PI1r=Zfx&WRL~+oUne;R!azK?H?4&a|}DHGO?VhDR@k zZ<(?io-^z;Hqq-v(D!(@P+V=uvs1+~Fv`g3`F;53QLWuQOK(FJzF7r6(j)n@IrACO zdu6%)3v$*+9MZ8xLGvEm5SdmxpLmKwizXS=k^uwM zdFg`rPDO>{rpJdXA(Ufsw0pV7+t1rcC(@%ucFM}-6!i-|9FiCek#2WY%g^%-sxWY) zx7caQ%D)R;YJ#~@j8Uv2dW7HMgPq53W5MI;p%=NzgGKT{E;T4+Km75@PHgpv7w(}B)~ z^#@VK2&NSjt5Ms0x&k z%$C@5G}5XP7e6S;_4FCjHAUi+;3@)C#T7q+;eR+|4CL-_bHL}zMtWYV$43mb(p~9{ zRZ*BqnLbtwIVAZXO+`~Jk`J~6r7d((W+ZtE2 zWN_*VIj=WLd#!?TwwYuVIR1lR z@1w0s*U05GlSrkiHq*ljk-jzjs6gN%@=5jAQ5AhHuC|W3s6H~XFXae9e++r#`;AVw zU{YOJo71*gZ=Guu()Q6MvYwblNg}THz&|m^a(kSgGpikHBx{XCalj&_r$LBwgR}Ze zdy%EO8b`L##T0WXkY*`WIVv&d<&UO}E2Tt+Dra^q%ESOkcHjY@xj(k6x=0*+9$j6Q`f%**vwA`xZ>YH6O>VfIRkUF_7 zu@z2Ee9xAhE|HT(D>^TR>Q@@*e~CXO2}wy$1<-s#EVR_h{{RiSaq|}5-$BRiu6LW1 zGux^mr$HpuWQ?E8s!j>@1MBIc7m6VzZB-i2w8b9qtJ$ACeR#mfu7Om-D5#5=!c;=V zgJc$F?l*p&waw#Yme{L!xBmbPwpGCakku zf^*wbE`3U7E^8^3jEELVHsrA@ogzXs%in=(M6` ztEN}D#^N~BWiyq_c)N47W61H6dk?On)^x>GmddKNC}}8EiBmak{zD#L_t9>suBPeR zJv4OaQf7;ouu90A_iP8(AQFGlzWRklZL7C*{Zd?}mK3^3j&_jB`*|lLu{i_VpKWxw zctm-57j80qUq(DtE&l)>l_F@RKQlV26~R5u2_IpNR@2Z&YGN6_B0!tt-Vb zg7Z-Tf+&&=so_ES0SXBUPr2{ss^hDu;i?HNJL%(^imQo|rc|fjA`I+M zc=sTC=vk)zIY~SL38;4tqi#_ZLEOjRokoWFFT*^P!UzebDkV^GgyWN+Ku(wMeMpH* zI?}X|%;5qM+i(Y$_c+oeK^$spOlu8Aj@l%&(?qnf%QS#Al5Zro(e6(i>zo}^Ege)} z2V1_oAWCDy2*dJ6Jb~NmjaSsYLoEb?Xy#Ww8z*j4a^bP$4>-=dRaVO+5ei?DsgH=w z67AZ%eo^m@J=(KS_HHm+R0R7|AMp-XL+d>e&bMM+`#Wsr>b$ph0mVxzZED#nsV zRgpp32XU{RQ(qM&O&in3C5XDkym5fXAb*d(h|bVM8$B|Xjhp2Mzy4=R=UohwdSLp9 z(V2}jMkEBXdwul+qKHFUsMGIBkT)IG@yPpVweDAf)U1iS!BU%Xz;Ed~5jM)IsYLO+ ziAH0dA&g_peR=&fmgHurnNhX|F%8lpjo^{N$k6juC9O=5A~U*`C6FDU@Ob;`T2_+G z(H1@!Aa5Bs#<<+ct9Z)8lHhF!J8{N>V4yucTyCtC&e1UiS~4;@*0EN{PcMg2@~V)f zRObUZJbm;$(pAA(86d}uki>W6QYm_V-F>*nS3P|64=7b&iWn2`>Gk&0#cbE0`aK7Y z@sRkH5Waj!*YhasGCrT%UDoEi(V8HPn`6##$Cfk>>uxn=WaQ{$;PEaEZNyEK0}Q9>t2Ia51JI2F*Km8XXe@8Y2;v@ z$2cE-0=M-PP#K6Sl0-_CRpc-|kFm~<-J@vTwyKrchfdyq_tKR_NX;mXBi!wUnR}6r z$M(^VfY8?r=`0CX3N2J6!ElS+hdsUXrV2S|E~_#s2pyC!2RZtK=c=OHcdMdBjtY+r zWB#0QeTe&Mp4C$!u4g2e$qxAQBmV%u+e+PZ3L0uIAH%9)jzw9iqZm?0Dt&RORkviR zdU(}DZfR34cm>9D&zazUx)pGVrimsEn2P{OAD2Ai>)$~7f;r5Tb!bY{#8Mn*fW?aw z^}*66+bdcL+&Xra^94<3gpy4d`G#=EpD$C+e%ccpbyiwO6v2dLPS!jXY>wH{dtK5x zXpe^@KZsAtxPN|;Rc?W=NaP^u(puX0yWDXJ^JFXkXYg3dTR`f;5V zu-E)l1{vf809i@@0MFZ1?LABlEXgz>E2BOVNxXnIp4jcqbobNHS*oqIMz*p@Zm7j$ z5e$X{up{k{zLhOle?dp4F9Vq{@f@ihY@^TX&V#JKz^H3wmQS7-pTh+AK1cV_SjUe+ zxuo!i>+^aa(?NQk4~*&hq1}ka%FdCP>I`7Iu)$w{uDh<07WZNM zeH}$j88!6sQ&fh*EI|+MF|8`8nA11|Ga&g$J^AgXNSYd&GGg8&P&a~jQ|3mXR!tG9 z5vwNu05P3&@^Y4fmV{fGX(El2bBMqsW4E{JG`~LNsA;Lt7$ra^7~m@M3G~lzzN{;q zy;arX200XAm&x{*6Vky|;He8D5a^?p9mm)mRRBclD^0!4s#;J+<=rv`$A=&z zDt$*{HS!pv4QQ%{vNWDb!%IAFxDe#7$qD}eP8jF^0B*STJ?X`|kVhu?(xYiWxG2SN zN9sRqbAre&HyRkKDi)49rcoq>{IQPz08H?4qNT;sMB34BSNs2#daU^P zYG;z2L8e$&jjC9cKApx7mpYHuEfgjhXc;zdNxoB-P(UG1(>gga%Oq{F7C6VGD^ytH zqXJ0@_xtK4R_MZnY&!O1*!vw#xm^}jXc@U3zK24oY8qqV%nX4>3HI{W8nGo&+dV|E zts#ksF%5x1PREi?;pTb$2D?$p)^thfR1L(aVmKt{zo*kfipbJzb}t@AHQutMLiE(z z3aNE-p3FyWYdg{(Eqy?IXIE%Kq)V_Lm*ykeljp6jRr1l?yfHBTWJ>r_2nD>e?WL%; zMIAX}z9~%n-fN8zxlGkm%8H6ZDigGJVd<%{(I$p-P||p^7|36g`QZ8;N{^^(V7<~) z60<=xdp5YhkJsC_hFa~i)l?-cuET6w*zxlF54qG>YWHSOhCIXLN`)N9B-0du~vI&aF6cf(}9De#2Ra_^nL}^Ldn;7;7zw3=iiYVlv zk~rjD%Kre>z$4E~IgJP(UO6f%9jd-i6qb#n`9K3W2k)IhTBw!=oT4l$Kmix?A1!TL zQwPB$aMo{?*oHxpNjSza-$P!4>HI%sJ-VloPWAu|xX=CcBINcNMMtsP#nl#wU@%-n z=`6>?lqdvjZsdE6e|-k6k_zc$r?&XgQ6sm3;vRB%-H#)r%5hOeRTQ<;30N@!RegPZ zhxgOHFlvQ{uAP)Aj2)p*%6aEpc{Aj4;E}PU_7SzD6oNN)hA>>lyVoQP;2wLBH+}Uc zuH{Vj#f{}%;Iq0X=0#Nqxj#&S_tLe(_GDUkYa;N}0Eh`0bIw=+skXXRu7+l2XjWL1 zznQ?}{A-=%%a2$7$8nA5pJ2Y4YJ*ai8+j&xb zK;V6}CaF^+G{<2)+;KhB&atfsb_HLXacI~qx%gyggB04kp$oc@DW z@3CA&exAFo=$`cc2zZ%^lafbapH4CRX}Xn}6Er}^+dM>(xKc-K4^H3TK(F?wuJ!ee zJaE&sWhUr~C(jAnl^pO+IQGuCM{}#Gp`=-{O;D(z^S~|#R&=e)b^5+)U`;%6xQRjeRY+cO-2VVq z_d0!}x=NoBPg4w$BH@9M&WX3jtW_rN0~%&^jPhpDCbh~27@ zBqn%LJZ)fc^Ar7Cjt6XeY7JhIR4m)--v@Grh)M~3<#;&H>D%j}Rh4FosHT#dBOFo6 z!kvV84THFT$4&I~a4b(u)KXJ4Dq(h#Gl--h^_`X#yA)sObt=Z zy`p?jRtniFvi|@T^1vB@+s<$??oS7k>7~o^si;ZY60R0TR32dS_wv+=iT(zt8g<QdCo#88H!e zEVHou=lv(sLF~g-lETeSu~bw^QzE|4lw`0tz){@#ee~Tb$qm$`f(jVuiH~Zg2K)?< zpurjQ&Z$kJlqwaY{CW32vC1 zB}ejql1m}^hqwO#dOOn&TW;oMs8Sd)W3_RN{YG>y-A>aJ@d#lUATT}s^l>e(Euwko z8AHWR#ks&GNY0Ws(MMHd?@m}cYWHoHYqja1sd@hZnC^B6g1HTVe>QRt>F=(vT5QnJ z+vBOL7Z~ayn551&x~}4@f!GWHK|ft4(U_K!+fgABMI>OnYCsC(k{j!v+f(i}P_M(y zMG#2~9G5>R40{3SIKcYql6nyk%Mn{uPNgD>0$o|7&xy2Uc=b5__0CFWtEiP{QMr!% z=YVm>KAO_y8%J=RlBs8n&ef4YUBF-ygWJBkNh``y5aV{tm&OPl{{XI{-68?c5istF zZ%2BYV*daJsD#WOP@t;&;~~Dp4K(zI%Z;^6{tBT*0SM)Z<0J3RgVWk7giBL0G&J)@ zOfs_MN(T*(Pd@sc({*b}Zl;o0%EWhWC_9XH9+^6tzazgC5UizSrl^g0j!%@KKhzr| z?Z&xXd@tY%DAFkfNZb}VGSPr`^XZHoFx_DfNa144uTCnk-lPqyjVH_C+iM_=j_5M} zKF>ePo_|4~bEk)t#G3L0q?>e7u|~@&7;%C#jS`-f=pu!rbCjAqbB~m-{f>v!Fd>P@ z%Hc;j&z_6aNgZ7(EV6_uh~c~B46Xq4Isiyhsxr-cD=Tb*3b#T=UrXLMNxctFzIY0U5 zsnGb<^rESmU2}{v#?gRJ+eLJ04|KQ>PYa)vWGET*&->`L(ZwX9Kt5Iljy#C+)C)~J z+mazv8Z4doVT0w4G*;@W!ZM@zW-?BEiS7NgCE1pR%BHEPm`y3dOjxpG@`ArEl^RB; z3^G1)Lg&=!%1p*Tnb^t#A5eXCrB=QTT(2r`5e8))v(FkR!0M<%IuXV(oPX!jO!g*t z-J^u~rc4E2R?_0gOpPRj7(f~0)Q*3B6M{n#MnkoT-VS{@(0vBHk7;eIsimket4SP8 z8eAXhVO{y`Ruv=lk)!njOgb}U*8hLHCxk@9`q;y^91+vBO^lZ zl;F8%RtY+IPZ&IfAmIDyI-!#zCT4;)fE7T4CB1*}e)=^O%{tTX!wB*)dBOG4Ti48z zn76hd+kgjk3zzqKY=eX3H zO-U0Mf0m&UNjMLwFxnZb|MgIVYEU3G&&Ojvonue5qD(!enRIEZYz`$fu za6aC;q+iP+{+7Yx`|4aM(?HO^Hp`Wj8||p{RSyj`5TOeze5My4 z)xbKNNhCAIk{z*6vj?7AJdfW$rjf6prc>9bT87yjZ6?-*WD6`qgXTATbHUWh9m<~X z6rhzAl(2)AAz9T&89scReKgZsWnI@ASH{_uj>G=|QP1}}NN$r9bqw?&mUO`fxcU*R zM`AThuq~C4#{^JqmNi(xLdPr#8Noh$@u}0pM_449svn17m|2kb?fvwrW2%xgX=Y`Q zVF#X6fx**kQ;F&43d_CSn_%<7=Wnq(332EbXKnHPI>O2%--i+&KHUBE8nT}|N@3Iz zLB}9^A70uw6%4VOP)~nL2tq#^G*csRH{cBvfxICFOJ}yb*e_$wZJ%U zlO9@$=TC2o@OnDgURcGwVK`Ef7vPY5{{T&NucL+wlw|mFL}V;o3mhz3f_-xJsXOrxYx9C)3ZES2+A-B&;I~g zWwcJw*DX6JcHg%kWb@_v>swSYgqAT7BrXG}^wxCy=DrAntu(Md%PS1-JG-&=8X7g| zCcY)FsQf90!k{S_!P(mw`i}nkS?WuBTkp#)r^J?qs5-{OPj$A8yMQ!NF~hO*5Yks8cgGp7NGz!~+GIjM{PyPy)&X1lN9b2X{jiq ziBY8nL$Hy__WuAngW}awQJCb)!6}V*e^41bk?1(n{LZjNBh;aApzKdia&(J*4{@z{ zD4kX>4=fb&GusF4u6c32jVy77&;{+4HFp@+nbndLhn2owPk;RM(;XGYcp(ti|!Q468!fEPv}KgZ9R+f>W+6Wer;k(AF+tsHrMMV{^uU1GgLj_ZsEYws&YC zt%_u5(lyBqoDy-zuQ}2+@$Pi;Q_|D2cy8NXZMiO(&JH}i^#+E%^b$y}6RjL1D!R5` zYUiAK6UXnW`;vR1I5zqk+7Vexib&pSqR4!5=O_RWWfe^w)&zQ}ix@~^ za@fW_yw0QBF5=V;H9~lZ6`37ejE^Sw$2u8N)R9%)3q%popKx|LAprBvon%c5*2nL| z6ovw7YKxgw8^RgSh@^m59$5YU+OYbMsfD$vYLbFahANTNG5}j^XKLrmBx5?JI%59O z)k^geEgTVGZFbsZ=Zp`zI(@W$BwDFqk`;{956vQhlGyU+wg=x zsHj+`l$A~ST(RSh*%{XTb#s!(dg#i!YIqRa{(0%q_rCWfNG;}es5l+D*G4xCl+)3Z zbR=0)w&5j2wd%!;lT^Bg1TbOgMJ%r{BXQ$A1NnZVl75;l6bQ)HgiR((G?NI%9#&wu^ZsIW|S}bmuzFKImH;OvQq>6@DS)o?>$RnI&b_5Llbc57$ zKx(Fzok~Y6uEDX6<0pgebtSFKLplB>1wu7W;T&X?2WbO2^TxEjT4qSTXmcb=d_%jx zIpBRX(sQb0qLtVV-Xh;1W8xVwNMqP~Y0i$i3Z9s{Lx@BaHu-)XW8gcnUjC$$lk~=x zp`?Op9|d-oZe?g@hDI8TPSez!}*-pWBkblOQem$*`tz9A8y_HDD z8RG5Wfz1Zz_Uqi zLsh|vcE=i=eX;AG+d#T~rK`E#XzC!68+8RVnjCcJs7Bd^$qm~G;d}fUE z+yw*?`kh!8!?mO$G?5dMTk^KyjD0br`oNTt&?{_cqF}`V?g_>}L9DtO=#2z2&J;{b z!#r$*Jditcs6rz`t4q23IizI-@)sZPjW8=9oXJc!s;a6X^d0{IbEy>Y!&6UMr!Oam zx)3{Oejh(!`kgSpvv?$yqBu=c63XREGiN^fn@w~zRc`~UGUFRh*ne(y#DE+`4{{Y6NM2C8ZKRSY}57$QPsZbpV3~`S{`7o6Ktq*ReR%hKgPOJR3#lm zuPkpQk^WUv-yEO52koYcnqeKrv|uDMByR~m!)t!y_t4j;1~W{M>#ZfjrYTWr;ujcY0G#2J0h6CzPBgj-upR!0 zv@<$_WQ{v#1g`{R_tL~|TUL-yN<+TjFa{X&jz`l@H7Y(CTCK_u{u37*#>cjOdyNgJ zw|p3QUBGDeg>>)RgQkroVeEr75%91fR%s>h$}qtk zBSnfzdF}=|LYQ2#$H(T+pZ_GEXfkLGcmPc(XEs(T+h@{{U?1TM87ebSXt&GsNtxFU{eB!i;%p9qXs< z{aZ&39aKgc%Iv0`ADBu0#QA&Z&Bk4fV1O*eHnAl9#1owcxYeaBY<^)GU>n)5DfqT literal 0 HcmV?d00001 diff --git a/examples/oddecho/problem_statement/echo_cave.jpg b/examples/oddecho/problem_statement/echo_cave.jpg deleted file mode 100644 index e197bf1b4a2e9d2d2782e29680869536a27684b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35667 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 diff --git a/examples/oddecho/problem_statement/problem.sv.md b/examples/oddecho/problem_statement/problem.sv.md index 4ffd89cf..55f9806f 100644 --- a/examples/oddecho/problem_statement/problem.sv.md +++ b/examples/oddecho/problem_statement/problem.sv.md @@ -1,6 +1,6 @@ **EKO! Eko! Ek...** -![](echo_cave.jpg) +![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. From 11b6a13be45616c0c3c8464d7deb2f9f6c5d7802 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 12 Mar 2025 02:07:58 +0100 Subject: [PATCH 28/53] Sanitize image sources --- problemtools/md2html.py | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 7b398369..d39dfb12 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -5,6 +5,7 @@ import argparse import json import subprocess +import re from . import statement_common @@ -31,7 +32,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: _copy_images(statement_path, - lambda img_name: handle_image(os.path.join(problem, "problem_statement", img_name))) + lambda img_name: handle_image(problem, img_name)) command = ["pandoc", statement_path, "-t" , "html", "-f", "markdown-raw_html"] statement_html = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout @@ -70,21 +71,29 @@ def convert(problem: str, options: argparse.Namespace) -> bool: return True -def handle_image(src: str) -> None: +def handle_image(problem_root: str, img_src: str) -> None: """This is called for every image in the statement - Copies the image from the statement to the output directory + First, check if we actually allow this image + Then, copies the image from the statement to the output directory Args: - src: full file path to the image + problem_root: the root of the problem directory + img_src: the image source as in the Markdown statement """ - 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): + src_pattern = r'^[a-zA-Z0-9.]+\.(png|jpg|jpeg)$' + + if not re.match(src_pattern, img_src): + raise Exception(f"Image source must match regex {src_pattern}") + + source_name = os.path.join(problem_root, "problem_statement", img_src) + + if not os.path.isfile(source_name): + raise Exception(f"File {source_name} not found in problem_statement") + if os.path.isfile(img_src): # already copied return - with open(src, "rb") as img: - with open(file_name, "wb") as out: + with open(source_name, "rb") as img: + with open(img_src, "wb") as out: out.write(img.read()) From bfd4703dbb518024674a28789a82bec20eacf7a3 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 12 Mar 2025 02:13:07 +0100 Subject: [PATCH 29/53] Remove SVG dependency --- Dockerfile | 1 - README.md | 4 ++-- admin/docker/Dockerfile.minimal | 1 - debian/control | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index cff647c5..2c3b4c57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,6 @@ RUN apt-get update && \ python3-pip \ python3-plastex \ python3-yaml \ - rsvg-convert \ sudo \ texlive-fonts-recommended \ texlive-lang-cyrillic \ diff --git a/README.md b/README.md index 96758f52..84494e09 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 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 + sudo apt install ghostscript libgmpxx4ldbl pandoc python3-minimal python-pkg-resources python3-plastex python3-yaml 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++ 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 + 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: diff --git a/admin/docker/Dockerfile.minimal b/admin/docker/Dockerfile.minimal index 340f0b20..886d1a2d 100644 --- a/admin/docker/Dockerfile.minimal +++ b/admin/docker/Dockerfile.minimal @@ -25,7 +25,6 @@ RUN apt update && \ 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 d1bf4179..717a9b53 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}, pandoc, python3-plastex, python3-pkg-resources, rsvg-convert, 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, 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 From d93577132a44bfd8b1eb578398c6f869923149cf Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 12 Mar 2025 20:19:16 +0100 Subject: [PATCH 30/53] Better markdown styling --- problemtools/md2html.py | 4 +-- problemtools/statement_common.py | 1 - .../markdown_html/default-layout.html | 5 ++- .../templates/markdown_html/problem.css | 35 +++++++++++++------ 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index d39dfb12..d3fd100d 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -33,7 +33,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: _copy_images(statement_path, lambda img_name: handle_image(problem, img_name)) - command = ["pandoc", statement_path, "-t" , "html", "-f", "markdown-raw_html"] + command = ["pandoc", statement_path, "-t" , "html", "-f", "markdown-raw_html", "--mathjax"] statement_html = subprocess.run(command, capture_output=True, text=True, shell=False, check=True).stdout @@ -81,7 +81,7 @@ def handle_image(problem_root: str, img_src: str) -> None: img_src: the image source as in the Markdown statement """ - src_pattern = r'^[a-zA-Z0-9.]+\.(png|jpg|jpeg)$' + src_pattern = r'^[a-zA-Z0-9._]+\.(png|jpg|jpeg)$' if not re.match(src_pattern, img_src): raise Exception(f"Image source must match regex {src_pattern}") diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 2d6f75ad..0bbb0cac 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -76,7 +76,6 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: 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 diff --git a/problemtools/templates/markdown_html/default-layout.html b/problemtools/templates/markdown_html/default-layout.html index 814324c1..93a84572 100644 --- a/problemtools/templates/markdown_html/default-layout.html +++ b/problemtools/templates/markdown_html/default-layout.html @@ -8,9 +8,8 @@ /problem.yaml" create mode 100644 "problemtools/tests/problems///problem_statement/problem.md" create mode 100644 problemtools/tests/problems/problemnamexss/problem.yaml create mode 100644 problemtools/tests/problems/problemnamexss/problem_statement/problem.md create mode 100644 problemtools/tests/problems/samplexss/data/sample/1.ans create mode 100644 problemtools/tests/problems/samplexss/data/sample/1.in create mode 100644 problemtools/tests/problems/samplexss/data/sample/testdata.yaml create mode 100644 problemtools/tests/problems/samplexss/data/testdata.yaml create mode 100644 problemtools/tests/problems/samplexss/problem.yaml create mode 100644 problemtools/tests/problems/samplexss/problem_statement/problem.md create mode 100644 problemtools/tests/problems/specialcharacterssample/data/sample/1.ans create mode 100644 problemtools/tests/problems/specialcharacterssample/data/sample/1.in create mode 100644 problemtools/tests/problems/specialcharacterssample/data/sample/testdata.yaml create mode 100644 problemtools/tests/problems/specialcharacterssample/data/testdata.yaml create mode 100644 problemtools/tests/problems/specialcharacterssample/problem.yaml create mode 100644 problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md create mode 100644 problemtools/tests/problems/statementxss/problem.yaml create mode 100644 problemtools/tests/problems/statementxss/problem_statement/problem.md create mode 100644 problemtools/tests/test_markdown.py create mode 100644 problemtools/tests/test_xss.py diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 776e3cf0..2e2db6a3 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -1,11 +1,12 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- -import os.path -import string import argparse +import html +import os +import string import subprocess -import re -import tempfile + +import nh3 from . import statement_common @@ -34,46 +35,67 @@ def convert(problem: str, options: argparse.Namespace) -> bool: statement_common.assert_images_are_valid_md(statement_path) statement_common.foreach_image(statement_path, lambda img_name: copy_image(problem, img_name)) - - command = ["pandoc", statement_path, "-t" , "html", "-f", "markdown-raw_html", "--mathjax"] + + command = ["pandoc", statement_path, "-t" , "html", "--mathjax"] 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 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) + problem_name = statement_common.get_yaml_problem_name(problem, options.language) + + if problem_name: + problem_name = html.escape(problem_name) - html_template = _substitute_template(templatepath, "default-layout.html", + statement_html = _substitute_template(templatepath, "default-layout.html", statement_html=statement_html, language=options.language, title=problem_name or "Missing problem name", - problemid=problembase) + problemid=problembase) # No need to escape problem shortname, the spec has tight restrictions directory names samples = statement_common.format_samples(problem, to_pdf=False) # Insert samples at {{nextsample}} and {{remainingsamples}} - html_template, remaining_samples = statement_common.inject_samples(html_template, samples, "") + statement_html, remaining_samples = statement_common.inject_samples(statement_html, samples, "") # Insert the remaining samples at the bottom - if FOOTNOTES_STRING in html_template: - pos = html_template.find(FOOTNOTES_STRING) + if FOOTNOTES_STRING in statement_html: + pos = statement_html.find(FOOTNOTES_STRING) else: - pos = html_template.find("") - html_template = html_template[:pos] + "".join(remaining_samples) + html_template[pos:] - - html_template = replace_hr_in_footnotes(html_template) + pos = statement_html.find("") + statement_html = statement_html[:pos] + "".join(remaining_samples) + statement_html[pos:] + + statement_html = replace_hr_in_footnotes(statement_html) + html_body = statement_html[statement_html.find(""):] + statement_html = statement_html[:statement_html.find("")] + + allowed_classes = ("sample", "problemheader", "problembody", + "sampleinteractionwrite", "sampleinteractionread") + def attribute_filter(tag, attribute, value): + if attribute == "class" and value in allowed_classes: + return value + if tag == "img" and attribute == "src": + return value + return None + + html_body = nh3.clean(html_body, + link_rel="noopener nofollow noreferrer", + attribute_filter=attribute_filter, + tags=nh3.ALLOWED_TAGS | {"img"}, + attributes={"table": {"class"}, "div": {"class"}, "img": {"src"}}, + ) + statement_html += html_body with open(destfile, "w", encoding="utf-8", errors="xmlcharrefreplace") as output_file: - output_file.write(html_template) + output_file.write(statement_html) if options.css: with open("problem.css", "w") as output_file: diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 27fe2ffd..b7c6b995 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -32,7 +32,6 @@ def md2pdf(options: argparse.Namespace) -> bool: statement_common.assert_images_are_valid_md(statement_path) 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"))), @@ -48,7 +47,7 @@ def md2pdf(options: argparse.Namespace) -> bool: with open(statement_path, "r") as file: statement_md = file.read() - problem_name = statement_common.get_problem_name(problem_root, options.language) + problem_name = statement_common.get_yaml_problem_name(problem_root, options.language) # Add problem name and id to the top problem_id = os.path.basename(problem_root) @@ -66,7 +65,7 @@ def md2pdf(options: argparse.Namespace) -> bool: 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}", "-f", "markdown-raw_html"] + command = ["pandoc", temp_file.name, "-o", destfile, f"--resource-path={statement_dir}"] try: return subprocess.run(command, capture_output=True, text=True, shell=False, check=True) except subprocess.CalledProcessError as e: diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 945a116f..5ed6defa 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -7,7 +7,7 @@ import json from pathlib import Path -from . import verifyproblem +import yaml SUPPORTED_EXTENSIONS = ("tex", "md") @@ -43,7 +43,7 @@ def find_statement_extension(problem_root: str, language: Optional[str]) -> str: for language {language or 'en'}""") if len(extensions) == 1: return extensions[0] - raise Exception(f"No statement found for language {language or 'en'}") + raise FileNotFoundError(f"No statement found for language {language or 'en'}") def json_dfs(data, callback) -> None: @@ -63,7 +63,7 @@ def json_dfs(data, callback) -> None: def foreach_image(statement_path, callback): # Find all images in the statement and call callback for each one - command = ["pandoc", statement_path, "-t" , "json", "-f", "markdown-raw_html"] + 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) @@ -77,8 +77,7 @@ def assert_image_is_valid(problem_root: str, img_src: str) -> None: source_name = os.path.join(problem_root, img_src) if not os.path.isfile(source_name): - print(source_name) - raise Exception(f"File {img_src} not found in problem_statement") + raise FileNotFoundError(f"Resource file {img_src} not found in problem_statement") def assert_images_are_valid_md(statement_path: str) -> None: @@ -88,49 +87,63 @@ def assert_images_are_valid_md(statement_path: str) -> None: foreach_image(statement_path, lambda img_name: assert_image_is_valid(problem_root, img_name)) -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") +def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str]: + + # TODO: getting this should be done using verifyproblem + # Wait until new config parsing system is in place + config_file = Path(problem) / 'problem.yaml' + + if not config_file.is_file(): + raise FileNotFoundError("No problem.yaml found") + + try: + with open(config_file) as f: + config = yaml.safe_load(f) + if config is None: + config = {} + except Exception as e: + raise Exception(f"Invalid problem.yaml: {e}") + + if 'name' in config and not isinstance(config['name'], dict): + config['name'] = {'': config['name']} + 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 is None: + language = "en" if language not in names: raise Exception(f"No problem name defined for language {language or 'en'}") return names[language] -def inject_samples(html, samples, sample_separator): +def inject_samples(statement_html, samples, sample_separator): """Injects samples at occurences of {{nextsample}} and {{remainingsamples}} Non-destructive, returns the new html and all left-over samples Returns: """ - + while True: - match = re.search(r'\{\{(nextsample|remainingsamples)\}\}', html) + match = re.search(r'\{\{(nextsample|remainingsamples)\}\}', statement_html) if not match: break matched_text = match.group(1) if matched_text == "nextsample" and len(samples) == 0: raise Exception("Error: called {{nextsample}} without any samples left") - + num_inject = 1 if matched_text == "nextsample" else len(samples) to_inject = sample_separator.join(samples[:num_inject]) samples = samples[num_inject:] - + # Always inject, even if to_inject is empty # This will remove all occurences of {{nextsample}} and {{remainingsamples}} # (And also properly throw an error if {{nextsample}} is called with no samples left) - html = html[:match.start()] + to_inject + html[match.end():] + statement_html = statement_html[:match.start()] + to_inject + statement_html[match.end():] - return html, samples + return statement_html, samples def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: @@ -253,6 +266,7 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pd elif interaction[0] == '<': left = False else: + left = True print(f"Warning: Interaction had unknown prefix {interaction[0]}") lines.append(r""" \begin{table}[H] @@ -275,5 +289,5 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pd if to_pdf: return line + '\\vspace{-15pt}'.join(lines) - else: - return line + ''.join(lines) + + return line + ''.join(lines) diff --git "a/problemtools/tests/problems///problem.yaml" "b/problemtools/tests/problems///problem.yaml" new file mode 100644 index 00000000..97d008eb --- /dev/null +++ "b/problemtools/tests/problems///problem.yaml" @@ -0,0 +1 @@ +name: Problem ID xss diff --git "a/problemtools/tests/problems///problem_statement/problem.md" "b/problemtools/tests/problems///problem_statement/problem.md" new file mode 100644 index 00000000..8b137891 --- /dev/null +++ "b/problemtools/tests/problems///problem_statement/problem.md" @@ -0,0 +1 @@ + diff --git a/problemtools/tests/problems/problemnamexss/problem.yaml b/problemtools/tests/problems/problemnamexss/problem.yaml new file mode 100644 index 00000000..2f3393a7 --- /dev/null +++ b/problemtools/tests/problems/problemnamexss/problem.yaml @@ -0,0 +1 @@ +name: diff --git a/problemtools/tests/problems/problemnamexss/problem_statement/problem.md b/problemtools/tests/problems/problemnamexss/problem_statement/problem.md new file mode 100644 index 00000000..95c3b387 --- /dev/null +++ b/problemtools/tests/problems/problemnamexss/problem_statement/problem.md @@ -0,0 +1 @@ +XSS injection via problem name. diff --git a/problemtools/tests/problems/samplexss/data/sample/1.ans b/problemtools/tests/problems/samplexss/data/sample/1.ans new file mode 100644 index 00000000..0f61cbb0 --- /dev/null +++ b/problemtools/tests/problems/samplexss/data/sample/1.ans @@ -0,0 +1 @@ +PWNED diff --git a/problemtools/tests/problems/samplexss/data/sample/1.in b/problemtools/tests/problems/samplexss/data/sample/1.in new file mode 100644 index 00000000..9114f1c2 --- /dev/null +++ b/problemtools/tests/problems/samplexss/data/sample/1.in @@ -0,0 +1,3 @@ + diff --git a/problemtools/tests/problems/samplexss/data/sample/testdata.yaml b/problemtools/tests/problems/samplexss/data/sample/testdata.yaml new file mode 100644 index 00000000..8034585a --- /dev/null +++ b/problemtools/tests/problems/samplexss/data/sample/testdata.yaml @@ -0,0 +1,5 @@ +on_reject: continue +range: 0 0 +accept_score: 0 +grader_flags: first_error +input_validator_flags: nFive=0 diff --git a/problemtools/tests/problems/samplexss/data/testdata.yaml b/problemtools/tests/problems/samplexss/data/testdata.yaml new file mode 100644 index 00000000..6e832954 --- /dev/null +++ b/problemtools/tests/problems/samplexss/data/testdata.yaml @@ -0,0 +1,3 @@ +on_reject: continue +range: 0 2 +grader_flags: ignore_sample diff --git a/problemtools/tests/problems/samplexss/problem.yaml b/problemtools/tests/problems/samplexss/problem.yaml new file mode 100644 index 00000000..54e6792a --- /dev/null +++ b/problemtools/tests/problems/samplexss/problem.yaml @@ -0,0 +1 @@ +name: Sample XSS diff --git a/problemtools/tests/problems/samplexss/problem_statement/problem.md b/problemtools/tests/problems/samplexss/problem_statement/problem.md new file mode 100644 index 00000000..0405e8d6 --- /dev/null +++ b/problemtools/tests/problems/samplexss/problem_statement/problem.md @@ -0,0 +1,27 @@ +Various XSS methods. Hopefully the sanitizer doesn't let any of them through. + + + + + + +Click me + + + +Click me + + + + + + + + + + + +

+ diff --git a/problemtools/tests/problems/specialcharacterssample/data/sample/1.ans b/problemtools/tests/problems/specialcharacterssample/data/sample/1.ans new file mode 100644 index 00000000..e66448f5 --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/data/sample/1.ans @@ -0,0 +1 @@ +Nice! diff --git a/problemtools/tests/problems/specialcharacterssample/data/sample/1.in b/problemtools/tests/problems/specialcharacterssample/data/sample/1.in new file mode 100644 index 00000000..950eee18 --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/data/sample/1.in @@ -0,0 +1 @@ +0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ diff --git a/problemtools/tests/problems/specialcharacterssample/data/sample/testdata.yaml b/problemtools/tests/problems/specialcharacterssample/data/sample/testdata.yaml new file mode 100644 index 00000000..8034585a --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/data/sample/testdata.yaml @@ -0,0 +1,5 @@ +on_reject: continue +range: 0 0 +accept_score: 0 +grader_flags: first_error +input_validator_flags: nFive=0 diff --git a/problemtools/tests/problems/specialcharacterssample/data/testdata.yaml b/problemtools/tests/problems/specialcharacterssample/data/testdata.yaml new file mode 100644 index 00000000..6e832954 --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/data/testdata.yaml @@ -0,0 +1,3 @@ +on_reject: continue +range: 0 2 +grader_flags: ignore_sample diff --git a/problemtools/tests/problems/specialcharacterssample/problem.yaml b/problemtools/tests/problems/specialcharacterssample/problem.yaml new file mode 100644 index 00000000..10e3241d --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/problem.yaml @@ -0,0 +1 @@ +name: Special Characters Sample diff --git a/problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md b/problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md new file mode 100644 index 00000000..abf3e60b --- /dev/null +++ b/problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md @@ -0,0 +1 @@ +All printable ASCII characters in sample diff --git a/problemtools/tests/problems/statementxss/problem.yaml b/problemtools/tests/problems/statementxss/problem.yaml new file mode 100644 index 00000000..adbc951a --- /dev/null +++ b/problemtools/tests/problems/statementxss/problem.yaml @@ -0,0 +1 @@ +name: XSS diff --git a/problemtools/tests/problems/statementxss/problem_statement/problem.md b/problemtools/tests/problems/statementxss/problem_statement/problem.md new file mode 100644 index 00000000..0405e8d6 --- /dev/null +++ b/problemtools/tests/problems/statementxss/problem_statement/problem.md @@ -0,0 +1,27 @@ +Various XSS methods. Hopefully the sanitizer doesn't let any of them through. + + + + + + +Click me + + + +Click me + + + + + + + + + + + +
+ diff --git a/problemtools/tests/test_markdown.py b/problemtools/tests/test_markdown.py new file mode 100644 index 00000000..968012e1 --- /dev/null +++ b/problemtools/tests/test_markdown.py @@ -0,0 +1,8 @@ +from pathlib import Path +from problemtools.tests.test_xss import render + +def test_sample_escaping(): + problem_path = Path(__file__).parent / "problems" / "specialcharacterssample" + html = render(problem_path) + all_printable = r"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~" + assert all_printable in html diff --git a/problemtools/tests/test_xss.py b/problemtools/tests/test_xss.py new file mode 100644 index 00000000..f932aba7 --- /dev/null +++ b/problemtools/tests/test_xss.py @@ -0,0 +1,33 @@ +import os +from pathlib import Path +from problemtools.problem2html import convert, get_parser +import tempfile + +def render(problem_path): + with tempfile.TemporaryDirectory() as temp_dir: + args, _unknown = get_parser().parse_known_args(['--problem', str(problem_path.resolve()), '--dest-dir', str(temp_dir)]) + convert(args) + with open(f"{temp_dir}/index.html", "r") as f: + html = f.read() + return html + +def test_no_xss_statement(): + problem_path = Path(__file__).parent / "problems" / "statementxss" + html = render(problem_path) + assert "alert" not in html + +def test_no_xss_problemname(): + problem_path = Path(__file__).parent / "problems" / "problemnamexss" + html = render(problem_path) + assert " - - Click me diff --git a/problemtools/tests/problems/statementxss/problem_statement/problem.md b/problemtools/tests/problems/statementxss/problem_statement/problem.md index 0405e8d6..1a555545 100644 --- a/problemtools/tests/problems/statementxss/problem_statement/problem.md +++ b/problemtools/tests/problems/statementxss/problem_statement/problem.md @@ -5,7 +5,6 @@ Various XSS methods. Hopefully the sanitizer doesn't let any of them through. alert("Hello world!"); - Click me diff --git a/problemtools/tests/problems/twofootnotes/problem.yaml b/problemtools/tests/problems/twofootnotes/problem.yaml new file mode 100644 index 00000000..e936cbc6 --- /dev/null +++ b/problemtools/tests/problems/twofootnotes/problem.yaml @@ -0,0 +1 @@ +name: Footnote Test 2 diff --git a/problemtools/tests/problems/twofootnotes/problem_statement/problem.en.md b/problemtools/tests/problems/twofootnotes/problem_statement/problem.en.md new file mode 100644 index 00000000..d95657ad --- /dev/null +++ b/problemtools/tests/problems/twofootnotes/problem_statement/problem.en.md @@ -0,0 +1,9 @@ +Footnote test 2 + +[^1] + +[^2] + +[^1]: [https://sv.wikipedia.org/wiki/Interferens](https://sv.wikipedia.org/wiki/Interferens) + +[^2]: [https://sv.wikipedia.org/wiki/Interferens](https://sv.wikipedia.org/wiki/Interferens) diff --git a/problemtools/tests/test_markdown.py b/problemtools/tests/test_markdown.py index 968012e1..2b9501bc 100644 --- a/problemtools/tests/test_markdown.py +++ b/problemtools/tests/test_markdown.py @@ -1,8 +1,34 @@ from pathlib import Path from problemtools.tests.test_xss import render +from problemtools.md2html import FOOTNOTES_STRING +import pytest def test_sample_escaping(): problem_path = Path(__file__).parent / "problems" / "specialcharacterssample" html = render(problem_path) all_printable = r"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~" assert all_printable in html + +def test_footnotes(): + # We always want footnotes to be at the bottom + # When we insert samples, we need to insert them right above the first footnote + # To do this, we search for a string (very fragile) + problem_path = Path(__file__).parent / "problems" / "footnote" + html = render(problem_path) + assert FOOTNOTES_STRING in html + + problem_path = Path(__file__).parent / "problems" / "twofootnotes" + html = render(problem_path) + assert FOOTNOTES_STRING in html + +def test_footnotes_href(): + # We use allowlist-based id values for footnotes. Ensure they have not changed + problem_path = Path(__file__).parent / "problems" / "footnote" + html = render(problem_path) + assert "fn1" in html and "fnref1" in html + +def test_invalid_image_throws(): + # If images can point to img that doesn't exist, it's arbitrary web request + problem_path = Path(__file__).parent / "problems" / "imgrequest" + with pytest.raises(Exception): + render(problem_path) diff --git a/problemtools/tests/test_xss.py b/problemtools/tests/test_xss.py index f932aba7..0e32775e 100644 --- a/problemtools/tests/test_xss.py +++ b/problemtools/tests/test_xss.py @@ -15,7 +15,7 @@ def test_no_xss_statement(): problem_path = Path(__file__).parent / "problems" / "statementxss" html = render(problem_path) assert "alert" not in html - + def test_no_xss_problemname(): problem_path = Path(__file__).parent / "problems" / "problemnamexss" html = render(problem_path) @@ -26,6 +26,7 @@ def test_no_xss_sample(): html = render(problem_path) assert "/problem_statement/problem.md" => "problemtools/tests/problems///statement/problem.md" (100%) rename problemtools/tests/problems/footnote/{problem_statement => statement}/problem.en.md (100%) rename problemtools/tests/problems/imgrequest/{problem_statement => statement}/problem.md (100%) rename problemtools/tests/problems/problemnamexss/{problem_statement => statement}/problem.md (100%) rename problemtools/tests/problems/samplexss/{problem_statement => statement}/problem.md (100%) rename problemtools/tests/problems/specialcharacterssample/{problem_statement => statement}/problem.md (100%) rename problemtools/tests/problems/statementxss/{problem_statement => statement}/problem.md (100%) rename problemtools/tests/problems/twofootnotes/{problem_statement => statement}/problem.en.md (100%) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 5d4f0f85..c46a43fb 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -32,7 +32,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: raise FileNotFoundError('No markdown statement found') if not os.path.isfile(statement_path): - raise FileNotFoundError(f"Error! {statement_path} is not a file") + raise FileNotFoundError(f"Error! {statement_path} does not exist") command = ["pandoc", statement_path, "-t" , "html", "--mathjax"] @@ -130,7 +130,7 @@ def copy_image(problem_root: str, img_src: str) -> None: img_src: the image source as in the Markdown statement """ - source_name = os.path.join(problem_root, "problem_statement", img_src) + source_name = os.path.join(problem_root, "statement", img_src) if os.path.isfile(img_src): # already copied return diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 59b01d16..249fff95 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -1,7 +1,6 @@ import os from typing import Optional, List import html -import tempfile import subprocess import re import json @@ -14,14 +13,14 @@ 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}") + statement_path = os.path.join(problem_root, f"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}") + statement_path = os.path.join(problem_root, f"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}") + statement_path = os.path.join(problem_root, f"statement/problem.{language}.{extension}") if os.path.isfile(statement_path): return statement_path return None @@ -75,9 +74,9 @@ def is_image_valid(problem_root: str, img_src: str) -> str|None: if extension not in (".png", ".jpg", ".jpeg"): # ".svg" return f"Unsupported image extension {extension} for image {img_src}" - source_file = Path(problem_root) / "problem_statement" / img_src + source_file = Path(problem_root) / "statement" / img_src if not source_file.exists(): - return f"Resource file {img_src} not found in problem_statement" + return f"Resource file {img_src} not found in statement" return None @@ -182,6 +181,34 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: return samples +def escape_latex_char(char: str) -> str: + if len(char) != 1: + raise ValueError("Input must be a single character.") + + replacements = { + "\\": "\\textbackslash{}", + "^": "\\textasciicircum{}", + "~": "\\textasciitilde{}", + "#": "\\#", + "$": "\\$", + "%": "\\%", + "&": "\\&", + "_": "\\_", + "{": "\\{", + "}": "\\}", + "*": "\\*", + "<": "\\textless{}", + ">": "\\textgreater{}", + "|": "\\textbar{}", + "'": "\\textquotesingle{}", + "`": "\\textasciigrave{}", + "\"":"\\verb|\"|", + ",": "\\verb|,|", + "-": "\\verb|-|", + "[": "\\verb|[|", + "]": "\\verb|]|", + } + return replacements.get(char, char) # Default: return unmodified char def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bool) -> str: """ @@ -203,31 +230,91 @@ def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bo 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 not to_pdf: + return """ + + + + + + + + + + + +
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 + + # Try to pack input and output into a Markdown table like this + # Precompute characters widths in LaTeX, pack as much + # as possible without causing overflow in the LaTeX table + # Use an obscene number of columns so Markdown is not limiting + """ + +---------------------------------+---------------------------------+ + | Sample Input 1 | Sample Output 1 | + +=================================+=================================+ + |0123456789abcdefghijklmnopqrstuv-|Nice! | + |wxyzABCDEFGHIJKLMNOPQRS- | | + |TUVWXYZ!"#$%&'()*+,-./:;<=>?- | | + |@[\]^_`{|}~ | | + +---------------------------------+---------------------------------+ + """ + # Need to account for if we have >= 10 samples + casenum_len = len(str(casenum))-1 + + # If there are lots of ^, we use lots of \\textasciicircum{}, and they must all fit + # Lower if debugging (or zoom out terminal veery far) + table_cols = 1000 + row = f"|{' ' * (table_cols + 16)}|{' ' * (table_cols + 16)}|\n" + ascii_char_widths = {' ': 3.33333, '!': 3.2, '"': 6.2, '#': 9.6, '$': 5.9, '%': 9.6, '&': 9.0, "'": 3.2, '(': 4.5, ')': 4.5, '*': 5.8, '+': 9.0, ',': 6.2, '-': 6.5, '.': 5, '/': 5.8, '0': 5.8, '1': 5.8, '2': 5.8, '3': 5.8, '4': 5.8, '5': 5.8, '6': 5.8, '7': 5.8, '8': 5.8, '9': 5.8, ':': 3.2, ';': 3.2, '<': 8.9, '=': 8.9, '>': 8.9, '?': 5.4, '@': 8.9, 'A': 7.50002, 'B': 7.08336, 'C': 7.22223, 'D': 7.6389, 'E': 6.80557, 'F': 6.5278, 'G': 7.84723, 'H': 7.50002, 'I': 3.61111, 'J': 5.1389, 'K': 8.5, 'L': 6.25002, 'M': 9.16669, 'N': 7.50002, 'O': 8.5, 'P': 6.80557, 'Q': 8.5, 'R': 7.36111, 'S': 5.55557, 'T': 7.22223, 'U': 7.50002, 'V': 7.50002, 'W': 10.2778, 'X': 7.50002, 'Y': 7.50002, 'Z': 6.11111, '[': 6.2, '\\': 6.0, ']': 6.2, '^': 6.5, '_': 8.6, '`': 5.8, 'a': 5.8, 'b': 5.55557, 'c': 4.44444, 'd': 5.55557, 'e': 4.44444, 'f': 3.05557, 'g': 5.8, 'h': 5.55557, 'i': 3.2, 'j': 3.05557, 'k': 5.2778, 'l': 3.2, 'm': 9.6, 'n': 5.55557, 'o': 5.8, 'p': 5.55557, 'q': 5.27779, 'r': 3.91667, 's': 3.94444, 't': 4.5, 'u': 5.55557, 'v': 5.2778, 'w': 7.22223, 'x': 5.2778, 'y': 5.2778, 'z': 4.44444, '{': 5.8, '|': 3.3, '}': 5.8, '~': 6.5} + space_per_row = 160 # Number of LaTeX units of horizontal space available + chars_per_row = (table_cols + 16)-1 # Save one space for - + num_rows = 0 + table = list(f""" ++----------------{'-' * table_cols}+----------------{'-' * table_cols}+ +| Sample Input {casenum} {' ' * (table_cols-casenum_len)}| Sample Output {casenum}{' ' * (table_cols-casenum_len)}| ++================{'=' * table_cols}+================{'=' * table_cols}+ +""") + base_table_offset = len(table) + def insert_into_table(offset, text): + nonlocal num_rows, table + curr_row = -1 + for line in text.split("\n"): + while len(line): + curr_row += 1 + if curr_row >= num_rows: + num_rows+=1 + table += list(row) + table += list(row) + + # Add stuff to write to this line while it fits + curr_vspace = 0 + curr_line = "" + # Must fit in both Markdown table and LaTeX table + while len(line) and \ + len(curr_line)+1 str: @@ -286,7 +373,7 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pd line_type = "sampleinteractionread" else: print(f"Warning: Interaction had unknown prefix {interaction[0]}") - lines.append(f"""
{data}
""") + lines.append(f"""
{html.escape(data)}
""") if to_pdf: return line + '\\vspace{-15pt}'.join(lines) diff --git "a/problemtools/tests/problems///problem_statement/problem.md" "b/problemtools/tests/problems///statement/problem.md" similarity index 100% rename from "problemtools/tests/problems///problem_statement/problem.md" rename to "problemtools/tests/problems///statement/problem.md" diff --git a/problemtools/tests/problems/footnote/problem_statement/problem.en.md b/problemtools/tests/problems/footnote/statement/problem.en.md similarity index 100% rename from problemtools/tests/problems/footnote/problem_statement/problem.en.md rename to problemtools/tests/problems/footnote/statement/problem.en.md diff --git a/problemtools/tests/problems/imgrequest/problem_statement/problem.md b/problemtools/tests/problems/imgrequest/statement/problem.md similarity index 100% rename from problemtools/tests/problems/imgrequest/problem_statement/problem.md rename to problemtools/tests/problems/imgrequest/statement/problem.md diff --git a/problemtools/tests/problems/problemnamexss/problem_statement/problem.md b/problemtools/tests/problems/problemnamexss/statement/problem.md similarity index 100% rename from problemtools/tests/problems/problemnamexss/problem_statement/problem.md rename to problemtools/tests/problems/problemnamexss/statement/problem.md diff --git a/problemtools/tests/problems/samplexss/problem_statement/problem.md b/problemtools/tests/problems/samplexss/statement/problem.md similarity index 100% rename from problemtools/tests/problems/samplexss/problem_statement/problem.md rename to problemtools/tests/problems/samplexss/statement/problem.md diff --git a/problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md b/problemtools/tests/problems/specialcharacterssample/statement/problem.md similarity index 100% rename from problemtools/tests/problems/specialcharacterssample/problem_statement/problem.md rename to problemtools/tests/problems/specialcharacterssample/statement/problem.md diff --git a/problemtools/tests/problems/statementxss/problem_statement/problem.md b/problemtools/tests/problems/statementxss/statement/problem.md similarity index 100% rename from problemtools/tests/problems/statementxss/problem_statement/problem.md rename to problemtools/tests/problems/statementxss/statement/problem.md diff --git a/problemtools/tests/problems/twofootnotes/problem_statement/problem.en.md b/problemtools/tests/problems/twofootnotes/statement/problem.en.md similarity index 100% rename from problemtools/tests/problems/twofootnotes/problem_statement/problem.en.md rename to problemtools/tests/problems/twofootnotes/statement/problem.en.md From 213f9aca65c559bbbcea61306f74a05a477b0c83 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Tue, 8 Apr 2025 02:19:57 +0200 Subject: [PATCH 40/53] Better md -> pdf sample rendering --- problemtools/problem2pdf.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index b7c6b995..078af4c4 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -27,7 +27,7 @@ def md2pdf(options: argparse.Namespace) -> bool: 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") + raise FileNotFoundError(f"Error! {statement_path} does not exist") statement_common.assert_images_are_valid_md(statement_path) @@ -38,12 +38,12 @@ def md2pdf(options: argparse.Namespace) -> bool: 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") + raise FileNotFoundError("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") + statement_dir = os.path.join(problem_root, "statement") with open(statement_path, "r") as file: statement_md = file.read() @@ -62,16 +62,15 @@ def md2pdf(options: argparse.Namespace) -> bool: # If we don't add newline, the topmost table might get attached to a footnote statement_md += "\n" + "\n".join(remaining_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}"] - try: - return 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 - + print("Rendering!") + command = ["pandoc", "-f", "markdown", "-o", destfile, f"--resource-path={statement_dir}"] + try: + return subprocess.run(command, input=statement_md, 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 def latex2pdf(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) From d745f6e37b1d3a73e613ab45afc6ebc7c36629c7 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Tue, 8 Apr 2025 02:23:17 +0200 Subject: [PATCH 41/53] Another escape --- problemtools/statement_common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 249fff95..1ae8a2b6 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -364,7 +364,7 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pd \hline \end{tabular} \end{table}""" % {"justify": "" if left else "\\hspace*{\\fill}\n", - "text": data}) + "text": html.escape(data)}) else: line_type = "" if interaction[0] == '>': From d4e27a29b8e0902d2618e666c940b02d6c7f59ae Mon Sep 17 00:00:00 2001 From: Matistjati Date: Tue, 8 Apr 2025 06:34:23 +0200 Subject: [PATCH 42/53] More careful with images --- problemtools/md2html.py | 7 ++++-- problemtools/problem2pdf.py | 18 ++++++++++--- problemtools/statement_common.py | 25 +++++++++++++++---- .../problems/imgrequest/statement/problem.md | 2 +- .../tests/problems/imgrequest2/problem.yaml | 1 + .../problems/imgrequest2/statement/problem.md | 3 +++ problemtools/tests/test_markdown.py | 15 ++++++++--- problemtools/tests/test_xss.py | 13 +++++++--- 8 files changed, 65 insertions(+), 19 deletions(-) create mode 100644 problemtools/tests/problems/imgrequest2/problem.yaml create mode 100644 problemtools/tests/problems/imgrequest2/statement/problem.md diff --git a/problemtools/md2html.py b/problemtools/md2html.py index c46a43fb..42f9450a 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -91,7 +91,7 @@ def is_fn_id(s): "footnotes") # Annoying: nh3 will ignore exceptions in attribute_filter - image_fail_reason = None + image_fail_reason: str|None = None def attribute_filter(tag, attribute, value): if attribute == "class" and value in allowed_classes: return value @@ -118,7 +118,10 @@ def attribute_filter(tag, attribute, value): ) if image_fail_reason: - raise Exception(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 diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 078af4c4..0973138d 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -40,13 +40,13 @@ def md2pdf(options: argparse.Namespace) -> bool: if not os.path.isfile(table_fix_path): raise FileNotFoundError("Could not find markdown pdf template") - with open(table_fix_path, "r") as file: + with open(table_fix_path, "r", encoding="utf-8") as file: table_fix = file.read() statement_dir = os.path.join(problem_root, "statement") - with open(statement_path, "r") as file: + with open(statement_path, "r", encoding="utf-8") as file: statement_md = file.read() - + problem_name = statement_common.get_yaml_problem_name(problem_root, options.language) # Add problem name and id to the top @@ -65,13 +65,23 @@ def md2pdf(options: argparse.Namespace) -> bool: print("Rendering!") command = ["pandoc", "-f", "markdown", "-o", destfile, f"--resource-path={statement_dir}"] try: - return subprocess.run(command, input=statement_md, capture_output=True, + subprocess.run(command, input=statement_md, 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 + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: + command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", + "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] + subprocess.run(command, capture_output=True, + text=True, shell=False, check=True + ) + shutil.copy(f.name, destfile) + + return True + def latex2pdf(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) problembase = os.path.splitext(os.path.basename(problem_root))[0] diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index 1ae8a2b6..f94af909 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -1,9 +1,10 @@ import os from typing import Optional, List import html -import subprocess -import re import json +import re +import subprocess +import tempfile from pathlib import Path import yaml @@ -63,8 +64,11 @@ def json_dfs(data, callback) -> None: def foreach_image(statement_path, callback): # Find all images in the statement and call callback for each one command = ["pandoc", statement_path, "-t" , "json"] - statement_json = subprocess.run(command, capture_output=True, - text=True, shell=False, check=True).stdout + # Must create a working directory for pytest to work + with tempfile.TemporaryDirectory() as dir: + statement_json = subprocess.run(command, capture_output=True, text=True, + shell=False, check=True, cwd=dir).stdout + json_dfs(json.loads(statement_json), callback) def is_image_valid(problem_root: str, img_src: str) -> str|None: @@ -79,13 +83,24 @@ def is_image_valid(problem_root: str, img_src: str) -> str|None: return f"Resource file {img_src} not found in statement" return None +def assert_image_is_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 (".png", ".jpg", ".jpeg"): # ".svg" + raise ValueError(f"Unsupported image extension {extension} for image {img_src}") + + source_file = Path(problem_root) / "statement" / img_src + if not source_file.exists(): + raise FileNotFoundError(f"Resource file {img_src} not found in statement") + def assert_images_are_valid_md(statement_path: str) -> None: # Find all images in the statement and assert that they exist and # use valid image extensions problem_root = os.path.dirname(statement_path) foreach_image(statement_path, - lambda img_name: is_image_valid(problem_root, img_name)) + lambda img_name: assert_image_is_valid(problem_root, img_name)) def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str]: diff --git a/problemtools/tests/problems/imgrequest/statement/problem.md b/problemtools/tests/problems/imgrequest/statement/problem.md index a97e2f1a..53ac7554 100644 --- a/problemtools/tests/problems/imgrequest/statement/problem.md +++ b/problemtools/tests/problems/imgrequest/statement/problem.md @@ -1,3 +1,3 @@ Make web request via image - +![Alt text](http:picsum.photos/400) diff --git a/problemtools/tests/problems/imgrequest2/problem.yaml b/problemtools/tests/problems/imgrequest2/problem.yaml new file mode 100644 index 00000000..57f37816 --- /dev/null +++ b/problemtools/tests/problems/imgrequest2/problem.yaml @@ -0,0 +1 @@ +name: Make web request via image diff --git a/problemtools/tests/problems/imgrequest2/statement/problem.md b/problemtools/tests/problems/imgrequest2/statement/problem.md new file mode 100644 index 00000000..a97e2f1a --- /dev/null +++ b/problemtools/tests/problems/imgrequest2/statement/problem.md @@ -0,0 +1,3 @@ +Make web request via image + + diff --git a/problemtools/tests/test_markdown.py b/problemtools/tests/test_markdown.py index 2b9501bc..75535306 100644 --- a/problemtools/tests/test_markdown.py +++ b/problemtools/tests/test_markdown.py @@ -1,5 +1,5 @@ from pathlib import Path -from problemtools.tests.test_xss import render +from problemtools.tests.test_xss import render, renderpdf from problemtools.md2html import FOOTNOTES_STRING import pytest @@ -29,6 +29,13 @@ def test_footnotes_href(): def test_invalid_image_throws(): # If images can point to img that doesn't exist, it's arbitrary web request - problem_path = Path(__file__).parent / "problems" / "imgrequest" - with pytest.raises(Exception): - render(problem_path) + for problem in ("imgrequest", "imgrequest2"): + problem_path = Path(__file__).parent / "problems" / problem + with pytest.raises(ValueError): + render(problem_path) + + # Pandoc won't make a web request for imgrequest2 + with pytest.raises(ValueError): + renderpdf(Path(__file__).parent / "problems" / "imgrequest") + + diff --git a/problemtools/tests/test_xss.py b/problemtools/tests/test_xss.py index 0e32775e..aafc561b 100644 --- a/problemtools/tests/test_xss.py +++ b/problemtools/tests/test_xss.py @@ -1,16 +1,23 @@ import os from pathlib import Path -from problemtools.problem2html import convert, get_parser +from problemtools import problem2html +from problemtools import problem2pdf import tempfile def render(problem_path): with tempfile.TemporaryDirectory() as temp_dir: - args, _unknown = get_parser().parse_known_args(['--problem', str(problem_path.resolve()), '--dest-dir', str(temp_dir)]) - convert(args) + args, _unknown = problem2html.get_parser().parse_known_args(['--problem', str(problem_path.resolve()), '--dest-dir', str(temp_dir)]) + problem2html.convert(args) with open(f"{temp_dir}/index.html", "r") as f: html = f.read() return html +def renderpdf(problem_path): + with tempfile.TemporaryDirectory() as temp_dir: + outpath = os.path.join(temp_dir, "out.pdf") + args, _unknown = problem2pdf.get_parser().parse_known_args(['--problem', str(problem_path.resolve()), '--o', outpath]) + problem2pdf.convert(args) + def test_no_xss_statement(): problem_path = Path(__file__).parent / "problems" / "statementxss" html = render(problem_path) From fdde1a462345f236cbf84b1544362a7dcc7401d9 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Tue, 8 Apr 2025 06:37:49 +0200 Subject: [PATCH 43/53] Make samplexss more focused --- .../problems/samplexss/statement/problem.md | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/problemtools/tests/problems/samplexss/statement/problem.md b/problemtools/tests/problems/samplexss/statement/problem.md index eb13d8f0..eba8940f 100644 --- a/problemtools/tests/problems/samplexss/statement/problem.md +++ b/problemtools/tests/problems/samplexss/statement/problem.md @@ -1,25 +1 @@ -Various XSS methods. Hopefully the sanitizer doesn't let any of them through. - - - - -Click me - - - -Click me - - - - - - - - - - - -
- +XSS via sample? From 3ded4a44ac207e63d50b334e8e6848e3fe993c31 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 06:32:44 +0200 Subject: [PATCH 44/53] Experimentally reuse normal LaTeX rendering --- problemtools/problem2pdf.py | 26 +- .../templates/latex/problemset_md.cls | 436 ++++++++++++++++++ 2 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 problemtools/templates/latex/problemset_md.cls diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 0973138d..23d75f26 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -4,6 +4,7 @@ import shutil import string import argparse +from pathlib import Path import subprocess import tempfile @@ -29,7 +30,29 @@ def md2pdf(options: argparse.Namespace) -> bool: if not os.path.isfile(statement_path): raise FileNotFoundError(f"Error! {statement_path} does not exist") - statement_common.assert_images_are_valid_md(statement_path) + #statement_common.assert_images_are_valid_md(statement_path) + + fake_tex = Path(statement_path).parent / "problem.tex" + print(f"{fake_tex=} {statement_path=}") + command = ["pandoc", statement_path, "-o", fake_tex] + 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 + + with open(fake_tex, "r") as f: + tex = f.read() + with open(fake_tex, "w") as f: + f.write('\\problemname{asd}\n'+tex) + + try: + latex2pdf(options) + finally: + fake_tex.unlink() + return False templatepaths = [os.path.join(os.path.dirname(__file__), 'templates/markdown_pdf'), '/usr/lib/problemtools/templates/markdown_pdf'] @@ -102,6 +125,7 @@ def latex2pdf(options: argparse.Namespace) -> bool: params.append('-draftmode') params.append(texfile) + print(texfile) status = subprocess.call(params, stdout=output) if status == 0: diff --git a/problemtools/templates/latex/problemset_md.cls b/problemtools/templates/latex/problemset_md.cls new file mode 100644 index 00000000..55d6e0fb --- /dev/null +++ b/problemtools/templates/latex/problemset_md.cls @@ -0,0 +1,436 @@ +\NeedsTeXFormat{LaTeX2e} +\ProvidesClass{problemset}[2012/01/19 Problem Set For ACM-Style Programming Contests] + + +\newif\ifplastex +\plastexfalse + +\newif\if@footer\@footertrue +\DeclareOption{nofooter}{\@footerfalse} + +\newif\if@problemnumbers\@problemnumberstrue +\DeclareOption{noproblemnumbers}{\@problemnumbersfalse} + +\newif\if@problemids\@problemidstrue +\DeclareOption{noproblemids}{\@problemidsfalse} + +\newif\if@samplenumbers\@samplenumberstrue +\DeclareOption{nosamplenumbers}{\@samplenumbersfalse} + +\newif\if@clearevenpages\@clearevenpagestrue + +\newif\if@autoincludesamples\@autoincludesamplestrue +\DeclareOption{noautoincludesamples}{\@autoincludesamplesfalse} + +\DeclareOption{plainproblems}{ + \@footerfalse + \@problemnumbersfalse + \@clearevenpagesfalse +} + +\DeclareOption*{\PassOptionsToClass{\CurrentOption}{article}} +\ProcessOptions\relax + +\LoadClass{article} + +\RequirePackage{times} % Font choice +\RequirePackage{amsmath} % AMS +\RequirePackage{amssymb} % AMS +\RequirePackage[OT2,T1]{fontenc} % Cyrillic and standard % TODO: make alphabet options more general +\RequirePackage[utf8]{inputenc} % UTF-8 support +\RequirePackage{fancyhdr} % Headers +\RequirePackage{graphicx} % Graphics +\RequirePackage{subfigure} % Subfigures +\RequirePackage{wrapfig} % Illustrations +\RequirePackage{import} % Proper file inclusion +\RequirePackage{fancyvrb} % +\RequirePackage{listingsutf8} % For samples +\RequirePackage[left=1in,right=1in,top=0.75in,bottom=0.75in]{geometry} +%\RequirePackage{fullpage} % Set up margins for full page +\RequirePackage{url} % Urls +\RequirePackage[normalem]{ulem} % \sout +\RequirePackage[colorlinks=true,implicit=false]{hyperref} +\ifplastex\else +\RequirePackage{xstring} +\RequirePackage{pgffor} +\fi + +\usepackage{graphicx} % Required for inserting images +\usepackage{hyperref} +\providecommand{\tightlist}{% + \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}} +\usepackage{longtable} +\usepackage{booktabs} + +%% Commands used to set name, logo, etc of contest +\newcommand*{\contestname}[1]{\def\@contestname{#1}} +\newcommand*{\contestshortname}[1]{\def\@contestshortname{#1}} +\newcommand*{\contestlogo}[1]{\def\@contestlogo{#1}} +\newcommand*{\location}[1]{\def\@location{#1}} +\newcommand*{\licenseblurb}[1]{\def\@licenseblurb{#1}} +\newcommand*{\problemlanguage}[1]{\def\@problemlanguage{#1}} +\contestname{} +\contestshortname{} +\contestlogo{} +\location{} +\licenseblurb{} +\problemlanguage{} + + +% Command to set a header logo +\newsavebox{\PS@headerbox} +\savebox{\PS@headerbox}{} +\addtolength{\headheight}{0.25in} +\addtolength{\textheight}{-0.25in} +\setlength{\headsep}{12pt} +\newcommand*{\headerlogo}[1]{ + \def\@headerlogo{#1} + \savebox{\PS@headerbox}{\includegraphics[width=\textwidth]{\@headerlogo}} + \addtolength{\textheight}{\headheight} + \settoheight{\headheight}{\usebox{\PS@headerbox}} + \addtolength{\headheight}{4.2pt} + \addtolength{\textheight}{-\headheight} +} + + + +% Typesetting sections in a problem + +\renewcommand\section{\@startsection{section}{1}{\z@}% + {-3ex}% + {1ex}% + {\normalfont\large\sf\bfseries}} +\newenvironment{Input}{\section*{Input}}{} +\newenvironment{Output}{\section*{Output}}{} +\newenvironment{Interaction}{\section*{Interaction}}{} + +\renewcommand\subsection{\@startsection{subsection}{2}{\z@}% + {-3.55ex}% + {1.7ex}% + {\normalfont\normalsize\sf\bfseries}} + +\renewcommand{\contentsname}{Problems} + + +% TODO: make last command of illustration optional +\newcommand{\illustration}[3]{ + \begin{wrapfigure}{r}{#1\textwidth} + \includegraphics[width=#1\textwidth]{#2} + \begin{flushright} + \vspace{-9pt} + \tiny #3 + \end{flushright} + \vspace{-15pt} + \end{wrapfigure} + \par + \noindent +} + + +%% Redefine cleardoublepage to put a text on even-numbered empty +%% pages. +\newcommand{\makeemptypage}{ + ~\thispagestyle{empty} + \vfill + \centerline{\Large \textsf{ This page is intentionally left blank.}} + \vfill + \clearpage +} +\renewcommand{\cleardoublepage}{ + \clearpage% + \ifodd\value{page}\else\makeemptypage\fi% +} + +\newcommand{\clearproblemsetpage}{ + \if@clearevenpages + \cleardoublepage + \else + \clearpage + \fi +} + + +%% Set up a problem counter and number problems A B C ... +\newcounter{problemcount} +\setcounter{problemcount}{0} +\newcommand{\problemnumber}{\Alph{problemcount}} + +%% Number figures as A.1 A.2... B.1 B.2... +%% (except if we're converting to HTML or if we're not using problem numbers) +\ifplastex\else +\if@problemnumbers +\renewcommand{\thefigure}{\problemnumber.\arabic{figure}} +\let\p@subfigure=\thefigure +\fi +\fi + + +%% Command for starting new problem + +%% Problem inclusion +\newcommand{\includeproblem}[1]{ + \startproblem{#1} + \import{#1/problem_statement/}{problem\@problemlanguage.tex} + + %% Automatically include samples 1..9, if enabled + \ifplastex\else + \if@autoincludesamples + \foreach \SampleNum in {1,...,9} { + \IfFileExists{\@problemid/data/sample/\SampleNum.interaction}{ + \displaysampleinteraction{\@problemid/data/sample/\SampleNum} + }{\IfFileExists{\@problemid/data/sample/\SampleNum.in}{ + \displaysample{\@problemid/data/sample/\SampleNum} + }{} + } + } + \fi + \fi +} + +\newcommand{\startproblem}[1]{ + \clearproblemsetpage + \refstepcounter{problemcount} + \setcounter{samplenum}{0} + \setcounter{figure}{0}% + \def\@problemid{#1} +} + +\newcommand{\problemname}[1]{ + \def\@problemname{#1} + \problemheader{\@problemname}{\@problemid} +} + +\newcommand{\ps@formattime}[1]{ + #1\ifdim#1in=1in second \else seconds \fi +} + +\newread\ps@timelimitfile +\newcommand{\problemheader}[2]{ + \begin{center} + \textsf{ + \if@problemnumbers {\huge Problem \problemnumber\\[3mm]} \fi + {\LARGE #1} + \if@problemids {\\[2mm]{\Large Problem ID: #2}} \fi + \IfFileExists{#2/.timelimit}{ + \openin\ps@timelimitfile=#2/.timelimit + \read\ps@timelimitfile to\ps@timelimit + \\[2mm]{\Large Time limit:\ps@formattime{\ps@timelimit}} + \closein\ps@timelimitfile + }{} + \\[5mm] + } + \end{center} + \addtocontents{toc}{ + \if@problemnumbers \problemnumber \fi + & \@problemname \\}% +} + +%% Commands related to sample data + +\newcommand{\sampleinputname}{Sample Input} +\newcommand{\sampleoutputname}{Sample Output} +\newcommand{\sampleinteractname}{Sample Interaction} +\newcommand{\sampleinteractreadname}{Read} +\newcommand{\sampleinteractwritename}{Write} + +\newcommand{\formatsampleheader}[1]{\textsf{\textbf{#1}}} + +%% Sample counter +\newcounter{samplenum} +\newcommand{\sampleid}{\arabic{samplenum}} + +%% Define the command used to give sample data +%% Takes filename as parameter +\newcommand{\includesample}[1]{ + \IfFileExists{\@problemid/data/sample/#1.interaction}{ + \displaysampleinteraction{\@problemid/data/sample/#1} + }{ + \IfFileExists{\@problemid/data/sample/#1.in}{ + \displaysample{\@problemid/data/sample/#1} + }{ + \ClassError{problemset}{Can't find any sample named #1}{} + } + } + +} + +\newcommand{\displaysample}[1]{ + \IfFileExists{#1.in}{}{\ClassError{problemset}{Can't find file '#1.in'}{}} + \IfFileExists{#1.ans}{}{\ClassError{problemset}{Can't find file '#1.ans'}{}} + \refstepcounter{samplenum} + \vspace{0.4cm} + \sampletable + {\sampleinputname{} \if@samplenumbers\sampleid\fi}{#1.in} + {\sampleoutputname{} \if@samplenumbers\sampleid\fi}{#1.ans} +} + +\newcommand{\displaysampleinteraction}[1]{ + \IfFileExists{#1.interaction}{}{\ClassError{problemset}{Can't find file '#1.interaction'}{}} + \refstepcounter{samplenum} + \vspace{0.4cm} + \sampletableinteractive{\sampleinteractname{} \if@samplenumbers\sampleid\fi} + {\sampleinteractreadname} + {\sampleinteractwritename} + {#1.interaction} +} + +\newlength{\PS@sampleidealwidth} +\setlength{\PS@sampleidealwidth}{0.473\textwidth} +\newsavebox{\PS@sampleinbox} +\newsavebox{\PS@sampleoutbox} +\newlength{\PS@sampleinwidth} +\newlength{\PS@sampleoutwidth} +\newlength{\PS@sampletotwidth} + +\newcommand{\sampletable}[4]{ + % First find widths of the two files + \savebox{\PS@sampleinbox}{\lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{#2}} + \savebox{\PS@sampleoutbox}{\lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{#4}} + \settowidth{\PS@sampleoutwidth}{\usebox{\PS@sampleoutbox}} + \settowidth{\PS@sampleinwidth}{\usebox{\PS@sampleinbox}} + \setlength{\PS@sampletotwidth}{\PS@sampleinwidth} + \addtolength{\PS@sampletotwidth}{\PS@sampleoutwidth} + % Check if too wide for side-by-side + \ifdim\PS@sampletotwidth>2\PS@sampleidealwidth + \par + \noindent + \begin{tabular}{|l|} + \multicolumn{1}{l}{\formatsampleheader{#1}}\\ + \hline + \parbox[t]{0.968\textwidth}{\vspace{-0.3cm}\usebox{\PS@sampleinbox}}\\ + \hline + \end{tabular} + \par + \vspace{0.25cm} + \noindent + \begin{tabular}{|l|} + \multicolumn{1}{l}{\formatsampleheader{#3}}\\ + \hline + \parbox[t]{0.968\textwidth}{\vspace{-0.3cm}\usebox{\PS@sampleoutbox}}\\ + \hline + \end{tabular} + \else + % Side by side possible, figure out if adjustments are needed. + \ifdim\PS@sampleoutwidth>\PS@sampleidealwidth% Sample out too large + \setlength{\PS@sampleinwidth}{2\PS@sampleidealwidth} + \addtolength{\PS@sampleinwidth}{-\PS@sampleoutwidth} + \else + \ifdim\PS@sampleinwidth>\PS@sampleidealwidth% Sample in too large + \setlength{\PS@sampleoutwidth}{2\PS@sampleidealwidth} + \addtolength{\PS@sampleoutwidth}{-\PS@sampleinwidth} + \else% Ideal case: neither sample in nor sammple out too large + \setlength{\PS@sampleinwidth}{\PS@sampleidealwidth} + \setlength{\PS@sampleoutwidth}{\PS@sampleidealwidth} + \fi + \fi + \par + \noindent + \begin{tabular}{|l|l|} + \multicolumn{1}{l}{\formatsampleheader{#1}} & + \multicolumn{1}{l}{\formatsampleheader{#3}} \\ + \hline + \parbox[t]{\PS@sampleinwidth}{\vspace{-0.3cm}\usebox{\PS@sampleinbox}} + & + \parbox[t]{\PS@sampleoutwidth}{\vspace{-0.3cm}\usebox{\PS@sampleoutbox}} + \\ + \hline + \end{tabular} + \fi + \par +} + +\newread\ps@sampleinteraction +\newwrite\ps@sampleinteractionmsg +\newcommand{\sampletableinteractive}[4]{ + \noindent + \begin{tabular}{p{0.306\textwidth}p{0.306\textwidth}p{0.306\textwidth}} + \formatsampleheader{#2} \hfill & + \centering\formatsampleheader{#1} & + \hfill \formatsampleheader{#3} \\ + \end{tabular} + \begingroup + \openin\ps@sampleinteraction=#4 + \def\curmode{x} + \def\showmessage{ + \if x\curmode\else + \immediate\closeout\ps@sampleinteractionmsg + \vspace{-\parskip} + \vspace{0.05cm} + \par\noindent + \if w\curmode\hfill\fi + \begin{tabular}{|l|} + \hline + \parbox[t]{0.55\textwidth}{ + \vspace{-0.49cm} + \lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{\jobname.pstmp} + \vspace{-0.21cm} + }\\ + \hline + \end{tabular} + \par + \fi + } + \@whilesw\unless\ifeof\ps@sampleinteraction\fi{ + \endlinechar=-1 + \readline\ps@sampleinteraction to\ps@interactionline + \endlinechar=13 + \def\mode{x} + \IfBeginWith{\ps@interactionline}{>}{\def\mode{w}}{} + \IfBeginWith{\ps@interactionline}{<}{\def\mode{r}}{} + \if x\mode\else + \if \mode\curmode\else + \showmessage + \immediate\openout\ps@sampleinteractionmsg=\jobname.pstmp + \edef\curmode{\mode} + \fi + \StrGobbleLeft{\ps@interactionline}{1}[\ps@interactionline] + \immediate\write\ps@sampleinteractionmsg{\ps@interactionline} + \fi + } + \showmessage + \closein\ps@sampleinteraction + \endgroup +} + + +% Remaining part of file is headers and toc, not tested with plasTeX +% and should not be used in plastex mode +\ifplastex\else + +\AtBeginDocument{ + %% Set up headers + \fancypagestyle{problem}{ + \fancyhf{} % Clear old junk + \fancyhead[C]{\usebox{\PS@headerbox}} + \if@footer + \fancyfoot[L]{ + \emph{ + \@contestshortname{} + \ifdefined\@problemname + \if@problemnumbers Problem \problemnumber:{} \fi + \@problemname + \fi + \ifx\@licenseblurb\@empty\relax\else + \\\@licenseblurb + \fi + } + } + \fancyfoot[R]{\thepage} + \fi + } + \renewcommand{\headrulewidth}{0pt} + \pagestyle{problem} + + % Set up table of contents for cover page + \addtocontents{toc}{\protect\begin{tabular}{cl}} +} + +\AtEndDocument{ + \clearproblemsetpage + % Annoyingly enough addtocontents won't work at end of doc + \immediate\write\@auxout{% + \string\@writefile{toc}{\string\end{tabular}}% + } +} + +\fi From 79b5a5d69fbe22c6f04edafe4334a62f32d71698 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 08:36:28 +0200 Subject: [PATCH 45/53] Use problemtools problem2pdf to handle md -> pdf --- problemtools/formatversion.py | 6 +- problemtools/md2html.py | 2 +- problemtools/problem2pdf.py | 108 +++-- problemtools/statement_common.py | 231 +++------- problemtools/template.py | 8 +- problemtools/templates/latex/problemset.cls | 5 + .../templates/latex/problemset_md.cls | 436 ------------------ .../templates/markdown_pdf/fix_tables.md | 14 - 8 files changed, 118 insertions(+), 692 deletions(-) delete mode 100644 problemtools/templates/latex/problemset_md.cls delete mode 100644 problemtools/templates/markdown_pdf/fix_tables.md diff --git a/problemtools/formatversion.py b/problemtools/formatversion.py index 12af9169..cf574ccf 100644 --- a/problemtools/formatversion.py +++ b/problemtools/formatversion.py @@ -13,16 +13,14 @@ class FormatData: A class containing data specific to the format version. name: the version name. statement_directory: the directory where the statements should be found. - statement_extensions: the allowed extensions for the statements. """ name: str statement_directory: str - statement_extensions: list[str] FORMAT_DATACLASSES = { - VERSION_LEGACY: FormatData(name=VERSION_LEGACY, statement_directory="problem_statement", statement_extensions=["tex"]), - VERSION_2023_07: FormatData(name=VERSION_2023_07, statement_directory="statement", statement_extensions=["md", "tex"]) + VERSION_LEGACY: FormatData(name=VERSION_LEGACY, statement_directory="problem_statement"), + VERSION_2023_07: FormatData(name=VERSION_2023_07, statement_directory="statement") } diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 42f9450a..1e41beff 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -58,7 +58,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: title=html.escape(problem_name) if problem_name else "Missing problem name", problemid=html.escape(problembase)) - samples = statement_common.format_samples(problem, to_pdf=False) + samples = statement_common.format_samples(problem) # Insert samples at {{nextsample}} and {{remainingsamples}} statement_html, remaining_samples = statement_common.inject_samples(statement_html, samples, "") diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 25f095d7..198e0f82 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -5,6 +5,7 @@ import string import argparse from pathlib import Path +import re import subprocess import tempfile @@ -32,7 +33,9 @@ def md2pdf(options: argparse.Namespace) -> bool: #statement_common.assert_images_are_valid_md(statement_path) - fake_tex = Path(statement_path).parent / "problem.tex" + # TODO: fix nextsample and remainingsamples + # TODO: better language code + fake_tex = Path(statement_path).parent / "problem.en.tex" print(f"{fake_tex=} {statement_path=}") command = ["pandoc", statement_path, "-o", fake_tex] try: @@ -43,67 +46,60 @@ def md2pdf(options: argparse.Namespace) -> bool: print(f"Error compiling Markdown to pdf: {e.stderr}") return False - with open(fake_tex, "r") as f: - tex = f.read() - with open(fake_tex, "w") as f: - f.write('\\problemname{asd}\n'+tex) - try: + with open(fake_tex, "r") as f: + tex = f.read() + + def format_latex_tables(latex_doc): + # Match table environments with column specs between @{...@{}} + 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) + + 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") + + problem_name = statement_common.get_yaml_problem_name(problem_root, options.language) + tex = '\\problemname{' + problem_name + '}\n' + tex + with open(fake_tex, "w") as f: + f.write(tex) + with open("SOGS.tex", "w") as f: + f.write(tex) + print("RENDERING!!") latex2pdf(options) + except Exception as e: + print(f"{e}") finally: fake_tex.unlink() - return False - - templatepaths = [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 FileNotFoundError("Could not find markdown pdf template") - - with open(table_fix_path, "r", encoding="utf-8") as file: - table_fix = file.read() - - statement_dir = os.path.join(problem_root, "statement") - with open(statement_path, "r", encoding="utf-8") as file: - statement_md = file.read() - - problem_name = statement_common.get_yaml_problem_name(problem_root, options.language) - - # Add problem name and id to the top - problem_id = os.path.basename(problem_root) - statement_md = r'\centerline{\large %s}' % f"Problem id: {problem_id}" + statement_md - statement_md = r'\centerline{\huge %s}' % problem_name + statement_md - # Add code that adds vertical and horizontal lines to all tables - statement_md = table_fix + statement_md - - samples = statement_common.format_samples(problem_root, to_pdf=True) - - statement_md, remaining_samples = statement_common.inject_samples(statement_md, samples, "\n") - # If we don't add newline, the topmost table might get attached to a footnote - statement_md += "\n" + "\n".join(remaining_samples) - - print("Rendering!") - command = ["pandoc", "-f", "markdown", "-o", destfile, f"--resource-path={statement_dir}"] - try: - subprocess.run(command, input=statement_md, 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 - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: - command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", - "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] - subprocess.run(command, capture_output=True, - text=True, shell=False, check=True - ) - shutil.copy(f.name, destfile) + # with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: + # command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", + # "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] + # subprocess.run(command, capture_output=True, + # text=True, shell=False, check=True + # ) + # shutil.copy(f.name, destfile) - return True + return False def latex2pdf(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) diff --git a/problemtools/statement_common.py b/problemtools/statement_common.py index f94af909..450ccd21 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_common.py @@ -9,21 +9,28 @@ import yaml +from . import formatversion + SUPPORTED_EXTENSIONS = ("tex", "md") +ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg") # ".svg" def find_statement(problem_root: str, extension: str, language: Optional[str]) -> Optional[str]: """Finds the "best" statement for given language and extension""" + statement_dir = Path(problem_root) / formatversion.get_format_data(problem_root).statement_directory + + candidates = [] if language is None: - statement_path = os.path.join(problem_root, f"statement/problem.en.{extension}") - if os.path.isfile(statement_path): - return statement_path - statement_path = os.path.join(problem_root, f"statement/problem.{extension}") - if os.path.isfile(statement_path): - return statement_path - return None - statement_path = os.path.join(problem_root, f"statement/problem.{language}.{extension}") - if os.path.isfile(statement_path): - return statement_path + candidates = [ + statement_dir / f"problem.en.{extension}", + statement_dir / f"problem.{extension}", + ] + else: + candidates = [statement_dir / f"problem.{language}.{extension}"] + + for candidate in candidates: + if candidate.is_file(): + return str(candidate) + return None @@ -75,7 +82,7 @@ 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 (".png", ".jpg", ".jpeg"): # ".svg" + if extension not in ALLOWED_IMAGE_EXTENSIONS: return f"Unsupported image extension {extension} for image {img_src}" source_file = Path(problem_root) / "statement" / img_src @@ -87,7 +94,7 @@ def assert_image_is_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 (".png", ".jpg", ".jpeg"): # ".svg" + if extension not in ALLOWED_IMAGE_EXTENSIONS: # ".svg" raise ValueError(f"Unsupported image extension {extension} for image {img_src}") source_file = Path(problem_root) / "statement" / img_src @@ -161,7 +168,7 @@ def inject_samples(statement_html, samples, sample_separator): return statement_html, samples -def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: +def format_samples(problem_root: str) -> List[str]: """Read all samples from the problem directory and convert them to pandoc-valid markdown Args: @@ -180,7 +187,7 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: 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)) + samples.append(format_interactive_sample(sample_path, sample, casenum)) casenum += 1 continue @@ -191,48 +198,18 @@ def format_samples(problem_root: str, to_pdf: bool = False) -> List[str]: if not os.path.isfile(outpath): continue - samples.append(format_normal_sample(sample_path, sample, casenum, to_pdf)) + samples.append(format_normal_sample(sample_path, sample, casenum)) casenum += 1 return samples -def escape_latex_char(char: str) -> str: - if len(char) != 1: - raise ValueError("Input must be a single character.") - - replacements = { - "\\": "\\textbackslash{}", - "^": "\\textasciicircum{}", - "~": "\\textasciitilde{}", - "#": "\\#", - "$": "\\$", - "%": "\\%", - "&": "\\&", - "_": "\\_", - "{": "\\{", - "}": "\\}", - "*": "\\*", - "<": "\\textless{}", - ">": "\\textgreater{}", - "|": "\\textbar{}", - "'": "\\textquotesingle{}", - "`": "\\textasciigrave{}", - "\"":"\\verb|\"|", - ",": "\\verb|,|", - "-": "\\verb|-|", - "[": "\\verb|[|", - "]": "\\verb|]|", - } - return replacements.get(char, char) # Default: return unmodified char - -def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bool) -> str: +def format_normal_sample(sample_root: str, sample: str, casenum: int) -> 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 @@ -245,94 +222,23 @@ def format_normal_sample(sample_root: str, sample: str, casenum: int, to_pdf: bo with open(outpath, "r", encoding="utf-8") as outfile: sample_output = outfile.read() - if not to_pdf: - return """ - - - - - - - - - - - -
Sample Input %(case)dSample Output %(case)d
%(input)s
%(output)s
""" % ({"case": casenum, "input": html.escape(sample_input), - "output": html.escape(sample_output)}) - - - # Try to pack input and output into a Markdown table like this - # Precompute characters widths in LaTeX, pack as much - # as possible without causing overflow in the LaTeX table - # Use an obscene number of columns so Markdown is not limiting - """ - +---------------------------------+---------------------------------+ - | Sample Input 1 | Sample Output 1 | - +=================================+=================================+ - |0123456789abcdefghijklmnopqrstuv-|Nice! | - |wxyzABCDEFGHIJKLMNOPQRS- | | - |TUVWXYZ!"#$%&'()*+,-./:;<=>?- | | - |@[\]^_`{|}~ | | - +---------------------------------+---------------------------------+ - """ - # Need to account for if we have >= 10 samples - casenum_len = len(str(casenum))-1 - - # If there are lots of ^, we use lots of \\textasciicircum{}, and they must all fit - # Lower if debugging (or zoom out terminal veery far) - table_cols = 1000 - row = f"|{' ' * (table_cols + 16)}|{' ' * (table_cols + 16)}|\n" - ascii_char_widths = {' ': 3.33333, '!': 3.2, '"': 6.2, '#': 9.6, '$': 5.9, '%': 9.6, '&': 9.0, "'": 3.2, '(': 4.5, ')': 4.5, '*': 5.8, '+': 9.0, ',': 6.2, '-': 6.5, '.': 5, '/': 5.8, '0': 5.8, '1': 5.8, '2': 5.8, '3': 5.8, '4': 5.8, '5': 5.8, '6': 5.8, '7': 5.8, '8': 5.8, '9': 5.8, ':': 3.2, ';': 3.2, '<': 8.9, '=': 8.9, '>': 8.9, '?': 5.4, '@': 8.9, 'A': 7.50002, 'B': 7.08336, 'C': 7.22223, 'D': 7.6389, 'E': 6.80557, 'F': 6.5278, 'G': 7.84723, 'H': 7.50002, 'I': 3.61111, 'J': 5.1389, 'K': 8.5, 'L': 6.25002, 'M': 9.16669, 'N': 7.50002, 'O': 8.5, 'P': 6.80557, 'Q': 8.5, 'R': 7.36111, 'S': 5.55557, 'T': 7.22223, 'U': 7.50002, 'V': 7.50002, 'W': 10.2778, 'X': 7.50002, 'Y': 7.50002, 'Z': 6.11111, '[': 6.2, '\\': 6.0, ']': 6.2, '^': 6.5, '_': 8.6, '`': 5.8, 'a': 5.8, 'b': 5.55557, 'c': 4.44444, 'd': 5.55557, 'e': 4.44444, 'f': 3.05557, 'g': 5.8, 'h': 5.55557, 'i': 3.2, 'j': 3.05557, 'k': 5.2778, 'l': 3.2, 'm': 9.6, 'n': 5.55557, 'o': 5.8, 'p': 5.55557, 'q': 5.27779, 'r': 3.91667, 's': 3.94444, 't': 4.5, 'u': 5.55557, 'v': 5.2778, 'w': 7.22223, 'x': 5.2778, 'y': 5.2778, 'z': 4.44444, '{': 5.8, '|': 3.3, '}': 5.8, '~': 6.5} - space_per_row = 160 # Number of LaTeX units of horizontal space available - chars_per_row = (table_cols + 16)-1 # Save one space for - - num_rows = 0 - table = list(f""" -+----------------{'-' * table_cols}+----------------{'-' * table_cols}+ -| Sample Input {casenum} {' ' * (table_cols-casenum_len)}| Sample Output {casenum}{' ' * (table_cols-casenum_len)}| -+================{'=' * table_cols}+================{'=' * table_cols}+ -""") - base_table_offset = len(table) - def insert_into_table(offset, text): - nonlocal num_rows, table - curr_row = -1 - for line in text.split("\n"): - while len(line): - curr_row += 1 - if curr_row >= num_rows: - num_rows+=1 - table += list(row) - table += list(row) - - # Add stuff to write to this line while it fits - curr_vspace = 0 - curr_line = "" - # Must fit in both Markdown table and LaTeX table - while len(line) and \ - len(curr_line)+1 str: + return """ + + + + + + + + + + + +
Sample Input %(case)dSample Output %(case)d
%(input)s
%(output)s
""" % ({"case": casenum, "input": html.escape(sample_input), + "output": html.escape(sample_output)}) + + +def format_interactive_sample(sample_root: str, sample: str, casenum: int) -> str: """ Args: @@ -344,53 +250,28 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int, to_pd 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
""" + + 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 = html.escape(interaction[1:]) - if to_pdf: - if interaction[0] == '>': - left = True - elif interaction[0] == '<': - left = False - else: - left = True - 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": html.escape(data)}) + line_type = "" + if interaction[0] == '>': + line_type = "sampleinteractionwrite" + elif interaction[0] == '<': + line_type = "sampleinteractionread" 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"""
{html.escape(data)}
""") - - if to_pdf: - return line + '\\vspace{-15pt}'.join(lines) + print(f"Warning: Interaction had unknown prefix {interaction[0]}") + lines.append(f"""
{html.escape(data)}
""") return line + ''.join(lines) diff --git a/problemtools/template.py b/problemtools/template.py index f0c7bc4b..b1e73466 100644 --- a/problemtools/template.py +++ b/problemtools/template.py @@ -16,18 +16,14 @@ def detect_version(problemdir, problemtex): class Template: - def __init__(self, problemdir, language=None, force_copy_cls=False, version="automatic"): + def __init__(self, problemdir, language=None, force_copy_cls=False): if not os.path.isdir(problemdir): raise Exception('%s is not a directory' % problemdir) if problemdir[-1] == '/': problemdir = problemdir[:-1] - if version == "automatic": - version_data = formatversion.get_format_data(problemdir) - - else: - version_data = formatversion.get_format_data_by_name(version) + version_data = formatversion.get_format_data(problemdir) stmtdir = os.path.join(problemdir, version_data.statement_directory) langs = [] diff --git a/problemtools/templates/latex/problemset.cls b/problemtools/templates/latex/problemset.cls index 1700901e..f747551c 100644 --- a/problemtools/templates/latex/problemset.cls +++ b/problemtools/templates/latex/problemset.cls @@ -50,6 +50,8 @@ \RequirePackage{url} % Urls \RequirePackage[normalem]{ulem} % \sout \RequirePackage[colorlinks=true,implicit=false]{hyperref} +\RequirePackage{longtable} % TODO: needed by Pandoc, but what do they do? +\RequirePackage{booktabs} % -||- \ifplastex\else \RequirePackage{xstring} \RequirePackage{pgffor} @@ -85,6 +87,9 @@ \addtolength{\textheight}{-\headheight} } +% Pandoc outputs these +\newcommand{\tightlist}{% + \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}} % Typesetting sections in a problem diff --git a/problemtools/templates/latex/problemset_md.cls b/problemtools/templates/latex/problemset_md.cls deleted file mode 100644 index 55d6e0fb..00000000 --- a/problemtools/templates/latex/problemset_md.cls +++ /dev/null @@ -1,436 +0,0 @@ -\NeedsTeXFormat{LaTeX2e} -\ProvidesClass{problemset}[2012/01/19 Problem Set For ACM-Style Programming Contests] - - -\newif\ifplastex -\plastexfalse - -\newif\if@footer\@footertrue -\DeclareOption{nofooter}{\@footerfalse} - -\newif\if@problemnumbers\@problemnumberstrue -\DeclareOption{noproblemnumbers}{\@problemnumbersfalse} - -\newif\if@problemids\@problemidstrue -\DeclareOption{noproblemids}{\@problemidsfalse} - -\newif\if@samplenumbers\@samplenumberstrue -\DeclareOption{nosamplenumbers}{\@samplenumbersfalse} - -\newif\if@clearevenpages\@clearevenpagestrue - -\newif\if@autoincludesamples\@autoincludesamplestrue -\DeclareOption{noautoincludesamples}{\@autoincludesamplesfalse} - -\DeclareOption{plainproblems}{ - \@footerfalse - \@problemnumbersfalse - \@clearevenpagesfalse -} - -\DeclareOption*{\PassOptionsToClass{\CurrentOption}{article}} -\ProcessOptions\relax - -\LoadClass{article} - -\RequirePackage{times} % Font choice -\RequirePackage{amsmath} % AMS -\RequirePackage{amssymb} % AMS -\RequirePackage[OT2,T1]{fontenc} % Cyrillic and standard % TODO: make alphabet options more general -\RequirePackage[utf8]{inputenc} % UTF-8 support -\RequirePackage{fancyhdr} % Headers -\RequirePackage{graphicx} % Graphics -\RequirePackage{subfigure} % Subfigures -\RequirePackage{wrapfig} % Illustrations -\RequirePackage{import} % Proper file inclusion -\RequirePackage{fancyvrb} % -\RequirePackage{listingsutf8} % For samples -\RequirePackage[left=1in,right=1in,top=0.75in,bottom=0.75in]{geometry} -%\RequirePackage{fullpage} % Set up margins for full page -\RequirePackage{url} % Urls -\RequirePackage[normalem]{ulem} % \sout -\RequirePackage[colorlinks=true,implicit=false]{hyperref} -\ifplastex\else -\RequirePackage{xstring} -\RequirePackage{pgffor} -\fi - -\usepackage{graphicx} % Required for inserting images -\usepackage{hyperref} -\providecommand{\tightlist}{% - \setlength{\itemsep}{0pt}\setlength{\parskip}{0pt}} -\usepackage{longtable} -\usepackage{booktabs} - -%% Commands used to set name, logo, etc of contest -\newcommand*{\contestname}[1]{\def\@contestname{#1}} -\newcommand*{\contestshortname}[1]{\def\@contestshortname{#1}} -\newcommand*{\contestlogo}[1]{\def\@contestlogo{#1}} -\newcommand*{\location}[1]{\def\@location{#1}} -\newcommand*{\licenseblurb}[1]{\def\@licenseblurb{#1}} -\newcommand*{\problemlanguage}[1]{\def\@problemlanguage{#1}} -\contestname{} -\contestshortname{} -\contestlogo{} -\location{} -\licenseblurb{} -\problemlanguage{} - - -% Command to set a header logo -\newsavebox{\PS@headerbox} -\savebox{\PS@headerbox}{} -\addtolength{\headheight}{0.25in} -\addtolength{\textheight}{-0.25in} -\setlength{\headsep}{12pt} -\newcommand*{\headerlogo}[1]{ - \def\@headerlogo{#1} - \savebox{\PS@headerbox}{\includegraphics[width=\textwidth]{\@headerlogo}} - \addtolength{\textheight}{\headheight} - \settoheight{\headheight}{\usebox{\PS@headerbox}} - \addtolength{\headheight}{4.2pt} - \addtolength{\textheight}{-\headheight} -} - - - -% Typesetting sections in a problem - -\renewcommand\section{\@startsection{section}{1}{\z@}% - {-3ex}% - {1ex}% - {\normalfont\large\sf\bfseries}} -\newenvironment{Input}{\section*{Input}}{} -\newenvironment{Output}{\section*{Output}}{} -\newenvironment{Interaction}{\section*{Interaction}}{} - -\renewcommand\subsection{\@startsection{subsection}{2}{\z@}% - {-3.55ex}% - {1.7ex}% - {\normalfont\normalsize\sf\bfseries}} - -\renewcommand{\contentsname}{Problems} - - -% TODO: make last command of illustration optional -\newcommand{\illustration}[3]{ - \begin{wrapfigure}{r}{#1\textwidth} - \includegraphics[width=#1\textwidth]{#2} - \begin{flushright} - \vspace{-9pt} - \tiny #3 - \end{flushright} - \vspace{-15pt} - \end{wrapfigure} - \par - \noindent -} - - -%% Redefine cleardoublepage to put a text on even-numbered empty -%% pages. -\newcommand{\makeemptypage}{ - ~\thispagestyle{empty} - \vfill - \centerline{\Large \textsf{ This page is intentionally left blank.}} - \vfill - \clearpage -} -\renewcommand{\cleardoublepage}{ - \clearpage% - \ifodd\value{page}\else\makeemptypage\fi% -} - -\newcommand{\clearproblemsetpage}{ - \if@clearevenpages - \cleardoublepage - \else - \clearpage - \fi -} - - -%% Set up a problem counter and number problems A B C ... -\newcounter{problemcount} -\setcounter{problemcount}{0} -\newcommand{\problemnumber}{\Alph{problemcount}} - -%% Number figures as A.1 A.2... B.1 B.2... -%% (except if we're converting to HTML or if we're not using problem numbers) -\ifplastex\else -\if@problemnumbers -\renewcommand{\thefigure}{\problemnumber.\arabic{figure}} -\let\p@subfigure=\thefigure -\fi -\fi - - -%% Command for starting new problem - -%% Problem inclusion -\newcommand{\includeproblem}[1]{ - \startproblem{#1} - \import{#1/problem_statement/}{problem\@problemlanguage.tex} - - %% Automatically include samples 1..9, if enabled - \ifplastex\else - \if@autoincludesamples - \foreach \SampleNum in {1,...,9} { - \IfFileExists{\@problemid/data/sample/\SampleNum.interaction}{ - \displaysampleinteraction{\@problemid/data/sample/\SampleNum} - }{\IfFileExists{\@problemid/data/sample/\SampleNum.in}{ - \displaysample{\@problemid/data/sample/\SampleNum} - }{} - } - } - \fi - \fi -} - -\newcommand{\startproblem}[1]{ - \clearproblemsetpage - \refstepcounter{problemcount} - \setcounter{samplenum}{0} - \setcounter{figure}{0}% - \def\@problemid{#1} -} - -\newcommand{\problemname}[1]{ - \def\@problemname{#1} - \problemheader{\@problemname}{\@problemid} -} - -\newcommand{\ps@formattime}[1]{ - #1\ifdim#1in=1in second \else seconds \fi -} - -\newread\ps@timelimitfile -\newcommand{\problemheader}[2]{ - \begin{center} - \textsf{ - \if@problemnumbers {\huge Problem \problemnumber\\[3mm]} \fi - {\LARGE #1} - \if@problemids {\\[2mm]{\Large Problem ID: #2}} \fi - \IfFileExists{#2/.timelimit}{ - \openin\ps@timelimitfile=#2/.timelimit - \read\ps@timelimitfile to\ps@timelimit - \\[2mm]{\Large Time limit:\ps@formattime{\ps@timelimit}} - \closein\ps@timelimitfile - }{} - \\[5mm] - } - \end{center} - \addtocontents{toc}{ - \if@problemnumbers \problemnumber \fi - & \@problemname \\}% -} - -%% Commands related to sample data - -\newcommand{\sampleinputname}{Sample Input} -\newcommand{\sampleoutputname}{Sample Output} -\newcommand{\sampleinteractname}{Sample Interaction} -\newcommand{\sampleinteractreadname}{Read} -\newcommand{\sampleinteractwritename}{Write} - -\newcommand{\formatsampleheader}[1]{\textsf{\textbf{#1}}} - -%% Sample counter -\newcounter{samplenum} -\newcommand{\sampleid}{\arabic{samplenum}} - -%% Define the command used to give sample data -%% Takes filename as parameter -\newcommand{\includesample}[1]{ - \IfFileExists{\@problemid/data/sample/#1.interaction}{ - \displaysampleinteraction{\@problemid/data/sample/#1} - }{ - \IfFileExists{\@problemid/data/sample/#1.in}{ - \displaysample{\@problemid/data/sample/#1} - }{ - \ClassError{problemset}{Can't find any sample named #1}{} - } - } - -} - -\newcommand{\displaysample}[1]{ - \IfFileExists{#1.in}{}{\ClassError{problemset}{Can't find file '#1.in'}{}} - \IfFileExists{#1.ans}{}{\ClassError{problemset}{Can't find file '#1.ans'}{}} - \refstepcounter{samplenum} - \vspace{0.4cm} - \sampletable - {\sampleinputname{} \if@samplenumbers\sampleid\fi}{#1.in} - {\sampleoutputname{} \if@samplenumbers\sampleid\fi}{#1.ans} -} - -\newcommand{\displaysampleinteraction}[1]{ - \IfFileExists{#1.interaction}{}{\ClassError{problemset}{Can't find file '#1.interaction'}{}} - \refstepcounter{samplenum} - \vspace{0.4cm} - \sampletableinteractive{\sampleinteractname{} \if@samplenumbers\sampleid\fi} - {\sampleinteractreadname} - {\sampleinteractwritename} - {#1.interaction} -} - -\newlength{\PS@sampleidealwidth} -\setlength{\PS@sampleidealwidth}{0.473\textwidth} -\newsavebox{\PS@sampleinbox} -\newsavebox{\PS@sampleoutbox} -\newlength{\PS@sampleinwidth} -\newlength{\PS@sampleoutwidth} -\newlength{\PS@sampletotwidth} - -\newcommand{\sampletable}[4]{ - % First find widths of the two files - \savebox{\PS@sampleinbox}{\lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{#2}} - \savebox{\PS@sampleoutbox}{\lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{#4}} - \settowidth{\PS@sampleoutwidth}{\usebox{\PS@sampleoutbox}} - \settowidth{\PS@sampleinwidth}{\usebox{\PS@sampleinbox}} - \setlength{\PS@sampletotwidth}{\PS@sampleinwidth} - \addtolength{\PS@sampletotwidth}{\PS@sampleoutwidth} - % Check if too wide for side-by-side - \ifdim\PS@sampletotwidth>2\PS@sampleidealwidth - \par - \noindent - \begin{tabular}{|l|} - \multicolumn{1}{l}{\formatsampleheader{#1}}\\ - \hline - \parbox[t]{0.968\textwidth}{\vspace{-0.3cm}\usebox{\PS@sampleinbox}}\\ - \hline - \end{tabular} - \par - \vspace{0.25cm} - \noindent - \begin{tabular}{|l|} - \multicolumn{1}{l}{\formatsampleheader{#3}}\\ - \hline - \parbox[t]{0.968\textwidth}{\vspace{-0.3cm}\usebox{\PS@sampleoutbox}}\\ - \hline - \end{tabular} - \else - % Side by side possible, figure out if adjustments are needed. - \ifdim\PS@sampleoutwidth>\PS@sampleidealwidth% Sample out too large - \setlength{\PS@sampleinwidth}{2\PS@sampleidealwidth} - \addtolength{\PS@sampleinwidth}{-\PS@sampleoutwidth} - \else - \ifdim\PS@sampleinwidth>\PS@sampleidealwidth% Sample in too large - \setlength{\PS@sampleoutwidth}{2\PS@sampleidealwidth} - \addtolength{\PS@sampleoutwidth}{-\PS@sampleinwidth} - \else% Ideal case: neither sample in nor sammple out too large - \setlength{\PS@sampleinwidth}{\PS@sampleidealwidth} - \setlength{\PS@sampleoutwidth}{\PS@sampleidealwidth} - \fi - \fi - \par - \noindent - \begin{tabular}{|l|l|} - \multicolumn{1}{l}{\formatsampleheader{#1}} & - \multicolumn{1}{l}{\formatsampleheader{#3}} \\ - \hline - \parbox[t]{\PS@sampleinwidth}{\vspace{-0.3cm}\usebox{\PS@sampleinbox}} - & - \parbox[t]{\PS@sampleoutwidth}{\vspace{-0.3cm}\usebox{\PS@sampleoutbox}} - \\ - \hline - \end{tabular} - \fi - \par -} - -\newread\ps@sampleinteraction -\newwrite\ps@sampleinteractionmsg -\newcommand{\sampletableinteractive}[4]{ - \noindent - \begin{tabular}{p{0.306\textwidth}p{0.306\textwidth}p{0.306\textwidth}} - \formatsampleheader{#2} \hfill & - \centering\formatsampleheader{#1} & - \hfill \formatsampleheader{#3} \\ - \end{tabular} - \begingroup - \openin\ps@sampleinteraction=#4 - \def\curmode{x} - \def\showmessage{ - \if x\curmode\else - \immediate\closeout\ps@sampleinteractionmsg - \vspace{-\parskip} - \vspace{0.05cm} - \par\noindent - \if w\curmode\hfill\fi - \begin{tabular}{|l|} - \hline - \parbox[t]{0.55\textwidth}{ - \vspace{-0.49cm} - \lstinputlisting[inputencoding=utf8/latin1,basicstyle=\ttfamily]{\jobname.pstmp} - \vspace{-0.21cm} - }\\ - \hline - \end{tabular} - \par - \fi - } - \@whilesw\unless\ifeof\ps@sampleinteraction\fi{ - \endlinechar=-1 - \readline\ps@sampleinteraction to\ps@interactionline - \endlinechar=13 - \def\mode{x} - \IfBeginWith{\ps@interactionline}{>}{\def\mode{w}}{} - \IfBeginWith{\ps@interactionline}{<}{\def\mode{r}}{} - \if x\mode\else - \if \mode\curmode\else - \showmessage - \immediate\openout\ps@sampleinteractionmsg=\jobname.pstmp - \edef\curmode{\mode} - \fi - \StrGobbleLeft{\ps@interactionline}{1}[\ps@interactionline] - \immediate\write\ps@sampleinteractionmsg{\ps@interactionline} - \fi - } - \showmessage - \closein\ps@sampleinteraction - \endgroup -} - - -% Remaining part of file is headers and toc, not tested with plasTeX -% and should not be used in plastex mode -\ifplastex\else - -\AtBeginDocument{ - %% Set up headers - \fancypagestyle{problem}{ - \fancyhf{} % Clear old junk - \fancyhead[C]{\usebox{\PS@headerbox}} - \if@footer - \fancyfoot[L]{ - \emph{ - \@contestshortname{} - \ifdefined\@problemname - \if@problemnumbers Problem \problemnumber:{} \fi - \@problemname - \fi - \ifx\@licenseblurb\@empty\relax\else - \\\@licenseblurb - \fi - } - } - \fancyfoot[R]{\thepage} - \fi - } - \renewcommand{\headrulewidth}{0pt} - \pagestyle{problem} - - % Set up table of contents for cover page - \addtocontents{toc}{\protect\begin{tabular}{cl}} -} - -\AtEndDocument{ - \clearproblemsetpage - % Annoyingly enough addtocontents won't work at end of doc - \immediate\write\@auxout{% - \string\@writefile{toc}{\string\end{tabular}}% - } -} - -\fi diff --git a/problemtools/templates/markdown_pdf/fix_tables.md b/problemtools/templates/markdown_pdf/fix_tables.md deleted file mode 100644 index 1b04614f..00000000 --- a/problemtools/templates/markdown_pdf/fix_tables.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -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' ---- From fcda10648556520afa82393a4b80ab4bc2e5c4f5 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 09:17:10 +0200 Subject: [PATCH 46/53] Cleanup --- problemtools/md2html.py | 45 +++++---- problemtools/problem2html.py | 4 +- problemtools/problem2pdf.py | 80 ++++++++------- ...{statement_common.py => statement_util.py} | 99 ++++++++----------- .../script>/problem.yaml" | 1 + .../tests/problems/footnote/problem.yaml | 1 + .../tests/problems/imgrequest/problem.yaml | 1 + .../tests/problems/imgrequest2/problem.yaml | 1 + .../problems/problemnamexss/problem.yaml | 1 + .../tests/problems/samplexss/problem.yaml | 1 + .../specialcharacterssample/problem.yaml | 1 + .../tests/problems/statementxss/problem.yaml | 1 + .../tests/problems/twofootnotes/problem.yaml | 1 + problemtools/verifyproblem.py | 1 - 14 files changed, 121 insertions(+), 117 deletions(-) rename problemtools/{statement_common.py => statement_util.py} (89%) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 1e41beff..253bd3ef 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -3,6 +3,7 @@ import argparse import html import os +from pathlib import Path import re import shutil import string @@ -10,7 +11,7 @@ import nh3 -from . import statement_common +from . import statement_util FOOTNOTES_STRING = '
' @@ -25,7 +26,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: 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", + statement_path = statement_util.find_statement(problem, extension="md", language=options.language) if statement_path is None: @@ -50,18 +51,20 @@ def convert(problem: str, options: argparse.Namespace) -> bool: if templatepath is None: raise FileNotFoundError('Could not find directory with markdown templates') - problem_name = statement_common.get_yaml_problem_name(problem, options.language) + with open(Path(templatepath) / "default-layout.html", "r", encoding="utf-8") as template_file: + template = template_file.read() - statement_html = _substitute_template(templatepath, "default-layout.html", - statement_html=statement_html, - language=options.language, - title=html.escape(problem_name) if problem_name else "Missing problem name", - problemid=html.escape(problembase)) + 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)} - samples = statement_common.format_samples(problem) + statement_html = template % substitution_params + samples = statement_util.format_samples(problem) # Insert samples at {{nextsample}} and {{remainingsamples}} - statement_html, remaining_samples = statement_common.inject_samples(statement_html, samples, "") + statement_html, remaining_samples = statement_util.inject_samples(statement_html, samples, "") # Insert the remaining samples at the bottom # However, footnotes should be below samples @@ -90,6 +93,18 @@ def is_fn_id(s): "sampleinteractionwrite", "sampleinteractionread", "footnotes") + 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): @@ -100,7 +115,7 @@ 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 = statement_common.is_image_valid(problem, value) + fail = is_image_valid(problem, value) if fail: nonlocal image_fail_reason image_fail_reason = fail @@ -138,11 +153,3 @@ def copy_image(problem_root: str, img_src: str) -> None: if os.path.isfile(img_src): # already copied return shutil.copyfile(source_name, img_src) - -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 c396e4a0..1807c2ad 100644 --- a/problemtools/problem2html.py +++ b/problemtools/problem2html.py @@ -8,7 +8,7 @@ from . import tex2html from . import md2html -from . import statement_common +from . import statement_util def convert(options: argparse.Namespace) -> None: problem = os.path.realpath(options.problem) @@ -32,7 +32,7 @@ def convert(options: argparse.Namespace) -> None: origcwd = os.getcwd() - if statement_common.find_statement_extension(problem, options.language) == "tex": + if statement_util.find_statement_extension(problem, options.language) == "tex": tex2html.convert(problem, options) else: md2html.convert(problem, options) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 198e0f82..a268a73d 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -1,21 +1,21 @@ #! /usr/bin/env python3 # -*- coding: utf-8 -*- +import argparse import os.path +import re import shutil import string -import argparse -from pathlib import Path -import re import subprocess import tempfile +from pathlib import Path from . import template -from . import statement_common +from . import statement_util def convert(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) - if statement_common.find_statement_extension(problem_root, language=options.language) == "md": + if statement_util.find_statement_extension(problem_root, language=options.language) == "md": return md2pdf(options) else: return latex2pdf(options) @@ -26,18 +26,18 @@ def md2pdf(options: argparse.Namespace) -> bool: 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) + statement_path = statement_util.find_statement(problem_root, extension="md", language=options.language) if not os.path.isfile(statement_path): raise FileNotFoundError(f"Error! {statement_path} does not exist") - #statement_common.assert_images_are_valid_md(statement_path) + statement_util.assert_images_are_valid_md(statement_path) - # TODO: fix nextsample and remainingsamples - # TODO: better language code - fake_tex = Path(statement_path).parent / "problem.en.tex" - print(f"{fake_tex=} {statement_path=}") - command = ["pandoc", statement_path, "-o", fake_tex] + language = options.language + if not language: + language = "en" + temp_tex_file = Path(statement_path).parent / f"problem.{language}.tex" + command = ["pandoc", statement_path, "-o", temp_tex_file] try: subprocess.run(command, capture_output=True, text=True, shell=False, check=True @@ -45,9 +45,9 @@ def md2pdf(options: argparse.Namespace) -> bool: except subprocess.CalledProcessError as e: print(f"Error compiling Markdown to pdf: {e.stderr}") return False - + try: - with open(fake_tex, "r") as f: + with open(temp_tex_file, "r", encoding="utf-8") as f: tex = f.read() def format_latex_tables(latex_doc): @@ -58,48 +58,56 @@ def format_latex_tables(latex_doc): ([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 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") - - problem_name = statement_common.get_yaml_problem_name(problem_root, options.language) + + # 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 = '\\problemname{' + problem_name + '}\n' + tex - with open(fake_tex, "w") as f: - f.write(tex) - with open("SOGS.tex", "w") as f: + with open(temp_tex_file, "w", encoding="utf-8") as f: f.write(tex) - print("RENDERING!!") - latex2pdf(options) - except Exception as e: - print(f"{e}") + + status = latex2pdf(options) + if status != 0: + return status finally: - fake_tex.unlink() + temp_tex_file.unlink() - # with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: - # command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", - # "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] - # subprocess.run(command, capture_output=True, - # text=True, shell=False, check=True - # ) - # shutil.copy(f.name, destfile) + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: + command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", + "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] + status = subprocess.run(command, capture_output=True, + text=True, shell=False, check=True + ) + shutil.copy(f.name, destfile) + except subprocess.CalledProcessError as e: + print(f"Error sanitizing PDF: {e} {e.stderr}") + raise - return False + return status == 0 def latex2pdf(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) diff --git a/problemtools/statement_common.py b/problemtools/statement_util.py similarity index 89% rename from problemtools/statement_common.py rename to problemtools/statement_util.py index 450ccd21..28e1b44a 100644 --- a/problemtools/statement_common.py +++ b/problemtools/statement_util.py @@ -17,7 +17,7 @@ def find_statement(problem_root: str, extension: str, language: Optional[str]) -> Optional[str]: """Finds the "best" statement for given language and extension""" statement_dir = Path(problem_root) / formatversion.get_format_data(problem_root).statement_directory - + candidates = [] if language is None: candidates = [ @@ -33,7 +33,6 @@ def find_statement(problem_root: str, extension: str, language: Optional[str]) - return None - def find_statement_extension(problem_root: str, language: Optional[str]) -> str: """Given a language, find whether the extension is tex or md @@ -52,6 +51,36 @@ def find_statement_extension(problem_root: str, language: Optional[str]) -> str: return extensions[0] raise FileNotFoundError(f"No statement found for language {language or 'en'}") +def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str]: + + # TODO: getting this should be done using verifyproblem + # Wait until new config parsing system is in place + config_file = Path(problem) / 'problem.yaml' + + if not config_file.is_file(): + raise FileNotFoundError("No problem.yaml found") + + try: + with open(config_file, "r", encoding="utf-8") as f: + config = yaml.safe_load(f) + if config is None: + config = {} + except Exception as e: + raise ValueError(f"Invalid problem.yaml: {e}") from e + + if 'name' in config and not isinstance(config['name'], dict): + config['name'] = {'': config['name']} + + 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 is None: + language = "en" + if language not in names: + raise ValueError(f"No problem name defined for language {language or 'en'}") + return names[language] def json_dfs(data, callback) -> None: """Traverse all items in a JSON tree, find all images, and call callback for each one""" @@ -67,80 +96,36 @@ def json_dfs(data, callback) -> None: for item in data: json_dfs(item, callback) - def foreach_image(statement_path, callback): - # Find all images in the statement and call callback for each one + """ Find all images in the statement and call callback for each one """ command = ["pandoc", statement_path, "-t" , "json"] # Must create a working directory for pytest to work - with tempfile.TemporaryDirectory() as dir: + with tempfile.TemporaryDirectory() as work_dir: statement_json = subprocess.run(command, capture_output=True, text=True, - shell=False, check=True, cwd=dir).stdout + shell=False, check=True, cwd=work_dir).stdout json_dfs(json.loads(statement_json), callback) -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 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 - def assert_image_is_valid(problem_root: str, img_src: str) -> str|None: - # Check that the image exists and uses an allowed extension + """ 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 ALLOWED_IMAGE_EXTENSIONS: # ".svg" raise ValueError(f"Unsupported image extension {extension} for image {img_src}") - source_file = Path(problem_root) / "statement" / img_src + source_file = Path(problem_root) / img_src if not source_file.exists(): raise FileNotFoundError(f"Resource file {img_src} not found in statement") - def assert_images_are_valid_md(statement_path: str) -> None: - # Find all images in the statement and assert that they exist and - # use valid image extensions + """ Find all images in the statement and assert that they exist and + use valid image extensions + + """ problem_root = os.path.dirname(statement_path) foreach_image(statement_path, lambda img_name: assert_image_is_valid(problem_root, img_name)) -def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str]: - - # TODO: getting this should be done using verifyproblem - # Wait until new config parsing system is in place - config_file = Path(problem) / 'problem.yaml' - - if not config_file.is_file(): - raise FileNotFoundError("No problem.yaml found") - - try: - with open(config_file, "r", encoding="utf-8") as f: - config = yaml.safe_load(f) - if config is None: - config = {} - except Exception as e: - raise ValueError(f"Invalid problem.yaml: {e}") from e - - if 'name' in config and not isinstance(config['name'], dict): - config['name'] = {'': config['name']} - - 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 is None: - language = "en" - if language not in names: - raise ValueError(f"No problem name defined for language {language or 'en'}") - return names[language] - - def inject_samples(statement_html, samples, sample_separator): """Injects samples at occurences of {{nextsample}} and {{remainingsamples}} Non-destructive, returns the new html and all left-over samples @@ -167,13 +152,11 @@ def inject_samples(statement_html, samples, sample_separator): return statement_html, samples - def format_samples(problem_root: str) -> 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 @@ -237,7 +220,6 @@ def format_normal_sample(sample_root: str, sample: str, casenum: int) -> str: """ % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)}) - def format_interactive_sample(sample_root: str, sample: str, casenum: int) -> str: """ @@ -245,7 +227,6 @@ def format_interactive_sample(sample_root: str, sample: str, casenum: int) -> st 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 diff --git "a/problemtools/tests/problems///problem.yaml" "b/problemtools/tests/problems///problem.yaml" index 97d008eb..81f4b166 100644 --- "a/problemtools/tests/problems///problem.yaml" +++ "b/problemtools/tests/problems///problem.yaml" @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Problem ID xss diff --git a/problemtools/tests/problems/footnote/problem.yaml b/problemtools/tests/problems/footnote/problem.yaml index e36e455a..90adb3d4 100644 --- a/problemtools/tests/problems/footnote/problem.yaml +++ b/problemtools/tests/problems/footnote/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Footnote Test diff --git a/problemtools/tests/problems/imgrequest/problem.yaml b/problemtools/tests/problems/imgrequest/problem.yaml index 57f37816..10ac351a 100644 --- a/problemtools/tests/problems/imgrequest/problem.yaml +++ b/problemtools/tests/problems/imgrequest/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Make web request via image diff --git a/problemtools/tests/problems/imgrequest2/problem.yaml b/problemtools/tests/problems/imgrequest2/problem.yaml index 57f37816..10ac351a 100644 --- a/problemtools/tests/problems/imgrequest2/problem.yaml +++ b/problemtools/tests/problems/imgrequest2/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Make web request via image diff --git a/problemtools/tests/problems/problemnamexss/problem.yaml b/problemtools/tests/problems/problemnamexss/problem.yaml index 2f3393a7..cad13b0b 100644 --- a/problemtools/tests/problems/problemnamexss/problem.yaml +++ b/problemtools/tests/problems/problemnamexss/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: diff --git a/problemtools/tests/problems/samplexss/problem.yaml b/problemtools/tests/problems/samplexss/problem.yaml index 54e6792a..5f9a55fb 100644 --- a/problemtools/tests/problems/samplexss/problem.yaml +++ b/problemtools/tests/problems/samplexss/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Sample XSS diff --git a/problemtools/tests/problems/specialcharacterssample/problem.yaml b/problemtools/tests/problems/specialcharacterssample/problem.yaml index 10e3241d..21125ad7 100644 --- a/problemtools/tests/problems/specialcharacterssample/problem.yaml +++ b/problemtools/tests/problems/specialcharacterssample/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Special Characters Sample diff --git a/problemtools/tests/problems/statementxss/problem.yaml b/problemtools/tests/problems/statementxss/problem.yaml index adbc951a..a728c041 100644 --- a/problemtools/tests/problems/statementxss/problem.yaml +++ b/problemtools/tests/problems/statementxss/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: XSS diff --git a/problemtools/tests/problems/twofootnotes/problem.yaml b/problemtools/tests/problems/twofootnotes/problem.yaml index e936cbc6..e8c5ca31 100644 --- a/problemtools/tests/problems/twofootnotes/problem.yaml +++ b/problemtools/tests/problems/twofootnotes/problem.yaml @@ -1 +1,2 @@ +problem_format_version: 2023-07 name: Footnote Test 2 diff --git a/problemtools/verifyproblem.py b/problemtools/verifyproblem.py index 3c5abbdb..8859ea20 100644 --- a/problemtools/verifyproblem.py +++ b/problemtools/verifyproblem.py @@ -28,7 +28,6 @@ from . import problem2pdf from . import problem2html -from . import statement_common from . import formatversion from . import config From 47bda29ee94ea46bf79d822abd9eff9be44653c2 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 09:22:54 +0200 Subject: [PATCH 47/53] librsvg out of focus for this PR --- Dockerfile | 1 - README.md | 4 ++-- admin/docker/Dockerfile.build | 1 - admin/docker/Dockerfile.full | 1 - admin/docker/Dockerfile.minimal | 1 - 5 files changed, 2 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index ac91a281..2c3b4c57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,6 @@ RUN apt-get update && \ libgmp-dev \ libgmp10 \ libgmpxx4ldbl \ - librsvg2-bin \ openjdk-8-jdk \ pandoc \ python3-minimal \ diff --git a/README.md b/README.md index 4572f138..84494e09 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 librsvg2-bin pandoc 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 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++ librsvg 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 + 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: diff --git a/admin/docker/Dockerfile.build b/admin/docker/Dockerfile.build index a6c124fa..e7041fb9 100644 --- a/admin/docker/Dockerfile.build +++ b/admin/docker/Dockerfile.build @@ -25,7 +25,6 @@ RUN apt update && \ libgmp-dev \ libgmp10 \ libgmpxx4ldbl \ - librsvg2-bin \ pandoc \ python3 \ python3-pytest \ diff --git a/admin/docker/Dockerfile.full b/admin/docker/Dockerfile.full index 036a930a..9fb5196a 100644 --- a/admin/docker/Dockerfile.full +++ b/admin/docker/Dockerfile.full @@ -19,7 +19,6 @@ RUN apt-get update && \ gnustep-devel gnustep gnustep-make gnustep-common gobjc \ libgmp3-dev \ libmozjs-78-dev \ - librsvg2-bin \ lua5.4 \ mono-complete \ nodejs \ diff --git a/admin/docker/Dockerfile.minimal b/admin/docker/Dockerfile.minimal index 45881e5d..886d1a2d 100644 --- a/admin/docker/Dockerfile.minimal +++ b/admin/docker/Dockerfile.minimal @@ -20,7 +20,6 @@ RUN apt update && \ apt install -y \ ghostscript \ libgmpxx4ldbl \ - librsvg2-bin \ pandoc \ python-pkg-resources \ python3-minimal \ From 054448e3fedb452ab7514fe4fe34b45e09afd8cc Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 09:28:27 +0200 Subject: [PATCH 48/53] Ensure nh3 --- Dockerfile | 1 + README.md | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2c3b4c57..02a400a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN apt-get update && \ openjdk-8-jdk \ pandoc \ python3-minimal \ + python-nh3 \ python3-pip \ python3-plastex \ python3-yaml \ diff --git a/README.md b/README.md index 84494e09..e490f67e 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ 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 pandoc 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-nh3 python-pkg-resources python3-plastex python3-yaml texlive-fonts-recommended texlive-lang-cyrillic texlive-latex-extra texlive-plain-generic tidy ### Fedora @@ -217,7 +217,7 @@ On Fedora, these dependencies can be installed with: 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). From ecdb6c4e0995b50fa78bd42fbc053fabb6763250 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Wed, 9 Apr 2025 09:40:30 +0200 Subject: [PATCH 49/53] Remove ghostscript sanitization. If it wasn't used before, it probably isn't needed --- problemtools/problem2pdf.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index a268a73d..b0a53cd0 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -95,18 +95,6 @@ def replacer(match): finally: temp_tex_file.unlink() - try: - with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as f: - command = ["gs", "-q", "-dBATCH", "-sDEVICE=pdfwrite", "-dNOPAUSE", - "-dCompatibilityLevel=1.7", f"-sOutputFile={f.name}", destfile] - status = subprocess.run(command, capture_output=True, - text=True, shell=False, check=True - ) - shutil.copy(f.name, destfile) - except subprocess.CalledProcessError as e: - print(f"Error sanitizing PDF: {e} {e.stderr}") - raise - return status == 0 def latex2pdf(options: argparse.Namespace) -> bool: From 690215f1e81987b6cd756bd653e89baf18d187ce Mon Sep 17 00:00:00 2001 From: Matistjati Date: Sun, 13 Apr 2025 03:34:25 +0200 Subject: [PATCH 50/53] Add nh3 to deb build --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 717a9b53..1d39a4a9 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: kattis-problemtools Section: devel Priority: optional Maintainer: Per Austrin -Build-Depends: debhelper (>= 8.0.0), g++ (>= 4.8), dh-python, python3, python3-setuptools, python3-pytest, python3-yaml, python3-setuptools, python3-pytest, libboost-regex-dev, libgmp-dev, automake, autoconf +Build-Depends: debhelper (>= 8.0.0), g++ (>= 4.8), dh-python, python3, python3-setuptools, python3-pytest, python3-yaml, python3-setuptools, python3-pytest, python3-nh3, libboost-regex-dev, libgmp-dev, automake, autoconf Standards-Version: 3.9.4 Homepage: https://github.com/Kattis/problemtools From 77cb2c99214e217ba2873c908df56291e89044e7 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Sun, 13 Apr 2025 05:59:40 +0200 Subject: [PATCH 51/53] Linting --- problemtools/md2html.py | 29 ++++++++++++++++------------- problemtools/problem2pdf.py | 20 +++++++++----------- problemtools/statement_util.py | 34 +++++++++++++++++++++++----------- problemtools/tex2html.py | 1 - 4 files changed, 48 insertions(+), 36 deletions(-) diff --git a/problemtools/md2html.py b/problemtools/md2html.py index 253bd3ef..aff5e5fb 100644 --- a/problemtools/md2html.py +++ b/problemtools/md2html.py @@ -16,6 +16,7 @@ FOOTNOTES_STRING = '
' + def convert(problem: str, options: argparse.Namespace) -> bool: """Convert a Markdown statement to HTML @@ -27,7 +28,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: destfile = string.Template(options.destfile).safe_substitute(problem=problembase) statement_path = statement_util.find_statement(problem, extension="md", - language=options.language) + language=options.language) if statement_path is None: raise FileNotFoundError('No markdown statement found') @@ -35,10 +36,9 @@ def convert(problem: str, options: argparse.Namespace) -> bool: 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", statement_path, "-t", "html", "--mathjax"] statement_html = subprocess.run(command, capture_output=True, text=True, - shell=False, check=True).stdout + shell=False, check=True).stdout statement_html = sanitize_html(problem, statement_html) @@ -56,15 +56,15 @@ def convert(problem: str, options: argparse.Namespace) -> bool: 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)} + "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, "") + statement_html, remaining_samples = statement_util.inject_samples(statement_html, samples) # Insert the remaining samples at the bottom # However, footnotes should be below samples @@ -82,6 +82,7 @@ def convert(problem: str, options: argparse.Namespace) -> bool: return True + def sanitize_html(problem: str, statement_html: str): # Allow footnote ids (the anchor points you jump to) def is_fn_id(s): @@ -90,10 +91,10 @@ def is_fn_id(s): return bool(re.fullmatch(pattern_id_top, s)) or bool(re.fullmatch(pattern_id_bottom, s)) allowed_classes = ("sample", "problemheader", "problembody", - "sampleinteractionwrite", "sampleinteractionread", - "footnotes") + "sampleinteractionwrite", "sampleinteractionread", + "footnotes") - def is_image_valid(problem_root: str, img_src: str) -> str|None: + 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 @@ -106,7 +107,8 @@ def is_image_valid(problem_root: str, img_src: str) -> str|None: return None # Annoying: nh3 will ignore exceptions in attribute_filter - image_fail_reason: str|None = None + image_fail_reason: str | None = None + def attribute_filter(tag, attribute, value): if attribute == "class" and value in allowed_classes: return value @@ -140,6 +142,7 @@ def attribute_filter(tag, attribute, value): return statement_html + def copy_image(problem_root: str, img_src: str) -> None: """Copy image to output directory @@ -150,6 +153,6 @@ def copy_image(problem_root: str, img_src: str) -> None: source_name = os.path.join(problem_root, "statement", img_src) - if os.path.isfile(img_src): # already copied + if os.path.isfile(img_src): # already copied return shutil.copyfile(source_name, img_src) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index b0a53cd0..85250fbb 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -12,6 +12,7 @@ from . import template from . import statement_util + def convert(options: argparse.Namespace) -> bool: problem_root = os.path.realpath(options.problem) @@ -23,12 +24,9 @@ def convert(options: argparse.Namespace) -> bool: 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_util.find_statement(problem_root, extension="md", language=options.language) - if not os.path.isfile(statement_path): + 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) @@ -37,11 +35,11 @@ def md2pdf(options: argparse.Namespace) -> bool: if not language: language = "en" temp_tex_file = Path(statement_path).parent / f"problem.{language}.tex" - command = ["pandoc", statement_path, "-o", temp_tex_file] + command = ["pandoc", statement_path, "-o", str(temp_tex_file)] try: subprocess.run(command, capture_output=True, - text=True, shell=False, check=True - ) + text=True, shell=False, check=True + ) except subprocess.CalledProcessError as e: print(f"Error compiling Markdown to pdf: {e.stderr}") return False @@ -51,7 +49,7 @@ def md2pdf(options: argparse.Namespace) -> bool: tex = f.read() def format_latex_tables(latex_doc): - # Match table environments with column specs between @{...@{}} + # Match table environments produced by pandoc pattern = r''' (\\begin\{longtable\}\[\]\{@\{\}) ([a-z]) @@ -85,18 +83,19 @@ def replacer(match): tex = tex.replace(r"\{\{remainingsamples\}\}", r"\remainingsamples") problem_name = statement_util.get_yaml_problem_name(problem_root, options.language) - tex = '\\problemname{' + problem_name + '}\n' + tex + 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 status + 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] @@ -117,7 +116,6 @@ def latex2pdf(options: argparse.Namespace) -> bool: params.append('-draftmode') params.append(texfile) - print(texfile) status = subprocess.call(params, stdout=output) if status == 0: diff --git a/problemtools/statement_util.py b/problemtools/statement_util.py index 28e1b44a..3beab3d8 100644 --- a/problemtools/statement_util.py +++ b/problemtools/statement_util.py @@ -1,5 +1,5 @@ import os -from typing import Optional, List +from typing import Optional, List, Tuple import html import json import re @@ -12,7 +12,8 @@ from . import formatversion SUPPORTED_EXTENSIONS = ("tex", "md") -ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg") # ".svg" +ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg") # ".svg" + def find_statement(problem_root: str, extension: str, language: Optional[str]) -> Optional[str]: """Finds the "best" statement for given language and extension""" @@ -33,6 +34,7 @@ def find_statement(problem_root: str, extension: str, language: Optional[str]) - return None + def find_statement_extension(problem_root: str, language: Optional[str]) -> str: """Given a language, find whether the extension is tex or md @@ -51,8 +53,9 @@ def find_statement_extension(problem_root: str, language: Optional[str]) -> str: return extensions[0] raise FileNotFoundError(f"No statement found for language {language or 'en'}") -def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str]: +def get_yaml_problem_name(problem: str, language: Optional[str]) -> str: + """Finds the problem name from the problem.yaml file""" # TODO: getting this should be done using verifyproblem # Wait until new config parsing system is in place config_file = Path(problem) / 'problem.yaml' @@ -82,6 +85,7 @@ def get_yaml_problem_name(problem: str, language: Optional[str]) -> Optional[str raise ValueError(f"No problem name defined for language {language or 'en'}") return names[language] + 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): @@ -96,9 +100,10 @@ def json_dfs(data, callback) -> None: for item in data: json_dfs(item, callback) + def foreach_image(statement_path, callback): """ Find all images in the statement and call callback for each one """ - command = ["pandoc", statement_path, "-t" , "json"] + command = ["pandoc", statement_path, "-t", "json"] # Must create a working directory for pytest to work with tempfile.TemporaryDirectory() as work_dir: statement_json = subprocess.run(command, capture_output=True, text=True, @@ -106,17 +111,19 @@ def foreach_image(statement_path, callback): json_dfs(json.loads(statement_json), callback) -def assert_image_is_valid(problem_root: str, img_src: str) -> str|None: + +def assert_image_is_valid(problem_root: str, img_src: 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 ALLOWED_IMAGE_EXTENSIONS: # ".svg" + if extension not in ALLOWED_IMAGE_EXTENSIONS: raise ValueError(f"Unsupported image extension {extension} for image {img_src}") source_file = Path(problem_root) / img_src if not source_file.exists(): raise FileNotFoundError(f"Resource file {img_src} not found in statement") + def assert_images_are_valid_md(statement_path: str) -> None: """ Find all images in the statement and assert that they exist and use valid image extensions @@ -124,14 +131,16 @@ def assert_images_are_valid_md(statement_path: str) -> None: """ problem_root = os.path.dirname(statement_path) foreach_image(statement_path, - lambda img_name: assert_image_is_valid(problem_root, img_name)) + lambda img_name: assert_image_is_valid(problem_root, img_name)) -def inject_samples(statement_html, samples, sample_separator): + +def inject_samples(statement_html: str, samples: List[str]) -> Tuple[str, List[str]]: """Injects samples at occurences of {{nextsample}} and {{remainingsamples}} - Non-destructive, returns the new html and all left-over samples + Non-destructive Returns: - """ + Statement with samples inject and left-over samples. + """ while True: match = re.search(r'\{\{(nextsample|remainingsamples)\}\}', statement_html) @@ -142,7 +151,7 @@ def inject_samples(statement_html, samples, sample_separator): raise ValueError("Error: called {{nextsample}} without any samples left") num_inject = 1 if matched_text == "nextsample" else len(samples) - to_inject = sample_separator.join(samples[:num_inject]) + to_inject = "".join(samples[:num_inject]) samples = samples[num_inject:] # Always inject, even if to_inject is empty @@ -152,6 +161,7 @@ def inject_samples(statement_html, samples, sample_separator): return statement_html, samples + def format_samples(problem_root: str) -> List[str]: """Read all samples from the problem directory and convert them to pandoc-valid markdown @@ -186,6 +196,7 @@ def format_samples(problem_root: str) -> List[str]: return samples + def format_normal_sample(sample_root: str, sample: str, casenum: int) -> str: """ @@ -220,6 +231,7 @@ def format_normal_sample(sample_root: str, sample: str, casenum: int) -> str: """ % ({"case": casenum, "input": html.escape(sample_input), "output": html.escape(sample_output)}) + def format_interactive_sample(sample_root: str, sample: str, casenum: int) -> str: """ diff --git a/problemtools/tex2html.py b/problemtools/tex2html.py index 49c88c78..598b9a89 100644 --- a/problemtools/tex2html.py +++ b/problemtools/tex2html.py @@ -23,7 +23,6 @@ def convert(problem: str, options: argparse.Namespace) -> None: 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') From 2e7653fcadf5b01b7b5236f6717d1275508c3132 Mon Sep 17 00:00:00 2001 From: Matistjati Date: Sun, 13 Apr 2025 06:04:15 +0200 Subject: [PATCH 52/53] Add back ghostscript sanitization --- problemtools/problem2pdf.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/problemtools/problem2pdf.py b/problemtools/problem2pdf.py index 85250fbb..4e687fac 100644 --- a/problemtools/problem2pdf.py +++ b/problemtools/problem2pdf.py @@ -23,6 +23,9 @@ def convert(options: argparse.Namespace) -> bool: 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) @@ -129,7 +132,24 @@ def latex2pdf(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 + + 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: + 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: From 51f5539adf3c422cb3d66ecfa272f9b774134d0b Mon Sep 17 00:00:00 2001 From: Matistjati Date: Sun, 13 Apr 2025 06:19:02 +0200 Subject: [PATCH 53/53] Remove unnecessary test --- .../script>/problem.yaml" | 2 -- .../script>/statement/problem.md" | 1 - problemtools/tests/test_xss.py | 7 ------- 3 files changed, 10 deletions(-) delete mode 100644 "problemtools/tests/problems///problem.yaml" delete mode 100644 "problemtools/tests/problems///statement/problem.md" diff --git "a/problemtools/tests/problems///problem.yaml" "b/problemtools/tests/problems///problem.yaml" deleted file mode 100644 index 81f4b166..00000000 --- "a/problemtools/tests/problems///problem.yaml" +++ /dev/null @@ -1,2 +0,0 @@ -problem_format_version: 2023-07 -name: Problem ID xss diff --git "a/problemtools/tests/problems///statement/problem.md" "b/problemtools/tests/problems///statement/problem.md" deleted file mode 100644 index 8b137891..00000000 --- "a/problemtools/tests/problems///statement/problem.md" +++ /dev/null @@ -1 +0,0 @@ - diff --git a/problemtools/tests/test_xss.py b/problemtools/tests/test_xss.py index aafc561b..e45c6c1d 100644 --- a/problemtools/tests/test_xss.py +++ b/problemtools/tests/test_xss.py @@ -32,10 +32,3 @@ def test_no_xss_sample(): problem_path = Path(__file__).parent / "problems" / "samplexss" html = render(problem_path) assert "