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,2762 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Evaluation-plan (평가계획) review-markdown parser + target skeleton.
3
+
4
+ The GOAL-loop recipe fills a blank province form from a structured review markdown
5
+ (Ⅰ 운영계획 + [1]~[11]). This module is the *content* half: it parses that markdown
6
+ into a structured :class:`EvalPlanContent` and derives the **target skeleton**
7
+ (how many achievement / 성취수준 / rubric / 성취율 tables the content requires) that
8
+ the quality scorer's C axis measures against (content-derived counts, not the
9
+ 1학기 gold's).
10
+
11
+ Kept deliberately format-tolerant: the review markdown is hand-authored, so the
12
+ parser locates sections by their numbered headers and pulls the GitHub-style
13
+ tables under each. Prose sections are returned as normalised text. No content is
14
+ invented — a missing section yields an empty field, surfaced by the scorer's D
15
+ axis rather than silently filled.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections.abc import Sequence
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any, cast
24
+
25
+ _CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮"
26
+
27
+
28
+ def _md_table_rows(block: str) -> list[list[str]]:
29
+ """Every data row of the first GitHub-style table in *block* (header +
30
+ separator dropped), each split into trimmed cells."""
31
+ rows: list[list[str]] = []
32
+ seen_sep = False
33
+ for line in block.splitlines():
34
+ s = line.strip()
35
+ if not s.startswith("|"):
36
+ if rows:
37
+ break # table ended
38
+ continue
39
+ cells = [c.strip() for c in s.strip("|").split("|")]
40
+ if all(set(c) <= {"-", ":", " "} and c for c in cells):
41
+ seen_sep = True
42
+ continue
43
+ rows.append(cells)
44
+ # first row is the header; keep only rows after the separator
45
+ if seen_sep and rows:
46
+ return rows[1:]
47
+ return rows[1:] if len(rows) > 1 else []
48
+
49
+
50
+ def _md_table_header(block: str) -> list[str]:
51
+ for line in block.splitlines():
52
+ s = line.strip()
53
+ if s.startswith("|"):
54
+ return [c.strip() for c in s.strip("|").split("|")]
55
+ return []
56
+
57
+
58
+ @dataclass
59
+ class RubricItem:
60
+ """One 평가요소 (evaluation element) with its observable-criteria 배점 ladder —
61
+ ``levels`` is ``[(descriptor, 배점), …]`` top score first, verbatim from the MD.
62
+ ``subtotal`` marks a 소계 row (the sub-area 만점, not a scored element)."""
63
+ name: str
64
+ levels: list[tuple[str, str]] = field(default_factory=list)
65
+ subtotal: bool = False
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ return {"name": self.name, "levels": [list(level) for level in self.levels],
69
+ "subtotal": self.subtotal}
70
+
71
+
72
+ @dataclass
73
+ class RubricSubArea:
74
+ """A 세부 영역 (가./나. …) or, for a single-table area, one anonymous sub-area
75
+ (``label==""``). ``points`` is the 소계 (0 if the MD gives none)."""
76
+ label: str = ""
77
+ points: int = 0
78
+ items: list[RubricItem] = field(default_factory=list)
79
+
80
+ def to_dict(self) -> dict[str, Any]:
81
+ return {"label": self.label, "points": self.points,
82
+ "items": [it.to_dict() for it in self.items]}
83
+
84
+
85
+ @dataclass
86
+ class Rubric:
87
+ title: str # "문제해결에 탐색 활용하기"
88
+ points: int # 35
89
+ standards: str # "[12인기02-04][12인기02-05]"
90
+ rows: list[list[str]] = field(default_factory=list) # [평가항목, 채점 기준]
91
+ # --- detailed 배점 rubric (current 평가계획 MD format), empty for the legacy
92
+ # synthetic format; :attr:`detailed` gates the detailed fill route.
93
+ subareas: list[RubricSubArea] = field(default_factory=list)
94
+ base_score: str = "" # 기본점수 (백지·미참여)
95
+ long_score: str = "" # 장기 미인정 결석자 (기본점수 −1)
96
+ task: str = "" # 수행과제
97
+ method: str = "" # 평가 방법
98
+ student_notes: str = "" # 학생 유의사항
99
+ criteria: str = "" # 평가기준(상/중/하) — 2015-개정 (3학년) only
100
+
101
+ @property
102
+ def detailed(self) -> bool:
103
+ """True when parsed from the current 평가계획 MD (per-element 배점 ladders)."""
104
+ return bool(self.subareas)
105
+
106
+ @property
107
+ def items(self) -> list[RubricItem]:
108
+ """All 평가요소 across sub-areas, in document order (flattened view)."""
109
+ return [it for sa in self.subareas for it in sa.items]
110
+
111
+ def to_dict(self) -> dict[str, Any]:
112
+ return {"title": self.title, "points": self.points,
113
+ "standards": self.standards, "rows": self.rows,
114
+ "subareas": [sa.to_dict() for sa in self.subareas],
115
+ "base_score": self.base_score, "long_score": self.long_score}
116
+
117
+
118
+ @dataclass
119
+ class EvalPlanContent:
120
+ title: str = ""
121
+ teacher: str = ""
122
+ schedule: list[list[str]] = field(default_factory=list) # Ⅰ, 6 cols/row
123
+ purposes: str = "" # §1
124
+ directions: str = "" # §2
125
+ policies: str = "" # §3
126
+ achievement_std: list[list[str]] = field(default_factory=list) # §4가 [성취기준,상,중,하]
127
+ levels: list[list[str]] = field(default_factory=list) # §4나 [영역,A,B,C]
128
+ achieve_rate: list[list[str]] = field(default_factory=list) # §5 [성취율,성취도]
129
+ ratio_header: list[str] = field(default_factory=list) # §6 header cells
130
+ ratio_rows: list[list[str]] = field(default_factory=list) # §6 data rows
131
+ rubrics: list[Rubric] = field(default_factory=list) # §7
132
+ affective: str = "" # §8
133
+ absentee: str = "" # §9
134
+ cautions: str = "" # §10
135
+ analysis: str = "" # §11
136
+
137
+ def to_dict(self) -> dict[str, Any]:
138
+ return {
139
+ "title": self.title, "teacher": self.teacher,
140
+ "schedule_rows": len(self.schedule),
141
+ "achievement_std_rows": len(self.achievement_std),
142
+ "level_rows": len(self.levels),
143
+ "achieve_rate_rows": len(self.achieve_rate),
144
+ "ratio_areas": len([c for c in self.ratio_header if any(ch in c for ch in _CIRCLED)]),
145
+ "rubrics": [r.to_dict() for r in self.rubrics],
146
+ }
147
+
148
+
149
+ def _section(md: str, start_pat: str, end_pats: list[str]) -> str:
150
+ m = re.search(start_pat, md)
151
+ if not m:
152
+ return ""
153
+ rest = md[m.end():]
154
+ ends = [re.search(p, rest) for p in end_pats]
155
+ cut = min((e.start() for e in ends if e), default=len(rest))
156
+ return rest[:cut]
157
+
158
+
159
+ # a per-standard header: ``**[code]** 진술`` at the start of a line
160
+ _ACH_STD_HEADER = re.compile(r"^\s*\*\*\s*(\[[^\]\n]+\])\s*\*\*\s*(.*?)\s*$")
161
+
162
+
163
+ def _parse_achievement_std(block: str) -> list[list[str]]:
164
+ """Parse §4가 into one row per 성취기준: ``[standard, L1_desc, L2_desc, ...]``.
165
+
166
+ Two markdown authoring shapes are accepted; the level count is read from the
167
+ data, never hard-coded:
168
+
169
+ * **unified** -- a single table ``성취기준 | 상 | 중 | 하`` (or ``| A |…| E |``) with
170
+ one row per standard, level descriptors across the columns. Parsed by
171
+ :func:`_md_table_rows`.
172
+ * **per-standard** -- a sequence of ``**[code]** 진술`` headers, each followed by
173
+ its own ``수준 | 성취수준`` table listing the levels as *rows*. Each is normalised
174
+ to ``[code+진술, <descriptor per level, in table order>]``, so a standard with an
175
+ A~E table becomes a 6-field row (bh = 5) matching the unified shape that
176
+ :func:`fill_achievement` and :func:`_std_level_map` already consume.
177
+
178
+ The per-standard shape is tried first; when no ``**[code]**`` header carries a
179
+ table, the unified single-table parse is returned instead."""
180
+ lines = block.splitlines()
181
+ n = len(lines)
182
+ stds: list[list[str]] = []
183
+ i = 0
184
+ while i < n:
185
+ m = _ACH_STD_HEADER.match(lines[i])
186
+ if not m:
187
+ i += 1
188
+ continue
189
+ standard = f"{m.group(1)} {m.group(2)}".strip()
190
+ # advance to this standard's table (blank/prose lines may intervene), but
191
+ # stop at the next standard header -- a header with no table of its own.
192
+ j = i + 1
193
+ while j < n and not lines[j].lstrip().startswith("|"):
194
+ if _ACH_STD_HEADER.match(lines[j]):
195
+ break
196
+ j += 1
197
+ rows: list[list[str]] = []
198
+ while j < n and lines[j].lstrip().startswith("|"):
199
+ cells = [c.strip() for c in lines[j].strip().strip("|").split("|")]
200
+ if not all(set(c) <= {"-", ":", " "} and c for c in cells):
201
+ rows.append(cells)
202
+ j += 1
203
+ if len(rows) >= 2: # header row + >=1 level row
204
+ descs = [r[-1] for r in rows[1:]] # right-most column = the descriptor
205
+ stds.append([standard, *descs])
206
+ i = j
207
+ else:
208
+ i += 1
209
+ return stds or _md_table_rows(block)
210
+
211
+
212
+ def parse_review_md(md_text: str) -> EvalPlanContent:
213
+ """Parse a 평가계획 review markdown into structured content."""
214
+ c = EvalPlanContent()
215
+
216
+ # title + teacher (first heading + 담당교사 mention)
217
+ m = re.search(r"#\s*(\d{4}학년도[^\n]+?계획)", md_text)
218
+ if m:
219
+ c.title = re.sub(r"\s*\(검토용\)\s*$", "", m.group(1)).strip()
220
+ m = re.search(r"담당교사\s*[::]\s*([^\s·|*]+)", md_text)
221
+ if m:
222
+ c.teacher = m.group(1).strip()
223
+
224
+ # Ⅰ schedule table
225
+ sched = _section(md_text, r"##\s*Ⅰ\.\s*교수학습 운영 계획", [r"##\s*Ⅱ\.", r"^##\s"])
226
+ c.schedule = _md_table_rows(sched)
227
+
228
+ # numbered sections [1]~[11] (### N. ...)
229
+ def sec(n: int) -> str:
230
+ return _section(md_text, rf"###\s*{n}\.\s", [rf"###\s*{n+1}\.\s", r"^##\s"])
231
+
232
+ c.purposes = _prose(sec(1))
233
+ c.directions = _prose(sec(2))
234
+ c.policies = _prose(sec(3))
235
+
236
+ s4 = sec(4)
237
+ ga = s4.split("**나.")[0]
238
+ na = ("**나." + s4.split("**나.")[1]) if "**나." in s4 else ""
239
+ c.achievement_std = _parse_achievement_std(ga)
240
+ c.levels = _md_table_rows(na)
241
+
242
+ c.achieve_rate = _md_table_rows(sec(5))
243
+
244
+ s6 = sec(6)
245
+ c.ratio_header = _md_table_header(s6)
246
+ c.ratio_rows = _md_table_rows(s6)
247
+
248
+ s7 = sec(7)
249
+ c.rubrics = _parse_rubrics(s7)
250
+
251
+ c.affective = _prose(sec(8))
252
+ c.absentee = _prose(sec(9))
253
+ c.cautions = _prose(sec(10))
254
+ c.analysis = _prose(sec(11))
255
+ return c
256
+
257
+
258
+ def _prose(text: str) -> str:
259
+ """Normalised prose of a numbered section with its leading **title line**
260
+ dropped -- the ``### N. 평가의 목적`` heading text the section regex sweeps in is
261
+ not prose. Everything up to (and excluding) the first ordinal '가.' marker or
262
+ '-'/'*' bullet on its own is the title; the rest is the actual content."""
263
+ lines = [ln.strip() for ln in text.strip().splitlines() if ln.strip()]
264
+ # drop a leading title line (no ordinal marker, no bullet) if content follows
265
+ if lines and not re.match(r"^([가-힣]\.\s|[-*·]\s)", lines[0]) and len(lines) > 1:
266
+ lines = lines[1:]
267
+ return _norm(" ".join(lines))
268
+
269
+
270
+ def _parse_rubrics(s7: str) -> list[Rubric]:
271
+ """Parse §7 수행평가 세부기준 into one :class:`Rubric` per 수행영역.
272
+
273
+ Dispatches on format: the current 평가계획 MD writes each area as an ``#### ①
274
+ title`` H4 heading with per-세부영역 ``평가요소 | 수행수준(채점 기준) | 배점`` tables
275
+ (the *detailed* 배점 rubric) — :func:`_parse_rubrics_detailed`. The older synthetic
276
+ format bolds the header inline (``**① title (NN점)**``) with a single flat 채점
277
+ 기준(배점) table — :func:`_parse_rubrics_legacy`. Detection is structural (an H4
278
+ circled heading), never subject-specific."""
279
+ if re.search(r"(?m)^####\s*[" + _CIRCLED + r"]", s7):
280
+ return _parse_rubrics_detailed(s7)
281
+ return _parse_rubrics_legacy(s7)
282
+
283
+
284
+ def _parse_rubrics_legacy(s7: str) -> list[Rubric]:
285
+ rubrics: list[Rubric] = []
286
+ # split on the bolded circled headers "**① title (NN점)** ..."
287
+ parts = re.split(r"\*\*([" + _CIRCLED + r"][^*]*?\(\d+점\)[^*]*)\*\*", s7)
288
+ # parts = [pre, header1, body1, header2, body2, ...]
289
+ for i in range(1, len(parts), 2):
290
+ header = parts[i]
291
+ body = parts[i + 1] if i + 1 < len(parts) else ""
292
+ mt = re.match(r"[" + _CIRCLED + r"]\s*(.*?)\s*\((\d+)점\)", header)
293
+ title = mt.group(1).strip() if mt else header.strip()
294
+ points = int(mt.group(2)) if mt else 0
295
+ ms = re.search(r"\[12[가-힣]*\d\d-\d\d\][^\n·]*", header + body)
296
+ standards = ms.group(0).strip() if ms else ""
297
+ rows = _md_table_rows(body)
298
+ rubrics.append(Rubric(title=title, points=points, standards=standards, rows=rows))
299
+ return rubrics
300
+
301
+
302
+ def _strip_inline_md(s: str) -> str:
303
+ """Drop ``**bold**`` / `` `code` `` emphasis, collapse whitespace — cell text is
304
+ spliced verbatim otherwise (no summarisation)."""
305
+ return _norm(re.sub(r"\*\*|`", "", s or ""))
306
+
307
+
308
+ def _iter_md_tables(block: str):
309
+ """Yield ``(preamble, header_cells, data_rows)`` for every GitHub table in *block*
310
+ in order. ``preamble`` is the non-table text since the previous table — it carries
311
+ the ``[세부 영역 …]`` marker that names each sub-area."""
312
+ lines = block.splitlines()
313
+ i = 0
314
+ preamble: list[str] = []
315
+ while i < len(lines):
316
+ if lines[i].strip().startswith("|"):
317
+ tbl = []
318
+ while i < len(lines) and lines[i].strip().startswith("|"):
319
+ tbl.append(lines[i].strip())
320
+ i += 1
321
+ rows = []
322
+ for t in tbl:
323
+ cells = [c.strip() for c in t.strip("|").split("|")]
324
+ if all(set(c) <= {"-", ":", " "} and c for c in cells):
325
+ continue
326
+ rows.append(cells)
327
+ if rows:
328
+ yield "\n".join(preamble), rows[0], rows[1:]
329
+ preamble = []
330
+ else:
331
+ preamble.append(lines[i])
332
+ i += 1
333
+
334
+
335
+ def _area_bullet(body: str, label: str) -> str:
336
+ """The value of a ``- **{label}**: …`` meta bullet in an area body ('' if absent)."""
337
+ m = re.search(rf"[-*]\s*\*\*{label}\*\*\s*[::]?\s*(.+)", body)
338
+ return m.group(1).strip() if m else ""
339
+
340
+
341
+ def _area_points(body: str, header: str) -> int:
342
+ """영역 만점 for an area — from the meta bullet / blockquote ('영역 만점: 50점',
343
+ '> 영역 만점 35점') or a ``(NN점)`` in the heading, else 0."""
344
+ m = re.search(r"영역\s*만점[은]?\**\s*[::]?\s*(\d+)\s*점", body)
345
+ if not m:
346
+ m = re.search(r"\((\d+)\s*점\)", header)
347
+ return int(m.group(1)) if m else 0
348
+
349
+
350
+ def _area_standards(body: str) -> str:
351
+ """The 성취기준 codes cited by an area, concatenated ('[12인기02-04][12인기02-05]')."""
352
+ m = re.search(r"(?:교육과정\s*성취기준|성취기준\s*/\s*성취수준|성취기준)[^\n]*?[::]\s*(.+)", body)
353
+ src = m.group(1) if m else body
354
+ codes = re.findall(r"\[1\d[가-힣A-Za-z]*\d\d-\d\d\](?:\s*~\s*\[1\d[가-힣A-Za-z]*\d\d-\d\d\])?", src)
355
+ return "".join(codes)
356
+
357
+
358
+ def _parse_area_rubric(header: str, title: str, body: str) -> Rubric:
359
+ """One 수행영역 → a detailed :class:`Rubric`: sub-area ``평가요소 | 수행수준 | 배점``
360
+ tables become :class:`RubricSubArea` blocks of :class:`RubricItem` ladders, and the
361
+ [영역 공통] / inline 기본점수 rows become ``base_score`` / ``long_score``. Content is
362
+ read verbatim — no invented 배점, no summarisation."""
363
+ rub = Rubric(title=title, points=_area_points(body, header),
364
+ standards=_area_standards(body),
365
+ task=_strip_inline_md(_area_bullet(body, "수행과제")),
366
+ method=_strip_inline_md(_area_bullet(body, "평가 방법")),
367
+ student_notes=_strip_inline_md(_area_bullet(body, "학생 유의사항")),
368
+ criteria=_area_bullet(body, r"평가기준\(상/중/하\)"))
369
+ for pre, hdr, rows in _iter_md_tables(body):
370
+ hdr_txt = " ".join(hdr)
371
+ if "평가요소" in hdr_txt and "배점" in hdr_txt:
372
+ rub.subareas.append(_parse_subarea(pre, rows, rub))
373
+ elif "구분" in hdr_txt and "배점" in hdr_txt:
374
+ _absorb_base_row(rows, rub) # [영역 공통] table
375
+ _sync_legacy_rows(rub)
376
+ return rub
377
+
378
+
379
+ def _parse_subarea(preamble: str, rows: list[list[str]], rub: Rubric) -> RubricSubArea:
380
+ """Group a sub-area table's rows into 평가요소 items (new item = non-empty 평가요소
381
+ cell, continuation rows = extra levels). A named sub-area's 소계 row is kept as a
382
+ trailing subtotal item (verbatim md — it marks the 가/나 boundary and the sub-area
383
+ 만점); 기본점수 / 장기 미인정 rows (appearing inline in single-table areas) feed
384
+ ``rub.base_score`` / ``long_score``."""
385
+ m = re.search(r"[세부 영역\s*([가-힣])\.\s*(.+?)\s*\((\d+)\s*점\)]", preamble or "")
386
+ sa = RubricSubArea(label=f"{m.group(1)}. {m.group(2)}" if m else "",
387
+ points=int(m.group(3)) if m else 0)
388
+ cur: RubricItem | None = None
389
+ for row in rows:
390
+ c0 = _strip_inline_md(row[0]) if len(row) > 0 else ""
391
+ c1 = _strip_inline_md(row[1]) if len(row) > 1 else ""
392
+ c2 = _strip_inline_md(row[2]) if len(row) > 2 else ""
393
+ if "소계" in c0:
394
+ if c2.isdigit():
395
+ sa.points = int(c2)
396
+ if sa.label: # keep 소계 as a subtotal row (가/나 marker)
397
+ sm = re.match(r"(.*?소계)\s*\((.*)\)\s*$", c0)
398
+ name, note = (sm.group(1), sm.group(2)) if sm else (c0, "")
399
+ sa.items.append(RubricItem(name=name, levels=[(note, c2)], subtotal=True))
400
+ cur = None
401
+ continue
402
+ if "장기 미인정" in c0 or "장기미인정" in c0:
403
+ if c2.isdigit():
404
+ rub.long_score = c2
405
+ continue
406
+ if "기본점수" in c0:
407
+ if c2.isdigit():
408
+ rub.base_score = c2
409
+ continue
410
+ if c0: # a new 평가요소
411
+ cur = RubricItem(name=c0)
412
+ sa.items.append(cur)
413
+ if cur is not None and (c1 or c2):
414
+ cur.levels.append((c1, c2))
415
+ return sa
416
+
417
+
418
+ def _absorb_base_row(rows: list[list[str]], rub: Rubric) -> None:
419
+ """Read [영역 공통] ``구분 | 배점`` rows into ``base_score`` / ``long_score``. 장기
420
+ 미인정 rows also carry '기본점수' (—1점), so match the more specific label first."""
421
+ for row in rows:
422
+ c0 = _strip_inline_md(row[0]) if row else ""
423
+ cN = _strip_inline_md(row[-1]) if row else ""
424
+ if not cN.isdigit():
425
+ continue
426
+ if "장기 미인정" in c0 or "장기미인정" in c0:
427
+ rub.long_score = cN
428
+ elif "기본점수" in c0:
429
+ rub.base_score = cN
430
+
431
+
432
+ def _sync_legacy_rows(rub: Rubric) -> None:
433
+ """Mirror the detailed model into the legacy ``rows`` shape ([name, 'desc **s** / …']
434
+ + a trailing 기본점수 summary) so legacy introspection / scorers keep working."""
435
+ rows: list[list[str]] = []
436
+ for it in rub.items:
437
+ ladder = " / ".join(f"{d} **{s}**" if s else d for d, s in it.levels)
438
+ rows.append([it.name, ladder])
439
+ if rub.base_score or rub.long_score:
440
+ rows.append([f"기본점수 **{rub.base_score}** · 장기 미인정 결석 **{rub.long_score}**", ""])
441
+ rub.rows = rows
442
+
443
+
444
+ def _parse_rubrics_detailed(s7: str) -> list[Rubric]:
445
+ """Parse the current-format §7 (``#### ①`` areas) into detailed rubrics."""
446
+ heads = list(re.finditer(r"(?m)^####\s*([" + _CIRCLED + r"])\s*(.+?)\s*$", s7))
447
+ out: list[Rubric] = []
448
+ for k, m in enumerate(heads):
449
+ start = m.end()
450
+ end = heads[k + 1].start() if k + 1 < len(heads) else len(s7)
451
+ out.append(_parse_area_rubric(m.group(0), m.group(2).strip(), s7[start:end]))
452
+ return out
453
+
454
+
455
+ def _norm(text: str) -> str:
456
+ return " ".join(text.split()).strip()
457
+
458
+
459
+ def expected_skeleton(content: EvalPlanContent, blank: str | Path | None = None) -> dict[str, int]:
460
+ """Content-derived block counts for the quality scorer's C axis.
461
+
462
+ The 평가계획 target collapses §4가 into a **single** achievement table (the
463
+ review MD ships one unified 성취기준 table for both the 2015-개정 상/중/하 and the
464
+ 2022-개정 A~E families), §4나 into a **single** 학기 단위 성취수준 table, keeps
465
+ one 성취율 table (the band-count that matches the MD), the 정기시험-stripped
466
+ 반영비율 table, and one rubric table per 수행영역.
467
+
468
+ The 2022-개정 (2학년) form additionally ships a 「다. 영역별 최소 성취수준」 table
469
+ (공통과목 전용); the owner's form-prep spec deletes it, so the target keeps
470
+ ``minlevel: 0``. ``blank`` is accepted for symmetry / future form detection but
471
+ the count target is content-derived and identical across 개정 (collapse-to-one),
472
+ so it is currently unused here."""
473
+ return {
474
+ "achievement": 1 if content.achievement_std else 0,
475
+ "level": 1 if content.levels else 0,
476
+ "minlevel": 0, # 최소 성취수준 (공통과목 전용) -> deleted
477
+ "rubric": len(content.rubrics),
478
+ "achieve_rate": 1 if content.achieve_rate else 0,
479
+ "ratio": 1 if content.ratio_header else 0,
480
+ }
481
+
482
+
483
+ def plan_structural_ops(blank: str | Path, content: EvalPlanContent | None = None) -> dict[str, Any]:
484
+ """Confident, gold-policy structural edits for a 평가계획 blank (no content
485
+ fills): delete the red/optional tables and the 정기시험 columns, keeping the
486
+ original formatting byte-for-byte. Targets are located by *classification*
487
+ (reusable across the form family), not fixed indices. Returns
488
+ ``{"ops": [...], "transcript": [...]}`` — feed ``ops`` to
489
+ :func:`hwpx.table_patch.apply_table_ops`. Table deletes are emitted in
490
+ descending index order so earlier indices stay valid (column deletes do not
491
+ shift table indices).
492
+
493
+ Deferred (needs content mapping into the blank's rich cells, honest-defer):
494
+ achievement / 성취수준 / rubric block restructuring and cell text fills.
495
+ """
496
+ from ..form_fill.classification import _classify, _tables
497
+
498
+ tabs = _tables(blank)
499
+ ops: list[dict[str, Any]] = []
500
+ transcript: list[str] = []
501
+ del_tables: list[int] = []
502
+ exp = expected_skeleton(content, blank) if content else None
503
+ by_kind: dict[str, list[int]] = {}
504
+
505
+ # Which 성취율 table to KEEP: the one whose grade-band count matches the review
506
+ # MD (2015-개정 keeps the 3단계 A~C; 2022-개정 keeps the 5단계 A~E). Content-
507
+ # driven, so it generalises across 개정 rather than hard-coding "delete 5단계".
508
+ want_bands = len(content.achieve_rate) if content and content.achieve_rate else None
509
+ keep_rate = _pick_achieve_rate(tabs, want_bands)
510
+
511
+ for i, t in enumerate(tabs):
512
+ kind = _classify(t)
513
+ by_kind.setdefault(kind, []).append(i)
514
+ if kind in ("seokcha", "submit", "notice_star"):
515
+ del_tables.append(i)
516
+ transcript.append(f"delete_table #{i} ({kind}) — red/optional, gold removes it")
517
+ elif kind == "minlevel":
518
+ del_tables.append(i)
519
+ transcript.append(f"delete_table #{i} (영역별 최소 성취수준) — 공통과목 전용, 삭제")
520
+ elif kind == "achieve_rate" and keep_rate is not None and i != keep_rate:
521
+ del_tables.append(i)
522
+ transcript.append(f"delete_table #{i} (성취율 variant) — keep only #{keep_rate} "
523
+ f"(matches MD {want_bands}단계)")
524
+ elif kind == "ratio":
525
+ cols = _regular_exam_cols(t)
526
+ if cols:
527
+ ops.append({"op": "delete_column", "tableIndex": i, "cols": cols})
528
+ transcript.append(f"delete_column #{i} cols {cols} (정기시험) — 100% 수행 subject")
529
+
530
+ # Surplus template-example tables: the blank ships >1 achievement / 성취수준 /
531
+ # rubric example (연주/비평 for 3학년, 6 국어 영역 for 2학년); the content needs
532
+ # `exp[kind]` of them. Delete the extras (keep the first) so the structure
533
+ # matches the content-derived skeleton.
534
+ detailed_rubrics = bool(content and content.rubrics and content.rubrics[0].detailed)
535
+ if exp:
536
+ for kind in ("achievement", "level", "rubric"):
537
+ idxs = by_kind.get(kind, [])
538
+ want = exp.get(kind, 0)
539
+ # For detailed rubrics the blank ships filled donor samples FIRST (인권·인포
540
+ # 그래픽) and clean empty templates LAST; the leading 인권 sample carries an
541
+ # extra 세부 항목 column the MD never uses, so keep the trailing clean
542
+ # templates instead (drop the surplus from the front).
543
+ surplus = idxs[:len(idxs) - want] if (kind == "rubric" and detailed_rubrics) else idxs[want:]
544
+ for i in surplus:
545
+ if i not in del_tables:
546
+ del_tables.append(i)
547
+ transcript.append(f"delete_table #{i} (surplus {kind} example) — "
548
+ f"content needs {want}, blank ships {len(idxs)}")
549
+
550
+ for i in sorted(set(del_tables), reverse=True):
551
+ ops.append({"op": "delete_table", "tableIndex": i})
552
+ # INDEX-SAFE ORDER: column deletes FIRST (they modify a table in place and do
553
+ # not shift table indices, so original indices are valid), THEN table deletes
554
+ # in descending index order (so each delete leaves lower indices unchanged).
555
+ # Emitting table deletes first would shift the ratio table under a later
556
+ # delete_column and silently corrupt the wrong table.
557
+ ops.sort(key=lambda o: (o["op"] == "delete_table", -o.get("tableIndex", 0)))
558
+ return {"ops": ops, "transcript": transcript,
559
+ "expected_skeleton": expected_skeleton(content) if content else None}
560
+
561
+
562
+ def _rate_bands(table) -> int:
563
+ """Number of distinct 성취도 grade bands (A..E) a 성취율 table declares — its
564
+ 'N단계'. Read from the grade column so 40%-boundary variants don't inflate it."""
565
+ from hwpx.table_patch import build_grid, table_text
566
+ tb = table.bytes
567
+ grid, rep = build_grid(tb)
568
+ grades: set[str] = set()
569
+ for r in range(1, rep.row_count): # row 0 is the header
570
+ c = grid.get((r, rep.col_count - 1)) # grade is the right-most column
571
+ if c is None:
572
+ continue
573
+ g = table_text(tb[c.start:c.end]).strip()
574
+ if g in ("A", "B", "C", "D", "E"):
575
+ grades.add(g)
576
+ return len(grades)
577
+
578
+
579
+ def _pick_achieve_rate(tabs, want_bands: int | None) -> int | None:
580
+ """Index of the single 성취율 table to KEEP among the blank's sample variants.
581
+
582
+ Chooses the variant whose grade-band count matches the review MD's 성취도 band
583
+ count (``want_bands``) -- 3학년 (2015-개정) keeps the 3단계 A~C, 2학년 (2022-개정)
584
+ keeps the 5단계 A~E. Ties (or ``want_bands`` unknown) fall back to the first
585
+ plain N-band table without a 40%-보장지도 boundary note (the canonical 고정분할
586
+ 점수 table), else the first 성취율 table. Returns ``None`` when there is at most
587
+ one 성취율 table (nothing to prune)."""
588
+ from ..form_fill.classification import _classify
589
+ rate = [(i, t) for i, t in enumerate(tabs) if _classify(t) == "achieve_rate"]
590
+ if len(rate) <= 1:
591
+ return None
592
+ if want_bands:
593
+ exact = [i for i, t in rate if _rate_bands(t) == want_bands]
594
+ # prefer the exact-band table WITHOUT the 40% 최소성취수준 보장지도 boundary
595
+ # note (that is the 공통과목 variant, not the canonical 고정분할점수 table)
596
+ plain = [i for i in exact if "보장지도" not in dict(rate)[i].text]
597
+ if plain:
598
+ return plain[0]
599
+ if exact:
600
+ return exact[0]
601
+ return rate[0][0]
602
+
603
+
604
+ def _regular_exam_cols(table) -> list[int]:
605
+ """Logical column indices of the 정기시험 span in a 반영비율 table header."""
606
+ from hwpx.table_patch import build_grid, table_text
607
+ tb = table.bytes
608
+ grid, rep = build_grid(tb)
609
+ cols: list[int] = []
610
+ for col in range(rep.col_count):
611
+ c = grid.get((0, col))
612
+ if c and "정기시험" in table_text(tb[c.start:c.end]):
613
+ cols.append(col)
614
+ return cols
615
+
616
+
617
+ # --------------------------------------------------------------------------- #
618
+ # Content fills (phase="all") -- byte-preserving cell/paragraph splices onto the
619
+ # restructured form. Every target is located by *classification* / grid geometry
620
+ # (reusable across the 평가계획 form family), never by a hard-coded 3학년 index; the
621
+ # structural deletions before them shift indices, so each fill re-locates its
622
+ # table fresh. All edits go through the byte-preserving primitives in
623
+ # :mod:`hwpx.table_patch` / :mod:`hwpx.patch` -- no table is ever regenerated.
624
+ # --------------------------------------------------------------------------- #
625
+
626
+ _STD_CODE = re.compile(r"\[1\d[가-힣A-Za-z]*\d\d-\d\d\]")
627
+
628
+
629
+ def _rubric_indices(data: bytes):
630
+ from ..form_fill.classification import _classify, _tables
631
+ return [i for i, t in enumerate(_tables(data)) if _classify(t) == "rubric"]
632
+
633
+
634
+ def _classify_index(data: bytes, kind: str) -> int | None:
635
+ """First table index whose scorer classification is *kind* (or None)."""
636
+ from ..form_fill.classification import _classify, _tables
637
+ for i, t in enumerate(_tables(data)):
638
+ if _classify(t) == kind:
639
+ return i
640
+ return None
641
+
642
+
643
+ def _grid_of(data: bytes, table_index: int):
644
+ """(section_path, table_bytes, grid, report) for a table by document index."""
645
+ from hwpx.table_patch import build_grid, iter_table_spans, section_parts
646
+ for sp, section in sorted(section_parts(data).items()):
647
+ spans = iter_table_spans(section)
648
+ if table_index < len(spans):
649
+ ts, te = spans[table_index]
650
+ tb = section[ts:te]
651
+ grid, rep = build_grid(tb)
652
+ return sp, tb, grid, rep
653
+ table_index -= len(spans)
654
+ raise IndexError("table index out of range across sections")
655
+
656
+
657
+ def _cell_text(tb: bytes, grid, row: int, col: int) -> str:
658
+ from hwpx.table_patch import table_text
659
+ c = grid.get((row, col))
660
+ return table_text(tb[c.start:c.end]) if c else ""
661
+
662
+
663
+ def fill_achievement(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
664
+ """Reshape the 성취기준 table to ``len(achievement_std)`` clean per-standard blocks
665
+ and fill each from the review MD (byte-preserving).
666
+
667
+ Works for BOTH 개정 by driving the block height off the content (the review MD
668
+ row is ``[code, <level descriptors...>]``): 2015-개정 (3학년) ships a 4-column
669
+ 음악 example (성취기준 | 평가준거 | 상/중/하 | 서술) with 3-row 상/중/하 blocks; 2022-
670
+ 개정 (2학년) ships a 3-column example (성취기준 | A~E | 서술) with 5-row A~E blocks.
671
+ We (1) drop the extra 평가준거 column when the blank is 4-wide, (2) delete every
672
+ data row except one canonical block whose height == the MD's level count,
673
+ (3) clone that block to N standards, then (4) splice each standard's code into
674
+ the leader cell and its level descriptors down the right-most (서술) column. The
675
+ level-label column (상/중/하 or A~E) carries over verbatim from the clone. A
676
+ no-op if the content has no achievement rows or the blank's block height doesn't
677
+ match the MD's level count (honest-defer, reported)."""
678
+ from hwpx.table_patch import apply_table_ops, direct_table_cells
679
+
680
+ stds = content.achievement_std
681
+ report: dict[str, Any] = {"n": len(stds), "filled": 0, "skipped": []}
682
+ if not stds:
683
+ return data, report
684
+ # descriptors per standard = MD columns after the code (3학년: 상/중/하 = 3;
685
+ # 2학년: A~E = 5). This is the target block height.
686
+ bh = max((len(s) - 1 for s in stds), default=0)
687
+ if bh < 1:
688
+ report["skipped"].append("achievement rows have no level descriptors")
689
+ return data, report
690
+
691
+ ti = _classify_index(data, "achievement")
692
+ if ti is None:
693
+ report["skipped"].append("no achievement table found")
694
+ return data, report
695
+
696
+ _sp, tb, _grid, rep = _grid_of(data, ti)
697
+ # Drop the 평가준거 column (only present in the 4-col 2015-개정 blank): it is the
698
+ # logical column that a clean standard's leader spans (cs2 header) but the MD
699
+ # has no content for. After the drop both 개정 are: col0=leader, col1=level
700
+ # label, col2=서술.
701
+ ops: list[dict[str, Any]] = []
702
+ if rep.col_count == 4:
703
+ ops.append({"op": "delete_column", "table_index": ti, "cols": [1]})
704
+ res = apply_table_ops(data, ops) if ops else None
705
+ data2 = res.data if res is not None else data
706
+ if res is not None and not res.ok:
707
+ report["skipped"].append(f"delete 평가준거 column: {[s.reason for s in res.skipped]}")
708
+ return data, report
709
+
710
+ # keep header + one clean block whose height matches the MD level count
711
+ _sp, tb, _grid, rep = _grid_of(data2, ti)
712
+ desc_col = rep.col_count - 1 # right-most (서술) column
713
+ leaders = sorted((c for c in direct_table_cells(tb) if c.col == 0 and c.row_span == bh),
714
+ key=lambda c: c.row)
715
+ if not leaders:
716
+ report["skipped"].append(
717
+ f"no clean {bh}-row block to seed from (blank block heights "
718
+ f"{sorted({c.row_span for c in direct_table_cells(tb) if c.col == 0 and c.row_span > 1})})")
719
+ return data, report
720
+ first = leaders[0]
721
+ keep = set(range(first.row, first.row + bh)) | {0}
722
+ delrows = sorted((r for r in range(rep.row_count) if r not in keep), reverse=True)
723
+ if delrows:
724
+ res = apply_table_ops(data2, [{"op": "delete_row", "table_index": ti, "rows": delrows}])
725
+ if not res.ok:
726
+ report["skipped"].append(f"prune to one block: {[s.reason for s in res.skipped]}")
727
+ return data, report
728
+ data2 = res.data
729
+
730
+ # grow to N blocks by cloning the single block (now rows 1..bh)
731
+ n = len(stds)
732
+ if n > 1:
733
+ res = apply_table_ops(data2, [{"op": "insert_block_by_clone", "table_index": ti,
734
+ "ref_rows": [1, bh], "count": n - 1}])
735
+ if not res.ok:
736
+ report["skipped"].append(f"clone to {n} blocks: {[s.reason for s in res.skipped]}")
737
+ return data, report
738
+ data2 = res.data
739
+
740
+ # fill each block: leader (rs=bh, col0) = code+text; 서술 col rows = descriptors
741
+ cells: list[dict[str, Any]] = []
742
+ for i, std in enumerate(stds):
743
+ lr = 1 + bh * i
744
+ cells.append({"table_index": ti, "row": lr, "col": 0, "text": std[0], "max_lines": 6})
745
+ for k, desc in enumerate(std[1:1 + bh]):
746
+ cells.append({"table_index": ti, "row": lr + k, "col": desc_col, "text": desc, "max_lines": 5})
747
+ from hwpx.table_patch import fill_cells
748
+ fr = fill_cells(data2, cells)
749
+ report["filled"] = len(fr.applied)
750
+ report["skipped"].extend(s.reason for s in fr.skipped)
751
+ return fr.data, report
752
+
753
+
754
+ _GRADE_ROW = re.compile(r"^[A-E]$")
755
+
756
+
757
+ def fill_levels(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
758
+ """Fill the 성취수준 table's descriptor column from the review MD levels,
759
+ replacing the blank's sample. Handles BOTH review shapes:
760
+
761
+ * 2015-개정 (3학년): ``levels`` is per-area ``[[영역, A, B, C], ...]`` → the first
762
+ area's A/B/C descriptors fill rows 1-3 of a ``성취수준 | 일반적 특성`` grid.
763
+ * 2022-개정 (2학년): ``levels`` is grade-major ``[[A, 서술], [B, 서술], ...]`` → each
764
+ grade's descriptor fills the matching A/B/C/D/E row of the ``학기 단위 성취수준``
765
+ grid (matched by the row's grade label, so a shape mismatch never corrupts it).
766
+
767
+ Only descriptor cells are touched (byte-preserving)."""
768
+ from hwpx.table_patch import fill_cells, table_text
769
+
770
+ report: dict[str, Any] = {"filled": 0, "skipped": []}
771
+ if not content.levels:
772
+ return data, report
773
+ ti = _classify_index(data, "level")
774
+ if ti is None:
775
+ report["skipped"].append("no level table found")
776
+ return data, report
777
+ _sp, tb, grid, rep = _grid_of(data, ti)
778
+
779
+ grade_major = all(len(r) >= 2 and _GRADE_ROW.match(r[0].strip()) for r in content.levels)
780
+ desc_col = rep.col_count - 1
781
+ cells = []
782
+ if grade_major:
783
+ # map grade letter -> its descriptor; fill each table row by its grade label
784
+ by_grade = {r[0].strip(): r[-1] for r in content.levels}
785
+ for row in range(1, rep.row_count):
786
+ c0 = grid.get((row, 0))
787
+ label = table_text(tb[c0.start:c0.end]).strip() if c0 else ""
788
+ if label in by_grade and grid.get((row, desc_col)):
789
+ cells.append({"table_index": ti, "row": row, "col": desc_col,
790
+ "text": by_grade[label], "max_lines": 6})
791
+ else:
792
+ abc = content.levels[0][1:1 + (rep.row_count - 1)] if len(content.levels[0]) >= 2 else []
793
+ for k, desc in enumerate(abc):
794
+ r = 1 + k
795
+ if grid.get((r, desc_col)):
796
+ cells.append({"table_index": ti, "row": r, "col": desc_col, "text": desc, "max_lines": 5})
797
+ fr = fill_cells(data, cells)
798
+ report["filled"] = len(fr.applied)
799
+ report["skipped"].extend(s.reason for s in fr.skipped)
800
+ return fr.data, report
801
+
802
+
803
+ def _batjeom_line(item_label: str, criteria: str) -> str:
804
+ """Compose one 채점기준 cell line: 'label: level 15 / level 12 / ...' from the
805
+ MD's bolded-배점 criteria string ('명확히 정의·트리 표현 **15** / ...')."""
806
+ clean = criteria.replace("**", "").strip()
807
+ return f"{item_label}: {clean}" if clean else item_label
808
+
809
+
810
+ def _top_batjeom(criteria: str) -> str:
811
+ m = re.findall(r"\*\*(\d+)\*\*", criteria)
812
+ return m[0] if m else ""
813
+
814
+
815
+ def _base_scores(rows: list[list[str]]) -> tuple[str, str, str, str]:
816
+ """Pull 기본점수 / 장기 미인정 labels+scores from a rubric's trailing summary
817
+ row ('기본점수(백지·미참여) **14** · 장기 미인정 결석 **13**').
818
+
819
+ The label itself can contain a middle-dot ('백지·미참여'), so split on the
820
+ score-bearing clause boundary ('**N** ·'), not on every '·'."""
821
+ base_label, base_score, long_label, long_score = "기본점수", "", "장기 미인정 결석", ""
822
+ for row in rows:
823
+ cell = row[0]
824
+ if "기본점수" not in cell:
825
+ continue
826
+ # boundary: the ' · ' that follows the first '**score**'
827
+ m = re.match(r"(.*?\*\*\d+\*\*)\s*·\s*(.*)", cell)
828
+ if m:
829
+ first, second = m.group(1), m.group(2)
830
+ else:
831
+ first, second = cell, ""
832
+ b = re.findall(r"\*\*(\d+)\*\*", first)
833
+ long = re.findall(r"\*\*(\d+)\*\*", second)
834
+ base_label = re.sub(r"\s*\*\*\d+\*\*", "", first).strip() or base_label
835
+ if second:
836
+ long_label = re.sub(r"\s*\*\*\d+\*\*", "", second).strip() or long_label
837
+ if b:
838
+ base_score = b[0]
839
+ if long:
840
+ long_score = long[0]
841
+ return base_label, base_score, long_label, long_score
842
+
843
+
844
+ def _parse_level_ladder(criteria: str) -> list[tuple[str, str]]:
845
+ """Parse one 평가항목's 채점기준 ladder ('전처리·완비 **30** / 대부분 **24** / …')
846
+ into ``[(descriptor, 배점), …]`` — one tuple per level, top score first. Each
847
+ level is a ``' / '``-separated clause whose ``**\\d+**`` marker is the 배점 and the
848
+ remaining text the descriptor. Clauses without a score marker are dropped (a level
849
+ must carry a 배점 to be faithfully placeable). This is the flat-ladder counterpart of
850
+ the 3학년 (2015-개정) ``_batjeom_line`` / ``_top_batjeom`` parse, kept as an explicit
851
+ (desc, score) list so the 2022 reshape can lay one grid row per level."""
852
+ out: list[tuple[str, str]] = []
853
+ for clause in re.split(r"\s*/\s*", (criteria or "").strip()):
854
+ m = re.search(r"\*\*(\d+)\*\*", clause)
855
+ if not m:
856
+ continue
857
+ desc = re.sub(r"\s*\*\*\d+\*\*\s*", "", clause).strip()
858
+ out.append((desc, m.group(1)))
859
+ return out
860
+
861
+
862
+ def _pf_bounds(tb: bytes, grid, rep) -> tuple[int | None, int | None]:
863
+ """(평가요소 header row, 기본점수 row) of a 2022 rubric — the 채점기준 ladder region
864
+ is the physical rows strictly between them."""
865
+ from hwpx.table_patch import table_text
866
+ ph = base = None
867
+ for r in range(rep.row_count):
868
+ c0 = grid.get((r, 0))
869
+ if c0 is None:
870
+ continue
871
+ t0 = table_text(tb[c0.start:c0.end])
872
+ if t0.replace(" ", "").strip() == "평가요소":
873
+ ph = r
874
+ if base is None and "기본점수" in t0:
875
+ base = r
876
+ return ph, base
877
+
878
+
879
+ def _pf_groups(tb: bytes, ph: int, base: int) -> list[tuple[int, int]]:
880
+ """The col0 평가요소 leader groups (row, rowSpan) strictly inside ``(ph, base)`` —
881
+ one per 평가항목, in document order. Deduped by byte span (a merged leader is one
882
+ group even though it covers several logical rows)."""
883
+ from hwpx.table_patch import direct_table_cells
884
+ seen: set[tuple[int, int]] = set()
885
+ groups: list[tuple[int, int]] = []
886
+ for c in sorted(direct_table_cells(tb), key=lambda c: c.row):
887
+ if c.col == 0 and ph < c.row < base and (c.start, c.end) not in seen:
888
+ seen.add((c.start, c.end))
889
+ groups.append((c.row, c.row_span))
890
+ return groups
891
+
892
+
893
+ def _pf_desc_col(tb: bytes, ph: int, base: int, bat_col: int) -> int:
894
+ """The 수행수준(채점기준) descriptor column — the widest (max colSpan) data cell in
895
+ the ladder region. A narrower col to its left (col 1) is the optional 세부 항목
896
+ (sub-item) column, present in the 45점 45×9 rubric but not the 30/25점 grids."""
897
+ from hwpx.table_patch import direct_table_cells
898
+ best, best_span = None, 0
899
+ for c in direct_table_cells(tb):
900
+ if ph < c.row < base and 1 <= c.col < bat_col and c.col_span > best_span:
901
+ best_span, best = c.col_span, c.col
902
+ return best if best is not None else 1
903
+
904
+
905
+ def _pf_subitems(tb: bytes, r0: int, h: int) -> list[tuple[int, int]]:
906
+ """col1 세부 항목 sub-blocks (row, rowSpan) inside a 평가요소 group [r0, r0+h)."""
907
+ from hwpx.table_patch import direct_table_cells
908
+ seen: set[tuple[int, int]] = set()
909
+ subs: list[tuple[int, int]] = []
910
+ for c in sorted(direct_table_cells(tb), key=lambda c: c.row):
911
+ if c.col == 1 and r0 <= c.row < r0 + h and (c.start, c.end) not in seen:
912
+ seen.add((c.start, c.end))
913
+ subs.append((c.row, c.row_span))
914
+ return subs
915
+
916
+
917
+ def _pf_clean_level_row(grid, r: int, desc_col: int, bat_col: int) -> bool:
918
+ """A row that OWNS both a descriptor cell (at ``desc_col``) and a 배점 cell (at
919
+ ``bat_col``) starting at row ``r`` with rowSpan==1 — a self-contained level row
920
+ safe to keep/clone. A row whose 배점 is covered by a vertical merge from above
921
+ (rowSpan>1) is NOT clean: keeping it while dropping its merge-top would hole the
922
+ grid, so those are the rows we delete."""
923
+ d = grid.get((r, desc_col))
924
+ b = grid.get((r, bat_col))
925
+ return bool(d and b and d.row == r and b.row == r and d.row_span == 1 and b.row_span == 1)
926
+
927
+
928
+ def _reduce_to_first_subitem(data: bytes, ti: int, r0: int, h: int) -> tuple[bytes, int, list[str]]:
929
+ """If a 평가요소 group [r0, r0+h) subdivides into >1 col1 세부 항목 block (the 45점
930
+ rubric does), delete every block after the first so the group maps to the MD's ONE
931
+ flat 평가항목 ladder. Byte-preserving (delete_row only); returns (data, new_height,
932
+ skip_reasons). The MD carries no sub-item decomposition, so keeping every sub-block
933
+ would have no faithful source — collapsing to the first block is the honest map."""
934
+ from hwpx.table_patch import apply_table_ops
935
+ _sp, tb, _grid, _rep = _grid_of(data, ti)
936
+ subs = _pf_subitems(tb, r0, h)
937
+ if len(subs) <= 1:
938
+ return data, h, []
939
+ _first_r, first_h = subs[0]
940
+ del_rows: list[int] = []
941
+ for sr, sh in subs[1:]:
942
+ del_rows.extend(range(sr, sr + sh))
943
+ res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti, "rows": sorted(set(del_rows), reverse=True)}])
944
+ if not res.ok:
945
+ return data, h, [f"drop surplus 세부 항목 blocks: {[s.reason for s in res.skipped]}"]
946
+ return res.data, first_h, []
947
+
948
+
949
+ def _normalize_ladder_group(data: bytes, ti: int, r0: int, h: int, m: int,
950
+ desc_col: int, bat_col: int) -> tuple[bytes, list[str]]:
951
+ """Reshape a 평가요소 group [r0, r0+h) to exactly ``m`` clean level rows (byte-
952
+ preserving delete_row / insert_row_by_clone). Keeps the leader row r0 plus the
953
+ first (m-1) clean interior level rows, deletes the rest, and clones a clean interior
954
+ row up when the group is short. Fail-closed: returns (data unchanged, [reason]) if
955
+ the shape can't be reached cleanly (e.g. too few clean rows to shrink to m)."""
956
+ from hwpx.table_patch import apply_table_ops
957
+ _sp, _tb, grid, _rep = _grid_of(data, ti)
958
+ interior = list(range(r0 + 1, r0 + h))
959
+ clean = [r for r in interior if _pf_clean_level_row(grid, r, desc_col, bat_col)]
960
+ if not _pf_clean_level_row(grid, r0, desc_col, bat_col):
961
+ return data, [f"group r0={r0}: leader row is not a clean level row"]
962
+ keep = clean[: m - 1]
963
+ del_rows = sorted(set(interior) - set(keep), reverse=True)
964
+ d = data
965
+ if del_rows:
966
+ res = apply_table_ops(d, [{"op": "delete_row", "table_index": ti, "rows": del_rows}])
967
+ if not res.ok:
968
+ return data, [f"group r0={r0}: shrink {del_rows}: {[s.reason for s in res.skipped]}"]
969
+ d = res.data
970
+ have = 1 + len(keep)
971
+ if have < m:
972
+ # clone the group's clean interior template (now at r0+1) up to m rows
973
+ res = apply_table_ops(d, [{"op": "insert_row_by_clone", "table_index": ti,
974
+ "ref_row": r0 + 1, "count": m - have}])
975
+ if not res.ok:
976
+ return data, [f"group r0={r0}: grow: {[s.reason for s in res.skipped]}"]
977
+ d = res.data
978
+ elif have > m:
979
+ return data, [f"group r0={r0}: only {len(clean)} clean rows, cannot shrink to {m}"]
980
+ return d, []
981
+
982
+
983
+ def _fill_rubric_2022_ladder(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, bool, list[str]]:
984
+ """Reshape + fill ONE 2022-개정 rubric's 수행수준 채점기준 ladder from the review MD,
985
+ byte-preserving. Each MD 평가항목 carries a flat level ladder ('desc **30** / desc
986
+ **24** / …'); the blank decomposes each 평가요소 into sample-specific sub-rows. We
987
+ map the blank's col0 평가요소 groups 1:1 to the MD 평가항목 (document order),
988
+ normalize each group to the MD ladder's level count via delete_row /
989
+ insert_row_by_clone (reshape ≠ regeneration — the original cells' formatting is
990
+ carried byte-for-byte), then splice each level's descriptor + 배점 in; the optional
991
+ 세부 항목 column is collapsed to the first sub-block and blanked (no MD source).
992
+
993
+ Returns ``(data, filled, notes)``. ``filled`` is True only when EVERY group was
994
+ reconciled and written — a rubric whose group count ≠ MD item count, whose ladder
995
+ won't parse, or whose reshape fails is left byte-identical and reported (fail-closed
996
+ / honest-defer), so the caller keeps its NEEDS_REVIEW note."""
997
+ from hwpx.table_patch import fill_cells
998
+
999
+ items = [row for row in rub.rows if not row[0].startswith("기본점수")]
1000
+ ladders = [_parse_level_ladder(it[1]) for it in items]
1001
+ if not items or any(not L for L in ladders):
1002
+ return data, False, [f"rubric ti={ti}: 채점기준 ladder unparseable for some 평가항목"]
1003
+
1004
+ _sp, tb, grid, rep = _grid_of(data, ti)
1005
+ ph, base = _pf_bounds(tb, grid, rep)
1006
+ if ph is None or base is None:
1007
+ return data, False, [f"rubric ti={ti}: no 평가요소/기본점수 bounds for 채점기준 ladder"]
1008
+ bat_col = rep.col_count - 1
1009
+ desc_col = _pf_desc_col(tb, ph, base, bat_col)
1010
+ has_sub = desc_col > 1
1011
+ groups = _pf_groups(tb, ph, base)
1012
+ if len(groups) != len(items):
1013
+ return data, False, [f"rubric ti={ti}: {len(groups)} 평가요소 groups != {len(items)} MD 평가항목"]
1014
+
1015
+ # RESHAPE each group to its ladder's level count. Process LAST→FIRST so earlier
1016
+ # (smaller-index) groups stay valid as later ones change row counts; re-locate the
1017
+ # groups fresh before every edit (indices shift).
1018
+ d = data
1019
+ for gi in range(len(groups) - 1, -1, -1):
1020
+ m = len(ladders[gi])
1021
+ _sp, tb, grid, rep = _grid_of(d, ti)
1022
+ ph2, base2 = _pf_bounds(tb, grid, rep)
1023
+ gg = _pf_groups(tb, cast(int, ph2), cast(int, base2))
1024
+ r0, h = gg[gi]
1025
+ if has_sub:
1026
+ d, _first_h, sub_sk = _reduce_to_first_subitem(d, ti, r0, h)
1027
+ if sub_sk:
1028
+ return data, False, [f"rubric ti={ti} group {gi}: {sub_sk[0]}"]
1029
+ _sp, tb, grid, rep = _grid_of(d, ti)
1030
+ ph2, base2 = _pf_bounds(tb, grid, rep)
1031
+ r0, h = _pf_groups(tb, cast(int, ph2), cast(int, base2))[gi]
1032
+ d, norm_sk = _normalize_ladder_group(d, ti, r0, h, m, desc_col, bat_col)
1033
+ if norm_sk:
1034
+ return data, False, [f"rubric ti={ti} group {gi}: {norm_sk[0]}"]
1035
+
1036
+ # FILL each normalized level row: descriptor + 배점, and blank the 세부 항목 cell.
1037
+ _sp, tb, grid, rep = _grid_of(d, ti)
1038
+ ph2, base2 = _pf_bounds(tb, grid, rep)
1039
+ gg = _pf_groups(tb, cast(int, ph2), cast(int, base2))
1040
+ cells: list[dict[str, Any]] = []
1041
+ for gi, (r0, _h) in enumerate(gg):
1042
+ for k, (desc, score) in enumerate(ladders[gi]):
1043
+ r = r0 + k
1044
+ dc = grid.get((r, desc_col))
1045
+ if dc is not None:
1046
+ cells.append({"table_index": ti, "row": dc.row, "col": dc.col, "text": desc, "max_lines": 4})
1047
+ bc = grid.get((r, bat_col))
1048
+ if bc is not None:
1049
+ cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
1050
+ if has_sub:
1051
+ sc = grid.get((r0, 1))
1052
+ if sc is not None and sc.col == 1:
1053
+ cells.append({"table_index": ti, "row": sc.row, "col": 1, "text": "", "max_lines": 1})
1054
+ fr = fill_cells(d, cells)
1055
+ return fr.data, True, [s.reason for s in fr.skipped]
1056
+
1057
+
1058
+ def _rubric_is_2022(data: bytes, ti: int) -> bool:
1059
+ """A 2022-개정 rubric table leads its first row with '평가 영역명' (the 2015-개정
1060
+ one leads with '교육과정성취기준')."""
1061
+ from hwpx.table_patch import table_text
1062
+ _sp, tb, grid, _rep = _grid_of(data, ti)
1063
+ c0 = grid.get((0, 0))
1064
+ return bool(c0) and table_text(tb[c0.start:c0.end]).strip().startswith("평가 영역명")
1065
+
1066
+
1067
+ def _item_label(row0: str) -> str:
1068
+ """Clean a review rubric item's label cell for splicing (drop markdown emphasis)."""
1069
+ return re.sub(r"\*\*(\d+)\*\*", r"\1", row0).replace("**", "").strip()
1070
+
1071
+
1072
+ def _std_level_map(achievement_std: list[list[str]]) -> dict[str, list[str]]:
1073
+ """Map each 성취기준 code -> its ``[A, B, C, D, E]`` 성취수준 descriptors from the
1074
+ review MD's §4가 table (row = ``[code+진술, A, B, C, D, E]``). Codes with fewer
1075
+ than five descriptor columns are skipped (honest: no partial level fill)."""
1076
+ out: dict[str, list[str]] = {}
1077
+ for row in achievement_std:
1078
+ if not row:
1079
+ continue
1080
+ m = _STD_CODE.search(row[0])
1081
+ if m is None:
1082
+ continue
1083
+ levels = [c.strip() for c in row[1:6]]
1084
+ if len(levels) == 5 and all(levels):
1085
+ out[m.group(0)] = levels
1086
+ return out
1087
+
1088
+
1089
+ def _fill_rubric_ae_levels(
1090
+ ti: int, tb: bytes, grid, rep, rub: Rubric, std_levels: dict[str, list[str]],
1091
+ ) -> tuple[list[dict[str, Any]], list[str]]:
1092
+ """Address the A~E descriptor cells of a 2022-개정 rubric's 성취기준별 성취수준 block
1093
+ with the review MD's real 성취수준 descriptors for the rubric's PRIMARY standard.
1094
+
1095
+ A block is a run of rows whose grade-label cell (the col immediately left of the
1096
+ wide 서술 cell) holds 'A'..'E'; the blank ships the descriptors as a foreign sample
1097
+ (통합사회/미술 프로젝트 prose). The blank can ship SEVERAL A~E blocks under one rubric
1098
+ (one per sample standard). We locate every block after the '성취기준' label row and
1099
+ fill the *i*-th block from the *i*-th referenced standard's review 성취수준 (the
1100
+ PRIMARY -- first -- standard drives block 0), splicing that standard's A→col+1 …
1101
+ E→col+5 descriptor into each grade row's 서술 cell. Standard codes come from
1102
+ ``rub.standards`` (a ``~`` range contributes only its leading code); a block with
1103
+ no matching standard is left untouched (honest -- no fabricated mapping).
1104
+
1105
+ Returns ``(cells, skipped)``: cell specs for :func:`fill_cells` and fail-closed
1106
+ skip reasons (no A~E block, or a standard has no MD 성취수준 row). Only the 서술
1107
+ descriptor cell of each grade row is addressed -- the grade label and the span-5
1108
+ 성취기준 leader are left untouched."""
1109
+ from hwpx.table_patch import direct_table_cells, table_text
1110
+
1111
+ codes = _STD_CODE.findall(rub.standards or "")
1112
+ if not codes:
1113
+ return [], [f"rubric ti={ti}: no standard code in {rub.standards!r} for A~E levels"]
1114
+
1115
+ # '성취기준' label row bounds the block start; the next '평가 방법' row bounds its end.
1116
+ std_label_row = next(
1117
+ (r for r in range(rep.row_count)
1118
+ if (c0 := grid.get((r, 0))) is not None
1119
+ and table_text(tb[c0.start:c0.end]).replace(" ", "").strip() == "성취기준"),
1120
+ None,
1121
+ )
1122
+ if std_label_row is None:
1123
+ return [], [f"rubric ti={ti}: no '성취기준' label row to anchor A~E block"]
1124
+ method_row = next(
1125
+ (r for r in range(std_label_row + 1, rep.row_count)
1126
+ if (c0 := grid.get((r, 0))) is not None
1127
+ and "평가" in table_text(tb[c0.start:c0.end]) and "방법" in table_text(tb[c0.start:c0.end])),
1128
+ rep.row_count,
1129
+ )
1130
+
1131
+ # A grade cell is a direct 1x1 cell holding exactly one of 'A'..'E' in the block;
1132
+ # its 서술 descriptor is the direct cell in the same row starting at grade.col+1.
1133
+ grade_rows: list[tuple[str, int, int]] = [] # (grade, row, grade_col)
1134
+ for c in sorted(direct_table_cells(tb), key=lambda c: c.row):
1135
+ if not (std_label_row < c.row < method_row):
1136
+ continue
1137
+ t = table_text(tb[c.start:c.end]).strip()
1138
+ if t in ("A", "B", "C", "D", "E") and c.col_span == 1:
1139
+ grade_rows.append((t, c.row, c.col))
1140
+ if not grade_rows:
1141
+ return [], [f"rubric ti={ti}: no A~E grade rows in 성취기준별 성취수준 block"]
1142
+
1143
+ # split into contiguous A→…→E blocks (a new 'A' starts a new block); the blank can
1144
+ # ship several sample blocks under one rubric (one per sample standard).
1145
+ blocks: list[list[tuple[str, int, int]]] = []
1146
+ for g, r, col in grade_rows:
1147
+ if g == "A" or not blocks:
1148
+ blocks.append([])
1149
+ blocks[-1].append((g, r, col))
1150
+
1151
+ cells: list[dict[str, Any]] = []
1152
+ skipped: list[str] = []
1153
+ for bi, block in enumerate(blocks):
1154
+ # map the i-th block to the i-th referenced standard; the primary standard
1155
+ # drives block 0. A block past the referenced standards is left untouched.
1156
+ if bi >= len(codes):
1157
+ skipped.append(f"rubric ti={ti}: A~E block {bi} has no {bi}-th referenced standard -- left as-is")
1158
+ continue
1159
+ levels = std_levels.get(codes[bi])
1160
+ if levels is None:
1161
+ skipped.append(f"rubric ti={ti}: standard {codes[bi]} (block {bi}) not in review 성취수준 map")
1162
+ continue
1163
+ lvl_by_grade = dict(zip("ABCDE", levels))
1164
+ for g, r, gcol in block:
1165
+ desc = grid.get((r, gcol + 1))
1166
+ if desc is None or desc.col <= gcol:
1167
+ skipped.append(f"rubric ti={ti}: grade {g} row {r} has no 서술 cell right of col {gcol}")
1168
+ continue
1169
+ text = lvl_by_grade.get(g)
1170
+ if not text:
1171
+ continue
1172
+ # address the 서술 cell by its own top-left logical position (merge-safe).
1173
+ cells.append({"table_index": ti, "row": desc.row, "col": desc.col,
1174
+ "text": text, "max_lines": 4})
1175
+ return cells, skipped
1176
+
1177
+
1178
+ def _locate_rubric_2022_rows(tb: bytes, grid: Any, rep: Any) -> tuple[int | None, int | None, int | None, int | None]:
1179
+ """Locate the 수행과제 / 성취기준-label / 평가요소 header / 기본점수 rows."""
1180
+
1181
+ from hwpx.table_patch import table_text
1182
+
1183
+ task_row = std_label_row = ph = base = None
1184
+ for r in range(rep.row_count):
1185
+ c0 = grid.get((r, 0))
1186
+ if c0 is None:
1187
+ continue
1188
+ t0 = table_text(tb[c0.start:c0.end]).strip()
1189
+ t0c = t0.replace(" ", "")
1190
+ if task_row is None and t0c == "수행과제":
1191
+ task_row = r
1192
+ if std_label_row is None and t0c == "성취기준":
1193
+ std_label_row = r
1194
+ if t0c == "평가요소":
1195
+ ph = r
1196
+ if base is None and "기본점수" in t0:
1197
+ base = r
1198
+ return task_row, std_label_row, ph, base
1199
+
1200
+
1201
+ def _rubric_2022_header_cells(ti: int, rub: Rubric) -> list[dict[str, Any]]:
1202
+ # header: title / points -- addressed by adjacent label (geometry-free)
1203
+ return [
1204
+ {"table_index": ti, "cell_anchor": {"label": "평가 영역명", "dir": "right"},
1205
+ "text": rub.title, "max_lines": 2},
1206
+ {"table_index": ti, "cell_anchor": {"label": "영역 만점", "dir": "right"},
1207
+ "text": f"{rub.points}점", "max_lines": 1},
1208
+ ]
1209
+
1210
+
1211
+ def _rubric_2022_standards_method_row(tb: bytes, grid: Any, rep: Any, std_label_row: int) -> int:
1212
+ from hwpx.table_patch import table_text
1213
+
1214
+ return next(
1215
+ (
1216
+ r for r in range(std_label_row + 1, rep.row_count)
1217
+ if (c0 := grid.get((r, 0))) is not None
1218
+ and "평가" in table_text(tb[c0.start:c0.end])
1219
+ and "방법" in table_text(tb[c0.start:c0.end])
1220
+ ),
1221
+ rep.row_count,
1222
+ )
1223
+
1224
+
1225
+ def _rubric_2022_standards_leader_rows(tb: bytes, std_label_row: int, method_row: int) -> list[int]:
1226
+ from hwpx.table_patch import direct_table_cells
1227
+
1228
+ return [
1229
+ c.row for c in sorted(direct_table_cells(tb), key=lambda c: c.row)
1230
+ if c.col == 0 and std_label_row < c.row < method_row and c.row_span >= 2
1231
+ ]
1232
+
1233
+
1234
+ def _rubric_2022_standards_cells(
1235
+ ti: int, rub: Rubric, tb: bytes, grid: Any, rep: Any, std_label_row: int | None
1236
+ ) -> tuple[list[dict[str, Any]], list[str]]:
1237
+ """성취기준 codes: the '성취기준' LABEL row's right neighbour is the '성취기준별
1238
+ 성취수준' A~E banner (a section header, NOT a value slot) -- so we write the
1239
+ codes into the A~E block's col-0 LEADER cell(s) instead (the merged cells that
1240
+ span the A..E rows and currently carry the blank's foreign sample codes).
1241
+ The blank can ship SEVERAL sample 성취기준 blocks under one rubric (each its own
1242
+ A~E leader); the MD supplies one codes string per rubric, so we overwrite EVERY
1243
+ leader between the 성취기준 label row and the next 평가 방법 section, leaving no
1244
+ foreign code behind (the first carries the codes; the rest are blanked)."""
1245
+
1246
+ cells: list[dict[str, Any]] = []
1247
+ skipped: list[str] = []
1248
+ if rub.standards and std_label_row is not None:
1249
+ method_row = _rubric_2022_standards_method_row(tb, grid, rep, std_label_row)
1250
+ leaders = _rubric_2022_standards_leader_rows(tb, std_label_row, method_row)
1251
+ if leaders:
1252
+ for j, lr in enumerate(leaders):
1253
+ cells.append({"table_index": ti, "row": lr, "col": 0,
1254
+ "text": rub.standards if j == 0 else "", "max_lines": 4})
1255
+ else:
1256
+ skipped.append(f"rubric ti={ti}: no A~E 성취기준 block leader to place codes")
1257
+ return cells, skipped
1258
+
1259
+
1260
+ def _rubric_2022_task_cells(
1261
+ ti: int, rub: Rubric, grid: Any, rep: Any, task_row: int | None
1262
+ ) -> list[dict[str, Any]]:
1263
+ """수행과제: the blank ships a foreign sample task (통사 인권 문제 …). The MD has no
1264
+ dedicated 수행과제 string, so synthesise a faithful one from THIS area's 평가항목
1265
+ labels (the tasks the rubric actually scores) -- replaces the sample subject."""
1266
+
1267
+ cells: list[dict[str, Any]] = []
1268
+ if task_row is not None:
1269
+ items = [_item_label(row[0]) for row in rub.rows if not row[0].startswith("기본점수")]
1270
+ if items:
1271
+ # the value cell is the FIRST cell to the right of the 수행과제 label's own
1272
+ # col-span (the 45점 rubric's label spans cols 0-1, so grid[(task_row, 1)]
1273
+ # would be the label itself -- start past it, not at a fixed col 1).
1274
+ label_cell = grid.get((task_row, 0))
1275
+ start_col = (label_cell.col + label_cell.col_span) if label_cell is not None else 1
1276
+ task_cell = next((grid.get((task_row, cc)) for cc in range(start_col, rep.col_count)
1277
+ if grid.get((task_row, cc)) is not None), None)
1278
+ if task_cell is not None:
1279
+ cells.append({"table_index": ti, "row": task_cell.row, "col": task_cell.col,
1280
+ "text": "∙" + " ∙".join(items), "max_lines": 3})
1281
+ return cells
1282
+
1283
+
1284
+ def _rubric_2022_element_leader_rows(tb: bytes, ph: int, base: int) -> list[int]:
1285
+ from hwpx.table_patch import direct_table_cells
1286
+
1287
+ seen: set[tuple[int, int]] = set()
1288
+ leaders: list[int] = []
1289
+ for c in sorted(direct_table_cells(tb), key=lambda c: c.row):
1290
+ if c.col == 0 and ph < c.row < base and c.row_span >= 2 and (c.start, c.end) not in seen:
1291
+ seen.add((c.start, c.end))
1292
+ leaders.append(c.row)
1293
+ return leaders
1294
+
1295
+
1296
+ def _rubric_2022_element_label_cells(
1297
+ ti: int, rub: Rubric, leaders: list[int]
1298
+ ) -> tuple[list[dict[str, Any]], list[str]]:
1299
+ items = [_item_label(row[0]) for row in rub.rows if not row[0].startswith("기본점수")]
1300
+ cells: list[dict[str, Any]] = []
1301
+ skipped: list[str] = []
1302
+ if leaders and items:
1303
+ k = len(leaders)
1304
+ packed = items if len(items) <= k else items[:k - 1] + [" / ".join(items[k - 1:])]
1305
+ for row, label in zip(leaders, packed):
1306
+ cells.append({"table_index": ti, "row": row, "col": 0, "text": label, "max_lines": 3})
1307
+ elif items:
1308
+ skipped.append(f"rubric ti={ti}: no 평가요소 leader cells to place {len(items)} items")
1309
+ return cells, skipped
1310
+
1311
+
1312
+ def _rubric_2022_base_score_cells(
1313
+ ti: int, rub: Rubric, grid: Any, rep: Any, base: int, lastcol: int
1314
+ ) -> list[dict[str, Any]]:
1315
+ _base_label, base_score, _long_label, long_score = _base_scores(rub.rows)
1316
+ cells: list[dict[str, Any]] = []
1317
+ if base_score and grid.get((base, lastcol)):
1318
+ cells.append({"table_index": ti, "row": base, "col": lastcol, "text": base_score, "max_lines": 1})
1319
+ if long_score and base + 1 < rep.row_count and grid.get((base + 1, lastcol)):
1320
+ cells.append({"table_index": ti, "row": base + 1, "col": lastcol, "text": long_score, "max_lines": 1})
1321
+ return cells
1322
+
1323
+
1324
+ def _rubric_2022_leader_and_base_cells(
1325
+ ti: int, rub: Rubric, tb: bytes, grid: Any, rep: Any, ph: int | None, base: int | None, lastcol: int
1326
+ ) -> tuple[list[dict[str, Any]], list[str]]:
1327
+ """평가요소 수행수준 leader cells (col0 merged cells strictly inside (ph, base))
1328
+ plus the 기본점수 / 장기 미인정 배점 (right-most column of the two base rows)."""
1329
+
1330
+ if ph is None or base is None:
1331
+ return [], [f"rubric ti={ti}: could not bound 평가요소 region (ph={ph}, base={base})"]
1332
+ leaders = _rubric_2022_element_leader_rows(tb, ph, base)
1333
+ label_cells, skipped = _rubric_2022_element_label_cells(ti, rub, leaders)
1334
+ base_cells = _rubric_2022_base_score_cells(ti, rub, grid, rep, base, lastcol)
1335
+ return label_cells + base_cells, skipped
1336
+
1337
+
1338
+ def _fill_rubric_2022(data: bytes, ti: int, rub: Rubric,
1339
+ std_levels: dict[str, list[str]] | None = None) -> tuple[bytes, list[str]]:
1340
+ """Fill ONE 2022-개정 수행평가 세부기준 rubric table (평가 영역명 layout) from a
1341
+ review rubric, byte-preserving. These tables are heterogeneous rich grids
1342
+ (수행과제 / 성취기준 A~E block / 평가방법 / 학생 유의사항 / 평가요소 수행수준 blocks /
1343
+ 기본점수·장기미인정). We overwrite the cleanly-addressable label cells the scorer
1344
+ measures -- the 평가 영역명 (title), 영역 만점 (points), 성취기준 (codes), the 성취
1345
+ 기준별 성취수준 A~E descriptors (from the primary standard's review 성취수준, via
1346
+ ``std_levels``), the 평가요소 수행수준 leader cells (the review 평가항목 labels), the
1347
+ 수행과제 task, and the 기본점수 / 장기 미인정 배점 -- AND reshape+fill the 수행수준
1348
+ 채점기준 descriptor ladder (:func:`_fill_rubric_2022_ladder`): each 평가요소 group is
1349
+ normalized (delete_row / insert_row_by_clone -- reshape, NOT regeneration) to the
1350
+ MD ladder's level count, then each level's descriptor + 배점 is spliced in. A rubric
1351
+ whose ladder can't be reconciled cleanly is left as blank sample and reported
1352
+ (NEEDS_REVIEW, fail-closed) rather than corrupted."""
1353
+ from hwpx.table_patch import fill_cells
1354
+
1355
+ skipped: list[str] = []
1356
+
1357
+ # STEP 0 (structure+content): reshape and fill the 수행수준 채점기준 ladder FIRST --
1358
+ # it deletes/clones rows, so the label-cell locations below must be found on the
1359
+ # reshaped grid. Fail-closed: on any reconciliation failure the ladder pass returns
1360
+ # the input bytes unchanged and its notes carry the reason (a NEEDS_REVIEW note is
1361
+ # re-emitted at the end when ``ladder_filled`` is False).
1362
+ data, ladder_filled, ladder_notes = _fill_rubric_2022_ladder(data, ti, rub)
1363
+ skipped.extend(ladder_notes)
1364
+
1365
+ _sp, tb, grid, rep = _grid_of(data, ti)
1366
+ lastcol = rep.col_count - 1
1367
+
1368
+ task_row, std_label_row, ph, base = _locate_rubric_2022_rows(tb, grid, rep)
1369
+
1370
+ cells: list[dict[str, Any]] = _rubric_2022_header_cells(ti, rub)
1371
+
1372
+ standards_cells, standards_sk = _rubric_2022_standards_cells(ti, rub, tb, grid, rep, std_label_row)
1373
+ cells.extend(standards_cells)
1374
+ skipped.extend(standards_sk)
1375
+
1376
+ # 성취기준별 성취수준 A~E descriptors: the blank ships the 서술 cells as a foreign
1377
+ # sample (통합사회/미술 프로젝트 prose). Splice the primary standard's review 성취수준
1378
+ # into each grade row's 서술 cell (fail-closed: reported, never corrupted).
1379
+ if std_levels:
1380
+ ae_cells, ae_sk = _fill_rubric_ae_levels(ti, tb, grid, rep, rub, std_levels)
1381
+ cells.extend(ae_cells)
1382
+ skipped.extend(ae_sk)
1383
+
1384
+ cells.extend(_rubric_2022_task_cells(ti, rub, grid, rep, task_row))
1385
+
1386
+ leader_base_cells, leader_base_sk = _rubric_2022_leader_and_base_cells(
1387
+ ti, rub, tb, grid, rep, ph, base, lastcol
1388
+ )
1389
+ cells.extend(leader_base_cells)
1390
+ skipped.extend(leader_base_sk)
1391
+
1392
+ fr = fill_cells(data, cells)
1393
+ skipped.extend(s.reason for s in fr.skipped)
1394
+ # honest-defer (no-silent-true): re-emit a NEEDS_REVIEW note ONLY when the 수행수준
1395
+ # 채점기준 descriptor ladder could NOT be reconciled byte-preservingly (STEP 0
1396
+ # returned ladder_filled=False -- e.g. group count ≠ MD item count, or a group's
1397
+ # clean-row shape resisted normalization). When the ladder WAS filled, the note is
1398
+ # omitted so the caller no longer reads this rubric as carrying sample prose.
1399
+ if not ladder_filled:
1400
+ skipped.append(f"rubric ti={ti}: NEEDS_REVIEW — 수행수준 채점기준 descriptor ladder "
1401
+ "left as blank sample (no byte-preserving map to review score ladder)")
1402
+ return fr.data, skipped
1403
+
1404
+
1405
+ def _fill_rubric_headings(data: bytes, content: EvalPlanContent) -> tuple[bytes, list[str]]:
1406
+ """Rewrite each 2022-개정 rubric's ordinal heading paragraph -- the '가. 인권 문제
1407
+ 해결 프로젝트' / '나. 인포그래픽 디자인' line that sits OUTSIDE the table, directly
1408
+ before its '평가 영역명' paragraph -- to '<ordinal>. <review 영역명>', byte-preserving
1409
+ (paragraph text splice only). One heading per rubric, matched in document order to
1410
+ ``content.rubrics``; a heading with no matching rubric is left untouched (honest --
1411
+ no fabricated title). Located by structure (an ordinal-marker paragraph immediately
1412
+ followed by '평가 영역명'), so it never touches an in-table cell."""
1413
+ from hwpx.patch import paragraph_chunks, paragraph_patch
1414
+ from hwpx.table_patch import section_parts, table_text
1415
+
1416
+ skipped: list[str] = []
1417
+ sections = section_parts(data)
1418
+ if not sections or not content.rubrics:
1419
+ return data, skipped
1420
+ sp = min(sections)
1421
+ section = sections[sp]
1422
+ texts = [table_text(chunk).strip() for chunk in paragraph_chunks(section)]
1423
+ heads = [i for i in range(len(texts) - 1)
1424
+ if re.match(r"^[가나다라마바사아자차카타파하]\.(\s|$)", texts[i]) and texts[i + 1] == "평가 영역명"]
1425
+ patches: list[dict[str, Any]] = []
1426
+ for k, pidx in enumerate(heads):
1427
+ if k >= len(content.rubrics):
1428
+ break
1429
+ patches.append({"section_path": sp, "paragraph_index": pidx,
1430
+ "text": f"{_ORDINALS[k]}. {content.rubrics[k].title}"})
1431
+ if len(heads) != len(content.rubrics):
1432
+ skipped.append(f"rubric headings: {len(heads)} ordinal headings vs {len(content.rubrics)} rubrics")
1433
+ if not patches:
1434
+ return data, skipped
1435
+ pres = paragraph_patch(data, patches)
1436
+ skipped.extend(s.reason for s in pres.skipped)
1437
+ return pres.data, skipped
1438
+
1439
+
1440
+ # --------------------------------------------------------------------------- #
1441
+ # Detailed §7 fill (current 평가계획 MD) -- per-평가요소 배점 ladders. Reshapes the
1442
+ # blank rubric's 평가요소 ladder to the MD's element count (grow / shrink the col0
1443
+ # vertical-merge blocks) and splices every observable criterion + 배점 verbatim.
1444
+ # Every target is located by geometry (label text / grid spans), reusable across the
1445
+ # form family, and every edit goes through the byte-preserving table_patch primitives.
1446
+ # --------------------------------------------------------------------------- #
1447
+
1448
+ def _row_label_value(grid: Any, rep: Any, tb: bytes, matches) -> tuple[int | None, Any, Any]:
1449
+ """(row, label_cell, value_cell) for the first cell whose text satisfies *matches*;
1450
+ value_cell is the grid cell immediately to its right (spanning-aware)."""
1451
+ from hwpx.table_patch import table_text
1452
+ for r in range(rep.row_count):
1453
+ for c in range(rep.col_count):
1454
+ cell = grid.get((r, c))
1455
+ if cell is None or cell.row != r or cell.col != c:
1456
+ continue
1457
+ if matches(table_text(tb[cell.start:cell.end])):
1458
+ return r, cell, grid.get((r, c + cell.col_span))
1459
+ return None, None, None
1460
+
1461
+
1462
+ def _fill_2022_header(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
1463
+ """Overwrite the header value cells (평가 영역명 / 영역 만점 / 학기 / 수행과제 / 학생
1464
+ 유의사항) — located by their label, so it works on both the clean template and the
1465
+ donor samples that survive structural pruning."""
1466
+ from hwpx.table_patch import fill_cells
1467
+ _sp, tb, grid, rep = _grid_of(data, ti)
1468
+
1469
+ def _n(t: str) -> str:
1470
+ return t.replace(" ", "").strip()
1471
+
1472
+ specs = [
1473
+ (lambda t: _n(t) == "평가영역명", rub.title, 3),
1474
+ (lambda t: _n(t) == "영역만점", f"{rub.points}점", 1),
1475
+ (lambda t: _n(t) == "학기", "2학기", 1),
1476
+ (lambda t: _n(t) == "수행과제", rub.task, 6),
1477
+ (lambda t: _n(t) == "학생유의사항", rub.student_notes, 6),
1478
+ ]
1479
+ cells: list[dict[str, Any]] = []
1480
+ for pred, val, ml in specs:
1481
+ if not val:
1482
+ continue
1483
+ _r, _lc, vc = _row_label_value(grid, rep, tb, pred)
1484
+ if vc is not None:
1485
+ cells.append({"table_index": ti, "row": vc.row, "col": vc.col, "text": val, "max_lines": ml})
1486
+ # 평가 방법: the donor sample's checkbox grid carries the WRONG subject's ☑ marks.
1487
+ # Overwrite the value cell with the MD's method (its ☑ list) and blank the second
1488
+ # checkbox row so no foreign check survives.
1489
+ if rub.method:
1490
+ _mr, _mlc, mvc = _row_label_value(
1491
+ grid, rep, tb, lambda t: _n(t) == "평가방법"
1492
+ )
1493
+ if mvc is not None:
1494
+ cells.append({"table_index": ti, "row": mvc.row, "col": mvc.col, "text": rub.method, "max_lines": 2})
1495
+ nxt = grid.get((mvc.row + 1, mvc.col))
1496
+ if nxt is not None and nxt.row == mvc.row + 1:
1497
+ cells.append({"table_index": ti, "row": nxt.row, "col": nxt.col, "text": "", "max_lines": 1})
1498
+ fr = fill_cells(data, cells)
1499
+ return fr.data, [s.reason for s in fr.skipped]
1500
+
1501
+
1502
+ def _delete_2022_ae_block(data: bytes, ti: int) -> tuple[bytes, list[str]]:
1503
+ """Delete the 성취기준 + 성취기준별 성취수준 (A~E) block. The current MD gives no
1504
+ per-area A~E descriptors (it refers to §4), so keeping the sample rows would show
1505
+ empty/foreign A~E — dropping them is the honest render. Bounds: the '성취기준' label
1506
+ row through the row before '평가 방법'."""
1507
+ from hwpx.table_patch import apply_table_ops, table_text
1508
+ _sp, tb, grid, rep = _grid_of(data, ti)
1509
+ ae_start = method_row = None
1510
+ for r in range(rep.row_count):
1511
+ c0 = grid.get((r, 0))
1512
+ if c0 is None or c0.row != r:
1513
+ continue
1514
+ t = table_text(tb[c0.start:c0.end]).replace(" ", "").strip()
1515
+ if ae_start is None and t == "성취기준":
1516
+ ae_start = r
1517
+ if t == "평가방법":
1518
+ method_row = r
1519
+ break
1520
+ if ae_start is None or method_row is None or method_row <= ae_start:
1521
+ return data, [f"rubric ti={ti}: A~E block bounds not found (성취기준={ae_start}, 평가방법={method_row})"]
1522
+ rows = list(range(ae_start, method_row))
1523
+ res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti, "rows": sorted(rows, reverse=True)}])
1524
+ if not res.ok:
1525
+ return data, [f"rubric ti={ti}: delete A~E block: {[s.reason for s in res.skipped]}"]
1526
+ return res.data, []
1527
+
1528
+
1529
+ def _grow_ladder_groups(data: bytes, ti: int, n: int) -> tuple[bytes, list[str]]:
1530
+ """Reshape the col0 평가요소 ladder to exactly *n* groups. Clones the shortest clean
1531
+ group block (``insert_block_by_clone`` — copies formatting byte-for-byte) to grow,
1532
+ deletes whole surplus blocks to shrink. Fail-closed."""
1533
+ from hwpx.table_patch import apply_table_ops
1534
+ _sp, tb, grid, rep = _grid_of(data, ti)
1535
+ ph, base = _pf_bounds(tb, grid, rep)
1536
+ if ph is None or base is None:
1537
+ return data, [f"rubric ti={ti}: no 평가요소/기본점수 bounds for ladder growth"]
1538
+ groups = _pf_groups(tb, ph, base)
1539
+ g = len(groups)
1540
+ if g == n:
1541
+ return data, []
1542
+ if g < n:
1543
+ r0, h = min(groups, key=lambda gh: gh[1]) # clone the shortest block
1544
+ res = apply_table_ops(data, [{"op": "insert_block_by_clone", "table_index": ti,
1545
+ "ref_rows": [r0, r0 + h - 1], "count": n - g}])
1546
+ if not res.ok:
1547
+ return data, [f"rubric ti={ti}: grow ladder to {n} groups: {[s.reason for s in res.skipped]}"]
1548
+ return res.data, []
1549
+ del_rows: list[int] = []
1550
+ for r0, h in groups[n:]:
1551
+ del_rows.extend(range(r0, r0 + h))
1552
+ res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti,
1553
+ "rows": sorted(set(del_rows), reverse=True)}])
1554
+ if not res.ok:
1555
+ return data, [f"rubric ti={ti}: shrink ladder to {n} groups: {[s.reason for s in res.skipped]}"]
1556
+ return res.data, []
1557
+
1558
+
1559
+ def _fill_2022_ladder_detailed(data: bytes, ti: int, items: list[RubricItem]) -> tuple[bytes, bool, list[str]]:
1560
+ """Normalize each 평가요소 group to its element's level count and splice the
1561
+ descriptor + 배점 of every level (verbatim from the MD's structured levels — never
1562
+ the lossy '/'-joined form). ``_grow_ladder_groups`` must have made the group count
1563
+ equal to ``len(items)`` first. Reuses the byte-preserving group reshape helpers."""
1564
+ from hwpx.table_patch import fill_cells
1565
+ _sp, tb, grid, rep = _grid_of(data, ti)
1566
+ ph, base = _pf_bounds(tb, grid, rep)
1567
+ if ph is None or base is None:
1568
+ return data, False, [f"rubric ti={ti}: no ladder bounds"]
1569
+ bat_col = rep.col_count - 1
1570
+ desc_col = _pf_desc_col(tb, ph, base, bat_col)
1571
+ has_sub = desc_col > 1
1572
+ groups = _pf_groups(tb, ph, base)
1573
+ if len(groups) != len(items):
1574
+ return data, False, [f"rubric ti={ti}: {len(groups)} 평가요소 groups != {len(items)} MD elements"]
1575
+
1576
+ d = data
1577
+ for gi in range(len(groups) - 1, -1, -1):
1578
+ m = max(1, len(items[gi].levels))
1579
+ _sp, tb, grid, rep = _grid_of(d, ti)
1580
+ ph2, base2 = _pf_bounds(tb, grid, rep)
1581
+ r0, h = _pf_groups(tb, cast(int, ph2), cast(int, base2))[gi]
1582
+ if has_sub:
1583
+ d, _fh, sub_sk = _reduce_to_first_subitem(d, ti, r0, h)
1584
+ if sub_sk:
1585
+ return data, False, [f"rubric ti={ti} group {gi}: {sub_sk[0]}"]
1586
+ _sp, tb, grid, rep = _grid_of(d, ti)
1587
+ ph2, base2 = _pf_bounds(tb, grid, rep)
1588
+ r0, h = _pf_groups(tb, cast(int, ph2), cast(int, base2))[gi]
1589
+ d, norm_sk = _normalize_ladder_group(d, ti, r0, h, m, desc_col, bat_col)
1590
+ if norm_sk:
1591
+ return data, False, [f"rubric ti={ti} group {gi}: {norm_sk[0]}"]
1592
+
1593
+ _sp, tb, grid, rep = _grid_of(d, ti)
1594
+ ph2, base2 = _pf_bounds(tb, grid, rep)
1595
+ gg = _pf_groups(tb, cast(int, ph2), cast(int, base2))
1596
+ cells: list[dict[str, Any]] = []
1597
+ for gi, (r0, _h) in enumerate(gg):
1598
+ lead = grid.get((r0, 0))
1599
+ if lead is not None:
1600
+ cells.append({"table_index": ti, "row": lead.row, "col": lead.col,
1601
+ "text": items[gi].name, "max_lines": 4})
1602
+ for k, (desc, score) in enumerate(items[gi].levels):
1603
+ r = r0 + k
1604
+ dc = grid.get((r, desc_col))
1605
+ if dc is not None:
1606
+ cells.append({"table_index": ti, "row": dc.row, "col": dc.col, "text": desc, "max_lines": 6})
1607
+ bc = grid.get((r, bat_col))
1608
+ if bc is not None and score:
1609
+ cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
1610
+ if has_sub:
1611
+ sc = grid.get((r0, 1))
1612
+ if sc is not None and sc.col == 1:
1613
+ cells.append({"table_index": ti, "row": sc.row, "col": 1, "text": "", "max_lines": 1})
1614
+ fr = fill_cells(d, cells)
1615
+ return fr.data, True, [s.reason for s in fr.skipped]
1616
+
1617
+
1618
+ def _fill_2022_base_scores(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
1619
+ """Fill the 기본점수 / 장기 미인정 결석자 배점 cells (right-most column). The row
1620
+ labels already carry the form's standard text, so only the 배점 is spliced. 장기
1621
+ 미인정 rows also contain '기본점수' (−1점), so match the more specific label first."""
1622
+ from hwpx.table_patch import fill_cells, table_text
1623
+ _sp, tb, grid, rep = _grid_of(data, ti)
1624
+ bat_col = rep.col_count - 1
1625
+ base_row = long_row = None
1626
+ for r in range(rep.row_count):
1627
+ c0 = grid.get((r, 0))
1628
+ if c0 is None or c0.row != r:
1629
+ continue
1630
+ t = table_text(tb[c0.start:c0.end])
1631
+ if "장기 미인정" in t or "장기미인정" in t:
1632
+ long_row = r
1633
+ elif "기본점수" in t:
1634
+ base_row = r
1635
+ cells: list[dict[str, Any]] = []
1636
+ for row, score in ((base_row, rub.base_score), (long_row, rub.long_score)):
1637
+ if row is None or not score:
1638
+ continue
1639
+ bc = grid.get((row, bat_col))
1640
+ if bc is not None:
1641
+ cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
1642
+ fr = fill_cells(data, cells)
1643
+ return fr.data, [s.reason for s in fr.skipped]
1644
+
1645
+
1646
+ def _fill_rubric_detailed_2022(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, bool, list[str]]:
1647
+ """Fill ONE 2022-개정 (평가 영역명) rubric with a detailed area's content: header
1648
+ values, drop the A~E block, grow the 평가요소 ladder to the element count, splice
1649
+ every level's criterion + 배점, and the 기본점수 배점. Returns (data, ladder_filled,
1650
+ notes)."""
1651
+ notes: list[str] = []
1652
+ data, n = _fill_2022_header(data, ti, rub)
1653
+ notes += n
1654
+ data, n = _delete_2022_ae_block(data, ti)
1655
+ notes += n
1656
+ items = rub.items
1657
+ data, n = _grow_ladder_groups(data, ti, len(items))
1658
+ notes += n
1659
+ data, filled, n = _fill_2022_ladder_detailed(data, ti, items)
1660
+ notes += n
1661
+ data, n = _fill_2022_base_scores(data, ti, rub)
1662
+ notes += n
1663
+ data = _prune_header_empty_bullets(data, ti)
1664
+ return data, filled, notes
1665
+
1666
+
1667
+ def _split_criteria(criteria: str) -> tuple[str, str, str]:
1668
+ """Split a 평가기준(상/중/하) bullet ('상=… / 중=… / 하=…') into its three
1669
+ descriptors (verbatim, '' for any absent level)."""
1670
+ out = {}
1671
+ for key in ("상", "중", "하"):
1672
+ m = re.search(rf"{key}\s*=\s*(.+?)(?=\s*[//]\s*[상중하]\s*=|$)", criteria or "")
1673
+ out[key] = _strip_inline_md(m.group(1)) if m else ""
1674
+ return out["상"], out["중"], out["하"]
1675
+
1676
+
1677
+ def _3hak_ladder_bounds(tb: bytes, grid: Any, rep: Any) -> tuple[int | None, int | None]:
1678
+ """(영역(만점) header row, 기본점수 row) of a 2015-개정 rubric ladder."""
1679
+ from hwpx.table_patch import table_text
1680
+ hr = br = None
1681
+ for r in range(rep.row_count):
1682
+ c0 = grid.get((r, 0))
1683
+ if c0 is not None and c0.row == r:
1684
+ t = table_text(tb[c0.start:c0.end]).replace(" ", "")
1685
+ if "영역" in t and "만점" in t:
1686
+ hr = r
1687
+ c1 = grid.get((r, 1))
1688
+ if c1 is not None and c1.row == r and br is None and "기본점수" in table_text(tb[c1.start:c1.end]):
1689
+ br = r
1690
+ return hr, br
1691
+
1692
+
1693
+ def _fill_3hak_header(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
1694
+ """Fill the 교육과정성취기준 codes, the 평가기준 상/중/하 descriptors, and 학생 유의
1695
+ 사항 — located by label geometry."""
1696
+ from hwpx.table_patch import fill_cells
1697
+
1698
+ def _n(t: str) -> str:
1699
+ return t.replace(" ", "").strip()
1700
+
1701
+ _sp, tb, grid, rep = _grid_of(data, ti)
1702
+ sang, jung, ha = _split_criteria(rub.criteria)
1703
+ specs = [
1704
+ (lambda t: _n(t) in ("교육과정성취기준", "성취기준"), rub.standards, 3),
1705
+ (lambda t: _n(t) == "상", sang, 4),
1706
+ (lambda t: _n(t) == "중", jung, 4),
1707
+ (lambda t: _n(t) == "하", ha, 4),
1708
+ (lambda t: _n(t) == "학생유의사항", rub.student_notes, 4),
1709
+ ]
1710
+ cells: list[dict[str, Any]] = []
1711
+ for pred, val, ml in specs:
1712
+ if not val:
1713
+ continue
1714
+ _r, _lc, vc = _row_label_value(grid, rep, tb, pred)
1715
+ if vc is not None:
1716
+ cells.append({"table_index": ti, "row": vc.row, "col": vc.col, "text": val, "max_lines": ml})
1717
+ fr = fill_cells(data, cells)
1718
+ return fr.data, [s.reason for s in fr.skipped]
1719
+
1720
+
1721
+ def _fill_3hak_base_scores(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
1722
+ from hwpx.table_patch import fill_cells, table_text
1723
+ _sp, tb, grid, rep = _grid_of(data, ti)
1724
+ bat_col = rep.col_count - 1
1725
+ base_row = long_row = None
1726
+ for r in range(rep.row_count):
1727
+ c1 = grid.get((r, 1))
1728
+ if c1 is None or c1.row != r:
1729
+ continue
1730
+ t = table_text(tb[c1.start:c1.end])
1731
+ if "장기 미인정" in t or "장기미인정" in t:
1732
+ long_row = r
1733
+ elif "기본점수" in t:
1734
+ base_row = r
1735
+ cells: list[dict[str, Any]] = []
1736
+ for row, score in ((base_row, rub.base_score), (long_row, rub.long_score)):
1737
+ if row is None or not score:
1738
+ continue
1739
+ bc = grid.get((row, bat_col))
1740
+ if bc is not None:
1741
+ cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
1742
+ fr = fill_cells(data, cells)
1743
+ return fr.data, [s.reason for s in fr.skipped]
1744
+
1745
+
1746
+ def _build_3hak_ladder(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, bool, list[str]]:
1747
+ """Reshape the 5-column 영역(만점)|평가항목|평가요소|채점 기준|배점 ladder to one physical
1748
+ row per MD level, then subdivide the single 평가요소 merge into per-element blocks and
1749
+ the 평가항목 merge into per-세부영역 blocks (``split_cell_vertical``, byte-preserving).
1750
+ Leaders land on each block's top row; 채점 기준 + 배점 land on every level row."""
1751
+ from hwpx.table_patch import apply_table_ops
1752
+
1753
+ subareas = [(sa.label, sa.points, [it for it in sa.items if not it.subtotal])
1754
+ for sa in rub.subareas]
1755
+ subareas = [(lbl, pts, its) for lbl, pts, its in subareas if its]
1756
+ if not subareas:
1757
+ return data, False, [f"rubric ti={ti}: 3학년 rubric has no scored elements"]
1758
+
1759
+ flat: list[tuple[str, str]] = []
1760
+ item_sizes: list[int] = []
1761
+ subarea_sizes: list[int] = []
1762
+ item_leaders: list[tuple[int, str]] = []
1763
+ subarea_leaders: list[tuple[int, str]] = []
1764
+ off = 0
1765
+ for lbl, pts, its in subareas:
1766
+ sa_start, sa_total = off, 0
1767
+ for it in its:
1768
+ levels = it.levels or [("", "")]
1769
+ item_leaders.append((off, it.name))
1770
+ item_sizes.append(len(levels))
1771
+ flat.extend(levels)
1772
+ off += len(levels)
1773
+ sa_total += len(levels)
1774
+ subarea_leaders.append((sa_start, f"{lbl} ({pts}점)" if lbl else ""))
1775
+ subarea_sizes.append(sa_total)
1776
+ n = off
1777
+
1778
+ _sp, tb, grid, rep = _grid_of(data, ti)
1779
+ hr, br = _3hak_ladder_bounds(tb, grid, rep)
1780
+ if hr is None or br is None:
1781
+ return data, False, [f"rubric ti={ti}: no 영역(만점)/기본점수 ladder bounds"]
1782
+ body0 = cast(int, hr) + 1
1783
+ cur = br - body0
1784
+ # Grow / shrink the body to n rows. Clone a COVERED body row (body0+1): its cells are
1785
+ # rowSpan==1 (채점 기준·배점) while col0/1/2 merges span across it and auto-extend.
1786
+ if n > cur:
1787
+ res = apply_table_ops(data, [{"op": "insert_row_by_clone", "table_index": ti,
1788
+ "ref_row": body0 + 1, "count": n - cur}])
1789
+ if not res.ok:
1790
+ return data, False, [f"rubric ti={ti}: grow ladder to {n} rows: {[s.reason for s in res.skipped]}"]
1791
+ data = res.data
1792
+ elif n < cur:
1793
+ res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti,
1794
+ "rows": list(range(body0 + n, br))}])
1795
+ if not res.ok:
1796
+ return data, False, [f"rubric ti={ti}: shrink ladder to {n} rows: {[s.reason for s in res.skipped]}"]
1797
+ data = res.data
1798
+
1799
+ # Subdivide 평가요소 (col2) per element and 평가항목 (col1) per 세부영역.
1800
+ for col, sizes in ((2, item_sizes), (1, subarea_sizes)):
1801
+ if len(sizes) > 1:
1802
+ res = apply_table_ops(data, [{"op": "split_cell_vertical", "table_index": ti,
1803
+ "row": body0, "col": col, "sizes": sizes}])
1804
+ if not res.ok:
1805
+ return data, False, [f"rubric ti={ti}: split col{col} {sizes}: {[s.reason for s in res.skipped]}"]
1806
+ data = res.data
1807
+
1808
+ from hwpx.table_patch import fill_cells
1809
+ _sp, tb, grid, rep = _grid_of(data, ti)
1810
+ hr, _br = _3hak_ladder_bounds(tb, grid, rep)
1811
+ body0 = cast(int, hr) + 1
1812
+ bat_col = rep.col_count - 1
1813
+ cells: list[dict[str, Any]] = []
1814
+ c0 = grid.get((body0, 0))
1815
+ if c0 is not None:
1816
+ cells.append({"table_index": ti, "row": c0.row, "col": 0,
1817
+ "text": f"{rub.title}({rub.points}점)", "max_lines": 4})
1818
+ for roff, lbl in subarea_leaders:
1819
+ c = grid.get((body0 + roff, 1))
1820
+ if c is not None and lbl:
1821
+ cells.append({"table_index": ti, "row": c.row, "col": 1, "text": lbl, "max_lines": 4})
1822
+ for roff, name in item_leaders:
1823
+ c = grid.get((body0 + roff, 2))
1824
+ if c is not None:
1825
+ cells.append({"table_index": ti, "row": c.row, "col": 2, "text": name, "max_lines": 4})
1826
+ for k, (desc, score) in enumerate(flat):
1827
+ c3 = grid.get((body0 + k, 3))
1828
+ if c3 is not None:
1829
+ cells.append({"table_index": ti, "row": c3.row, "col": c3.col, "text": desc, "max_lines": 6})
1830
+ c4 = grid.get((body0 + k, bat_col))
1831
+ if c4 is not None and score:
1832
+ cells.append({"table_index": ti, "row": c4.row, "col": c4.col, "text": score, "max_lines": 1})
1833
+ fr = fill_cells(data, cells)
1834
+ return fr.data, True, [s.reason for s in fr.skipped]
1835
+
1836
+
1837
+ def _fill_rubric_detailed_3hak(
1838
+ data: bytes, ti: int, rub: Rubric, content: EvalPlanContent
1839
+ ) -> tuple[bytes, bool, list[str]]:
1840
+ """Fill ONE 2015-개정 (교육과정성취기준) rubric with a detailed area's content: header
1841
+ (성취기준 / 평가기준 상·중·하 / 학생 유의사항), the nested 배점 ladder, and the 기본점수
1842
+ 배점. Returns (data, ladder_filled, notes)."""
1843
+ notes: list[str] = []
1844
+ data, n = _fill_3hak_header(data, ti, rub)
1845
+ notes += n
1846
+ data, ok, n = _build_3hak_ladder(data, ti, rub)
1847
+ notes += n
1848
+ data, n = _fill_3hak_base_scores(data, ti, rub)
1849
+ notes += n
1850
+ data = _prune_header_empty_bullets(data, ti)
1851
+ return data, ok, notes
1852
+
1853
+
1854
+ def _empty_trailing_para_spans(cell_xml: bytes) -> list[tuple[int, int]]:
1855
+ """Byte spans of every empty ``<hp:p>`` after the first in a cell — the donor
1856
+ template's leftover list-bullet paragraphs, which render as stray/red bullets once
1857
+ the MD text is spliced into the first paragraph. The first paragraph is always
1858
+ kept (a cell needs one)."""
1859
+ s = cell_xml.decode("utf-8")
1860
+ spans: list[tuple[int, int]] = []
1861
+ for i, m in enumerate(re.finditer(r"<hp:p\b.*?</hp:p>", s, re.DOTALL)):
1862
+ if i == 0:
1863
+ continue
1864
+ txt = "".join(
1865
+ re.findall(
1866
+ r"<hp:t\b[^>]*>(.*?)</hp:t>",
1867
+ m.group(0),
1868
+ re.DOTALL,
1869
+ )
1870
+ )
1871
+ if not txt.strip():
1872
+ spans.append((len(s[:m.start()].encode("utf-8")), len(s[:m.end()].encode("utf-8"))))
1873
+ return spans
1874
+
1875
+
1876
+ def _prune_header_empty_bullets(data: bytes, ti: int) -> bytes:
1877
+ """Drop the empty trailing bullet paragraphs left in a rubric's 수행과제 / 학생 유의
1878
+ 사항 value cells (byte-preserving: removes whole empty ``<hp:p>`` elements, rewrites
1879
+ only the affected section part)."""
1880
+ from hwpx.patch import rewrite_package_parts
1881
+ from hwpx.table_patch import (
1882
+ build_grid,
1883
+ iter_table_spans,
1884
+ section_parts,
1885
+ table_text,
1886
+ )
1887
+
1888
+ idx = ti
1889
+ for sp, section in sorted(section_parts(data).items()):
1890
+ spans = iter_table_spans(section)
1891
+ if idx >= len(spans):
1892
+ idx -= len(spans)
1893
+ continue
1894
+ ts, te = spans[idx]
1895
+ tb = section[ts:te]
1896
+ grid, rep = build_grid(tb)
1897
+ edits: list[tuple[int, int]] = []
1898
+ for r in range(rep.row_count):
1899
+ for cprime in range(rep.col_count):
1900
+ cell = grid.get((r, cprime))
1901
+ if cell is None or (cell.row, cell.col) != (r, cprime):
1902
+ continue
1903
+ if table_text(tb[cell.start:cell.end]).replace(" ", "").strip() not in ("수행과제", "학생유의사항"):
1904
+ continue
1905
+ vc = grid.get((r, cell.col + cell.col_span))
1906
+ if vc is None:
1907
+ continue
1908
+ for a, b in _empty_trailing_para_spans(tb[vc.start:vc.end]):
1909
+ edits.append((ts + vc.start + a, ts + vc.start + b))
1910
+ if not edits:
1911
+ return data
1912
+ new_section = section
1913
+ for a, b in sorted(edits, reverse=True):
1914
+ new_section = new_section[:a] + new_section[b:]
1915
+ return rewrite_package_parts(data, {sp: new_section})
1916
+ return data
1917
+
1918
+
1919
+ def _fill_rubrics_detailed(
1920
+ data: bytes, content: EvalPlanContent, report: dict[str, Any]
1921
+ ) -> tuple[bytes, dict[str, Any]]:
1922
+ """Fill every §7 rubric from a detailed area. Routes by blank shape: 평가 영역명
1923
+ tables (2학년) → :func:`_fill_rubric_detailed_2022`; 교육과정성취기준 tables (3학년) →
1924
+ :func:`_fill_rubric_detailed_3hak`."""
1925
+ filled = 0
1926
+ for i, rub in enumerate(content.rubrics):
1927
+ idxs = _rubric_indices(data)
1928
+ if i >= len(idxs):
1929
+ report["skipped"].append(f"rubric {i}: no matching blank table")
1930
+ continue
1931
+ ti = idxs[i]
1932
+ if _rubric_is_2022(data, ti):
1933
+ data, ok, notes = _fill_rubric_detailed_2022(data, ti, rub)
1934
+ else:
1935
+ data, ok, notes = _fill_rubric_detailed_3hak(data, ti, rub, content)
1936
+ report["skipped"].extend(f"rubric {i}: {n}" for n in notes)
1937
+ if ok:
1938
+ filled += 1
1939
+ report["filled"] = filled
1940
+ return data, report
1941
+
1942
+
1943
+ def fill_rubrics(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
1944
+ """Fill each 수행평가 세부기준 rubric table from the matching review rubric
1945
+ (byte-preserving). Replaces the sample 성취기준 codes / 평가항목 labels.
1946
+
1947
+ Routes by 개정: 2015-개정 (3학년, '교육과정성취기준' rubric) shrinks the example item
1948
+ block to the review item count and splices 성취기준 / 상·중·하 / 항목 / 배점 rows;
1949
+ 2022-개정 (2학년, '평가 영역명' rubric) overwrites the cleanly-addressable label
1950
+ cells (title / points / 성취기준 / A~E / 수행과제 / 평가요소 leaders / 기본점수 배점),
1951
+ reshapes+fills the 수행수준 채점기준 ladder, and rewrites each rubric's ordinal
1952
+ heading paragraph (가./나./다. + sample project title) to the review 영역명."""
1953
+
1954
+ report: dict[str, Any] = {"rubrics": len(content.rubrics), "filled": 0, "skipped": []}
1955
+
1956
+ # Detailed route (current 평가계획 MD): each area carries a per-element 배점 ladder
1957
+ # that the summary routes below can't represent. Grows the blank's 평가요소 ladder to
1958
+ # the MD's element count and splices every observable criterion + 배점 verbatim.
1959
+ if content.rubrics and content.rubrics[0].detailed:
1960
+ return _fill_rubrics_detailed(data, content, report)
1961
+
1962
+ # 2022-개정 route: fill each rubric table's label cells + 채점기준 ladder + heading.
1963
+ idxs0 = _rubric_indices(data)
1964
+ if idxs0 and _rubric_is_2022(data, idxs0[0]):
1965
+ return _fill_rubrics_2022_route(data, content, report)
1966
+
1967
+ # STEP 1 (structure): shrink each rubric's item block to its review item count.
1968
+ data = _shrink_rubric_item_blocks(data, content, report)
1969
+ # STEP 2 (content): fill each rubric's cells.
1970
+ data = _fill_rubric_cells_legacy(data, content, report)
1971
+ return data, report
1972
+
1973
+
1974
+ def _fill_rubrics_2022_route(
1975
+ data: bytes, content: EvalPlanContent, report: dict[str, Any]
1976
+ ) -> tuple[bytes, dict[str, Any]]:
1977
+ std_levels = _std_level_map(content.achievement_std)
1978
+ filled = 0
1979
+ for i, rub in enumerate(content.rubrics):
1980
+ idxs = _rubric_indices(data)
1981
+ if i >= len(idxs):
1982
+ report["skipped"].append(f"rubric {i}: no matching blank table")
1983
+ continue
1984
+ data, sk = _fill_rubric_2022(data, idxs[i], rub, std_levels)
1985
+ report["skipped"].extend(sk)
1986
+ filled += 1
1987
+ # rewrite each rubric's ordinal heading paragraph (outside the table) that holds
1988
+ # the blank's sample project title (가. 인권 문제 해결 프로젝트 …).
1989
+ data, head_sk = _fill_rubric_headings(data, content)
1990
+ report["skipped"].extend(head_sk)
1991
+ report["filled"] = filled
1992
+ return data, report
1993
+
1994
+
1995
+ def _shrink_rubric_item_blocks(data: bytes, content: EvalPlanContent, report: dict[str, Any]) -> bytes:
1996
+ """STEP 1: shrink each rubric's item block to its review item count."""
1997
+
1998
+ from hwpx.table_patch import apply_table_ops
1999
+
2000
+ for i, rub in enumerate(content.rubrics):
2001
+ items = [row for row in rub.rows if not row[0].startswith("기본점수")]
2002
+ n = max(1, len(items))
2003
+ idxs = _rubric_indices(data)
2004
+ if i >= len(idxs):
2005
+ report["skipped"].append(f"rubric {i}: no matching blank table")
2006
+ continue
2007
+ ti = idxs[i]
2008
+ hdr, base, _nrows = _rubric_bounds(data, ti)
2009
+ if hdr is None or base is None:
2010
+ report["skipped"].append(f"rubric {i}: could not bound item block")
2011
+ continue
2012
+ first_item = hdr + 1
2013
+ todel = list(range(first_item + n, base)) # keep n, drop the surplus
2014
+ if todel:
2015
+ res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti, "rows": todel}])
2016
+ if not res.ok:
2017
+ report["skipped"].append(f"rubric {i} shrink: {[s.reason for s in res.skipped]}")
2018
+ continue
2019
+ data = res.data
2020
+ return data
2021
+
2022
+
2023
+ def _fill_rubric_cells_legacy(data: bytes, content: EvalPlanContent, report: dict[str, Any]) -> bytes:
2024
+ """STEP 2: fill each rubric's cells (2015-개정 route)."""
2025
+
2026
+ levels = content.levels
2027
+ for i, rub in enumerate(content.rubrics):
2028
+ idxs = _rubric_indices(data)
2029
+ if i >= len(idxs):
2030
+ continue
2031
+ ti = idxs[i]
2032
+ levels_for_area = levels[i] if i < len(levels) else None
2033
+ data, skipped, filled_count = _fill_rubric_legacy_cells(
2034
+ data, ti, rub, levels_for_area, content.achievement_std
2035
+ )
2036
+ report["filled"] += filled_count
2037
+ report["skipped"].extend(f"rubric {i}: {s.reason}" for s in skipped)
2038
+ return data
2039
+
2040
+
2041
+ def _fill_rubric_legacy_cells(
2042
+ data: bytes,
2043
+ ti: int,
2044
+ rub: Rubric,
2045
+ levels_for_area: Sequence[str] | None,
2046
+ achievement_std: Any,
2047
+ ) -> tuple[bytes, list[Any], int]:
2048
+ """Fill one legacy (2015-개정) rubric table's cells.
2049
+
2050
+ Returns (data, skip_reasons, filled_count).
2051
+ """
2052
+
2053
+ from hwpx.table_patch import fill_cells
2054
+
2055
+ hdr, base, _nrows = _rubric_bounds(data, ti)
2056
+ if hdr is None or base is None:
2057
+ return data, [], 0
2058
+ first_item = hdr + 1
2059
+ items = [row for row in rub.rows if not row[0].startswith("기본점수")]
2060
+ _sp, tb, grid, _rep = _grid_of(data, ti)
2061
+
2062
+ cells: list[dict[str, Any]] = []
2063
+ # r0 성취기준 (replaces 한국사 sample codes)
2064
+ std_cell = _rubric_std_cell(ti, rub, achievement_std)
2065
+ if std_cell is not None:
2066
+ cells.append(std_cell)
2067
+ # r1-3 상/중/하 평가기준 from the matching area's A/B/C descriptors
2068
+ cells.extend(_rubric_level_cells(ti, levels_for_area, grid))
2069
+ # 영역(만점) leader
2070
+ area_cell = _rubric_area_leader_cell(ti, rub, first_item, grid)
2071
+ if area_cell is not None:
2072
+ cells.append(area_cell)
2073
+ # 평가항목 labels -> the merged 평가항목 cell's paragraphs (one line per item)
2074
+ item_cell = _rubric_item_labels_cell(ti, first_item, grid, tb, items)
2075
+ if item_cell is not None:
2076
+ cells.append(item_cell)
2077
+ # per-item 채점기준 rows (col3) + top 배점 (col4)
2078
+ cells.extend(_rubric_item_criteria_cells(ti, first_item, base, grid, items))
2079
+ # 기본점수 / 장기 미인정 rows
2080
+ cells.extend(_rubric_base_score_cells(ti, base, grid, rub.rows))
2081
+
2082
+ fr = fill_cells(data, cells)
2083
+ return fr.data, list(fr.skipped), len(fr.applied)
2084
+
2085
+
2086
+ def _rubric_std_cell(ti: int, rub: Rubric, achievement_std: Any) -> dict[str, Any] | None:
2087
+ std_text = rub.standards or (achievement_std and "")
2088
+ if std_text:
2089
+ return {"table_index": ti, "row": 0, "col": 1, "text": std_text, "max_lines": 3}
2090
+ return None
2091
+
2092
+
2093
+ def _rubric_level_cells(
2094
+ ti: int, levels_for_area: Sequence[str] | None, grid: Any
2095
+ ) -> list[dict[str, Any]]:
2096
+ cells: list[dict[str, Any]] = []
2097
+ if levels_for_area is not None and len(levels_for_area) >= 4:
2098
+ for k, desc in enumerate(levels_for_area[1:4]):
2099
+ rr = 1 + k
2100
+ if grid.get((rr, 2)):
2101
+ cells.append({"table_index": ti, "row": rr, "col": 2, "text": desc, "max_lines": 4})
2102
+ return cells
2103
+
2104
+
2105
+ def _rubric_area_leader_cell(ti: int, rub: Rubric, first_item: int, grid: Any) -> dict[str, Any] | None:
2106
+ if grid.get((first_item, 0)) is not None:
2107
+ return {"table_index": ti, "row": first_item, "col": 0,
2108
+ "text": f"{rub.title}({rub.points}점)", "max_lines": 3}
2109
+ return None
2110
+
2111
+
2112
+ def _rubric_item_labels_cell(
2113
+ ti: int, first_item: int, grid: Any, tb: Any, items: list[list[str]]
2114
+ ) -> dict[str, Any] | None:
2115
+ from hwpx.table_patch import cell_paragraph_spans
2116
+
2117
+ item_cell = grid.get((first_item, 1))
2118
+ if item_cell is None:
2119
+ return None
2120
+ n_para = len(cell_paragraph_spans(tb[item_cell.start:item_cell.end]))
2121
+ labels = [it[0] for it in items]
2122
+ if n_para < 1:
2123
+ return None
2124
+ # pack labels across available paragraphs; extras merged into the last
2125
+ if len(labels) <= n_para:
2126
+ packed = labels
2127
+ else:
2128
+ packed = labels[:n_para - 1] + [" · ".join(labels[n_para - 1:])]
2129
+ return {"table_index": ti, "row": first_item, "col": 1, "text": "\n".join(packed), "max_lines": 2}
2130
+
2131
+
2132
+ def _rubric_item_criteria_cells(
2133
+ ti: int, first_item: int, base: int, grid: Any, items: list[list[str]]
2134
+ ) -> list[dict[str, Any]]:
2135
+ cells: list[dict[str, Any]] = []
2136
+ for k, it in enumerate(items):
2137
+ rr = first_item + k
2138
+ if rr >= base:
2139
+ break
2140
+ if grid.get((rr, 3)):
2141
+ cells.append({"table_index": ti, "row": rr, "col": 3,
2142
+ "text": _batjeom_line(it[0], it[1]), "max_lines": 3})
2143
+ top = _top_batjeom(it[1])
2144
+ if top and grid.get((rr, 4)):
2145
+ cells.append({"table_index": ti, "row": rr, "col": 4, "text": top, "max_lines": 1})
2146
+ return cells
2147
+
2148
+
2149
+ def _rubric_base_score_cells(
2150
+ ti: int, base: int, grid: Any, rub_rows: list[list[str]]
2151
+ ) -> list[dict[str, Any]]:
2152
+ base_label, base_score, long_label, long_score = _base_scores(rub_rows)
2153
+ cells: list[dict[str, Any]] = []
2154
+ if grid.get((base, 1)):
2155
+ cells.append({"table_index": ti, "row": base, "col": 1, "text": base_label, "max_lines": 2})
2156
+ if base_score and grid.get((base, 4)):
2157
+ cells.append({"table_index": ti, "row": base, "col": 4, "text": base_score, "max_lines": 1})
2158
+ if grid.get((base + 1, 1)):
2159
+ cells.append({"table_index": ti, "row": base + 1, "col": 1, "text": long_label, "max_lines": 2})
2160
+ if long_score and grid.get((base + 1, 4)):
2161
+ cells.append({"table_index": ti, "row": base + 1, "col": 4, "text": long_score, "max_lines": 1})
2162
+ return cells
2163
+
2164
+
2165
+ def _rubric_bounds(data: bytes, ti: int) -> tuple[int | None, int | None, int]:
2166
+ """(header_row, 기본점수_row, row_count) of a rubric table -- the item block is
2167
+ the physical rows between them."""
2168
+ from hwpx.table_patch import table_text
2169
+ _sp, tb, grid, rep = _grid_of(data, ti)
2170
+ hdr = base = None
2171
+ for r in range(rep.row_count):
2172
+ c0 = grid.get((r, 0))
2173
+ if c0 is not None:
2174
+ t0 = table_text(tb[c0.start:c0.end])
2175
+ if "영역" in t0 and "만점" in t0:
2176
+ hdr = r
2177
+ c1 = grid.get((r, 1))
2178
+ if c1 is not None and base is None and "기본점수" in table_text(tb[c1.start:c1.end]):
2179
+ base = r
2180
+ return hdr, base, rep.row_count
2181
+
2182
+
2183
+ def _prose_items(raw: str, header_words: Sequence[str] = ()) -> list[str]:
2184
+ """Split a ``_norm``'d prose section ('가. ... 나. ... 다. ...') into 가/나/다
2185
+ items, dropping any leading section-title words the parser swept in."""
2186
+ s = raw.strip()
2187
+ for hw in header_words:
2188
+ if s.startswith(hw):
2189
+ s = s[len(hw):].strip()
2190
+ # split before each Korean-letter ordinal marker '가.'..'하.'
2191
+ parts = re.split(r"(?<!\S)(?=[가-힣]\.\s)", s)
2192
+ items = [re.sub(r"^[가-힣]\.\s*", "", p).strip() for p in parts if p.strip()]
2193
+ # drop residual '- ' bullet / title fragments with no ordinal
2194
+ return [it for it in items if it and not it.startswith("평가")]
2195
+
2196
+
2197
+ _ORDINALS = "가나다라마바사아자차카타파하"
2198
+
2199
+
2200
+ def fill_sections(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
2201
+ """Fill the 가./나./다. prose slots of §1 목적 / §2 기본방향 / §3 방침 with the
2202
+ review MD's items (byte-preserving paragraph splices).
2203
+
2204
+ A slot = a paragraph starting with an ordinal marker ('가.'…), whether empty
2205
+ (§1/§2 ship empty placeholders) or already holding the blank's generic sample
2206
+ prose (§3). Each slot keeps its ordinal ('가. <item>') so the numbering
2207
+ survives, and NO item is dropped: when the review has more items than slots,
2208
+ the surplus is appended -- with its own ordinals -- to the last slot (the
2209
+ blank's fixed placeholder count can't grow, and paragraph_patch only
2210
+ *replaces*). §11 결과분석 is deferred (its 가./(1)(2)(3)/나. sub-structure would
2211
+ be flattened) and keeps the blank's generic content -- reported, not faked.
2212
+ Sections whose slots can't be located are reported."""
2213
+ from hwpx.patch import paragraph_chunks, paragraph_patch
2214
+ from hwpx.table_patch import section_parts, table_text
2215
+
2216
+ report: dict[str, Any] = {"filled": 0, "skipped": [], "deferred": ["analysis(§11): rich sub-structure kept generic"]}
2217
+ sections = section_parts(data)
2218
+ if not sections:
2219
+ return data, report
2220
+ sp = min(sections)
2221
+ section = sections[sp]
2222
+ texts = [table_text(chunk).strip() for chunk in paragraph_chunks(section)]
2223
+
2224
+ def slots_after(header_kw: str, next_kw: str) -> list[int]:
2225
+ try:
2226
+ hi = next(i for i, t in enumerate(texts) if t == header_kw)
2227
+ except StopIteration:
2228
+ return []
2229
+ end = len(texts)
2230
+ for j in range(hi + 1, len(texts)):
2231
+ if texts[j] == next_kw:
2232
+ end = j
2233
+ break
2234
+ # a slot = a paragraph whose text is an ordinal marker (empty or filled)
2235
+ return [j for j in range(hi + 1, end)
2236
+ if re.match(r"^[가나다라마바사아자차카타파하]\.(\s|$)", texts[j])]
2237
+
2238
+ plan = [
2239
+ ("purposes", "평가의 목적", "평가의 기본 방향", ("평가의 목적",)),
2240
+ ("directions", "평가의 기본 방향", "평가 방침", ("평가의 기본 방향",)),
2241
+ ("policies", "평가 방침", "성취기준 및 성취수준", ("평가 방침",)),
2242
+ ]
2243
+ patches: list[dict[str, Any]] = []
2244
+ for attr, header, nxt, titlewords in plan:
2245
+ items = _prose_items(getattr(content, attr, ""), titlewords)
2246
+ slots = slots_after(header, nxt)
2247
+ if not slots or not items:
2248
+ if items:
2249
+ report["skipped"].append(f"{attr}: no ordinal placeholder located")
2250
+ continue
2251
+ # number every item, then pack into the fixed slots without dropping any
2252
+ numbered = [f"{_ORDINALS[i]}. {it}" for i, it in enumerate(items)]
2253
+ k = len(slots)
2254
+ packed = numbered if len(numbered) <= k else numbered[:k - 1] + [" ".join(numbered[k - 1:])]
2255
+ for slot_idx, text in zip(slots, packed):
2256
+ patches.append({"section_path": sp, "paragraph_index": slot_idx, "text": text})
2257
+
2258
+ if not patches:
2259
+ report["skipped"].append("no fillable prose placeholders located")
2260
+ return data, report
2261
+ pres = paragraph_patch(data, patches)
2262
+ report["filled"] = len(pres.applied)
2263
+ report["skipped"].extend(s.reason for s in pres.skipped)
2264
+ return pres.data, report
2265
+
2266
+
2267
+ def _flatten_schedule_merges(data: bytes, ti: int) -> tuple[bytes, list[str]]:
2268
+ """Split every vertical merge in the schedule's *data* rows into unit rows.
2269
+
2270
+ The donor ships a subject sample whose 단원명/성취기준 may span two weeks (a
2271
+ ``rowSpan==2`` cell). The review has distinct content per week, so a naive
2272
+ one-row-per-week fill writes the first week into the merged cell and can only
2273
+ *skip* the second (its cell is covered) -- the second week's 단원/성취기준 is
2274
+ absorbed. Splitting each vertical merge into unit cells (byte-preserving, the
2275
+ original formatting cloned onto the new cell) restores the 1:1 mapping so no
2276
+ row is absorbed. Header row (0) is left untouched. Content-agnostic."""
2277
+ from hwpx.table_patch import apply_table_ops, direct_table_cells
2278
+ notes: list[str] = []
2279
+ while True:
2280
+ _sp, tb, _grid, _rep = _grid_of(data, ti)
2281
+ merge = next(((c.row, c.col, c.row_span)
2282
+ for c in sorted(direct_table_cells(tb), key=lambda c: (c.row, c.col))
2283
+ if c.row >= 1 and c.row_span > 1), None)
2284
+ if merge is None:
2285
+ break
2286
+ r, col, rs = merge
2287
+ res = apply_table_ops(data, [{"op": "split_cell_vertical", "table_index": ti,
2288
+ "row": r, "col": col, "sizes": [1] * rs}])
2289
+ if not res.ok:
2290
+ notes.append(f"split donor merge r{r}c{col} (rowSpan {rs}) refused: "
2291
+ f"{[s.reason for s in res.skipped]}")
2292
+ break
2293
+ data = res.data
2294
+ notes.append(f"split donor merge r{r}c{col} (rowSpan {rs}) into {rs} unit rows")
2295
+ return data, notes
2296
+
2297
+
2298
+ def fill_schedule(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
2299
+ """Fill the Ⅰ 교수학습 운영 계획 schedule table from ``content.schedule`` (one
2300
+ logical row per review row, 6 columns), shrink-to-fit at 4 lines. Located by
2301
+ the widest multi-row data table under the Ⅰ heading. Any vertical merge a donor
2302
+ sample left in the data rows is split first so every review row maps to its own
2303
+ form row (no absorption)."""
2304
+ from hwpx.table_patch import fill_cells
2305
+
2306
+ report: dict[str, Any] = {"rows": len(content.schedule), "filled": 0, "skipped": [], "unmerged": []}
2307
+ if not content.schedule:
2308
+ return data, report
2309
+ ti = _schedule_index(data)
2310
+ if ti is None:
2311
+ report["skipped"].append("no schedule table found")
2312
+ return data, report
2313
+ data, report["unmerged"] = _flatten_schedule_merges(data, ti)
2314
+ cells = []
2315
+ for i, row in enumerate(content.schedule):
2316
+ r = i + 1 # row 0 is the header
2317
+ for col in range(min(6, len(row))):
2318
+ cells.append({"table_index": ti, "row": r, "col": col, "text": row[col], "max_lines": 4})
2319
+ fr = fill_cells(data, cells)
2320
+ report["filled"] = len(fr.applied)
2321
+ report["skipped"].extend(s.reason for s in fr.skipped)
2322
+ return fr.data, report
2323
+
2324
+
2325
+ def _schedule_index(data: bytes) -> int | None:
2326
+ """Table index of the Ⅰ schedule grid: the first multi-row table whose header
2327
+ row carries the 월/주/성취기준 signature."""
2328
+ from hwpx.table_patch import (
2329
+ build_grid,
2330
+ iter_table_spans,
2331
+ section_parts,
2332
+ table_text,
2333
+ )
2334
+ base = 0
2335
+ for sp, section in sorted(section_parts(data).items()):
2336
+ spans = iter_table_spans(section)
2337
+ for ti, (s, e) in enumerate(spans):
2338
+ tb = section[s:e]
2339
+ grid, rep = build_grid(tb)
2340
+ if rep.row_count < 3:
2341
+ continue
2342
+ hdr = " ".join(table_text(tb[grid[(0, c)].start:grid[(0, c)].end])
2343
+ for c in range(rep.col_count) if grid.get((0, c)))
2344
+ if "월" in hdr and "주" in hdr and "성취기준" in hdr:
2345
+ return base + ti
2346
+ base += len(spans)
2347
+ return None
2348
+
2349
+
2350
+ _PCT_RE = re.compile(r"(\d+)\s*%")
2351
+
2352
+
2353
+ def _percent_or_value(value: str) -> str:
2354
+ match = _PCT_RE.search(value)
2355
+ return match.group(0) if match else value
2356
+
2357
+
2358
+ def _ratio_columns(tb: bytes, grid, rep) -> tuple[int, list[int], int | None]:
2359
+ """(label_col, area_cols, total_col) of a 반영비율 grid.
2360
+
2361
+ ``label_col`` is the right-most column of the header's left-most (구분/평가 종류)
2362
+ cell -- 3학년 spans 1 column, 2학년 spans 2 -- so reading a row's label from col 0
2363
+ is safe but *writing* area values must start after this. ``total_col`` is the
2364
+ header column carrying '합계'. ``area_cols`` are the columns strictly between the
2365
+ label span and 합계 (the per-영역 data columns), 정기시험 already deleted."""
2366
+ from hwpx.table_patch import table_text
2367
+ c00 = grid.get((0, 0))
2368
+ label_cols = [cc for cc in range(rep.col_count) if grid.get((0, cc)) is c00]
2369
+ label_col = label_cols[-1] if label_cols else 0
2370
+ total_col = None
2371
+ for cc in range(rep.col_count):
2372
+ cell = grid.get((0, cc))
2373
+ if cell and "합계" in table_text(tb[cell.start:cell.end]):
2374
+ total_col = cc
2375
+ break
2376
+ area_cols = [cc for cc in range(label_col + 1, rep.col_count) if cc != total_col]
2377
+ return label_col, area_cols, total_col
2378
+
2379
+
2380
+ def _ratio_row_source(label: str, content: EvalPlanContent) -> tuple[str, list[str], str | None] | None:
2381
+ """Map a produced 반영비율 row *label* to its MD source: a ``(kind, area_values,
2382
+ total)`` tuple, or None to leave the row untouched.
2383
+
2384
+ The blank ships more rows than the MD's 5 data rows (a plain '반영 비율' %-only
2385
+ summary + a '시기/영역' area-name row on top of the MD's 영역 만점/논술형/시기/
2386
+ 성취기준/평가요소), so the mapping is by tolerant label keyword, not position:
2387
+
2388
+ * '영역 만점' → the MD 영역 만점(반영비율) cells ('35점(35%)' …)
2389
+ * plain '반영 비율' (NOT 영역 만점) → the bare percentages of 영역 만점 ('35%' …)
2390
+ * '시기/영역' (area-name row) → the MD ratio_header 영역 names
2391
+ * '논술형' → MD 논술형 평가 반영비율
2392
+ * '평가 시기' / '시기' (without 영역) → MD 평가 시기
2393
+ * '성취기준' → MD 성취기준 (this is where the blank's foreign sample codes live)
2394
+ * '평가요소' → MD 평가요소
2395
+ """
2396
+ lab = label.replace(" ", "")
2397
+ rows = {r[0].replace(" ", ""): r for r in content.ratio_rows}
2398
+
2399
+ def row(*keys: str) -> list[str] | None:
2400
+ for k, value in rows.items():
2401
+ if any(key in k for key in keys):
2402
+ return value
2403
+ return None
2404
+
2405
+ def data_and_total(r: list[str] | None) -> tuple[list[str], str | None] | None:
2406
+ if not r:
2407
+ return None
2408
+ vals = r[1:]
2409
+ total = vals[-1] if len(vals) > len(_area_names(content)) else None
2410
+ # area values are the leading cells; a trailing 합계 (last) is the total
2411
+ area = vals[:len(_area_names(content))]
2412
+ return area, total
2413
+
2414
+ areas = _area_names(content)
2415
+ if "영역만점" in lab:
2416
+ dt = data_and_total(row("영역만점", "만점"))
2417
+ return ("영역 만점", dt[0], dt[1]) if dt else None
2418
+ if lab.startswith("반영비율") or lab == "반영비율":
2419
+ dt = data_and_total(row("영역만점", "만점"))
2420
+ if not dt:
2421
+ return None
2422
+ pcts = [_percent_or_value(value) for value in dt[0]]
2423
+ return ("반영 비율", pcts, dt[1])
2424
+ if "시기/영역" in lab or lab == "시기영역":
2425
+ # the area-name row: fill from the MD ratio_header 영역 names
2426
+ return ("영역명", list(areas), None)
2427
+ if "논술형" in lab:
2428
+ dt = data_and_total(row("논술형"))
2429
+ return ("논술형", dt[0], dt[1]) if dt else None
2430
+ if "평가시기" in lab or (lab.startswith("시기") and "영역" not in lab):
2431
+ dt = data_and_total(row("평가시기", "시기"))
2432
+ return ("평가 시기", dt[0], dt[1]) if dt else None
2433
+ if "성취기준" in lab:
2434
+ dt = data_and_total(row("성취기준"))
2435
+ return ("성취기준", dt[0], dt[1]) if dt else None
2436
+ if "평가요소" in lab:
2437
+ dt = data_and_total(row("평가요소"))
2438
+ return ("평가요소", dt[0], dt[1]) if dt else None
2439
+ return None
2440
+
2441
+
2442
+ def _area_names(content: EvalPlanContent) -> list[str]:
2443
+ """The 영역 names from the MD §6 header ('① 문제해결에 …', …) -- every header cell
2444
+ that is not the leading 구분 label or the trailing 합계."""
2445
+ hdr = content.ratio_header
2446
+ if not hdr:
2447
+ return []
2448
+ inner = hdr[1:]
2449
+ if inner and "합계" in inner[-1]:
2450
+ inner = inner[:-1]
2451
+ return inner
2452
+
2453
+
2454
+ def fill_ratio(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
2455
+ """Fill the 반영비율 (평가의 종류와 반영비율) table's data cells from the review MD
2456
+ §6 (byte-preserving). The recipe deletes the 정기시험 column but never wrote the
2457
+ 수행평가 area data, so the produced table carries 100% sample content (50/50/50 %,
2458
+ 통합과학 subjects, 통과 성취기준 codes). This maps ``content.ratio_rows`` onto the
2459
+ produced rows by their left-most LABEL (tolerant keyword match, so 반영 비율 vs
2460
+ 영역 만점 both resolve) and fills the per-영역 columns + the 시기/영역 area names +
2461
+ the header 영역 names. Handles BOTH forms (3학년 5-col, 2학년 6-col with a 2-col
2462
+ label span). No-op / reported if the MD ships no §6 table."""
2463
+ from hwpx.table_patch import fill_cells, table_text
2464
+
2465
+ report: dict[str, Any] = {"rows_filled": 0, "skipped": []}
2466
+ if not content.ratio_rows and not content.ratio_header:
2467
+ report["skipped"].append("MD has no §6 ratio table")
2468
+ return data, report
2469
+ ti = _classify_index(data, "ratio")
2470
+ if ti is None:
2471
+ report["skipped"].append("no ratio table found")
2472
+ return data, report
2473
+ _sp, tb, grid, rep = _grid_of(data, ti)
2474
+ label_col, area_cols, total_col = _ratio_columns(tb, grid, rep)
2475
+ if not area_cols:
2476
+ report["skipped"].append(f"no area columns detected (label_col={label_col}, total_col={total_col})")
2477
+ return data, report
2478
+
2479
+ cells: list[dict[str, Any]] = []
2480
+ # header 평가 종류 row (r0): the area-name header cells still say '수행평가' -- leave
2481
+ # them (they are the 평가 종류, correctly 수행평가); the 영역 names go in the 시기/영역
2482
+ # row so they are not duplicated.
2483
+ for r in range(1, rep.row_count):
2484
+ c0 = grid.get((r, 0))
2485
+ label = table_text(tb[c0.start:c0.end]).strip() if c0 else ""
2486
+ src = _ratio_row_source(label, content)
2487
+ if src is None:
2488
+ continue
2489
+ kind, area_vals, total = src
2490
+ wrote = False
2491
+ # Dedup horizontally-merged area cells: the blank collapses some rows' 영역
2492
+ # columns into ONE spanned cell (e.g. the '반영 비율' summary is a single
2493
+ # cell over all 영역). Writing per-column would collide on the same physical
2494
+ # cell; instead we write ONE value per distinct cell. A merged summary cell
2495
+ # takes the row's 합계 (its meaning is the aggregate), a merged non-summary
2496
+ # cell takes the joined area values; distinct per-영역 cells take their own
2497
+ # value, and an MD '—' CLEARS the blank's leftover sample (writes empty).
2498
+ seen_cells: set[tuple[int, int]] = set()
2499
+ for k, cc in enumerate(area_cols):
2500
+ cell = grid.get((r, cc))
2501
+ if cell is None:
2502
+ continue
2503
+ key = (cell.start, cell.end)
2504
+ if key in seen_cells:
2505
+ continue
2506
+ seen_cells.add(key)
2507
+ spanned = [ac for ac in area_cols if grid.get((r, ac)) is cell]
2508
+ if len(spanned) > 1:
2509
+ # a merged area cell — one physical cell over several 영역 columns
2510
+ if kind == "반영 비율":
2511
+ val = (total or "").strip()
2512
+ else:
2513
+ parts = [area_vals[area_cols.index(ac)].strip()
2514
+ for ac in spanned if area_cols.index(ac) < len(area_vals)]
2515
+ parts = [p for p in parts if p and p != "—"]
2516
+ val = " / ".join(dict.fromkeys(parts))
2517
+ else:
2518
+ val = area_vals[k].strip() if k < len(area_vals) else ""
2519
+ if val == "—":
2520
+ val = "" # clear leftover sample where the MD has no value
2521
+ cells.append({"table_index": ti, "row": r, "col": cc, "text": val, "max_lines": 3})
2522
+ wrote = True
2523
+ # 합계 column: fill only when the MD supplies a concrete total (not '—')
2524
+ if total and total.strip() not in ("", "—") and total_col is not None and grid.get((r, total_col)):
2525
+ cells.append({"table_index": ti, "row": r, "col": total_col, "text": total.strip(), "max_lines": 1})
2526
+ if wrote:
2527
+ report["rows_filled"] += 1
2528
+
2529
+ if not cells:
2530
+ report["skipped"].append("no ratio rows matched the MD labels")
2531
+ return data, report
2532
+ fr = fill_cells(data, cells)
2533
+ report["filled_cells"] = len(fr.applied)
2534
+ report["skipped"].extend(s.reason for s in fr.skipped)
2535
+ return fr.data, report
2536
+
2537
+
2538
+ def finalize_evalplan(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
2539
+ """Deterministic post-fill cleanup: turn a *filled* donor 평가계획 form into a
2540
+ submittable 채움본 without a bespoke driver script.
2541
+
2542
+ Runs the recipe's mechanical steps -- fill the title / teacher / 정의적 cells,
2543
+ prune the donor's instruction scaffolding and orphaned sample headings (bounded
2544
+ by the form's OWN section headings, never by subject or grade), strip the red
2545
+ guidance runs, recolor filled blue slots to body black, and remove trailing
2546
+ table captions. Every decision is structure/pattern based; no subject or grade
2547
+ string appears. Genuine ambiguity (novel instruction phrasing, non-1:1 정의적
2548
+ cell mapping, tie-broken 변형 선택) is intentionally NOT resolved here -- that is
2549
+ the skill's (LLM) judgment.
2550
+
2551
+ ``data`` must already be a :func:`fill_evalplan` (``phase="all"``) output; the
2552
+ deletes are computed while the red guidance runs are still intact so a black
2553
+ ordinal sharing a paragraph with a red instruction is removed as a unit. Returns
2554
+ ``(clean_bytes, report)``."""
2555
+ import html
2556
+ import io
2557
+ import zipfile
2558
+
2559
+ from hwpx.body_patch import (
2560
+ apply_body_ops,
2561
+ direct_paragraph_spans,
2562
+ recolor_runs_by_color,
2563
+ strip_runs_by_color,
2564
+ )
2565
+ from hwpx.table_patch import fill_cells, strip_trailing_table_captions
2566
+
2567
+ from ..form_fill.classification import _tables
2568
+ from ..form_fill.guidance import is_form_instruction
2569
+
2570
+ T = re.compile(r"<hp:t(?:\s[^>]*)?>(.*?)</hp:t>", re.DOTALL)
2571
+ RUN = re.compile(r"<hp:run\b[^>]*?>.*?</hp:run>", re.DOTALL)
2572
+ ORDINAL = re.compile(r"^[가-하]\.?$|^\(\d+\)$|^\*+$|^[·・\s]*$")
2573
+ # legit 평가계획 notes that sit among instructions -- form-generic, never a
2574
+ # subject/grade token, so keeping them is not hardcoding a subject.
2575
+ KEEP = ("가급적 동점자",)
2576
+
2577
+ report: dict[str, Any] = {
2578
+ "deleted": 0, "skipped": [], "captions": 0,
2579
+ "title": False, "teacher": False, "affective_rows": 0,
2580
+ }
2581
+
2582
+ def _sec_hdr(d: bytes) -> tuple[str, str]:
2583
+ z = zipfile.ZipFile(io.BytesIO(d))
2584
+ sec = z.read("Contents/section0.xml").decode("utf-8")
2585
+ hdr_name = next(n for n in z.namelist() if n.endswith("header.xml"))
2586
+ return sec, z.read(hdr_name).decode("utf-8")
2587
+
2588
+ def _red_ids(hdr: str) -> set[str]:
2589
+ out: set[str] = set()
2590
+ for cm in re.finditer(
2591
+ r"<hh:charPr\b[^>]*\bid=\"(\d+)\"[^>]*textColor=\"([#0-9A-Fa-f]+)\"", hdr
2592
+ ):
2593
+ if cm.group(2).upper() == "#FF0000":
2594
+ out.add(cm.group(1))
2595
+ return out
2596
+
2597
+ def _run_texts(block: str, reds: set[str]) -> tuple[str, str]:
2598
+ black: list[str] = []
2599
+ red: list[str] = []
2600
+ for rm in RUN.finditer(block):
2601
+ run = rm.group(0)
2602
+ txt = html.unescape("".join(T.findall(run)))
2603
+ cid = re.search(r'charPrIDRef="(\d+)"', run)
2604
+ (red if (cid and cid.group(1) in reds) else black).append(txt)
2605
+ return "".join(black), "".join(red)
2606
+
2607
+ # ---- paragraph deletes computed on the FILLED bytes (red still intact) ------
2608
+ sec, hdr = _sec_hdr(data)
2609
+ reds = _red_ids(hdr)
2610
+ spans = direct_paragraph_spans(sec)
2611
+ blocks = [sec[a:b] for (a, b) in spans]
2612
+ texts = [html.unescape("".join(T.findall(bl))).strip() for bl in blocks]
2613
+ has_tbl = ["<hp:tbl" in bl for bl in blocks]
2614
+ del_idx: set[int] = set()
2615
+
2616
+ def first(pred, start: int = 0) -> int | None:
2617
+ return next((i for i in range(start, len(texts)) if pred(i)), None)
2618
+
2619
+ # (2e) 성취수준별 고정분할점수 labels -- red form scaffolding; delete all (the
2620
+ # engine already kept the single subject-matching 성취율 table by band count).
2621
+ del_idx.update(
2622
+ i for i in range(len(texts))
2623
+ if not has_tbl[i] and texts[i].startswith("성취수준별 고정분할점수")
2624
+ )
2625
+
2626
+ # (2a) 최소 성취수준 orphan heading block -- its table was pruned (공통과목 전용);
2627
+ # delete the non-table paragraphs from the heading down to the §5 table.
2628
+ ms = first(lambda i: not has_tbl[i] and texts[i].startswith("다. 최소 성취수준"))
2629
+ sec5 = first(lambda i: has_tbl[i] and "기준 성취율과 성취도" in texts[i])
2630
+ if ms is not None and sec5 is not None:
2631
+ del_idx.update(i for i in range(ms, sec5) if not has_tbl[i])
2632
+
2633
+ # (2f) foreign donor sample headings between the §7 and §8 tables.
2634
+ s7 = first(lambda i: has_tbl[i] and "수행평가 세부기준" in texts[i])
2635
+ s8 = first(lambda i: has_tbl[i] and "정의적 능력 평가" in texts[i],
2636
+ start=(s7 + 1) if s7 is not None else 0)
2637
+ if s7 is not None and s8 is not None:
2638
+ del_idx.update(i for i in range(s7 + 1, s8) if not has_tbl[i] and texts[i])
2639
+
2640
+ # (2b) foreign "(N) area" sub-headers between §4 and §5 (donor sample labels;
2641
+ # the MD's §4 uses 가./나. markers, never "(N) word", so this is safe).
2642
+ s4h = first(lambda i: has_tbl[i] and "성취기준 및 성취수준" in texts[i])
2643
+ s5h = first(lambda i: has_tbl[i] and "기준 성취율과 성취도" in texts[i],
2644
+ start=(s4h + 1) if s4h is not None else 0)
2645
+ if s4h is not None and s5h is not None:
2646
+ del_idx.update(
2647
+ i for i in range(s4h + 1, s5h)
2648
+ if not has_tbl[i] and re.match(r"^\(\d+\)\s*\S", texts[i])
2649
+ )
2650
+
2651
+ # (2c) red-guidance-with-trivial-black + black instruction paragraphs.
2652
+ for i, bl in enumerate(blocks):
2653
+ if has_tbl[i] or i in del_idx or any(k in texts[i] for k in KEEP):
2654
+ continue
2655
+ black_txt, red_txt = _run_texts(bl, reds)
2656
+ if (
2657
+ red_txt.strip() and ORDINAL.match(black_txt.strip())
2658
+ ) or is_form_instruction(texts[i]):
2659
+ del_idx.add(i)
2660
+
2661
+ # ---- title + §8 정의적 + teacher fills (paragraph count unchanged) -----------
2662
+ cells: list[dict[str, Any]] = []
2663
+ if content.title:
2664
+ cells.append({"table_index": 0, "row": 0, "col": 0, "text": content.title})
2665
+ report["title"] = True
2666
+ # §8 정의적 능력 평가 표: replace the donor sample with the MD's 측면 rows (1:1).
2667
+ aff = re.findall(
2668
+ r"(\S+적 측면\([^)]*\))\s*[::]\s*(.+?)(?=\s*-\s*\S+적 측면|$)", content.affective or ""
2669
+ )
2670
+ aff_ti = next((gi for gi, t in enumerate(_tables(data)) if "정의적 능력 평가 요소" in t.text), None)
2671
+ if aff_ti is not None and aff:
2672
+ for r, (label, desc) in enumerate(aff[:3], start=1):
2673
+ cells.append({"table_index": aff_ti, "row": r, "col": 0, "text": label.strip(), "max_lines": 3})
2674
+ cells.append({"table_index": aff_ti, "row": r, "col": 1, "text": desc.strip(), "max_lines": 4})
2675
+ report["affective_rows"] = min(len(aff), 3)
2676
+ if cells:
2677
+ data = fill_cells(data, cells).data
2678
+ if content.teacher:
2679
+ # fill the placeholder, then recolor the filled value to body-black so the
2680
+ # red-strip below does not remove it; robust to the label+placeholder being
2681
+ # in one run (2학년) or split black label + red placeholder (3학년).
2682
+ data = apply_body_ops(data, [
2683
+ {"op": "replace_text", "find": "◯◯◯, ◯◯◯", "replace": content.teacher, "count": 1},
2684
+ {"op": "restyle_text", "find": content.teacher, "textColor": "#000000", "count": 1},
2685
+ {"op": "replace_text", "find": "(**)", "replace": "", "count": 4},
2686
+ ]).data
2687
+ report["teacher"] = True
2688
+
2689
+ # ---- apply deletes (high->low so indices stay valid) ------------------------
2690
+ dr = apply_body_ops(data, [{"op": "delete_paragraph", "index": i}
2691
+ for i in sorted(del_idx, reverse=True)])
2692
+ data = dr.data
2693
+ report["deleted"] = len(del_idx) - len(dr.skipped)
2694
+ report["skipped"] = [str(s) for s in dr.skipped]
2695
+
2696
+ # ---- strip residual red, recolor filled blue->black, strip captions --------
2697
+ data = strip_runs_by_color(data, ["#FF0000"]).data
2698
+ data = recolor_runs_by_color(data, ["#0000FF"], "#000000").data
2699
+ cap = strip_trailing_table_captions(data)
2700
+ data = cap.data
2701
+ report["captions"] = len(cap.applied)
2702
+ return data, report
2703
+
2704
+
2705
+ def fill_evalplan(
2706
+ blank: str | Path,
2707
+ content: EvalPlanContent,
2708
+ *,
2709
+ output: str | None = None,
2710
+ phase: str = "structural",
2711
+ ) -> dict[str, Any]:
2712
+ """Apply the recipe to a blank form.
2713
+
2714
+ ``phase="structural"`` runs the confident deletions only (red/optional tables,
2715
+ 정기시험 column, surplus example tables). ``phase="all"`` additionally runs the
2716
+ content fills -- schedule, achievement, levels, rubrics, section prose -- each
2717
+ byte-preserving and located by classification/geometry (index-safe against the
2718
+ prior structural deletes). ``phase="clean"`` runs ``"all"`` and then
2719
+ :func:`finalize_evalplan` -- the deterministic post-fill cleanup (title/teacher/
2720
+ 정의적 fills, scaffolding + orphan prune, red-strip, blue->black recolor, caption
2721
+ strip) that yields a submittable 채움본 with no bespoke driver. Returns the apply
2722
+ result dict plus the op-plan transcript and, for ``phase`` in ``{"all","clean"}``,
2723
+ a ``content_report`` per region (plus ``content_report["finalize"]`` for clean)."""
2724
+ from hwpx.table_patch import apply_table_ops
2725
+
2726
+ plan = plan_structural_ops(blank, content)
2727
+ result = apply_table_ops(blank, plan["ops"])
2728
+ data = result.data
2729
+ payload = result.to_dict()
2730
+
2731
+ content_report: dict[str, Any] = {}
2732
+ if phase in ("all", "clean"):
2733
+ data, content_report["schedule"] = fill_schedule(data, content)
2734
+ data, content_report["achievement"] = fill_achievement(data, content)
2735
+ data, content_report["levels"] = fill_levels(data, content)
2736
+ data, content_report["rubrics"] = fill_rubrics(data, content)
2737
+ data, content_report["ratio"] = fill_ratio(data, content)
2738
+ data, content_report["sections"] = fill_sections(data, content)
2739
+ if phase == "clean":
2740
+ data, content_report["finalize"] = finalize_evalplan(data, content)
2741
+
2742
+ if output is not None:
2743
+ from pathlib import Path as _P
2744
+ _P(output).write_bytes(data)
2745
+ payload["outputPath"] = output
2746
+ payload["transcript"] = plan["transcript"]
2747
+ payload["expected_skeleton"] = plan["expected_skeleton"]
2748
+ payload["content_report"] = content_report
2749
+ payload["_data"] = data
2750
+ return payload
2751
+
2752
+
2753
+ def parse_review_file(path: str | Path) -> EvalPlanContent:
2754
+ return parse_review_md(Path(path).read_text(encoding="utf-8"))
2755
+
2756
+
2757
+ __all__ = [ # noqa: RUF022 - frozen 4.x public ordering
2758
+ "EvalPlanContent", "Rubric", "parse_review_md", "parse_review_file",
2759
+ "expected_skeleton", "plan_structural_ops", "fill_evalplan", "finalize_evalplan",
2760
+ "fill_schedule", "fill_achievement", "fill_levels", "fill_rubrics", "fill_ratio",
2761
+ "fill_sections",
2762
+ ]