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 al menú de compartir, a un visor de PDF o al diálogo de impresión del sistema. Esta guía recorre todo el flujo 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 programar tú mismo la detección de bordes o la corrección de perspectiva:
| iOS | Android | |
|---|---|---|
| Escáner nativo | VisionKit VNDocumentCameraViewController |
Google ML Kit Document Scanner (Play services) |
| Detección de bordes y recorte | Sí | Sí |
| Filtros / limpieza | VisionKit integrado | base, base_with_filter, full modos (eliminación de manchas y dedos en full) |
| Límite de páginas por sesión | 24 | De 1 a 24, por defecto 20 |
| Prompt de permiso de cámara | Sí, requiere NSCameraUsageDescription |
No, el scanner corre en Play services |
| Funciona en emulador / simulador | No | No |
| Output 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 tanto para fotos de la galería como para archivos recibidos de tu backend.
Instala los plugins
bun add @capgo/capacitor-document-scanner @capacitor/filesystem pdf-lib
bunx cap sync
El plugin de scanner sigue Capacitor versiones mayores, por lo que la v8 del plugin apunta a Capacitor 8. pdf-lib es una librería de JavaScript puro sin code nativo, lo que significa que se ejecuta en el WebView y no requiere sincronización.
iOS setup
Añade un string de uso de cámara a ios/App/App/Info.plist. VisionKit abre la cámara, e iOS cerrará 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 los uploads a la App Store ahora requieren Xcode 26, así que haz el build con él antes de enviar. Si no tienes un Mac, Build con 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, por lo que tu app no solicita CAMERA. El dispositivo requiere Google Play services, lo que excluye la mayoría de los emuladores y teléfonos sin servicios de Google. Planifica un fallback (una captura de cámara normal) si lanzas en mercados donde Play services no esté disponible.
Escanear 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 cancela:
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 ?? [];
}
Vale la pena conocer estas opciones:
responseType:ImageFilePath(por defecto) devuelve rutas de archivos,Base64devuelve strings en base64. Prefiere rutas de archivos para documentos de varias páginas. Los strings base64 de 20 páginas en alta resolución pueden alcanzar decenas de megabytes y cada byte cruza el bridge de Capacitor.maxNumDocuments: cámbialo a1para flujos de una sola página como recibos o DNI.reviewCapturedDocument: muestra cada página capturada antes de continuar, ideal para escanear documentos importantes.brightnessycontrast: post-procesamiento aplicado a las imágenes. Un contraste de1.2a1.5suele ayudar con recibos desgastados.scannerMode(Android):Fullañade limpieza basada en ML,Basees más rápido y mantiene la imagen intacta.
La ubicación de los archivos varía 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 ubicación es ideal para almacenamiento a largo plazo, por lo que el siguiente flujo genera el PDF, lo guarda donde prefieras y elimina las imágenes de las páginas.
Combina las páginas en un solo PDF
pdf-lib puede incrustar JPEGs directamente sin re-codificarlos, por lo que el tamaño del PDF será similar a la suma de las páginas. Lee cada página mediante Capacitor.convertFileSrc, que convierte una ruta nativa en una URL que la 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, cambia la lógica de A4 por pdf.addPage([image.width, image.height]) y dibuja la imagen a tamaño completo. Los recibos se ven mejor así por ser largos y estrechos.
Alternativa: renderizar el PDF de forma nativa
Si tu app ya genera PDFs desde HTML (facturas, reportes), puedes reutilizar ese flujo con @capgo/capacitor-pdf-generator. Renderiza un string HTML con el motor de impresión de la plataforma y devuelve un 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
}
Esto es útil si quieres una portada, un título o un footer con fecha en cada página. Para convertir imagen a PDF, pdf-lib te da más control sobre la geometría de la página y la compresión de imagen.
Guardar el PDF
Escribe el output en base64 con @capacitor/filesystem. Elige el directorio según la permanencia del archivo:
| Directorio | Android | iOS | Úsalo para |
|---|---|---|---|
Directory.Cache |
Caché de la app, el SO puede borrarla | Library/Caches, el SO puede borrarla |
PDFs que subes o compartes al instante |
Directory.Data |
Archivos privados, se borran al desinstalar | App Documents carpeta, con copia en iCloud |
PDFs que la app gestiona y muestra luego |
Directory.LibraryNoCloud |
Archivos privados de la app | Library/NoCloud, sin copia de seguridad |
PDFs pesados que puedes descargar de nuevo |
En iOS, Directory.Data y Directory.Documents apunta 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 necesidad de permisos de almacenamiento en Android 10+. En iOS, la misma llamada abre el share sheet para que el usuario elija “Guardar en Archivos”.
Integra 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 se ejecuta. En un Android de gama media, 10 páginas suelen tardar mucho menos de unos pocos segundos, ya que los datos JPEG se copian, no se decodifican.
Comparte, abre o imprime el PDF
Comparte con el share sheet 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 cache y lo sirve a través de su propio FileProvider en Android, así que no necesitas 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 cache en Android. Debes añadir otras carpetas 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 haz un post 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 el plugin uploader de Capacitor para eventos de progreso y subidas multipart.
Haz que los PDFs escaneados sean buscables
Image-only PDFs cannot be searched or copied from. If users expect to search their scans:
- Ejecuta OCR en cada imagen de página antes de borrarla. Las opciones on-device incluyen ML Kit Text Recognition en Android y el framework Vision en iOS, ambos accesibles mediante plugins de la comunidad de Capacitor; también puedes usar un servicio de OCR en el servidor.
- Guarda el texto reconocido en tu base de datos junto a la ruta del PDF y busca allí.
- Si el PDF en sí debe contener texto, genéralo en el servidor con una herramienta de 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 pesado en la app.
Solución de problemas
“El escáner de documentos no es compatible con los emuladores de Android”: es lo esperado. Usa un dispositivo físico con Google Play services.
La app se cierra en iOS en cuanto se abre el escáner: NSCameraUsageDescription is missing from Info.plist, o el usuario denegó el acceso a la cámara. Revisa Ajustes > Privacidad > Cámara.
embedJpg lanza “SOI not found”: los bytes no son un JPEG. Esto pasa si pasaste un data URL en base64 donde pdf-lib esperaba bytes puros, o si obtuviste una ruta que devolvió una página de error HTML. Loguea Capacitor.convertFileSrc(path) y ábrelo en el inspector de Safari o Chrome.
El PDF es enorme: reduce 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: rota dentro de pdf-lib con page.setRotation(degrees(90)) o permite que el usuario corrija la rotación en la pantalla de revisión con reviewCapturedDocument: true.
El error al compartir 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 de file sharer o copia el PDF a Directory.Cache primero.
Old page images fill up storage on iOS: el scanner escribe JPEGs en el directorio Documents. Elimínalos tras generar el PDF, como en scanToPdf.
Lanzando actualizaciones al flujo de escaneo
La mayor parte de esta funcionalidad está en TypeScript: el layout del PDF, el nombrado de archivos, la lógica de upload y la UI. Los cambios en estas partes no requieren un nuevo release en la tienda. Con Live Updates de Capgo puedes publicar un fix del layout del PDF o del endpoint de upload para 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. El documentación del scanner de documentos revisa todas las opciones si necesitas ajustar más el scanner.