python-hwpx-automation 6.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Exam re-typesetting (조판): authored exam Markdown -> school form .hwpx body."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .compose import (
|
|
6
|
+
ComposePlan,
|
|
7
|
+
ComposeResult,
|
|
8
|
+
ParaSpec,
|
|
9
|
+
compose_exam_into_form,
|
|
10
|
+
lower_exam,
|
|
11
|
+
replace_body_region,
|
|
12
|
+
)
|
|
13
|
+
from .ir import ExamDoc, Placeholder, Question, QuestionSet
|
|
14
|
+
from .measure import (
|
|
15
|
+
SplitReport,
|
|
16
|
+
column_x_bounds,
|
|
17
|
+
group_question_blocks,
|
|
18
|
+
measure_question_splits,
|
|
19
|
+
)
|
|
20
|
+
from .parser import ExamParseError, parse_exam_markdown
|
|
21
|
+
from .profile import FormProfile, FormProfileError, ResolvedStyle, profile_form
|
|
22
|
+
|
|
23
|
+
__all__ = [ # noqa: RUF022 - frozen public export order
|
|
24
|
+
"ExamDoc",
|
|
25
|
+
"Placeholder",
|
|
26
|
+
"Question",
|
|
27
|
+
"QuestionSet",
|
|
28
|
+
"ExamParseError",
|
|
29
|
+
"parse_exam_markdown",
|
|
30
|
+
"FormProfile",
|
|
31
|
+
"FormProfileError",
|
|
32
|
+
"ResolvedStyle",
|
|
33
|
+
"profile_form",
|
|
34
|
+
"column_x_bounds",
|
|
35
|
+
"group_question_blocks",
|
|
36
|
+
"measure_question_splits",
|
|
37
|
+
"SplitReport",
|
|
38
|
+
"ParaSpec",
|
|
39
|
+
"lower_exam",
|
|
40
|
+
"replace_body_region",
|
|
41
|
+
"ComposePlan",
|
|
42
|
+
"ComposeResult",
|
|
43
|
+
"compose_exam_into_form",
|
|
44
|
+
]
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Lower the Exam IR into the school form's body region.
|
|
2
|
+
|
|
3
|
+
Strategy (Phase-0 grounded): map each role onto the form's EXISTING style,
|
|
4
|
+
attach keepWithNext (column cohesion) to every paragraph of a 문항 except its
|
|
5
|
+
last, and INSERT into the body region [body_start..body_end] — never append
|
|
6
|
+
(Hancom drops appended content)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from hwpx.document import HwpxDocument
|
|
13
|
+
|
|
14
|
+
from ..form_fill.fit.wordbox import (
|
|
15
|
+
detect_overflow,
|
|
16
|
+
extract_cell_clips,
|
|
17
|
+
extract_glyph_boxes,
|
|
18
|
+
)
|
|
19
|
+
from ..rendering import resolve_hancom_oracle
|
|
20
|
+
from .ir import ExamDoc, Question, QuestionSet
|
|
21
|
+
from .measure import measure_question_splits
|
|
22
|
+
from .parser import parse_exam_markdown
|
|
23
|
+
from .profile import FormProfile, ResolvedStyle, profile_form
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(slots=True)
|
|
27
|
+
class ParaSpec:
|
|
28
|
+
text: str
|
|
29
|
+
role: str # key into FormProfile.role_styles
|
|
30
|
+
keep_with_next: bool
|
|
31
|
+
is_question_head: bool
|
|
32
|
+
question_number: str | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _choice_role(n_choices: int, idx: int) -> str:
|
|
36
|
+
# answer-row styles encode a "rows" hint; default to choice1, fall back gracefully
|
|
37
|
+
return "choice1"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _lower_question(q: Question) -> list[ParaSpec]:
|
|
41
|
+
specs: list[ParaSpec] = [
|
|
42
|
+
ParaSpec(
|
|
43
|
+
text=f"{q.number}. {q.stem}".rstrip(),
|
|
44
|
+
role="number",
|
|
45
|
+
keep_with_next=True,
|
|
46
|
+
is_question_head=True,
|
|
47
|
+
question_number=q.number,
|
|
48
|
+
)
|
|
49
|
+
]
|
|
50
|
+
for i, choice in enumerate(q.choices):
|
|
51
|
+
specs.append(
|
|
52
|
+
ParaSpec(
|
|
53
|
+
text=choice,
|
|
54
|
+
role=_choice_role(len(q.choices), i),
|
|
55
|
+
keep_with_next=True,
|
|
56
|
+
is_question_head=False,
|
|
57
|
+
question_number=q.number,
|
|
58
|
+
)
|
|
59
|
+
)
|
|
60
|
+
# Placeholders ([그림N]/[표N]/[식N]) are preserved verbatim INSIDE the stem text
|
|
61
|
+
# by the parser (in authored order), so they are NOT re-emitted here — doing so
|
|
62
|
+
# rendered each one twice (once inline, once standalone). The Placeholder IR
|
|
63
|
+
# entries remain as metadata for the gate's integrity check.
|
|
64
|
+
if specs:
|
|
65
|
+
specs[
|
|
66
|
+
-1
|
|
67
|
+
].keep_with_next = False # last para of the 문항 may break before the next
|
|
68
|
+
return specs
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def lower_exam(exam: ExamDoc, profile: FormProfile) -> list[ParaSpec]:
|
|
72
|
+
specs: list[ParaSpec] = []
|
|
73
|
+
for block in exam.blocks:
|
|
74
|
+
if isinstance(block, QuestionSet):
|
|
75
|
+
specs.append(
|
|
76
|
+
ParaSpec(
|
|
77
|
+
text=block.passage,
|
|
78
|
+
role="box",
|
|
79
|
+
keep_with_next=True,
|
|
80
|
+
is_question_head=False,
|
|
81
|
+
question_number=None,
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
for member in block.members:
|
|
85
|
+
specs.extend(_lower_question(member))
|
|
86
|
+
else:
|
|
87
|
+
specs.extend(_lower_question(block))
|
|
88
|
+
# drop roles the form lacks -> fall back to "normal"
|
|
89
|
+
for s in specs:
|
|
90
|
+
if s.role not in profile.role_styles:
|
|
91
|
+
s.role = "normal"
|
|
92
|
+
return specs
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _keep_para_pr(
|
|
96
|
+
doc: HwpxDocument, base: ResolvedStyle, keep: bool, cache: dict
|
|
97
|
+
) -> str | None:
|
|
98
|
+
key = (base.name, keep)
|
|
99
|
+
if key in cache:
|
|
100
|
+
return cache[key]
|
|
101
|
+
pid = doc.oxml.headers[0].ensure_paragraph_format(
|
|
102
|
+
base_para_pr_id=base.para_pr_id,
|
|
103
|
+
break_setting={"keep_with_next": keep, "keep_lines": True},
|
|
104
|
+
)
|
|
105
|
+
cache[key] = pid
|
|
106
|
+
return pid
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(slots=True)
|
|
110
|
+
class ComposePlan:
|
|
111
|
+
"""Snapshot of a pending composition: specs + the target profile.
|
|
112
|
+
|
|
113
|
+
Passed to replace_body_region when the caller wants to defer materialization
|
|
114
|
+
(e.g. inspect specs before writing to the document)."""
|
|
115
|
+
|
|
116
|
+
specs: list[ParaSpec]
|
|
117
|
+
profile: FormProfile
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def replace_body_region(
|
|
121
|
+
doc: HwpxDocument, profile: FormProfile, specs: list[ParaSpec]
|
|
122
|
+
) -> dict[str, int]:
|
|
123
|
+
section = doc.sections[0]
|
|
124
|
+
paragraphs = section.paragraphs
|
|
125
|
+
start, end = profile.body_start, profile.body_end
|
|
126
|
+
old_slots = [
|
|
127
|
+
paragraphs[i] for i in range(start, end + 1)
|
|
128
|
+
] # capture wrappers up front
|
|
129
|
+
|
|
130
|
+
cache: dict = {}
|
|
131
|
+
temp = []
|
|
132
|
+
for spec in specs:
|
|
133
|
+
style = profile.role_styles.get(spec.role) or profile.role_styles["normal"]
|
|
134
|
+
para_pr_id = _keep_para_pr(doc, style, spec.keep_with_next, cache)
|
|
135
|
+
temp.append(
|
|
136
|
+
section.add_paragraph( # appended to tail; cloned into body below, then removed
|
|
137
|
+
spec.text,
|
|
138
|
+
para_pr_id_ref=para_pr_id,
|
|
139
|
+
style_id_ref=style.style_id,
|
|
140
|
+
char_pr_id_ref=style.char_pr_id,
|
|
141
|
+
inherit_style=False,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
section.insert_paragraphs(start, temp) # deep-clone into the body
|
|
146
|
+
for wrapper in reversed(temp):
|
|
147
|
+
section.remove_paragraph(wrapper) # drop the temporary tail copies
|
|
148
|
+
for wrapper in old_slots:
|
|
149
|
+
section.remove_paragraph(wrapper) # drop the old form slots
|
|
150
|
+
section.mark_dirty()
|
|
151
|
+
|
|
152
|
+
# `inserted` is index-aligned with `specs`; each 문항 head now lives at
|
|
153
|
+
# body-region offset `start + offset`. Build the convergence anchor map.
|
|
154
|
+
anchors: dict[str, int] = {}
|
|
155
|
+
for offset, spec in enumerate(specs):
|
|
156
|
+
if spec.is_question_head and spec.question_number is not None:
|
|
157
|
+
anchors[spec.question_number] = start + offset
|
|
158
|
+
return anchors
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
# Convergence driver (Task 8)
|
|
163
|
+
# ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class ComposeResult:
|
|
168
|
+
out_path: str
|
|
169
|
+
render_checked: bool
|
|
170
|
+
splits: int | None
|
|
171
|
+
overflow: int | None
|
|
172
|
+
placeholders_ok: bool
|
|
173
|
+
rounds: int
|
|
174
|
+
needs_review: bool
|
|
175
|
+
notes: tuple[str, ...]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _insert_break(section, head_index: int, kind: str) -> None:
|
|
179
|
+
attr = "pageBreak" if kind == "page" else "columnBreak"
|
|
180
|
+
section.paragraphs[head_index].element.set(attr, "1")
|
|
181
|
+
section.mark_dirty()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def compose_exam_into_form(
|
|
185
|
+
form_path: str,
|
|
186
|
+
exam_md: str,
|
|
187
|
+
out_path: str,
|
|
188
|
+
*,
|
|
189
|
+
oracle=None,
|
|
190
|
+
max_rounds: int = 2,
|
|
191
|
+
role_style_names=None,
|
|
192
|
+
) -> ComposeResult:
|
|
193
|
+
notes: list[str] = []
|
|
194
|
+
exam = parse_exam_markdown(exam_md)
|
|
195
|
+
expected_ph = {ph.raw_text for q in exam.iter_questions() for ph in q.placeholders}
|
|
196
|
+
|
|
197
|
+
doc = HwpxDocument.open(form_path)
|
|
198
|
+
prof = profile_form(doc, role_style_names=role_style_names)
|
|
199
|
+
specs = lower_exam(exam, prof)
|
|
200
|
+
anchors = replace_body_region(doc, prof, specs)
|
|
201
|
+
doc.save_to_path(out_path)
|
|
202
|
+
notes.append(
|
|
203
|
+
f"composed {len(list(exam.iter_questions()))} 문항 into body"
|
|
204
|
+
f" [{prof.body_start}..{prof.body_end}]"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if oracle is None:
|
|
208
|
+
oracle = resolve_hancom_oracle()
|
|
209
|
+
if not oracle.available():
|
|
210
|
+
notes.append(
|
|
211
|
+
"oracle unavailable -> render_checked=false, needs_review=true (no silent true)"
|
|
212
|
+
)
|
|
213
|
+
return ComposeResult(out_path, False, None, None, True, 0, True, tuple(notes))
|
|
214
|
+
|
|
215
|
+
valid_ids = set(anchors)
|
|
216
|
+
n_questions = len(valid_ids)
|
|
217
|
+
rounds = 0
|
|
218
|
+
splits = overflow = None
|
|
219
|
+
placeholders_ok = True
|
|
220
|
+
while rounds < max_rounds:
|
|
221
|
+
rounds += 1
|
|
222
|
+
pdf = oracle.render_pdf(out_path)
|
|
223
|
+
if not pdf:
|
|
224
|
+
notes.append(
|
|
225
|
+
f"round {rounds}: render returned None -> render_checked=false"
|
|
226
|
+
)
|
|
227
|
+
return ComposeResult(
|
|
228
|
+
out_path, False, None, None, True, rounds, True, tuple(notes)
|
|
229
|
+
)
|
|
230
|
+
# Scope split grouping to the composed 문항 numbers so the form's preserved
|
|
231
|
+
# chrome (e.g. a "2026." year in the 관리박스) never opens a spurious block.
|
|
232
|
+
report = measure_question_splits(pdf, valid_ids=valid_ids)
|
|
233
|
+
if report.n_blocks == 0:
|
|
234
|
+
# None of the composed 문항 are in the extractable text layer: Hancom
|
|
235
|
+
# exported the body as vector curves (observed on the school 원안지
|
|
236
|
+
# forms, blank and filled alike). The text-glyph gate is structurally
|
|
237
|
+
# blind here, so 문항-split / overflow / placeholder integrity are
|
|
238
|
+
# UNVERIFIABLE — report unverified + needs_review, never a silent 0
|
|
239
|
+
# (Constitution V). Visual verification (rendered image) is required.
|
|
240
|
+
notes.append(
|
|
241
|
+
f"round {rounds}: 0 of {n_questions} composed 문항 found in the"
|
|
242
|
+
" extractable text layer — Hancom curve-export form; 문항-split,"
|
|
243
|
+
" overflow and placeholder integrity are UNVERIFIABLE by the text"
|
|
244
|
+
" gate. Visual verification required (render the output)."
|
|
245
|
+
)
|
|
246
|
+
return ComposeResult(
|
|
247
|
+
out_path, True, None, None, True, rounds, True, tuple(notes)
|
|
248
|
+
)
|
|
249
|
+
glyphs = extract_glyph_boxes(pdf)
|
|
250
|
+
clips = extract_cell_clips(pdf)
|
|
251
|
+
rendered_text = "".join(g.text for g in glyphs)
|
|
252
|
+
placeholders_ok = all(
|
|
253
|
+
ph.replace(" ", "") in rendered_text.replace(" ", "") for ph in expected_ph
|
|
254
|
+
)
|
|
255
|
+
overflow = len(detect_overflow(glyphs, clips))
|
|
256
|
+
splits = report.n_splits
|
|
257
|
+
notes.append(
|
|
258
|
+
f"round {rounds}: splits={splits} kinds={report.kinds}"
|
|
259
|
+
f" overflow={overflow} ph_ok={placeholders_ok} blocks={report.n_blocks}/{n_questions}"
|
|
260
|
+
)
|
|
261
|
+
if splits == 0:
|
|
262
|
+
break
|
|
263
|
+
# fix: force a break on each straddling 문항's head paragraph, then re-render.
|
|
264
|
+
# split_ids ⊆ valid_ids = set(anchors) (the grouping is scoped), so every
|
|
265
|
+
# block_id resolves to a head index.
|
|
266
|
+
section = doc.sections[0]
|
|
267
|
+
kind = "page" if report.kinds.get("page") else "column"
|
|
268
|
+
for block_id in report.split_ids:
|
|
269
|
+
_insert_break(section, anchors[block_id], kind)
|
|
270
|
+
doc.save_to_path(out_path)
|
|
271
|
+
|
|
272
|
+
needs_review = splits is None or splits > 0 or not placeholders_ok
|
|
273
|
+
return ComposeResult(
|
|
274
|
+
out_path,
|
|
275
|
+
True,
|
|
276
|
+
splits,
|
|
277
|
+
overflow,
|
|
278
|
+
placeholders_ok,
|
|
279
|
+
rounds,
|
|
280
|
+
needs_review,
|
|
281
|
+
tuple(notes),
|
|
282
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Normalized exam model (Exam IR). Metadata lives in the form's 관리박스, NOT here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class Placeholder:
|
|
11
|
+
id: str # e.g. "그림1"
|
|
12
|
+
kind: str # "img" | "table" | "equation"
|
|
13
|
+
raw_text: str # literal marker preserved verbatim, e.g. "[그림1]"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class Question:
|
|
18
|
+
number: str # literal 문항 number text, e.g. "1"
|
|
19
|
+
stem: str # 발문
|
|
20
|
+
choices: tuple[str, ...] = () # 답항 ①~⑤, literal markers included
|
|
21
|
+
points: str | None = None # 배점, e.g. "3"; None if absent
|
|
22
|
+
placeholders: tuple[
|
|
23
|
+
Placeholder, ...
|
|
24
|
+
] = () # [그림N]/[표N]/[식N] referenced by this 문항
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class QuestionSet: # 세트문제 (shared 공통지문)
|
|
29
|
+
passage: str
|
|
30
|
+
rng: str # e.g. "3∼4"
|
|
31
|
+
members: tuple[Question, ...] = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class ExamDoc:
|
|
36
|
+
title: str = ""
|
|
37
|
+
blocks: tuple[Question | QuestionSet, ...] = ()
|
|
38
|
+
|
|
39
|
+
def iter_questions(self) -> Iterator[Question]:
|
|
40
|
+
for block in self.blocks:
|
|
41
|
+
if isinstance(block, QuestionSet):
|
|
42
|
+
yield from block.members
|
|
43
|
+
else:
|
|
44
|
+
yield block
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Pure render-geometry helpers for the 문항-split oracle gate.
|
|
3
|
+
|
|
4
|
+
Lifted from the Phase-0 spike (scripts/exam_spike_keep_together.py) so the
|
|
5
|
+
composer's gate and the spike share ONE implementation (DRY)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from collections import defaultdict
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..rendering.block_splits import Block, detect_block_splits
|
|
14
|
+
|
|
15
|
+
from ..form_fill.fit.wordbox import extract_glyph_boxes
|
|
16
|
+
|
|
17
|
+
DEFAULT_QUESTION_MARKER = re.compile(r"^\s*(\d+)\s*\.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def column_x_bounds(glyphs) -> list[tuple[float, float]]:
|
|
21
|
+
"""Derive two column x-ranges by splitting the body x-extent at mid-gutter.
|
|
22
|
+
|
|
23
|
+
Cluster glyph x-centers into a left group and a right group at the page
|
|
24
|
+
mid-line; the bounds are each group's [min x0, max x1].
|
|
25
|
+
"""
|
|
26
|
+
if not glyphs:
|
|
27
|
+
return []
|
|
28
|
+
xs0 = min(g.x0 for g in glyphs)
|
|
29
|
+
xs1 = max(g.x1 for g in glyphs)
|
|
30
|
+
mid = (xs0 + xs1) / 2.0
|
|
31
|
+
left = [g for g in glyphs if (g.x0 + g.x1) / 2.0 < mid]
|
|
32
|
+
right = [g for g in glyphs if (g.x0 + g.x1) / 2.0 >= mid]
|
|
33
|
+
bounds: list[tuple[float, float]] = []
|
|
34
|
+
if left:
|
|
35
|
+
bounds.append((min(g.x0 for g in left), max(g.x1 for g in left)))
|
|
36
|
+
if right:
|
|
37
|
+
bounds.append((min(g.x0 for g in right), max(g.x1 for g in right)))
|
|
38
|
+
return bounds
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def group_question_blocks(
|
|
42
|
+
glyphs,
|
|
43
|
+
*,
|
|
44
|
+
marker_re: re.Pattern = DEFAULT_QUESTION_MARKER,
|
|
45
|
+
valid_ids: set[str] | None = None,
|
|
46
|
+
) -> list[Block]:
|
|
47
|
+
"""Group glyphs into one Block per question, sliced on *marker_re*.
|
|
48
|
+
|
|
49
|
+
A question owns every glyph from its marker's (page, line position) up to
|
|
50
|
+
the next question's marker, in reading order. The block id is
|
|
51
|
+
``m.group(1)`` — the captured group from *marker_re*.
|
|
52
|
+
|
|
53
|
+
Reading order is (page, column [left before right by page mid], y, x).
|
|
54
|
+
|
|
55
|
+
*valid_ids*, when given, restricts which marker matches count as a question
|
|
56
|
+
start: a line is treated as a marker only if its captured id is in the set.
|
|
57
|
+
This scopes grouping to the questions actually composed, so stray numeric-
|
|
58
|
+
dot text in the form's preserved chrome (e.g. a "2026." year) never opens a
|
|
59
|
+
spurious block.
|
|
60
|
+
"""
|
|
61
|
+
if not glyphs:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
def line_key(g):
|
|
65
|
+
return (g.page, g.block, g.line)
|
|
66
|
+
|
|
67
|
+
lines: dict[tuple, list] = defaultdict(list)
|
|
68
|
+
for g in glyphs:
|
|
69
|
+
lines[line_key(g)].append(g)
|
|
70
|
+
|
|
71
|
+
records = []
|
|
72
|
+
for gl in lines.values():
|
|
73
|
+
gl_sorted = sorted(gl, key=lambda g: g.x0)
|
|
74
|
+
text = "".join(g.text for g in gl_sorted)
|
|
75
|
+
cx = sum((g.x0 + g.x1) / 2.0 for g in gl_sorted) / len(gl_sorted)
|
|
76
|
+
cy = sum((g.y0 + g.y1) / 2.0 for g in gl_sorted) / len(gl_sorted)
|
|
77
|
+
records.append(
|
|
78
|
+
{
|
|
79
|
+
"page": gl_sorted[0].page,
|
|
80
|
+
"cx": cx,
|
|
81
|
+
"cy": cy,
|
|
82
|
+
"text": text,
|
|
83
|
+
"glyphs": gl_sorted,
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
by_page: dict[int, list] = defaultdict(list)
|
|
88
|
+
for rec in records:
|
|
89
|
+
by_page[rec["page"]].append(rec)
|
|
90
|
+
ordered: list[dict] = []
|
|
91
|
+
for page in sorted(by_page):
|
|
92
|
+
recs = by_page[page]
|
|
93
|
+
xs0 = min(r["cx"] for r in recs)
|
|
94
|
+
xs1 = max(r["cx"] for r in recs)
|
|
95
|
+
mid = (xs0 + xs1) / 2.0
|
|
96
|
+
recs.sort(key=lambda r: (0 if r["cx"] < mid else 1, r["cy"], r["cx"]))
|
|
97
|
+
ordered.extend(recs)
|
|
98
|
+
|
|
99
|
+
blocks: list[Block] = []
|
|
100
|
+
cur_id: str | None = None
|
|
101
|
+
cur_glyphs: list = []
|
|
102
|
+
for rec in ordered:
|
|
103
|
+
m = marker_re.search(rec["text"]) or marker_re.search(
|
|
104
|
+
rec["text"].replace(" ", "")
|
|
105
|
+
)
|
|
106
|
+
if m is not None and valid_ids is not None and m.group(1) not in valid_ids:
|
|
107
|
+
m = None # numeric-dot text that is not a composed 문항 (chrome) — not a marker
|
|
108
|
+
if m is not None:
|
|
109
|
+
if cur_id is not None:
|
|
110
|
+
blocks.append(Block(id=cur_id, glyphs=cur_glyphs))
|
|
111
|
+
cur_id = m.group(1)
|
|
112
|
+
cur_glyphs = list(rec["glyphs"])
|
|
113
|
+
elif cur_id is not None:
|
|
114
|
+
cur_glyphs.extend(rec["glyphs"])
|
|
115
|
+
if cur_id is not None:
|
|
116
|
+
blocks.append(Block(id=cur_id, glyphs=cur_glyphs))
|
|
117
|
+
return blocks
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class SplitReport:
|
|
122
|
+
"""Summary of 문항 split detection for one rendered PDF."""
|
|
123
|
+
|
|
124
|
+
n_splits: int
|
|
125
|
+
n_blocks: int
|
|
126
|
+
n_glyphs: int
|
|
127
|
+
kinds: dict[str, int]
|
|
128
|
+
split_ids: tuple[str, ...]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def measure_question_splits(
|
|
132
|
+
pdf_path: str,
|
|
133
|
+
*,
|
|
134
|
+
marker_re: re.Pattern = DEFAULT_QUESTION_MARKER,
|
|
135
|
+
valid_ids: set[str] | None = None,
|
|
136
|
+
) -> SplitReport:
|
|
137
|
+
"""Extract glyphs from *pdf_path* and report how many blocks are split.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
pdf_path: Path to a rendered PDF.
|
|
141
|
+
marker_re: Compiled regex whose group(1) identifies the question number.
|
|
142
|
+
Defaults to :data:`DEFAULT_QUESTION_MARKER`.
|
|
143
|
+
valid_ids: When given, only marker matches whose id is in this set open a
|
|
144
|
+
block (scopes grouping to the composed 문항; ignores chrome numbers).
|
|
145
|
+
``n_blocks == 0`` then means none of the composed 문항 were found in
|
|
146
|
+
the extractable text layer — the caller treats that as *unverifiable*
|
|
147
|
+
(e.g. a form whose body Hancom exports as vector curves), never a
|
|
148
|
+
silent "0 splits".
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
A :class:`SplitReport` summarising split counts, block counts, and kinds.
|
|
152
|
+
"""
|
|
153
|
+
glyphs = extract_glyph_boxes(pdf_path)
|
|
154
|
+
blocks = group_question_blocks(glyphs, marker_re=marker_re, valid_ids=valid_ids)
|
|
155
|
+
page_height = max((g.y1 for g in glyphs), default=0.0)
|
|
156
|
+
bounds = column_x_bounds(glyphs)
|
|
157
|
+
splits = detect_block_splits(blocks, bounds, page_height)
|
|
158
|
+
kinds: dict[str, int] = {}
|
|
159
|
+
for s in splits:
|
|
160
|
+
kinds[s.kind] = kinds.get(s.kind, 0) + 1
|
|
161
|
+
return SplitReport(
|
|
162
|
+
len(splits), len(blocks), len(glyphs), kinds, tuple(s.block_id for s in splits)
|
|
163
|
+
)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Parse the recommended exam-md convention into the Exam IR.
|
|
2
|
+
|
|
3
|
+
The skill authors this md, so the grammar is strict and any unattributable
|
|
4
|
+
line fails loud (Constitution VI/IX — no silently wrong layout)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from hwpx.tools.table_cleanup import normalize_cell_text
|
|
11
|
+
|
|
12
|
+
from .ir import ExamDoc, Placeholder, Question, QuestionSet
|
|
13
|
+
|
|
14
|
+
_TITLE_RE = re.compile(r"^#\s+(?P<t>.*\S)\s*$")
|
|
15
|
+
_Q_RE = re.compile(r"^##\s+(?P<n>\d+)\.\s*(?:\((?P<p>\d+)\s*점\))?\s*$")
|
|
16
|
+
_SET_RE = re.compile(r"^##\s+(?P<a>\d+)\s*[~∼-]\s*(?P<b>\d+)\.\s*세트\s*$")
|
|
17
|
+
_MEMBER_RE = re.compile(r"^###\s+(?P<n>\d+)\.\s*(?:\((?P<p>\d+)\s*점\))?\s*$")
|
|
18
|
+
_CHOICE_RE = re.compile(r"^\s*(?P<mark>[①②③④⑤⑥⑦⑧⑨⑩])\s*(?P<t>.*)$")
|
|
19
|
+
_PLACEHOLDER_RE = re.compile(r"\[(?P<k>그림|표|식)\s*(?P<n>\d+)\]")
|
|
20
|
+
|
|
21
|
+
_KIND = {"그림": "img", "표": "table", "식": "equation"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExamParseError(ValueError):
|
|
25
|
+
def __init__(self, line_no: int, text: str, reason: str) -> None:
|
|
26
|
+
super().__init__(f"line {line_no}: {reason}: {text!r}")
|
|
27
|
+
self.line_no = line_no
|
|
28
|
+
self.text = text
|
|
29
|
+
self.reason = reason
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _QBuf:
|
|
33
|
+
"""Accumulates one 문항 across lines, then freezes to a Question."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, number: str, points: str | None) -> None:
|
|
36
|
+
self.number = number
|
|
37
|
+
self.points = points
|
|
38
|
+
self.stem_lines: list[str] = []
|
|
39
|
+
self.choices: list[str] = []
|
|
40
|
+
self.placeholders: list[Placeholder] = []
|
|
41
|
+
|
|
42
|
+
def add_text(self, text: str) -> None:
|
|
43
|
+
for m in _PLACEHOLDER_RE.finditer(text):
|
|
44
|
+
pid = f"{m.group('k')}{m.group('n')}"
|
|
45
|
+
self.placeholders.append(
|
|
46
|
+
Placeholder(id=pid, kind=_KIND[m.group("k")], raw_text=m.group(0))
|
|
47
|
+
)
|
|
48
|
+
self.stem_lines.append(text)
|
|
49
|
+
|
|
50
|
+
def add_choice(self, mark: str, text: str) -> None:
|
|
51
|
+
self.choices.append(f"{mark} {text}".rstrip())
|
|
52
|
+
|
|
53
|
+
def freeze(self) -> Question:
|
|
54
|
+
return Question(
|
|
55
|
+
number=self.number,
|
|
56
|
+
stem=normalize_cell_text(" ".join(self.stem_lines)),
|
|
57
|
+
choices=tuple(self.choices),
|
|
58
|
+
points=self.points,
|
|
59
|
+
placeholders=tuple(self.placeholders),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parse_exam_markdown(md: str, *, title: str = "") -> ExamDoc:
|
|
64
|
+
blocks: list[Question | QuestionSet] = []
|
|
65
|
+
cur_q: _QBuf | None = None
|
|
66
|
+
set_open = False
|
|
67
|
+
set_rng = ""
|
|
68
|
+
set_passage: list[str] = []
|
|
69
|
+
set_members: list[Question] = []
|
|
70
|
+
|
|
71
|
+
def flush_q() -> None:
|
|
72
|
+
nonlocal cur_q
|
|
73
|
+
if cur_q is None:
|
|
74
|
+
return
|
|
75
|
+
q = cur_q.freeze()
|
|
76
|
+
if set_open:
|
|
77
|
+
set_members.append(q)
|
|
78
|
+
else:
|
|
79
|
+
blocks.append(q)
|
|
80
|
+
cur_q = None
|
|
81
|
+
|
|
82
|
+
def flush_set() -> None:
|
|
83
|
+
nonlocal set_open, set_rng, set_passage, set_members
|
|
84
|
+
if not set_open:
|
|
85
|
+
return
|
|
86
|
+
blocks.append(
|
|
87
|
+
QuestionSet(
|
|
88
|
+
passage=normalize_cell_text(" ".join(set_passage)),
|
|
89
|
+
rng=set_rng,
|
|
90
|
+
members=tuple(set_members),
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
set_open, set_rng, set_passage, set_members = False, "", [], []
|
|
94
|
+
|
|
95
|
+
for i, raw in enumerate(md.splitlines(), start=1):
|
|
96
|
+
line = raw.rstrip()
|
|
97
|
+
if not line.strip():
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
m_set = _SET_RE.match(line)
|
|
101
|
+
if m_set:
|
|
102
|
+
flush_q()
|
|
103
|
+
flush_set()
|
|
104
|
+
set_open = True
|
|
105
|
+
set_rng = f"{m_set.group('a')}∼{m_set.group('b')}"
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
m_member = _MEMBER_RE.match(line)
|
|
109
|
+
if m_member:
|
|
110
|
+
if not set_open:
|
|
111
|
+
raise ExamParseError(i, line, "'### N.' member outside a 세트 header")
|
|
112
|
+
flush_q()
|
|
113
|
+
cur_q = _QBuf(m_member.group("n"), m_member.group("p"))
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
m_q = _Q_RE.match(line)
|
|
117
|
+
if m_q:
|
|
118
|
+
flush_q()
|
|
119
|
+
flush_set()
|
|
120
|
+
cur_q = _QBuf(m_q.group("n"), m_q.group("p"))
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
m_title = _TITLE_RE.match(line)
|
|
124
|
+
if m_title and not blocks and cur_q is None and not set_open:
|
|
125
|
+
title = title or normalize_cell_text(m_title.group("t"))
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
m_choice = _CHOICE_RE.match(line)
|
|
129
|
+
if m_choice:
|
|
130
|
+
# A 답항(choice) cannot exist without an active 문항. Even inside an
|
|
131
|
+
# open 세트, a choice before the first member must fail loud rather
|
|
132
|
+
# than be silently swallowed into the set passage.
|
|
133
|
+
if cur_q is None:
|
|
134
|
+
raise ExamParseError(i, line, "답항(choice) outside any 문항")
|
|
135
|
+
cur_q.add_choice(
|
|
136
|
+
m_choice.group("mark"), normalize_cell_text(m_choice.group("t"))
|
|
137
|
+
)
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
# plain content line: belongs to the current 문항 stem, or the set passage
|
|
141
|
+
if cur_q is not None:
|
|
142
|
+
cur_q.add_text(line.strip())
|
|
143
|
+
elif set_open:
|
|
144
|
+
set_passage.append(line.strip())
|
|
145
|
+
else:
|
|
146
|
+
raise ExamParseError(i, line, "content before any 문항 / 세트 header")
|
|
147
|
+
|
|
148
|
+
flush_q()
|
|
149
|
+
flush_set()
|
|
150
|
+
return ExamDoc(title=title, blocks=tuple(blocks))
|