@huicai/cli 2026.814.0 → 2026.1019.0
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.
- package/LICENSE +391 -32
- package/README.md +137 -8
- package/bin/huicai.js +146 -73
- package/package.json +27 -8
- package/skills/ab-testing/trigger-cases.json +30 -0
- package/skills/academic-writing/trigger-cases.json +30 -0
- package/skills/accounting-ops/trigger-cases.json +30 -0
- package/skills/ai-agent/trigger-cases.json +30 -0
- package/skills/ai-aiops/trigger-cases.json +30 -0
- package/skills/ai-coding/trigger-cases.json +30 -0
- package/skills/ai-compliance/trigger-cases.json +30 -0
- package/skills/ai-eval/trigger-cases.json +30 -0
- package/skills/ai-llm-app/trigger-cases.json +30 -0
- package/skills/ai-mlops/trigger-cases.json +30 -0
- package/skills/ai-multimodal/trigger-cases.json +30 -0
- package/skills/ai-prompt/trigger-cases.json +30 -0
- package/skills/ai-rag/trigger-cases.json +30 -0
- package/skills/anomaly-detection/trigger-cases.json +30 -0
- package/skills/api-design/trigger-cases.json +31 -0
- package/skills/api-integration/trigger-cases.json +31 -0
- package/skills/arch-design/trigger-cases.json +12 -0
- package/skills/arch-diagram/SKILL.md +118 -0
- package/skills/arch-diagram/trigger-cases.json +30 -0
- package/skills/arch-distributed/trigger-cases.json +30 -0
- package/skills/arch-multitenant/trigger-cases.json +30 -0
- package/skills/audio-brief/SKILL.md +65 -0
- package/skills/audio-brief/trigger-cases.json +30 -0
- package/skills/audit-assist/trigger-cases.json +30 -0
- package/skills/audit-chain/trigger-cases.json +31 -0
- package/skills/banking-credit/trigger-cases.json +30 -0
- package/skills/brand-design/trigger-cases.json +30 -0
- package/skills/business-plan/trigger-cases.json +30 -0
- package/skills/capital-ops/trigger-cases.json +30 -0
- package/skills/cicd-pipeline/trigger-cases.json +31 -0
- package/skills/code-archaeology/SKILL.md +55 -0
- package/skills/code-archaeology/trigger-cases.json +9 -0
- package/skills/code-quality/SKILL.md +138 -0
- package/skills/code-quality/references/languages.md +107 -0
- package/skills/code-quality/scripts/__pycache__/code_battery.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_check.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_check.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_csharp.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_fix.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_fix.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_java.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_java.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_jsts.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_lens.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_lens.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_python.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_python.cpython-313.pyc +0 -0
- package/skills/code-quality/scripts/__pycache__/code_testplan.cpython-312.pyc +0 -0
- package/skills/code-quality/scripts/code_battery.py +1072 -0
- package/skills/code-quality/scripts/code_check.py +1269 -0
- package/skills/code-quality/scripts/code_csharp.py +371 -0
- package/skills/code-quality/scripts/code_fix.py +201 -0
- package/skills/code-quality/scripts/code_java.py +695 -0
- package/skills/code-quality/scripts/code_jsts.py +313 -0
- package/skills/code-quality/scripts/code_lens.py +1079 -0
- package/skills/code-quality/scripts/code_python.py +536 -0
- package/skills/code-quality/scripts/code_testplan.py +239 -0
- package/skills/code-quality/trigger-cases.json +45 -0
- package/skills/code-review/SKILL.md +24 -0
- package/skills/code-review/trigger-cases.json +35 -0
- package/skills/commit-and-pr/trigger-cases.json +31 -0
- package/skills/content-creation/trigger-cases.json +30 -0
- package/skills/corporate-governance/trigger-cases.json +30 -0
- package/skills/crawl-chain/trigger-cases.json +31 -0
- package/skills/customer-service/trigger-cases.json +30 -0
- package/skills/data-analysis/trigger-cases.json +30 -0
- package/skills/data-chain/SKILL.md +1 -1
- package/skills/data-chain/trigger-cases.json +31 -0
- package/skills/data-governance/trigger-cases.json +30 -0
- package/skills/data-report-pro/trigger-cases.json +30 -0
- package/skills/data-streaming/trigger-cases.json +30 -0
- package/skills/data-wrangling/SKILL.md +1 -1
- package/skills/data-wrangling/trigger-cases.json +30 -0
- package/skills/db-es/trigger-cases.json +30 -0
- package/skills/db-migration/trigger-cases.json +30 -0
- package/skills/db-mysql/trigger-cases.json +30 -0
- package/skills/db-postgres/trigger-cases.json +30 -0
- package/skills/db-redis/trigger-cases.json +30 -0
- package/skills/debug-root-cause/trigger-cases.json +30 -0
- package/skills/dependency-audit/SKILL.md +56 -0
- package/skills/dependency-audit/trigger-cases.json +7 -0
- package/skills/dependency-upgrade/trigger-cases.json +31 -0
- package/skills/dev-cpp/trigger-cases.json +30 -0
- package/skills/dev-cross-platform/trigger-cases.json +30 -0
- package/skills/dev-csharp/SKILL.md +23 -0
- package/skills/dev-csharp/trigger-cases.json +30 -0
- package/skills/dev-frontend/SKILL.md +19 -0
- package/skills/dev-frontend/trigger-cases.json +30 -0
- package/skills/dev-go/trigger-cases.json +30 -0
- package/skills/dev-harmony/trigger-cases.json +30 -0
- package/skills/dev-java/SKILL.md +20 -0
- package/skills/dev-java/trigger-cases.json +30 -0
- package/skills/dev-mobile/trigger-cases.json +30 -0
- package/skills/dev-node/trigger-cases.json +30 -0
- package/skills/dev-php/trigger-cases.json +30 -0
- package/skills/dev-python/SKILL.md +21 -0
- package/skills/dev-python/trigger-cases.json +30 -0
- package/skills/dev-rust/trigger-cases.json +30 -0
- package/skills/dev-toolchain/trigger-cases.json +31 -0
- package/skills/doc-chain/trigger-cases.json +31 -0
- package/skills/doc-compare/trigger-cases.json +30 -0
- package/skills/docx-author/SKILL.md +127 -25
- package/skills/docx-author/scripts/__pycache__/docx_battery.cpython-312.pyc +0 -0
- package/skills/docx-author/scripts/__pycache__/docx_build.cpython-312.pyc +0 -0
- package/skills/docx-author/scripts/__pycache__/docx_check.cpython-312.pyc +0 -0
- package/skills/docx-author/scripts/__pycache__/docx_layout.cpython-312.pyc +0 -0
- package/skills/docx-author/scripts/__pycache__/zh_prose.cpython-312.pyc +0 -0
- package/skills/docx-author/scripts/docx_battery.py +586 -0
- package/skills/docx-author/scripts/docx_build.py +995 -0
- package/skills/docx-author/scripts/docx_check.py +1306 -0
- package/skills/docx-author/scripts/docx_doc.py +14 -154
- package/skills/docx-author/scripts/docx_layout.py +490 -0
- package/skills/docx-author/scripts/zh_prose.py +639 -0
- package/skills/docx-author/trigger-cases.json +58 -0
- package/skills/echo-brief/SKILL.md +84 -0
- package/skills/echo-brief/references/echo-format.md +68 -0
- package/skills/echo-brief/trigger-cases.json +40 -0
- package/skills/ecommerce-ops/trigger-cases.json +30 -0
- package/skills/education-training/trigger-cases.json +30 -0
- package/skills/ehs-safety/trigger-cases.json +30 -0
- package/skills/email-writing/trigger-cases.json +30 -0
- package/skills/eng-ai/trigger-cases.json +30 -0
- package/skills/eng-architecture/trigger-cases.json +31 -0
- package/skills/eng-automation/trigger-cases.json +31 -0
- package/skills/eng-collaboration/trigger-cases.json +30 -0
- package/skills/eng-complex-env/trigger-cases.json +30 -0
- package/skills/eng-data-platform/trigger-cases.json +31 -0
- package/skills/eng-database/trigger-cases.json +31 -0
- package/skills/eng-develop/trigger-cases.json +30 -0
- package/skills/eng-docs/SKILL.md +24 -0
- package/skills/eng-docs/trigger-cases.json +30 -0
- package/skills/eng-efficiency/trigger-cases.json +30 -0
- package/skills/eng-infra/trigger-cases.json +30 -0
- package/skills/eng-integration/trigger-cases.json +31 -0
- package/skills/eng-legacy/trigger-cases.json +31 -0
- package/skills/eng-observability/trigger-cases.json +31 -0
- package/skills/eng-ops/trigger-cases.json +31 -0
- package/skills/eng-performance/trigger-cases.json +31 -0
- package/skills/eng-planning/trigger-cases.json +31 -0
- package/skills/eng-quality/SKILL.md +8 -0
- package/skills/eng-quality/trigger-cases.json +31 -0
- package/skills/eng-release/trigger-cases.json +31 -0
- package/skills/eng-reliability/trigger-cases.json +31 -0
- package/skills/eng-requirements/trigger-cases.json +12 -0
- package/skills/eng-research/trigger-cases.json +30 -0
- package/skills/eng-security/trigger-cases.json +31 -0
- package/skills/eng-ux/trigger-cases.json +30 -0
- package/skills/engineering-chain/trigger-cases.json +30 -0
- package/skills/env-offline/trigger-cases.json +31 -0
- package/skills/env-private-deploy/trigger-cases.json +31 -0
- package/skills/env-xinchuang/trigger-cases.json +31 -0
- package/skills/equipment-tpm/trigger-cases.json +30 -0
- package/skills/esg-report/trigger-cases.json +30 -0
- package/skills/event-management/trigger-cases.json +30 -0
- package/skills/excel-analysis-chain/SKILL.md +17 -4
- package/skills/excel-analysis-chain/trigger-cases.json +31 -0
- package/skills/explain-codebase/trigger-cases.json +11 -0
- package/skills/file-ingest/trigger-cases.json +30 -0
- package/skills/finance-analysis/trigger-cases.json +31 -0
- package/skills/finance-chain/trigger-cases.json +31 -0
- package/skills/financial-modeling/trigger-cases.json +31 -0
- package/skills/find-simplifications/SKILL.md +43 -0
- package/skills/find-simplifications/trigger-cases.json +23 -0
- package/skills/foreign-trade/trigger-cases.json +30 -0
- package/skills/funnel-cohort/trigger-cases.json +30 -0
- package/skills/gov-document/trigger-cases.json +30 -0
- package/skills/gui-change-evidence/SKILL.md +47 -0
- package/skills/gui-change-evidence/trigger-cases.json +23 -0
- package/skills/hr-chain/trigger-cases.json +31 -0
- package/skills/hr-operations/trigger-cases.json +30 -0
- package/skills/info-disclosure/trigger-cases.json +31 -0
- package/skills/infra-gateway/trigger-cases.json +31 -0
- package/skills/infra-k8s/trigger-cases.json +31 -0
- package/skills/infra-mq/trigger-cases.json +30 -0
- package/skills/insurance-advisory/trigger-cases.json +30 -0
- package/skills/internal-audit/trigger-cases.json +30 -0
- package/skills/ip-management/trigger-cases.json +31 -0
- package/skills/ipo-listing/trigger-cases.json +31 -0
- package/skills/it-audit/trigger-cases.json +31 -0
- package/skills/itsm-ops/trigger-cases.json +31 -0
- package/skills/knowledge-sop/trigger-cases.json +31 -0
- package/skills/legal-chain/trigger-cases.json +31 -0
- package/skills/legal-contract/trigger-cases.json +30 -0
- package/skills/litigation-support/trigger-cases.json +31 -0
- package/skills/logistics-warehouse/trigger-cases.json +30 -0
- package/skills/long-doc-analysis/trigger-cases.json +31 -0
- package/skills/management-accounting/trigger-cases.json +30 -0
- package/skills/manufacturing-chain/trigger-cases.json +31 -0
- package/skills/manufacturing-quality/trigger-cases.json +30 -0
- package/skills/marketing-analytics/trigger-cases.json +31 -0
- package/skills/marketing-chain/trigger-cases.json +31 -0
- package/skills/meeting-minutes/trigger-cases.json +30 -0
- package/skills/metrics-system/trigger-cases.json +31 -0
- package/skills/negotiation-prep/trigger-cases.json +30 -0
- package/skills/obs-prometheus/trigger-cases.json +31 -0
- package/skills/office-automation/trigger-cases.json +31 -0
- package/skills/okr-review/trigger-cases.json +30 -0
- package/skills/ops-fleet/trigger-cases.json +31 -0
- package/skills/ops-linux/trigger-cases.json +31 -0
- package/skills/optimization-solver/trigger-cases.json +30 -0
- package/skills/patent-disclosure/SKILL.md +105 -0
- package/skills/patent-disclosure/references/figures.md +110 -0
- package/skills/patent-disclosure/references/outline.md +162 -0
- package/skills/patent-disclosure/scripts/patent_docx.py +230 -0
- package/skills/patent-disclosure/trigger-cases.json +31 -0
- package/skills/patent-drafting/SKILL.md +108 -0
- package/skills/patent-drafting/trigger-cases.json +31 -0
- package/skills/patent-portfolio/SKILL.md +81 -0
- package/skills/patent-portfolio/trigger-cases.json +31 -0
- package/skills/pdf-toolkit/trigger-cases.json +31 -0
- package/skills/perf-optimize/trigger-cases.json +31 -0
- package/skills/pivot-crosstab/trigger-cases.json +30 -0
- package/skills/policy-analysis/trigger-cases.json +31 -0
- package/skills/portfolio-analysis/trigger-cases.json +31 -0
- package/skills/ppt-chain/SKILL.md +14 -6
- package/skills/ppt-chain/trigger-cases.json +31 -0
- package/skills/pptx-author/SKILL.md +86 -33
- package/skills/pptx-author/scripts/__pycache__/pptx_check.cpython-312.pyc +0 -0
- package/skills/pptx-author/scripts/__pycache__/pptx_deck.cpython-312.pyc +0 -0
- package/skills/pptx-author/scripts/__pycache__/pptx_layout.cpython-312.pyc +0 -0
- package/skills/pptx-author/scripts/pptx_battery.py +178 -0
- package/skills/pptx-author/scripts/pptx_build.py +119 -0
- package/skills/pptx-author/scripts/pptx_check.py +1182 -0
- package/skills/pptx-author/scripts/pptx_deck.py +736 -145
- package/skills/pptx-author/scripts/pptx_layout.py +384 -0
- package/skills/pptx-author/trigger-cases.json +15 -0
- package/skills/pr-comms/trigger-cases.json +30 -0
- package/skills/procurement-supply/trigger-cases.json +31 -0
- package/skills/product-design/trigger-cases.json +31 -0
- package/skills/production-planning/trigger-cases.json +31 -0
- package/skills/project-management/trigger-cases.json +31 -0
- package/skills/project-proposal/trigger-cases.json +31 -0
- package/skills/prose-standard/SKILL.md +53 -0
- package/skills/prose-standard/trigger-cases.json +23 -0
- package/skills/qa-api-automation/trigger-cases.json +31 -0
- package/skills/qa-mobile-automation/trigger-cases.json +31 -0
- package/skills/qa-ui-automation/trigger-cases.json +31 -0
- package/skills/quant-analysis/trigger-cases.json +31 -0
- package/skills/rag-triage/SKILL.md +64 -0
- package/skills/rag-triage/evals.json +57 -0
- package/skills/rag-triage/references/triage-playbook.md +75 -0
- package/skills/rag-triage/trigger-cases.json +31 -0
- package/skills/real-estate-property/trigger-cases.json +30 -0
- package/skills/refactor-safely/SKILL.md +1 -1
- package/skills/refactor-safely/trigger-cases.json +30 -0
- package/skills/regression-forecast/trigger-cases.json +31 -0
- package/skills/release-checklist/trigger-cases.json +31 -0
- package/skills/report-craft/SKILL.md +76 -0
- package/skills/report-craft/references/feasibility.md +118 -0
- package/skills/report-craft/references/review-checklist.md +51 -0
- package/skills/report-craft/references/visuals.md +114 -0
- package/skills/report-craft/references/writing.md +80 -0
- package/skills/report-craft/scripts/__pycache__/chart_render.cpython-312.pyc +0 -0
- package/skills/report-craft/scripts/__pycache__/fin_model.cpython-312.pyc +0 -0
- package/skills/report-craft/scripts/__pycache__/project_facts.cpython-312.pyc +0 -0
- package/skills/report-craft/scripts/chart_render.py +1222 -0
- package/skills/report-craft/scripts/fin_model.py +602 -0
- package/skills/report-craft/scripts/project_facts.py +580 -0
- package/skills/report-craft/scripts/report_battery.py +349 -0
- package/skills/report-craft/trigger-cases.json +15 -0
- package/skills/retail-store-ops/trigger-cases.json +30 -0
- package/skills/risk-compliance/trigger-cases.json +31 -0
- package/skills/scope-creep/SKILL.md +45 -0
- package/skills/scope-creep/trigger-cases.json +7 -0
- package/skills/sec-auth/trigger-cases.json +31 -0
- package/skills/sec-compliance/trigger-cases.json +31 -0
- package/skills/securities-chain/trigger-cases.json +31 -0
- package/skills/securities-research/trigger-cases.json +31 -0
- package/skills/security-audit/trigger-cases.json +31 -0
- package/skills/slides-html/SKILL.md +25 -10
- package/skills/slides-html/trigger-cases.json +11 -0
- package/skills/sql-analysis/SKILL.md +110 -0
- package/skills/sql-analysis/trigger-cases.json +34 -0
- package/skills/sql-tuning/trigger-cases.json +31 -0
- package/skills/statistical-testing/trigger-cases.json +31 -0
- package/skills/strategy-planning/trigger-cases.json +30 -0
- package/skills/supply-chain/trigger-cases.json +31 -0
- package/skills/tax-compliance/trigger-cases.json +30 -0
- package/skills/tech-doc/SKILL.md +262 -0
- package/skills/tech-doc/scripts/__pycache__/archive_battery.cpython-312.pyc +0 -0
- package/skills/tech-doc/scripts/__pycache__/archive_build.cpython-312.pyc +0 -0
- package/skills/tech-doc/scripts/__pycache__/archive_check.cpython-312.pyc +0 -0
- package/skills/tech-doc/scripts/__pycache__/archive_spec.cpython-312.pyc +0 -0
- package/skills/tech-doc/scripts/__pycache__/doc_templates.cpython-312.pyc +0 -0
- package/skills/tech-doc/scripts/archive_battery.py +860 -0
- package/skills/tech-doc/scripts/archive_build.py +599 -0
- package/skills/tech-doc/scripts/archive_check.py +1307 -0
- package/skills/tech-doc/scripts/archive_spec.py +499 -0
- package/skills/tech-doc/scripts/doc_templates.py +624 -0
- package/skills/tech-doc/trigger-cases.json +57 -0
- package/skills/tech-research/trigger-cases.json +31 -0
- package/skills/tender-bidding/trigger-cases.json +31 -0
- package/skills/translation-pro/trigger-cases.json +30 -0
- package/skills/treasury-mgmt/trigger-cases.json +31 -0
- package/skills/trim-cot-leakage/SKILL.md +58 -0
- package/skills/trim-cot-leakage/trigger-cases.json +23 -0
- package/skills/web-craft/SKILL.md +103 -0
- package/skills/web-craft/references/craft-checklist.md +75 -0
- package/skills/web-craft/references/design-direction.md +122 -0
- package/skills/web-craft/references/h5-mobile.md +100 -0
- package/skills/web-craft/references/motion.md +136 -0
- package/skills/web-craft/references/recipes.md +74 -0
- package/skills/web-craft/templates/wedding-invitation.html +1180 -0
- package/skills/web-craft/trigger-cases.json +14 -0
- package/skills/web-scraping/trigger-cases.json +34 -0
- package/skills/web-search/SKILL.md +11 -10
- package/skills/web-search/trigger-cases.json +31 -0
- package/skills/weekly-report/SKILL.md +134 -0
- package/skills/weekly-report/trigger-cases.json +11 -0
- package/skills/write-tests/trigger-cases.json +31 -0
- package/skills/xlsx-analysis/SKILL.md +143 -77
- package/skills/xlsx-analysis/scripts/__pycache__/data_profile.cpython-312.pyc +0 -0
- package/skills/xlsx-analysis/scripts/__pycache__/xlsx_build.cpython-312.pyc +0 -0
- package/skills/xlsx-analysis/scripts/__pycache__/xlsx_check.cpython-312.pyc +0 -0
- package/skills/xlsx-analysis/scripts/__pycache__/xlsx_layout.cpython-312.pyc +0 -0
- package/skills/xlsx-analysis/scripts/__pycache__/xlsx_toolkit.cpython-312.pyc +0 -0
- package/skills/xlsx-analysis/scripts/data_profile.py +391 -0
- package/skills/xlsx-analysis/scripts/xlsx_battery.py +389 -0
- package/skills/xlsx-analysis/scripts/xlsx_build.py +522 -0
- package/skills/xlsx-analysis/scripts/xlsx_check.py +924 -0
- package/skills/xlsx-analysis/scripts/xlsx_layout.py +235 -0
- package/skills/xlsx-analysis/scripts/xlsx_style.py +28 -66
- package/skills/xlsx-analysis/scripts/xlsx_toolkit.py +61 -5
- package/skills/xlsx-analysis/trigger-cases.json +59 -0
- package/squads/hc-defense.md +47 -0
- package/squads/hc-rdarchive.md +110 -0
- package/squads/hc-redteam.md +50 -0
- package/squads/hc-weekly.md +125 -0
- package/src/account.js +1 -1
- package/src/acp.js +1 -0
- package/src/advisor.js +1 -1
- package/src/agent.js +1 -1
- package/src/api.js +1 -1
- package/src/archaeology.js +1 -0
- package/src/ask-ui.js +1 -0
- package/src/assets-import.js +1 -1
- package/src/autofix.js +1 -1
- package/src/automation-tools.js +1 -1
- package/src/automation.js +1 -1
- package/src/autorun.js +1 -1
- package/src/banner.js +1 -1
- package/src/capability-probe.js +1 -0
- package/src/checkpoints.js +1 -1
- package/src/code-tools.js +1 -0
- package/src/codeindex.js +1 -1
- package/src/codemode.js +1 -0
- package/src/command-bridge.js +1 -0
- package/src/command-ui.js +1 -0
- package/src/commands.js +1 -1
- package/src/compaction.js +1 -1
- package/src/computer.js +1 -1
- package/src/config.js +1 -1
- package/src/consult.js +1 -1
- package/src/context-report.js +1 -1
- package/src/context-window.js +1 -0
- package/src/context.js +1 -1
- package/src/crew.js +1 -0
- package/src/crush.js +1 -0
- package/src/daemon.js +1 -1
- package/src/decisions.js +1 -1
- package/src/depaudit.js +1 -0
- package/src/device.js +1 -1
- package/src/diagram-draw.js +1 -0
- package/src/diagram-layouts.js +1 -0
- package/src/diagram-raster.js +1 -0
- package/src/diagram.js +1 -0
- package/src/dictation.js +1 -0
- package/src/diffview.js +1 -1
- package/src/doctor.js +1 -1
- package/src/docx-tools.js +1 -0
- package/src/errkind.js +1 -1
- package/src/evidence.js +1 -0
- package/src/executors.js +1 -1
- package/src/expert-catalog.js +1 -1
- package/src/experts-store.js +1 -1
- package/src/experts.js +1 -1
- package/src/external-agents.js +1 -0
- package/src/forks.js +1 -1
- package/src/fs-observe.js +1 -0
- package/src/gitflow.js +1 -1
- package/src/goalround.js +1 -0
- package/src/hooks.js +1 -1
- package/src/input.js +1 -1
- package/src/insights.js +1 -1
- package/src/jobs.js +1 -0
- package/src/kbsync.js +1 -1
- package/src/lsp.js +1 -0
- package/src/mcp-apps.js +1 -0
- package/src/mcp.js +1 -1
- package/src/memory.js +1 -1
- package/src/notify.js +1 -1
- package/src/permissions.js +1 -1
- package/src/plan-limits.js +1 -0
- package/src/policy.js +1 -0
- package/src/pptx-tools.js +1 -0
- package/src/presence.js +1 -1
- package/src/privatedir.js +1 -0
- package/src/pyruntime.js +1 -1
- package/src/radar.js +1 -0
- package/src/render.js +1 -1
- package/src/repl.js +1 -1
- package/src/report-tools.js +1 -0
- package/src/routing.js +1 -0
- package/src/sandbox-probe.js +1 -0
- package/src/sandbox-win-launcher.mjs +156 -0
- package/src/sandbox.js +1 -0
- package/src/scan.js +1 -1
- package/src/schedule.js +1 -1
- package/src/scopecreep.js +1 -0
- package/src/sdk.js +1 -0
- package/src/selection.js +1 -0
- package/src/session-events.js +1 -0
- package/src/session.js +1 -1
- package/src/sessionindex.js +1 -0
- package/src/sessionlog.js +1 -0
- package/src/skill-catalog.js +1 -0
- package/src/skill-lint.js +1 -0
- package/src/skill-optimize.js +1 -0
- package/src/skill-scan.js +1 -0
- package/src/skills.js +1 -1
- package/src/spill.js +1 -0
- package/src/squad.js +1 -1
- package/src/subagent.js +1 -1
- package/src/surface-state.js +1 -0
- package/src/tapd.js +1 -0
- package/src/terminal-select.js +1 -0
- package/src/terminal.js +1 -0
- package/src/test-runner.js +1 -0
- package/src/todo.js +1 -1
- package/src/toolpipe.js +1 -0
- package/src/tools.js +1 -1
- package/src/toon.js +1 -0
- package/src/transcript.js +1 -1
- package/src/tui/app.js +1 -1
- package/src/tui/command-menu.js +1 -0
- package/src/tui/progress.js +1 -0
- package/src/tui/scroll.js +1 -0
- package/src/tui/welcome.js +1 -0
- package/src/tui/workbench.js +1 -0
- package/src/ui-spec.js +1 -0
- package/src/uitree.js +1 -0
- package/src/uninstall.js +1 -1
- package/src/visual.js +1 -1
- package/src/voice-form.js +1 -0
- package/src/voice.js +1 -0
- package/src/watch.js +1 -1
- package/src/web-audit.js +1 -0
- package/src/web-cdp.js +1 -0
- package/src/web-inspect.js +1 -0
- package/src/websearch.js +1 -0
- package/src/weekly.js +1 -0
- package/src/workflow.js +1 -0
- package/src/worklog.js +1 -0
- package/src/xlsx-tools.js +1 -0
- package/ui/command-picker.js +79 -0
- package/skills/ab-testing/scripts/__pycache__/ab_test.cpython-312.pyc +0 -0
- package/skills/accounting-ops/scripts/__pycache__/acct_tools.cpython-312.pyc +0 -0
- package/skills/anomaly-detection/scripts/__pycache__/anomaly.cpython-312.pyc +0 -0
- package/skills/audit-assist/scripts/__pycache__/audit_sampling.cpython-312.pyc +0 -0
- package/skills/banking-credit/scripts/__pycache__/credit_metrics.cpython-312.pyc +0 -0
- package/skills/data-report-pro/scripts/__pycache__/bigdata.cpython-312.pyc +0 -0
- package/skills/data-wrangling/scripts/__pycache__/join_tools.cpython-312.pyc +0 -0
- package/skills/ecommerce-ops/scripts/__pycache__/gmv_decompose.cpython-312.pyc +0 -0
- package/skills/funnel-cohort/scripts/__pycache__/funnel.cpython-312.pyc +0 -0
- package/skills/management-accounting/scripts/__pycache__/costing.cpython-312.pyc +0 -0
- package/skills/manufacturing-quality/scripts/__pycache__/spc.cpython-312.pyc +0 -0
- package/skills/marketing-analytics/scripts/__pycache__/rfm.cpython-312.pyc +0 -0
- package/skills/optimization-solver/scripts/__pycache__/solver.cpython-312.pyc +0 -0
- package/skills/pivot-crosstab/scripts/__pycache__/pivot_tools.cpython-312.pyc +0 -0
- package/skills/portfolio-analysis/scripts/__pycache__/portfolio.cpython-312.pyc +0 -0
- package/skills/procurement-supply/scripts/__pycache__/supply.cpython-312.pyc +0 -0
- package/skills/production-planning/scripts/__pycache__/mrp_capacity.cpython-312.pyc +0 -0
- package/skills/project-management/scripts/__pycache__/cpm.cpython-312.pyc +0 -0
- package/skills/quant-analysis/scripts/__pycache__/quant.cpython-312.pyc +0 -0
- package/skills/real-estate-property/scripts/__pycache__/property_calc.cpython-312.pyc +0 -0
- package/skills/regression-forecast/scripts/__pycache__/regress.cpython-312.pyc +0 -0
- package/skills/securities-research/scripts/__pycache__/valuation.cpython-312.pyc +0 -0
- package/skills/statistical-testing/scripts/__pycache__/stat_tests.cpython-312.pyc +0 -0
- package/skills/treasury-mgmt/scripts/__pycache__/cashflow_forecast.cpython-312.pyc +0 -0
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
# PPT 版式体检(对任意 .pptx 都能跑,不限于本技能生成的)。
|
|
2
|
+
#
|
|
3
|
+
# 解决的真问题:幻灯片是"盲写"的——python-pptx 把文字塞进一个固定大小的框,
|
|
4
|
+
# 塞不塞得下没人知道;图片给了 width 又给 height 就被悄悄拉成长脸;
|
|
5
|
+
# 两个框坐标算错就直接压在一起。这些在保存那一刻全都不报错,
|
|
6
|
+
# 等人打开 PPT 才看见,而那时模型早就说"已完成"了。
|
|
7
|
+
#
|
|
8
|
+
# 本模块把这些做成确定性检查:逐页遍历形状 → 还原几何与文字度量 →
|
|
9
|
+
# 判溢出/重叠/变形/越界/字号/对比度/表格/内容密度/跨页一致性,
|
|
10
|
+
# 每条都带页码、元素名与具体数字(估算高 4.62" vs 可用高 3.90"),
|
|
11
|
+
# 而不是一句"排版有问题"。
|
|
12
|
+
#
|
|
13
|
+
# 用法:
|
|
14
|
+
# python pptx_check.py 汇报.pptx # 人读报告
|
|
15
|
+
# python pptx_check.py 汇报.pptx --json # 机读
|
|
16
|
+
# python pptx_check.py 汇报.pptx --fix # 自动修可安全修的,另存 -已修复.pptx
|
|
17
|
+
# python pptx_check.py 汇报.pptx --fix --inplace
|
|
18
|
+
# 或在 run_python 里:from pptx_check import inspect, report, fix
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
25
|
+
|
|
26
|
+
from pptx import Presentation # noqa: E402
|
|
27
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE # noqa: E402
|
|
28
|
+
from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, PP_ALIGN # noqa: E402
|
|
29
|
+
from pptx.oxml.ns import qn # noqa: E402
|
|
30
|
+
from pptx.util import Emu, Pt # noqa: E402
|
|
31
|
+
|
|
32
|
+
import pptx_layout as L # noqa: E402
|
|
33
|
+
|
|
34
|
+
# ── 阈值(集中在这里,便于按场合调紧调松)────────────────────────────────────
|
|
35
|
+
SAFE_MARGIN = 0.22 # 安全边距(英寸):内容离页边小于它就算贴边
|
|
36
|
+
OVERLAP_MIN = 0.06 # 重叠面积占比低于它视为误差,不报
|
|
37
|
+
DISTORT_WARN = 0.02 # 图片宽高比偏差 >2% 提醒
|
|
38
|
+
DISTORT_ERR = 0.06 # >6% 判为明显变形
|
|
39
|
+
MIN_DPI = 96 # 图片渲染分辨率下限(低于此贴出来是糊的)
|
|
40
|
+
FONT_ERR = 10.0 # 字号下限(磅):低于此投影上根本看不清
|
|
41
|
+
FONT_WARN = 14.0
|
|
42
|
+
CONTRAST_ERR = 3.0
|
|
43
|
+
CONTRAST_WARN = 4.5
|
|
44
|
+
MAX_BULLETS = 6
|
|
45
|
+
MAX_BULLET_CHARS = 25
|
|
46
|
+
MAX_SLIDE_CHARS = 220
|
|
47
|
+
MAX_TITLE_CHARS = 24
|
|
48
|
+
TITLE_JITTER = 0.06 # 各页标题基线抖动上限(英寸)
|
|
49
|
+
|
|
50
|
+
LEVEL_ORDER = {'error': 0, 'warn': 1, 'info': 2}
|
|
51
|
+
LEVEL_CN = {'error': '必修', 'warn': '建议', 'info': '提示'}
|
|
52
|
+
|
|
53
|
+
CODE_CN = {
|
|
54
|
+
'overflow_v': '文字溢出',
|
|
55
|
+
'overflow_h': '横向超宽',
|
|
56
|
+
'out_of_slide': '超出页面',
|
|
57
|
+
'off_grid': '贴边过近',
|
|
58
|
+
'overlap': '元素重叠',
|
|
59
|
+
'image_distort': '图片变形',
|
|
60
|
+
'image_blurry': '图片发虚',
|
|
61
|
+
'table_overflow': '表格超界',
|
|
62
|
+
'tiny_shape': '元素过小',
|
|
63
|
+
'font_too_small': '字号过小',
|
|
64
|
+
'no_ea_font': '中文字体未设',
|
|
65
|
+
'font_zoo': '字体过杂',
|
|
66
|
+
'low_contrast': '对比度不足',
|
|
67
|
+
'autofit_shrunk': '被迫缩字',
|
|
68
|
+
'bullet_overload': '要点过多',
|
|
69
|
+
'bullet_too_long': '要点过长',
|
|
70
|
+
'title_too_long': '标题过长',
|
|
71
|
+
'text_wall': '文字过密',
|
|
72
|
+
'empty_slide': '空白页',
|
|
73
|
+
'dup_title': '标题重复',
|
|
74
|
+
'title_jitter': '标题错位',
|
|
75
|
+
'palette_zoo': '配色过杂',
|
|
76
|
+
'size_odd': '页面比例',
|
|
77
|
+
}
|
|
78
|
+
FIXABLE = {'image_distort', 'out_of_slide', 'off_grid', 'overflow_v', 'table_overflow'}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Finding(dict):
|
|
82
|
+
def __init__(self, page, code, level, detail, hint='', where='', rect=None, fixable=None):
|
|
83
|
+
super().__init__(
|
|
84
|
+
page=page, code=code, level=level, name=CODE_CN.get(code, code),
|
|
85
|
+
detail=detail, hint=hint, where=where,
|
|
86
|
+
fixable=(code in FIXABLE) if fixable is None else bool(fixable),
|
|
87
|
+
rect=None if rect is None else [round(rect.x, 2), round(rect.y, 2), round(rect.w, 2), round(rect.h, 2)],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── 几何还原 ────────────────────────────────────────────────────────────────
|
|
92
|
+
def _in(v):
|
|
93
|
+
return None if v is None else v / L.EMU_IN
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _rect_of(sh, inherit=None):
|
|
97
|
+
"""形状矩形(英寸)。占位符缺尺寸时回退到版式里同 idx 的占位符。"""
|
|
98
|
+
x, y, w, h = sh.left, sh.top, sh.width, sh.height
|
|
99
|
+
if None in (x, y, w, h) and inherit is not None:
|
|
100
|
+
x = x if x is not None else inherit.left
|
|
101
|
+
y = y if y is not None else inherit.top
|
|
102
|
+
w = w if w is not None else inherit.width
|
|
103
|
+
h = h if h is not None else inherit.height
|
|
104
|
+
if None in (x, y, w, h):
|
|
105
|
+
return None
|
|
106
|
+
return L.Rect(_in(x), _in(y), _in(w), _in(h))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _rot_aabb(r, deg):
|
|
110
|
+
"""旋转后的外接矩形(重叠判定用;0/180 度原样返回)。"""
|
|
111
|
+
d = float(deg or 0) % 360
|
|
112
|
+
if d < 0.01 or abs(d - 180) < 0.01:
|
|
113
|
+
return r
|
|
114
|
+
import math
|
|
115
|
+
a = math.radians(d)
|
|
116
|
+
cx, cy = r.x + r.w / 2, r.y + r.h / 2
|
|
117
|
+
hw = (abs(r.w * math.cos(a)) + abs(r.h * math.sin(a))) / 2
|
|
118
|
+
hh = (abs(r.w * math.sin(a)) + abs(r.h * math.cos(a))) / 2
|
|
119
|
+
return L.Rect(cx - hw, cy - hh, hw * 2, hh * 2)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _group_xf(grp):
|
|
123
|
+
"""组合的子坐标变换(chOff/chExt → off/ext)。取不到返回恒等。"""
|
|
124
|
+
try:
|
|
125
|
+
xfrm = grp._element.find(qn('p:grpSpPr')).find(qn('a:xfrm'))
|
|
126
|
+
off, ext = xfrm.find(qn('a:off')), xfrm.find(qn('a:ext'))
|
|
127
|
+
ch_off, ch_ext = xfrm.find(qn('a:chOff')), xfrm.find(qn('a:chExt'))
|
|
128
|
+
ox, oy = int(off.get('x')), int(off.get('y'))
|
|
129
|
+
cx, cy = int(ext.get('cx')), int(ext.get('cy'))
|
|
130
|
+
gx, gy = int(ch_off.get('x')), int(ch_off.get('y'))
|
|
131
|
+
gw, gh = int(ch_ext.get('cx')), int(ch_ext.get('cy'))
|
|
132
|
+
sx = cx / gw if gw else 1.0
|
|
133
|
+
sy = cy / gh if gh else 1.0
|
|
134
|
+
return (ox / L.EMU_IN, oy / L.EMU_IN, gx / L.EMU_IN, gy / L.EMU_IN, sx, sy)
|
|
135
|
+
except Exception:
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _layout_ph(slide, sh):
|
|
140
|
+
"""占位符在版式里的对应形状(继承尺寸用)。"""
|
|
141
|
+
try:
|
|
142
|
+
if not sh.is_placeholder:
|
|
143
|
+
return None
|
|
144
|
+
idx = sh.placeholder_format.idx
|
|
145
|
+
for p in slide.slide_layout.placeholders:
|
|
146
|
+
if p.placeholder_format.idx == idx:
|
|
147
|
+
return p
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def walk(slide):
|
|
154
|
+
"""展开一页的所有形状(组合按变换递归展开),按 z 序返回 [(shape, Rect, z)]。"""
|
|
155
|
+
out = []
|
|
156
|
+
|
|
157
|
+
def rec(container, xf, depth):
|
|
158
|
+
for sh in container.shapes:
|
|
159
|
+
if sh.shape_type == MSO_SHAPE_TYPE.GROUP:
|
|
160
|
+
sub = _group_xf(sh)
|
|
161
|
+
out.append((sh, _apply(_rect_of(sh), xf), len(out), True))
|
|
162
|
+
if sub and depth < 4:
|
|
163
|
+
rec(sh, _compose(xf, sub), depth + 1)
|
|
164
|
+
continue
|
|
165
|
+
r = _rect_of(sh, _layout_ph(slide, sh))
|
|
166
|
+
if r is None:
|
|
167
|
+
continue
|
|
168
|
+
out.append((sh, _apply(r, xf), len(out), False))
|
|
169
|
+
|
|
170
|
+
def _apply(r, xf):
|
|
171
|
+
if r is None or xf is None:
|
|
172
|
+
return r
|
|
173
|
+
ox, oy, gx, gy, sx, sy = xf
|
|
174
|
+
return L.Rect(ox + (r.x - gx) * sx, oy + (r.y - gy) * sy, r.w * sx, r.h * sy)
|
|
175
|
+
|
|
176
|
+
def _compose(outer, inner):
|
|
177
|
+
if outer is None:
|
|
178
|
+
return inner
|
|
179
|
+
ox, oy, gx, gy, sx, sy = outer
|
|
180
|
+
ix, iy, igx, igy, isx, isy = inner
|
|
181
|
+
nx = ox + (ix - gx) * sx
|
|
182
|
+
ny = oy + (iy - gy) * sy
|
|
183
|
+
return (nx, ny, igx, igy, isx * sx, isy * sy)
|
|
184
|
+
|
|
185
|
+
rec(slide, None, 0)
|
|
186
|
+
return [(sh, r, z) for sh, r, z, is_grp in out if r is not None and not is_grp]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ── 文字度量还原 ────────────────────────────────────────────────────────────
|
|
190
|
+
def _run_size(run, para, sh):
|
|
191
|
+
if run.font.size is not None:
|
|
192
|
+
return run.font.size.pt
|
|
193
|
+
try:
|
|
194
|
+
if para.font.size is not None:
|
|
195
|
+
return para.font.size.pt
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
# 占位符按类型给经验默认;普通文本框 PowerPoint 默认 18pt
|
|
199
|
+
try:
|
|
200
|
+
if sh.is_placeholder and sh.placeholder_format.idx == 0:
|
|
201
|
+
return 44.0
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
return 18.0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _bodypr(tf):
|
|
208
|
+
try:
|
|
209
|
+
return tf._txBody.find(qn('a:bodyPr'))
|
|
210
|
+
except Exception:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _insets(tf):
|
|
215
|
+
"""文本框内边距(磅):左右、上下。"""
|
|
216
|
+
bp = _bodypr(tf)
|
|
217
|
+
lr, tb = L.INSET_LR, L.INSET_TB
|
|
218
|
+
if bp is not None:
|
|
219
|
+
try:
|
|
220
|
+
li = bp.get('lIns')
|
|
221
|
+
ri = bp.get('rIns')
|
|
222
|
+
ti = bp.get('tIns')
|
|
223
|
+
bi = bp.get('bIns')
|
|
224
|
+
lv = int(li) / L.EMU_PT if li is not None else L.INSET_LR
|
|
225
|
+
rv = int(ri) / L.EMU_PT if ri is not None else L.INSET_LR
|
|
226
|
+
tv = int(ti) / L.EMU_PT if ti is not None else L.INSET_TB
|
|
227
|
+
bv = int(bi) / L.EMU_PT if bi is not None else L.INSET_TB
|
|
228
|
+
lr, tb = (lv + rv) / 2, (tv + bv) / 2
|
|
229
|
+
except Exception:
|
|
230
|
+
pass
|
|
231
|
+
return lr, tb
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _autofit(tf):
|
|
235
|
+
"""PowerPoint 自动缩放:返回 (字号比例, 行距压缩)。none/spAutoFit 返回 (1,0)。"""
|
|
236
|
+
bp = _bodypr(tf)
|
|
237
|
+
if bp is None:
|
|
238
|
+
return 1.0, 0.0
|
|
239
|
+
na = bp.find(qn('a:normAutofit'))
|
|
240
|
+
if na is None:
|
|
241
|
+
return 1.0, 0.0
|
|
242
|
+
fs = na.get('fontScale')
|
|
243
|
+
lr = na.get('lnSpcReduction')
|
|
244
|
+
return (int(fs) / 100000.0 if fs else 1.0), (int(lr) / 100000.0 if lr else 0.0)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _word_wrap(tf):
|
|
248
|
+
bp = _bodypr(tf)
|
|
249
|
+
if bp is not None and bp.get('wrap') == 'none':
|
|
250
|
+
return False
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _para_indent(p):
|
|
255
|
+
"""段落左缩进(磅):优先读 pPr@marL(建版侧写入的挂起缩进),否则按层级估算。"""
|
|
256
|
+
try:
|
|
257
|
+
pPr = p._p.find(qn('a:pPr'))
|
|
258
|
+
if pPr is not None and pPr.get('marL') is not None:
|
|
259
|
+
return int(pPr.get('marL')) / L.EMU_PT
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
return (p.level or 0) * 0.28 * L.PT_IN
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def para_lines(sh, rect, tf=None):
|
|
266
|
+
"""还原一个文本框的排版:返回 (总高磅, 最大行宽磅, 可用宽磅, 可用高磅, 段落明细)。"""
|
|
267
|
+
tf = tf or sh.text_frame
|
|
268
|
+
lr, tb = _insets(tf)
|
|
269
|
+
scale, ln_red = _autofit(tf)
|
|
270
|
+
avail_w = rect.w * L.PT_IN - 2 * lr
|
|
271
|
+
avail_h = rect.h * L.PT_IN - 2 * tb
|
|
272
|
+
total = 0.0
|
|
273
|
+
max_w = 0.0
|
|
274
|
+
detail = []
|
|
275
|
+
for p in tf.paragraphs:
|
|
276
|
+
segs = []
|
|
277
|
+
for r in p.runs:
|
|
278
|
+
if r.text:
|
|
279
|
+
segs.append((r.text, _run_size(r, p, sh) * scale, bool(r.font.bold)))
|
|
280
|
+
if not segs:
|
|
281
|
+
# 空段落也占一行高
|
|
282
|
+
sz = 18.0
|
|
283
|
+
try:
|
|
284
|
+
if p.font.size is not None:
|
|
285
|
+
sz = p.font.size.pt
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
total += sz * scale * L.LINE_H
|
|
289
|
+
detail.append(dict(text='', lines=1, size=sz))
|
|
290
|
+
continue
|
|
291
|
+
indent = _para_indent(p)
|
|
292
|
+
w = max(10.0, avail_w - indent)
|
|
293
|
+
lines = L.wrap_segments(segs, w) if _word_wrap(tf) else [(''.join(s[0] for s in segs), max(s[1] for s in segs))]
|
|
294
|
+
ls = 1.0
|
|
295
|
+
try:
|
|
296
|
+
if p.line_spacing is not None:
|
|
297
|
+
ls = float(p.line_spacing) if isinstance(p.line_spacing, (int, float)) else p.line_spacing.pt / max(1.0, max(s[1] for s in segs)) / L.LINE_H
|
|
298
|
+
except Exception:
|
|
299
|
+
ls = 1.0
|
|
300
|
+
ls = max(0.5, ls * (1 - ln_red))
|
|
301
|
+
h = L.text_height(lines, ls)
|
|
302
|
+
try:
|
|
303
|
+
if p.space_before is not None:
|
|
304
|
+
h += p.space_before.pt
|
|
305
|
+
if p.space_after is not None:
|
|
306
|
+
h += p.space_after.pt
|
|
307
|
+
except Exception:
|
|
308
|
+
pass
|
|
309
|
+
total += h
|
|
310
|
+
for txt, sz in lines:
|
|
311
|
+
max_w = max(max_w, L.measure(txt, sz) + indent)
|
|
312
|
+
detail.append(dict(text=''.join(s[0] for s in segs), lines=len(lines),
|
|
313
|
+
size=max(s[1] for s in segs)))
|
|
314
|
+
return total, max_w, avail_w, avail_h, detail
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def ink_box(sh, rect):
|
|
318
|
+
"""文字实际占据的区域(不是文本框本身)。
|
|
319
|
+
|
|
320
|
+
重叠判定必须用它:真实 PPT 里大量文本框远大于文字本身,
|
|
321
|
+
按框判会把"两个空框挨着"报成重叠,一屏假阳性之后没人再看报告。
|
|
322
|
+
"""
|
|
323
|
+
try:
|
|
324
|
+
tf = sh.text_frame
|
|
325
|
+
except Exception:
|
|
326
|
+
return rect
|
|
327
|
+
if not (sh.has_text_frame and tf.text.strip()):
|
|
328
|
+
return rect
|
|
329
|
+
total, max_w, avail_w, avail_h, _ = para_lines(sh, rect, tf)
|
|
330
|
+
lr, tb = _insets(tf)
|
|
331
|
+
w = min(rect.w, (min(max_w, avail_w) + 2 * lr) / L.PT_IN)
|
|
332
|
+
h = min(rect.h, (total + 2 * tb) / L.PT_IN)
|
|
333
|
+
# 水平锚点
|
|
334
|
+
al = None
|
|
335
|
+
for p in tf.paragraphs:
|
|
336
|
+
if p.alignment is not None:
|
|
337
|
+
al = p.alignment
|
|
338
|
+
break
|
|
339
|
+
if al == PP_ALIGN.CENTER:
|
|
340
|
+
x = rect.x + (rect.w - w) / 2
|
|
341
|
+
elif al == PP_ALIGN.RIGHT:
|
|
342
|
+
x = rect.right - w
|
|
343
|
+
else:
|
|
344
|
+
x = rect.x
|
|
345
|
+
# 垂直锚点
|
|
346
|
+
anc = None
|
|
347
|
+
try:
|
|
348
|
+
anc = tf.vertical_anchor
|
|
349
|
+
except Exception:
|
|
350
|
+
pass
|
|
351
|
+
if anc == MSO_ANCHOR.MIDDLE:
|
|
352
|
+
y = rect.y + (rect.h - h) / 2
|
|
353
|
+
elif anc == MSO_ANCHOR.BOTTOM:
|
|
354
|
+
y = rect.bottom - h
|
|
355
|
+
else:
|
|
356
|
+
y = rect.y
|
|
357
|
+
return L.Rect(x, y, max(w, 0.02), max(h, 0.02))
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ── 颜色还原 ────────────────────────────────────────────────────────────────
|
|
361
|
+
def _theme_map(prs):
|
|
362
|
+
"""主题配色 clrScheme → {'dk1':'000000', 'accent1':'4472C4', ...}"""
|
|
363
|
+
out = {}
|
|
364
|
+
try:
|
|
365
|
+
for part in prs.part.package.iter_parts():
|
|
366
|
+
if 'theme' not in str(part.partname):
|
|
367
|
+
continue
|
|
368
|
+
root = part._element if hasattr(part, '_element') else None
|
|
369
|
+
if root is None:
|
|
370
|
+
from lxml import etree
|
|
371
|
+
root = etree.fromstring(part.blob)
|
|
372
|
+
scheme = root.find('.//' + qn('a:clrScheme'))
|
|
373
|
+
if scheme is None:
|
|
374
|
+
continue
|
|
375
|
+
for child in scheme:
|
|
376
|
+
key = child.tag.split('}')[-1]
|
|
377
|
+
srgb = child.find(qn('a:srgbClr'))
|
|
378
|
+
if srgb is not None:
|
|
379
|
+
out[key] = srgb.get('val')
|
|
380
|
+
continue
|
|
381
|
+
sysc = child.find(qn('a:sysClr'))
|
|
382
|
+
if sysc is not None:
|
|
383
|
+
out[key] = sysc.get('lastClr') or ('000000' if 'Text' in (sysc.get('val') or '') else 'FFFFFF')
|
|
384
|
+
break
|
|
385
|
+
except Exception:
|
|
386
|
+
pass
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
_THEME_ALIAS = {
|
|
391
|
+
'TEXT_1': 'dk1', 'BACKGROUND_1': 'lt1', 'TEXT_2': 'dk2', 'BACKGROUND_2': 'lt2',
|
|
392
|
+
'DARK_1': 'dk1', 'LIGHT_1': 'lt1', 'DARK_2': 'dk2', 'LIGHT_2': 'lt2',
|
|
393
|
+
'ACCENT_1': 'accent1', 'ACCENT_2': 'accent2', 'ACCENT_3': 'accent3',
|
|
394
|
+
'ACCENT_4': 'accent4', 'ACCENT_5': 'accent5', 'ACCENT_6': 'accent6',
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _color_hex(color, theme):
|
|
399
|
+
"""ColorFormat → 'RRGGBB';解析不出返回 None(宁可跳过检查也不猜)。"""
|
|
400
|
+
if color is None:
|
|
401
|
+
return None
|
|
402
|
+
try:
|
|
403
|
+
if color.type is None:
|
|
404
|
+
return None
|
|
405
|
+
if str(color.type).startswith('RGB') or color.type == 1:
|
|
406
|
+
return str(color.rgb)
|
|
407
|
+
except Exception:
|
|
408
|
+
return None
|
|
409
|
+
try:
|
|
410
|
+
name = str(color.theme_color).split('.')[-1].split(' ')[0]
|
|
411
|
+
base = theme.get(_THEME_ALIAS.get(name, ''), None)
|
|
412
|
+
if not base:
|
|
413
|
+
return None
|
|
414
|
+
try:
|
|
415
|
+
b = color.brightness
|
|
416
|
+
except Exception:
|
|
417
|
+
b = 0
|
|
418
|
+
rgb = L.hex_rgb(base)
|
|
419
|
+
if rgb and b:
|
|
420
|
+
if b > 0:
|
|
421
|
+
rgb = tuple(int(c + (255 - c) * b) for c in rgb)
|
|
422
|
+
else:
|
|
423
|
+
rgb = tuple(int(c * (1 + b)) for c in rgb)
|
|
424
|
+
return '%02X%02X%02X' % rgb
|
|
425
|
+
return base
|
|
426
|
+
except Exception:
|
|
427
|
+
return None
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _fill_hex(sh, theme):
|
|
431
|
+
"""形状填充色(纯色/渐变首停靠色);无填充返回 None。"""
|
|
432
|
+
try:
|
|
433
|
+
f = sh.fill
|
|
434
|
+
t = str(f.type)
|
|
435
|
+
if 'SOLID' in t or f.type == 1:
|
|
436
|
+
return _color_hex(f.fore_color, theme)
|
|
437
|
+
if 'GRADIENT' in t:
|
|
438
|
+
try:
|
|
439
|
+
return _color_hex(f.gradient_stops[0].color, theme)
|
|
440
|
+
except Exception:
|
|
441
|
+
return None
|
|
442
|
+
except Exception:
|
|
443
|
+
pass
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ── 主检查 ──────────────────────────────────────────────────────────────────
|
|
448
|
+
def inspect(path, strict=False):
|
|
449
|
+
"""体检一份 pptx,返回 {ok, path, slides, size, findings, stats}。"""
|
|
450
|
+
prs = Presentation(path)
|
|
451
|
+
sw, sh_in = _in(prs.slide_width), _in(prs.slide_height)
|
|
452
|
+
page = L.Rect(0, 0, sw, sh_in)
|
|
453
|
+
theme = _theme_map(prs)
|
|
454
|
+
findings = []
|
|
455
|
+
titles = []
|
|
456
|
+
title_tops = []
|
|
457
|
+
fonts = set()
|
|
458
|
+
fills = set()
|
|
459
|
+
stats = dict(shapes=0, texts=0, pictures=0, tables=0, charts=0, chars=0)
|
|
460
|
+
|
|
461
|
+
for pno, slide in enumerate(prs.slides, 1):
|
|
462
|
+
items = walk(slide)
|
|
463
|
+
stats['shapes'] += len(items)
|
|
464
|
+
boxes = [] # (shape, rect, ink, kind, z)
|
|
465
|
+
slide_chars = 0
|
|
466
|
+
bullet_count = 0
|
|
467
|
+
slide_title = ''
|
|
468
|
+
|
|
469
|
+
for sh, rect, z in items:
|
|
470
|
+
kind = 'shape'
|
|
471
|
+
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
472
|
+
kind = 'pic'
|
|
473
|
+
stats['pictures'] += 1
|
|
474
|
+
elif getattr(sh, 'has_table', False):
|
|
475
|
+
kind = 'table'
|
|
476
|
+
stats['tables'] += 1
|
|
477
|
+
elif getattr(sh, 'has_chart', False):
|
|
478
|
+
kind = 'chart'
|
|
479
|
+
stats['charts'] += 1
|
|
480
|
+
elif sh.has_text_frame and sh.text_frame.text.strip():
|
|
481
|
+
kind = 'text'
|
|
482
|
+
stats['texts'] += 1
|
|
483
|
+
|
|
484
|
+
label = _label(sh, kind)
|
|
485
|
+
arect = _rot_aabb(rect, getattr(sh, 'rotation', 0))
|
|
486
|
+
|
|
487
|
+
# ① 越界 / 贴边
|
|
488
|
+
if arect.w > 0.02 and arect.h > 0.02:
|
|
489
|
+
out_l = max(0.0, page.x - arect.x)
|
|
490
|
+
out_t = max(0.0, page.y - arect.y)
|
|
491
|
+
out_r = max(0.0, arect.right - page.right)
|
|
492
|
+
out_b = max(0.0, arect.bottom - page.bottom)
|
|
493
|
+
worst = max(out_l, out_t, out_r, out_b)
|
|
494
|
+
full_bleed = kind not in ('text', 'table') and arect.w >= sw * 0.98 and arect.h >= sh_in * 0.98
|
|
495
|
+
if worst > 0.02 and not full_bleed:
|
|
496
|
+
findings.append(Finding(
|
|
497
|
+
pno, 'out_of_slide', 'error' if worst > 0.12 or kind in ('text', 'table') else 'warn',
|
|
498
|
+
f'{label} 超出页面 {worst:.2f}"('
|
|
499
|
+
+ '、'.join(x for x in [
|
|
500
|
+
f'左溢 {out_l:.2f}"' if out_l > 0.02 else '',
|
|
501
|
+
f'上溢 {out_t:.2f}"' if out_t > 0.02 else '',
|
|
502
|
+
f'右溢 {out_r:.2f}"' if out_r > 0.02 else '',
|
|
503
|
+
f'下溢 {out_b:.2f}"' if out_b > 0.02 else '',
|
|
504
|
+
] if x) + ')',
|
|
505
|
+
'把元素移回页内,或等比缩小', label, rect))
|
|
506
|
+
elif worst <= 0.02 and kind in ('text', 'pic', 'table', 'chart'):
|
|
507
|
+
near = min(arect.x - page.x, arect.y - page.y, page.right - arect.right, page.bottom - arect.bottom)
|
|
508
|
+
if near < SAFE_MARGIN and arect.w < sw * 0.98:
|
|
509
|
+
findings.append(Finding(
|
|
510
|
+
pno, 'off_grid', 'warn',
|
|
511
|
+
f'{label} 距页边仅 {max(0.0, near):.2f}"(安全边距 {SAFE_MARGIN}")',
|
|
512
|
+
'往内收,四边留白一致才显整齐', label, rect))
|
|
513
|
+
# 细长条是分隔线/色带(设计件),只有两边都小才是误建的碎片
|
|
514
|
+
if (arect.w < 0.08 and arect.h < 0.08) or (kind in ('text', 'pic', 'table', 'chart')
|
|
515
|
+
and (arect.w < 0.12 or arect.h < 0.1)):
|
|
516
|
+
findings.append(Finding(pno, 'tiny_shape', 'warn',
|
|
517
|
+
f'{label} 尺寸仅 {arect.w:.2f}"×{arect.h:.2f}",几乎不可见',
|
|
518
|
+
'删掉或给足尺寸', label, rect, fixable=False))
|
|
519
|
+
|
|
520
|
+
# ② 图片变形 / 发虚
|
|
521
|
+
if kind == 'pic':
|
|
522
|
+
findings.extend(_check_picture(pno, sh, rect, label))
|
|
523
|
+
|
|
524
|
+
# ③ 文字度量
|
|
525
|
+
ink = rect
|
|
526
|
+
if kind == 'text':
|
|
527
|
+
ink = ink_box(sh, rect)
|
|
528
|
+
findings.extend(_check_text(pno, sh, rect, ink, label, theme, boxes, page, strict))
|
|
529
|
+
txt = sh.text_frame.text
|
|
530
|
+
slide_chars += len(txt.replace('\n', ''))
|
|
531
|
+
stats['chars'] += len(txt.replace('\n', ''))
|
|
532
|
+
bullet_count += _bullet_paras(sh, rect, sh_in)
|
|
533
|
+
for p in sh.text_frame.paragraphs:
|
|
534
|
+
for r in p.runs:
|
|
535
|
+
if r.font.name:
|
|
536
|
+
fonts.add(r.font.name)
|
|
537
|
+
if not slide_title and _is_title(sh, rect, page):
|
|
538
|
+
slide_title = txt.strip().split('\n')[0]
|
|
539
|
+
# 满版底色的页(封面/章节页/结尾页)本来就是另一种构图,
|
|
540
|
+
# 把它们的标题拉进"各页标题该对齐"的比对里只会误报
|
|
541
|
+
if not _full_bleed_page(items, page):
|
|
542
|
+
title_tops.append((pno, round(rect.y, 3)))
|
|
543
|
+
|
|
544
|
+
# ④ 表格
|
|
545
|
+
if kind == 'table':
|
|
546
|
+
findings.extend(_check_table(pno, sh, rect, page, label))
|
|
547
|
+
|
|
548
|
+
f = _fill_hex(sh, theme)
|
|
549
|
+
if f:
|
|
550
|
+
fills.add(f)
|
|
551
|
+
boxes.append((sh, rect, ink, kind, z, label))
|
|
552
|
+
|
|
553
|
+
# ⑤ 重叠(页内两两比对,用 ink 而非框)
|
|
554
|
+
findings.extend(_check_overlap(pno, boxes))
|
|
555
|
+
|
|
556
|
+
# ⑥ 内容密度
|
|
557
|
+
content = [b for b in boxes if b[3] in ('text', 'pic', 'table', 'chart')]
|
|
558
|
+
if not content:
|
|
559
|
+
findings.append(Finding(pno, 'empty_slide', 'warn', '整页没有任何正文/图/表', '补内容或删页'))
|
|
560
|
+
if bullet_count > MAX_BULLETS + 1:
|
|
561
|
+
findings.append(Finding(pno, 'bullet_overload', 'warn',
|
|
562
|
+
f'本页 {bullet_count} 个段落/要点(建议 ≤{MAX_BULLETS})',
|
|
563
|
+
'一页一个观点,多的拆页或进备注'))
|
|
564
|
+
if slide_chars > MAX_SLIDE_CHARS:
|
|
565
|
+
findings.append(Finding(pno, 'text_wall', 'warn',
|
|
566
|
+
f'本页正文 {slide_chars} 字(建议 ≤{MAX_SLIDE_CHARS})',
|
|
567
|
+
'PPT 不是文档:留结论进正文,论证进备注'))
|
|
568
|
+
if slide_title:
|
|
569
|
+
titles.append((pno, slide_title))
|
|
570
|
+
if len(slide_title) > MAX_TITLE_CHARS:
|
|
571
|
+
findings.append(Finding(pno, 'title_too_long', 'warn',
|
|
572
|
+
f'标题 {len(slide_title)} 字:{slide_title[:30]}…',
|
|
573
|
+
f'压到 {MAX_TITLE_CHARS} 字内,长的挪到副标题'))
|
|
574
|
+
|
|
575
|
+
# ⑦ 跨页一致性
|
|
576
|
+
findings.extend(_check_consistency(titles, title_tops, fonts, fills, sw, sh_in))
|
|
577
|
+
|
|
578
|
+
order = sorted(findings, key=lambda f: (LEVEL_ORDER.get(f['level'], 3), f['page']))
|
|
579
|
+
return dict(
|
|
580
|
+
ok=not any(f['level'] == 'error' for f in order),
|
|
581
|
+
path=os.path.abspath(path),
|
|
582
|
+
slides=len(prs.slides),
|
|
583
|
+
size=f'{sw:.2f}"×{sh_in:.2f}"',
|
|
584
|
+
ratio=_ratio(sw, sh_in),
|
|
585
|
+
findings=order,
|
|
586
|
+
stats=stats,
|
|
587
|
+
counts={k: sum(1 for f in order if f['level'] == k) for k in ('error', 'warn', 'info')},
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _ratio(w, h):
|
|
592
|
+
r = w / h if h else 0
|
|
593
|
+
for name, v in (('16:9', 16 / 9), ('4:3', 4 / 3), ('16:10', 16 / 10), ('A4横', 297 / 210)):
|
|
594
|
+
if abs(r - v) < 0.02:
|
|
595
|
+
return name
|
|
596
|
+
return f'{r:.2f}:1'
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _label(sh, kind):
|
|
600
|
+
n = (sh.name or '').strip()
|
|
601
|
+
base = {'pic': '图片', 'table': '表格', 'chart': '图表', 'text': '文本', 'shape': '形状'}[kind]
|
|
602
|
+
if kind == 'text':
|
|
603
|
+
t = sh.text_frame.text.strip().split('\n')[0][:12]
|
|
604
|
+
return f'「{t}」' if t else base
|
|
605
|
+
return f'{base}({n[:16]})' if n and not n.startswith(('TextBox', 'Rectangle')) else base
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _is_title(sh, rect, page):
|
|
609
|
+
try:
|
|
610
|
+
if sh.is_placeholder and sh.placeholder_format.idx == 0:
|
|
611
|
+
return True
|
|
612
|
+
except Exception:
|
|
613
|
+
pass
|
|
614
|
+
if rect.y > page.h * 0.22:
|
|
615
|
+
return False
|
|
616
|
+
for p in sh.text_frame.paragraphs:
|
|
617
|
+
for r in p.runs:
|
|
618
|
+
if r.font.size and r.font.size.pt >= 24:
|
|
619
|
+
return True
|
|
620
|
+
return False
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _full_bleed_page(items, page):
|
|
624
|
+
"""整页铺底的构图(封面/章节/结尾)。"""
|
|
625
|
+
for sh, r, _z in items:
|
|
626
|
+
if r.w >= page.w * 0.95 and r.h >= page.h * 0.95 and sh.shape_type != MSO_SHAPE_TYPE.PICTURE:
|
|
627
|
+
try:
|
|
628
|
+
if sh.fill.type is not None and 'BACKGROUND' not in str(sh.fill.type):
|
|
629
|
+
return True
|
|
630
|
+
except Exception:
|
|
631
|
+
continue
|
|
632
|
+
return False
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _check_picture(pno, sh, rect, label):
|
|
636
|
+
out = []
|
|
637
|
+
try:
|
|
638
|
+
img = sh.image
|
|
639
|
+
nw, nh = img.size
|
|
640
|
+
except Exception:
|
|
641
|
+
return out
|
|
642
|
+
# 裁剪后的有效原始比例
|
|
643
|
+
cl = getattr(sh, 'crop_left', 0) or 0
|
|
644
|
+
cr = getattr(sh, 'crop_right', 0) or 0
|
|
645
|
+
ct = getattr(sh, 'crop_top', 0) or 0
|
|
646
|
+
cb = getattr(sh, 'crop_bottom', 0) or 0
|
|
647
|
+
ew = nw * max(0.01, 1 - cl - cr)
|
|
648
|
+
eh = nh * max(0.01, 1 - ct - cb)
|
|
649
|
+
if ew <= 0 or eh <= 0 or rect.w <= 0 or rect.h <= 0:
|
|
650
|
+
return out
|
|
651
|
+
nat = ew / eh
|
|
652
|
+
cur = rect.w / rect.h
|
|
653
|
+
dev = abs(cur / nat - 1)
|
|
654
|
+
if dev > DISTORT_WARN:
|
|
655
|
+
want_h = rect.w / nat
|
|
656
|
+
out.append(Finding(
|
|
657
|
+
pno, 'image_distort', 'error' if dev > DISTORT_ERR else 'warn',
|
|
658
|
+
f'{label} 被{"拉宽" if cur > nat else "压扁"} {dev * 100:.0f}%'
|
|
659
|
+
f'(当前 {cur:.2f}:1,原图 {nat:.2f}:1)',
|
|
660
|
+
f'保持原比例:宽 {rect.w:.2f}" 时高应为 {want_h:.2f}"(或只给 width/height 之一)',
|
|
661
|
+
label, rect))
|
|
662
|
+
dpi_x = ew / rect.w if rect.w else 999
|
|
663
|
+
dpi_y = eh / rect.h if rect.h else 999
|
|
664
|
+
dpi = min(dpi_x, dpi_y)
|
|
665
|
+
if dpi < MIN_DPI:
|
|
666
|
+
out.append(Finding(pno, 'image_blurry', 'warn',
|
|
667
|
+
f'{label} 渲染分辨率仅 {dpi:.0f} dpi(原图 {int(ew)}×{int(eh)}px,占 {rect.w:.2f}"×{rect.h:.2f}")',
|
|
668
|
+
'换高分辨率原图,或缩小显示尺寸', label, rect))
|
|
669
|
+
return out
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _check_text(pno, sh, rect, ink, label, theme, prior, page, strict):
|
|
673
|
+
out = []
|
|
674
|
+
tf = sh.text_frame
|
|
675
|
+
total, max_w, avail_w, avail_h, detail = para_lines(sh, rect, tf)
|
|
676
|
+
scale, _ = _autofit(tf)
|
|
677
|
+
|
|
678
|
+
# 纵向溢出。两种结局要分开判:
|
|
679
|
+
# · 普通框 → 文字被切掉(下半截看不见);
|
|
680
|
+
# · spAutoFit 框(python-pptx add_textbox 的默认!)→ 框自己长高,
|
|
681
|
+
# 不切字但会一路顶下去,超过页底就是纸面外,比切字更难发现。
|
|
682
|
+
if avail_h > 0 and total > avail_h * L.TOL:
|
|
683
|
+
over = total / avail_h - 1
|
|
684
|
+
auto_grow = False
|
|
685
|
+
try:
|
|
686
|
+
auto_grow = tf.auto_size == MSO_AUTO_SIZE.SHAPE_TO_FIT_TEXT
|
|
687
|
+
except Exception:
|
|
688
|
+
pass
|
|
689
|
+
need_h = (total + 2 * _insets(tf)[1]) / L.PT_IN
|
|
690
|
+
grown_bottom = rect.y + need_h
|
|
691
|
+
if auto_grow:
|
|
692
|
+
spill = grown_bottom - page.bottom
|
|
693
|
+
lvl = 'error' if spill > 0.02 else 'warn'
|
|
694
|
+
why = (f';框是「随文字增高」,会一路长到 y={grown_bottom:.2f}",'
|
|
695
|
+
f'{"顶出页底 %.2f\"(这部分永远看不到)" % spill if spill > 0.02 else "把下方元素挤开"}')
|
|
696
|
+
else:
|
|
697
|
+
lvl = 'error' if over >= 0.12 else 'warn'
|
|
698
|
+
why = ';超出部分会被直接切掉'
|
|
699
|
+
out.append(Finding(
|
|
700
|
+
pno, 'overflow_v', lvl,
|
|
701
|
+
f'{label} 文字装不下:估算需 {need_h:.2f}",框高仅 {rect.h:.2f}"(超 {over * 100:.0f}%,共 {sum(d["lines"] for d in detail)} 行)' + why,
|
|
702
|
+
'减少内容 / 拆页 / 调小字号 / 加高文本框(pptx_check --fix 可自动缩字或加高)', label, rect))
|
|
703
|
+
if scale < 0.92:
|
|
704
|
+
out.append(Finding(pno, 'autofit_shrunk', 'warn',
|
|
705
|
+
f'{label} 被 PowerPoint 自动缩到原字号的 {scale * 100:.0f}%(说明内容本来就塞不下)',
|
|
706
|
+
'内容拆页,别靠自动缩字硬塞', label, rect))
|
|
707
|
+
# 横向超宽(关了自动换行时)
|
|
708
|
+
if not _word_wrap(tf) and max_w > avail_w * L.TOL:
|
|
709
|
+
out.append(Finding(pno, 'overflow_h', 'error',
|
|
710
|
+
f'{label} 关闭了自动换行且单行宽 {max_w / L.PT_IN:.2f}" > 可用 {avail_w / L.PT_IN:.2f}"',
|
|
711
|
+
'打开 word_wrap 或加宽文本框', label, rect))
|
|
712
|
+
|
|
713
|
+
# 字号 / 东亚字体 / 对比度
|
|
714
|
+
bg = _bg_under(ink, prior) or 'FFFFFF'
|
|
715
|
+
seen_small = False
|
|
716
|
+
for p in tf.paragraphs:
|
|
717
|
+
for r in p.runs:
|
|
718
|
+
if not r.text.strip():
|
|
719
|
+
continue
|
|
720
|
+
size = _run_size(r, p, sh) * scale
|
|
721
|
+
if size < FONT_ERR and not seen_small:
|
|
722
|
+
seen_small = True
|
|
723
|
+
out.append(Finding(pno, 'font_too_small', 'error',
|
|
724
|
+
f'{label} 出现 {size:.0f}pt 文字(投影/远看不清)',
|
|
725
|
+
f'正文不低于 {FONT_WARN:.0f}pt,注释不低于 12pt', label, rect))
|
|
726
|
+
elif size < FONT_WARN and not seen_small and strict:
|
|
727
|
+
seen_small = True
|
|
728
|
+
out.append(Finding(pno, 'font_too_small', 'warn',
|
|
729
|
+
f'{label} 出现 {size:.0f}pt 文字', f'建议 ≥{FONT_WARN:.0f}pt', label, rect))
|
|
730
|
+
if _has_cjk(r.text) and r.font.name and not _has_ea(r):
|
|
731
|
+
out.append(Finding(pno, 'no_ea_font', 'warn',
|
|
732
|
+
f'{label} 中文未设东亚字体(只设了 {r.font.name})',
|
|
733
|
+
'同时设 <a:ea typeface>,否则中文回退到别的字体、实际行宽与预期不符', label, rect))
|
|
734
|
+
fg = _color_hex(r.font.color, theme)
|
|
735
|
+
c = L.contrast(L.hex_rgb(fg), L.hex_rgb(bg)) if fg else None
|
|
736
|
+
if c is not None:
|
|
737
|
+
lim = CONTRAST_ERR if size >= 24 else CONTRAST_WARN
|
|
738
|
+
if c < lim:
|
|
739
|
+
out.append(Finding(pno, 'low_contrast', 'error' if c < CONTRAST_ERR else 'warn',
|
|
740
|
+
f'{label} 文字 #{fg} 压在 #{bg} 上,对比度仅 {c:.1f}:1(需 ≥{lim})',
|
|
741
|
+
f'改用 #{L.readable_on(bg)} 一类的可读色', label, rect))
|
|
742
|
+
break
|
|
743
|
+
else:
|
|
744
|
+
continue
|
|
745
|
+
break
|
|
746
|
+
|
|
747
|
+
# 要点过长
|
|
748
|
+
for d in detail:
|
|
749
|
+
t = d['text'].strip().lstrip('•·-— ')
|
|
750
|
+
if len(t) > MAX_BULLET_CHARS and d['size'] < 28:
|
|
751
|
+
out.append(Finding(pno, 'bullet_too_long', 'warn',
|
|
752
|
+
f'要点 {len(t)} 字:{t[:24]}…',
|
|
753
|
+
f'一条要点压到 {MAX_BULLET_CHARS} 字内,展开的话进备注', label, rect))
|
|
754
|
+
break
|
|
755
|
+
return out
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _bullet_paras(sh, rect, page_h):
|
|
759
|
+
"""这个文本框算几条要点。
|
|
760
|
+
|
|
761
|
+
只认"真正的要点列表":同一个框里 ≥2 段有内容的正文。页码、页脚、KPI 卡片标签
|
|
762
|
+
都是单段短文本,把它们数进来会让"本页 15 个要点"这种告警天天误报,
|
|
763
|
+
而一个天天误报的检查等于没有检查。
|
|
764
|
+
"""
|
|
765
|
+
if rect.y > page_h - 0.62: # 页脚带
|
|
766
|
+
return 0
|
|
767
|
+
paras = [p for p in sh.text_frame.paragraphs if p.text.strip()]
|
|
768
|
+
if len(paras) < 2:
|
|
769
|
+
return 0
|
|
770
|
+
sizes = [r.font.size.pt for p in paras for r in p.runs if r.font.size]
|
|
771
|
+
if sizes and max(sizes) < 12: # 注释/脚注一类的小字块
|
|
772
|
+
return 0
|
|
773
|
+
return len(paras)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _has_cjk(s):
|
|
777
|
+
return any(0x2E80 <= ord(c) <= 0x9FFF or 0xF900 <= ord(c) <= 0xFAFF for c in s)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _has_ea(run):
|
|
781
|
+
try:
|
|
782
|
+
rPr = run._r.find(qn('a:rPr'))
|
|
783
|
+
return rPr is not None and rPr.find(qn('a:ea')) is not None
|
|
784
|
+
except Exception:
|
|
785
|
+
return False
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _bg_under(ink, prior):
|
|
789
|
+
"""ink 底下最靠上的一块纯色填充(z 序更小者在下);找不到返回 None。"""
|
|
790
|
+
best = None
|
|
791
|
+
for sh, rect, _ink, kind, z, _label in prior:
|
|
792
|
+
if kind == 'pic':
|
|
793
|
+
continue
|
|
794
|
+
if rect.contains(ink, 0.02) or rect.overlap_ratio(ink) > 0.6:
|
|
795
|
+
f = _fill_hex(sh, {})
|
|
796
|
+
if f:
|
|
797
|
+
best = f
|
|
798
|
+
return best
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _check_table(pno, sh, rect, page, label):
|
|
802
|
+
out = []
|
|
803
|
+
try:
|
|
804
|
+
tbl = sh.table
|
|
805
|
+
except Exception:
|
|
806
|
+
return out
|
|
807
|
+
cols = [c.width / L.EMU_IN for c in tbl.columns]
|
|
808
|
+
heights = _table_need(tbl, cols) # 与 _fix_table 同一把尺
|
|
809
|
+
need_h = sum(max(h, (row.height / L.EMU_IN) if row.height else 0) for h, row in zip(heights, tbl.rows))
|
|
810
|
+
for ri, row in enumerate(tbl.rows):
|
|
811
|
+
for ci, cell in enumerate(row.cells):
|
|
812
|
+
if ci >= len(cols) or not cell.text.strip():
|
|
813
|
+
continue
|
|
814
|
+
sizes = [r.font.size.pt for p in cell.text_frame.paragraphs for r in p.runs if r.font.size]
|
|
815
|
+
size = max(sizes) if sizes else 14.0
|
|
816
|
+
ml = cell.margin_left / L.EMU_PT if cell.margin_left is not None else 7.2
|
|
817
|
+
mr = cell.margin_right / L.EMU_PT if cell.margin_right is not None else 7.2
|
|
818
|
+
w = max(10.0, cols[ci] * L.PT_IN - ml - mr)
|
|
819
|
+
lines = L.wrap_segments([(cell.text, size, False)], w)
|
|
820
|
+
if len(lines) > 2 and size >= 12:
|
|
821
|
+
out.append(Finding(pno, 'table_overflow', 'warn',
|
|
822
|
+
f'{label} 第{ri + 1}行第{ci + 1}列「{cell.text[:14]}」要折 {len(lines)} 行',
|
|
823
|
+
'加宽该列、缩短文案或减小表格字号', label, rect, fixable=False))
|
|
824
|
+
if rect.y + need_h > page.bottom + 0.02:
|
|
825
|
+
out.append(Finding(pno, 'table_overflow', 'error',
|
|
826
|
+
f'{label} 实际需高 {need_h:.2f}",从 y={rect.y:.2f}" 起会顶出页面(页高 {page.h:.2f}")',
|
|
827
|
+
f'行数减到 {max(2, int((page.bottom - rect.y) / max(0.28, need_h / max(1, len(tbl.rows)))))} 行以内,或分两页', label, rect))
|
|
828
|
+
if len(tbl.rows) > 12:
|
|
829
|
+
out.append(Finding(pno, 'table_overflow', 'warn',
|
|
830
|
+
f'{label} 共 {len(tbl.rows)} 行,投影上看不清',
|
|
831
|
+
'摘要上页、明细另附 xlsx 并在页脚注明', label, rect, fixable=False))
|
|
832
|
+
return out
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
# 装饰性形状:无文字的纯色块(卡片底/色条),与其它元素重叠是设计而非缺陷
|
|
836
|
+
def _is_decor(kind, sh):
|
|
837
|
+
if kind != 'shape':
|
|
838
|
+
return False
|
|
839
|
+
try:
|
|
840
|
+
return not (sh.has_text_frame and sh.text_frame.text.strip())
|
|
841
|
+
except Exception:
|
|
842
|
+
return True
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _check_overlap(pno, boxes):
|
|
846
|
+
out = []
|
|
847
|
+
n = len(boxes)
|
|
848
|
+
for i in range(n):
|
|
849
|
+
sh_a, rect_a, ink_a, kind_a, z_a, lab_a = boxes[i]
|
|
850
|
+
if kind_a not in ('text', 'pic', 'table', 'chart'):
|
|
851
|
+
continue
|
|
852
|
+
for j in range(i + 1, n):
|
|
853
|
+
sh_b, rect_b, ink_b, kind_b, z_b, lab_b = boxes[j]
|
|
854
|
+
if kind_b not in ('text', 'pic', 'table', 'chart'):
|
|
855
|
+
continue
|
|
856
|
+
a = _rot_aabb(ink_a, getattr(sh_a, 'rotation', 0))
|
|
857
|
+
b = _rot_aabb(ink_b, getattr(sh_b, 'rotation', 0))
|
|
858
|
+
ratio = a.overlap_ratio(b)
|
|
859
|
+
if ratio <= OVERLAP_MIN:
|
|
860
|
+
continue
|
|
861
|
+
# 文字压在图/图表上:只有当上层是图(把字盖住)才算问题;字在图上是常见设计
|
|
862
|
+
if {kind_a, kind_b} == {'text', 'pic'} or {kind_a, kind_b} == {'text', 'chart'}:
|
|
863
|
+
txt_i = (kind_a == 'text')
|
|
864
|
+
z_txt, z_img = (z_a, z_b) if txt_i else (z_b, z_a)
|
|
865
|
+
lab_txt, lab_img = (lab_a, lab_b) if txt_i else (lab_b, lab_a)
|
|
866
|
+
if z_img > z_txt and ratio > 0.12:
|
|
867
|
+
out.append(Finding(pno, 'overlap', 'error',
|
|
868
|
+
f'{lab_img} 盖住了 {lab_txt}(压住 {ratio * 100:.0f}%,图在文字上层)',
|
|
869
|
+
'把图移开或置于底层', lab_txt, a.intersect(b)))
|
|
870
|
+
continue
|
|
871
|
+
lvl = 'error' if ratio > 0.12 else 'warn'
|
|
872
|
+
out.append(Finding(pno, 'overlap', lvl,
|
|
873
|
+
f'{lab_a} 与 {lab_b} 重叠 {ratio * 100:.0f}%'
|
|
874
|
+
f'(相交区 {a.intersect(b).w:.2f}"×{a.intersect(b).h:.2f}")',
|
|
875
|
+
'错开位置或缩小其一;同一行的多块内容用等分栅格排', f'{lab_a}/{lab_b}',
|
|
876
|
+
a.intersect(b)))
|
|
877
|
+
return out
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _check_consistency(titles, title_tops, fonts, fills, sw, sh_in):
|
|
881
|
+
out = []
|
|
882
|
+
if len(title_tops) >= 3:
|
|
883
|
+
ys = [y for _, y in title_tops]
|
|
884
|
+
lo, hi = min(ys), max(ys)
|
|
885
|
+
if hi - lo > TITLE_JITTER:
|
|
886
|
+
worst = max(title_tops, key=lambda t: abs(t[1] - sorted(ys)[len(ys) // 2]))
|
|
887
|
+
out.append(Finding(worst[0], 'title_jitter', 'warn',
|
|
888
|
+
f'各页标题上边距在 {lo:.2f}"~{hi:.2f}" 之间跳动(第 {worst[0]} 页 {worst[1]:.2f}")',
|
|
889
|
+
'所有内容页标题用同一 y 坐标,翻页时标题不该乱动'))
|
|
890
|
+
if len(fonts) > 3:
|
|
891
|
+
out.append(Finding(0, 'font_zoo', 'warn', f'全篇用了 {len(fonts)} 种字体:{"、".join(sorted(fonts)[:6])}',
|
|
892
|
+
'一套 PPT 两种字体足够(标题一种、正文一种)'))
|
|
893
|
+
if len(fills) > 10:
|
|
894
|
+
out.append(Finding(0, 'palette_zoo', 'info', f'全篇出现 {len(fills)} 种填充色',
|
|
895
|
+
'收敛到一套主题色(主色/强调/浅底/中性)'))
|
|
896
|
+
seen = {}
|
|
897
|
+
for pno, t in titles:
|
|
898
|
+
seen.setdefault(t, []).append(pno)
|
|
899
|
+
for t, pages in seen.items():
|
|
900
|
+
if len(pages) > 1 and len(t) > 2:
|
|
901
|
+
out.append(Finding(pages[1], 'dup_title', 'info',
|
|
902
|
+
f'第 {"、".join(map(str, pages))} 页标题都是「{t[:16]}」',
|
|
903
|
+
'续页标题加「(续)」,或换成各页真正的结论'))
|
|
904
|
+
r = sw / sh_in if sh_in else 0
|
|
905
|
+
if abs(r - 16 / 9) > 0.02:
|
|
906
|
+
out.append(Finding(0, 'size_odd', 'info', f'页面比例 {_ratio(sw, sh_in)}({sw:.2f}"×{sh_in:.2f}")',
|
|
907
|
+
'现在的投影/电视几乎都是 16:9;4:3 会两侧留黑边'))
|
|
908
|
+
return out
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
# ── 报告 ────────────────────────────────────────────────────────────────────
|
|
912
|
+
def report(res, max_per_code=4, max_lines=60):
|
|
913
|
+
"""把体检结果写成给人/给模型看的紧凑报告。"""
|
|
914
|
+
c = res['counts']
|
|
915
|
+
head = (f'【版式体检】{os.path.basename(res["path"])} · {res["slides"]} 页 · {res["ratio"]} '
|
|
916
|
+
f'· 必修 {c["error"]} / 建议 {c["warn"]} / 提示 {c["info"]}')
|
|
917
|
+
if not res['findings']:
|
|
918
|
+
return head + '\n✓ 未发现溢出、重叠、变形、越界等版式问题。'
|
|
919
|
+
lines = [head]
|
|
920
|
+
shown = 0
|
|
921
|
+
for lvl in ('error', 'warn', 'info'):
|
|
922
|
+
group = [f for f in res['findings'] if f['level'] == lvl]
|
|
923
|
+
if not group:
|
|
924
|
+
continue
|
|
925
|
+
lines.append(f'\n{LEVEL_CN[lvl]}({len(group)})')
|
|
926
|
+
per = {}
|
|
927
|
+
for f in group:
|
|
928
|
+
per.setdefault(f['code'], []).append(f)
|
|
929
|
+
for code, fs in per.items():
|
|
930
|
+
for f in fs[:max_per_code]:
|
|
931
|
+
loc = f'p{f["page"]}' if f['page'] else '全篇'
|
|
932
|
+
lines.append(f' {loc} {f["name"]} {f["detail"]}')
|
|
933
|
+
if f['hint']:
|
|
934
|
+
lines.append(f' → {f["hint"]}')
|
|
935
|
+
shown += 1
|
|
936
|
+
if shown >= max_lines:
|
|
937
|
+
break
|
|
938
|
+
if len(fs) > max_per_code:
|
|
939
|
+
rest = fs[max_per_code:]
|
|
940
|
+
lines.append(f' (同类还有 {len(rest)} 处:{"、".join("p" + str(x["page"]) for x in rest[:12])})')
|
|
941
|
+
if shown >= max_lines:
|
|
942
|
+
lines.append(' …(更多问题已省略,修完上面这些再跑一次)')
|
|
943
|
+
break
|
|
944
|
+
if shown >= max_lines:
|
|
945
|
+
break
|
|
946
|
+
fixable = sorted({f['code'] for f in res['findings'] if f.get('fixable')})
|
|
947
|
+
if fixable:
|
|
948
|
+
lines.append(f'\n可自动修复:{"、".join(CODE_CN[x] for x in fixable)} '
|
|
949
|
+
f'→ fix("{os.path.basename(res["path"])}");其余需改内容/版式后重新生成。')
|
|
950
|
+
return '\n'.join(lines)
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
# ── 自动修复 ────────────────────────────────────────────────────────────────
|
|
954
|
+
def fix(path, out=None, inplace=False, font_floor=14.0):
|
|
955
|
+
"""自动修可安全修的问题(默认另存,绝不覆盖原件)。
|
|
956
|
+
|
|
957
|
+
只做**确定性且不改内容**的修复:
|
|
958
|
+
· 图片变形 → 按原图比例等比重排(在原框内居中)
|
|
959
|
+
· 超出页面 / 贴边 → 移回页内,过大的等比缩小
|
|
960
|
+
· 文字溢出 → 先在 [font_floor, 原字号] 内逐级缩字;仍不够则在页内向下加高;
|
|
961
|
+
还不够则打开「自动缩排」兜底,并如实报出"建议拆页"
|
|
962
|
+
绝不删除任何元素、绝不改文案——那是人的决定。
|
|
963
|
+
"""
|
|
964
|
+
prs = Presentation(path)
|
|
965
|
+
sw, sh_in = _in(prs.slide_width), _in(prs.slide_height)
|
|
966
|
+
page = L.Rect(0, 0, sw, sh_in)
|
|
967
|
+
changes = []
|
|
968
|
+
still = []
|
|
969
|
+
|
|
970
|
+
for pno, slide in enumerate(prs.slides, 1):
|
|
971
|
+
items = walk(slide)
|
|
972
|
+
for sh, rect, z in items:
|
|
973
|
+
# 图片变形
|
|
974
|
+
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
975
|
+
try:
|
|
976
|
+
nw, nh = sh.image.size
|
|
977
|
+
cl = (sh.crop_left or 0) + (sh.crop_right or 0)
|
|
978
|
+
ct = (sh.crop_top or 0) + (sh.crop_bottom or 0)
|
|
979
|
+
nat = (nw * max(0.01, 1 - cl)) / (nh * max(0.01, 1 - ct))
|
|
980
|
+
cur = rect.w / rect.h if rect.h else nat
|
|
981
|
+
if abs(cur / nat - 1) > DISTORT_WARN:
|
|
982
|
+
box = L.contain_fit(nat, 1.0, rect)
|
|
983
|
+
sh.left, sh.top = Emu(int(box.x * L.EMU_IN)), Emu(int(box.y * L.EMU_IN))
|
|
984
|
+
sh.width, sh.height = Emu(int(box.w * L.EMU_IN)), Emu(int(box.h * L.EMU_IN))
|
|
985
|
+
changes.append(f'p{pno} 图片恢复原比例 {nat:.2f}:1({rect.w:.2f}×{rect.h:.2f} → {box.w:.2f}×{box.h:.2f}")')
|
|
986
|
+
rect = box
|
|
987
|
+
except Exception:
|
|
988
|
+
pass
|
|
989
|
+
|
|
990
|
+
# 越界:先等比缩到页内,再平移回来
|
|
991
|
+
r = L.Rect(rect.x, rect.y, rect.w, rect.h)
|
|
992
|
+
full_bleed = r.w >= sw * 0.98 and r.h >= sh_in * 0.98
|
|
993
|
+
if not full_bleed and (r.x < -0.02 or r.y < -0.02 or r.right > page.right + 0.02 or r.bottom > page.bottom + 0.02):
|
|
994
|
+
m = SAFE_MARGIN
|
|
995
|
+
inner = L.Rect(m, m, sw - 2 * m, sh_in - 2 * m)
|
|
996
|
+
s = min(1.0, inner.w / r.w if r.w else 1, inner.h / r.h if r.h else 1)
|
|
997
|
+
nw_, nh_ = r.w * s, r.h * s
|
|
998
|
+
nx = min(max(r.x, m), max(m, sw - m - nw_))
|
|
999
|
+
ny = min(max(r.y, m), max(m, sh_in - m - nh_))
|
|
1000
|
+
sh.left, sh.top = Emu(int(nx * L.EMU_IN)), Emu(int(ny * L.EMU_IN))
|
|
1001
|
+
if s < 1.0:
|
|
1002
|
+
sh.width, sh.height = Emu(int(nw_ * L.EMU_IN)), Emu(int(nh_ * L.EMU_IN))
|
|
1003
|
+
changes.append(f'p{pno} {_label(sh, _kind_of(sh))} 移回页内'
|
|
1004
|
+
+ (f'并缩到 {s * 100:.0f}%' if s < 1 else ''))
|
|
1005
|
+
rect = L.Rect(nx, ny, nw_, nh_)
|
|
1006
|
+
|
|
1007
|
+
# 表格顶出页面:缩字 + 收边距 + 回写行高
|
|
1008
|
+
if getattr(sh, 'has_table', False):
|
|
1009
|
+
r3 = _fix_table(pno, sh, rect, page, changes)
|
|
1010
|
+
if r3:
|
|
1011
|
+
still.append(r3)
|
|
1012
|
+
continue
|
|
1013
|
+
|
|
1014
|
+
# 文字溢出:缩字 → 加高 → 自动缩排
|
|
1015
|
+
if sh.has_text_frame and sh.text_frame.text.strip():
|
|
1016
|
+
r2 = _fix_overflow(pno, sh, rect, page, font_floor, changes)
|
|
1017
|
+
if r2:
|
|
1018
|
+
still.append(r2)
|
|
1019
|
+
|
|
1020
|
+
dst = path if inplace else (out or _sibling(path, '-已修复'))
|
|
1021
|
+
prs.save(dst)
|
|
1022
|
+
return dict(saved=dst, changed=changes, unresolved=still)
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _fix_overflow(pno, sh, rect, page, floor, changes):
|
|
1026
|
+
tf = sh.text_frame
|
|
1027
|
+
total, max_w, avail_w, avail_h, _ = para_lines(sh, rect, tf)
|
|
1028
|
+
if avail_h <= 0 or total <= avail_h * L.TOL:
|
|
1029
|
+
return None
|
|
1030
|
+
sizes = [(p, r, _run_size(r, p, sh)) for p in tf.paragraphs for r in p.runs if r.text.strip()]
|
|
1031
|
+
if not sizes:
|
|
1032
|
+
return None
|
|
1033
|
+
base = max(s for _, _, s in sizes)
|
|
1034
|
+
# ① 逐级缩字(等比缩所有 run,保持字号层级)
|
|
1035
|
+
k = 1.0
|
|
1036
|
+
while k > 0.55:
|
|
1037
|
+
k -= 0.05
|
|
1038
|
+
if base * k < floor:
|
|
1039
|
+
break
|
|
1040
|
+
for p, r, s0 in sizes:
|
|
1041
|
+
r.font.size = Pt(round(s0 * k, 1))
|
|
1042
|
+
total, _, _, avail_h, _ = para_lines(sh, rect, tf)
|
|
1043
|
+
if total <= avail_h:
|
|
1044
|
+
changes.append(f'p{pno} {_label(sh, "text")} 字号缩到 {k * 100:.0f}%({base:.0f}→{base * k:.0f}pt)以消除溢出')
|
|
1045
|
+
return None
|
|
1046
|
+
for p, r, s0 in sizes: # 缩不动就还原,改走加高
|
|
1047
|
+
r.font.size = Pt(s0)
|
|
1048
|
+
# ② 页内向下加高
|
|
1049
|
+
total, _, _, avail_h, _ = para_lines(sh, rect, tf)
|
|
1050
|
+
need = total / L.PT_IN + 2 * L.INSET_TB / L.PT_IN
|
|
1051
|
+
room = page.bottom - SAFE_MARGIN - rect.y
|
|
1052
|
+
if need <= room:
|
|
1053
|
+
sh.height = Emu(int(need * L.EMU_IN))
|
|
1054
|
+
changes.append(f'p{pno} {_label(sh, "text")} 文本框加高到 {need:.2f}"(原 {rect.h:.2f}")')
|
|
1055
|
+
return None
|
|
1056
|
+
# ③ 兜底:打开自动缩排,并如实报出该拆页
|
|
1057
|
+
try:
|
|
1058
|
+
tf.word_wrap = True
|
|
1059
|
+
tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
|
|
1060
|
+
changes.append(f'p{pno} {_label(sh, "text")} 已打开「自动缩排」兜底')
|
|
1061
|
+
except Exception:
|
|
1062
|
+
pass
|
|
1063
|
+
return (f'p{pno} {_label(sh, "text")} 内容确实太多(需 {need:.2f}",页内最多 {room:.2f}"):'
|
|
1064
|
+
'已开自动缩排避免溢出,但正确做法是拆成两页或把细节挪进备注')
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _kind_of(sh):
|
|
1068
|
+
if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
|
|
1069
|
+
return 'pic'
|
|
1070
|
+
if getattr(sh, 'has_table', False):
|
|
1071
|
+
return 'table'
|
|
1072
|
+
if getattr(sh, 'has_chart', False):
|
|
1073
|
+
return 'chart'
|
|
1074
|
+
try:
|
|
1075
|
+
if sh.has_text_frame and sh.text_frame.text.strip():
|
|
1076
|
+
return 'text'
|
|
1077
|
+
except Exception:
|
|
1078
|
+
pass
|
|
1079
|
+
return 'shape'
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def _table_need(tbl, cols):
|
|
1083
|
+
"""按单元格实际折行算出每行需要的高度(英寸)。"""
|
|
1084
|
+
heights = []
|
|
1085
|
+
for row in tbl.rows:
|
|
1086
|
+
need = 0.22
|
|
1087
|
+
for ci, cell in enumerate(row.cells):
|
|
1088
|
+
if ci >= len(cols) or not cell.text.strip():
|
|
1089
|
+
continue
|
|
1090
|
+
sizes = [r.font.size.pt for p in cell.text_frame.paragraphs for r in p.runs if r.font.size]
|
|
1091
|
+
size = max(sizes) if sizes else 14.0
|
|
1092
|
+
ml = cell.margin_left / L.EMU_PT if cell.margin_left is not None else 7.2
|
|
1093
|
+
mr = cell.margin_right / L.EMU_PT if cell.margin_right is not None else 7.2
|
|
1094
|
+
mt = cell.margin_top / L.EMU_PT if cell.margin_top is not None else 3.6
|
|
1095
|
+
mb = cell.margin_bottom / L.EMU_PT if cell.margin_bottom is not None else 3.6
|
|
1096
|
+
w = max(10.0, cols[ci] * L.PT_IN - ml - mr)
|
|
1097
|
+
h = (L.text_height(L.wrap_segments([(cell.text, size, False)], w)) + mt + mb) / L.PT_IN
|
|
1098
|
+
need = max(need, h)
|
|
1099
|
+
heights.append(need)
|
|
1100
|
+
return heights
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _fix_table(pno, sh, rect, page, changes):
|
|
1104
|
+
"""表格顶出页面:先缩字、再收紧单元格上下边距,最后把算出的行高写回。"""
|
|
1105
|
+
try:
|
|
1106
|
+
tbl = sh.table
|
|
1107
|
+
except Exception:
|
|
1108
|
+
return None
|
|
1109
|
+
cols = [c.width / L.EMU_IN for c in tbl.columns]
|
|
1110
|
+
avail = page.bottom - SAFE_MARGIN - rect.y
|
|
1111
|
+
need = sum(_table_need(tbl, cols))
|
|
1112
|
+
if need <= avail:
|
|
1113
|
+
return None
|
|
1114
|
+
runs = [r for row in tbl.rows for cell in row.cells
|
|
1115
|
+
for p in cell.text_frame.paragraphs for r in p.runs if r.text.strip()]
|
|
1116
|
+
base = max((r.font.size.pt for r in runs if r.font.size), default=14.0)
|
|
1117
|
+
orig = [(r, r.font.size.pt if r.font.size else 14.0) for r in runs]
|
|
1118
|
+
k = 1.0
|
|
1119
|
+
while k > 0.6 and need > avail:
|
|
1120
|
+
k -= 0.05
|
|
1121
|
+
if base * k < 10.0:
|
|
1122
|
+
break
|
|
1123
|
+
for r, s0 in orig:
|
|
1124
|
+
r.font.size = Pt(round(s0 * k, 1))
|
|
1125
|
+
need = sum(_table_need(tbl, cols))
|
|
1126
|
+
if need > avail: # 再收紧上下内边距
|
|
1127
|
+
for row in tbl.rows:
|
|
1128
|
+
for cell in row.cells:
|
|
1129
|
+
cell.margin_top = Pt(1.5)
|
|
1130
|
+
cell.margin_bottom = Pt(1.5)
|
|
1131
|
+
need = sum(_table_need(tbl, cols))
|
|
1132
|
+
hs = _table_need(tbl, cols)
|
|
1133
|
+
if need <= avail:
|
|
1134
|
+
for row, h in zip(tbl.rows, hs):
|
|
1135
|
+
row.height = Emu(int(h * L.EMU_IN))
|
|
1136
|
+
changes.append(f'p{pno} 表格缩到 {k * 100:.0f}% 字号并回写行高,总高 {need:.2f}" 装进可用 {avail:.2f}"')
|
|
1137
|
+
return None
|
|
1138
|
+
for r, s0 in orig: # 缩不动就还原,如实报该分页
|
|
1139
|
+
r.font.size = Pt(s0)
|
|
1140
|
+
per = need / max(1, len(tbl.rows))
|
|
1141
|
+
return (f'p{pno} 表格 {len(tbl.rows)} 行需 {need:.2f}",页内只有 {avail:.2f}":'
|
|
1142
|
+
f'缩到 10pt 仍装不下,请拆成每页 ≤{max(2, int(avail / per))} 行,或只上摘要、明细另附 xlsx')
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _sibling(path, suffix):
|
|
1146
|
+
root, ext = os.path.splitext(path)
|
|
1147
|
+
return root + suffix + ext
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
# ── CLI ─────────────────────────────────────────────────────────────────────
|
|
1151
|
+
def main(argv):
|
|
1152
|
+
if not argv or argv[0] in ('-h', '--help'):
|
|
1153
|
+
print(__doc__ or '用法:python pptx_check.py <文件.pptx> [--json] [--fix] [--inplace] [--strict]')
|
|
1154
|
+
return 2
|
|
1155
|
+
path = argv[0]
|
|
1156
|
+
as_json = '--json' in argv
|
|
1157
|
+
do_fix = '--fix' in argv
|
|
1158
|
+
strict = '--strict' in argv
|
|
1159
|
+
if not os.path.exists(path):
|
|
1160
|
+
print(f'文件不存在:{path}')
|
|
1161
|
+
return 1
|
|
1162
|
+
res = inspect(path, strict=strict)
|
|
1163
|
+
if do_fix:
|
|
1164
|
+
fr = fix(path, inplace='--inplace' in argv)
|
|
1165
|
+
res2 = inspect(fr['saved'], strict=strict)
|
|
1166
|
+
if as_json:
|
|
1167
|
+
print(json.dumps(dict(before=res, fixed=fr, after=res2), ensure_ascii=False, indent=1))
|
|
1168
|
+
else:
|
|
1169
|
+
print(report(res))
|
|
1170
|
+
print('\n【自动修复】已改 %d 处 → %s' % (len(fr['changed']), fr['saved']))
|
|
1171
|
+
for c in fr['changed'][:20]:
|
|
1172
|
+
print(' ·', c)
|
|
1173
|
+
for u in fr['unresolved'][:10]:
|
|
1174
|
+
print(' ⚠', u)
|
|
1175
|
+
print('\n' + report(res2))
|
|
1176
|
+
return 0
|
|
1177
|
+
print(json.dumps(res, ensure_ascii=False, indent=1) if as_json else report(res))
|
|
1178
|
+
return 0 if res['ok'] else 1
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
if __name__ == '__main__':
|
|
1182
|
+
sys.exit(main(sys.argv[1:]))
|