@ssi-lib/document-capture
v1.0.3
Published
Captura de fotos y documentos: diálogo fullscreen, cámara o archivo, recorte al recuadro y compresión en canvas.
Readme
@ssi-lib/document-capture
Captura de cédula/ID, documentos y foto de perfil para React (ESM + CJS + tipos). Cámara o galería/explorador, recorte automático al tipo de recuadro configurado y compresión en canvas — sin OpenCV, sin detección automática, sin recortador interactivo.
El objetivo es andar en celulares de gama baja: JPEG, un solo encode, la mejor resolución que la cámara pueda dar sin frenar el arranque del preview, y foco continuo para que el documento salga nítido.
Instalación
npm i @ssi-lib/document-capturePeer deps: react / react-dom (>=18). El CSS se inyecta automáticamente al importar el paquete — no hace falta import './styles.css'.
Los tipos de recuadro
Solo existen tres view: no hay formas genéricas tipo "cuadrado" o "sin recuadro".
| view | Qué es | Ratio (ancho:alto) | Orientación |
| --- | --- | --- | --- |
| 'id' (default) | Cédula / carnet / tarjeta (formato ID-1) | 1.586 : 1 | Horizontal |
| 'document' | Hoja de documento (formato A4) | 1 : 1.414 | Vertical |
| 'profile' | Foto de perfil (cabeza y hombros) | 3 : 4 | Vertical, ~90% del ancho |
Ese ratio se usa para dos cosas, en cámara y en archivo por igual:
- El recuadro guía que se dibuja sobre el preview de la cámara (
profileademás dibuja una silueta de una sola línea — cabeza circular y hombros — para encajar el rostro). - El recorte automático (centrado, tipo
object-fit: cover) que se aplica a la foto final — tanto la que sale de la cámara (recortada al recuadro) como la que el usuario elige de la galería/explorador (recortada al mismo ratio, centrada, sin cropper interactivo).
Se puede leer el ratio exacto en código: VIEW_ASPECT.id === 1.586, VIEW_ASPECT.document === 1 / 1.414, VIEW_ASPECT.profile === 3 / 4.
Uso básico — cédula (caso principal)
import { useState } from 'react';
import { DocumentCapture, type CaptureResult } from '@ssi-lib/document-capture';
export function CaptureCedula() {
const [open, setOpen] = useState(false);
const [shot, setShot] = useState<CaptureResult | null>(null);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Capturar cédula
</button>
<DocumentCapture
open={open}
view="id"
label="Cédula — frente"
placeholder="Alineá la cédula dentro del recuadro"
maxBytes={1_500_000}
onCapture={setShot}
onClose={() => setOpen(false)}
/>
{shot && <img src={shot.dataUrl} alt="" />}
</>
);
}view="id" es el default, así que también vale sin pasarlo:
<DocumentCapture open={open} onCapture={setShot} onClose={() => setOpen(false)} />open controla la visibilidad. onClose se llama al cancelar (× o Escape) y justo después de un onCapture exitoso.
Ejemplo completo — cédula frente y reverso
Patrón típico de onboarding: dos capturas seguidas con la misma cámara, reutilizando el mismo componente.
import { useState } from 'react';
import { DocumentCapture, type CaptureResult } from '@ssi-lib/document-capture';
type Lado = 'frente' | 'reverso' | null;
export function CaptureCedulaCompleta() {
const [lado, setLado] = useState<Lado>(null);
const [frente, setFrente] = useState<CaptureResult | null>(null);
const [reverso, setReverso] = useState<CaptureResult | null>(null);
const listo = frente && reverso;
return (
<>
{!frente && (
<button type="button" onClick={() => setLado('frente')}>
Capturar frente
</button>
)}
{frente && !reverso && (
<button type="button" onClick={() => setLado('reverso')}>
Capturar reverso
</button>
)}
{listo && <p>Cédula completa ✓</p>}
<DocumentCapture
open={lado !== null}
view="id"
label={lado === 'frente' ? 'Cédula — frente' : 'Cédula — reverso'}
placeholder="Alineá la cédula dentro del recuadro"
maxBytes={1_500_000}
onCapture={(result) => {
if (lado === 'frente') setFrente(result);
else setReverso(result);
}}
onClose={() => setLado(null)}
/>
</>
);
}Ejemplo — documento (hoja A4)
<DocumentCapture
open={open}
view="document"
label="Comprobante de domicilio"
placeholder="Que se vea la hoja completa"
maxEdge={1800}
onCapture={setShot}
onClose={() => setOpen(false)}
/>Ejemplo — cámara frontal
<DocumentCapture
open={open}
facing="user"
view="profile"
onCapture={setShot}
onClose={() => setOpen(false)}
/>facing es solo el arranque. Dentro del diálogo el usuario puede invertir la cámara con ↻ aunque el prop diga otra cosa. La frontal se espeja solo en el preview; la foto sale sin espejar. Trasera, normal.
Ejemplo — foto de perfil (view="profile")
Retrato vertical 3:4. En cámara el recuadro usa ~90% del ancho; si no entra en alto, se achica y mantiene el ratio. Lo que se recorta es el recuadro con las marcas de esquina, no el contorno de la silueta.
La silueta es una guía visual, solo en source="camera":
- Una sola línea abierta: empieza abajo a la izquierda, sube el hombro, rodea la cabeza en círculo y termina abajo a la derecha. Sin cruces ni cierre en el pecho.
- Cabeza circular; hombros al ancho del recuadro; torso más corto en vertical; cuello un poco más ancho, no más alto. La figura queda un poco más abajo del borde superior del recuadro.
- Trazo blanco al 50% de opacidad,
stroke-width0.1, difuminado. - El interior es un hueco (se ve la cámara). El resto del recuadro va velado, con un desvanecido suave entre el hueco y el velo — no un corte seco.
- En
source="file"no hay silueta: el recorte es al ratio 3:4, centrado.
Sin copy default: label / placeholder / html los pone el cliente si los necesita. Cámara trasera por default.
import { useState } from 'react';
import { DocumentCapture, type CaptureResult } from '@ssi-lib/document-capture';
export function CapturePerfil() {
const [open, setOpen] = useState(false);
const [shot, setShot] = useState<CaptureResult | null>(null);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Foto de perfil
</button>
<DocumentCapture
open={open}
view="profile"
onCapture={setShot}
onClose={() => setOpen(false)}
/>
{shot && <img src={shot.dataUrl} alt="" />}
</>
);
}Frontal de entrada: facing="user" (el preview se espeja; la foto no).
Ejemplo — galería/explorador (source="file")
source="file" no tiene diálogo propio: apenas open pasa a true se abre directamente el selector nativo del sistema (galería en mobile, explorador en desktop) — no hay recuadro ni pantalla de por medio que tocar primero. Al elegir una imagen, se recorta automáticamente al ratio de view (centrado) antes de comprimir.
import { useState } from 'react';
import { DocumentCapture, type CaptureResult } from '@ssi-lib/document-capture';
export function ElegirDeGaleria() {
const [open, setOpen] = useState(false);
const [shot, setShot] = useState<CaptureResult | null>(null);
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Elegir de galería
</button>
<DocumentCapture
open={open}
source="file"
view="id"
accept="image/*"
maxBytes={1_500_000}
onCapture={setShot}
onClose={() => setOpen(false)}
/>
</>
);
}label/placeholder/html no aplican a source="file" (no hay dónde mostrarlos: no hay diálogo). Si la lectura del archivo falla (por ejemplo un PDF que supera maxBytes), se muestra un toast fijo abajo de la pantalla con el error — eso es lo único que renderiza este modo aparte del <input> oculto.
Si el usuario cancela el selector sin elegir nada, onClose se llama automáticamente en navegadores que soportan el evento cancel del input (Chrome/Firefox recientes). En navegadores sin ese evento (Safari viejo), open se mantiene en true hasta que el consumidor lo cierre por su cuenta.
Ejemplo — cámara o galería con el mismo estado
Patrón común: un botón "Tomar foto" y otro "Elegir de galería", ambos alimentando el mismo flujo.
import { useState } from 'react';
import { DocumentCapture, type CaptureResult } from '@ssi-lib/document-capture';
export function CapturaOGaleria() {
const [open, setOpen] = useState(false);
const [source, setSource] = useState<'camera' | 'file'>('camera');
const [shot, setShot] = useState<CaptureResult | null>(null);
return (
<>
<button
type="button"
onClick={() => {
setSource('camera');
setOpen(true);
}}
>
Tomar foto
</button>
<button
type="button"
onClick={() => {
setSource('file');
setOpen(true);
}}
>
Elegir de galería
</button>
<DocumentCapture
open={open}
source={source}
view="id"
label="Cédula — frente"
placeholder="Alineá la cédula dentro del recuadro"
maxBytes={1_500_000}
onCapture={setShot}
onClose={() => setOpen(false)}
/>
</>
);
}Ejemplo — sin recortar (crop={false})
Entrega la imagen completa (cámara: el frame entero, sin recortar al recuadro que queda solo de guía visual; archivo: la imagen tal cual, sin recorte de aspect ratio).
<DocumentCapture
open={open}
view="document"
crop={false}
onCapture={setShot}
onClose={() => setOpen(false)}
/>Ejemplo — tope de tamaño y formato
<DocumentCapture
open={open}
view="id"
mimeType="image/jpeg"
quality={0.75}
maxEdge={1280}
maxBytes={800_000}
onCapture={setShot}
onClose={() => setOpen(false)}
/>maxBytes primero baja la calidad JPEG (hasta 0.5) y, si no alcanza, baja la resolución en pasos del 82% hasta 6 intentos.
Ejemplo — copy propio con HTML
<DocumentCapture
open={open}
view="id"
html="<strong>Cédula</strong><br/><span style='opacity:.8'>Que no tape las esquinas</span>"
onCapture={setShot}
onClose={() => setOpen(false)}
/>html reemplaza label/placeholder (innerHTML, sin sanitizar — solo contenido de confianza propio). Solo aplica a source="camera".
Ejemplo — aceptar también PDF en source="file"
<DocumentCapture
open={open}
source="file"
view="document"
accept="image/*,.pdf,application/pdf"
maxBytes={2_000_000}
onCapture={setShot}
onClose={() => setOpen(false)}
/>Los PDF (o cualquier tipo que no empiece con image/) no se recortan ni redimensionan: se devuelven como data URL tal cual, y solo se valida maxBytes (si lo supera, error).
Ejemplo — helper sin diálogo (processImageFile)
Para integrar el mismo pipeline de recorte + resize + compresión en tu propio input de archivo, sin usar el componente:
import { processImageFile, VIEW_ASPECT } from '@ssi-lib/document-capture';
async function onChange(file: File) {
const result = await processImageFile(file, {
aspectRatio: VIEW_ASPECT.id, // 1.586:1; también .document o .profile. Omitilo para no recortar
maxEdge: 1600,
maxBytes: 1_500_000,
});
console.log(result.dataUrl, result.width, result.height, result.bytes);
}Props de DocumentCapture
| Prop | Tipo | Default | Requerido | Descripción |
| --- | --- | --- | --- | --- |
| open | boolean | — | sí | Abre/cierra. En source="camera" monta un diálogo fullscreen; en source="file" dispara el picker nativo del sistema. |
| source | 'camera' \| 'file' | 'camera' | no | Cámara con diálogo propio, o selector de archivo/galería sin diálogo. |
| facing | 'environment' \| 'user' | 'environment' | no | Trasera o frontal. Solo source="camera"; se puede invertir dentro del diálogo. |
| view | 'id' \| 'document' \| 'profile' | 'id' | no | Recuadro: cédula (horizontal, 1.586:1), hoja (vertical, 1:1.414) o perfil (retrato 3:4, silueta de cabeza y hombros en cámara). Define el recuadro guía y el ratio de recorte. |
| crop | boolean | true | no | Recorta al ratio de view. En cámara, al recuadro; en archivo, centrado. Si es false, entrega la imagen completa. |
| maxEdge | number | 1600 | no | Lado mayor en px después del encode. |
| maxBytes | number | — | no | Tope del data URL. Baja calidad JPEG y después resolución. |
| mimeType | 'image/jpeg' \| 'image/png' | 'image/jpeg' | no | Formato de salida de imágenes. |
| quality | number | 0.82 | no | Calidad JPEG 0–1. |
| accept | string | 'image/*' | no | accept del input file. Ej. 'image/*,.pdf'. Solo source="file". |
| onCapture | (result: CaptureResult) => void | — | sí | Entrega la captura. |
| onClose | () => void | — | no | Cancelar y también tras una captura exitosa. En source="file", también al cancelar el picker nativo (si el navegador soporta el evento cancel). |
| label | string | — | no | Título sobre el recuadro. Solo source="camera". Sin copy default. |
| placeholder | string | — | no | Texto de ayuda sobre el recuadro. Solo source="camera". Sin copy default. |
| html | string | — | no | Reemplaza label/placeholder (innerHTML, sin sanitizar). Solo source="camera". |
Resultado (CaptureResult)
interface CaptureResult {
dataUrl: string;
base64: string;
mimeType: string;
width: number; // 0 si no es imagen (p. ej. PDF)
height: number;
bytes: number;
source: 'camera' | 'file';
name?: string; // nombre original si vino de archivo
}Cámara: arranque rápido + mejor resolución + foco continuo
getUserMedia se intenta en este orden, todo con ideal (nunca lanza OverconstrainedError por sí solo):
facingModepedido, 1280×720, confocusMode/exposureMode/whiteBalanceModeen'continuous'.facingModepedido, sin resolución fija (mismos hints de foco).- Cualquier cámara (
video: true).
Se pide 720p primero a propósito: cambiar el sensor a un modo de mayor resolución es notablemente más lento en hardware de gama baja, y eso es demora real antes de ver el primer frame. Una vez que el video ya está mostrando algo, se pide 1080p en segundo plano sobre el mismo track (applyConstraints — sin permiso nuevo, sin reabrir el stream); si el dispositivo no puede, se queda con lo que ya tenía andando.
focusMode: 'continuous' es lo que le pide al driver que reenfoque solo — sin este hint, varios drivers de Android no reajustan el foco a la distancia corta típica de una cédula/documento sostenido cerca de la cámara.
Hace falta HTTPS o localhost. Al cerrar, desmontar o cambiar de cámara se cortan los tracks. La vista frontal se espeja solo en preview (transform); la foto sale sin espejar.
Archivo/galería: por qué no hay diálogo
Elegir un archivo nunca necesitó un recuadro propio ni un paso extra: source="file" dispara el <input type="file"> nativo apenas se abre, así el usuario ve directamente el selector del sistema (galería, Fotos, explorador de archivos) sin una pantalla negra intermedia que tocar primero. El recorte al tipo de documento configurado (view) pasa después, solo, sobre lo que el usuario eligió — no requiere que el usuario ajuste nada a mano.
CSS
Clases con prefijo ssi-doc- (solo relevantes en source="camera", salvo .ssi-doc-file y .ssi-doc-file-error):
| Clase | Elemento |
| --- | --- |
| .ssi-doc-dialog | Contenedor fullscreen de la cámara |
| .ssi-doc-dialog.is-id / .is-document / .is-profile | Recuadro según view |
| .ssi-doc-silhouette | Guía de perfil: línea abierta (cabeza circular + hombros) y hueco velado. Solo view="profile" |
| .ssi-doc-dialog.is-user | Preview espejado (cámara frontal) |
| .ssi-doc-video | <video> |
| .ssi-doc-frame | Recuadro guía/recorte |
| .ssi-doc-shutter / .ssi-doc-flip / .ssi-doc-close | Controles |
| .ssi-doc-copy / .ssi-doc-label / .ssi-doc-hint | Título y ayuda |
| .ssi-doc-error | Error dentro del diálogo de cámara |
| .ssi-doc-file | <input type="file">, oculto, usado en source="file" |
| .ssi-doc-file-error | Toast fijo con el error de lectura en source="file" (no hay diálogo donde mostrarlo) |
Desarrollo
npm install
npm run build
npm run typecheckPreview local: library-hub/ui-preview-library → ruta /document-capture.
