diff --git a/.gitignore b/.gitignore index f58861c..cb0c118 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ out/ .DS_Store .idea/ .vscode/ +.env diff --git a/README.md b/README.md index 7fbefd8..8d10752 100644 --- a/README.md +++ b/README.md @@ -9,87 +9,81 @@ TestMagick 대표 이미지

-TestMagick는 구조화된 문제 파일에서 문제지와 정답지를 자동으로 생성하는 로컬 CLI 도구입니다. -입력 데이터를 Typst 문서로 렌더링한 뒤 PDF로 컴파일하여, 반복적인 시험 대비 문서 작업을 빠르게 처리할 수 있습니다. - ## TestMagick란? -TestMagick는 개인 학습/출제 워크플로우를 위해 설계되었습니다. - -- 웹 서버 배포 없이 로컬에서 바로 실행 -- 문제 데이터를 한 번에 정리하여 반복 출력 -- 수식이나 코드가 포함된 문제 깔끔하게 렌더링 +TestMagick는 구조화된 문제 파일(YAML/JSON)에서 문제지와 정답지를 자동으로 생성하는 로컬 CLI 도구입니다. +LLM(Claude, GPT 등)과 협업하여 기존 시험지 PDF를 데이터로 변환하고, 이를 Typst 기반으로 조판하여 컴파일합니다. ## 기능 및 특징 TestMagick는 다음 기능을 제공합니다. -- [Pydantic](https://docs.pydantic.dev/) 기반 입력 스키마 검증 -- YAML/JSON 입력 파일 로딩 -- Typst 템플릿 렌더링 -- 수식 렌더링 -- 코드 블록 렌더링 +- **PDF 파일 전처리 (`preprocess`)**: PDF를 분석하여 텍스트/이미지를 분리 추출하거나 Markdown(LaTeX)으로 변환해 토큰 비용을 최소화 +- **LLM 피드백 루프 (`validate --expect`)**: LLM이 생성한 YAML의 문제 누락을 자동 감지하고 재학습용 교정 프롬프트 생성 +- **복합 문항 지원**: `subproblems`(소문제), `question_blocks`(수식, 표, 그래프, 조건 박스 등) 스키마 지원 +- **Typst 조판 렌더링 (`build`)**: 구조화된 데이터를 기반으로 시험지/해설지 고품질 PDF 일괄 생성 ## 설치 사전 준비: 1. Python 3.11 이상 -2. Typst CLI +2. [Typst CLI](https://typst.app/docs/installation/) (PDF 컴파일용) ```bash python -m venv .venv source .venv/bin/activate pip install -e ".[dev]" -``` - -## 입력 형식 - -입력 파일은 YAML 또는 JSON을 사용할 수 있습니다. - -참고 예시: - -- `./data/sample_problems.yaml` - -다음을 포함해야 합니다: -- `mcq`의 `answer`는 1부터 시작하는 인덱스(예: `2`) 또는 선택지 텍스트 -- `short` 문제는 `choices`를 포함하면 안 됨 -- 수식/코드 렌더링은 `question_typst`, `choices_typst`, `answer_typst` 사용 +# PDF 전처리 기능을 사용하려면 추가 패키지 설치 +pip install -e ".[preprocess]" -## 사용 방법 +# Markdown 변환(marker-pdf) 기능을 사용하려면 추가 패키지 설치 +pip install -e ".[markdown]" +``` -빠른 실행: +## 워크플로우 및 사용 방법 +### 1. PDF 전처리 (LLM 입력용 변환) +원본 시험지 PDF를 LLM에 전달하기 좋은 형태로 최적화합니다. ```bash -./run +python -m testmagick preprocess input.pdf --out preprocessed/ ``` +- `preprocessed/` 폴더에 LLM 프롬프트 가이드(`prompt.md`), 스키마 요약(`schema_ref.md`), 그리고 텍스트/이미지 파일이 생성됩니다. +- 옵션: `--method` (`auto`, `mixed`, `text`, `images`, `markdown`) -검증만: - +### 2. 검증 (Validation 및 자동 교정) +LLM이 작성한 YAML 파일의 문법과 구조를 검사합니다. ```bash -./run validate --input data/sample_problems.yaml +python -m testmagick validate --input data/exam.yaml ``` - -PDF 생성: - +**누락 검사 및 피드백 프롬프트 생성:** +문제가 길어 LLM이 소문제를 누락했는지 확인하려면 `--expect` 옵션을 사용하세요. ```bash -./run build --input data/sample_problems.yaml --out out +python -m testmagick validate --input data/exam.yaml --expect "1:5, 7:8" ``` +기대 개수(예: 1번 문항 5개, 7번 문항 8개)와 다를 경우, LLM에게 다시 복사해 넣을 수 있는 **교정용 프롬프트**를 자동 출력합니다. -모듈 직접 실행: - +### 3. PDF 빌드 (Typst 컴파일) +검증된 YAML 데이터를 바탕으로 Typst 문서를 렌더링하고 PDF로 추출합니다. ```bash -python -m testmagick validate --input data/sample_problems.yaml -python -m testmagick build --input data/sample_problems.yaml --out out +python -m testmagick build --input data/exam.yaml --out out/ ``` +## 입력 형식 (YAML 스키마) + +TestMagick은 유연하고 강력한 스키마를 제공합니다. 자세한 내용은 전처리 시 생성되는 `schema_ref.md`를 참고하세요. + +**주요 특징:** +- `mcq`(객관식), `short`(주관식) 타입 지원 +- 인라인 수식은 `$ ... $`와 함께 `question_typst` 등에 작성 +- `question_blocks`를 통해 가운데 정렬 수식(`formula`), 표(`table`), 그래프(`graph`) 삽입 가능 +- 주관식 문항은 `subproblems` 배열을 통해 여러 개의 소문제를 포함 가능 + ## 출력 결과 -기본적으로 `out/` 디렉터리에 아래 파일이 생성됩니다. +`build` 명령을 실행하면 지정한 출력 폴더(`out/`)에 다음 파일들이 생성됩니다. -- `exam.typ` -- `answer.typ` -- `exam.pdf` -- `answer.pdf` -- `package.zip` +- `exam.typ` / `answer.typ` (생성된 Typst 소스 코드) +- `exam.pdf` / `answer.pdf` (최종 컴파일된 시험지 및 정답지 PDF) +- `package.zip` (모든 산출물이 압축된 아카이브, `--no-zip`으로 비활성화 가능) diff --git a/data/showcase.yaml b/data/showcase.yaml new file mode 100644 index 0000000..a87c4e5 --- /dev/null +++ b/data/showcase.yaml @@ -0,0 +1,307 @@ +title: "TestMagick 기능 종합 예시" +course: "전 기능 시연용 문제지" +date: "2026-03-17" + +problems: + + # ────────────────────────────────────────────── + # 1. MCQ — 일반 텍스트 question + choices + # ────────────────────────────────────────────── + - id: "1" + type: mcq + question: "다음 중 파이썬(Python)의 특징으로 옳지 않은 것은?" + choices: + - "인터프리터 언어이다." + - "정적 타이핑을 기본으로 사용한다." + - "객체지향 프로그래밍을 지원한다." + - "들여쓰기로 코드 블록을 구분한다." + - "풍부한 표준 라이브러리를 제공한다." + answer: 2 + points: 2 + source: "기본 MCQ 예시" + + # ────────────────────────────────────────────── + # 2. MCQ — question_typst (수식 포함) + # ────────────────────────────────────────────── + - id: "2" + type: mcq + question_typst: | + 방정식 $x^2 - 5x + 6 = 0$의 두 근의 합은? + choices_typst: + - "$3$" + - "$5$" + - "$6$" + - "$-5$" + - "$-6$" + answer: 2 + points: 2 + source: "question_typst + choices_typst 예시" + + # ────────────────────────────────────────────── + # 3. MCQ — formula 블록 + requirements 블록 + # ────────────────────────────────────────────── + - id: "3" + type: mcq + question_typst: "등차수열 {$a_n$}에서 $a_1 = 2$, 공차 $d = 3$일 때, 처음 10항의 합 $S_(10)$은?" + question_blocks: + - type: formula + content: "S_n = n/2 (2a_1 + (n-1)d)" + - type: requirements + content: "단, n은 자연수이다." + choices: ["125점", "155점", "170점", "185점", "200점"] + answer: 2 + points: 3 + source: "formula + requirements 블록 예시" + + # ────────────────────────────────────────────── + # 4. MCQ — table 블록 (수식 셀 포함) + # ────────────────────────────────────────────── + - id: "4" + type: mcq + question: "아래 표에서 2023년 영업이익률(영업이익 ÷ 매출 × 100)에 가장 가까운 것은?" + question_blocks: + - type: table + headers: ["구분", "2022년", "2023년"] + rows: + - ["매출(억원)", "1,200", "1,500"] + - ["영업이익(억원)", "150", "200"] + - ["#[이익률(%) $approx$]", "#[$150/1200 times 100$]", "#[$200/1500 times 100$]"] + choices: ["10%", "13%", "15%", "18%", "20%"] + answer: 2 + points: 2 + source: "table 블록 + 수식 셀 예시" + + # ────────────────────────────────────────────── + # 5. MCQ — answer_typst (번호 유지 + 해설) + # ────────────────────────────────────────────── + - id: "5" + type: mcq + question: "다음 중 그래프에서 오일러 경로(Eulerian Path)가 존재하기 위한 조건은?" + choices: + - "모든 정점의 차수가 짝수이다." + - "홀수 차수를 가진 정점이 정확히 2개이다." + - "정점의 개수가 간선의 개수보다 많다." + - "그래프가 완전 그래프이다." + answer: 2 + points: 2 + answer_typst: | + 홀수 차수 정점이 *정확히 2개*일 때 오일러 경로(시작점 ≠ 끝점)가 존재한다. + 모든 정점의 차수가 짝수이면 오일러 _회로_(시작점 = 끝점)가 존재한다. + source: "answer_typst (객관식 해설) 예시" + + # ────────────────────────────────────────────── + # 6. Short — typst 블록 (코드 블록) + # ────────────────────────────────────────────── + - id: "6" + type: short + question: "아래 파이썬 코드의 출력 결과를 쓰시오." + question_blocks: + - type: typst + content: | + #raw(block: true, lang: "python", "def fib(n): + if n <= 1: + return n + return fib(n-1) + fib(n-2) + + for i in range(6): + print(fib(i), end=' ')") + answer: "0 1 1 2 3 5" + points: 3 + source: "typst 블록 + raw 코드 예시" + + # ────────────────────────────────────────────── + # 7. Short — graph 블록 (directed, undirected, bidirectional, bend, label, self-loop) + # ────────────────────────────────────────────── + - id: "7" + type: short + question: "아래 그래프에서 정점 A에서 정점 D까지의 최단 경로와 그 거리를 구하시오." + question_blocks: + - type: graph + nodes: + - id: "A" + label: "$A$" + pos: [0, 0] + - id: "B" + label: "$B$" + pos: [3, 2] + - id: "C" + label: "$C$" + pos: [3, -2] + - id: "D" + label: "$D$" + pos: [6, 0] + - id: "E" + label: "$E$" + pos: [3, 0] + edges: + - from: "A" + to: "B" + label: "4" + - from: "A" + to: "C" + label: "2" + - from: "A" + to: "E" + label: "3" + - from: "B" + to: "D" + label: "3" + - from: "C" + to: "D" + label: "5" + - from: "E" + to: "D" + label: "2" + - from: "B" + to: "E" + label: "1" + directed: false + - from: "C" + to: "E" + label: "1" + bidirectional: true + - from: "E" + to: "E" + bend: 60 + label: "0" + - type: requirements + content: "단, 간선 레이블은 이동 거리이며, 방향이 없는 간선(─)은 양방향 이동 가능하다." + answer_typst: | + 최단 경로: $A -> E -> D$, 거리: $3 + 2 = 5$ + points: 4 + source: "graph 블록 전 기능 예시 (directed/undirected/bidirectional/bend/label/self-loop)" + + # ────────────────────────────────────────────── + # 8. MCQ — mapping 블록 + # ────────────────────────────────────────────── + - id: "8" + type: mcq + question: "아래 사상(mapping) f : X → Y에서, f가 전단사 함수(bijection)가 되려면 추가해야 할 화살표는?" + question_blocks: + - type: mapping + from_set: + label: "$X$" + nodes: ["1", "2", "3", "4"] + to_set: + label: "$Y$" + nodes: ["a", "b", "c", "d"] + arrow_label: "$f$" + edges: + - from: "1" + to: "a" + - from: "2" + to: "c" + - from: "3" + to: "d" + choices: + - "4 → a" + - "4 → b" + - "4 → c" + - "4 → d" + answer: 2 + points: 2 + source: "mapping 블록 예시" + + # ────────────────────────────────────────────── + # 9. Short — 소문제 1단계 (MCQ + short 혼합) + # ────────────────────────────────────────────── + - id: "9" + type: short + question_typst: | + 행렬 $A = mat(2, 1; 5, 3)$에 대하여 다음 물음에 답하시오. + subproblems: + - id: "(1)" + type: short + question_typst: "행렬식 $det(A)$를 구하시오." + answer_typst: | + $det(A) = 2 times 3 - 1 times 5 = 6 - 5 = 1$ + points: 2 + - id: "(2)" + type: short + question_typst: "역행렬 $A^(-1)$을 구하시오." + answer_typst: | + $A^(-1) = 1/1 mat(3, -1; -5, 2) = mat(3, -1; -5, 2)$ + points: 3 + - id: "(3)" + type: mcq + question_typst: "$A^(-1)$가 존재하기 위한 필요충분조건은?" + choices_typst: + - "$det(A) = 0$" + - "$det(A) eq.not 0$" + - "$det(A) > 0$" + - "$A = I$ (단위행렬)" + answer: 2 + points: 1 + points: 6 + source: "소문제 1단계 예시" + + # ────────────────────────────────────────────── + # 10. Short — question + question_blocks 혼합 + # (text / formula / requirements 블록 + answer_typst) + # ────────────────────────────────────────────── + - id: "10" + type: short + question: "아래 조건을 이용하여 등비수열의 합 $S_6$을 구하시오." + question_blocks: + - type: text + content: "첫째항이 3이고 공비가 2인 등비수열이 있다." + - type: formula + content: "S_n = frac(a(r^n - 1), r - 1) quad (r eq.not 1)" + - type: requirements + content: "단, 풀이 과정을 함께 서술하시오." + answer_typst: | + $S_6 = frac(3(2^6 - 1), 2 - 1) = 3 times (64 - 1) = 3 times 63 = 189$ + points: 3 + source: "question + text/formula/requirements 블록 혼합 예시" + + # ────────────────────────────────────────────── + # 11~13. Section — 공통 지문 묶음 + # ────────────────────────────────────────────── + - kind: section + title_typst: | + ※ [11~13번] 다음 벡터를 보고 물음에 답하시오. + content_blocks: + - type: typst + content: | + #align(center)[ + $bold(u) = vec(1, 2, 3), quad bold(v) = vec(4, 0, -1), quad bold(w) = vec(2, -1, 2)$ + ] + - type: requirements + content: "단, 모든 계산은 유리수 범위에서 수행한다." + problems: + - id: "11" + type: short + question_typst: "내적 $bold(u) dot bold(v)$를 구하시오." + answer_typst: | + $bold(u) dot bold(v) = 1 times 4 + 2 times 0 + 3 times (-1) = 4 + 0 - 3 = 1$ + points: 2 + + - id: "12" + type: mcq + question_typst: "벡터 $bold(u)$와 $bold(w)$가 이루는 각도 $theta$에 대해 $cos theta$는?" + question_blocks: + - type: formula + content: "cos theta = (bold(u) dot bold(w)) / (||bold(u)|| dot ||bold(w)||)" + choices_typst: + - "$6 / sqrt(42)$" + - "$8 / sqrt(42)$" + - "$10 / sqrt(42)$" + - "$4 / sqrt(42)$" + answer: 1 + points: 3 + answer_typst: | + $bold(u) dot bold(w) = 1 times 2 + 2 times (-1) + 3 times 2 = 2 - 2 + 6 = 6$ + + $||bold(u)|| = sqrt(1+4+9) = sqrt(14), quad ||bold(w)|| = sqrt(4+1+4) = sqrt(9) = 3$ + + $therefore cos theta = 6 / (sqrt(14) times 3) = 6 / sqrt(126) = 6 / (3 sqrt(14)) = 2/sqrt(14) = sqrt(14)/7$ + + - id: "13" + type: short + question_typst: "벡터 $bold(u)$의 단위벡터 $hat(bold(u))$를 구하시오." + question_blocks: + - type: text + content: "(각 성분을 분수 또는 근호를 이용하여 나타내시오.)" + answer_typst: | + $hat(bold(u)) = bold(u) / ||bold(u)|| = 1/sqrt(14) vec(1, 2, 3) = vec(1/sqrt(14), 2/sqrt(14), 3/sqrt(14))$ + points: 2 diff --git a/data/test_mapping.yaml b/data/test_mapping.yaml new file mode 100644 index 0000000..274ee46 --- /dev/null +++ b/data/test_mapping.yaml @@ -0,0 +1,22 @@ +title: 매핑 테스트 +problems: + - id: Q1 + type: short + question: 다음 대응을 보고 물음에 답하시오. + question_blocks: + - type: mapping + arrow_label: f + from: + label: X + nodes: ["1", "2", "3"] + to: + label: Y + nodes: ["a", "b", "c", "d"] + edges: + - from: "1" + to: "a" + - from: "2" + to: ["b", "c"] + - from: "3" + to: "d" + answer: 함수이다 diff --git a/pyproject.toml b/pyproject.toml index d46b166..7ff2290 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ dependencies = [ "Jinja2>=3.1.4,<4", "PyYAML>=6.0.1,<7", "pydantic>=2.6,<3", + "python-dotenv>=1.0,<2", + "rich>=13.0,<15", ] [project.optional-dependencies] @@ -26,6 +28,10 @@ markdown = [ "pymupdf>=1.24,<2", "marker-pdf>=1.0", ] +generate = [ + "pymupdf>=1.24,<2", + "anthropic>=0.40,<1", +] [project.scripts] testmagick = "testmagick.cli:main" diff --git a/src/testmagick/builder.py b/src/testmagick/builder.py index beb60fe..bd25ff5 100644 --- a/src/testmagick/builder.py +++ b/src/testmagick/builder.py @@ -55,7 +55,9 @@ def build_exam( ) -> BuildArtifacts: ensure_typst_available() exam_set = load_exam(input_path) - rendered = render_typst_files(exam_set=exam_set, out_dir=out_dir, title_override=title_override) + rendered = render_typst_files( + exam_set=exam_set, out_dir=out_dir, title_override=title_override, yaml_path=input_path + ) exam_pdf = out_dir / "exam.pdf" answer_pdf = out_dir / "answer.pdf" diff --git a/src/testmagick/cli.py b/src/testmagick/cli.py index c69e26c..74f718f 100644 --- a/src/testmagick/cli.py +++ b/src/testmagick/cli.py @@ -7,6 +7,7 @@ from testmagick.builder import BuildError, build_exam from testmagick.io import InputLoadError, load_exam +from testmagick.schema import Section def _build_parser() -> argparse.ArgumentParser: @@ -62,6 +63,76 @@ def _build_parser() -> argparse.ArgumentParser: help="package.zip 생성을 비활성화합니다.", ) + generate_parser = subparsers.add_parser( + "generate", + help="PDF에서 YAML을 자동 생성하고 PDF를 빌드합니다 (LLM 피드백 루프).", + ) + generate_parser.add_argument( + "--input", + required=True, + type=Path, + help="입력 PDF 파일 경로", + ) + generate_parser.add_argument( + "--out", + type=Path, + default=Path("out"), + help="출력 디렉터리 (기본: out)", + ) + generate_parser.add_argument( + "--method", + choices=["auto", "mixed", "text", "images", "markdown"], + default="auto", + help="전처리 방식 (기본: auto)", + ) + generate_parser.add_argument( + "--dpi", + type=int, + default=150, + help="이미지 모드 해상도 (기본: 150)", + ) + generate_parser.add_argument( + "--quality", + type=int, + default=72, + help="JPEG 압축 품질 0-100 (기본: 72)", + ) + generate_parser.add_argument( + "--model", + type=str, + default="claude-opus-4-6", + help="Claude 모델 ID (기본: claude-opus-4-6)", + ) + generate_parser.add_argument( + "--rounds", + type=int, + default=3, + metavar="N", + help="최대 LLM 교정 라운드 수 (기본: 3)", + ) + generate_parser.add_argument( + "--answers", + type=Path, + default=None, + metavar="PDF", + help="답지/해설 PDF 경로 (있으면 문제지와 함께 LLM에 전달)", + ) + generate_parser.add_argument( + "--no-build", + action="store_true", + help="PDF 빌드를 건너뜁니다 (YAML만 생성)", + ) + generate_parser.add_argument( + "--expect", + type=str, + default=None, + metavar="SPEC", + help=( + "소문제 수 기대치. 형식: '문제ID:개수,...' " + "(예: '1:5,7:8'). 부족 시 LLM 재교정 요청." + ), + ) + preprocess_parser = subparsers.add_parser( "preprocess", help="PDF를 LLM 전달용으로 전처리합니다 (텍스트 추출 또는 이미지 압축).", @@ -184,18 +255,25 @@ def _run_validate(input_path: Path, expect: str | None = None) -> int: print(f"{_err_tag()} {exc}") return 1 - problems = exam_set.problems - total_sub = sum(len(p.subproblems) for p in problems if p.subproblems) + # Flatten problems from sections for counting and display + all_problems = [] + for item in exam_set.problems: + if isinstance(item, Section): + all_problems.extend(item.problems) + else: + all_problems.append(item) + + total_sub = sum(len(p.subproblems) for p in all_problems if p.subproblems) total_pts = sum( sum(s.points for s in p.subproblems) if p.subproblems else p.points - for p in problems + for p in all_problems ) print(f"{_ok_tag()} 검증 완료: {_color(str(input_path), '36')}") print() - col_n = max(len(str(len(problems))), 1) - col_id = max((len(p.id) for p in problems), default=2) + col_n = max(len(str(len(all_problems))), 1) + col_id = max((len(p.id) for p in all_problems), default=2) col_pts = 6 header = ( @@ -207,21 +285,33 @@ def _run_validate(input_path: Path, expect: str | None = None) -> int: print(_color(header, "2")) print(_color(" " + "─" * (len(header) - 2), "2")) - for i, p in enumerate(problems, 1): - if p.subproblems: - pts = sum(s.points for s in p.subproblems) - sub_ids = " ".join(s.id for s in p.subproblems) - sub_summary = f"{len(p.subproblems)}개 {_color(sub_ids, '2')}" - else: - pts = p.points - sub_summary = _color("─", "2") + def _sub_ids_str(subs: list) -> str: + return " ".join(s.id for s in subs) - pts_str = f"{pts:.1f}pt" - print(f" {i:>{col_n}} {p.id:<{col_id}} {pts_str:>{col_pts}} {sub_summary}") + num = 0 + for item in exam_set.problems: + if isinstance(item, Section): + section_header = item.title_typst or item.title or "(섹션)" + print(_color(f" ── [섹션] {section_header} ──", "2")) + probs = item.problems + else: + probs = [item] + for p in probs: + num += 1 + if p.subproblems: + pts = sum(s.points for s in p.subproblems) + sub_ids = _sub_ids_str(p.subproblems) + sub_summary = f"{len(p.subproblems)}개 {_color(sub_ids, '2')}" + else: + pts = p.points + sub_summary = _color("─", "2") + + pts_str = f"{pts:.1f}pt" + print(f" {num:>{col_n}} {p.id:<{col_id}} {pts_str:>{col_pts}} {sub_summary}") print() parts = [ - f"문제 {len(problems)}개", + f"문제 {len(all_problems)}개", f"소문제 {total_sub}개" if total_sub else None, f"총 {total_pts:.1f}pt", ] @@ -237,7 +327,7 @@ def _run_validate(input_path: Path, expect: str | None = None) -> int: print(f"{_err_tag()} {exc}") return 1 - actual_map = {p.id: len(p.subproblems or []) for p in problems} + actual_map = {p.id: len(p.subproblems or []) for p in all_problems} issues: list[tuple[str, int, int]] = [] for pid, exp_count in expected_map.items(): act_count = actual_map.get(pid, 0) @@ -375,9 +465,43 @@ def _run_preprocess( def main(argv: list[str] | None = None) -> int: + try: + return _main(argv) + except KeyboardInterrupt: + print("\n중단됨") + return 130 + + +def _main(argv: list[str] | None = None) -> int: + from dotenv import load_dotenv + load_dotenv() + parser = _build_parser() args = parser.parse_args(argv) + if args.command == "generate": + from testmagick.generate import run_generate + + expect_map: dict[str, int] | None = None + if args.expect: + try: + expect_map = _parse_expect(args.expect) + except ValueError as exc: + print(f"{_err_tag()} {exc}") + return 1 + + return run_generate( + pdf_path=args.input, + out_dir=args.out, + answers_pdf=args.answers, + method=args.method, + dpi=args.dpi, + quality=args.quality, + model=args.model, + max_rounds=args.rounds, + no_build=args.no_build, + expect_map=expect_map, + ) if args.command == "validate": return _run_validate(input_path=args.input, expect=args.expect) if args.command == "build": diff --git a/src/testmagick/generate.py b/src/testmagick/generate.py new file mode 100644 index 0000000..46ce021 --- /dev/null +++ b/src/testmagick/generate.py @@ -0,0 +1,448 @@ +"""generate.py — PDF → LLM 피드백 루프 → YAML → PDF 자동 생성 파이프라인.""" + +from __future__ import annotations + +import base64 +import os +import re +from pathlib import Path + +from rich.console import Console +from rich.live import Live +from rich.markup import escape +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +console = Console(highlight=False) + +# ── 의존성 체크 ──────────────────────────────────────────────────────────────── + + +def _require_anthropic() -> None: + try: + import anthropic # noqa: F401 + except ImportError: + raise ImportError( + "anthropic 패키지가 설치되어 있지 않습니다.\n" + "설치: pip install anthropic 또는 uv pip install anthropic" + ) + + +# ── 출력 헬퍼 ───────────────────────────────────────────────────────────────── + + +def _ok(label: str, detail: str = "") -> None: + line = f"[green]✓[/green] [bold]{escape(label)}[/bold]" + if detail: + line += f" [dim]{escape(detail)}[/dim]" + console.print(line) + + +def _warn(label: str, detail: str = "") -> None: + line = f"[yellow]![/yellow] [bold]{escape(label)}[/bold]" + if detail: + line += f" [yellow]{escape(detail)}[/yellow]" + console.print(line) + + +def _err(label: str, detail: str = "") -> None: + line = f"[red]✗[/red] [bold]{escape(label)}[/bold]" + if detail: + line += f" [red]{escape(detail)}[/red]" + console.print(line) + + +def _stream_preview(text: str) -> Text: + """스트리밍 수신 중 Live 패널에 표시할 미리보기.""" + lines = text.splitlines() + tail = lines[-14:] if len(lines) > 14 else lines + t = Text() + t.append(f" {len(text):,}자 수신 중...\n\n", style="dim") + for line in tail: + t.append(f" {line}\n", style="dim") + return t + + +# ── LLM 요청 구성 ───────────────────────────────────────────────────────────── + +_REQUEST_PROMPT = """\ +위 스키마를 참고하여 첨부한 문제지를 TestMagick YAML 형식으로 변환해주세요. + +규칙: +- 각 문제 id는 원본 번호 그대로 사용 ("1", "2", ...) +- 독립 줄 수식(가운데 정렬)은 question_blocks의 formula 타입으로 ($ 없이 Typst 수식 문법) +- 행렬: mat(delim: "[", ...) / 첨가행렬: augment: #N +- 소문제가 있으면 반드시 subproblems 배열로 +- 풀이/해설은 answer_typst에 Typst 마크업으로 작성 +- question 필드에는 수식이 전혀 없는 순수 텍스트만 사용 +- 문장 중간에 수식이 한 글자라도 있으면 question_typst 사용, 인라인 수식은 $...$ +- choices/answer도 동일: 수식 포함 시 choices_typst / answer_typst 사용 +- 소문제 id에 부모 문제 번호를 포함하지 말 것: "10a"가 아닌 "a", "(1)", "가" 등 사용 +- 공통 지문/제시문이 있는 문제 묶음은 반드시 kind: section으로 묶을 것 + (예: "다음 행렬 A~H를 이용하여 3~8번 물음에 답하라" → section으로 묶고 제시문은 content_blocks에) +- Typst에서 ~는 non-breaking space임. 물결표(~) 문자를 title_typst 등에 쓸 때는 + \\~로 이스케이프하고 YAML 싱글쿼트로 감쌀 것 + (예: title_typst: '※ [3\\~8번] ...') +- 완전한 YAML만 출력. 반드시 ```yaml 블록으로 감싸서 출력. +""" + +_CORRECTION_PREFIX = ( + "아래 스키마 검증 오류가 발생했습니다. " + "오류를 모두 수정하여 완전한 YAML을 다시 ```yaml 블록으로만 출력해주세요.\n\n" +) + + +def _prep_to_blocks(text_file: Path | None, image_files: list[Path]) -> list[dict]: + """전처리 결과(text_file + image_files)를 content 블록 목록으로 변환한다.""" + image_map = {f.name: f for f in image_files} + blocks: list[dict] = [] + + if text_file and text_file.exists(): + raw = text_file.read_text(encoding="utf-8") + buf: list[str] = [] + + for line in raw.splitlines(): + m = re.match(r"\[페이지 \d+ → 이미지: (page_\d+\.jpg)\]", line) + if m: + chunk = "\n".join(buf).strip() + if chunk: + blocks.append({"type": "text", "text": chunk}) + buf = [] + img_path = image_map.get(m.group(1)) + if img_path and img_path.exists(): + data = base64.standard_b64encode(img_path.read_bytes()).decode() + blocks.append({ + "type": "image", + "source": {"type": "base64", "media_type": "image/jpeg", "data": data}, + }) + else: + buf.append(line) + + chunk = "\n".join(buf).strip() + if chunk: + blocks.append({"type": "text", "text": chunk}) + + elif image_files: + for img_path in sorted(image_files): + data = base64.standard_b64encode(img_path.read_bytes()).decode() + blocks.append({ + "type": "image", + "source": {"type": "base64", "media_type": "image/jpeg", "data": data}, + }) + + return blocks + + +def _build_user_content( + text_file: Path | None, + image_files: list[Path], + ans_text_file: Path | None = None, + ans_image_files: list[Path] | None = None, +) -> list[dict]: + """LLM 요청의 content 블록 목록을 구성한다. 답지가 있으면 섹션 구분 후 추가.""" + content: list[dict] = [] + + if ans_text_file is not None or ans_image_files: + content.append({"type": "text", "text": "# 문제지"}) + + content.extend(_prep_to_blocks(text_file, image_files)) + + if ans_text_file is not None or ans_image_files: + content.append({"type": "text", "text": "# 답지 / 해설\n위 문제들의 정답과 풀이입니다."}) + content.extend(_prep_to_blocks(ans_text_file, ans_image_files or [])) + + content.append({"type": "text", "text": _REQUEST_PROMPT}) + return content + + +# ── YAML 추출 및 검증 ────────────────────────────────────────────────────────── + + +def _extract_yaml(response: str) -> str | None: + m = re.search(r"```(?:yaml|YAML)\s*\n(.*?)\n```", response, re.DOTALL) + if m: + return m.group(1).strip() + m = re.search(r"((?:title:|problems:).*)", response, re.DOTALL) + if m: + return m.group(1).strip() + return None + + +def _normalize_sub_ids(yaml_text: str) -> str: + """소문제 ID에서 부모 문제 번호 prefix 제거 (예: "10a" → "a", "5-1" → "1").""" + import yaml as _yaml + + try: + data = _yaml.safe_load(yaml_text) + except Exception: + return yaml_text + + def _fix(problems: list) -> None: + for item in problems: + if not isinstance(item, dict): + continue + if item.get("kind") == "section": + _fix(item.get("problems") or []) + continue + pid = str(item.get("id", "")) + subs = item.get("subproblems") or [] + for sub in subs: + sid = str(sub.get("id", "")) + if sid.startswith(pid) and len(sid) > len(pid): + remainder = sid[len(pid):] + # strip separators like '-', '_', '.' + sub["id"] = remainder.lstrip("-_.") + + _fix(data.get("problems") or []) + return _yaml.dump(data, allow_unicode=True, default_flow_style=False, sort_keys=False) + + +def _validate_text(yaml_text: str, path: Path): + from testmagick.io import InputLoadError, load_exam + + path.write_text(yaml_text, encoding="utf-8") + try: + return None, load_exam(path) + except InputLoadError as exc: + return str(exc), None + + +def _check_expect(exam, expect_map: dict[str, int]) -> list[tuple[str, int, int]]: + from testmagick.schema import Section + actual = {} + for item in exam.problems: + probs = item.problems if isinstance(item, Section) else [item] + for p in probs: + actual[p.id] = len(p.subproblems or []) + return [ + (pid, exp, actual.get(pid, 0)) + for pid, exp in expect_map.items() + if actual.get(pid, 0) < exp + ] + + +def _expect_feedback(issues: list[tuple[str, int, int]], yaml_text: str) -> str: + lines = "\n".join( + f"- 문제 \"{pid}\": 소문제 {exp}개 필요, 현재 {act}개 ({exp - act}개 누락)" + for pid, exp, act in issues + ) + return ( + f"다음 문제에서 소문제가 누락되어 있습니다:\n\n{lines}\n\n" + "누락된 소문제를 모두 채워서 완성된 YAML을 다시 ```yaml 블록으로 출력해주세요.\n" + "기존 문제는 그대로 유지하고 누락된 소문제만 추가하세요. 절대 생략하지 마세요.\n\n" + f"현재 YAML:\n```yaml\n{yaml_text}\n```" + ) + + +# ── 메인 파이프라인 ──────────────────────────────────────────────────────────── + + +def run_generate( + pdf_path: Path, + out_dir: Path, + *, + answers_pdf: Path | None = None, + method: str = "auto", + dpi: int = 150, + quality: int = 72, + model: str = "claude-opus-4-6", + max_rounds: int = 3, + no_build: bool = False, + expect_map: dict[str, int] | None = None, +) -> int: + _require_anthropic() + import anthropic + + from testmagick.builder import BuildError, build_exam + from testmagick.io import InputLoadError + from testmagick.preprocess import preprocess_pdf + + if not os.getenv("ANTHROPIC_API_KEY"): + _err("인증 오류", "ANTHROPIC_API_KEY 환경 변수가 설정되지 않았습니다.") + return 1 + + client = anthropic.Anthropic() + out_dir.mkdir(parents=True, exist_ok=True) + + # ── 헤더 ────────────────────────────────────────────────────────────────── + console.print() + console.rule( + f"[bold]generate[/bold] " + f"[cyan]{escape(str(pdf_path))}[/cyan] [dim]→[/dim] " + f"[cyan]{escape(str(out_dir))}[/cyan]" + ) + console.print() + + # ── 전처리 ──────────────────────────────────────────────────────────────── + try: + with console.status("문제지 전처리 중...", spinner="dots"): + prep = preprocess_pdf( + pdf_path, out_dir / "prep", method=method, dpi=dpi, quality=quality + ) + except ImportError as exc: + _err("전처리", f"의존성 오류: {exc}") + return 1 + except Exception as exc: + _err("전처리", str(exc)) + return 1 + + img_n = len(prep.image_files) + prep_detail = f"{prep.method} · {prep.page_count}페이지" + if img_n: + prep_detail += f" · 이미지 {img_n}장" + _ok("문제지 전처리", prep_detail) + + ans_prep = None + if answers_pdf: + try: + with console.status("답지 전처리 중...", spinner="dots"): + ans_prep = preprocess_pdf( + answers_pdf, out_dir / "prep_answers", + method=method, dpi=dpi, quality=quality, + ) + except Exception as exc: + _err("답지 전처리", str(exc)) + return 1 + ans_img_n = len(ans_prep.image_files) + ans_detail = f"{ans_prep.method} · {ans_prep.page_count}페이지" + if ans_img_n: + ans_detail += f" · 이미지 {ans_img_n}장" + _ok("답지 전처리", ans_detail) + + system_prompt = prep.schema_ref.read_text(encoding="utf-8") + messages: list[dict] = [ + {"role": "user", "content": _build_user_content( + prep.text_file, prep.image_files, + ans_prep.text_file if ans_prep else None, + ans_prep.image_files if ans_prep else None, + )}, + ] + + yaml_path = out_dir / "exam.yaml" + validated = False + artifacts = None + + for rnd in range(1, max_rounds + 1): + console.print() + console.rule(f"[dim]라운드 {rnd} / {max_rounds}[/dim]", style="dim") + console.print() + + op_label = "YAML 생성" if rnd == 1 else "YAML 교정" + response_text = "" + out_tokens = 0 + + # ── LLM 스트리밍 ────────────────────────────────────────────────────── + try: + with client.messages.stream( + model=model, + max_tokens=16000, + system=system_prompt, + messages=messages, + ) as stream: + with Live( + Text(" 시작 중...", style="dim"), + console=console, + refresh_per_second=12, + transient=False, + ) as live: + for chunk in stream.text_stream: + response_text += chunk + live.update(_stream_preview(response_text)) + final_msg = stream.get_final_message() + out_tokens = final_msg.usage.output_tokens + except KeyboardInterrupt: + console.print("\n\n[dim]중단됨[/dim]") + return 130 + except anthropic.AuthenticationError: + _err(op_label, "인증 오류 — ANTHROPIC_API_KEY를 확인하세요") + return 1 + except anthropic.APIError as exc: + _err(op_label, str(exc)) + return 1 + + _ok(op_label, f"{len(response_text):,}자 · {out_tokens:,} 토큰") + (out_dir / f"round_{rnd}_response.txt").write_text(response_text, encoding="utf-8") + + # ── YAML 추출 ───────────────────────────────────────────────────────── + yaml_text = _extract_yaml(response_text) + if not yaml_text: + _warn("YAML 추출", "응답에서 YAML 블록을 찾을 수 없음") + if rnd < max_rounds: + messages += [ + {"role": "assistant", "content": response_text}, + {"role": "user", "content": ( + "응답에서 YAML 블록을 찾을 수 없습니다. " + "반드시 ```yaml 블록으로 감싸서 완전한 YAML만 다시 출력해주세요." + )}, + ] + continue + + yaml_text = _normalize_sub_ids(yaml_text) + _ok("YAML 추출", f"{len(yaml_text):,}자") + (out_dir / f"round_{rnd}.yaml").write_text(yaml_text, encoding="utf-8") + + # ── 스키마 검증 ─────────────────────────────────────────────────────── + err, exam = _validate_text(yaml_text, yaml_path) + if err is not None: + short_err = err.splitlines()[0][:80] + if rnd < max_rounds: + _warn("스키마 검증", short_err) + messages += [ + {"role": "assistant", "content": response_text}, + {"role": "user", "content": f"{_CORRECTION_PREFIX}```\n{err}\n```"}, + ] + else: + _err("스키마 검증", short_err) + continue + + _ok("스키마 검증", "통과") + + # ── 문제 수 검증 ────────────────────────────────────────────────────── + if expect_map: + issues = _check_expect(exam, expect_map) + if issues: + summary = " ".join(f"{pid}: {act}/{exp}개" for pid, exp, act in issues) + if rnd < max_rounds: + _warn("문제 수 검증", f"누락 {len(issues)}건 — {summary}") + messages += [ + {"role": "assistant", "content": response_text}, + {"role": "user", "content": _expect_feedback(issues, yaml_text)}, + ] + continue + else: + _warn("문제 수 검증", f"누락 {len(issues)}건 — 최대 라운드 도달") + else: + _ok("문제 수 검증", "통과") + + validated = True + break + + # ── PDF 빌드 ────────────────────────────────────────────────────────────── + console.print() + if not no_build and validated: + try: + with console.status("PDF 빌드 중...", spinner="dots"): + artifacts = build_exam(input_path=yaml_path, out_dir=out_dir) + _ok("PDF 빌드", "완료") + except (InputLoadError, BuildError) as exc: + _err("PDF 빌드", str(exc)) + return 1 + elif not validated: + _warn("PDF 빌드", "검증 실패로 건너뜀") + + # ── 최종 결과 패널 ──────────────────────────────────────────────────────── + console.print() + grid = Table.grid(padding=(0, 2)) + grid.add_column(style="dim", min_width=5) + grid.add_column(style="cyan") + grid.add_row("YAML", str(yaml_path.resolve())) + if artifacts: + grid.add_row("문제지", str(artifacts.exam_pdf.resolve())) + grid.add_row("정답지", str(artifacts.answer_pdf.resolve())) + + status = "[green bold]완료[/green bold]" if validated else "[yellow bold]불완전[/yellow bold]" + border = "green" if validated else "yellow" + console.print(Panel(grid, title=status, border_style=border, padding=(1, 2))) + + return 0 if validated else 1 diff --git a/src/testmagick/preprocess.py b/src/testmagick/preprocess.py index 80d1ea0..8e9efb5 100644 --- a/src/testmagick/preprocess.py +++ b/src/testmagick/preprocess.py @@ -172,14 +172,32 @@ def _clean_text(text: str) -> str: # ─── 렌더링 헬퍼 ───────────────────────────────────────────────────────────── +_MAX_IMAGE_BYTES = 4 * 1024 * 1024 # 4 MB (API 한도 5 MB에 여유) +_MAX_IMAGE_PX = 7900 # API 한도 8000 px에 여유 + + def _render_page(page: object, path: Path, dpi: int, quality: int) -> tuple[int, int]: - """페이지를 JPEG로 렌더링하고 (width, height)를 반환한다.""" + """페이지를 JPEG로 렌더링하고 (width, height)를 반환한다. + + 픽셀 크기(8000px) 또는 파일 크기(5MB)를 초과하면 DPI를 낮춰 재렌더링한다. + """ import fitz - scale = dpi / 72.0 - mat = fitz.Matrix(scale, scale) - pix = page.get_pixmap(matrix=mat, colorspace=fitz.csGRAY) # type: ignore[attr-defined] - path.write_bytes(pix.tobytes(output="jpg", jpg_quality=quality)) - return pix.width, pix.height + + cur_dpi = dpi + while True: + scale = cur_dpi / 72.0 + mat = fitz.Matrix(scale, scale) + pix = page.get_pixmap(matrix=mat, colorspace=fitz.csGRAY) # type: ignore[attr-defined] + + if (pix.width > _MAX_IMAGE_PX or pix.height > _MAX_IMAGE_PX) and cur_dpi > 50: + cur_dpi = int(cur_dpi * 0.8) + continue + + data = pix.tobytes(output="jpg", jpg_quality=quality) + if len(data) <= _MAX_IMAGE_BYTES or cur_dpi <= 50: + path.write_bytes(data) + return pix.width, pix.height + cur_dpi = int(cur_dpi * 0.8) # ─── 토큰 추정 ──────────────────────────────────────────────────────────────── @@ -305,6 +323,12 @@ def _run_mixed( - ["2023", "1,800"] ``` +### text — 일반 텍스트 단락 +```yaml +- type: text + content: "첫째항이 3이고 공비가 2인 등비수열이 있다." +``` + ### requirements — 조건 박스 ```yaml - type: requirements @@ -346,10 +370,59 @@ def _run_mixed( question_typst: "다항식 $p(x) = a_0 + a_1 x + a_2 x^2$이 점 $(1, 3)$을 지날 때 $a_0$를 구하라." ``` +## MCQ answer_typst (객관식 해설) +객관식에도 `answer_typst`로 해설을 작성할 수 있다. 선택지 번호(①②③…)는 유지된다. +```yaml +- id: "1" + type: mcq + question: "다음 중 참인 것은?" + choices: ["참", "거짓"] + answer: 1 + answer_typst: | + ① 참이다. $x = 0$일 때 성립한다. +``` + +## choices_typst (수식 선택지) +선택지에 수식이 포함될 경우 `choices_typst`를 사용한다. +```yaml +choices_typst: + - "$x = 1, 2$" + - "$x = 2, 3$" + - "$x = -2, -3$" +answer: 2 +``` + ## subproblem 필드 Problem과 동일 (id, type, question, question_typst, question_blocks, choices, answer, points). 단, subproblems 중첩 불가. +## Section (공통 지문 묶음) +여러 문제가 같은 제시문·데이터를 공유할 때 사용한다. +문제지에서 "다음 ○○를 이용하여 N~M번 물음에 답하라" 형태의 지문 박스가 있으면 +반드시 section으로 묶어라. + +**주의**: Typst에서 `~`는 non-breaking space로 처리된다. +물결표 문자(~)를 출력하려면 `\\~`로 이스케이프하고 YAML 싱글쿼트로 감싸야 한다. +```yaml +- kind: section + title_typst: '※ [3\\~8번] 다음 행렬 $A$\\~$H$를 이용하여 물음에 답하라.' + content_blocks: + - type: formula + content: "A = mat(delim: \"[\", 1, 2; 3, 4), quad B = mat(delim: \"[\", 5, 6; 7, 8)" + problems: + - id: "3" + type: short + question: "각 행렬의 크기를 구하라." + answer_typst: "..." + - id: "4" + ... +``` +- `title_typst`: 섹션 제목 (Typst 수식 포함 가능) +- `content_blocks`: 공통 제시문 블록 (formula / table / typst 등) +- `problems`: 섹션 안의 문제 목록 (Problem과 동일 구조, kind 필드 없음) +- section 안 문제에는 제시문을 반복하지 말 것 (content_blocks에 한 번만 작성) + + ## 자주 쓰는 Typst 수식 | 내용 | Typst | |------|-------| @@ -392,12 +465,18 @@ def _run_mixed( - 독립 줄 수식(가운데 정렬)은 question_blocks의 formula 타입으로 ($ 없이 Typst 수식 문법) - 행렬은 mat(delim: "[", ...) 사용, 첨가행렬은 augment: #N 파라미터 - 소문제가 있으면 반드시 subproblems 배열로 +- 소문제 id에 부모 문제 번호를 포함하지 말 것: "10a"가 아닌 "a", "(1)", "가" 등 사용 - 풀이/해설은 answer_typst에 Typst 마크업으로 작성 - question 필드에는 수식이 전혀 없는 순수 텍스트만 사용 - 문장 중간에 수식이 한 글자라도 있으면 반드시 question_typst 사용, 인라인 수식은 $...$로 감쌀 것 예) question_typst: "$a_1 eq.not a_2$일 때, 유일해임을 보여라." - choices/answer도 동일: 수식 포함 시 choices_typst, answer_typst 사용 +- 공통 지문/제시문이 있는 문제 묶음은 반드시 kind: section으로 묶을 것 + (예: "다음 행렬 A~H를 이용하여 3~8번 물음에 답하라" → section으로 묶고 제시문은 content_blocks에) +- Typst에서 ~는 non-breaking space임. 물결표 문자는 \\~로 이스케이프하고 YAML 싱글쿼트로 감쌀 것 + 예) title_typst: '※ [3\\~8번] 다음 행렬 $A$\\~$H$를 이용하여 물음에 답하라.' +- 완전한 YAML만 출력. 반드시 ```yaml 블록으로 감싸서 출력. ``` """ diff --git a/src/testmagick/renderer.py b/src/testmagick/renderer.py index e10464d..79a10e8 100644 --- a/src/testmagick/renderer.py +++ b/src/testmagick/renderer.py @@ -12,9 +12,12 @@ ExamSet, FormulaBlock, GraphBlock, + ImageBlock, + MappingBlock, Problem, QuestionBlock, RequirementsBlock, + Section, SubProblem, TableBlock, TypstBlock, @@ -42,6 +45,23 @@ def typst_string(value: str) -> str: return json.dumps(value, ensure_ascii=False) +def _fix_latex_braces(math: str) -> str: + """LaTeX 스타일 _{...} / ^{...} → Typst 스타일 _(...) / ^(...) 변환 (비중첩 한정).""" + import re + return re.sub(r'([_^])\{([^{}]*)\}', r'\1(\2)', math) + + +def typst_cell(value: str) -> str: + """Typst content block [...] 안에서 사용. + $...$ 또는 #으로 시작하면 raw, 아니면 #"..." 이스케이프.""" + s = value.strip() + if s.startswith("$") and s.endswith("$") and len(s) > 2: + return _fix_latex_braces(s) + if s.startswith("#"): + return s + return "#" + json.dumps(value, ensure_ascii=False) + + def _bend_ctrl( from_pos: list[float], to_pos: list[float], bend_deg: float ) -> list[list[float]] | None: @@ -65,6 +85,71 @@ def _bend_ctrl( return [c1, c2] +def _mapping_payload(block: MappingBlock) -> dict[str, Any]: + from_nodes = block.from_set.nodes + to_nodes = block.to_set.nodes + fn, tn = len(from_nodes), len(to_nodes) + + oval_hw = 1.2 # oval half-width + node_spacing = 0.9 + pad_y = 0.55 # vertical padding inside oval + + def node_ys(n: int) -> list[float]: + top = (n - 1) * node_spacing / 2 + return [top - i * node_spacing for i in range(n)] + + left_ys = node_ys(fn) + right_ys = node_ys(tn) + + left_cx = -2.5 + right_cx = 2.5 + left_hh = max(fn - 1, 0) * node_spacing / 2 + pad_y + right_hh = max(tn - 1, 0) * node_spacing / 2 + pad_y + left_radius = round(min(oval_hw, left_hh), 4) + right_radius = round(min(oval_hw, right_hh), 4) + + from_idx = {n: i for i, n in enumerate(from_nodes)} + to_idx = {n: i for i, n in enumerate(to_nodes)} + + edges_out = [] + for edge in block.edges: + if edge.from_node not in from_idx: + continue + fi = from_idx[edge.from_node] + targets = edge.to if isinstance(edge.to, list) else [edge.to] + for t in targets: + if t not in to_idx: + continue + ti = to_idx[t] + edges_out.append({ + "left_idx": fi + 1, # 1-based (matches Jinja loop.index) + "right_idx": ti + 1, + }) + + return { + "type": "mapping", + "from_label": block.from_set.label, + "to_label": block.to_set.label, + "arrow_label": block.arrow_label, + "left_cx": left_cx, + "left_hh": round(left_hh, 4), + "left_radius": left_radius, + "right_cx": right_cx, + "right_hh": round(right_hh, 4), + "right_radius": right_radius, + "oval_hw": oval_hw, + "left_nodes": [ + {"label": n, "x": left_cx, "y": round(left_ys[i], 4)} + for i, n in enumerate(from_nodes) + ], + "right_nodes": [ + {"label": n, "x": right_cx, "y": round(right_ys[i], 4)} + for i, n in enumerate(to_nodes) + ], + "edges": edges_out, + } + + def _block_payload(block: QuestionBlock) -> dict[str, Any]: if isinstance(block, TableBlock): return { @@ -78,6 +163,10 @@ def _block_payload(block: QuestionBlock) -> dict[str, Any]: return {"type": "requirements", "content": block.content} if isinstance(block, TypstBlock): return {"type": "typst", "content": block.content} + if isinstance(block, ImageBlock): + return {"type": "image", "src": block.src, "width": block.width, "align": block.align} + if isinstance(block, MappingBlock): + return _mapping_payload(block) if isinstance(block, GraphBlock): node_pos = {n.id: n.pos for n in block.nodes} return { @@ -94,6 +183,7 @@ def _block_payload(block: QuestionBlock) -> dict[str, Any]: "label": e.label, "directed": e.directed, "bidirectional": e.bidirectional, + "is_self_loop": e.from_node == e.to, } for e in block.edges ], @@ -173,12 +263,25 @@ def _sub_answer_payload(sub: SubProblem) -> dict[str, str | bool]: def _subproblem_payload(sub: SubProblem) -> dict[str, Any]: answer = _sub_answer_payload(sub) + + question = sub.question or "" + if question == sub.id: + question = "" + elif question.startswith(sub.id + " "): + question = question[len(sub.id) + 1 :].lstrip() + + question_typst = sub.question_typst or "" + if question_typst == sub.id: + question_typst = "" + elif question_typst.startswith(sub.id + " "): + question_typst = question_typst[len(sub.id) + 1 :].lstrip() + return { "id": sub.id, "type": sub.type, - "question": sub.question or "", - "question_typst": sub.question_typst or "", - "question_is_typst": bool(sub.question_typst), + "question": question, + "question_typst": question_typst, + "question_is_typst": bool(question_typst), "question_blocks": [_block_payload(b) for b in (sub.question_blocks or [])], "choices": _sub_choice_payload(sub), "answer_text": str(answer["value"]), @@ -209,6 +312,28 @@ def _problem_payload(problem: Problem) -> ProblemPayload: } + +def _flat_problems(exam_set: ExamSet) -> list[Problem]: + result = [] + for item in exam_set.problems: + if isinstance(item, Section): + result.extend(item.problems) + else: + result.append(item) + return result + + +def _section_payload(section: Section) -> dict[str, Any]: + return { + "kind": "section", + "title": section.title or "", + "title_typst": section.title_typst or "", + "title_is_typst": bool(section.title_typst), + "content_blocks": [_block_payload(b) for b in (section.content_blocks or [])], + "problems": [_problem_payload(p) for p in section.problems], + } + + def _create_environment() -> Environment: env = Environment( loader=PackageLoader("testmagick", "templates"), @@ -218,21 +343,51 @@ def _create_environment() -> Environment: ) env.filters["option_label"] = option_label env.filters["typst_string"] = typst_string + env.filters["typst_cell"] = typst_cell return env +def _resolve_image_paths(items: list[dict], yaml_dir: Path) -> None: + """block payload 안의 image.src를 절대경로로 변환한다.""" + for item in items: + blocks = item.get("question_blocks") or [] + blocks += item.get("content_blocks") or [] + for sub in item.get("subproblems") or []: + blocks += sub.get("question_blocks") or [] + for b in blocks: + if b.get("type") == "image": + p = Path(b["src"]) + if not p.is_absolute(): + b["src"] = str((yaml_dir / p).resolve()) + for sub_item in item.get("problems") or []: + _resolve_image_paths([sub_item], yaml_dir) + + def render_typst_files( exam_set: ExamSet, out_dir: Path, title_override: str | None = None, + yaml_path: Path | None = None, ) -> RenderedFiles: out_dir.mkdir(parents=True, exist_ok=True) env = _create_environment() + + items = [] + for item in exam_set.problems: + if isinstance(item, Section): + items.append(_section_payload(item)) + else: + items.append(_problem_payload(item)) + + if yaml_path is not None: + _resolve_image_paths(items, yaml_path.parent) + payload = { "title": title_override or exam_set.title, "course": exam_set.course, "date": exam_set.date, - "problems": [_problem_payload(p) for p in exam_set.problems], + "items": items, + "problems": [_problem_payload(p) for p in _flat_problems(exam_set)], } exam_typ_content = env.get_template("exam.typ.j2").render(**payload) diff --git a/src/testmagick/schema.py b/src/testmagick/schema.py index d834b2d..ad3f62e 100644 --- a/src/testmagick/schema.py +++ b/src/testmagick/schema.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Annotated, Literal, Union +from typing import Annotated, Any, Literal, Union from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -107,8 +107,86 @@ def _validate_edges(self) -> GraphBlock: return self +class MappingEdge(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + from_node: str = Field(alias="from", min_length=1) + to: str | list[str] # 단일 노드 또는 복수 노드 (일대다) + + @field_validator("from_node") + @classmethod + def _strip_from(cls, value: str) -> str: + cleaned = value.strip() + if not cleaned: + raise ValueError("MappingEdge의 from은 비워둘 수 없습니다.") + return cleaned + + @field_validator("to") + @classmethod + def _strip_to(cls, value: str | list[str]) -> str | list[str]: + if isinstance(value, str): + cleaned = value.strip() + if not cleaned: + raise ValueError("MappingEdge의 to는 비워둘 수 없습니다.") + return cleaned + cleaned = [v.strip() for v in value if v and v.strip()] + if not cleaned: + raise ValueError("MappingEdge의 to 목록이 비어 있습니다.") + return cleaned + + +class MappingSet(BaseModel): + label: str | None = None # 집합 이름 (예: "X", "$A$") + nodes: list[str] = Field(min_length=1) + + @field_validator("nodes") + @classmethod + def _strip_nodes(cls, value: list[str]) -> list[str]: + cleaned = [v.strip() for v in value if v and v.strip()] + if not cleaned: + raise ValueError("MappingSet의 nodes가 비어 있습니다.") + return cleaned + + +class ImageBlock(BaseModel): + type: Literal["image"] = "image" + src: str # YAML 기준 상대경로 또는 절대경로 (빌드 시 절대경로로 변환) + width: str = "80%" # Typst width 값 (예: "60%", "8cm") + align: str = "center" # "left" | "center" | "right" + + +class MappingBlock(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + type: Literal["mapping"] = "mapping" + arrow_label: str | None = None # 함수 이름 (예: "f") + from_set: MappingSet = Field(alias="from") + to_set: MappingSet = Field(alias="to") + edges: list[MappingEdge] = Field(default_factory=list) + + @model_validator(mode="after") + def _validate_edges(self) -> MappingBlock: + from_ids = set(self.from_set.nodes) + to_ids = set(self.to_set.nodes) + for edge in self.edges: + if edge.from_node not in from_ids: + raise ValueError( + f"MappingEdge의 from '{edge.from_node}'이 from_set.nodes에 없습니다." + ) + targets = edge.to if isinstance(edge.to, list) else [edge.to] + for t in targets: + if t not in to_ids: + raise ValueError( + f"MappingEdge의 to '{t}'이 to_set.nodes에 없습니다." + ) + return self + + QuestionBlock = Annotated[ - Union[TextBlock, TypstBlock, FormulaBlock, RequirementsBlock, TableBlock, GraphBlock], + Union[ + TextBlock, TypstBlock, FormulaBlock, RequirementsBlock, + TableBlock, GraphBlock, MappingBlock, ImageBlock, + ], Field(discriminator="type"), ] @@ -233,6 +311,7 @@ def resolved_answer_index(self) -> int | None: # ─── Problem ────────────────────────────────────────────────────────────────── class Problem(BaseModel): + kind: Literal["problem"] = "problem" id: str = Field(min_length=1) type: QuestionType question: str | None = None @@ -389,11 +468,42 @@ def resolved_answer_index(self) -> int | None: return None +# ─── Section ────────────────────────────────────────────────────────────────── + +class Section(BaseModel): + kind: Literal["section"] = "section" + title: str | None = None + title_typst: str | None = None + content_blocks: list[QuestionBlock] | None = None + problems: list[Problem] = Field(min_length=1) + + +ExamSetItem = Annotated[ + Union[Problem, Section], + Field(discriminator="kind"), +] + + class ExamSet(BaseModel): title: str = "제목 없는 시험지" course: str | None = None date: str | None = None - problems: list[Problem] = Field(default_factory=list, min_length=1) + problems: list[ExamSetItem] = Field(default_factory=list, min_length=1) + + @model_validator(mode="before") + @classmethod + def _inject_kind(cls, data: Any) -> Any: + """YAML에 kind 필드가 없는 항목은 기본값 'problem'을 주입.""" + if isinstance(data, dict): + raw_problems = data.get("problems", []) + if isinstance(raw_problems, list): + injected = [] + for item in raw_problems: + if isinstance(item, dict) and "kind" not in item: + item = {**item, "kind": "problem"} + injected.append(item) + data = {**data, "problems": injected} + return data @field_validator("title") @classmethod @@ -415,10 +525,12 @@ def _strip_optional_text(cls, value: str | None) -> str | None: def _validate_unique_problem_ids(self) -> ExamSet: seen: set[str] = set() duplicates: set[str] = set() - for problem in self.problems: - if problem.id in seen: - duplicates.add(problem.id) - seen.add(problem.id) + for item in self.problems: + ids = [item.id] if isinstance(item, Problem) else [p.id for p in item.problems] + for pid in ids: + if pid in seen: + duplicates.add(pid) + seen.add(pid) if duplicates: duplicate_text = ", ".join(sorted(duplicates)) raise ValueError(f"중복된 문제 ID가 있습니다: {duplicate_text}") diff --git a/src/testmagick/templates/_blocks.typ.j2 b/src/testmagick/templates/_blocks.typ.j2 index e29d3c4..53870ab 100644 --- a/src/testmagick/templates/_blocks.typ.j2 +++ b/src/testmagick/templates/_blocks.typ.j2 @@ -32,18 +32,75 @@ align: center + horizon, {%- if block.headers %} {%- for h in block.headers %} - table.cell(fill: luma(230))[#text(weight: "bold")[#{{ h | typst_string }}]], + table.cell(fill: luma(230))[#text(weight: "bold")[{{ h | typst_cell }}]], {%- endfor %} {%- endif %} {%- for row in block.rows %} {%- for cell in row %} - [#{{ cell | typst_string }}], + [{{ cell | typst_cell }}], {%- endfor %} {%- endfor %} ) ] #v(8pt) {%- endif %} +{%- elif block.type == "image" %} +#v(8pt) +#align({{ block.align }})[ + #image({{ block.src | typst_string }}, width: {{ block.width }}) +] +#v(4pt) +{%- elif block.type == "mapping" %} +#v(8pt) +#align(center)[ + #{ + import "@preview/cetz:0.3.4": canvas, draw + canvas({ + import draw: * + let _aw = (end: ">", fill: black, size: 0.2) + let _k = 0.5523 + let _hw = {{ block.oval_hw }} + let _lhh = {{ block.left_hh }} + let _rhh = {{ block.right_hh }} + let _lcx = {{ block.left_cx }} + let _rcx = {{ block.right_cx }} + merge-path(close: true, fill: luma(245), stroke: 0.7pt + black, { + bezier((_lcx, _lhh), (_lcx + _hw, 0), (_lcx + _k * _hw, _lhh), (_lcx + _hw, _k * _lhh)) + bezier((_lcx + _hw, 0), (_lcx, -_lhh), (_lcx + _hw, -_k * _lhh), (_lcx + _k * _hw, -_lhh)) + bezier((_lcx, -_lhh), (_lcx - _hw, 0), (_lcx - _k * _hw, -_lhh), (_lcx - _hw, -_k * _lhh)) + bezier((_lcx - _hw, 0), (_lcx, _lhh), (_lcx - _hw, _k * _lhh), (_lcx - _k * _hw, _lhh)) + }) + merge-path(close: true, fill: luma(245), stroke: 0.7pt + black, { + bezier((_rcx, _rhh), (_rcx + _hw, 0), (_rcx + _k * _hw, _rhh), (_rcx + _hw, _k * _rhh)) + bezier((_rcx + _hw, 0), (_rcx, -_rhh), (_rcx + _hw, -_k * _rhh), (_rcx + _k * _hw, -_rhh)) + bezier((_rcx, -_rhh), (_rcx - _hw, 0), (_rcx - _k * _hw, -_rhh), (_rcx - _hw, -_k * _rhh)) + bezier((_rcx - _hw, 0), (_rcx, _rhh), (_rcx - _hw, _k * _rhh), (_rcx - _k * _hw, _rhh)) + }) +{%- if block.from_label %} + content((_lcx, _lhh + 0.42), text(size: 10pt)[{{ block.from_label }}]) +{%- endif %} +{%- if block.to_label %} + content((_rcx, _rhh + 0.42), text(size: 10pt)[{{ block.to_label }}]) +{%- endif %} + +{%- if block.arrow_label %} + let _ay = calc.max(_lhh, _rhh) + 0.4 + line((_lcx + _hw + 0.15, _ay - 0.2), (_rcx - _hw - 0.15, _ay - 0.2), mark: _aw, stroke: 0.6pt) + content((0, _ay), text(size: 10pt)[{{ block.arrow_label }}]) +{%- endif %} +{%- for n in block.left_nodes %} + content(({{ n.x }}, {{ n.y }}), text(size: 9pt)[{{ n.label }}], name: "_ln_{{ loop.index }}") +{%- endfor %} +{%- for n in block.right_nodes %} + content(({{ n.x }}, {{ n.y }}), text(size: 9pt)[{{ n.label }}], name: "_rn_{{ loop.index }}") +{%- endfor %} +{%- for e in block.edges %} + line("_ln_{{ e.left_idx }}.east", "_rn_{{ e.right_idx }}.west", mark: _aw, stroke: 0.6pt) +{%- endfor %} + }) + } +] +#v(8pt) {%- elif block.type == "graph" %} #v(8pt) #align(center)[ @@ -68,15 +125,25 @@ {%- endfor %} ) {%- for e in block.edges %} -{%- if e.ctrl_pos %} +{%- if e.is_self_loop %} + { + let _np = _p.at({{ e.from | typst_string }}) + let _lw = 0.7 + let _lh = 1.4 + bezier((_np.at(0) - _r * 0.7, _np.at(1) + _r * 0.7), (_np.at(0) + _r * 0.7, _np.at(1) + _r * 0.7), (_np.at(0) - _lw, _np.at(1) + _lh), (_np.at(0) + _lw, _np.at(1) + _lh){% if e.directed %}, mark: _aw{% endif %}) +{%- if e.label %} + content((_np.at(0), _np.at(1) + _lh + 0.2), box(fill: white, inset: 2pt)[#text(size: 8pt)[{{ e.label }}]]) +{%- endif %} + } +{%- elif e.ctrl_pos %} let _c1_{{ loop.index }} = ({{ e.ctrl_pos[0][0] }}, {{ e.ctrl_pos[0][1] }}) let _c2_{{ loop.index }} = ({{ e.ctrl_pos[1][0] }}, {{ e.ctrl_pos[1][1] }}) - bezier(_ef(_p.at({{ e.from | typst_string }}), _c1_{{ loop.index }}), _ef(_p.at({{ e.to | typst_string }}), _c2_{{ loop.index }}), _c1_{{ loop.index }}, _c2_{{ loop.index }}, mark: {% if e.bidirectional %}_baw{% elif e.directed %}_aw{% endif %}) + bezier(_ef(_p.at({{ e.from | typst_string }}), _c1_{{ loop.index }}), _ef(_p.at({{ e.to | typst_string }}), _c2_{{ loop.index }}), _c1_{{ loop.index }}, _c2_{{ loop.index }}{% if e.bidirectional %}, mark: _baw{% elif e.directed %}, mark: _aw{% endif %}) {%- if e.label %} content(((_c1_{{ loop.index }}.at(0) + _c2_{{ loop.index }}.at(0)) / 2, (_c1_{{ loop.index }}.at(1) + _c2_{{ loop.index }}.at(1)) / 2), box(fill: white, inset: 2pt)[#text(size: 8pt)[{{ e.label }}]]) {%- endif %} {%- else %} - line(_ef(_p.at({{ e.from | typst_string }}), _p.at({{ e.to | typst_string }})), _ef(_p.at({{ e.to | typst_string }}), _p.at({{ e.from | typst_string }})), mark: {% if e.bidirectional %}_baw{% elif e.directed %}_aw{% endif %}) + line(_ef(_p.at({{ e.from | typst_string }}), _p.at({{ e.to | typst_string }})), _ef(_p.at({{ e.to | typst_string }}), _p.at({{ e.from | typst_string }})){% if e.bidirectional %}, mark: _baw{% elif e.directed %}, mark: _aw{% endif %}) {%- if e.label %} content(((_p.at({{ e.from | typst_string }}).at(0) + _p.at({{ e.to | typst_string }}).at(0)) / 2, (_p.at({{ e.from | typst_string }}).at(1) + _p.at({{ e.to | typst_string }}).at(1)) / 2), box(fill: white, inset: 2pt)[#text(size: 8pt)[{{ e.label }}]]) {%- endif %} diff --git a/src/testmagick/templates/answer.typ.j2 b/src/testmagick/templates/answer.typ.j2 index 4c39693..f80f496 100644 --- a/src/testmagick/templates/answer.typ.j2 +++ b/src/testmagick/templates/answer.typ.j2 @@ -2,10 +2,6 @@ #set page( paper: "a4", margin: (top: 25mm, bottom: 22mm, left: 25mm, right: 25mm), - footer: context { - set text(size: 8.5pt, fill: luma(140)) - align(center)[Page #counter(page).display() of #counter(page).final().first()] - }, ) #set text( @@ -77,21 +73,21 @@ {% for p in problems %} #block( width: 100%, - breakable: true, + breakable: {% if p.has_subproblems %}true{% else %}false{% endif %}, above: {% if loop.first %}0pt{% else %}14pt{% endif %}, below: 0pt, )[ #grid( columns: (auto, 1fr, auto), - align: horizon, - text(weight: "bold", size: 11pt)[{{ loop.index }}.], - [], - text(size: 9.5pt, weight: "bold")[{% if p.has_subproblems %}소문제 포함{% else %}정답: {{ p.answer_label }}{% endif %}], + align: (left + top, left + top, right + top), + column-gutter: 4pt, + [#text(weight: "bold", size: 11pt)[{{ loop.index }}.]], [], + [#text(size: 9.5pt, weight: "bold")[{% if p.has_subproblems %}소문제 포함{% else %}정답: {{ p.answer_label }}{% endif %}]], ) #v(4pt) {% if not p.has_subproblems %} - #pad(left: 16pt)[ + #pad(left: 18pt)[ {% if p.answer_is_typst %} {{ p.answer_text }} {% else %} @@ -101,12 +97,12 @@ {% else %} {% for sub in p.subproblems %} #v(6pt) - #pad(left: 16pt)[ + #pad(left: 18pt)[ #grid( columns: (auto, 1fr, auto), column-gutter: 6pt, align: top, - text(weight: "bold")[{{ sub.id | typst_string }}], + text(weight: "bold")[{% if sub.id.startswith('(') %}#{{ sub.id | typst_string }}{% else %}(#{{ sub.id | typst_string }}){% endif %}], [ {% if sub.answer_is_typst %} {{ sub.answer_text }} diff --git a/src/testmagick/templates/exam.typ.j2 b/src/testmagick/templates/exam.typ.j2 index d186688..6b5dad3 100644 --- a/src/testmagick/templates/exam.typ.j2 +++ b/src/testmagick/templates/exam.typ.j2 @@ -3,10 +3,6 @@ #set page( paper: "a4", margin: (top: 25mm, bottom: 22mm, left: 25mm, right: 25mm), - footer: context { - set text(size: 8.5pt, fill: luma(140)) - align(center)[Page #counter(page).display() of #counter(page).final().first()] - }, ) #set text( @@ -68,40 +64,37 @@ #v(16pt) // ─── Problems ─── -{% for p in problems %} +{% macro render_problem(p, num) %} #block( width: 100%, - breakable: true, - above: {% if loop.first %}0pt{% else %}18pt{% endif %}, + breakable: {% if p.has_subproblems %}true{% else %}false{% endif %}, + above: {% if num == 1 %}0pt{% else %}18pt{% endif %}, below: 0pt, )[ - #text(weight: "bold", size: 11pt)[{{ loop.index }}.] #h(2pt) - #text(size: 9.5pt)[\({{ "%.1f"|format(p.points) }} pts\)] - #v(3pt) -{% if p.question_is_typst %} -{{ p.question_typst }} -{% elif p.question %} -#{{ p.question | typst_string }} -{% endif %} + #grid( + columns: (auto, 1fr), + column-gutter: 4pt, + align: top, + [#text(weight: "bold", size: 11pt)[{{ num }}.]], + [{% if p.question_is_typst %}{{ p.question_typst | trim }}{% elif p.question %}#{{ p.question | typst_string }}{% endif %} {% if p.question_blocks %} {{ render_blocks(p.question_blocks) }} {% endif %} {% if p.has_subproblems %} - #v(16pt) +#v(10pt) {% for sub in p.subproblems %} - #block( - width: 100%, - breakable: true, - above: {% if loop.first %}0pt{% else %}16pt{% endif %}, - below: 0pt, - )[ - #pad(left: 16pt)[ - #grid( - columns: (auto, 1fr), - column-gutter: 6pt, - align: top, - text(weight: "bold")[{{ sub.id | typst_string }}], - [ +#block( + width: 100%, + breakable: false, + above: {% if loop.first %}0pt{% else %}14pt{% endif %}, + below: 0pt, +)[ + #grid( + columns: (auto, 1fr), + column-gutter: 6pt, + align: top, + text(weight: "bold")[{% if sub.id.startswith('(') %}#{{ sub.id | typst_string }}{% else %}(#{{ sub.id | typst_string }}){% endif %}], + [ {% if sub.question_is_typst %} {{ sub.question_typst }} {% elif sub.question %} @@ -111,49 +104,74 @@ {{ render_blocks(sub.question_blocks) }} {% endif %} {% if sub.type == "mcq" %} - #v(4pt) - #pad(left: 8pt)[ +#v(4pt) +#pad(left: 8pt)[ {% for choice in sub.choices %} - #block(spacing: 5pt)[{{ choice.label }} #h(4pt) {% if choice.is_typst %}{{ choice.value }}{% else %}#{{ choice.value | typst_string }}{% endif %}] +#block(spacing: 5pt)[{{ choice.label }} #h(4pt) {% if choice.is_typst %}{{ choice.value }}{% else %}#{{ choice.value | typst_string }}{% endif %}] {% if not loop.last %} - #v(2pt) +#v(2pt) {% endif %} {% endfor %} - ] +] {% else %} - #v(6pt) - #block( - width: 100%, - height: 25pt, - stroke: (bottom: 0.4pt + luma(200)), - )[] +#v(6pt) +#block( + width: 100%, + height: 12pt, + stroke: (bottom: 0.4pt + luma(200)), +)[] {% endif %} - ], - ) - ] - ] + ], + ) +] {% endfor %} {% elif p.type == "mcq" %} - - #v(7pt) - #pad(left: 16pt)[ +#v(7pt) {% for choice in p.choices %} - #block(spacing: 7pt)[{{ choice.label }} #h(4pt) {% if choice.is_typst %}{{ choice.value }}{% else %}#{{ choice.value | typst_string }}{% endif %}] +#block(spacing: 7pt)[{{ choice.label }} #h(4pt) {% if choice.is_typst %}{{ choice.value }}{% else %}#{{ choice.value | typst_string }}{% endif %}] {% if not loop.last %} - #v(4pt) +#v(4pt) {% endif %} {% endfor %} - ] {% else %} - - #v(8pt) - #pad(left: 16pt)[ - #block( - width: 100%, - height: 30pt, - stroke: (bottom: 0.4pt + luma(200)), - )[] - ] +#v(8pt) +#block( + width: 100%, + height: 15pt, + stroke: (bottom: 0.4pt + luma(200)), +)[] +{% endif %} + ], + ) +] +{% endmacro %} +{% set ns = namespace(num=0) %} +{% for item in items %} +{% if item.kind == "section" %} +#v(14pt) +#block( + width: 100%, + inset: (x: 10pt, y: 7pt), + stroke: 0.4pt + luma(160), + fill: luma(252), +)[ + #set text(size: 10pt) +{% if item.title_is_typst %} + {{ item.title_typst }} +{% elif item.title %} + #{{ item.title | typst_string }} +{% endif %} +{% if item.content_blocks %} +{{ render_blocks(item.content_blocks) }} {% endif %} ] +#v(4pt) +{% for p in item.problems %} +{% set ns.num = ns.num + 1 %} +{{ render_problem(p, ns.num) }} +{% endfor %} +{% else %} +{% set ns.num = ns.num + 1 %} +{{ render_problem(item, ns.num) }} +{% endif %} {% endfor %} diff --git a/tests/test_fix_id_rendering.py b/tests/test_fix_id_rendering.py new file mode 100644 index 0000000..04f757d --- /dev/null +++ b/tests/test_fix_id_rendering.py @@ -0,0 +1,78 @@ +from pathlib import Path + +from testmagick.renderer import render_typst_files +from testmagick.schema import ExamSet + + +def test_render_subproblem_redundant_id_and_quotes(tmp_path: Path) -> None: + exam = ExamSet.model_validate({ + "title": "SubRedundant", + "problems": [{ + "id": "Q1", + "type": "short", + "question": "판단하라.", + "subproblems": [ + { + "id": "(a)", + "type": "short", + "question": "(a) 이원 일차방정식...", + "answer": "참", + }, + { + "id": "(b)", + "type": "short", + "question_typst": "(b) $x=y$", + "answer_typst": "참", + }, + ], + }], + }) + rendered = render_typst_files(exam_set=exam, out_dir=tmp_path) + exam_typ = rendered.exam_typ.read_text(encoding="utf-8") + + # 1. Check for quote fix: it should be text(weight: "bold")[#"(a)"] + assert 'text(weight: "bold")[#"(a)"]' in exam_typ + + # 2. Check for redundant ID stripping in plain question + assert '#"이원 일차방정식..."' in exam_typ + assert '#"(a) 이원 일차방정식..."' not in exam_typ + + # 3. Check for redundant ID stripping in question_typst + assert '$x=y$' in exam_typ + assert '(b) $x=y$' not in exam_typ + +def test_render_subproblem_exact_id_match(tmp_path: Path) -> None: + exam = ExamSet.model_validate({ + "title": "SubExact", + "problems": [{ + "id": "Q1", + "type": "short", + "question": "판단하라.", + "subproblems": [ + { + "id": "(a)", + "type": "short", + "question": "(a)", + "answer": "참", + }, + { + "id": "(b)", + "type": "short", + "question_typst": "(b)", + "answer_typst": "참", + }, + ], + }], + }) + rendered = render_typst_files(exam_set=exam, out_dir=tmp_path) + exam_typ = rendered.exam_typ.read_text(encoding="utf-8") + # print(exam_typ) # debug + + # Check for quote fix + assert 'text(weight: "bold")[#"(a)"]' in exam_typ + assert 'text(weight: "bold")[#"(b)"]' in exam_typ + + # Check that each ID only appears once in the relevant parts + # (one for the bold label, and it shouldn't appear in the question part) + assert exam_typ.count('#"(a)"') == 1 + assert exam_typ.count('#"(b)"') == 1 diff --git a/uv.lock b/uv.lock index c738ee5..47387f3 100644 --- a/uv.lock +++ b/uv.lock @@ -670,6 +670,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + [[package]] name = "markdown2" version = "2.5.5" @@ -798,6 +810,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -1614,6 +1635,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + [[package]] name = "ruff" version = "0.15.6" @@ -1870,7 +1904,9 @@ source = { editable = "." } dependencies = [ { name = "jinja2" }, { name = "pydantic" }, + { name = "python-dotenv" }, { name = "pyyaml" }, + { name = "rich" }, ] [package.optional-dependencies] @@ -1878,6 +1914,10 @@ dev = [ { name = "pytest" }, { name = "ruff" }, ] +generate = [ + { name = "anthropic" }, + { name = "pymupdf" }, +] markdown = [ { name = "marker-pdf" }, { name = "pymupdf" }, @@ -1888,16 +1928,20 @@ preprocess = [ [package.metadata] requires-dist = [ + { name = "anthropic", marker = "extra == 'generate'", specifier = ">=0.40,<1" }, { name = "jinja2", specifier = ">=3.1.4,<4" }, { name = "marker-pdf", marker = "extra == 'markdown'", specifier = ">=1.0" }, { name = "pydantic", specifier = ">=2.6,<3" }, + { name = "pymupdf", marker = "extra == 'generate'", specifier = ">=1.24,<2" }, { name = "pymupdf", marker = "extra == 'markdown'", specifier = ">=1.24,<2" }, { name = "pymupdf", marker = "extra == 'preprocess'", specifier = ">=1.24,<2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0,<9" }, + { name = "python-dotenv", specifier = ">=1.0,<2" }, { name = "pyyaml", specifier = ">=6.0.1,<7" }, + { name = "rich", specifier = ">=13.0,<15" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.0,<1" }, ] -provides-extras = ["dev", "preprocess", "markdown"] +provides-extras = ["dev", "preprocess", "markdown", "generate"] [[package]] name = "threadpoolctl"