Para escanear un documento a PDF en una app de Capacitor, abre el escáner nativo con @capgo/capacitor-document-scanner, toma las páginas JPEG que devuelve y combínalas en un solo PDF con pdf-lib. Desde ahí escribes el PDF con @capacitor/filesystem y lo pasas a la hoja de compartir, un visor de PDF o el diálogo de impresión del sistema. Esta guía recorre el flujo completo con code que se ejecuta en Capacitor 8, tanto en iOS como en Android.
Cómo funciona el escaneo a PDF en iOS y Android
Ambas plataformas incluyen un escáner de documentos del sistema, así que no necesitas implementar la detección de bordes ni la corrección de perspectiva tú mismo:
| iOS | Android | |
|---|---|---|
| Escáner nativo | VisionKit VNDocumentCameraViewController |
Google ML Kit Document Scanner (Play services) |
| Detección de bordes y recorte | Sí | Sí |
| Filtros / limpieza | Integrado en VisionKit | base, base_with_filter, full modos (eliminación de manchas y dedos en full) |
| Límite de páginas por sesión | 24 | 1 a 24, predeterminado 20 |
| Solicitud de permiso de cámara | Sí, se requiere NSCameraUsageDescription |
No, el escáner se ejecuta en Play services |
| Funciona en emulador / simulador | No | No |
| Salida del plugin | Páginas JPEG | Páginas JPEG |
El plugin devuelve imágenes, no un PDF. Es una separación deliberada: tú controlas el tamaño de página, la compresión y el nombre del archivo, y el mismo PDF code funciona para fotos seleccionadas de la galería o archivos recibidos de tu backend.
Instala los plugins
bun add @capgo/capacitor-document-scanner @capacitor/filesystem pdf-lib
bunx cap sync
El plugin de escáner sigue las versiones principales de Capacitor, por lo que la v8 del plugin apunta a Capacitor 8. pdf-lib es una biblioteca de JavaScript pura sin code nativo, lo que significa que se ejecuta en el WebView y no requiere sincronización.
Configurar iOS
Añade un string de uso de cámara a ios/App/App/Info.plist. VisionKit abre la cámara, e iOS cierra la app si falta la clave:
<key>NSCameraUsageDescription</key>
<string>We use the camera to scan your documents.</string>
El Swift package del plugin apunta a iOS 15 o superior, lo que coincide con el mínimo de Capacitor 8. Recuerda que las subidas a la App Store ahora requieren Xcode 26, así que haz el build con él antes de enviar. Si no tienes un Mac, Build de Capgo puede generar el binario de iOS firmado en la nube.
Configuración de Android
Nada que añadir a AndroidManifest.xml. El scanner de ML Kit es un módulo de Google Play services que se descarga al primer uso y gestiona la cámara por sí mismo, así que tu app no solicita CAMERA. El dispositivo necesita Google Play services, lo que descarta la mayoría de emuladores y los teléfonos sin Google. Planifica un fallback (una captura de cámara normal) si lanzas la app en mercados donde no haya Play services.
Escanea las páginas
Empieza con una función que abra el scanner y devuelva las rutas de las páginas, o null cuando el usuario cancele:
import {
DocumentScanner,
ResponseType,
ScannerMode,
ScanDocumentResponseStatus,
} from '@capgo/capacitor-document-scanner';
export async function scanPages(): Promise<string[] | null> {
const result = await DocumentScanner.scanDocument({
responseType: ResponseType.ImageFilePath,
maxNumDocuments: 20,
letUserAdjustCrop: true,
scannerMode: ScannerMode.Full, // Android only
croppedImageQuality: 90, // Android only
});
if (result.status === ScanDocumentResponseStatus.Cancel) {
return null;
}
return result.scannedImages ?? [];
}
Hay algunas opciones que conviene conocer:
responseType:ImageFilePath(por defecto) devuelve rutas de archivo,Base64devuelve cadenas base64. Prefiere rutas de archivo para documentos de varias páginas. Las cadenas base64 de 20 páginas en alta resolución pueden alcanzar decenas de megabytes y cada byte cruza el puente Capacitor.maxNumDocuments: establécelo en1para flujos de una sola página, como recibos o tarjetas de identidad.reviewCapturedDocument: muestra cada página capturada antes de continuar, útil cuando los usuarios escanean documentos importantes.brightnessycontrast: posprocesamiento aplicado a las imágenes. Un contraste de1.2a1.5suele ayudar con recibos descoloridos.scannerMode(Android):Fullañade limpieza basada en ML,Basees más rápido y mantiene la imagen intacta.
Dónde terminan los archivos cambia según la plataforma. En Android, las páginas van al directorio de caché de la app. En iOS, el plugin las escribe en el directorio Documents de la app. Ninguna de las dos ubicaciones sirve para almacenar a largo plazo, así que el flujo de abajo crea el PDF, lo guarda donde quieras y elimina las imágenes de las páginas.
Combina las páginas en un solo PDF
pdf-lib puede incrustar JPEGs directamente sin recodificarlos, así que el tamaño del PDF se mantiene cerca de la suma de los tamaños de las páginas. Lee cada página mediante Capacitor.convertFileSrc, que convierte una ruta nativa en una URL que el WebView puede cargar:
import { Capacitor } from '@capacitor/core';
import { PDFDocument } from 'pdf-lib';
const A4 = { width: 595.28, height: 841.89 }; // points
export async function pagesToPdf(paths: string[]): Promise<string> {
const pdf = await PDFDocument.create();
pdf.setTitle('Scanned document');
pdf.setCreator('My Capacitor App');
for (const path of paths) {
const bytes = await fetch(Capacitor.convertFileSrc(path)).then((r) => r.arrayBuffer());
const image = await pdf.embedJpg(bytes);
// Fit the image inside an A4 page, keeping its aspect ratio
const scale = Math.min(A4.width / image.width, A4.height / image.height);
const width = image.width * scale;
const height = image.height * scale;
const page = pdf.addPage([A4.width, A4.height]);
page.drawImage(image, {
x: (A4.width - width) / 2,
y: (A4.height - height) / 2,
width,
height,
});
}
// Base64 without the data URI prefix, ready for Filesystem.writeFile
return pdf.saveAsBase64();
}
Si prefieres páginas que coincidan exactamente con el papel escaneado, reemplaza la lógica de A4 con pdf.addPage([image.width, image.height]) y dibuja la imagen a tamaño completo. Los recibos se ven mejor así porque son largos y estrechos.
Alternativa: renderizar el PDF de forma nativa
Si tu app ya genera PDFs desde HTML (facturas, informes), puedes reutilizar esa ruta con @capgo/capacitor-pdf-generator. Renderiza una cadena HTML con el motor de impresión de la plataforma y devuelve base64:
import { PdfGenerator } from '@capgo/capacitor-pdf-generator';
import { Capacitor } from '@capacitor/core';
const html = `
<html><body style="margin:0">
${paths
.map((p) => `<img src="${Capacitor.convertFileSrc(p)}" style="width:100%;page-break-after:always" />`)
.join('')}
</body></html>`;
const result = await PdfGenerator.fromData({
data: html,
documentSize: 'A4',
orientation: 'portrait',
type: 'base64',
});
if (result.type === 'base64') {
// result.base64 contains the PDF
}
Es útil cuando quieres una portada, un título o un pie de página con fecha en cada página. Para conversión pura de imagen a PDF, pdf-lib te da más control sobre la geometría de la página y la compresión de imágenes.
Guardar el PDF
Escribe la salida base64 con @capacitor/filesystem. Elige el directorio según cuánto tiempo debe durar el archivo:
| Directorio | Android | iOS | Úsalo para |
|---|---|---|---|
Directory.Cache |
Caché de la app, puede ser borrada por el SO | Library/Caches, puede ser borrada por el SO |
PDFs que subas o compartas de inmediato |
Directory.Data |
Archivos privados de la app, eliminados al desinstalar | app Documents carpeta, respaldada en iCloud |
PDF que tu app gestiona y vuelve a mostrar más tarde |
Directory.LibraryNoCloud |
Archivos privados de la app | Library/NoCloud, sin respaldo |
PDF grandes que puedes volver a descargar |
En iOS, Directory.Data y Directory.Documents apuntan a la misma carpeta. Añade UIFileSharingEnabled y LSSupportsOpeningDocumentsInPlace a Info.plist si los usuarios deben ver esos PDFs en la app Archivos.
import { Filesystem, Directory } from '@capacitor/filesystem';
export async function savePdf(base64: string, name: string) {
const fileName = `${name.replace(/[^a-z0-9-_]/gi, '_')}.pdf`;
const { uri } = await Filesystem.writeFile({
path: `scans/${fileName}`,
data: base64,
directory: Directory.Data,
recursive: true,
});
return uri; // file:// URI you can share, open, or print
}
export async function deletePages(paths: string[]) {
await Promise.all(
paths.map((path) => Filesystem.deleteFile({ path }).catch(() => undefined)),
);
}
En plataformas nativas, writeFile trata un string sin una encoding opción como base64 y escribe los bytes binarios. No pases Encoding.UTF8 para PDFs, o el archivo se corromperá.
Si el usuario debe poder encontrar el PDF en su carpeta de Descargas en Android, usa @capgo/capacitor-file-sharerde save() método, que escribe en MediaStore Downloads sin ningún permiso de almacenamiento en Android 10+. En iOS, la misma llamada abre la hoja de compartir para que el usuario elija “Guardar en Archivos”.
Integrar todo el flujo
export async function scanToPdf(title: string) {
const pages = await scanPages();
if (!pages || pages.length === 0) return null;
try {
const base64 = await pagesToPdf(pages);
return await savePdf(base64, title);
} finally {
await deletePages(pages);
}
}
Conéctalo a un botón y muestra un spinner mientras pagesToPdf ejecuciones. En un teléfono Android de gama media, 10 páginas suelen tardar menos de unos segundos, ya que los datos JPEG se copian, no se decodifican.
Compartir, abrir o imprimir el PDF
Compartir con la hoja de compartir del sistema
import { FileSharer } from '@capgo/capacitor-file-sharer';
await FileSharer.share({
filename: 'contract.pdf',
path: pdfUri,
contentType: 'application/pdf',
subject: 'Signed contract',
});
@capgo/capacitor-file-sharer copia el archivo en su propia carpeta de caché y lo sirve a través de su propio FileProvider en Android, así que no hace falta editar file_paths.xml, incluso si el PDF está en Directory.Data. Si usas el oficial @capacitor/share plugin oficial en su lugar, su files opción funciona directamente solo para el directorio de caché en Android. Las demás carpetas deben añadirse a android/app/src/main/res/xml/file_paths.xml.
Abrir en la app de PDF del usuario
Para una vista previa rápida, el file opener de la comunidad pasa el archivo a Quick Look en iOS o al visor de PDF predeterminado en Android:
bun add @capacitor-community/file-opener
bunx cap sync
import { FileOpener } from '@capacitor-community/file-opener';
await FileOpener.open({
filePath: pdfUri,
contentType: 'application/pdf',
});
Imprimir
@capgo/capacitor-printer abre UIPrintInteractionController en iOS y PrintManager en Android:
import { Printer } from '@capgo/capacitor-printer';
await Printer.printPdf({
name: 'Signed contract',
path: pdfUri,
});
El name se convierte en el nombre del trabajo de impresión y en el nombre de archivo por defecto si el usuario elige “Guardar como PDF” en el diálogo de impresión.
Subir el PDF
Para enviar el PDF a tu backend, léelo como un Blob y envíalo con fetch, o usa un uploader nativo para archivos grandes para que la subida continúe cuando la app pase a segundo plano:
import { Uploader } from '@capgo/capacitor-uploader';
const { id } = await Uploader.startUpload({
filePath: pdfUri,
serverUrl: 'https://api.example.com/documents',
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
mimeType: 'application/pdf',
});
Consulta la plugin uploader de Capacitor para eventos de progreso y subidas multipart.
Haz que los PDF escaneados sean buscables
Los PDF solo de imagen no se pueden buscar ni copiar de ellos. Si los usuarios esperan buscar en sus escaneos:
- Ejecuta OCR en cada imagen de página antes de eliminarla. Las opciones on-device incluyen ML Kit Text Recognition en Android y el framework Vision en iOS, accesibles mediante plugins de la comunidad de Capacitor, o un servicio de OCR en el servidor.
- Almacena el texto reconocido en tu base de datos junto a la ruta del PDF y busca ahí.
- Si el PDF debe contener texto, genéralo en el servidor con una herramienta OCR que escriba una capa de texto invisible, como OCRmyPDF.
Para la mayoría de las apps, el paso 2 es suficiente y evita incluir un segundo modelo grande en la app.
Solución de problemas
“El escáner de documentos no es compatible con emuladores de Android”: es lo esperado. Usa un dispositivo físico con Google Play services.
La app se cierra en iOS justo al abrir el escáner: NSCameraUsageDescription is missing from Info.plist, o el usuario denegó el acceso a la cámara anteriormente. Revisa Ajustes, Privacidad, Cámara.
embedJpg lanza “SOI not found”: los bytes no son un JPEG. Esto ocurre si pasaste una data URL base64 donde pdf-lib esperaba bytes crudos, o si cargaste una ruta que devolvió una página de error HTML. Registra Capacitor.convertFileSrc(path) y ábrelo en el inspector de Safari o Chrome.
El PDF es enorme: más baja croppedImageQuality (Android) o vuelve a comprimir las páginas con @capgo/capacitor-file-compressor antes de incrustarlas. Una calidad de 70 a 80 suele ser legible para documentos de texto.
Las páginas aparecen rotadas: rótalas dentro de pdf-lib con page.setRotation(degrees(90)) o deja que el usuario corrija la rotación en la pantalla de revisión del escáner con reviewCapturedDocument: true.
Compartir falla en Android con FileUriExposedException o “Failed to find configured root”: pasaste una ruta fuera de las carpetas declaradas en file_paths.xml a @capacitor/share. Usa el plugin file sharer o copia el PDF a Directory.Cache primero.
Old page images fill up storage on iOS: el escáner escribe JPEGs en el directorio Documents. Elimínalos después de crear el PDF, como se muestra en scanToPdf.
Publicar actualizaciones del flujo de escaneo
La mayor parte de esta función está en TypeScript: el diseño del PDF, el nombre de archivo, la lógica de upload y la UI. Los cambios en esas partes no requieren una nueva versión en la tienda. Con actualizaciones Live Update de Capgo puedes enviar un fix al layout del PDF o al endpoint de upload a todos los usuarios el mismo día, mientras que los cambios nativos (añadir el plugin, editar Info.plist) siguen pasando por un build normal. La documentación del escáner de documentos consulta todas las opciones si necesitas ajustar más el escáner.