|
23 | 23 | "引用", |
24 | 24 | ] |
25 | 25 |
|
| 26 | +FIGURE_TARGET_SECTIONS = { |
| 27 | + "研究问题", |
| 28 | + "数据与任务定义", |
| 29 | + "方法主线", |
| 30 | + "关键结果", |
| 31 | + "深度分析", |
| 32 | + "局限", |
| 33 | + "我的笔记", |
| 34 | +} |
| 35 | + |
| 36 | +FIGURE_BUCKET_RESIDUE_TOKENS = { |
| 37 | + "剩余", |
| 38 | + "残余", |
| 39 | + "未放置", |
| 40 | + "未处理", |
| 41 | + "待补", |
| 42 | +} |
| 43 | + |
| 44 | +FIGURE_BUCKET_VISUAL_TOKENS = { |
| 45 | + "图", |
| 46 | + "表", |
| 47 | + "图片", |
| 48 | + "图表", |
| 49 | + "占位", |
| 50 | +} |
| 51 | + |
| 52 | +ENGLISH_FIGURE_BUCKET_RESIDUE_TOKENS = { |
| 53 | + "remaining", |
| 54 | + "leftover", |
| 55 | + "unplaced", |
| 56 | + "unresolved", |
| 57 | + "backlog", |
| 58 | +} |
| 59 | + |
| 60 | +ENGLISH_FIGURE_BUCKET_VISUAL_TOKENS = { |
| 61 | + "figure", |
| 62 | + "figures", |
| 63 | + "fig", |
| 64 | + "figs", |
| 65 | + "table", |
| 66 | + "tables", |
| 67 | + "placeholder", |
| 68 | + "placeholders", |
| 69 | +} |
| 70 | + |
26 | 71 |
|
27 | 72 | def parser() -> argparse.ArgumentParser: |
28 | 73 | p = argparse.ArgumentParser(description=__doc__ or "lint note") |
@@ -285,6 +330,103 @@ def inspect_figure_callouts(text: str) -> list[str]: |
285 | 330 | return warnings |
286 | 331 |
|
287 | 332 |
|
| 333 | +def is_figure_bucket_heading(title: str) -> bool: |
| 334 | + normalized = title.strip().lower() |
| 335 | + has_chinese_residue = any(token in normalized for token in FIGURE_BUCKET_RESIDUE_TOKENS) |
| 336 | + has_chinese_visual = any(token in normalized for token in FIGURE_BUCKET_VISUAL_TOKENS) |
| 337 | + if has_chinese_residue and has_chinese_visual: |
| 338 | + return True |
| 339 | + has_english_residue = any(token in normalized for token in ENGLISH_FIGURE_BUCKET_RESIDUE_TOKENS) |
| 340 | + has_english_visual = any(token in normalized for token in ENGLISH_FIGURE_BUCKET_VISUAL_TOKENS) |
| 341 | + return has_english_residue and has_english_visual |
| 342 | + |
| 343 | + |
| 344 | +def figure_bucket_heading_issues(text: str) -> list[dict[str, object]]: |
| 345 | + issues: list[dict[str, object]] = [] |
| 346 | + for idx, line in enumerate(text.splitlines(), start=1): |
| 347 | + match = re.match(r"^(#{2,3})\s+(.+)$", line.strip()) |
| 348 | + if not match: |
| 349 | + continue |
| 350 | + heading = match.group(2).strip() |
| 351 | + if is_figure_bucket_heading(heading): |
| 352 | + issues.append( |
| 353 | + { |
| 354 | + "line_number": idx, |
| 355 | + "heading": heading, |
| 356 | + "reason": "figure_placeholder_bucket_heading", |
| 357 | + } |
| 358 | + ) |
| 359 | + return issues |
| 360 | + |
| 361 | + |
| 362 | +def figure_callout_placement_issues(text: str) -> list[dict[str, object]]: |
| 363 | + issues: list[dict[str, object]] = [] |
| 364 | + lines = text.splitlines() |
| 365 | + for idx, line in enumerate(lines): |
| 366 | + stripped = line.strip() |
| 367 | + if stripped.startswith("[FIGURE_PLACEHOLDER]"): |
| 368 | + issues.append( |
| 369 | + { |
| 370 | + "line_number": idx + 1, |
| 371 | + "line": stripped, |
| 372 | + "reason": "legacy_figure_placeholder_block_used", |
| 373 | + } |
| 374 | + ) |
| 375 | + continue |
| 376 | + if not stripped.startswith("> [!figure]"): |
| 377 | + continue |
| 378 | + |
| 379 | + current_section = section_name_for_line(lines, idx) |
| 380 | + current_subsection = subsection_name_for_line(lines, idx) |
| 381 | + location = "" |
| 382 | + j = idx + 1 |
| 383 | + while j < len(lines): |
| 384 | + nxt = lines[j].strip() |
| 385 | + if not nxt.startswith(">"): |
| 386 | + break |
| 387 | + if nxt.startswith("> 建议位置:"): |
| 388 | + location = nxt.removeprefix("> 建议位置:").strip() |
| 389 | + break |
| 390 | + j += 1 |
| 391 | + |
| 392 | + if not location: |
| 393 | + issues.append( |
| 394 | + { |
| 395 | + "line_number": idx + 1, |
| 396 | + "callout": stripped, |
| 397 | + "current_section": current_section, |
| 398 | + "current_subsection": current_subsection, |
| 399 | + "reason": "figure_callout_missing_location", |
| 400 | + } |
| 401 | + ) |
| 402 | + continue |
| 403 | + |
| 404 | + if current_subsection and current_subsection in location: |
| 405 | + continue |
| 406 | + target_sections = [section for section in FIGURE_TARGET_SECTIONS if section in location] |
| 407 | + if target_sections and current_section not in target_sections: |
| 408 | + issues.append( |
| 409 | + { |
| 410 | + "line_number": idx + 1, |
| 411 | + "callout": stripped, |
| 412 | + "current_section": current_section, |
| 413 | + "current_subsection": current_subsection, |
| 414 | + "declared_location": location, |
| 415 | + "target_sections": target_sections, |
| 416 | + "reason": "figure_callout_placement_mismatch", |
| 417 | + } |
| 418 | + ) |
| 419 | + return issues |
| 420 | + |
| 421 | + |
| 422 | +def figure_structure_issues(text: str) -> list[dict[str, object]]: |
| 423 | + return figure_bucket_heading_issues(text) + figure_callout_placement_issues(text) |
| 424 | + |
| 425 | + |
| 426 | +def figure_structure_passes(text: str) -> bool: |
| 427 | + return not figure_structure_issues(text) |
| 428 | + |
| 429 | + |
288 | 430 | def is_prose_line(line: str) -> bool: |
289 | 431 | stripped = line.strip() |
290 | 432 | if not stripped: |
@@ -653,7 +795,12 @@ def main() -> None: |
653 | 795 | linebreak_issues = suspicious_mid_sentence_linebreaks(body_text) |
654 | 796 | code_math_issues = suspicious_code_formatted_math(text) |
655 | 797 | math_issues = math_render_issues(text) |
| 798 | + figure_issues = figure_structure_issues(text) |
656 | 799 | warnings.extend(inspect_figure_callouts(text)) |
| 800 | + for issue in figure_issues: |
| 801 | + reason = str(issue.get("reason", "")) |
| 802 | + if reason and reason not in warnings: |
| 803 | + warnings.append(reason) |
657 | 804 | warnings.extend(front_matter_order_warnings(text)) |
658 | 805 | warnings.extend(mechanism_flow_warnings(text)) |
659 | 806 | if not body_text.lstrip().startswith("# "): |
@@ -689,9 +836,11 @@ def main() -> None: |
689 | 836 | "linebreak_issues": linebreak_issues, |
690 | 837 | "code_math_issues": code_math_issues, |
691 | 838 | "math_render_issues": math_issues, |
| 839 | + "figure_structure_issues": figure_issues, |
692 | 840 | "passes_basic_structure": not missing_sections and not {"title_heading_missing", "no_level2_sections", "front_matter_order_invalid"} & set(warnings), |
693 | 841 | "passes_style_gate": not mixed_issues and not linebreak_issues and not code_math_issues, |
694 | 842 | "passes_math_gate": not math_issues, |
| 843 | + "passes_figure_gate": not figure_issues, |
695 | 844 | } |
696 | 845 | emit(payload, args.output) |
697 | 846 |
|
|
0 commit comments