/ src / reports / problem-area-cropper.js
problem-area-cropper.js
  1  /**
  2   * Problem Area Cropper
  3   *
  4   * Produces tight zoomed-in screenshots for each problem area in the report.
  5   *
  6   * Strategy (in priority order):
  7   *   1. Selector screenshot — if area.selector was captured by full-page-capture.js while
  8   *      the page was open, use that buffer directly (perfectly tight around the element)
  9   *   2. y% crop fallback — crop from the full-page screenshot using approximate_y_position_percent
 10   *      (legacy behaviour, used when selector didn't match or wasn't provided)
 11   */
 12  
 13  import sharp from 'sharp';
 14  import Logger from '../utils/logger.js';
 15  
 16  const logger = new Logger('ProblemAreaCropper');
 17  
 18  const MISSING_KEYWORDS = [
 19    'no ',
 20    'missing',
 21    'lacks',
 22    'absent',
 23    'no clear',
 24    'not present',
 25    'does not have',
 26    'without',
 27    "doesn't have",
 28    'there is no',
 29  ];
 30  
 31  /**
 32   * Crop problem areas from a full-page screenshot (or selector screenshots when available).
 33   * @param {Buffer} fullPageBuffer - Full-page screenshot PNG buffer
 34   * @param {Array} problemAreas - Array of problem area objects from LLM
 35   * @param {Map<string,Buffer>} [selectorScreenshots] - Optional map of selector → tight element buffer
 36   * @returns {Array<Object>} Array of { factor, imageBuffer, description, recommendation, severity, missing, current_text, suggested_text, usedSelector }
 37   */
 38  export async function cropProblemAreas(
 39    fullPageBuffer,
 40    problemAreas,
 41    selectorScreenshots = new Map()
 42  ) {
 43    if (!problemAreas || problemAreas.length === 0) {
 44      logger.info('No problem areas to crop');
 45      return [];
 46    }
 47  
 48    const metadata = await sharp(fullPageBuffer).metadata();
 49    const { width: imgWidth, height: imgHeight } = metadata;
 50  
 51    logger.info(
 52      `Cropping ${problemAreas.length} problem areas from ${imgWidth}x${imgHeight} screenshot`
 53    );
 54  
 55    const crops = [];
 56  
 57    for (const area of problemAreas) {
 58      try {
 59        const isMissing = MISSING_KEYWORDS.some(kw =>
 60          (area.description || '').toLowerCase().includes(kw)
 61        );
 62  
 63        let imageBuffer;
 64        let usedSelector = false;
 65  
 66        // Strategy 1: use pre-captured selector screenshot (tight, perfectly framed)
 67        if (area.selector && selectorScreenshots.has(area.selector)) {
 68          const rawBuffer = selectorScreenshots.get(area.selector);
 69          // Normalise to JPEG at consistent quality
 70          imageBuffer = await sharp(rawBuffer).jpeg({ quality: 90 }).toBuffer();
 71          usedSelector = true;
 72          logger.debug(`Using selector screenshot for ${area.factor}: "${area.selector}"`);
 73        } else {
 74          // Strategy 2: y% crop fallback from full-page buffer
 75          const cropWidth = area.zoom_hint === 'tight' ? 480 : 768;
 76          const cropHeight = area.zoom_hint === 'tight' ? 280 : 400;
 77  
 78          const yPercent = area.approximate_y_position_percent || 50;
 79          const yCenter = Math.round((yPercent / 100) * imgHeight);
 80  
 81          const cropTop = Math.max(0, yCenter - Math.round(cropHeight / 2));
 82          const actualCropHeight = Math.min(cropHeight, imgHeight - cropTop);
 83          const actualCropWidth = Math.min(imgWidth, cropWidth * 2);
 84  
 85          if (actualCropHeight < 50) {
 86            logger.warn(`Skipping crop for ${area.factor}: insufficient height`);
 87            continue;
 88          }
 89  
 90          imageBuffer = await sharp(fullPageBuffer)
 91            .extract({ left: 0, top: cropTop, width: actualCropWidth, height: actualCropHeight })
 92            .resize(cropWidth, null, { fit: 'inside', withoutEnlargement: true })
 93            .jpeg({ quality: 90 })
 94            .toBuffer();
 95  
 96          if (area.selector) {
 97            logger.debug(
 98              `Selector "${area.selector}" not captured for ${area.factor} — using y% fallback`
 99            );
100          }
101        }
102  
103        crops.push({
104          factor: area.factor,
105          imageBuffer,
106          description: area.description,
107          recommendation: area.recommendation,
108          severity: area.severity || 'medium',
109          missing: isMissing,
110          current_text: area.current_text,
111          suggested_text: area.suggested_text,
112          usedSelector,
113        });
114      } catch (error) {
115        logger.warn(`Failed to crop area ${area.factor}: ${error.message}`);
116      }
117    }
118  
119    const selectorCount = crops.filter(c => c.usedSelector).length;
120    logger.success(
121      `Cropped ${crops.length}/${problemAreas.length} problem areas (${selectorCount} via selector, ${crops.length - selectorCount} via y% fallback)`
122    );
123    return crops;
124  }