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.
Files changed (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. 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))