From d10f905ed8317f001bf038c6d6175dd86b4657fd Mon Sep 17 00:00:00 2001
From: Andy Hong
Date: Tue, 17 Mar 2026 00:20:50 +0900
Subject: [PATCH 1/3] =?UTF-8?q?feat:=20CLI=20UX=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 1 +
README.md | 90 +++---
data/test_mapping.yaml | 22 ++
pyproject.toml | 6 +
src/testmagick/cli.py | 104 ++++++
src/testmagick/generate.py | 400 ++++++++++++++++++++++++
src/testmagick/preprocess.py | 30 +-
src/testmagick/renderer.py | 106 ++++++-
src/testmagick/schema.py | 70 ++++-
src/testmagick/templates/_blocks.typ.j2 | 67 +++-
src/testmagick/templates/answer.typ.j2 | 4 +-
src/testmagick/templates/exam.typ.j2 | 10 +-
tests/test_fix_id_rendering.py | 76 +++++
13 files changed, 918 insertions(+), 68 deletions(-)
create mode 100644 data/test_mapping.yaml
create mode 100644 src/testmagick/generate.py
create mode 100644 tests/test_fix_id_rendering.py
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는 구조화된 문제 파일에서 문제지와 정답지를 자동으로 생성하는 로컬 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/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/cli.py b/src/testmagick/cli.py
index c69e26c..c4507c0 100644
--- a/src/testmagick/cli.py
+++ b/src/testmagick/cli.py
@@ -62,6 +62,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 전달용으로 전처리합니다 (텍스트 추출 또는 이미지 압축).",
@@ -375,9 +445,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..7971def
--- /dev/null
+++ b/src/testmagick/generate.py
@@ -0,0 +1,400 @@
+"""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.rule import Rule
+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 사용
+- 완전한 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 _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]]:
+ actual = {p.id: len(p.subproblems or []) for p in exam.problems}
+ 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=8192,
+ 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
+
+ _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..111debe 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)
# ─── 토큰 추정 ────────────────────────────────────────────────────────────────
diff --git a/src/testmagick/renderer.py b/src/testmagick/renderer.py
index e10464d..9bf5d72 100644
--- a/src/testmagick/renderer.py
+++ b/src/testmagick/renderer.py
@@ -12,6 +12,7 @@
ExamSet,
FormulaBlock,
GraphBlock,
+ MappingBlock,
Problem,
QuestionBlock,
RequirementsBlock,
@@ -42,6 +43,22 @@ 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 +82,72 @@ 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]
+ fy = left_ys[fi]
+ 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 +161,8 @@ 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, MappingBlock):
+ return _mapping_payload(block)
if isinstance(block, GraphBlock):
node_pos = {n.id: n.pos for n in block.nodes}
return {
@@ -94,6 +179,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 +259,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"]),
@@ -218,6 +317,7 @@ def _create_environment() -> Environment:
)
env.filters["option_label"] = option_label
env.filters["typst_string"] = typst_string
+ env.filters["typst_cell"] = typst_cell
return env
diff --git a/src/testmagick/schema.py b/src/testmagick/schema.py
index d834b2d..be15dfc 100644
--- a/src/testmagick/schema.py
+++ b/src/testmagick/schema.py
@@ -107,8 +107,76 @@ 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 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],
Field(discriminator="type"),
]
diff --git a/src/testmagick/templates/_blocks.typ.j2 b/src/testmagick/templates/_blocks.typ.j2
index e29d3c4..e3f97d5 100644
--- a/src/testmagick/templates/_blocks.typ.j2
+++ b/src/testmagick/templates/_blocks.typ.j2
@@ -32,18 +32,69 @@
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 == "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,7 +119,17 @@
{%- 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), mark: {% if e.directed %}_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 %})
diff --git a/src/testmagick/templates/answer.typ.j2 b/src/testmagick/templates/answer.typ.j2
index 4c39693..e711bd7 100644
--- a/src/testmagick/templates/answer.typ.j2
+++ b/src/testmagick/templates/answer.typ.j2
@@ -77,7 +77,7 @@
{% for p in problems %}
#block(
width: 100%,
- breakable: true,
+ breakable: false,
above: {% if loop.first %}0pt{% else %}14pt{% endif %},
below: 0pt,
)[
@@ -106,7 +106,7 @@
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..ab49f50 100644
--- a/src/testmagick/templates/exam.typ.j2
+++ b/src/testmagick/templates/exam.typ.j2
@@ -71,7 +71,7 @@
{% for p in problems %}
#block(
width: 100%,
- breakable: true,
+ breakable: false,
above: {% if loop.first %}0pt{% else %}18pt{% endif %},
below: 0pt,
)[
@@ -91,7 +91,7 @@
{% for sub in p.subproblems %}
#block(
width: 100%,
- breakable: true,
+ breakable: false,
above: {% if loop.first %}0pt{% else %}16pt{% endif %},
below: 0pt,
)[
@@ -100,7 +100,7 @@
columns: (auto, 1fr),
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.question_is_typst %}
{{ sub.question_typst }}
@@ -124,7 +124,7 @@
#v(6pt)
#block(
width: 100%,
- height: 25pt,
+ height: 12pt,
stroke: (bottom: 0.4pt + luma(200)),
)[]
{% endif %}
@@ -150,7 +150,7 @@
#pad(left: 16pt)[
#block(
width: 100%,
- height: 30pt,
+ height: 15pt,
stroke: (bottom: 0.4pt + luma(200)),
)[]
]
diff --git a/tests/test_fix_id_rendering.py b/tests/test_fix_id_rendering.py
new file mode 100644
index 0000000..10f0fb5
--- /dev/null
+++ b/tests/test_fix_id_rendering.py
@@ -0,0 +1,76 @@
+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
From a9af7b109b81dae87a504cb640e6c7f5c2989d96 Mon Sep 17 00:00:00 2001
From: Andy Hong
Date: Tue, 17 Mar 2026 13:03:39 +0900
Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=AC=ED=94=84?=
=?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
data/showcase.yaml | 307 ++++++++++++++++++++++++
src/testmagick/builder.py | 2 +-
src/testmagick/cli.py | 54 +++--
src/testmagick/generate.py | 59 ++++-
src/testmagick/preprocess.py | 59 +++++
src/testmagick/renderer.py | 61 ++++-
src/testmagick/schema.py | 58 ++++-
src/testmagick/templates/_blocks.typ.j2 | 12 +-
src/testmagick/templates/answer.typ.j2 | 18 +-
src/testmagick/templates/exam.typ.j2 | 136 ++++++-----
tests/test_fix_id_rendering.py | 1 +
11 files changed, 660 insertions(+), 107 deletions(-)
create mode 100644 data/showcase.yaml
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/src/testmagick/builder.py b/src/testmagick/builder.py
index beb60fe..d67cfaf 100644
--- a/src/testmagick/builder.py
+++ b/src/testmagick/builder.py
@@ -55,7 +55,7 @@ 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 c4507c0..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:
@@ -254,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 = (
@@ -277,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",
]
@@ -307,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)
diff --git a/src/testmagick/generate.py b/src/testmagick/generate.py
index 7971def..e5f496e 100644
--- a/src/testmagick/generate.py
+++ b/src/testmagick/generate.py
@@ -11,7 +11,6 @@
from rich.live import Live
from rich.markup import escape
from rich.panel import Panel
-from rich.rule import Rule
from rich.table import Table
from rich.text import Text
@@ -79,6 +78,11 @@ def _stream_preview(text: str) -> Text:
- 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 블록으로 감싸서 출력.
"""
@@ -164,6 +168,35 @@ def _extract_yaml(response: str) -> str | None:
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
@@ -175,7 +208,12 @@ def _validate_text(yaml_text: str, path: Path):
def _check_expect(exam, expect_map: dict[str, int]) -> list[tuple[str, int, int]]:
- actual = {p.id: len(p.subproblems or []) for p in exam.problems}
+ 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()
@@ -238,7 +276,9 @@ def run_generate(
# ── 전처리 ────────────────────────────────────────────────────────────────
try:
with console.status("문제지 전처리 중...", spinner="dots"):
- prep = preprocess_pdf(pdf_path, out_dir / "prep", method=method, dpi=dpi, quality=quality)
+ prep = preprocess_pdf(
+ pdf_path, out_dir / "prep", method=method, dpi=dpi, quality=quality
+ )
except ImportError as exc:
_err("전처리", f"의존성 오류: {exc}")
return 1
@@ -256,7 +296,10 @@ def run_generate(
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)
+ 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
@@ -292,7 +335,7 @@ def run_generate(
try:
with client.messages.stream(
model=model,
- max_tokens=8192,
+ max_tokens=16000,
system=system_prompt,
messages=messages,
) as stream:
@@ -327,10 +370,14 @@ def run_generate(
if rnd < max_rounds:
messages += [
{"role": "assistant", "content": response_text},
- {"role": "user", "content": "응답에서 YAML 블록을 찾을 수 없습니다. 반드시 ```yaml 블록으로 감싸서 완전한 YAML만 다시 출력해주세요."},
+ {"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")
diff --git a/src/testmagick/preprocess.py b/src/testmagick/preprocess.py
index 111debe..a7f65e9 100644
--- a/src/testmagick/preprocess.py
+++ b/src/testmagick/preprocess.py
@@ -323,6 +323,12 @@ def _run_mixed(
- ["2023", "1,800"]
```
+### text — 일반 텍스트 단락
+```yaml
+- type: text
+ content: "첫째항이 3이고 공비가 2인 등비수열이 있다."
+```
+
### requirements — 조건 박스
```yaml
- type: requirements
@@ -364,10 +370,57 @@ 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 |
|------|-------|
@@ -410,12 +463,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 9bf5d72..79a10e8 100644
--- a/src/testmagick/renderer.py
+++ b/src/testmagick/renderer.py
@@ -12,10 +12,12 @@
ExamSet,
FormulaBlock,
GraphBlock,
+ ImageBlock,
MappingBlock,
Problem,
QuestionBlock,
RequirementsBlock,
+ Section,
SubProblem,
TableBlock,
TypstBlock,
@@ -50,7 +52,8 @@ def _fix_latex_braces(math: str) -> str:
def typst_cell(value: str) -> str:
- """Typst content block [...] 안에서 사용. $...$ 또는 #으로 시작하면 raw, 아니면 #"..." 이스케이프."""
+ """Typst content block [...] 안에서 사용.
+ $...$ 또는 #으로 시작하면 raw, 아니면 #"..." 이스케이프."""
s = value.strip()
if s.startswith("$") and s.endswith("$") and len(s) > 2:
return _fix_latex_braces(s)
@@ -113,7 +116,6 @@ def node_ys(n: int) -> list[float]:
if edge.from_node not in from_idx:
continue
fi = from_idx[edge.from_node]
- fy = left_ys[fi]
targets = edge.to if isinstance(edge.to, list) else [edge.to]
for t in targets:
if t not in to_idx:
@@ -161,6 +163,8 @@ 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):
@@ -308,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"),
@@ -321,18 +347,47 @@ def _create_environment() -> Environment:
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 be15dfc..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
@@ -148,6 +148,13 @@ def _strip_nodes(cls, value: list[str]) -> list[str]:
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)
@@ -176,7 +183,10 @@ def _validate_edges(self) -> MappingBlock:
QuestionBlock = Annotated[
- Union[TextBlock, TypstBlock, FormulaBlock, RequirementsBlock, TableBlock, GraphBlock, MappingBlock],
+ Union[
+ TextBlock, TypstBlock, FormulaBlock, RequirementsBlock,
+ TableBlock, GraphBlock, MappingBlock, ImageBlock,
+ ],
Field(discriminator="type"),
]
@@ -301,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
@@ -457,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
@@ -483,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 e3f97d5..53870ab 100644
--- a/src/testmagick/templates/_blocks.typ.j2
+++ b/src/testmagick/templates/_blocks.typ.j2
@@ -44,6 +44,12 @@
]
#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)[
@@ -124,7 +130,7 @@
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), mark: {% if e.directed %}_aw{% endif %})
+ 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 %}
@@ -132,12 +138,12 @@
{%- 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 e711bd7..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: false,
+ 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,7 +97,7 @@
{% else %}
{% for sub in p.subproblems %}
#v(6pt)
- #pad(left: 16pt)[
+ #pad(left: 18pt)[
#grid(
columns: (auto, 1fr, auto),
column-gutter: 6pt,
diff --git a/src/testmagick/templates/exam.typ.j2 b/src/testmagick/templates/exam.typ.j2
index ab49f50..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: false,
- 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: false,
- 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")[{% if sub.id.startswith('(') %}#{{ sub.id | typst_string }}{% else %}(#{{ sub.id | typst_string }}){% endif %}],
- [
+#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: 12pt,
- 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: 15pt,
- 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
index 10f0fb5..971f754 100644
--- a/tests/test_fix_id_rendering.py
+++ b/tests/test_fix_id_rendering.py
@@ -1,4 +1,5 @@
from pathlib import Path
+
from testmagick.renderer import render_typst_files
from testmagick.schema import ExamSet
From 1518ab2d9c110d323055abc31b8ff05b463e0920 Mon Sep 17 00:00:00 2001
From: Andy Hong
Date: Tue, 17 Mar 2026 13:05:44 +0900
Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=EB=A6=B0=ED=8A=B8=20=ED=95=B4?=
=?UTF-8?q?=EA=B2=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/testmagick/builder.py | 4 ++-
src/testmagick/generate.py | 3 ++-
src/testmagick/preprocess.py | 6 +++--
tests/test_fix_id_rendering.py | 1 +
uv.lock | 46 +++++++++++++++++++++++++++++++++-
5 files changed, 55 insertions(+), 5 deletions(-)
diff --git a/src/testmagick/builder.py b/src/testmagick/builder.py
index d67cfaf..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, yaml_path=input_path)
+ 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/generate.py b/src/testmagick/generate.py
index e5f496e..46ce021 100644
--- a/src/testmagick/generate.py
+++ b/src/testmagick/generate.py
@@ -81,7 +81,8 @@ def _stream_preview(text: str) -> Text:
- 소문제 id에 부모 문제 번호를 포함하지 말 것: "10a"가 아닌 "a", "(1)", "가" 등 사용
- 공통 지문/제시문이 있는 문제 묶음은 반드시 kind: section으로 묶을 것
(예: "다음 행렬 A~H를 이용하여 3~8번 물음에 답하라" → section으로 묶고 제시문은 content_blocks에)
-- Typst에서 ~는 non-breaking space임. 물결표(~) 문자를 title_typst 등에 쓸 때는 \\~로 이스케이프하고 YAML 싱글쿼트로 감쌀 것
+- Typst에서 ~는 non-breaking space임. 물결표(~) 문자를 title_typst 등에 쓸 때는
+ \\~로 이스케이프하고 YAML 싱글쿼트로 감쌀 것
(예: title_typst: '※ [3\\~8번] ...')
- 완전한 YAML만 출력. 반드시 ```yaml 블록으로 감싸서 출력.
"""
diff --git a/src/testmagick/preprocess.py b/src/testmagick/preprocess.py
index a7f65e9..8e9efb5 100644
--- a/src/testmagick/preprocess.py
+++ b/src/testmagick/preprocess.py
@@ -398,9 +398,11 @@ def _run_mixed(
## Section (공통 지문 묶음)
여러 문제가 같은 제시문·데이터를 공유할 때 사용한다.
-문제지에서 "다음 ○○를 이용하여 N~M번 물음에 답하라" 형태의 지문 박스가 있으면 반드시 section으로 묶어라.
+문제지에서 "다음 ○○를 이용하여 N~M번 물음에 답하라" 형태의 지문 박스가 있으면
+반드시 section으로 묶어라.
-**주의**: Typst에서 `~`는 non-breaking space로 처리된다. 물결표 문자(~)를 출력하려면 `\\~`로 이스케이프하고 YAML 싱글쿼트로 감싸야 한다.
+**주의**: Typst에서 `~`는 non-breaking space로 처리된다.
+물결표 문자(~)를 출력하려면 `\\~`로 이스케이프하고 YAML 싱글쿼트로 감싸야 한다.
```yaml
- kind: section
title_typst: '※ [3\\~8번] 다음 행렬 $A$\\~$H$를 이용하여 물음에 답하라.'
diff --git a/tests/test_fix_id_rendering.py b/tests/test_fix_id_rendering.py
index 971f754..04f757d 100644
--- a/tests/test_fix_id_rendering.py
+++ b/tests/test_fix_id_rendering.py
@@ -3,6 +3,7 @@
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",
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"