microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
.github/skills/experimental/powerpoint/scripts/invoke-pptx-pipeline.sh
354lines · modecode
| 1 | #!/usr/bin/env bash |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | # |
| 5 | # invoke-pptx-pipeline.sh |
| 6 | # Orchestrates PowerPoint slide deck operations via Python scripts. |
| 7 | |
| 8 | set -euo pipefail |
| 9 | |
| 10 | SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" |
| 11 | SKILL_ROOT="$(dirname "${SCRIPT_DIR}")" |
| 12 | VENV_DIR="${SKILL_ROOT}/.venv" |
| 13 | |
| 14 | # Defaults |
| 15 | RESOLUTION=150 |
| 16 | VALIDATION_MODEL="claude-haiku-4.5" |
| 17 | SKIP_VENV_SETUP=false |
| 18 | |
| 19 | err() { |
| 20 | printf "ERROR: %s\n" "$1" >&2 |
| 21 | exit 1 |
| 22 | } |
| 23 | |
| 24 | usage() { |
| 25 | cat <<EOF |
| 26 | Usage: $(basename "$0") --action <Action> [OPTIONS] |
| 27 | |
| 28 | Actions: |
| 29 | build Build a PowerPoint deck from YAML content |
| 30 | extract Extract content from an existing PPTX to YAML |
| 31 | validate Validate a PPTX deck (property checks + optional vision) |
| 32 | export Export PPTX slides to JPG images |
| 33 | |
| 34 | Options: |
| 35 | --action <action> Required. build|extract|validate|export |
| 36 | --content-dir <path> Content directory (required for build) |
| 37 | --style <path> Style YAML path (required for build) |
| 38 | --output <path> Output PPTX path (required for build) |
| 39 | --input <path> Input PPTX path (required for extract/validate/export) |
| 40 | --output-dir <path> Output directory (required for extract) |
| 41 | --template <path> Template PPTX for themed builds (optional, build) |
| 42 | --source <path> Source PPTX for partial rebuilds (optional, build) |
| 43 | --slides <list> Comma-separated slide numbers (optional) |
| 44 | --image-output-dir <path> Image output directory (required for export) |
| 45 | --resolution <dpi> DPI for exported images (default: 150) |
| 46 | --validation-prompt <text> Vision validation prompt text (optional) |
| 47 | --validation-prompt-file <path> Vision validation prompt file (optional) |
| 48 | --validation-model <model> Vision model name (default: claude-haiku-4.5) |
| 49 | --skip-venv-setup Skip virtual environment setup |
| 50 | -h, --help Show this help message |
| 51 | EOF |
| 52 | exit 0 |
| 53 | } |
| 54 | |
| 55 | test_uv_availability() { |
| 56 | if ! command -v uv &>/dev/null; then |
| 57 | err "uv is required but was not found on PATH. Install with: curl -LsSf https://astral.sh/uv/install.sh | sh" |
| 58 | fi |
| 59 | } |
| 60 | |
| 61 | initialize_python_environment() { |
| 62 | echo "Syncing Python environment via uv..." |
| 63 | uv sync --directory "${SKILL_ROOT}" |
| 64 | echo "Environment synchronized." |
| 65 | } |
| 66 | |
| 67 | get_venv_python_path() { |
| 68 | echo "${VENV_DIR}/bin/python" |
| 69 | } |
| 70 | |
| 71 | assert_build_parameters() { |
| 72 | [[ -z "${CONTENT_DIR:-}" ]] && err "Build action requires --content-dir." |
| 73 | [[ -z "${STYLE_PATH:-}" ]] && err "Build action requires --style." |
| 74 | [[ -z "${OUTPUT_PATH:-}" ]] && err "Build action requires --output." |
| 75 | if [[ -n "${SLIDES:-}" && -z "${SOURCE_PATH:-}" ]]; then |
| 76 | err "--slides requires --source for partial rebuilds." |
| 77 | fi |
| 78 | } |
| 79 | |
| 80 | assert_extract_parameters() { |
| 81 | [[ -z "${INPUT_PATH:-}" ]] && err "Extract action requires --input." |
| 82 | [[ -z "${OUTPUT_DIR:-}" ]] && err "Extract action requires --output-dir." |
| 83 | } |
| 84 | |
| 85 | assert_validate_parameters() { |
| 86 | [[ -z "${INPUT_PATH:-}" ]] && err "Validate action requires --input." |
| 87 | } |
| 88 | |
| 89 | assert_export_parameters() { |
| 90 | [[ -z "${INPUT_PATH:-}" ]] && err "Export action requires --input." |
| 91 | [[ -z "${IMAGE_OUTPUT_DIR:-}" ]] && err "Export action requires --image-output-dir." |
| 92 | } |
| 93 | |
| 94 | invoke_build_deck() { |
| 95 | local python |
| 96 | python="$(get_venv_python_path)" |
| 97 | local script="${SCRIPT_DIR}/build_deck.py" |
| 98 | |
| 99 | local -a args=( |
| 100 | "${script}" |
| 101 | "--content-dir" "${CONTENT_DIR}" |
| 102 | "--style" "${STYLE_PATH}" |
| 103 | "--output" "${OUTPUT_PATH}" |
| 104 | ) |
| 105 | |
| 106 | [[ -n "${TEMPLATE_PATH:-}" ]] && args+=("--template" "${TEMPLATE_PATH}") |
| 107 | [[ -n "${SOURCE_PATH:-}" ]] && args+=("--source" "${SOURCE_PATH}") |
| 108 | [[ -n "${SLIDES:-}" ]] && args+=("--slides" "${SLIDES}") |
| 109 | |
| 110 | echo "Building deck from ${CONTENT_DIR} -> ${OUTPUT_PATH}" |
| 111 | "${python}" "${args[@]}" |
| 112 | } |
| 113 | |
| 114 | invoke_extract_content() { |
| 115 | local python |
| 116 | python="$(get_venv_python_path)" |
| 117 | local script="${SCRIPT_DIR}/extract_content.py" |
| 118 | |
| 119 | local -a args=( |
| 120 | "${script}" |
| 121 | "--input" "${INPUT_PATH}" |
| 122 | "--output-dir" "${OUTPUT_DIR}" |
| 123 | ) |
| 124 | |
| 125 | [[ -n "${SLIDES:-}" ]] && args+=("--slides" "${SLIDES}") |
| 126 | |
| 127 | echo "Extracting content from ${INPUT_PATH} -> ${OUTPUT_DIR}" |
| 128 | "${python}" "${args[@]}" |
| 129 | } |
| 130 | |
| 131 | invoke_export_slides() { |
| 132 | local python |
| 133 | python="$(get_venv_python_path)" |
| 134 | local export_script="${SCRIPT_DIR}/export_slides.py" |
| 135 | |
| 136 | if ! command -v libreoffice &>/dev/null && ! command -v soffice &>/dev/null; then |
| 137 | err "LibreOffice is required for PPTX-to-PDF export but was not found on PATH. Install with: brew install --cask libreoffice" |
| 138 | fi |
| 139 | |
| 140 | mkdir -p "${IMAGE_OUTPUT_DIR}" |
| 141 | |
| 142 | # Clear stale slide images from prior runs |
| 143 | local stale_count |
| 144 | stale_count="$(find "${IMAGE_OUTPUT_DIR}" -maxdepth 1 -name 'slide-*.jpg' 2>/dev/null | wc -l | tr -d ' ')" |
| 145 | if (( stale_count > 0 )); then |
| 146 | find "${IMAGE_OUTPUT_DIR}" -maxdepth 1 -name 'slide-*.jpg' -delete |
| 147 | echo "Cleared ${stale_count} stale slide image(s) from ${IMAGE_OUTPUT_DIR}" |
| 148 | fi |
| 149 | |
| 150 | local pdf_output="${IMAGE_OUTPUT_DIR}/slides.pdf" |
| 151 | |
| 152 | local -a args=( |
| 153 | "${export_script}" |
| 154 | "--input" "${INPUT_PATH}" |
| 155 | "--output" "${pdf_output}" |
| 156 | ) |
| 157 | |
| 158 | [[ -n "${SLIDES:-}" ]] && args+=("--slides" "${SLIDES}") |
| 159 | |
| 160 | echo "Exporting slides from ${INPUT_PATH} to PDF" |
| 161 | "${python}" "${args[@]}" |
| 162 | |
| 163 | # Convert PDF to JPG images |
| 164 | convert_to_slide_images "${pdf_output}" "${IMAGE_OUTPUT_DIR}" "${RESOLUTION}" "${SLIDES:-}" |
| 165 | |
| 166 | # Clean up intermediate PDF |
| 167 | if [[ -f "${pdf_output}" ]]; then |
| 168 | rm -f "${pdf_output}" |
| 169 | echo "Cleaned up intermediate PDF." |
| 170 | fi |
| 171 | } |
| 172 | |
| 173 | convert_to_slide_images() { |
| 174 | local pdf_path="$1" |
| 175 | local output_dir="$2" |
| 176 | local dpi="$3" |
| 177 | local slide_numbers="${4:-}" |
| 178 | |
| 179 | if command -v pdftoppm &>/dev/null; then |
| 180 | echo "Converting PDF to JPG via pdftoppm (${dpi} DPI)" |
| 181 | pdftoppm -jpeg -r "${dpi}" "${pdf_path}" "${output_dir}/slide" |
| 182 | |
| 183 | # Rename to zero-padded 3-digit format and optionally remap slide numbers |
| 184 | if [[ -n "${slide_numbers}" ]]; then |
| 185 | IFS=',' read -ra target_nums <<< "${slide_numbers}" |
| 186 | local idx=0 |
| 187 | for file in $(find "${output_dir}" -maxdepth 1 -name 'slide-*.jpg' | sort); do |
| 188 | if (( idx < ${#target_nums[@]} )); then |
| 189 | local new_name |
| 190 | new_name=$(printf "slide-%03d.jpg" "${target_nums[idx]}") |
| 191 | local base_name |
| 192 | base_name="$(basename "${file}")" |
| 193 | if [[ "${base_name}" != "${new_name}" ]]; then |
| 194 | mv "${file}" "${output_dir}/${new_name}" |
| 195 | fi |
| 196 | (( idx++ )) || true |
| 197 | fi |
| 198 | done |
| 199 | else |
| 200 | for file in $(find "${output_dir}" -maxdepth 1 -name 'slide-*.jpg' | sort); do |
| 201 | local base_name |
| 202 | base_name="$(basename "${file}")" |
| 203 | if [[ "${base_name}" =~ ^slide-([0-9]+)\.jpg$ ]]; then |
| 204 | local num="${BASH_REMATCH[1]}" |
| 205 | local new_name |
| 206 | new_name=$(printf "slide-%03d.jpg" "$((10#${num}))") |
| 207 | if [[ "${base_name}" != "${new_name}" ]]; then |
| 208 | mv "${file}" "${output_dir}/${new_name}" |
| 209 | fi |
| 210 | fi |
| 211 | done |
| 212 | fi |
| 213 | else |
| 214 | echo "pdftoppm not found, falling back to PyMuPDF" |
| 215 | local python |
| 216 | python="$(get_venv_python_path)" |
| 217 | local render_script="${SCRIPT_DIR}/render_pdf_images.py" |
| 218 | |
| 219 | local -a render_args=( |
| 220 | "${render_script}" |
| 221 | "--input" "${pdf_path}" |
| 222 | "--output-dir" "${output_dir}" |
| 223 | "--dpi" "${dpi}" |
| 224 | ) |
| 225 | [[ -n "${slide_numbers}" ]] && render_args+=("--slide-numbers" "${slide_numbers}") |
| 226 | |
| 227 | "${python}" "${render_args[@]}" |
| 228 | fi |
| 229 | |
| 230 | local image_count |
| 231 | image_count="$(find "${output_dir}" -maxdepth 1 -name 'slide-*.jpg' | wc -l | tr -d ' ')" |
| 232 | echo "Exported ${image_count} slide image(s) to ${output_dir}" |
| 233 | } |
| 234 | |
| 235 | invoke_validate_deck() { |
| 236 | local python |
| 237 | python="$(get_venv_python_path)" |
| 238 | local has_vision_prompt=false |
| 239 | [[ -n "${VALIDATION_PROMPT:-}" || -n "${VALIDATION_PROMPT_FILE:-}" ]] && has_vision_prompt=true |
| 240 | |
| 241 | local total_steps=2 |
| 242 | ${has_vision_prompt} && total_steps=3 |
| 243 | |
| 244 | # Default image output directory |
| 245 | if [[ -z "${IMAGE_OUTPUT_DIR:-}" ]]; then |
| 246 | IMAGE_OUTPUT_DIR="$(dirname "${INPUT_PATH}")/validation" |
| 247 | fi |
| 248 | |
| 249 | # Step 1: Export slides to images |
| 250 | echo "Step 1/${total_steps}: Exporting slides to images..." |
| 251 | invoke_export_slides |
| 252 | |
| 253 | # Step 2: Run PPTX property checks |
| 254 | echo "Step 2/${total_steps}: Running PPTX property checks..." |
| 255 | local pptx_script="${SCRIPT_DIR}/validate_deck.py" |
| 256 | local -a pptx_args=( |
| 257 | "${pptx_script}" |
| 258 | "--input" "${INPUT_PATH}" |
| 259 | ) |
| 260 | [[ -n "${CONTENT_DIR:-}" ]] && pptx_args+=("--content-dir" "${CONTENT_DIR}") |
| 261 | [[ -n "${SLIDES:-}" ]] && pptx_args+=("--slides" "${SLIDES}") |
| 262 | |
| 263 | local deck_output="${IMAGE_OUTPUT_DIR}/deck-validation-results.json" |
| 264 | pptx_args+=("--output" "${deck_output}") |
| 265 | local deck_report="${IMAGE_OUTPUT_DIR}/deck-validation-report.md" |
| 266 | pptx_args+=("--report" "${deck_report}") |
| 267 | pptx_args+=("--per-slide-dir" "${IMAGE_OUTPUT_DIR}") |
| 268 | |
| 269 | local exit_code=0 |
| 270 | "${python}" "${pptx_args[@]}" || exit_code=$? |
| 271 | if (( exit_code == 2 )); then |
| 272 | err "validate_deck.py encountered an error (exit code ${exit_code})." |
| 273 | fi |
| 274 | if (( exit_code == 1 )); then |
| 275 | echo "PPTX property checks found warnings — see ${deck_report}" |
| 276 | fi |
| 277 | |
| 278 | # Step 3: Vision validation (when prompt provided) |
| 279 | if ${has_vision_prompt}; then |
| 280 | echo "Step 3/${total_steps}: Running Copilot SDK vision validation..." |
| 281 | local vision_script="${SCRIPT_DIR}/validate_slides.py" |
| 282 | local -a vision_args=( |
| 283 | "${vision_script}" |
| 284 | "--image-dir" "${IMAGE_OUTPUT_DIR}" |
| 285 | "--model" "${VALIDATION_MODEL}" |
| 286 | ) |
| 287 | [[ -n "${VALIDATION_PROMPT:-}" ]] && vision_args+=("--prompt" "${VALIDATION_PROMPT}") |
| 288 | [[ -n "${VALIDATION_PROMPT_FILE:-}" ]] && vision_args+=("--prompt-file" "${VALIDATION_PROMPT_FILE}") |
| 289 | |
| 290 | local vision_output="${IMAGE_OUTPUT_DIR}/validation-results.json" |
| 291 | vision_args+=("--output" "${vision_output}") |
| 292 | [[ -n "${SLIDES:-}" ]] && vision_args+=("--slides" "${SLIDES}") |
| 293 | |
| 294 | "${python}" "${vision_args[@]}" |
| 295 | echo "Vision validation results: ${vision_output}" |
| 296 | fi |
| 297 | } |
| 298 | |
| 299 | parse_args() { |
| 300 | while (( $# > 0 )); do |
| 301 | case "$1" in |
| 302 | --action) ACTION="$2"; shift 2 ;; |
| 303 | --content-dir) CONTENT_DIR="$2"; shift 2 ;; |
| 304 | --style) STYLE_PATH="$2"; shift 2 ;; |
| 305 | --output) OUTPUT_PATH="$2"; shift 2 ;; |
| 306 | --input) INPUT_PATH="$2"; shift 2 ;; |
| 307 | --output-dir) OUTPUT_DIR="$2"; shift 2 ;; |
| 308 | --template) TEMPLATE_PATH="$2"; shift 2 ;; |
| 309 | --source) SOURCE_PATH="$2"; shift 2 ;; |
| 310 | --slides) SLIDES="$2"; shift 2 ;; |
| 311 | --image-output-dir) IMAGE_OUTPUT_DIR="$2"; shift 2 ;; |
| 312 | --resolution) RESOLUTION="$2"; shift 2 ;; |
| 313 | --validation-prompt) VALIDATION_PROMPT="$2"; shift 2 ;; |
| 314 | --validation-prompt-file) VALIDATION_PROMPT_FILE="$2"; shift 2 ;; |
| 315 | --validation-model) VALIDATION_MODEL="$2"; shift 2 ;; |
| 316 | --skip-venv-setup) SKIP_VENV_SETUP=true; shift ;; |
| 317 | -h|--help) usage ;; |
| 318 | *) err "Unknown option: $1" ;; |
| 319 | esac |
| 320 | done |
| 321 | } |
| 322 | |
| 323 | main() { |
| 324 | parse_args "$@" |
| 325 | |
| 326 | [[ -z "${ACTION:-}" ]] && err "Action is required. Use --action <build|extract|validate|export>." |
| 327 | |
| 328 | if [[ "${SKIP_VENV_SETUP}" == "false" ]]; then |
| 329 | test_uv_availability |
| 330 | initialize_python_environment |
| 331 | fi |
| 332 | |
| 333 | case "${ACTION}" in |
| 334 | build) |
| 335 | assert_build_parameters |
| 336 | invoke_build_deck |
| 337 | ;; |
| 338 | extract) |
| 339 | assert_extract_parameters |
| 340 | invoke_extract_content |
| 341 | ;; |
| 342 | validate) |
| 343 | assert_validate_parameters |
| 344 | invoke_validate_deck |
| 345 | ;; |
| 346 | export) |
| 347 | assert_export_parameters |
| 348 | invoke_export_slides |
| 349 | ;; |
| 350 | *) err "Unknown action: ${ACTION}. Use build|extract|validate|export." ;; |
| 351 | esac |
| 352 | } |
| 353 | |
| 354 | main "$@" |
| 355 | |