python-hwpx 4.0.0__py3-none-any.whl → 4.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hwpx/evalplan_fill.py CHANGED
@@ -54,16 +54,64 @@ def _md_table_header(block: str) -> list[str]:
54
54
  return []
55
55
 
56
56
 
57
+ @dataclass
58
+ class RubricItem:
59
+ """One 평가요소 (evaluation element) with its observable-criteria 배점 ladder —
60
+ ``levels`` is ``[(descriptor, 배점), …]`` top score first, verbatim from the MD.
61
+ ``subtotal`` marks a 소계 row (the sub-area 만점, not a scored element)."""
62
+ name: str
63
+ levels: list[tuple[str, str]] = field(default_factory=list)
64
+ subtotal: bool = False
65
+
66
+ def to_dict(self) -> dict[str, Any]:
67
+ return {"name": self.name, "levels": [list(l) for l in self.levels],
68
+ "subtotal": self.subtotal}
69
+
70
+
71
+ @dataclass
72
+ class RubricSubArea:
73
+ """A 세부 영역 (가./나. …) or, for a single-table area, one anonymous sub-area
74
+ (``label==""``). ``points`` is the 소계 (0 if the MD gives none)."""
75
+ label: str = ""
76
+ points: int = 0
77
+ items: list[RubricItem] = field(default_factory=list)
78
+
79
+ def to_dict(self) -> dict[str, Any]:
80
+ return {"label": self.label, "points": self.points,
81
+ "items": [it.to_dict() for it in self.items]}
82
+
83
+
57
84
  @dataclass
58
85
  class Rubric:
59
86
  title: str # "문제해결에 탐색 활용하기"
60
87
  points: int # 35
61
88
  standards: str # "[12인기02-04][12인기02-05]"
62
89
  rows: list[list[str]] = field(default_factory=list) # [평가항목, 채점 기준]
90
+ # --- detailed 배점 rubric (current 평가계획 MD format), empty for the legacy
91
+ # synthetic format; :attr:`detailed` gates the detailed fill route.
92
+ subareas: list[RubricSubArea] = field(default_factory=list)
93
+ base_score: str = "" # 기본점수 (백지·미참여)
94
+ long_score: str = "" # 장기 미인정 결석자 (기본점수 −1)
95
+ task: str = "" # 수행과제
96
+ method: str = "" # 평가 방법
97
+ student_notes: str = "" # 학생 유의사항
98
+ criteria: str = "" # 평가기준(상/중/하) — 2015-개정 (3학년) only
99
+
100
+ @property
101
+ def detailed(self) -> bool:
102
+ """True when parsed from the current 평가계획 MD (per-element 배점 ladders)."""
103
+ return bool(self.subareas)
104
+
105
+ @property
106
+ def items(self) -> list[RubricItem]:
107
+ """All 평가요소 across sub-areas, in document order (flattened view)."""
108
+ return [it for sa in self.subareas for it in sa.items]
63
109
 
64
110
  def to_dict(self) -> dict[str, Any]:
65
111
  return {"title": self.title, "points": self.points,
66
- "standards": self.standards, "rows": self.rows}
112
+ "standards": self.standards, "rows": self.rows,
113
+ "subareas": [sa.to_dict() for sa in self.subareas],
114
+ "base_score": self.base_score, "long_score": self.long_score}
67
115
 
68
116
 
69
117
  @dataclass
@@ -107,6 +155,59 @@ def _section(md: str, start_pat: str, end_pats: list[str]) -> str:
107
155
  return rest[:cut]
108
156
 
109
157
 
158
+ # a per-standard header: ``**[code]** 진술`` at the start of a line
159
+ _ACH_STD_HEADER = re.compile(r"^\s*\*\*\s*(\[[^\]\n]+\])\s*\*\*\s*(.*?)\s*$")
160
+
161
+
162
+ def _parse_achievement_std(block: str) -> list[list[str]]:
163
+ """Parse §4가 into one row per 성취기준: ``[standard, L1_desc, L2_desc, ...]``.
164
+
165
+ Two markdown authoring shapes are accepted; the level count is read from the
166
+ data, never hard-coded:
167
+
168
+ * **unified** -- a single table ``성취기준 | 상 | 중 | 하`` (or ``| A |…| E |``) with
169
+ one row per standard, level descriptors across the columns. Parsed by
170
+ :func:`_md_table_rows`.
171
+ * **per-standard** -- a sequence of ``**[code]** 진술`` headers, each followed by
172
+ its own ``수준 | 성취수준`` table listing the levels as *rows*. Each is normalised
173
+ to ``[code+진술, <descriptor per level, in table order>]``, so a standard with an
174
+ A~E table becomes a 6-field row (bh = 5) matching the unified shape that
175
+ :func:`fill_achievement` and :func:`_std_level_map` already consume.
176
+
177
+ The per-standard shape is tried first; when no ``**[code]**`` header carries a
178
+ table, the unified single-table parse is returned instead."""
179
+ lines = block.splitlines()
180
+ n = len(lines)
181
+ stds: list[list[str]] = []
182
+ i = 0
183
+ while i < n:
184
+ m = _ACH_STD_HEADER.match(lines[i])
185
+ if not m:
186
+ i += 1
187
+ continue
188
+ standard = f"{m.group(1)} {m.group(2)}".strip()
189
+ # advance to this standard's table (blank/prose lines may intervene), but
190
+ # stop at the next standard header -- a header with no table of its own.
191
+ j = i + 1
192
+ while j < n and not lines[j].lstrip().startswith("|"):
193
+ if _ACH_STD_HEADER.match(lines[j]):
194
+ break
195
+ j += 1
196
+ rows: list[list[str]] = []
197
+ while j < n and lines[j].lstrip().startswith("|"):
198
+ cells = [c.strip() for c in lines[j].strip().strip("|").split("|")]
199
+ if not all(set(c) <= {"-", ":", " "} and c for c in cells):
200
+ rows.append(cells)
201
+ j += 1
202
+ if len(rows) >= 2: # header row + >=1 level row
203
+ descs = [r[-1] for r in rows[1:]] # right-most column = the descriptor
204
+ stds.append([standard, *descs])
205
+ i = j
206
+ else:
207
+ i += 1
208
+ return stds or _md_table_rows(block)
209
+
210
+
110
211
  def parse_review_md(md_text: str) -> EvalPlanContent:
111
212
  """Parse a 평가계획 review markdown into structured content."""
112
213
  c = EvalPlanContent()
@@ -134,7 +235,7 @@ def parse_review_md(md_text: str) -> EvalPlanContent:
134
235
  s4 = sec(4)
135
236
  ga = s4.split("**나.")[0]
136
237
  na = ("**나." + s4.split("**나.")[1]) if "**나." in s4 else ""
137
- c.achievement_std = _md_table_rows(ga)
238
+ c.achievement_std = _parse_achievement_std(ga)
138
239
  c.levels = _md_table_rows(na)
139
240
 
140
241
  c.achieve_rate = _md_table_rows(sec(5))
@@ -166,6 +267,20 @@ def _prose(text: str) -> str:
166
267
 
167
268
 
168
269
  def _parse_rubrics(s7: str) -> list[Rubric]:
270
+ """Parse §7 수행평가 세부기준 into one :class:`Rubric` per 수행영역.
271
+
272
+ Dispatches on format: the current 평가계획 MD writes each area as an ``#### ①
273
+ title`` H4 heading with per-세부영역 ``평가요소 | 수행수준(채점 기준) | 배점`` tables
274
+ (the *detailed* 배점 rubric) — :func:`_parse_rubrics_detailed`. The older synthetic
275
+ format bolds the header inline (``**① title (NN점)**``) with a single flat 채점
276
+ 기준(배점) table — :func:`_parse_rubrics_legacy`. Detection is structural (an H4
277
+ circled heading), never subject-specific."""
278
+ if re.search(r"(?m)^####\s*[" + _CIRCLED + r"]", s7):
279
+ return _parse_rubrics_detailed(s7)
280
+ return _parse_rubrics_legacy(s7)
281
+
282
+
283
+ def _parse_rubrics_legacy(s7: str) -> list[Rubric]:
169
284
  rubrics: list[Rubric] = []
170
285
  # split on the bolded circled headers "**① title (NN점)** ..."
171
286
  parts = re.split(r"\*\*([" + _CIRCLED + r"][^*]*?\(\d+점\)[^*]*)\*\*", s7)
@@ -183,6 +298,158 @@ def _parse_rubrics(s7: str) -> list[Rubric]:
183
298
  return rubrics
184
299
 
185
300
 
301
+ def _strip_inline_md(s: str) -> str:
302
+ """Drop ``**bold**`` / `` `code` `` emphasis, collapse whitespace — cell text is
303
+ spliced verbatim otherwise (no summarisation)."""
304
+ return _norm(re.sub(r"\*\*|`", "", s or ""))
305
+
306
+
307
+ def _iter_md_tables(block: str):
308
+ """Yield ``(preamble, header_cells, data_rows)`` for every GitHub table in *block*
309
+ in order. ``preamble`` is the non-table text since the previous table — it carries
310
+ the ``[세부 영역 …]`` marker that names each sub-area."""
311
+ lines = block.splitlines()
312
+ i, preamble = 0, []
313
+ while i < len(lines):
314
+ if lines[i].strip().startswith("|"):
315
+ tbl = []
316
+ while i < len(lines) and lines[i].strip().startswith("|"):
317
+ tbl.append(lines[i].strip())
318
+ i += 1
319
+ rows = []
320
+ for t in tbl:
321
+ cells = [c.strip() for c in t.strip("|").split("|")]
322
+ if all(set(c) <= {"-", ":", " "} and c for c in cells):
323
+ continue
324
+ rows.append(cells)
325
+ if rows:
326
+ yield "\n".join(preamble), rows[0], rows[1:]
327
+ preamble = []
328
+ else:
329
+ preamble.append(lines[i])
330
+ i += 1
331
+
332
+
333
+ def _area_bullet(body: str, label: str) -> str:
334
+ """The value of a ``- **{label}**: …`` meta bullet in an area body ('' if absent)."""
335
+ m = re.search(rf"[-*]\s*\*\*{label}\*\*\s*[::]?\s*(.+)", body)
336
+ return m.group(1).strip() if m else ""
337
+
338
+
339
+ def _area_points(body: str, header: str) -> int:
340
+ """영역 만점 for an area — from the meta bullet / blockquote ('영역 만점: 50점',
341
+ '> 영역 만점 35점') or a ``(NN점)`` in the heading, else 0."""
342
+ m = re.search(r"영역\s*만점[은]?\**\s*[::]?\s*(\d+)\s*점", body)
343
+ if not m:
344
+ m = re.search(r"\((\d+)\s*점\)", header)
345
+ return int(m.group(1)) if m else 0
346
+
347
+
348
+ def _area_standards(body: str) -> str:
349
+ """The 성취기준 codes cited by an area, concatenated ('[12인기02-04][12인기02-05]')."""
350
+ m = re.search(r"(?:교육과정\s*성취기준|성취기준\s*/\s*성취수준|성취기준)[^\n]*?[::]\s*(.+)", body)
351
+ src = m.group(1) if m else body
352
+ 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)
353
+ return "".join(codes)
354
+
355
+
356
+ def _parse_area_rubric(header: str, title: str, body: str) -> Rubric:
357
+ """One 수행영역 → a detailed :class:`Rubric`: sub-area ``평가요소 | 수행수준 | 배점``
358
+ tables become :class:`RubricSubArea` blocks of :class:`RubricItem` ladders, and the
359
+ [영역 공통] / inline 기본점수 rows become ``base_score`` / ``long_score``. Content is
360
+ read verbatim — no invented 배점, no summarisation."""
361
+ rub = Rubric(title=title, points=_area_points(body, header),
362
+ standards=_area_standards(body),
363
+ task=_strip_inline_md(_area_bullet(body, "수행과제")),
364
+ method=_strip_inline_md(_area_bullet(body, "평가 방법")),
365
+ student_notes=_strip_inline_md(_area_bullet(body, "학생 유의사항")),
366
+ criteria=_area_bullet(body, r"평가기준\(상/중/하\)"))
367
+ for pre, hdr, rows in _iter_md_tables(body):
368
+ hdr_txt = " ".join(hdr)
369
+ if "평가요소" in hdr_txt and "배점" in hdr_txt:
370
+ rub.subareas.append(_parse_subarea(pre, rows, rub))
371
+ elif "구분" in hdr_txt and "배점" in hdr_txt:
372
+ _absorb_base_row(rows, rub) # [영역 공통] table
373
+ _sync_legacy_rows(rub)
374
+ return rub
375
+
376
+
377
+ def _parse_subarea(preamble: str, rows: list[list[str]], rub: Rubric) -> RubricSubArea:
378
+ """Group a sub-area table's rows into 평가요소 items (new item = non-empty 평가요소
379
+ cell, continuation rows = extra levels). A named sub-area's 소계 row is kept as a
380
+ trailing subtotal item (verbatim md — it marks the 가/나 boundary and the sub-area
381
+ 만점); 기본점수 / 장기 미인정 rows (appearing inline in single-table areas) feed
382
+ ``rub.base_score`` / ``long_score``."""
383
+ m = re.search(r"[세부 영역\s*([가-힣])\.\s*(.+?)\s*\((\d+)\s*점\)]", preamble or "")
384
+ sa = RubricSubArea(label=f"{m.group(1)}. {m.group(2)}" if m else "",
385
+ points=int(m.group(3)) if m else 0)
386
+ cur: RubricItem | None = None
387
+ for row in rows:
388
+ c0 = _strip_inline_md(row[0]) if len(row) > 0 else ""
389
+ c1 = _strip_inline_md(row[1]) if len(row) > 1 else ""
390
+ c2 = _strip_inline_md(row[2]) if len(row) > 2 else ""
391
+ if "소계" in c0:
392
+ if c2.isdigit():
393
+ sa.points = int(c2)
394
+ if sa.label: # keep 소계 as a subtotal row (가/나 marker)
395
+ sm = re.match(r"(.*?소계)\s*\((.*)\)\s*$", c0)
396
+ name, note = (sm.group(1), sm.group(2)) if sm else (c0, "")
397
+ sa.items.append(RubricItem(name=name, levels=[(note, c2)], subtotal=True))
398
+ cur = None
399
+ continue
400
+ if "장기 미인정" in c0 or "장기미인정" in c0:
401
+ if c2.isdigit():
402
+ rub.long_score = c2
403
+ continue
404
+ if "기본점수" in c0:
405
+ if c2.isdigit():
406
+ rub.base_score = c2
407
+ continue
408
+ if c0: # a new 평가요소
409
+ cur = RubricItem(name=c0)
410
+ sa.items.append(cur)
411
+ if cur is not None and (c1 or c2):
412
+ cur.levels.append((c1, c2))
413
+ return sa
414
+
415
+
416
+ def _absorb_base_row(rows: list[list[str]], rub: Rubric) -> None:
417
+ """Read [영역 공통] ``구분 | 배점`` rows into ``base_score`` / ``long_score``. 장기
418
+ 미인정 rows also carry '기본점수' (—1점), so match the more specific label first."""
419
+ for row in rows:
420
+ c0 = _strip_inline_md(row[0]) if row else ""
421
+ cN = _strip_inline_md(row[-1]) if row else ""
422
+ if not cN.isdigit():
423
+ continue
424
+ if "장기 미인정" in c0 or "장기미인정" in c0:
425
+ rub.long_score = cN
426
+ elif "기본점수" in c0:
427
+ rub.base_score = cN
428
+
429
+
430
+ def _sync_legacy_rows(rub: Rubric) -> None:
431
+ """Mirror the detailed model into the legacy ``rows`` shape ([name, 'desc **s** / …']
432
+ + a trailing 기본점수 summary) so legacy introspection / scorers keep working."""
433
+ rows: list[list[str]] = []
434
+ for it in rub.items:
435
+ ladder = " / ".join(f"{d} **{s}**" if s else d for d, s in it.levels)
436
+ rows.append([it.name, ladder])
437
+ if rub.base_score or rub.long_score:
438
+ rows.append([f"기본점수 **{rub.base_score}** · 장기 미인정 결석 **{rub.long_score}**", ""])
439
+ rub.rows = rows
440
+
441
+
442
+ def _parse_rubrics_detailed(s7: str) -> list[Rubric]:
443
+ """Parse the current-format §7 (``#### ①`` areas) into detailed rubrics."""
444
+ heads = list(re.finditer(r"(?m)^####\s*([" + _CIRCLED + r"])\s*(.+?)\s*$", s7))
445
+ out: list[Rubric] = []
446
+ for k, m in enumerate(heads):
447
+ start = m.end()
448
+ end = heads[k + 1].start() if k + 1 < len(heads) else len(s7)
449
+ out.append(_parse_area_rubric(m.group(0), m.group(2).strip(), s7[start:end]))
450
+ return out
451
+
452
+
186
453
  def _norm(text: str) -> str:
187
454
  return " ".join(text.split()).strip()
188
455
 
@@ -262,11 +529,17 @@ def plan_structural_ops(blank: str | Path, content: EvalPlanContent | None = Non
262
529
  # rubric example (연주/비평 for 3학년, 6 국어 영역 for 2학년); the content needs
263
530
  # `exp[kind]` of them. Delete the extras (keep the first) so the structure
264
531
  # matches the content-derived skeleton.
532
+ detailed_rubrics = bool(content and content.rubrics and content.rubrics[0].detailed)
265
533
  if exp:
266
534
  for kind in ("achievement", "level", "rubric"):
267
535
  idxs = by_kind.get(kind, [])
268
536
  want = exp.get(kind, 0)
269
- for i in idxs[want:]:
537
+ # For detailed rubrics the blank ships filled donor samples FIRST (인권·인포
538
+ # 그래픽) and clean empty templates LAST; the leading 인권 sample carries an
539
+ # extra 세부 항목 column the MD never uses, so keep the trailing clean
540
+ # templates instead (drop the surplus from the front).
541
+ surplus = idxs[:len(idxs) - want] if (kind == "rubric" and detailed_rubrics) else idxs[want:]
542
+ for i in surplus:
270
543
  if i not in del_tables:
271
544
  del_tables.append(i)
272
545
  transcript.append(f"delete_table #{i} (surplus {kind} example) — "
@@ -1162,6 +1435,488 @@ def _fill_rubric_headings(data: bytes, content: EvalPlanContent) -> tuple[bytes,
1162
1435
  return pres.data, skipped
1163
1436
 
1164
1437
 
1438
+ # --------------------------------------------------------------------------- #
1439
+ # Detailed §7 fill (current 평가계획 MD) -- per-평가요소 배점 ladders. Reshapes the
1440
+ # blank rubric's 평가요소 ladder to the MD's element count (grow / shrink the col0
1441
+ # vertical-merge blocks) and splices every observable criterion + 배점 verbatim.
1442
+ # Every target is located by geometry (label text / grid spans), reusable across the
1443
+ # form family, and every edit goes through the byte-preserving table_patch primitives.
1444
+ # --------------------------------------------------------------------------- #
1445
+
1446
+ def _row_label_value(grid: Any, rep: Any, tb: bytes, matches) -> tuple[int | None, Any, Any]:
1447
+ """(row, label_cell, value_cell) for the first cell whose text satisfies *matches*;
1448
+ value_cell is the grid cell immediately to its right (spanning-aware)."""
1449
+ from .table_patch import _text_of
1450
+ for r in range(rep.row_count):
1451
+ for c in range(rep.col_count):
1452
+ cell = grid.get((r, c))
1453
+ if cell is None or cell.row != r or cell.col != c:
1454
+ continue
1455
+ if matches(_text_of(tb[cell.start:cell.end])):
1456
+ return r, cell, grid.get((r, c + cell.col_span))
1457
+ return None, None, None
1458
+
1459
+
1460
+ def _fill_2022_header(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
1461
+ """Overwrite the header value cells (평가 영역명 / 영역 만점 / 학기 / 수행과제 / 학생
1462
+ 유의사항) — located by their label, so it works on both the clean template and the
1463
+ donor samples that survive structural pruning."""
1464
+ from .table_patch import fill_cells
1465
+ _sp, tb, grid, rep = _grid_of(data, ti)
1466
+
1467
+ def _n(t: str) -> str:
1468
+ return t.replace(" ", "").strip()
1469
+
1470
+ specs = [
1471
+ (lambda t: _n(t) == "평가영역명", rub.title, 3),
1472
+ (lambda t: _n(t) == "영역만점", f"{rub.points}점", 1),
1473
+ (lambda t: _n(t) == "학기", "2학기", 1),
1474
+ (lambda t: _n(t) == "수행과제", rub.task, 6),
1475
+ (lambda t: _n(t) == "학생유의사항", rub.student_notes, 6),
1476
+ ]
1477
+ cells: list[dict[str, Any]] = []
1478
+ for pred, val, ml in specs:
1479
+ if not val:
1480
+ continue
1481
+ _r, _lc, vc = _row_label_value(grid, rep, tb, pred)
1482
+ if vc is not None:
1483
+ cells.append({"table_index": ti, "row": vc.row, "col": vc.col, "text": val, "max_lines": ml})
1484
+ # 평가 방법: the donor sample's checkbox grid carries the WRONG subject's ☑ marks.
1485
+ # Overwrite the value cell with the MD's method (its ☑ list) and blank the second
1486
+ # checkbox row so no foreign check survives.
1487
+ if rub.method:
1488
+ mr, _mlc, mvc = _row_label_value(grid, rep, tb, lambda t: _n(t) == "평가방법")
1489
+ if mvc is not None:
1490
+ cells.append({"table_index": ti, "row": mvc.row, "col": mvc.col, "text": rub.method, "max_lines": 2})
1491
+ nxt = grid.get((mvc.row + 1, mvc.col))
1492
+ if nxt is not None and nxt.row == mvc.row + 1:
1493
+ cells.append({"table_index": ti, "row": nxt.row, "col": nxt.col, "text": "", "max_lines": 1})
1494
+ fr = fill_cells(data, cells)
1495
+ return fr.data, [s.reason for s in fr.skipped]
1496
+
1497
+
1498
+ def _delete_2022_ae_block(data: bytes, ti: int) -> tuple[bytes, list[str]]:
1499
+ """Delete the 성취기준 + 성취기준별 성취수준 (A~E) block. The current MD gives no
1500
+ per-area A~E descriptors (it refers to §4), so keeping the sample rows would show
1501
+ empty/foreign A~E — dropping them is the honest render. Bounds: the '성취기준' label
1502
+ row through the row before '평가 방법'."""
1503
+ from .table_patch import apply_table_ops, _text_of
1504
+ _sp, tb, grid, rep = _grid_of(data, ti)
1505
+ ae_start = method_row = None
1506
+ for r in range(rep.row_count):
1507
+ c0 = grid.get((r, 0))
1508
+ if c0 is None or c0.row != r:
1509
+ continue
1510
+ t = _text_of(tb[c0.start:c0.end]).replace(" ", "").strip()
1511
+ if ae_start is None and t == "성취기준":
1512
+ ae_start = r
1513
+ if t == "평가방법":
1514
+ method_row = r
1515
+ break
1516
+ if ae_start is None or method_row is None or method_row <= ae_start:
1517
+ return data, [f"rubric ti={ti}: A~E block bounds not found (성취기준={ae_start}, 평가방법={method_row})"]
1518
+ rows = list(range(ae_start, method_row))
1519
+ res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti, "rows": sorted(rows, reverse=True)}])
1520
+ if not res.ok:
1521
+ return data, [f"rubric ti={ti}: delete A~E block: {[s.reason for s in res.skipped]}"]
1522
+ return res.data, []
1523
+
1524
+
1525
+ def _grow_ladder_groups(data: bytes, ti: int, n: int) -> tuple[bytes, list[str]]:
1526
+ """Reshape the col0 평가요소 ladder to exactly *n* groups. Clones the shortest clean
1527
+ group block (``insert_block_by_clone`` — copies formatting byte-for-byte) to grow,
1528
+ deletes whole surplus blocks to shrink. Fail-closed."""
1529
+ from .table_patch import apply_table_ops
1530
+ _sp, tb, grid, rep = _grid_of(data, ti)
1531
+ ph, base = _pf_bounds(tb, grid, rep)
1532
+ if ph is None or base is None:
1533
+ return data, [f"rubric ti={ti}: no 평가요소/기본점수 bounds for ladder growth"]
1534
+ groups = _pf_groups(tb, ph, base)
1535
+ g = len(groups)
1536
+ if g == n:
1537
+ return data, []
1538
+ if g < n:
1539
+ r0, h = min(groups, key=lambda gh: gh[1]) # clone the shortest block
1540
+ res = apply_table_ops(data, [{"op": "insert_block_by_clone", "table_index": ti,
1541
+ "ref_rows": [r0, r0 + h - 1], "count": n - g}])
1542
+ if not res.ok:
1543
+ return data, [f"rubric ti={ti}: grow ladder to {n} groups: {[s.reason for s in res.skipped]}"]
1544
+ return res.data, []
1545
+ del_rows: list[int] = []
1546
+ for r0, h in groups[n:]:
1547
+ del_rows.extend(range(r0, r0 + h))
1548
+ res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti,
1549
+ "rows": sorted(set(del_rows), reverse=True)}])
1550
+ if not res.ok:
1551
+ return data, [f"rubric ti={ti}: shrink ladder to {n} groups: {[s.reason for s in res.skipped]}"]
1552
+ return res.data, []
1553
+
1554
+
1555
+ def _fill_2022_ladder_detailed(data: bytes, ti: int, items: list[RubricItem]) -> tuple[bytes, bool, list[str]]:
1556
+ """Normalize each 평가요소 group to its element's level count and splice the
1557
+ descriptor + 배점 of every level (verbatim from the MD's structured levels — never
1558
+ the lossy '/'-joined form). ``_grow_ladder_groups`` must have made the group count
1559
+ equal to ``len(items)`` first. Reuses the byte-preserving group reshape helpers."""
1560
+ from .table_patch import fill_cells
1561
+ _sp, tb, grid, rep = _grid_of(data, ti)
1562
+ ph, base = _pf_bounds(tb, grid, rep)
1563
+ if ph is None or base is None:
1564
+ return data, False, [f"rubric ti={ti}: no ladder bounds"]
1565
+ bat_col = rep.col_count - 1
1566
+ desc_col = _pf_desc_col(tb, ph, base, bat_col)
1567
+ has_sub = desc_col > 1
1568
+ groups = _pf_groups(tb, ph, base)
1569
+ if len(groups) != len(items):
1570
+ return data, False, [f"rubric ti={ti}: {len(groups)} 평가요소 groups != {len(items)} MD elements"]
1571
+
1572
+ d = data
1573
+ for gi in range(len(groups) - 1, -1, -1):
1574
+ m = max(1, len(items[gi].levels))
1575
+ _sp, tb, grid, rep = _grid_of(d, ti)
1576
+ ph2, base2 = _pf_bounds(tb, grid, rep)
1577
+ r0, h = _pf_groups(tb, ph2, base2)[gi]
1578
+ if has_sub:
1579
+ d, _fh, sub_sk = _reduce_to_first_subitem(d, ti, r0, h)
1580
+ if sub_sk:
1581
+ return data, False, [f"rubric ti={ti} group {gi}: {sub_sk[0]}"]
1582
+ _sp, tb, grid, rep = _grid_of(d, ti)
1583
+ ph2, base2 = _pf_bounds(tb, grid, rep)
1584
+ r0, h = _pf_groups(tb, ph2, base2)[gi]
1585
+ d, norm_sk = _normalize_ladder_group(d, ti, r0, h, m, desc_col, bat_col)
1586
+ if norm_sk:
1587
+ return data, False, [f"rubric ti={ti} group {gi}: {norm_sk[0]}"]
1588
+
1589
+ _sp, tb, grid, rep = _grid_of(d, ti)
1590
+ ph2, base2 = _pf_bounds(tb, grid, rep)
1591
+ gg = _pf_groups(tb, ph2, base2)
1592
+ cells: list[dict[str, Any]] = []
1593
+ for gi, (r0, _h) in enumerate(gg):
1594
+ lead = grid.get((r0, 0))
1595
+ if lead is not None:
1596
+ cells.append({"table_index": ti, "row": lead.row, "col": lead.col,
1597
+ "text": items[gi].name, "max_lines": 4})
1598
+ for k, (desc, score) in enumerate(items[gi].levels):
1599
+ r = r0 + k
1600
+ dc = grid.get((r, desc_col))
1601
+ if dc is not None:
1602
+ cells.append({"table_index": ti, "row": dc.row, "col": dc.col, "text": desc, "max_lines": 6})
1603
+ bc = grid.get((r, bat_col))
1604
+ if bc is not None and score:
1605
+ cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
1606
+ if has_sub:
1607
+ sc = grid.get((r0, 1))
1608
+ if sc is not None and sc.col == 1:
1609
+ cells.append({"table_index": ti, "row": sc.row, "col": 1, "text": "", "max_lines": 1})
1610
+ fr = fill_cells(d, cells)
1611
+ return fr.data, True, [s.reason for s in fr.skipped]
1612
+
1613
+
1614
+ def _fill_2022_base_scores(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
1615
+ """Fill the 기본점수 / 장기 미인정 결석자 배점 cells (right-most column). The row
1616
+ labels already carry the form's standard text, so only the 배점 is spliced. 장기
1617
+ 미인정 rows also contain '기본점수' (−1점), so match the more specific label first."""
1618
+ from .table_patch import fill_cells, _text_of
1619
+ _sp, tb, grid, rep = _grid_of(data, ti)
1620
+ bat_col = rep.col_count - 1
1621
+ base_row = long_row = None
1622
+ for r in range(rep.row_count):
1623
+ c0 = grid.get((r, 0))
1624
+ if c0 is None or c0.row != r:
1625
+ continue
1626
+ t = _text_of(tb[c0.start:c0.end])
1627
+ if "장기 미인정" in t or "장기미인정" in t:
1628
+ long_row = r
1629
+ elif "기본점수" in t:
1630
+ base_row = r
1631
+ cells: list[dict[str, Any]] = []
1632
+ for row, score in ((base_row, rub.base_score), (long_row, rub.long_score)):
1633
+ if row is None or not score:
1634
+ continue
1635
+ bc = grid.get((row, bat_col))
1636
+ if bc is not None:
1637
+ cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
1638
+ fr = fill_cells(data, cells)
1639
+ return fr.data, [s.reason for s in fr.skipped]
1640
+
1641
+
1642
+ def _fill_rubric_detailed_2022(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, bool, list[str]]:
1643
+ """Fill ONE 2022-개정 (평가 영역명) rubric with a detailed area's content: header
1644
+ values, drop the A~E block, grow the 평가요소 ladder to the element count, splice
1645
+ every level's criterion + 배점, and the 기본점수 배점. Returns (data, ladder_filled,
1646
+ notes)."""
1647
+ notes: list[str] = []
1648
+ data, n = _fill_2022_header(data, ti, rub); notes += n
1649
+ data, n = _delete_2022_ae_block(data, ti); notes += n
1650
+ items = rub.items
1651
+ data, n = _grow_ladder_groups(data, ti, len(items)); notes += n
1652
+ data, filled, n = _fill_2022_ladder_detailed(data, ti, items); notes += n
1653
+ data, n = _fill_2022_base_scores(data, ti, rub); notes += n
1654
+ data = _prune_header_empty_bullets(data, ti)
1655
+ return data, filled, notes
1656
+
1657
+
1658
+ def _split_criteria(criteria: str) -> tuple[str, str, str]:
1659
+ """Split a 평가기준(상/중/하) bullet ('상=… / 중=… / 하=…') into its three
1660
+ descriptors (verbatim, '' for any absent level)."""
1661
+ out = {}
1662
+ for key in ("상", "중", "하"):
1663
+ m = re.search(rf"{key}\s*=\s*(.+?)(?=\s*[//]\s*[상중하]\s*=|$)", criteria or "")
1664
+ out[key] = _strip_inline_md(m.group(1)) if m else ""
1665
+ return out["상"], out["중"], out["하"]
1666
+
1667
+
1668
+ def _3hak_ladder_bounds(tb: bytes, grid: Any, rep: Any) -> tuple[int | None, int | None]:
1669
+ """(영역(만점) header row, 기본점수 row) of a 2015-개정 rubric ladder."""
1670
+ from .table_patch import _text_of
1671
+ hr = br = None
1672
+ for r in range(rep.row_count):
1673
+ c0 = grid.get((r, 0))
1674
+ if c0 is not None and c0.row == r:
1675
+ t = _text_of(tb[c0.start:c0.end]).replace(" ", "")
1676
+ if "영역" in t and "만점" in t:
1677
+ hr = r
1678
+ c1 = grid.get((r, 1))
1679
+ if c1 is not None and c1.row == r and br is None and "기본점수" in _text_of(tb[c1.start:c1.end]):
1680
+ br = r
1681
+ return hr, br
1682
+
1683
+
1684
+ def _fill_3hak_header(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
1685
+ """Fill the 교육과정성취기준 codes, the 평가기준 상/중/하 descriptors, and 학생 유의
1686
+ 사항 — located by label geometry."""
1687
+ from .table_patch import fill_cells
1688
+
1689
+ def _n(t: str) -> str:
1690
+ return t.replace(" ", "").strip()
1691
+
1692
+ _sp, tb, grid, rep = _grid_of(data, ti)
1693
+ sang, jung, ha = _split_criteria(rub.criteria)
1694
+ specs = [
1695
+ (lambda t: _n(t) in ("교육과정성취기준", "성취기준"), rub.standards, 3),
1696
+ (lambda t: _n(t) == "상", sang, 4),
1697
+ (lambda t: _n(t) == "중", jung, 4),
1698
+ (lambda t: _n(t) == "하", ha, 4),
1699
+ (lambda t: _n(t) == "학생유의사항", rub.student_notes, 4),
1700
+ ]
1701
+ cells: list[dict[str, Any]] = []
1702
+ for pred, val, ml in specs:
1703
+ if not val:
1704
+ continue
1705
+ _r, _lc, vc = _row_label_value(grid, rep, tb, pred)
1706
+ if vc is not None:
1707
+ cells.append({"table_index": ti, "row": vc.row, "col": vc.col, "text": val, "max_lines": ml})
1708
+ fr = fill_cells(data, cells)
1709
+ return fr.data, [s.reason for s in fr.skipped]
1710
+
1711
+
1712
+ def _fill_3hak_base_scores(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, list[str]]:
1713
+ from .table_patch import fill_cells, _text_of
1714
+ _sp, tb, grid, rep = _grid_of(data, ti)
1715
+ bat_col = rep.col_count - 1
1716
+ base_row = long_row = None
1717
+ for r in range(rep.row_count):
1718
+ c1 = grid.get((r, 1))
1719
+ if c1 is None or c1.row != r:
1720
+ continue
1721
+ t = _text_of(tb[c1.start:c1.end])
1722
+ if "장기 미인정" in t or "장기미인정" in t:
1723
+ long_row = r
1724
+ elif "기본점수" in t:
1725
+ base_row = r
1726
+ cells: list[dict[str, Any]] = []
1727
+ for row, score in ((base_row, rub.base_score), (long_row, rub.long_score)):
1728
+ if row is None or not score:
1729
+ continue
1730
+ bc = grid.get((row, bat_col))
1731
+ if bc is not None:
1732
+ cells.append({"table_index": ti, "row": bc.row, "col": bc.col, "text": score, "max_lines": 1})
1733
+ fr = fill_cells(data, cells)
1734
+ return fr.data, [s.reason for s in fr.skipped]
1735
+
1736
+
1737
+ def _build_3hak_ladder(data: bytes, ti: int, rub: Rubric) -> tuple[bytes, bool, list[str]]:
1738
+ """Reshape the 5-column 영역(만점)|평가항목|평가요소|채점 기준|배점 ladder to one physical
1739
+ row per MD level, then subdivide the single 평가요소 merge into per-element blocks and
1740
+ the 평가항목 merge into per-세부영역 blocks (``split_cell_vertical``, byte-preserving).
1741
+ Leaders land on each block's top row; 채점 기준 + 배점 land on every level row."""
1742
+ from .table_patch import apply_table_ops
1743
+
1744
+ subareas = [(sa.label, sa.points, [it for it in sa.items if not it.subtotal])
1745
+ for sa in rub.subareas]
1746
+ subareas = [(lbl, pts, its) for lbl, pts, its in subareas if its]
1747
+ if not subareas:
1748
+ return data, False, [f"rubric ti={ti}: 3학년 rubric has no scored elements"]
1749
+
1750
+ flat: list[tuple[str, str]] = []
1751
+ item_sizes: list[int] = []
1752
+ subarea_sizes: list[int] = []
1753
+ item_leaders: list[tuple[int, str]] = []
1754
+ subarea_leaders: list[tuple[int, str]] = []
1755
+ off = 0
1756
+ for lbl, pts, its in subareas:
1757
+ sa_start, sa_total = off, 0
1758
+ for it in its:
1759
+ levels = it.levels or [("", "")]
1760
+ item_leaders.append((off, it.name))
1761
+ item_sizes.append(len(levels))
1762
+ flat.extend(levels)
1763
+ off += len(levels)
1764
+ sa_total += len(levels)
1765
+ subarea_leaders.append((sa_start, f"{lbl} ({pts}점)" if lbl else ""))
1766
+ subarea_sizes.append(sa_total)
1767
+ n = off
1768
+
1769
+ _sp, tb, grid, rep = _grid_of(data, ti)
1770
+ hr, br = _3hak_ladder_bounds(tb, grid, rep)
1771
+ if hr is None or br is None:
1772
+ return data, False, [f"rubric ti={ti}: no 영역(만점)/기본점수 ladder bounds"]
1773
+ body0 = hr + 1
1774
+ cur = br - body0
1775
+ # Grow / shrink the body to n rows. Clone a COVERED body row (body0+1): its cells are
1776
+ # rowSpan==1 (채점 기준·배점) while col0/1/2 merges span across it and auto-extend.
1777
+ if n > cur:
1778
+ res = apply_table_ops(data, [{"op": "insert_row_by_clone", "table_index": ti,
1779
+ "ref_row": body0 + 1, "count": n - cur}])
1780
+ if not res.ok:
1781
+ return data, False, [f"rubric ti={ti}: grow ladder to {n} rows: {[s.reason for s in res.skipped]}"]
1782
+ data = res.data
1783
+ elif n < cur:
1784
+ res = apply_table_ops(data, [{"op": "delete_row", "table_index": ti,
1785
+ "rows": list(range(body0 + n, br))}])
1786
+ if not res.ok:
1787
+ return data, False, [f"rubric ti={ti}: shrink ladder to {n} rows: {[s.reason for s in res.skipped]}"]
1788
+ data = res.data
1789
+
1790
+ # Subdivide 평가요소 (col2) per element and 평가항목 (col1) per 세부영역.
1791
+ for col, sizes in ((2, item_sizes), (1, subarea_sizes)):
1792
+ if len(sizes) > 1:
1793
+ res = apply_table_ops(data, [{"op": "split_cell_vertical", "table_index": ti,
1794
+ "row": body0, "col": col, "sizes": sizes}])
1795
+ if not res.ok:
1796
+ return data, False, [f"rubric ti={ti}: split col{col} {sizes}: {[s.reason for s in res.skipped]}"]
1797
+ data = res.data
1798
+
1799
+ from .table_patch import fill_cells
1800
+ _sp, tb, grid, rep = _grid_of(data, ti)
1801
+ hr, _br = _3hak_ladder_bounds(tb, grid, rep)
1802
+ body0 = hr + 1
1803
+ bat_col = rep.col_count - 1
1804
+ cells: list[dict[str, Any]] = []
1805
+ c0 = grid.get((body0, 0))
1806
+ if c0 is not None:
1807
+ cells.append({"table_index": ti, "row": c0.row, "col": 0,
1808
+ "text": f"{rub.title}({rub.points}점)", "max_lines": 4})
1809
+ for roff, lbl in subarea_leaders:
1810
+ c = grid.get((body0 + roff, 1))
1811
+ if c is not None and lbl:
1812
+ cells.append({"table_index": ti, "row": c.row, "col": 1, "text": lbl, "max_lines": 4})
1813
+ for roff, name in item_leaders:
1814
+ c = grid.get((body0 + roff, 2))
1815
+ if c is not None:
1816
+ cells.append({"table_index": ti, "row": c.row, "col": 2, "text": name, "max_lines": 4})
1817
+ for k, (desc, score) in enumerate(flat):
1818
+ c3 = grid.get((body0 + k, 3))
1819
+ if c3 is not None:
1820
+ cells.append({"table_index": ti, "row": c3.row, "col": c3.col, "text": desc, "max_lines": 6})
1821
+ c4 = grid.get((body0 + k, bat_col))
1822
+ if c4 is not None and score:
1823
+ cells.append({"table_index": ti, "row": c4.row, "col": c4.col, "text": score, "max_lines": 1})
1824
+ fr = fill_cells(data, cells)
1825
+ return fr.data, True, [s.reason for s in fr.skipped]
1826
+
1827
+
1828
+ def _fill_rubric_detailed_3hak(
1829
+ data: bytes, ti: int, rub: Rubric, content: EvalPlanContent
1830
+ ) -> tuple[bytes, bool, list[str]]:
1831
+ """Fill ONE 2015-개정 (교육과정성취기준) rubric with a detailed area's content: header
1832
+ (성취기준 / 평가기준 상·중·하 / 학생 유의사항), the nested 배점 ladder, and the 기본점수
1833
+ 배점. Returns (data, ladder_filled, notes)."""
1834
+ notes: list[str] = []
1835
+ data, n = _fill_3hak_header(data, ti, rub); notes += n
1836
+ data, ok, n = _build_3hak_ladder(data, ti, rub); notes += n
1837
+ data, n = _fill_3hak_base_scores(data, ti, rub); notes += n
1838
+ data = _prune_header_empty_bullets(data, ti)
1839
+ return data, ok, notes
1840
+
1841
+
1842
+ def _empty_trailing_para_spans(cell_xml: bytes) -> list[tuple[int, int]]:
1843
+ """Byte spans of every empty ``<hp:p>`` after the first in a cell — the donor
1844
+ template's leftover list-bullet paragraphs, which render as stray/red bullets once
1845
+ the MD text is spliced into the first paragraph. The first paragraph is always
1846
+ kept (a cell needs one)."""
1847
+ s = cell_xml.decode("utf-8")
1848
+ spans: list[tuple[int, int]] = []
1849
+ for i, m in enumerate(re.finditer(r"<hp:p\b.*?</hp:p>", s, re.S)):
1850
+ if i == 0:
1851
+ continue
1852
+ txt = "".join(re.findall(r"<hp:t\b[^>]*>(.*?)</hp:t>", m.group(0), re.S))
1853
+ if not txt.strip():
1854
+ spans.append((len(s[:m.start()].encode("utf-8")), len(s[:m.end()].encode("utf-8"))))
1855
+ return spans
1856
+
1857
+
1858
+ def _prune_header_empty_bullets(data: bytes, ti: int) -> bytes:
1859
+ """Drop the empty trailing bullet paragraphs left in a rubric's 수행과제 / 학생 유의
1860
+ 사항 value cells (byte-preserving: removes whole empty ``<hp:p>`` elements, rewrites
1861
+ only the affected section part)."""
1862
+ from .table_patch import _sections, _iter_table_spans, build_grid, _text_of
1863
+ from .patch import _rewrite_zip_entries
1864
+
1865
+ idx = ti
1866
+ for sp, section in sorted(_sections(data).items()):
1867
+ spans = _iter_table_spans(section)
1868
+ if idx >= len(spans):
1869
+ idx -= len(spans)
1870
+ continue
1871
+ ts, te = spans[idx]
1872
+ tb = section[ts:te]
1873
+ grid, rep = build_grid(tb)
1874
+ edits: list[tuple[int, int]] = []
1875
+ for r in range(rep.row_count):
1876
+ for cprime in range(rep.col_count):
1877
+ cell = grid.get((r, cprime))
1878
+ if cell is None or (cell.row, cell.col) != (r, cprime):
1879
+ continue
1880
+ if _text_of(tb[cell.start:cell.end]).replace(" ", "").strip() not in ("수행과제", "학생유의사항"):
1881
+ continue
1882
+ vc = grid.get((r, cell.col + cell.col_span))
1883
+ if vc is None:
1884
+ continue
1885
+ for a, b in _empty_trailing_para_spans(tb[vc.start:vc.end]):
1886
+ edits.append((ts + vc.start + a, ts + vc.start + b))
1887
+ if not edits:
1888
+ return data
1889
+ new_section = section
1890
+ for a, b in sorted(edits, reverse=True):
1891
+ new_section = new_section[:a] + new_section[b:]
1892
+ return _rewrite_zip_entries(data, {sp: new_section})
1893
+ return data
1894
+
1895
+
1896
+ def _fill_rubrics_detailed(
1897
+ data: bytes, content: EvalPlanContent, report: dict[str, Any]
1898
+ ) -> tuple[bytes, dict[str, Any]]:
1899
+ """Fill every §7 rubric from a detailed area. Routes by blank shape: 평가 영역명
1900
+ tables (2학년) → :func:`_fill_rubric_detailed_2022`; 교육과정성취기준 tables (3학년) →
1901
+ :func:`_fill_rubric_detailed_3hak`."""
1902
+ filled = 0
1903
+ for i, rub in enumerate(content.rubrics):
1904
+ idxs = _rubric_indices(data)
1905
+ if i >= len(idxs):
1906
+ report["skipped"].append(f"rubric {i}: no matching blank table")
1907
+ continue
1908
+ ti = idxs[i]
1909
+ if _rubric_is_2022(data, ti):
1910
+ data, ok, notes = _fill_rubric_detailed_2022(data, ti, rub)
1911
+ else:
1912
+ data, ok, notes = _fill_rubric_detailed_3hak(data, ti, rub, content)
1913
+ report["skipped"].extend(f"rubric {i}: {n}" for n in notes)
1914
+ if ok:
1915
+ filled += 1
1916
+ report["filled"] = filled
1917
+ return data, report
1918
+
1919
+
1165
1920
  def fill_rubrics(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
1166
1921
  """Fill each 수행평가 세부기준 rubric table from the matching review rubric
1167
1922
  (byte-preserving). Replaces the sample 성취기준 codes / 평가항목 labels.
@@ -1175,6 +1930,12 @@ def fill_rubrics(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str
1175
1930
 
1176
1931
  report: dict[str, Any] = {"rubrics": len(content.rubrics), "filled": 0, "skipped": []}
1177
1932
 
1933
+ # Detailed route (current 평가계획 MD): each area carries a per-element 배점 ladder
1934
+ # that the summary routes below can't represent. Grows the blank's 평가요소 ladder to
1935
+ # the MD's element count and splices every observable criterion + 배점 verbatim.
1936
+ if content.rubrics and content.rubrics[0].detailed:
1937
+ return _fill_rubrics_detailed(data, content, report)
1938
+
1178
1939
  # 2022-개정 route: fill each rubric table's label cells + 채점기준 ladder + heading.
1179
1940
  idxs0 = _rubric_indices(data)
1180
1941
  if idxs0 and _rubric_is_2022(data, idxs0[0]):
@@ -1480,20 +2241,53 @@ def fill_sections(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[st
1480
2241
  return pres.data, report
1481
2242
 
1482
2243
 
2244
+ def _flatten_schedule_merges(data: bytes, ti: int) -> tuple[bytes, list[str]]:
2245
+ """Split every vertical merge in the schedule's *data* rows into unit rows.
2246
+
2247
+ The donor ships a subject sample whose 단원명/성취기준 may span two weeks (a
2248
+ ``rowSpan==2`` cell). The review has distinct content per week, so a naive
2249
+ one-row-per-week fill writes the first week into the merged cell and can only
2250
+ *skip* the second (its cell is covered) -- the second week's 단원/성취기준 is
2251
+ absorbed. Splitting each vertical merge into unit cells (byte-preserving, the
2252
+ original formatting cloned onto the new cell) restores the 1:1 mapping so no
2253
+ row is absorbed. Header row (0) is left untouched. Content-agnostic."""
2254
+ from .table_patch import apply_table_ops, _direct_cells
2255
+ notes: list[str] = []
2256
+ while True:
2257
+ _sp, tb, _grid, _rep = _grid_of(data, ti)
2258
+ merge = next(((c.row, c.col, c.row_span)
2259
+ for c in sorted(_direct_cells(tb), key=lambda c: (c.row, c.col))
2260
+ if c.row >= 1 and c.row_span > 1), None)
2261
+ if merge is None:
2262
+ break
2263
+ r, col, rs = merge
2264
+ res = apply_table_ops(data, [{"op": "split_cell_vertical", "table_index": ti,
2265
+ "row": r, "col": col, "sizes": [1] * rs}])
2266
+ if not res.ok:
2267
+ notes.append(f"split donor merge r{r}c{col} (rowSpan {rs}) refused: "
2268
+ f"{[s.reason for s in res.skipped]}")
2269
+ break
2270
+ data = res.data
2271
+ notes.append(f"split donor merge r{r}c{col} (rowSpan {rs}) into {rs} unit rows")
2272
+ return data, notes
2273
+
2274
+
1483
2275
  def fill_schedule(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
1484
2276
  """Fill the Ⅰ 교수학습 운영 계획 schedule table from ``content.schedule`` (one
1485
2277
  logical row per review row, 6 columns), shrink-to-fit at 4 lines. Located by
1486
- the widest multi-row data table under the Ⅰ heading. Merged-cell skips (the
1487
- month/week span carry-overs) are expected and reported, not errors."""
2278
+ the widest multi-row data table under the Ⅰ heading. Any vertical merge a donor
2279
+ sample left in the data rows is split first so every review row maps to its own
2280
+ form row (no absorption)."""
1488
2281
  from .table_patch import fill_cells
1489
2282
 
1490
- report: dict[str, Any] = {"rows": len(content.schedule), "filled": 0, "skipped": []}
2283
+ report: dict[str, Any] = {"rows": len(content.schedule), "filled": 0, "skipped": [], "unmerged": []}
1491
2284
  if not content.schedule:
1492
2285
  return data, report
1493
2286
  ti = _schedule_index(data)
1494
2287
  if ti is None:
1495
2288
  report["skipped"].append("no schedule table found")
1496
2289
  return data, report
2290
+ data, report["unmerged"] = _flatten_schedule_merges(data, ti)
1497
2291
  cells = []
1498
2292
  for i, row in enumerate(content.schedule):
1499
2293
  r = i + 1 # row 0 is the header
@@ -1708,6 +2502,171 @@ def fill_ratio(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str,
1708
2502
  return fr.data, report
1709
2503
 
1710
2504
 
2505
+ def finalize_evalplan(data: bytes, content: EvalPlanContent) -> tuple[bytes, dict[str, Any]]:
2506
+ """Deterministic post-fill cleanup: turn a *filled* donor 평가계획 form into a
2507
+ submittable 채움본 without a bespoke driver script.
2508
+
2509
+ Runs the recipe's mechanical steps -- fill the title / teacher / 정의적 cells,
2510
+ prune the donor's instruction scaffolding and orphaned sample headings (bounded
2511
+ by the form's OWN section headings, never by subject or grade), strip the red
2512
+ guidance runs, recolor filled blue slots to body black, and remove trailing
2513
+ table captions. Every decision is structure/pattern based; no subject or grade
2514
+ string appears. Genuine ambiguity (novel instruction phrasing, non-1:1 정의적
2515
+ cell mapping, tie-broken 변형 선택) is intentionally NOT resolved here -- that is
2516
+ the skill's (LLM) judgment.
2517
+
2518
+ ``data`` must already be a :func:`fill_evalplan` (``phase="all"``) output; the
2519
+ deletes are computed while the red guidance runs are still intact so a black
2520
+ ordinal sharing a paragraph with a red instruction is removed as a unit. Returns
2521
+ ``(clean_bytes, report)``."""
2522
+ import html
2523
+ import io
2524
+ import zipfile
2525
+
2526
+ from .body_patch import (
2527
+ apply_body_ops,
2528
+ direct_paragraph_spans,
2529
+ recolor_runs_by_color,
2530
+ strip_runs_by_color,
2531
+ )
2532
+ from .formfill_quality import _tables
2533
+ from .guidance_scan import is_form_instruction
2534
+ from .table_patch import fill_cells, strip_trailing_table_captions
2535
+
2536
+ T = re.compile(r"<hp:t(?:\s[^>]*)?>(.*?)</hp:t>", re.S)
2537
+ RUN = re.compile(r"<hp:run\b[^>]*?>.*?</hp:run>", re.S)
2538
+ ORDINAL = re.compile(r"^[가-하]\.?$|^\(\d+\)$|^\*+$|^[·・\s]*$")
2539
+ # legit 평가계획 notes that sit among instructions -- form-generic, never a
2540
+ # subject/grade token, so keeping them is not hardcoding a subject.
2541
+ KEEP = ("가급적 동점자",)
2542
+
2543
+ report: dict[str, Any] = {
2544
+ "deleted": 0, "skipped": [], "captions": 0,
2545
+ "title": False, "teacher": False, "affective_rows": 0,
2546
+ }
2547
+
2548
+ def _sec_hdr(d: bytes) -> tuple[str, str]:
2549
+ z = zipfile.ZipFile(io.BytesIO(d))
2550
+ sec = z.read("Contents/section0.xml").decode("utf-8")
2551
+ hdr_name = next(n for n in z.namelist() if n.endswith("header.xml"))
2552
+ return sec, z.read(hdr_name).decode("utf-8")
2553
+
2554
+ def _red_ids(hdr: str) -> set[str]:
2555
+ out: set[str] = set()
2556
+ for cm in re.finditer(
2557
+ r"<hh:charPr\b[^>]*\bid=\"(\d+)\"[^>]*textColor=\"([#0-9A-Fa-f]+)\"", hdr
2558
+ ):
2559
+ if cm.group(2).upper() == "#FF0000":
2560
+ out.add(cm.group(1))
2561
+ return out
2562
+
2563
+ def _run_texts(block: str, reds: set[str]) -> tuple[str, str]:
2564
+ black, red = [], []
2565
+ for rm in RUN.finditer(block):
2566
+ run = rm.group(0)
2567
+ txt = html.unescape("".join(T.findall(run)))
2568
+ cid = re.search(r'charPrIDRef="(\d+)"', run)
2569
+ (red if (cid and cid.group(1) in reds) else black).append(txt)
2570
+ return "".join(black), "".join(red)
2571
+
2572
+ # ---- paragraph deletes computed on the FILLED bytes (red still intact) ------
2573
+ sec, hdr = _sec_hdr(data)
2574
+ reds = _red_ids(hdr)
2575
+ spans = direct_paragraph_spans(sec)
2576
+ blocks = [sec[a:b] for (a, b) in spans]
2577
+ texts = [html.unescape("".join(T.findall(bl))).strip() for bl in blocks]
2578
+ has_tbl = ["<hp:tbl" in bl for bl in blocks]
2579
+ del_idx: set[int] = set()
2580
+
2581
+ def first(pred, start: int = 0) -> int | None:
2582
+ return next((i for i in range(start, len(texts)) if pred(i)), None)
2583
+
2584
+ # (2e) 성취수준별 고정분할점수 labels -- red form scaffolding; delete all (the
2585
+ # engine already kept the single subject-matching 성취율 table by band count).
2586
+ del_idx.update(
2587
+ i for i in range(len(texts))
2588
+ if not has_tbl[i] and texts[i].startswith("성취수준별 고정분할점수")
2589
+ )
2590
+
2591
+ # (2a) 최소 성취수준 orphan heading block -- its table was pruned (공통과목 전용);
2592
+ # delete the non-table paragraphs from the heading down to the §5 table.
2593
+ ms = first(lambda i: not has_tbl[i] and texts[i].startswith("다. 최소 성취수준"))
2594
+ sec5 = first(lambda i: has_tbl[i] and "기준 성취율과 성취도" in texts[i])
2595
+ if ms is not None and sec5 is not None:
2596
+ del_idx.update(i for i in range(ms, sec5) if not has_tbl[i])
2597
+
2598
+ # (2f) foreign donor sample headings between the §7 and §8 tables.
2599
+ s7 = first(lambda i: has_tbl[i] and "수행평가 세부기준" in texts[i])
2600
+ s8 = first(lambda i: has_tbl[i] and "정의적 능력 평가" in texts[i],
2601
+ start=(s7 + 1) if s7 is not None else 0)
2602
+ if s7 is not None and s8 is not None:
2603
+ del_idx.update(i for i in range(s7 + 1, s8) if not has_tbl[i] and texts[i])
2604
+
2605
+ # (2b) foreign "(N) area" sub-headers between §4 and §5 (donor sample labels;
2606
+ # the MD's §4 uses 가./나. markers, never "(N) word", so this is safe).
2607
+ s4h = first(lambda i: has_tbl[i] and "성취기준 및 성취수준" in texts[i])
2608
+ s5h = first(lambda i: has_tbl[i] and "기준 성취율과 성취도" in texts[i],
2609
+ start=(s4h + 1) if s4h is not None else 0)
2610
+ if s4h is not None and s5h is not None:
2611
+ del_idx.update(
2612
+ i for i in range(s4h + 1, s5h)
2613
+ if not has_tbl[i] and re.match(r"^\(\d+\)\s*\S", texts[i])
2614
+ )
2615
+
2616
+ # (2c) red-guidance-with-trivial-black + black instruction paragraphs.
2617
+ for i, bl in enumerate(blocks):
2618
+ if has_tbl[i] or i in del_idx or any(k in texts[i] for k in KEEP):
2619
+ continue
2620
+ black_txt, red_txt = _run_texts(bl, reds)
2621
+ if red_txt.strip() and ORDINAL.match(black_txt.strip()):
2622
+ del_idx.add(i)
2623
+ elif is_form_instruction(texts[i]):
2624
+ del_idx.add(i)
2625
+
2626
+ # ---- title + §8 정의적 + teacher fills (paragraph count unchanged) -----------
2627
+ cells: list[dict[str, Any]] = []
2628
+ if content.title:
2629
+ cells.append({"table_index": 0, "row": 0, "col": 0, "text": content.title})
2630
+ report["title"] = True
2631
+ # §8 정의적 능력 평가 표: replace the donor sample with the MD's 측면 rows (1:1).
2632
+ aff = re.findall(
2633
+ r"(\S+적 측면\([^)]*\))\s*[::]\s*(.+?)(?=\s*-\s*\S+적 측면|$)", content.affective or ""
2634
+ )
2635
+ aff_ti = next((gi for gi, t in enumerate(_tables(data)) if "정의적 능력 평가 요소" in t.text), None)
2636
+ if aff_ti is not None and aff:
2637
+ for r, (label, desc) in enumerate(aff[:3], start=1):
2638
+ cells.append({"table_index": aff_ti, "row": r, "col": 0, "text": label.strip(), "max_lines": 3})
2639
+ cells.append({"table_index": aff_ti, "row": r, "col": 1, "text": desc.strip(), "max_lines": 4})
2640
+ report["affective_rows"] = min(len(aff), 3)
2641
+ if cells:
2642
+ data = fill_cells(data, cells).data
2643
+ if content.teacher:
2644
+ # fill the placeholder, then recolor the filled value to body-black so the
2645
+ # red-strip below does not remove it; robust to the label+placeholder being
2646
+ # in one run (2학년) or split black label + red placeholder (3학년).
2647
+ data = apply_body_ops(data, [
2648
+ {"op": "replace_text", "find": "◯◯◯, ◯◯◯", "replace": content.teacher, "count": 1},
2649
+ {"op": "restyle_text", "find": content.teacher, "textColor": "#000000", "count": 1},
2650
+ {"op": "replace_text", "find": "(**)", "replace": "", "count": 4},
2651
+ ]).data
2652
+ report["teacher"] = True
2653
+
2654
+ # ---- apply deletes (high->low so indices stay valid) ------------------------
2655
+ dr = apply_body_ops(data, [{"op": "delete_paragraph", "index": i}
2656
+ for i in sorted(del_idx, reverse=True)])
2657
+ data = dr.data
2658
+ report["deleted"] = len(del_idx) - len(dr.skipped)
2659
+ report["skipped"] = [str(s) for s in dr.skipped]
2660
+
2661
+ # ---- strip residual red, recolor filled blue->black, strip captions --------
2662
+ data = strip_runs_by_color(data, ["#FF0000"]).data
2663
+ data = recolor_runs_by_color(data, ["#0000FF"], "#000000").data
2664
+ cap = strip_trailing_table_captions(data)
2665
+ data = cap.data
2666
+ report["captions"] = len(cap.applied)
2667
+ return data, report
2668
+
2669
+
1711
2670
  def fill_evalplan(
1712
2671
  blank: str | Path,
1713
2672
  content: EvalPlanContent,
@@ -1721,8 +2680,12 @@ def fill_evalplan(
1721
2680
  정기시험 column, surplus example tables). ``phase="all"`` additionally runs the
1722
2681
  content fills -- schedule, achievement, levels, rubrics, section prose -- each
1723
2682
  byte-preserving and located by classification/geometry (index-safe against the
1724
- prior structural deletes). Returns the apply result dict plus the op-plan
1725
- transcript and, for ``phase="all"``, a ``content_report`` per region."""
2683
+ prior structural deletes). ``phase="clean"`` runs ``"all"`` and then
2684
+ :func:`finalize_evalplan` -- the deterministic post-fill cleanup (title/teacher/
2685
+ 정의적 fills, scaffolding + orphan prune, red-strip, blue->black recolor, caption
2686
+ strip) that yields a submittable 채움본 with no bespoke driver. Returns the apply
2687
+ result dict plus the op-plan transcript and, for ``phase`` in ``{"all","clean"}``,
2688
+ a ``content_report`` per region (plus ``content_report["finalize"]`` for clean)."""
1726
2689
  from .table_patch import apply_table_ops
1727
2690
 
1728
2691
  plan = plan_structural_ops(blank, content)
@@ -1731,13 +2694,15 @@ def fill_evalplan(
1731
2694
  payload = result.to_dict()
1732
2695
 
1733
2696
  content_report: dict[str, Any] = {}
1734
- if phase == "all":
2697
+ if phase in ("all", "clean"):
1735
2698
  data, content_report["schedule"] = fill_schedule(data, content)
1736
2699
  data, content_report["achievement"] = fill_achievement(data, content)
1737
2700
  data, content_report["levels"] = fill_levels(data, content)
1738
2701
  data, content_report["rubrics"] = fill_rubrics(data, content)
1739
2702
  data, content_report["ratio"] = fill_ratio(data, content)
1740
2703
  data, content_report["sections"] = fill_sections(data, content)
2704
+ if phase == "clean":
2705
+ data, content_report["finalize"] = finalize_evalplan(data, content)
1741
2706
 
1742
2707
  if output is not None:
1743
2708
  from pathlib import Path as _P
@@ -1756,7 +2721,7 @@ def parse_review_file(path: str | Path) -> EvalPlanContent:
1756
2721
 
1757
2722
  __all__ = [
1758
2723
  "EvalPlanContent", "Rubric", "parse_review_md", "parse_review_file",
1759
- "expected_skeleton", "plan_structural_ops", "fill_evalplan",
2724
+ "expected_skeleton", "plan_structural_ops", "fill_evalplan", "finalize_evalplan",
1760
2725
  "fill_schedule", "fill_achievement", "fill_levels", "fill_rubrics", "fill_ratio",
1761
2726
  "fill_sections",
1762
2727
  ]