Skill: kimjoohyeon-wq/daily-scan (version 1.0.4)

--- name: daily-scan description: "Scan photographed documents into searchable PDFs with OCR and stable file naming. Use when the user sends document photos and asks to scan, save, archive, OCR, or retrieve prior scans. Trigger words: 스캔 or scan for a single page, 스캔연속 or scan multi for a multi-page combined PDF, and 스캔찾아 or scan find for retrieval." --- # Daily Scan ## Overview Turn phone photos of documents into searchable PDFs with OCR and stable filenames based on capture date and headline text. Preserve the original photo, generate a readable scan-like PDF, and support later retrieval of saved scan files. ## Runtime Requirements - Default OCR path: local Tesseract CLI - Required local dependencies for the stable path: - `tesseract` - Python packages used by bundled scripts: `opencv-python` or `cv2`, `Pillow`, and either `reportlab` or `ocrmypdf` depending on the active PDF path - Optional/experimental OCR path: - PaddleOCR-based script exists but is not the default stable engine - No cloud upload is required for core operation - The skill assumes bundled helper scripts under `scripts/` are present and callable by the host agent ## Workflow 1. Confirm the trigger. - `스캔` / `scan` — one-page processing - `스캔연속` / `scan multi` — combine multiple photos into one searchable PDF - `스캔찾아` / `scan find` — search previously saved scan files 2. Collect attached image files or search keywords. 3. Apply document-style cleanup when possible. - straighten or rotate when needed - improve contrast for readability - keep output practical rather than over-processed 4. Run OCR in Korean and English. 5. Build the filename as: - `YYYY-MM-DD + headline text` - derive headline text from the top 2 to 3 OCR lines 6. Create a searchable PDF. 7. Save output to the local storage destination. 8. Keep the original image with the processed result. 9. For retrieval requests, search by date, headline text, or OCR keyword in the configured scan storage path. 10. Return: - filename - save location - OCR title line ## Storage Rules - Default local staging/search path: `daily-scan-storage/YYYY-MM` - This skill is designed for local scan creation and retrieval only - Use year/month folder structure - Do not auto-classify document types ## Operating Rules - For multi-page capture, combine pages into one PDF only when the trigger is `스캔연속` or `scan multi` - OCR language defaults to Korean plus English - Retrieval requests should search existing saved scan outputs before asking follow-up questions - Keep replies concise ## Failure Handling - If OCR fails, still save the PDF when possible - If headline extraction fails, ask the user what title to use - If OCR fails, explicitly report that OCR failed - Preserve the original image unless the user later asks otherwise ## Current Limits - Korean searchable PDF quality depends on OCR engine quality and PDF text-layer handling - The Tesseract path is the current stable default - The PaddleOCR path is experimental and should not be treated as the default engine - This skill does not require external upload tools or cloud credentials ## Output Contract Return only the practical result: - saved filename - save location - extracted title line when available ## Resources ### scripts/ Bundled scripts are used for: - image cleanup - OCR execution - searchable PDF generation - saved scan retrieval ### references/ Store implementation notes for OCR engine choice and filename normalization if the skill grows more complex.

path: scripts/build_searchable_pdf.py
content: from pathlib import Path
import sys
import json
import cv2
from PIL import Image
from datetime import datetime
import re
import subprocess


def enhance_document(image_path: str, output_path: str) -> str:
    img = cv2.imread(image_path)
    if img is None:
        raise FileNotFoundError(image_path)

    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    gray = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)

    thresh = cv2.adaptiveThreshold(
        gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 15
    )

    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
    cleaned = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel)

    cv2.imwrite(output_path, cleaned)
    return output_path


def run_ocr(image_path: str, lang: str = 'kor+eng') -> str:
    result = subprocess.run(
        ['tesseract', image_path, 'stdout', '-l', lang, '--psm', '6'],
        capture_output=True,
        text=True,
        encoding='utf-8',
        errors='ignore'
    )
    if result.returncode != 0:
        raise RuntimeError(result.stderr.strip() or 'tesseract failed')
    return result.stdout.strip()


def sanitize_filename(text: str) -> str:
    text = re.sub(r'\s+', ' ', text).strip()
    text = re.sub(r'[\\/:*?"<>|]', '', text)
    return text[:80] or 'untitled'


def extract_headline(ocr_text: str) -> str:
    lines = [line.strip() for line in ocr_text.splitlines() if line.strip()]
    headline = ' '.join(lines[:3])
    return sanitize_filename(headline)


def make_filename(ocr_text: str) -> str:
    date_part = datetime.now().strftime('%Y-%m-%d')
    headline = extract_headline(ocr_text)
    return f'{date_part}_{headline}.pdf'


def build_image_pdf(image_paths: list[str], pdf_path: str) -> str:
    images = []
    for image_path in image_paths:
        img = Image.open(image_path).convert('RGB')
        images.append(img)

    first, rest = images[0], images[1:]
    first.save(pdf_path, save_all=True, append_images=rest)
    return pdf_path


def apply_ocrmypdf(input_pdf: str, output_pdf: str) -> str:
    result = subprocess.run(
        ['ocrmypdf', '--force-ocr', '-l', 'kor+eng', input_pdf, output_pdf],
        capture_output=True,
        text=True,
        encoding='utf-8',
        errors='ignore'
    )
    if result.returncode != 0:
        raise RuntimeError(result.stderr.strip() or result.stdout.strip() or 'ocrmypdf failed')
    return output_pdf


if __name__ == '__main__':
    if len(sys.argv) < 3:
        print('usage: python build_searchable_pdf.py <output_dir> <image1> [image2 ... imageN]')
        sys.exit(1)

    output_dir = Path(sys.argv[1])
    if output_dir.name == 'AUTO':
        output_dir = Path.cwd() / 'daily-scan-storage' / datetime.now().strftime('%Y-%m')
    source_images = [Path(p) for p in sys.argv[2:]]
    output_dir.mkdir(parents=True, exist_ok=True)

    enhanced_paths = []
    ocr_texts = []

    for img_path in source_images:
        enhanced_path = output_dir / f'enhanced_{img_path.name}'
        enhance_document(str(img_path), str(enhanced_path))
        enhanced_paths.append(str(enhanced_path))
        ocr_texts.append(run_ocr(str(enhanced_path)))

    joined_ocr = '\n'.join(ocr_texts)
    filename = make_filename(joined_ocr)
    image_pdf = output_dir / 'temp_image.pdf'
    pdf_out = output_dir / filename
    build_image_pdf(enhanced_paths, str(image_pdf))
    apply_ocrmypdf(str(image_pdf), str(pdf_out))

    result = {
        'pdf': str(pdf_out),
        'headline': extract_headline(joined_ocr),
        'ocr_failed': not bool(joined_ocr.strip())
    }
    print(json.dumps(result, ensure_ascii=False))


path: scripts/build_searchable_pdf_paddle.py
content: from pathlib import Path
import sys
import json
import cv2
from reportlab.pdfgen import canvas
from reportlab.lib.utils import ImageReader
from PIL import Image
from datetime import datetime
import re
from paddleocr import PaddleOCR

ocr_engine = PaddleOCR(use_textline_orientation=True, lang='korean')


def enhance_document(image_path: str, output_path: str) -> str:
    img = cv2.imread(image_path)
    if img is None:
        raise FileNotFoundError(image_path)

    h, w = img.shape[:2]
    if h > w:
        img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)

    lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB)
    l, a, b = cv2.split(lab)
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
    l2 = clahe.apply(l)
    enhanced = cv2.merge((l2, a, b))
    enhanced = cv2.cvtColor(enhanced, cv2.COLOR_LAB2BGR)

    cv2.imwrite(output_path, enhanced)
    return output_path


def run_ocr(image_path: str) -> str:
    result = ocr_engine.predict(image_path)
    lines = []
    for item in result:
        if isinstance(item, dict):
            lines.extend(item.get('rec_texts', []))
    return '\n'.join([str(line).strip() for line in lines if str(line).strip()])


def sanitize_filename(text: str) -> str:
    text = re.sub(r'\s+', ' ', text).strip()
    text = re.sub(r'[\\/:*?"<>|]', '', text)
    return text[:80] or 'untitled'


def extract_headline(ocr_text: str) -> str:
    lines = [line.strip() for line in ocr_text.splitlines() if line.strip()]
    headline = ' '.join(lines[:3])
    return sanitize_filename(headline)


def make_filename(ocr_text: str) -> str:
    date_part = datetime.now().strftime('%Y-%m-%d')
    headline = extract_headline(ocr_text)
    return f'{date_part}_{headline}.pdf'


def build_searchable_pdf(image_paths: list[str], pdf_path: str, ocr_texts: list[str]) -> str:
    c = None
    for idx, image_path in enumerate(image_paths):
        img = Image.open(image_path)
        width, height = img.size
        if c is None:
            c = canvas.Canvas(pdf_path, pagesize=(width, height))
        else:
            c.setPageSize((width, height))

        c.drawImage(ImageReader(img), 0, 0, width=width, height=height)
        text = c.beginText(0, height)
        text.setTextRenderMode(3)
        text.setFont('Helvetica', 10)
        for line in ocr_texts[idx].splitlines():
            text.textLine(line[:500])
        c.drawText(text)
        c.showPage()

    c.save()
    return pdf_path


if __name__ == '__main__':
    if len(sys.argv) < 3:
        print('usage: python build_searchable_pdf_paddle.py <output_dir> <image1> [image2 ... imageN]')
        sys.exit(1)

    output_dir = Path(sys.argv[1])
    source_images = [Path(p) for p in sys.argv[2:]]
    output_dir.mkdir(parents=True, exist_ok=True)

    enhanced_paths = []
    ocr_texts = []

    for img_path in source_images:
        enhanced_path = output_dir / f'enhanced_{img_path.name}'
        enhance_document(str(img_path), str(enhanced_path))
        enhanced_paths.append(str(enhanced_path))
        ocr_texts.append(run_ocr(str(enhanced_path)))

    joined_ocr = '\n'.join(ocr_texts)
    filename = make_filename(joined_ocr)
    pdf_out = output_dir / filename
    build_searchable_pdf(enhanced_paths, str(pdf_out), ocr_texts)

    result = {
        'pdf': str(pdf_out),
        'headline': extract_headline(joined_ocr),
        'ocr_failed': not bool(joined_ocr.strip())
    }
    print(json.dumps(result, ensure_ascii=False))


path: scripts/search_scans.py
content: from pathlib import Path
import sys


def main():
    if len(sys.argv) < 3:
        print('usage: python search_scans.py <root_dir> <query>')
        sys.exit(1)

    root = Path(sys.argv[1])
    query = sys.argv[2].lower()
    scan_root = root / 'daily-scan-storage'

    if not scan_root.exists():
        print('[]')
        return

    matches = []
    for p in scan_root.rglob('*.pdf'):
        if query in p.name.lower():
            matches.append(str(p))

    print('\n'.join(matches[:20]))


if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)