TL;DR. Stack: PHP 8.3 + CodeIgniter 4 + smalot/pdfparser + IA opcional (OpenAI / Claude) + ZipArchive para generar SCORM. Pipeline: upload → validación → parse → estructuración (manual o IA) → export SCORM. Gotchas principales: ext_in case-sensitive, PDFs escaneados sin OCR, CSRF con tokenRandomize=true rompiendo APIs JSON, y setup-server.sh destruyendo files de appstarter con rsync --delete. Lo solucionamos todo — te contamos cómo.

1. Arquitectura del pipeline

El pipeline tiene 5 fases. Cada una puede fallar independientemente y cada una tiene su propio contrato de entrada y salida:

PDF ─┬→ [1. Upload + validación]
     │   │
     │   └→ multipart/form-data · valida ext/mime/size · stored path
     │
     ├→ [2. Parse]
     │   │
     │   └→ smalot/pdfparser · texto por página · metadatos PDF
     │
     ├→ [3. Estructuración]
     │   │
     │   └→ módulos {title, text} · manual o vía LLM
     │
     ├→ [4. Editor visual]
     │   │
     │   └→ Alpine.js + autosave JSON API
     │
     └→ [5. Export SCORM]
         │
         └→ ZipArchive · imsmanifest.xml · SCOs HTML · paquete .zip

El punto clave es que cada fase es idempotente: puedes re-ejecutar desde cualquier fase sin corromper los datos previos. El content_json del curso guarda el output de fase 2, y fase 3/4/5 lo modifican añadiendo campos sin perder el original.

2. Fase 1: upload + validación

En CodeIgniter 4 con rules nativas. Aquí está el controlador simplificado:

public function pdfUpload(): RedirectResponse
{
    $rules = [
        'pdf' => [
            'label' => 'PDF',
            'rules' => 'uploaded[pdf]|ext_in[pdf,pdf,PDF]|'
                     . 'mime_in[pdf,application/pdf]|max_size[pdf,32768]',
        ],
        'title_override' => 'permit_empty|max_length[200]',
    ];

    if (! $this->validate($rules)) {
        return redirect()->back()
            ->withInput()
            ->with('errors', $this->validator->getErrors());
    }

    $file = $this->request->getFile('pdf');
    $tenantDir = WRITEPATH . 'uploads/pdfs/' . (int) session('tenant_id');
    if (! is_dir($tenantDir)) mkdir($tenantDir, 0775, true);

    $storedName = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.pdf';
    $file->move($tenantDir, $storedName);
    $storedPath = $tenantDir . '/' . $storedName;
    // ... continúa a fase 2
}
Gotcha 1 · Sintaxis de ext_in con fichero. La primera vez lo escribimos ext_in[pdf,PDF] y fallaba en todos los PDFs con extensión en minúscula. La sintaxis real es ext_in[field_name,ext1,ext2,...] — el primer parámetro es el nombre del campo, no una extensión. Así que admitía solo .PDF mayúscula. Corregido a ext_in[pdf,pdf,PDF].

Aislamos los uploads por tenant en subcarpetas writable/uploads/pdfs/{tenant_id}/. Evita colisiones de filename y simplifica auditoría + borrado al dar de baja un tenant.

3. Fase 2: parse del PDF

Librería: smalot/pdfparser. PHP puro, sin dependencias nativas. Funciona con PDFs textuales; para escaneados hace falta OCR (ver gotcha más abajo).

use Smalot\PdfParser\Parser as PdfParser;

try {
    $parser = new PdfParser();
    $pdf = $parser->parseFile($storedPath);
} catch (\Throwable $e) {
    @unlink($storedPath);
    log_message('error', 'PDF parse fail: ' . $e->getMessage());
    return redirect()->back()
        ->with('error', 'No se pudo parsear el PDF');
}

$details = $pdf->getDetails();  // Title, Author, Subject, Producer...
$pages   = $pdf->getPages();

$modules = [];
foreach ($pages as $i => $page) {
    $text = trim((string) $page->getText());
    $modules[] = [
        'index'  => $i + 1,
        'title'  => 'Página ' . ($i + 1),
        'text'   => $text,
        'length' => strlen($text),
    ];
}
Gotcha 2 · PDFs escaneados. Si el PDF es una imagen escaneada (muy común en contratos antiguos, manuales viejos), getText() devuelve vacío. En el roadmap de CreaForm tenemos integración con Tesseract vía thiagoalessio/tesseract-ocr-for-php, pero requiere binario en el servidor (apt install tesseract-ocr-spa). Para MVP detectamos "página sin texto" y mostramos aviso al usuario.

4. Fase 3: estructuración (manual o IA)

Por defecto, cada página del PDF es un módulo. Funciona para documentos bien estructurados. Para documentos largos o mal formateados, añadimos una capa opcional con LLM que reorganiza:

// Pseudo-código de la estructuración con LLM (opcional)
$prompt = "Dado el siguiente texto de un curso corporativo, "
        . "propón una división en módulos pedagógicos coherentes. "
        . "Cada módulo debe tener título breve y objetivos de aprendizaje. "
        . "Responde en JSON: { modules: [{title, text, objectives:[]}] }.\n\n"
        . substr($fullText, 0, 60000);  // cap a 60k chars

$response = $llm->chat([
    'model' => 'claude-sonnet-4',
    'response_format' => ['type' => 'json_object'],
    'messages' => [['role' => 'user', 'content' => $prompt]],
]);

$structured = json_decode($response, true);

El prompt incluye un system message con el estilo pedagógico que quiere el tenant (definido en su cuenta: "cursos breves de 15 min", "formación compliance con glosario obligatorio"). El output estructurado se guarda en content_json y es lo que el editor visual ya puede mutar.

5. Fase 4: editor visual con autosave

Alpine.js 3 en el cliente + JSON endpoint en el servidor. El editor muta un array modules en memoria y hace save debounced cada 900ms. Endpoint:

public function save(int $id): ResponseInterface
{
    $course = (new CourseModel())
        ->where('id', $id)
        ->where('tenant_id', session('tenant_id'))
        ->first();

    if (! $course) {
        return $this->response->setStatusCode(404)
            ->setJSON(['ok' => false, 'error' => 'not_found']);
    }

    $payload = $this->request->getJSON(true);
    if (! isset($payload['modules']) || ! is_array($payload['modules'])) {
        return $this->response->setStatusCode(400)
            ->setJSON(['ok' => false, 'error' => 'invalid_payload']);
    }

    // Sanitize + merge con content existente + bump version
    $content = json_decode($course['content_json'] ?: '{}', true);
    $content['modules'] = $this->sanitizeModules($payload['modules']);

    (new CourseModel())->update($id, [
        'version'      => (int) $course['version'] + 1,
        'content_json' => json_encode($content, JSON_UNESCAPED_UNICODE),
    ]);

    return $this->response->setJSON([
        'ok' => true, 'version' => (int) $course['version'] + 1,
    ]);
}
Gotcha 3 · CSRF con tokenRandomize=true. CI4 puede randomizar el CSRF token en cada response para ser resistente a BREACH. Pero al mezclar con un cliente JSON API que hace varios GET/POST seguidos, el token se desincroniza y todo POST devuelve 403. Desactivamos tokenRandomize (seguimos protegidos vía cookie+header comparison) y lo documentamos en el .env.example para no volver a pisarlo.

6. Fase 5: export SCORM

SCORM 1.2 es básicamente un zip con un imsmanifest.xml en la raíz y los SCOs (HTML self-contained) referenciados desde él. Usamos ZipArchive nativo:

public function exportScorm12(int $courseId): string
{
    $course = (new CourseModel())->find($courseId);
    $content = json_decode($course['content_json'], true);

    $tmpDir = WRITEPATH . 'tmp/scorm-' . $courseId . '-' . bin2hex(random_bytes(4));
    mkdir($tmpDir, 0775, true);

    // 1. Escribir SCOs (un HTML por módulo)
    foreach ($content['modules'] as $i => $module) {
        $html = view('scorm/sco', [
            'course'  => $course,
            'module'  => $module,
            'index'   => $i,
        ]);
        file_put_contents("$tmpDir/sco_{$i}.html", $html);
    }

    // 2. Escribir imsmanifest.xml
    $manifest = view('scorm/imsmanifest12', [
        'course'  => $course,
        'modules' => $content['modules'],
    ]);
    file_put_contents("$tmpDir/imsmanifest.xml", $manifest);

    // 3. Copiar schemas obligatorios (adlcp_rootv1p2.xsd, etc.)
    foreach (glob(APPPATH . 'scorm/schemas/*') as $schema) {
        copy($schema, $tmpDir . '/' . basename($schema));
    }

    // 4. Empaquetar zip
    $zipPath = $tmpDir . '.zip';
    $zip = new \ZipArchive();
    $zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
    foreach (new \RecursiveIteratorIterator(
        new \RecursiveDirectoryIterator($tmpDir)) as $file) {
        if ($file->isFile()) {
            $zip->addFile($file->getPathname(),
                substr($file->getPathname(), strlen($tmpDir) + 1));
        }
    }
    $zip->close();

    return $zipPath;
}

El SCO HTML incluye el SCORM API wrapper de ADL (SCORM_API_wrapper.js) para comunicarse con el LMS: reporta cmi.core.lesson_status=completed cuando el usuario llega al final y cmi.core.score.raw si hay quiz.

7. Otros gotchas del camino

rsync --delete destruyendo appstarter

En el deploy.sh inicial usamos rsync -az --delete para sincronizar app/. Problema: --delete borra en el destino todo lo que no está en origen. Como nuestro overlay solo trae controllers/models/views custom, borraba spark, app/Config/Paths.php, app/Common.php y todo lo que composer create-project codeigniter4/appstarter había creado. App caía con 500. Solución: --delete solo en landing/ (que sí es nuestro tree completo), modo aditivo en app/.

Límites PHP para uploads grandes

Ubuntu 24.04 LEMP trae upload_max_filesize=2M por defecto. Subimos a 40M + post_max_size=40M + memory_limit=256M + max_execution_time=120. Editar /etc/php/8.3/fpm/php.ini y systemctl reload php8.3-fpm.

Nginx body buffering

Uploads > 16KB van a tmp file (/var/lib/nginx/body/). Verifica client_max_body_size 64M en la vhost. Sin eso, Nginx devuelve 413 antes de que PHP se entere.

¿Quieres un pipeline así para tu empresa?

Construimos pipelines de ingesta con IA a medida de tu caso. Propuesta con alcance y precio cerrado en 72h.

Resumen técnico

CreaForm no es magia — es smalot/pdfparser + CI4 + MySQL + Alpine.js + ZipArchive, con decisiones de arquitectura pragmáticas (multi-tenant shared-schema, versionado de content_json, autosave debounced) y los gotchas de producción documentados. Todo el stack está publicado en la página de CreaForm y lo mantenemos en activo.

Si tienes un caso similar — convertir cualquier documentación en producto interactivo con IA — en IAGEAE construimos pipelines a medida aplicando exactamente este mismo stack y proceso.