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
}
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),
];
}
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,
]);
}
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.