diff --git a/docs/todo/20260119-rm000-overflow-autofix.md b/docs/todo/20260119-rm000-overflow-autofix.md new file mode 100644 index 00000000..8de31e89 --- /dev/null +++ b/docs/todo/20260119-rm000-overflow-autofix.md @@ -0,0 +1,54 @@ +--- +目標: テキスト溢れ時の自動対処ルールを適用して超過行を抑制する +関連ブランチ: feat/overflow-autofix +関連Issue: #542 +roadmap_item: RM-000 例: RMなしIssue C +--- + +- [x] ブランチ作成・初期コミット・push + - メモ: ブランチ名: feat/overflow-autofix / 初期コミット: docs(todo): add rm000 overflow autofix / push: 済み + - 必ず main からブランチを切る +- [x] 計画策定(スコープ・前提の整理) + - メモ: 承認済み Plan をそのまま転記する。以下の項目を含めること + - 対象スコープ: MappingStep で body 超過時に自動短縮し末尾に "..." を付与 + - 対象ファイル: src/pptx_generator/pipeline/mapping/processor.py, tests/pipeline/mapping/test_mapping_step_layout_assignment.py + - 前提: max_lines は layout の text_hint を使用 + - ドキュメント/コード修正方針: body を短縮し warnings に記録 + - 確認・共有方法: ToDo 更新、Issue コメント + - 想定影響ファイル: generate_ready.json の body 出力 + - リスク: 内容の末尾が削られる + - テスト方針: PYTHONPATH=src python -m pytest tests/pipeline/mapping/test_mapping_step_layout_assignment.py + - ロールバック方針: 追加処理を revert + - 承認メッセージ ID/リンク: ユーザー OK +- [x] 設計・実装方針の確定 + - メモ: max_lines 超過時に body を短縮し、末尾行へ "..." を付与する + - [x] 設計・実装方針メモの共有(不要) + - [x] 方針メモを更新するまで以降の stage へ進まないこと +- [x] 実装 + - メモ: src/pptx_generator/pipeline/mapping/processor.py, tests/pipeline/mapping/test_mapping_step_layout_assignment.py +- [x] テスト・検証 + - メモ: PYTHONPATH=src python -m pytest -n 0 tests/pipeline/mapping/test_mapping_step_layout_assignment.py / 10 passed / coverage.xml / diff-cover: python -m diff_cover.diff_cover_tool coverage.xml --compare-branch origin/main / Coverage 100%(26 lines) +- [x] ドキュメント更新 + - メモ: docs/todo/20260119-rm000-overflow-autofix.md, C:\PPT_test_textyabai\実施事項概要.md + - メモ: 対象外: docs/roadmap 配下, docs/requirements 配下, docs/design 配下, docs/runbook 配下, README.md / AGENTS.md + - [x] docs/roadmap 配下 + - [x] docs/requirements 配下(実装結果との整合を確認) + - [x] docs/design 配下(実装結果との整合を確認) + - [x] docs/runbook 配下 + - [x] README.md / AGENTS.md +- [x] 関連Issue 行動更新 + - メモ: 関連Issue: #542 +- [x] チェックリスト整合確認 + - メモ: 親タスクと子タスクのチェックを整合 +- [x] PR 作成 + - メモ: PR #543 https://github.com/yurake/pptx_generator/pull/543 + +## メモ +- 連続性メモ(短文で上書き) + - 前提/制約: max_lines は layout の text_hint を使用 + - 決定と根拠: 超過時は末尾行に "..." を付けて短縮 + - リスク(UNCONFIRMED): 内容の末尾が削られる + - Now/Next: Now=レビュー待ち / Next=マージ対応 + - テスト実績/抜け: PYTHONPATH=src python -m pytest -n 0 tests/pipeline/mapping/test_mapping_step_layout_assignment.py / 10 passed / diff-cover 100% + - テスト実績/抜け: +- 計画のみで完了する場合は、判断者・判断日・次アクション条件を記載する diff --git a/src/pptx_generator/pipeline/mapping/processor.py b/src/pptx_generator/pipeline/mapping/processor.py index 5b728588..82c821b3 100644 --- a/src/pptx_generator/pipeline/mapping/processor.py +++ b/src/pptx_generator/pipeline/mapping/processor.py @@ -398,25 +398,52 @@ def _apply_capacity_controls( max_lines = layout.max_lines() body = elements.get("body") - if max_lines is not None and isinstance(body, list) and len(body) > max_lines: - warnings.append( - f"body が許容行数 {max_lines} を超過しています(現在 {len(body)} 行)" - ) - capacity_warnings.append( - MappingLogCapacityWarning( - slide_id=slide_id, - element="body", - max_lines=max_lines, - actual_lines=len(body), - layout_id=layout.layout_id, + if isinstance(body, list): + if max_lines is not None and len(body) > max_lines: + actual_lines = len(body) + trimmed_body, trimmed = self._trim_body_lines(body, max_lines) + if trimmed: + elements["body"] = trimmed_body + warnings.append( + "body が許容行数 {max} を超過していたため {max} 行に短縮しました(元 {actual} 行)".format( + max=max_lines, + actual=actual_lines, + ) + ) + capacity_warnings.append( + MappingLogCapacityWarning( + slide_id=slide_id, + element="body", + max_lines=max_lines, + actual_lines=actual_lines, + layout_id=layout.layout_id, + ) ) - ) - - if isinstance(body, list) and not body: - warnings.append("body が空です") + if not body: + warnings.append("body が空です") return fallback, ai_patches, warnings, capacity_warnings + @staticmethod + def _trim_body_lines(body: list[str], max_lines: int) -> tuple[list[str], bool]: + if max_lines <= 0: + return ([], bool(body)) + if len(body) <= max_lines: + return (list(body), False) + trimmed = list(body[:max_lines]) + if trimmed: + trimmed[-1] = MappingSlideProcessor._append_ellipsis(trimmed[-1]) + return (trimmed, True) + + @staticmethod + def _append_ellipsis(text: str) -> str: + stripped = text.rstrip() + if not stripped: + return "..." + if stripped.endswith("..."): + return stripped + return f"{stripped}..." + @staticmethod def _build_auto_draw_payload(spec_slide: Slide | None) -> list[dict[str, float]]: if spec_slide is None: diff --git a/tests/pipeline/mapping/test_mapping_step_layout_assignment.py b/tests/pipeline/mapping/test_mapping_step_layout_assignment.py index 77d0b8b4..5c94a966 100644 --- a/tests/pipeline/mapping/test_mapping_step_layout_assignment.py +++ b/tests/pipeline/mapping/test_mapping_step_layout_assignment.py @@ -30,6 +30,7 @@ from pptx_generator.pipeline.base import PipelineContext from pptx_generator.pipeline.mapping import MappingOptions, MappingStep from pptx_generator.pipeline.mapping.processor import MappingSlideProcessor +from pptx_generator.pipeline.mapping.types import LayoutProfile from pptx_generator.prepare import ( PrepareBodyBlock, PrepareCard, @@ -242,7 +243,7 @@ def test_mapping_step_applies_fallback_when_body_overflow(tmp_path: Path) -> Non generate_ready_payload = json.loads(generate_ready_path.read_text(encoding="utf-8")) body = generate_ready_payload["slides"][0]["elements"]["body"] - assert body == ["1行目", "2行目", "3行目"], "オーバーフロー時でも本文は維持されること" + assert body == ["1行目", "2行目..."], "オーバーフロー時は本文を短縮すること" assert generate_ready_payload["slides"][0]["meta"]["fallback"] == "none" assert generate_ready_payload["meta"]["template_path"] == template_path.name @@ -255,7 +256,7 @@ def test_mapping_step_applies_fallback_when_body_overflow(tmp_path: Path) -> Non assert mapping_payload["meta"]["ai_patch_count"] == 0 assert mapping_payload["meta"]["analyzer_issue_count"] == 0 assert slide_log["warnings"] == [ - "body が許容行数 2 を超過しています(現在 3 行)" + "body が許容行数 2 を超過していたため 2 行に短縮しました(元 3 行)" ] assert slide_log["capacity_warnings"] == [ { @@ -356,6 +357,46 @@ def test_mapping_step_assigns_table_anchor(tmp_path: Path) -> None: assert "table" not in slide_elements +def test_mapping_capacity_controls_warn_on_empty_body(tmp_path: Path) -> None: + processor = MappingSlideProcessor( + options=MappingOptions(output_dir=tmp_path), + layout_catalog={}, + ) + layout = LayoutProfile( + layout_id="layout_basic", + layout_name="Basic", + usage_tags=(), + text_hint={"max_lines": 2}, + media_hint={}, + ) + elements = {"body": []} + + fallback, ai_patches, warnings, capacity_warnings = processor._apply_capacity_controls( + slide_id="s01", + layout=layout, + elements=elements, + ) + + assert fallback.applied is False + assert ai_patches == [] + assert warnings == ["body が空です"] + assert capacity_warnings == [] + assert elements["body"] == [] + + +def test_mapping_capacity_controls_helpers() -> None: + trimmed, changed = MappingSlideProcessor._trim_body_lines(["本文"], 0) + assert trimmed == [] + assert changed is True + + trimmed, changed = MappingSlideProcessor._trim_body_lines(["本文"], 2) + assert trimmed == ["本文"] + assert changed is False + + assert MappingSlideProcessor._append_ellipsis("") == "..." + assert MappingSlideProcessor._append_ellipsis("本文...") == "本文..." + + def test_mapping_step_errors_on_invalid_content_artifact(tmp_path: Path, caplog) -> None: spec = _build_spec(["本文"]) context = PipelineContext(spec=spec, workdir=tmp_path)