From c1e2387cedade5bc7650bc67a0db7e534e9e79e4 Mon Sep 17 00:00:00 2001 From: LeeJhin Date: Thu, 11 Jun 2026 15:28:23 +0900 Subject: [PATCH] feat(web): real Korean exam sheet layout for PDF export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시험지 미리보기/PDF 가 실제 한국 시험지 조판으로 출력되도록 전면 재설계. - ExamSheet 재설계: 제목 박스(과목 라벨·발행 정보) + 인적사항 표 (학년·반/번호/이름/점수) + 2단 컬럼·가운데 괘선(명조 본문) 조판 - 객관식 ①~⑤ 보기를 본문에서 분리해 별도 행으로 배치 (lib/exam-choices), 빠른 정답표에서 정답 값을 보기 번호(③)로 매핑 - 빠른 정답을 별지(인쇄 시 새 페이지)의 격자 표로 분리 - 미리보기 = 인쇄와 동일 마크업을 ScaledSheet 로 축소 렌더 (A4 794px) - 인쇄: 화면 UI 를 display:none 처리해 잔여 빈 페이지 제거, column-fill 균형 유지로 한 페이지 미만 분량의 왼단 쏠림 해소, 인쇄 중 document.title 을 시험지 제목으로 바꿔 PDF 파일명 적용 - segmentAuto: 조사가 붙은 수식 토큰(x+2인, b^{2}-c^{2}의) 을 수식+조사로 분리해 ^{} 원문 노출 제거, 부등호 비교(b>c)도 수식으로 인식 검증: Playwright 24/24 (미리보기 구조·보기 분리·정답 매핑·옵션 토글· 배율 재계산·인쇄 2페이지 PDF·8문항 3페이지 흐름), vitest 43/43, build 통과 Co-Authored-By: Claude Fable 5 --- packages/web/__tests__/exam-choices.test.ts | 72 ++++ packages/web/__tests__/latex-renderer.test.ts | 20 ++ packages/web/app/app/new/export/view.tsx | 259 +++++++++++-- packages/web/app/globals.css | 339 +++++++++++------- .../web/components/math/latex-renderer.tsx | 17 +- packages/web/lib/exam-choices.ts | 59 +++ 6 files changed, 593 insertions(+), 173 deletions(-) create mode 100644 packages/web/__tests__/exam-choices.test.ts create mode 100644 packages/web/lib/exam-choices.ts diff --git a/packages/web/__tests__/exam-choices.test.ts b/packages/web/__tests__/exam-choices.test.ts new file mode 100644 index 0000000..2265e52 --- /dev/null +++ b/packages/web/__tests__/exam-choices.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + answerChoiceIndex, + splitChoices, +} from "../lib/exam-choices"; + +/* + * splitChoices — 시험지 조판용 5지선다 보기 분리. + * + * 계약: 마커 ①~⑤ 가 "순서대로 모두" 존재할 때만 분리한다. + * 일부만 있거나 순서가 어긋나면 전체를 본문으로 둔다 (조판이 깨지는 것보다 + * 인라인 유지가 안전). + */ +describe("splitChoices", () => { + it("표준 5지선다를 본문과 보기 5개로 분리한다", () => { + const q = + "이때 a²-b²-c²의 값으로 옳은 것은? ① 8 ② 10 ③ 12 ④ 14 ⑤ 16"; + const { body, choices } = splitChoices(q); + expect(body).toBe("이때 a²-b²-c²의 값으로 옳은 것은?"); + expect(choices).toEqual(["8", "10", "12", "14", "16"]); + }); + + it("LaTeX 조각이 든 보기도 그대로 보존한다", () => { + const q = "다음 중 옳은 것은? ① x^{2} ② 2 x + 1 ③ \\sqrt{3} ④ x - 1 ⑤ 0"; + const { choices } = splitChoices(q); + expect(choices).toEqual(["x^{2}", "2 x + 1", "\\sqrt{3}", "x - 1", "0"]); + }); + + it("마커가 없는 주관식은 분리하지 않는다", () => { + const q = "B와 C의 부피의 합에서 A의 부피를 뺀 식을 간단히 나타내어라."; + expect(splitChoices(q)).toEqual({ body: q, choices: null }); + }); + + it("마커가 일부만 있으면 분리하지 않는다", () => { + const q = "다음 중 옳은 것은? ① 8 ② 10 ③ 12"; + expect(splitChoices(q)).toEqual({ body: q, choices: null }); + }); + + it("마커 순서가 어긋나면 분리하지 않는다", () => { + const q = "② 번 문장을 보고 답하라. ① 8 ③ 12 ④ 14 ⑤ 16"; + expect(splitChoices(q)).toEqual({ body: q, choices: null }); + }); + + it("빈 보기가 생기면 분리하지 않는다", () => { + const q = "옳은 것은? ① ② 10 ③ 12 ④ 14 ⑤ 16"; + expect(splitChoices(q)).toEqual({ body: q, choices: null }); + }); +}); + +describe("answerChoiceIndex", () => { + const choices = ["8", "10", "12", "14", "16"]; + + it("정답 값이 보기와 일치하면 0-base 인덱스를 준다", () => { + expect(answerChoiceIndex("12", choices)).toBe(2); + }); + + it("공백 차이는 무시한다", () => { + expect(answerChoiceIndex("3x+8", ["3 x + 8", "x", "1", "2", "0"])).toBe(0); + }); + + it("일치하는 보기가 없으면 null", () => { + expect(answerChoiceIndex("99", choices)).toBeNull(); + }); + + it("둘 이상 일치하면 모호하므로 null", () => { + expect(answerChoiceIndex("8", ["8", "8", "1", "2", "3"])).toBeNull(); + }); + + it("빈 정답은 null", () => { + expect(answerChoiceIndex(" ", choices)).toBeNull(); + }); +}); diff --git a/packages/web/__tests__/latex-renderer.test.ts b/packages/web/__tests__/latex-renderer.test.ts index b2c0927..92802ea 100644 --- a/packages/web/__tests__/latex-renderer.test.ts +++ b/packages/web/__tests__/latex-renderer.test.ts @@ -179,4 +179,24 @@ describe("segmentAuto", () => { { kind: "text", value: "1단계: 경우를 나눈다\n2단계: 곱한다" }, ]); }); + + it("splits a particle glued to a math run (수식+조사)", () => { + expect(segmentAuto("값이 2a(b-c)+b^{2}+c^{2}+12와 같다.")).toEqual([ + { kind: "text", value: "값이 " }, + { kind: "inline", latex: "2a(b-c)+b^{2}+c^{2}+12" }, + { kind: "text", value: "와 같다." }, + ]); + }); + + it("treats a glued inequality like b>c인 as math + particle", () => { + expect(segmentAuto("b>c인 양수")).toEqual([ + { kind: "inline", latex: "b>c" }, + { kind: "text", value: "인 양수" }, + ]); + }); + + it("leaves ordinary digit-prefixed words as text (10명의, 5곳에)", () => { + const source = "서로 다른 10명의 학생을 5곳에 배치한다."; + expect(segmentAuto(source)).toEqual([{ kind: "text", value: source }]); + }); }); diff --git a/packages/web/app/app/new/export/view.tsx b/packages/web/app/app/new/export/view.tsx index 77a2a87..893b58d 100644 --- a/packages/web/app/app/new/export/view.tsx +++ b/packages/web/app/app/new/export/view.tsx @@ -1,10 +1,15 @@ "use client"; import Link from "next/link"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { LatexAuto } from "@/components/math/latex-renderer"; import { type Grade, type SchoolLevel, type Topic, gradeLabel } from "../topic/data"; import { verificationStorageKey } from "@/lib/verification-storage-key"; +import { + CHOICE_MARKERS, + answerChoiceIndex, + splitChoices, +} from "@/lib/exam-choices"; import type { ResultProblem } from "../result/types"; function buildDefaultTitle( @@ -104,60 +109,217 @@ function toResultProblem(problem: StoredProblem, index: number): ResultProblem { status, questionLatex: problem.question_latex, answerLatex: problem.answer_latex, - solutionLatex: problem.explanation_latex ?? "검증 파이프라인에서 생성된 문항입니다.", + solutionLatex: problem.explanation_latex ?? null, failReason: status === "fail" ? "검증 실패 — 채택할 수 없습니다." : null, }; } +/* ───────────────────────────────────────────────────────────── + * ExamSheet — 한국 시험지 표준 조판. + * 1면: 제목 박스(과목 라벨 + 제목 + 발행 정보) → 인적사항 표 + * → 2단 컬럼(가운데 괘선) 문항. 객관식은 ①~⑤ 보기를 분리 조판. + * 별지: "빠른 정답" 표 (인쇄 시 새 페이지). + * preview / print 가 같은 마크업·px 스타일을 공유한다 — 미리보기는 + * ScaledSheet 가 축소만 하고, 인쇄는 @page 마진 안에 1:1 로 흐른다. + * ──────────────────────────────────────────────────────────── */ + +/* 보기 5개가 길면 한 줄 배치가 깨지므로 세로 스택으로 전환 */ +const CHOICE_STACK_THRESHOLD = 40; + +type AnswerEntry = { + id: string; + label: number; + /* 보기와 일치하면 ①~⑤ 마커, 아니면 원본 정답 문자열 */ + marker: string | null; + raw: string; +}; + +function answerEntries(problems: ResultProblem[]): AnswerEntry[] { + return problems.map((p, i) => { + const { choices } = splitChoices(p.questionLatex); + const idx = choices === null ? null : answerChoiceIndex(p.answerLatex, choices); + return { + id: p.id, + label: i + 1, + marker: idx === null ? null : (CHOICE_MARKERS[idx] ?? null), + raw: p.answerLatex, + }; + }); +} + +function chunk(arr: readonly T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +function ExamProblem({ problem, label }: { problem: ResultProblem; label: number }) { + const { body, choices } = splitChoices(problem.questionLatex); + const stacked = + choices !== null && choices.join("").length > CHOICE_STACK_THRESHOLD; + return ( +
  • +

    + {label}.{" "} + +

    + {choices !== null ? ( +
      + {choices.map((choice, k) => ( +
    1. + + + + +
    2. + ))} +
    + ) : null} +
  • + ); +} + +/* 빠른 정답 — 한 줄 5문항씩, 문항 행(음영) + 정답 행 반복. + * 마지막 줄은 빈 칸으로 채워 격자를 고르게 유지한다. + */ +const ANSWERS_PER_ROW = 5; + +function AnswerTable({ entries }: { entries: AnswerEntry[] }) { + const rows = chunk(entries, ANSWERS_PER_ROW); + return ( + + + {rows.map((row, ri) => { + const pad = Array.from({ length: ANSWERS_PER_ROW - row.length }); + return ( + + + {row.map((e) => ( + + ))} + {pad.map((_, k) => ( + + + {row.map((e) => ( + + ))} + {pad.map((_, k) => ( + + + ); + })} + +
    + {e.label} +
    + {e.marker !== null ? e.marker : } +
    + ); +} + function ExamSheet({ options, problems, date, + subject, }: { options: Options; problems: ResultProblem[]; date: string; + subject: string; }) { + const title = options.title.length > 0 ? options.title : "수학 시험지"; + const issueParts: string[] = []; + if (options.showDate && date.length > 0) issueParts.push(date); + issueParts.push(`총 ${problems.length}문항`); return (
    -
    -

    {options.title.length > 0 ? options.title : "수학 시험지"}

    - {options.showDate && date.length > 0 ? ( -

    {date}

    - ) : null} -
    - 이름: __________ - 반: ____ - 번호: ____ -
    -
    -
      - {problems.map((p, i) => ( -
    1. - {i + 1}. - - - -
    2. - ))} -
    +
    +
    +
    +

    {subject}

    +

    {title}

    +

    {issueParts.join(" · ")}

    +
    +
    + 학년 · 반 + + 번호 + + 이름 + + 점수 + +
    +
    +
      + {problems.map((p, i) => ( + + ))} +
    +
    {options.includeAnswers && problems.length > 0 ? ( -
    -

    정답

    -
      - {problems.map((p, i) => ( -
    1. - {i + 1}. - -
    2. - ))} -
    +
    +

    빠른 정답

    +

    {title}

    +
    ) : null}
    ); } +/* ─── ScaledSheet — A4(794px) 시트를 미리보기 폭에 맞춰 축소 ─── + * transform: scale 은 레이아웃 높이에 반영되지 않으므로 실측 높이에 + * 배율을 곱해 wrapper 높이를 고정한다. + */ +const A4_WIDTH_PX = 794; + +function ScaledSheet({ children }: { children: React.ReactNode }) { + const outerRef = useRef(null); + const innerRef = useRef(null); + const [scale, setScale] = useState(0.6); + const [height, setHeight] = useState(null); + + useEffect(() => { + const update = (): void => { + const outer = outerRef.current; + const inner = innerRef.current; + if (outer === null || inner === null) return; + const next = Math.min(1, outer.clientWidth / A4_WIDTH_PX); + setScale(next); + setHeight(inner.offsetHeight * next); + }; + update(); + const observer = new ResizeObserver(update); + if (outerRef.current !== null) observer.observe(outerRef.current); + if (innerRef.current !== null) observer.observe(innerRef.current); + return () => observer.disconnect(); + }, []); + + return ( +
    +
    + {children} +
    +
    + ); +} + export function ExportView({ schoolLevel, grade, @@ -220,9 +382,16 @@ export function ExportView({ /* afterprint — 사용자가 시스템 print dialog 를 닫은 시점. * 저장/취소 여부는 브라우저 API 가 노출하지 않으므로 일괄 "완료" 로 표기. + * 인쇄 동안 document.title 을 시험지 제목으로 바꿔 PDF 파일명으로 쓰고, + * 끝나면 원래 탭 제목으로 되돌린다. */ + const tabTitleRef = useRef(null); useEffect(() => { const onAfterPrint = (): void => { + if (tabTitleRef.current !== null) { + document.title = tabTitleRef.current; + tabTitleRef.current = null; + } setDownloaded(true); }; window.addEventListener("afterprint", onAfterPrint); @@ -285,10 +454,18 @@ export function ExportView({ const onDownload = (): void => { setDownloaded(false); - /* 브라우저 print API — 시스템 dialog 에서 "PDF 로 저장" 선택. */ + /* 브라우저 print API — 시스템 dialog 에서 "PDF 로 저장" 선택. + * 저장 파일명은 document.title 을 따르므로 잠시 시험지 제목으로 교체. + */ + const fileTitle = options.title.trim(); + if (fileTitle.length > 0) { + tabTitleRef.current = document.title; + document.title = fileTitle; + } window.print(); }; const backHref = resultHref(schoolLevel, grade, topic, mode, sourceItemId); + const examSubject = `${gradeLabel(grade, schoolLevel)} · ${topic.name}`; /* 옵션 변경 핸들러 */ const setTitle = (v: string): void => @@ -344,11 +521,14 @@ export function ExportView({
    - + + +
    @@ -528,6 +708,7 @@ export function ExportView({ options={options} problems={orderedProblems} date={date} + subject={examSubject} />
    diff --git a/packages/web/app/globals.css b/packages/web/app/globals.css index bf28f4f..c1011be 100644 --- a/packages/web/app/globals.css +++ b/packages/web/app/globals.css @@ -45,6 +45,8 @@ --font-sans: "Inter", "Noto Sans KR", system-ui, -apple-system, sans-serif; --font-serif: "Fraunces", "Noto Serif KR", serif; --font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", monospace; + /* 시험지 본문 — 한국 시험지 표준 명조 계열 (인쇄물과 동일 인상) */ + --font-exam: "Noto Serif KR", "Batang", "AppleMyungjo", "Nanum Myeongjo", serif; --spacing-section: 48px; --spacing-hero: 96px; @@ -1844,8 +1846,8 @@ /* S6 — export layout */ .export-layout { display: grid; - grid-template-columns: 60% 40%; - gap: 32px; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 48px; align-items: start; } @media (max-width: 900px) { @@ -1856,88 +1858,201 @@ } /* pdf-preview-thumbnail — DESIGN.md {component.pdf-preview-thumbnail} - * A4 1:1.41 비율, soft-cloud bg, 시험지 첫 페이지 mini-render. + * 시험지 미리보기 배경판. 실제 A4 시트(.exam-sheet)를 ScaledSheet 가 + * 컨테이너 폭에 맞춰 축소해 보여준다 — 인쇄와 동일한 마크업/스타일. */ .pdf-preview-thumbnail { background: var(--color-soft-cloud); - color: var(--color-ink); - aspect-ratio: 1 / 1.41; + border: 1px solid var(--color-hairline-soft); + padding: 28px; + } + .sheet-scaler { width: 100%; - max-width: 420px; - padding: 32px 28px; - font-family: var(--font-sans); overflow: hidden; - border: 1px solid var(--color-hairline-soft); } - .pdf-preview-thumbnail .exam-header h2 { - font-family: var(--font-serif); - font-weight: 500; - font-size: 15px; - line-height: 1.25; - color: var(--color-ink); - margin: 0 0 6px; + .sheet-scaler-inner { + width: 794px; + transform-origin: top left; } - .pdf-preview-thumbnail .exam-header .meta { - font-family: var(--font-mono); - font-size: 10px; - color: var(--color-ink-3); - letter-spacing: 0.04em; - margin: 0 0 4px; + .pdf-preview-thumbnail .exam-page { + box-shadow: + 0 1px 2px rgba(15, 15, 15, 0.12), + 0 10px 32px rgba(15, 15, 15, 0.1); } - .pdf-preview-thumbnail .exam-header .name-fields { - font-size: 10px; - color: var(--color-ink-3); - border-top: 1px solid var(--color-hairline); - border-bottom: 1px solid var(--color-hairline); - padding: 6px 0; - margin: 8px 0 14px; - display: flex; - gap: 12px; + .pdf-preview-thumbnail .exam-page + .exam-page { + margin-top: 28px; } - .pdf-preview-thumbnail .exam-problems { - list-style: none; - padding: 0; + + /* ─── exam-sheet — 한국 시험지 조판 (preview/print 공용) ─── + * px 고정 — 미리보기는 transform 축소, 인쇄는 1:1 (96dpi 기준 + * 794px = 210mm). 종이는 테마와 무관하게 흰 바탕 + 검정 잉크. + * 셀 음영은 브라우저 기본 인쇄 설정(배경 미인쇄)에서 사라져도 + * 구조가 유지되도록 괘선만으로 성립해야 한다. + */ + .exam-sheet { + font-family: var(--font-exam); + color: #161616; + } + .exam-page { + width: 794px; /* 210mm */ + min-height: 1123px; /* 297mm */ + padding: 60px 53px; /* ≈ 16mm 14mm — @page 마진과 일치 */ + background: #ffffff; + } + + /* 머리 — 제목 박스: 상단 굵은 괘선, 하단 가는 괘선 */ + .exam-head { + margin-bottom: 30px; + } + .exam-head-box { + border-top: 3px solid #161616; + border-bottom: 1px solid #161616; + padding: 20px 10px 16px; + text-align: center; + } + .exam-subject { + margin: 0 0 10px; + font-size: 12px; + letter-spacing: 0.22em; + color: #3a3a3a; + } + .exam-title { margin: 0; - display: flex; - flex-direction: column; - gap: 8px; + font-size: 26px; + font-weight: 600; + line-height: 1.35; + letter-spacing: 0.01em; + word-break: keep-all; } - .pdf-preview-thumbnail .exam-problem { - font-size: 11px; - line-height: 1.4; - display: grid; - grid-template-columns: 16px 1fr; - gap: 4px; - color: var(--color-ink); + .exam-issue { + margin: 10px 0 0; + font-size: 11.5px; + color: #4a4a4a; + white-space: pre-wrap; } - .pdf-preview-thumbnail .exam-problem .num { - font-weight: 500; + + /* 인적사항 — 제목 박스 아래 붙는 한 줄 기입란 */ + .exam-id-row { + display: flex; + align-items: stretch; + border: 1px solid #161616; + border-top: 0; + font-size: 11.5px; + } + .exam-id-row .label { + padding: 8px 14px; + background: #f4f4f2; + border-right: 1px solid #161616; + white-space: nowrap; + display: flex; + align-items: center; } - .pdf-preview-thumbnail .katex { - font-size: 0.9em; + .exam-id-row .blank { + flex: 1; + min-width: 48px; + border-right: 1px solid #161616; } - .pdf-preview-thumbnail .exam-answers { - margin-top: 14px; - padding-top: 8px; - border-top: 1px solid var(--color-hairline-soft); - font-size: 10px; - color: var(--color-ink-3); + .exam-id-row .blank.wide { + flex: 1.9; } - .pdf-preview-thumbnail .exam-answers h3 { - font-family: var(--font-mono); - font-size: 10px; - font-weight: 500; - letter-spacing: 0.04em; - color: var(--color-ink-2); - margin: 0 0 4px; + .exam-id-row > :last-child { + border-right: 0; } - .pdf-preview-thumbnail .exam-answers ol { + + /* 본문 — 2단 컬럼, 가운데 세로 괘선 */ + .exam-cols { list-style: none; + margin: 0; padding: 0; + column-count: 2; + column-gap: 44px; + column-rule: 1px solid #9a9a9a; + column-fill: balance; + } + .exam-q { + break-inside: avoid; + page-break-inside: avoid; + margin-bottom: 38px; + font-size: 13.5px; + line-height: 1.92; + } + .exam-q-text { margin: 0; + word-break: keep-all; + overflow-wrap: anywhere; + } + .exam-q-num { + font-weight: 700; + font-size: 14.5px; + margin-right: 2px; + } + .exam-q .katex { + font-size: 1.04em; + } + + /* 보기 ①~⑤ — 짧으면 가로 분산, 길면 세로 스택(.stacked) */ + .exam-choices { + list-style: none; + margin: 13px 0 0; + padding: 0 0 0 6px; display: flex; + flex-wrap: wrap; + column-gap: 28px; + row-gap: 8px; + } + .exam-choices li { + display: inline-flex; + align-items: baseline; + gap: 7px; + } + .exam-choices.stacked { flex-direction: column; - gap: 3px; + row-gap: 9px; + } + .exam-choice-marker { + flex-shrink: 0; + } + + /* 별지 — 빠른 정답 */ + .exam-answers-title { + margin: 0; + border-top: 3px solid #161616; + border-bottom: 1px solid #161616; + padding: 16px 0; + text-align: center; + font-size: 20px; + font-weight: 600; + letter-spacing: 0.35em; + text-indent: 0.35em; /* letter-spacing 끝글자 보정 */ + } + .exam-answers-sub { + margin: 10px 0 26px; + text-align: center; + font-size: 11.5px; + color: #4a4a4a; + } + .exam-answers-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + } + .exam-answers-table th, + .exam-answers-table td { + border: 1px solid #161616; + text-align: center; + vertical-align: middle; + } + .exam-answers-table .num-row th { + background: #f4f4f2; + padding: 8px 6px; + font-size: 12px; + font-weight: 600; + } + .exam-answers-table .ans-row td { + padding: 12px 8px; + font-size: 13.5px; + word-break: keep-all; + overflow-wrap: anywhere; } /* options pane (S6 우 40%) */ @@ -2019,89 +2134,35 @@ line-height: 1.5; } - /* print-root — 화면에선 숨김, 인쇄에서만 노출되는 A4 풀-페이지 */ + /* print-root — 화면에선 숨김, 인쇄에서만 노출되는 A4 풀-페이지. + * 시트 스타일은 .exam-sheet 공용 규칙을 그대로 쓰고, 여기서는 + * 종이 흉내(고정폭/여백/그림자)만 끄고 @page 마진에 맡긴다. + */ .print-root { display: none; } - .print-root .exam-sheet { - color: var(--color-ink); - font-family: var(--font-sans); - } - .print-root .exam-header h2 { - font-family: var(--font-serif); - font-size: 22pt; - font-weight: 500; - line-height: 1.15; - margin: 0 0 6mm; - } - .print-root .exam-header .meta { - font-family: var(--font-mono); - font-size: 10pt; - margin: 0 0 4mm; - color: var(--color-ink-3); - } - .print-root .exam-header .name-fields { - font-size: 11pt; - border-top: 0.5pt solid var(--color-ink); - border-bottom: 0.5pt solid var(--color-ink); - padding: 3mm 0; - margin: 6mm 0 10mm; - display: flex; - gap: 12mm; - } - .print-root .exam-problems { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 10mm; - } - .print-root .exam-problem { - font-size: 12pt; - line-height: 1.55; - display: grid; - grid-template-columns: 8mm 1fr; - gap: 2mm; - page-break-inside: avoid; - break-inside: avoid; - } - .print-root .exam-problem .num { - font-weight: 500; - } - .print-root .exam-answers { - margin-top: 14mm; - padding-top: 4mm; - border-top: 0.5pt solid var(--color-ink); - page-break-before: auto; - } - .print-root .exam-answers h3 { - font-family: var(--font-serif); - font-size: 14pt; - font-weight: 500; - margin: 0 0 4mm; - } - .print-root .exam-answers ol { - list-style: none; - padding: 0; - margin: 0; - display: flex; - flex-direction: column; - gap: 3mm; - } @media print { @page { size: A4; - margin: 18mm; + margin: 16mm 14mm; } html, body { - background: var(--color-canvas-pure); + background: #ffffff; } body * { visibility: hidden !important; } + /* 화면 UI 는 visibility 만으로는 박스 높이가 남아 끝에 빈 페이지를 + * 만든다 — 자리 자체를 제거한다. (print-root 는 이들의 형제 노드) + */ + .sub-nav, + .page-body, + .action-bar-sticky, + .inline-notice { + display: none !important; + } .print-root, .print-root * { visibility: visible !important; @@ -2113,6 +2174,20 @@ left: 0; width: 100%; } + .print-root .exam-page { + width: auto; + min-height: 0; + padding: 0; + } + .print-root .exam-page + .exam-page { + margin-top: 0; + break-before: page; + page-break-before: always; + } + /* column-fill 은 공용 balance 를 그대로 쓴다 — 인쇄에서 가득 찬 + * 페이지는 순차로 채워지고 마지막 페이지만 좌우 균형이 맞는다. + * (auto 로 두면 한 페이지 미만 분량이 왼단에만 몰린다) + */ } /* empty state (S2 filter 결과 0건) */ diff --git a/packages/web/components/math/latex-renderer.tsx b/packages/web/components/math/latex-renderer.tsx index ff7ddeb..6527ce3 100644 --- a/packages/web/components/math/latex-renderer.tsx +++ b/packages/web/components/math/latex-renderer.tsx @@ -192,8 +192,13 @@ export function LatexMixed({ source }: LatexMixedProps) { */ const MATH_WORD = /^[A-Za-z0-9\\^_{}+\-*/=<>().,:%|']+$/; -const MATH_SIGNAL = /\\[a-zA-Z]+|[\^_]\{|=|[0-9A-Za-z)] ?[+\-*/] ?[0-9A-Za-z(]/; +const MATH_SIGNAL = /\\[a-zA-Z]+|[\^_]\{|=|[0-9A-Za-z)] ?[+\-*/<>] ?[0-9A-Za-z(]/; const TRAILING_PUNCT = /[.,]+$/; +/* 조사가 수식에 바로 붙은 토큰 ("x+2인", "b^{2}-c^{2}의") — ASCII 수식 + * 머리(2자 이상)와 나머지 꼬리로 쪼갠다. 머리에 수식 신호가 있을 때만 + * 수식으로 취급해 "10명의" 같은 일반 단어는 건드리지 않는다. + */ +const GLUED_MATH = /^([A-Za-z0-9\\^_{}+\-*/=<>().,:%|']{2,})(.+)$/; export function segmentAuto(source: string): Segment[] { if (/\$|\\\(|\\\[/.test(source)) return splitDelimited(source); @@ -210,7 +215,15 @@ export function segmentAuto(source: string): Segment[] { while (i < tokens.length) { const token = tokens[i] ?? ""; if (token.length === 0 || !MATH_WORD.test(token)) { - text += token; + const glued = token.match(GLUED_MATH); + const head = glued?.[1] ?? ""; + if (glued !== null && MATH_SIGNAL.test(head)) { + flushText(); + out.push({ kind: "inline", latex: head }); + text += glued[2] ?? ""; + } else { + text += token; + } i += 1; continue; } diff --git a/packages/web/lib/exam-choices.ts b/packages/web/lib/exam-choices.ts new file mode 100644 index 0000000..cc0b7eb --- /dev/null +++ b/packages/web/lib/exam-choices.ts @@ -0,0 +1,59 @@ +/* 객관식 보기(①~⑤) 분리 — 시험지 조판용. + * + * 에이전트가 주는 question_latex 는 5지선다 보기까지 본문 끝에 인라인으로 + * 붙어 온다 ("...옳은 것은? ① 8 ② 10 ③ 12 ④ 14 ⑤ 16"). 시험지에서는 + * 보기를 별도 행으로 조판해야 하므로, 표준 마커 ①~⑤ 가 순서대로 모두 + * 존재할 때만 본문과 보기를 분리한다. 그 외(주관식, 마커 일부만 존재, + * 순서 어긋남)는 전체를 본문으로 둔다. + */ + +export const CHOICE_MARKERS = ["①", "②", "③", "④", "⑤"] as const; + +export type SplitQuestion = { + body: string; + choices: string[] | null; +}; + +export function splitChoices(question: string): SplitQuestion { + const whole: SplitQuestion = { body: question, choices: null }; + const start = question.indexOf(CHOICE_MARKERS[0]); + if (start === -1) return whole; + + const tail = question.slice(start); + const positions: number[] = []; + for (const marker of CHOICE_MARKERS) { + const pos = tail.indexOf(marker); + const prev = positions[positions.length - 1] ?? -1; + if (pos === -1 || pos <= prev) return whole; + positions.push(pos); + } + + const choices: string[] = []; + for (let k = 0; k < positions.length; k++) { + const from = (positions[k] ?? 0) + 1; + const to = k + 1 < positions.length ? positions[k + 1] : tail.length; + const choice = tail.slice(from, to).trim(); + if (choice.length === 0) return whole; + choices.push(choice); + } + return { body: question.slice(0, start).trim(), choices }; +} + +/* 정답 문자열이 보기 중 정확히 하나와 일치하면 그 인덱스(0-base)를 준다. + * 빠른 정답표에서 "12" 대신 "③" 으로 표기하기 위한 매핑. 공백 차이 + * ("3x+8" vs "3 x + 8")는 무시하고, 둘 이상 일치하면 모호하므로 포기. + */ +export function answerChoiceIndex( + answer: string, + choices: readonly string[], +): number | null { + const norm = (s: string): string => s.replace(/\s+/g, ""); + const target = norm(answer); + if (target.length === 0) return null; + const hits: number[] = []; + choices.forEach((choice, index) => { + if (norm(choice) === target) hits.push(index); + }); + const only = hits[0]; + return hits.length === 1 && only !== undefined ? only : null; +}