dni_peru_ocr 0.16.0
dni_peru_ocr: ^0.16.0 copied to clipboard
Peruvian DNI OCR helpers — ML Kit text recognition denoise, MRZ parsing, address extraction, and surname normalization for the Peruvian Documento Nacional de Identidad.
Changelog #
0.16.0 #
Changed (BREAKING) #
DniDataidentity fields (nombres,apellidoPaterno,apellidoMaterno,nombreCompleto) are nowString?instead ofString. Anullvalue means "not selected inDniFields" or "not returned by the lookup source", which consumers can distinguish from an empty string returned by a backend.DniDataMergernow returnsnullinstead of''for fields the consumer did not request viaDniFields.
Migration #
- Consumers that called
data.nombres.trim()(etc.) must guard againstnull:(data.nombres ?? '').trim().
0.15.2 #
Changed #
DniScannernow scalesHuntStateMachine.minFieldsForFastAdvancebased on the consumer'sDniFields.length, so smaller field selections (e.g.DniFields.kyc()= 7 fields) hit the fast-advance threshold instead of being stuck on the 12-field default.- Telemetry log now reports
filled/<selectedCount>instead of the hardcodedfilled/19.
0.15.1 #
Added #
OcrConsensusResult.fromHunt(HuntResult)factory — bridgesDniScannersingle-side output (HuntResult) to consumers that still expect anOcrConsensusResultdownstream.
0.15.0 #
Added #
DniScannersingle-side mode: passisBackSide: falseto scan only the front,isBackSide: trueto scan only the back. The scanner emits the newDniSideScanResultviaonSideCaptured. Two-sided mode (legacy default withisBackSide: null) remains unchanged.DniSideScanResult— payload for single-side captures (photo,isBackSide,hunt,reniecData).HuntStateMachineacceptsinitialPhase, so single-side mode can start directly atwaitingBack.
Notes #
- Single-side mode enables consumers like Inclub to keep step-by-step flows (with intermediate backend uploads between sides) while still using the bundled scanner UI.
0.14.0 #
Added #
DniScannernow supportslookupService,lookupTimeoutandonDniReady. Fires the RENIEC lookup automatically when the front capture exposes adocumentNumberand bundles the resolvedDniDataintoDniScanResult.reniecData.
Changed #
DniCameraMask.fieldsdefaults toDniFields.kyc()when omitted.DniCameraMaskconstructor is no longerconstbecausefieldsdefaults to a non-constDniFields.kyc()instance.
Fixed #
- Premature front-side capture:
CaptureDecideris now side-aware. Previously, the auto-capture path requiredHuntResult.isComplete(all 6 fixed fields including MRZ-only ones likeexpirationDate), which could never becometruewhile the user was still on the front. The widget then fell back to framing-only gates and captured blurry front shots before the OCR pipeline had consensus on the front-printed fields. - The decider now requires:
- Front side: every requested
DniFieldthat is physically printed on the front of the DNI (documentNumber, names, sex, nationality, organDonor, votingGroup, stateCivil) must be consensuated. - Back side: full
HuntResult.isComplete(unchanged behavior).
- Front side: every requested
Added #
HuntResult.requiredFields— propagates the consumer'sDniFieldsselection soisCompletehonors it.HuntResult.isFrontReady— new front-only gate, intersects the requested fields withHuntResult.frontPrintedFields.HuntResult.frontPrintedFields— public constant set listing everyDniFieldphysically printed on the front of the DNI Modelo 2020.CaptureDecider.decidenow acceptsisBackSide(defaults tofalsefor source compatibility).
Notes #
- Backwards-compatible: hosts that don't pass
fieldskeep the legacy triplet gate (documentNumber + lastName + firstName) on the front instead of capturing on framing alone.
0.13.1 #
Added #
ValidationGate.sideMismatch— new gate emitted when the user is showing the wrong side of the DNI (front whileisBackSide: true, or back whileisBackSide: false).DocumentValidator.evaluatenow rejects capture and shows "Estás mostrando el reverso. Voltea al frente del DNI." (or the inverse) using the existingDocumentSideDetectoranchors (CUI, REPÚBLICA DEL PERÚ, DONACIÓN DE ÓRGANOS, CONSTANCIA DE SUFRAGIO). Works on Modelo 2020 AND the azul booklet (DNI viejo).
Notes #
- Prevents capturing the same side twice (e.g. user showed the back twice and the back's MRZ filled both anverso and reverso slots).
- No public API change. Existing consumers automatically benefit.
0.13.0 #
Added #
InMemoryDniCache— public convenience implementation ofDniCachebacked by aMap<String, DniData>. Useful for tests, demos, and consumer apps that do not need persistence across process restarts. For production cross-session caching, consumers can still implementDniCachebacked by Hive,shared_preferences, sqflite, etc.
Notes #
- Additive only. No public API change beyond the new export.
- The example app used to ship its own
InMemoryDniCachecopy; that file was removed and the example now consumes the library's implementation.
0.12.1 #
Changed #
DniCameraMasknow uses sharedScannerHintandScannerFlashTogglewidgets (same asDniScanner). Hint pill appears below the document frame; flash toggle is centered at the bottom with high-contrast black background, white border, and shadow.- Flash toggle icon size increased to 22 (was 20) and padding to 12 (was 10) to meet Material accessibility 44×44 touch target minimum.
- Removed legacy
_GuideTextBannerand_FlashTogglefrom_camera_overlay_widgets.dart— single source of truth.
Notes #
- No public API change.
- Consumers using
DniCameraMask(Inclub, etc.) now see the same in-camera chrome thatDniScanneralready used in the example app: orange gradient banner, dark hint pill, accessible flash toggle.
0.12.0 #
Changed #
- Banner redesign in
DniCameraMask: the front→back transition now shows the same orange gradient banner used byDniScanner(Material Orange 600 → 700) with rotating flip icon and haptic feedback. PreviouslyDniCameraMaskhad its own gray-translucent_SideIntroRibbonthat consumers reported as nearly invisible. - Banner widget extracted to
_shared_scan_widgets.dartso bothDniScannerandDniCameraMaskrender the sameFlipDocumentBanner. Single source of truth for the in-camera UX. - Banner text:
"Voltee el documento"(was"Anverso listo — ahora voltea el DNI").
Notes #
- No public API change. The
_SideIntroRibbonwas already a private widget; consumers that embedDniCameraMasksee the new banner automatically. - The orange gradient is hardcoded (not theme-driven) to ensure all consumers get the same protagonist visual without per-app configuration.
0.11.2 #
Fixed #
_SideIntroRibbon("Voltee el documento" banner) now activates correctly whenDniCameraMaskis constructed directly withisBackSide: true, not only when the same widget instance transitions from front to back viadidUpdateWidget. This unblocks integrations like Inclub mobile app where each KYC step builds a freshDniCameraMaskinstance and the previous code path never triggered.
Notes #
- No public API change. Existing consumers reusing one widget across front/back see identical behavior. New behavior only fires for consumers constructing a back-side widget from scratch.
0.11.1 #
Changed #
- Moved
diofromdev_dependenciestodependenciesto comply with pub.dev publication rules. Packages imported fromlib/must be declared as direct dependencies.
Removed #
- Removed
analysis_options.yamlsuppression ofdepend_on_referenced_packagesthat was masking the dio placement issue.
Notes #
- No functional changes. v0.11.0 consumers see identical behavior.
- Consumers of
dni_peru_ocrnow transitively loaddioas a runtime dependency, even when not usingDioDniHttpClient. This is a tradeoff against the original v0.9.0 design promise of "OCR-only consumers pay no dio cost". Pub.dev compliance is non-negotiable; the bundle impact is ~150KB which is negligible compared to ML Kit dependencies the library already requires.
0.11.0 #
Added #
DniFieldenum — 19 public values mapped 1:1 to ExtractedFields properties.DniFieldsimmutable wrapper with factoriesrequired({...}),minimal(),kyc(),full()and value-based equality.FieldHunter.standard()accepts optionalDniFields? fieldsparameter. When provided, only extractors that produce at least one selected field are instantiated, reducing CPU and battery usage.DniScannerandDniCameraMaskwidgets accept optionalfieldsparameter.DniDataMerger.merge()accepts optionalfieldsparameter; non-selected fields are null in merged output even if RENIEC returned values.- Threshold scaling:
HuntStateMachine.minFieldsForFastAdvancescales proportionally asmax(3, (selectedCount * 0.75).round())clamped to 14.
Changed #
- Library version bumped from 0.10.0 to 0.11.0.
Notes #
- All field selection APIs are optional. Consumers omitting the
fieldsparameter see identical v0.10.0 behavior. - Multi-field extractors (MRZ, dates, ubigeo, surname) run if at least one of their produced fields is in the selected set.
- The
DniCameraMasklegacyOcrConsensusAccumulatorpipeline is not filtered byfields; only the FieldHunter path honors selection.
0.10.0 #
Added #
ReliableDniPipeline— internal orchestrator that fires lookup once per scan session when DNI consensus is reached, with a configurable 1500ms timeout and silent OCR fallback on failures.DniDataMerger— internal pure function applying RENIEC-prevails rules with whitespace defense; DNI number always preserved from OCR consensus.DniCameraMaskandDniCameraControllernow accept optionallookupServiceandonDniReadyparameters. When provided, the library automatically wires the lookup pipeline; consumers receive reliable DNI data throughonDniReadywithout writing any wiring code.KycThemegainsgradientStartandgradientEndcolor tokens for branded UI accents.
Changed #
_SideIntroRibbon(flip-card hint banner) redesigned with gradient background, slide-in animation, rotating icon, and haptic feedback on mount._FlashToggle(camera flash control) redesigned with larger touch target, higher contrast, and tactile feedback on tap.- Library version bumped from 0.9.0 to 0.10.0.
Notes #
- Lookup remains fully optional — consumers omitting the new parameters continue to use the OCR-only flow from v0.9.0 unchanged.
ReliableDniPipelineandDniDataMergerare internal and never exported. They are wired automatically whenlookupServiceis provided.rawSourcefield onDniDatais never set by the merger, preserving the silent merge contract: consumers cannot distinguish merged from OCR-only data via the API surface.
0.9.0 #
Added #
DniLookupServiceabstract contract for plugging external DNI data sources.DniDatacommon model with required fields (dni,nombres,apellidoPaterno,apellidoMaterno,nombreCompleto) and optional fields (ubigeo,departamento,provincia,distrito,rawSource, opt-inrawmap).DniLookupResultsealed class with 6 variants:DniLookupSuccess,DniLookupNotFound,DniLookupRateLimited,DniLookupNetworkError,DniLookupInvalidToken,DniLookupServerError.ApisPeruLookupService— built-in adapter fordniruc.apisperu.comwith defensive handling of plain-text 500 invalid-token responses.ReniecSunatLookupService— built-in adapter for self-hosted RENIEC/SUNAT backends with configurablebaseUrland optionalextraHeaders.DniHttpClientabstract HTTP contract +DioDniHttpClientadapter (consumer provides configuredDioinstance).CachingDniLookupServicedecorator — cache-aside pattern with in-memory TTL tracking; consumer providesDniCacheimplementation.FallbackDniLookupServicedecorator — ordered service chain with configurable retry predicate; stops onInvalidTokenby default.DniCacheabstract contract for consumer-implemented storage.DniHttpResponsevalue type carrying HTTP status code and response body.DniLookupRetryPredicatetypedef forFallbackDniLookupServicecustomization.
Changed #
- Library version bumped from 0.7.3 to 0.9.0 to reflect the new lookup capability.
Notes #
diois adev_dependency. Library consumers do not pay the bundle cost unless they instantiateDioDniHttpClient.- The lookup feature is fully optional; OCR-only consumers compile and run identically to v0.7.x.
- No breaking changes to v0.7.x public API. All existing exports remain intact.
analysis_options.yamlsuppressesdepend_on_referenced_packagesinfo-level lint as a documented short-term tradeoff.
0.7.3 - 2026-06-06 #
Added #
- Runnable example app under
example/demonstrating the full capture flow: Home → Scan (front + back withfrontSideFieldsseeding) → Result. ErrorScreenin the example covering document expiration, capture cancellation, camera permission denial, and camera initialization failure paths.- Confidence-graded
FieldCardandConfidenceBadgewidgets in the example showing how to renderOcrConsensusResultto end users. - CI step that runs
flutter analyze --fatal-warningsagainst the example so future API changes cannot silently break the demo.
Changed #
- Root README now points to the example app as the recommended starting point for new consumers.
0.7.2 - 2026-06-06 #
Changed (BREAKING for callback shape) #
DniCameraControllerconstructor parameteronValidCapturenow has typevoid Function(XFile file, OcrConsensusResult? consensus)instead ofvoid Function(dynamic file, dynamic consensus).DniCameraController.onCaptureDeliverednamed parametersfileandconsensusare now typedXFileandOcrConsensusResult?respectively.- Consumers that destructure the callback arguments with explicit dynamic types
will get a compile error and must update their callback signature. Consumers
using inferred-parameter lambdas (
(_, __) {}) are unaffected. - Patch version despite the shape change: this package has never been published to pub.dev, and the only known consumer is InClub (controlled by the same author). v0.8.0 is reserved for the upcoming internal refactor.
Refactored (internal) #
- Overlay widgets in
_camera_overlay_widgets.dartrenamed to library-private (_ManualCapturePanel,_SideIntroRibbon,_G1TelemetryOverlay,_GuideTextBanner,_DataMatchIndicator,_FlashToggle) and converted to apartfile ofdni_camera_mask.dartto enforce intra-library privacy. _DniCameraMaskState._processDocumentparameterinputImagetyped fromdynamictoInputImage— eliminates an implicit dynamic call into ML Kit.
Removed #
- Dead
&& trueguard on thekDebugModebranch inDniCameraMask.build(the telemetry overlay was always rendered in debug mode; the guard was a stale boolean from early development).
0.7.1 - 2026-06-06 #
Changed #
- Applied
dart formatacross all 25 library and test files (no logic changes). - Loosened
google_mlkit_face_detectionconstraint from exact pin0.13.2to caret^0.13.2, clearing thedart pub publish --dry-runwarning. - Added
topics: [ocr, flutter, identity, peru, mrz]topubspec.yamlfor pub.dev discoverability. - Added explicit
platforms: android / iosdeclaration topubspec.yaml. - Fixed 8 unresolved dartdoc cross-references across 5 library files.
- Updated README installation section:
dni_peru_ocr: ^0.7.1(pub.dev) is now the primary snippet;git:syntax moved to a secondary "Install from source" subsection.
0.7.0 (breaking — deprecated alias removal + ubigeo feature) #
Closes every follow-up item identified by the v0.6.x judgment-day audit.
Breaking surface is intentionally small; the v0.6.x APIs we removed were
already marked @Deprecated since v0.6.0–v0.6.4.
Breaking changes #
1. OcrConsensusBuilder typedef removed
The deprecated alias on top of OcrConsensusAccumulator was kept for
in-place migration across v0.6.x. It is now gone — replace any remaining
usage with OcrConsensusAccumulator directly. Behaviour is identical.
2. OcrFieldExtractor.extractStatic removed
The static alias for OcrFieldExtractor.extract is gone. The two names
always referred to the same logic; the alias only existed to soften the
v0.6.0 migration.
3. DocumentValidationResult.tiltCalculator is now a constructor parameter
The static mutable test seam:
DocumentValidationResult.tiltCalculator = (_) => 20.0;
final r = DocumentValidationResult.evaluate(...);
is replaced by an optional named parameter on evaluate():
final r = DocumentValidationResult.evaluate(
recognizedText: text,
imageSize: size,
tiltCalculator: (_) => 20.0, // optional, defaults to computeMedianTiltDegrees
);
Defaults to computeMedianTiltDegrees so production callers do not need
to change anything. Tests that previously assigned to the static field
must pass the function as a named argument instead. This eliminates the
last global mutable static in the package surface.
4. DocumentValidationResult.evaluate(theme:) removed
The theme parameter on evaluate() had been silently ignored since
v0.6.4 (it was only used by the removed borderColor field). The
parameter is now gone — drop it from the call site.
Non-breaking improvements #
5. Three new optional fields on OcrExtractedFields
department, province, district are populated by AddressFieldStrategy
when the back-side OCR exposes the DEPARTAMENTO/PROVINCIA/DISTRITO line.
Handles three real-world shapes:
ANCASH/SANTA/CHIMBOTE→ positional[department, province, district]/CALLAO/VENTANILLA→department + district, province null (RENIEC convention for constitutional provinces)LIMA/LIMA/VILLA MARIA DEL TRIUNFO→ multi-word district preserved
Consumers can read these fields directly without parsing the slash line
themselves. All three default to null when the line is missing.
6. Name vote consolidation by strict prefix containment
When the consensus accumulator has votes like
{lastName: MORENO} x1 and {lastName: MORENO ALEMAN} x1, the snapshot
now consolidates them: the longer variant wins and both votes count
toward its confidence. Uses strict prefix containment (no fuzzy
matching) so that JUAN and JOSE stay independent — fuzzy matching on
short names is too risky.
This is the same family of fix that v0.6.8 introduced for address, but limited to prefix matches because name fields are short and high-signal.
7. Address locked flag requires ≥ 2 votes
Single-vote address buckets cannot reach OcrFieldResult.locked = true
anymore — at least two corroborating frames are required. The displayed
value is unaffected (still emitted from the first vote), only the
locked flag is gated. Prevents premature lock on noisy single-frame
captures.
Tests #
7 new regression tests across ocr_consensus_test.dart,
address_field_strategy_test.dart, and dni_camera_mask_widget_test.dart:
- Name-prefix consolidation (3 cases).
- Ubigeo extraction for 3-part / 2-part / multi-word / absent variants (4 cases).
- Property-based order independence: shuffle 5 address votes 30× with different seeds, same winner every run.
- Widget-level E2E:
DniCameraMask.isBackSide: false → truerebuild propagates the flag to the innerDniCameraController(regression guard for the v0.6.4 stale-flag bug).
529/529 tests pass. flutter analyze clean.
Consumer impact for InClub #
No code changes required. InClub does not use any of the four removed
APIs (OcrConsensusBuilder, extractStatic, the static tiltCalculator,
or evaluate(theme:)). Bump the SHA only.
0.6.10 (docs) #
Documentation pass over the public surface and the most-touched internals.
What changed #
Production code is unmodified. The pass replaces ad-hoc bitácora comments
(obs #..., BUG ..., v0.6.x hotfix, reporter names) with DartDoc that
documents:
- Intent and role of each public class.
- SOLID principles applied (SRP, OCP, DIP) where relevant.
- Design patterns (Strategy, Accumulator, State holder injection).
- Invariants and lifecycle expectations.
- Empirical calibration values (e.g.
blurThreshold,_maxTiltDegrees) with the reasoning that drove them, separated from the bug-tracking history that lives in git / Engram.
Files touched:
lib/src/data/ocr_consensus.dartlib/src/data/address_noise_filter.dartlib/src/data/strategies/address_field_strategy.dartlib/src/presentation/document_validator.dartlib/src/presentation/image_quality_gate.dartlib/src/presentation/controllers/dni_camera_controller.dartlib/src/presentation/widgets/dni_camera_mask.dart
Bug-tracking metadata that was in the source moved to git log and to the
Engram observations linked from the CHANGELOG.md entries — the source
now talks about the what and the why, not the when.
Consumer impact #
No public API changes. No behaviour changes. Patch release. Bump SHA only.
521/521 tests pass. flutter analyze clean.
0.6.9 (polish + test hygiene) #
Two non-breaking polish items from the judgment-day audit (Engram obs #4688):
1. Per-field MRZ source tag in logs #
OcrExtractedFields.toString() was tagging every field with [MRZ] when
the accumulator was MRZ-sourced — including the address, which the MRZ
NEVER carries. Cosmetic but misleading during debug.
Fix: only fields the MRZ actually emits (documentNumber, paternal /
maternal surnames, given names, birth date, sex, expiration, nationality)
carry the tag now. Address and any future free-text field stay untagged.
2. Global setUp reset for tiltCalculator test seam #
DocumentValidationResult.tiltCalculator is a static mutable seam used
only by tests. Per-test addTearDown already cleaned it up, but if a
test crashed BEFORE the tearDown registered, the value leaked to every
subsequent test. Added a global setUp reset in both files that touch
the seam — defensive belt-and-suspenders against test contamination.
(The seam itself is the v0.6.0 antipattern survivor tiltCalculator —
will be replaced with constructor injection in v0.7.0.)
Tests #
2 new tests in test/data/ocr_field_extractor_test.dart:
- MRZ-sourced fields tagged, address untagged.
- Non-MRZ accumulator: no tags at all.
521/521 tests pass. flutter analyze clean.
Consumer impact #
No public API changes. Patch release. Bump SHA only.
0.6.8 (bugfix) #
Address vote consolidation across OCR micro-variants. Reported by JC against
v0.6.7: log showed MILAGRO across frames, UI showed MLAGRO (missing I).
Root cause #
ML Kit emits the same DNI address in many micro-variants across frames because each frame has slightly different OCR noise:
| Frame | OCR output |
|---|---|
| 1 | ASENT H15 DE ABRIL CALLE EL MILAGRO |
| 2 | ASENT H15 DE ABRIL CALLE EL MILAGRO MZ |
| 3 | ASENT H15 DE ABRIL CALLE EL MILAGRO MZ.B LT.19 |
| 4 | ASENT H15 DE ABRIL CALLE EL MLAGRO MZ.B LT.19 |
The previous _voteResult gave each variant its own bucket, so a
reduce(max) over single-vote buckets returned a non-deterministic winner
— whichever variant Dart's Map.entries iteration happened to encounter
last. In JC's case the corrupted MLAGRO variant kept winning.
Fix #
Address voting now consolidates near-duplicate variants:
- Sort buckets by string length descending.
- For each bucket starting from the longest, absorb every shorter bucket
that is either:
- A whitespace+dot-collapsed PREFIX of the longer string, OR
- ≥ 80% Levenshtein-similar to the longer string.
- The longest string in each consolidated group wins; its absorbed votes sum.
- The group with the highest combined vote count wins.
This guarantees deterministic, monotonically-preferring-completeness behaviour:
| Scenario | Winner (v0.6.8) |
|---|---|
| Progressive completion across frames | The longest variant |
3× MILAGRO + 1× MLAGRO |
MILAGRO (3-vote group beats 1-vote) |
| 5 different micro-variants, all 1 vote | The longest |
| Two unrelated addresses (rare) | The majority — no false merge |
Non-address fields (firstName, lastName, secondLastName,
documentNumber) keep the original strict equality voting. Only address
needed this looser policy because its content is free-text and ML Kit OCR
of the back-side stub is the noisiest signal in the pipeline.
Tests #
4 new regression tests in test/data/ocr_consensus_test.dart:
progressive completion: shorter variants merge into the longestOCR glitch in one frame is outvoted by the well-read majorityall variants 1-vote, longest wins (deterministic tie-break)unrelated addresses do NOT consolidate(defensive)
519/519 tests pass. flutter analyze clean.
Consumer impact #
No public API changes. Patch release. Bump SHA only.
0.6.7 (bugfix) #
Address continuation across ML Kit line splits — reported by JC against v0.6.6.
Symptom #
Real DNI back side prints MZ.B LT.19 on its own visual line, but ML Kit
emits it differently per frame. Sometimes the address line ends at MZ
and the lot fragment lands on the next line as B LT.19 (or just 19
when LT also lands on the previous line). The previous _buildAddress
required the next line to start with a known prefix (AV., MZ., …),
so a "tail-only" continuation like B LT.19 was dropped:
Log: ✅ Dirección: ASENT HI5 DE ABRIL CALLE EL MILAGRO MZ
^^ truncated here
Fix #
_buildAddress now also attaches the next line when the PREVIOUS line
ended with a dangling continuation anchor (MZ, MZA, LT, LTE,
NRO, INT, DPTO — with or without trailing dot) and the next line
looks like a short address fragment (≤ 30 chars, alphanumerics + dots,
not a known label).
Defensive guards:
≤ 30 charcap on the next-line fragment prevents stitching unrelated long lines.- Regex denylist for obvious labels (
DIRECCI*,DOMICILI*,DEPARTAMENT*,PROVIN*,DISTRIT*,UBIGEO,GRUPO,VOTACI*,DONACI*,ORGANO*,SANGUINE*,FECHA,CADUC*,NACIM*,SEXO,NACIONAL*) so we don't pull the next form label into the address. - Only fires when the previous line's LAST token is a dangling anchor —
a complete
MZ.Bon the previous line is NOT treated as dangling.
Tests #
3 new regression tests in test/data/strategies/address_field_strategy_test.dart:
MZat end of line1 +B LT.19on line2 → joined.LTat end of line1 +19on line2 → joined.- Continuation chain
MZ→B→LT 19across three lines.
515/515 tests pass. flutter analyze clean.
Consumer impact #
No public API changes. Patch release. Bump SHA only.
0.6.6 (bugfix) #
Two text-OCR fidelity fixes for real Peruvian DNI cards reported by JC after v0.6.5 verification. Both address the same root cause: the back-side MRZ (ICAO 9303 TD1) does NOT carry certain data, so we must rely on the front-side text-OCR to fill the gaps — and the text-OCR pipeline had two blind spots.
Bug C — Modern DNI "Apellidos" unified label was ignored #
Modern Peruvian DNI cards print a SINGLE Apellidos label with both
paternal and maternal surnames joined on one line (e.g. QUIROZ REMIGIO).
The text-OCR strategy only recognized the older two-line format
PRIMER APELLIDO / SEGUNDO APELLIDO, so it never extracted the maternal
surname from modern cards. The back-side MRZ only carries the paternal
half, so the snapshot ended up with lastName = QUIROZ and
secondLastName = null — the maternal surname REMIGIO was silently
dropped.
Fix: TextOcrFieldStrategy._tryExtractNameByLabel now also matches
the Apellidos label (singular) and splits the joined value:
- 1 token →
lastNameonly - 2 tokens →
lastName+secondLastName - 3+ tokens → first is paternal, rest joined as maternal (handles
compound maternal surnames like
DE LA CRUZ)
Guarded so it does NOT collide with the older Primer/Segundo Apellido
labels (those keep matching first).
Bug D — RENIEC Ñ→NXX MRZ encoding was not reversed #
The Peruvian MRZ encodes Ñ as NXX (ICAO 9303 has no Ñ codepoint).
Real example: ERMITAÑO → ERMITANXX0. When the front-side text-OCR
voted the correct ERMITAÑO (with real Ñ), the snapshot still showed
the corrupted MRZ value because _recoverTildeFromText only handled
plain accents (Á É Í Ó Ú), not the NXX substitution.
Fix: _recoverTildeFromText now also tries a fallback lookup with
NXX → N (and trailing 0 → O) substitution. When the text-OCR vote
key matches the collapsed MRZ form, the text-OCR display value wins —
recovering both the Ñ and any tildes that the MRZ stripped.
No fabrication: when text-OCR did NOT vote a variant, the MRZ value passes through verbatim (the user fixes it in the confirmation step).
Tests #
8 new regression tests:
test/data/strategies/text_ocr_field_strategy_test.dart (Bug C):
- Splits
Apellidos QUIROZ REMIGIOintolastName + secondLastName. - Single-token
ApellidosleavessecondLastNamenull (no fabrication). - 3+ token
Apellidosuses first as paternal, rest joined as maternal.
test/data/ocr_consensus_test.dart (Bug D):
firstName: MRZERMITANXX0+ text voteERMITAÑO→ snapshotERMITAÑO.lastName: MRZNUNXXEZ+ text voteNÚÑEZ→ snapshotNÚÑEZ.- No text-OCR vote → MRZ value passes through unchanged.
test/data/strategies/address_field_strategy_test.dart (regression guards):
- Real JC case:
MZ.C LT.20 3ER SECTOR URB.ANTONIA MORENO DE CACERES. 3ER/SECTOR/ZONAtokens preserved in address.
512/512 tests pass. flutter analyze clean.
Consumer impact #
No public API changes. Patch release. Bump SHA only.
0.6.5 (bugfix) #
Address parsing quality fix for real Peruvian DNI back-side scans. Reported after JC's v0.6.4 verification — once the controller flag fix unblocked the data flow, parsing-side issues became visible.
Symptom #
Real DNI back-side prints:
Dirección
ASENT.H15 DE ABRIL CALLE EL MILAGRO
MZ.B LT.19
AddressFieldStrategy._buildAddress was stopping after the first line
because MZA 12 LTE 8 (full-form variants) did not match
_isAddressContinuation. Long-form Peruvian address codes were dropped.
Fix #
Extended _isAddressContinuation prefix list with the long-form variants
of Manzana / Lote (used on rural and formal addresses):
const prefixes = [
'MZ.', 'MZ ',
+ 'MZA.', 'MZA ',
'LT.', 'LT ',
+ 'LTE.', 'LTE ',
'NRO.', 'NRO ',
'INT.', 'DPTO.',
+ 'INT ', 'DPTO ',
];
Also added the bare-space variants of INT / DPTO that the previous list missed (only dotted forms were recognized).
Tests #
Four new regression tests in test/data/strategies/address_field_strategy_test.dart:
MZ.B and LT.19 are preserved in the final address— JC's exact case.ABRIL is preserved (no token mangling)— guard against shantytown name corruption.MZ A LT 5 (space-separated, no dots) variant— alt format.MZA / LTE (full-word variants) preserved— the case that triggered the fix.
504/504 tests pass. flutter analyze clean (0 issues).
Consumer impact #
No public API changes. Patch release. Bump SHA only.
0.6.4 (bugfix — CRITICAL) #
Closes the "two-sided scan loses OCR" symptom for real. v0.6.1 fixed the
accumulator math, v0.6.3 wired the front-side seed, and this release fixes
the final drop point — a stale flag inside DniCameraController.
Root cause #
DniCameraController._isBackSide was declared final and only set in the
constructor. When Flutter REUSES the widget state across the front→back
step transition (which it does — the KycDocumentScanStep tree shape is
identical for both steps, so the DniCameraMask State is reused), the
controller instance is reused too. initState does NOT re-run, so
_isBackSide stays false even after onSideChanged(isBackSide: true).
onCaptureDelivered then passes _isBackSide ? consensus : null =
false ? ... : null = null, dropping the back-side consensus on the
floor right before it would have reached the host callback.
Observed symptom (JC v0.6.3 logs) #
🔴 [DniCameraMask] _triggerShutter SHOT:
isBackSide=true consensus=NOT-NULL firstName=JOSE CARLOS JOAO ✅
🟠 [_onValidCapture] ENTRY:
isFront=false consensus=NULL ❌
The widget logged a valid snapshot, then the host saw null. The drop
happened in DniCameraController.onCaptureDelivered.
Fix #
_isBackSide is now mutable and synced inside onSideChanged:
void onSideChanged({bool isBackSide = false, ...}) {
if (_isDisposed) return;
_isBackSide = isBackSide; // ← new
// ...rest of the method unchanged
}
Tests #
Two new regression tests in test/presentation/controllers/dni_camera_controller_test.dart:
- Controller constructed front-side →
onSideChanged(isBackSide: true)→onCaptureDelivered(consensus: nonNull)MUST deliver the consensus to the host. (RED onmain, GREEN with fix.) - Inverse: controller constructed back-side → flipped to front → consensus MUST be scrubbed regardless of what the host passes.
500/500 tests pass. flutter analyze clean.
Consumer impact #
No public API changes. Patch release. Bump SHA only.
0.6.3 (bugfix) #
Hotfix for "two-sided scan loses OCR" symptom reported against v0.6.x.
Root cause #
The package widget is destroyed and recreated when the host navigates from
the front step to the back step (Flutter's switch over enum steps does
NOT reuse state, even when the widget type is the same). The previous
_accumulatedFields is destroyed and the new back-side widget calls
onSideChanged(isBackSide: true) without a seed — so the back-side
accumulator starts blank.
When the back-side MRZ frames came partial, v0.6.1's _buildMrzResultFromFields
fallback to the vote map had nothing to fall back to (vote map also empty)
and the snapshot returned null names. The consumer then fell back to local
profile data, violating "OCR ALWAYS WINS".
Fix — two new opt-in DniCameraMask parameters #
onFrontSideOcrUpdated — callback emitted during front-side scanning
every time the internal _accumulatedFields learns a new field. Hosts
persist these values across the step transition (Bloc/Cubit/state holder).
frontSideFields — back-side widget receives the persisted fields and
the package seeds the back-side accumulator via
onSideChanged(isBackSide: true, frontSideFields: ...).
Both parameters are opt-in. Existing consumers keep their behavior; hosts that want correct two-sided scanning wire both.
Consumer integration sketch #
// Front step
DniCameraMask(
isBackSide: false,
onFrontSideOcrUpdated: (fields) {
context.read<KycCubit>().rememberFrontSideOcr(fields);
},
onValidCapture: (file, _) => cubit.captureFront(file.path),
controller: cameraController,
)
// Back step
DniCameraMask(
isBackSide: true,
frontSideFields: state.frontSideOcr, // ← from your state holder
onValidCapture: (file, consensus) =>
cubit.captureBackWithConsensus(file.path, consensus: consensus!),
controller: cameraController,
)
Tests #
498/498 pass (unchanged — the new params are inert in the existing test
scenarios). flutter analyze clean.
0.6.2 (bugfix) #
Second hotfix on top of v0.6.1 — addresses BUG 1A (Spanish address anchor) and BUG 2 (back-side motion blur) reported by JC against v0.6.0 (Engram obs #4669). No breaking API changes — consumers bump SHA only.
Bug 1A — Dirección: anchor now recognized #
Real Peruvian electronic DNIs print "Dirección:" (Spanish accent) on the
reverse, NOT "Domicilio:". AddressFieldStrategy was DOMICILIO-only. Now
accepts DOMICILIO, DOM, DOM., DIRECCIÓN, and DIRECCION as Strategy 1
anchors — both inline (Dirección: ASENT.H15...) and as a label on its own
line followed by the address.
The DIRECCION token stays in kAddressNoiseDenylist so it does not pollute
joined address strings — the anchor is detected on the raw line BEFORE the
noise filter runs.
Bug 2 — Configurable MRZ lock threshold (motion blur mitigation) #
OcrConsensusAccumulator lock-fires after 2 consecutive MRZ-valid frames
(~66ms at 30fps). For the electronic DNI back side, the underlying
takePicture() pipeline needs more stability time to deliver a sharp still.
New constructor parameter mrzConsecutiveRequired exposes the threshold:
// Default: 2 (backwards compatible — booklet DNI or front)
OcrConsensusAccumulator();
// Back side electronic DNI: 5 frames ≈ 165ms stability window
OcrConsensusAccumulator(mrzConsecutiveRequired: 5);
Host widgets can pick the right threshold per side. Consumers do NOT need to change anything to keep the v0.6.x behavior.
Tests #
10 new regression tests:
- 4 for BUG 1A in
test/data/strategies/address_field_strategy_test.dart. - 6 for BUG 2 in
test/data/ocr_consensus_test.dart.
498/498 tests pass. flutter analyze clean (0 issues).
0.6.1 (bugfix) #
Hotfix for two pre-existing bugs in OcrConsensusAccumulator reported by JC
against v0.6.0. No public API changes — patch release, consumers bump SHA only.
Bug 3A — firstName / lastName now fall back to the vote map #
_buildMrzResultFromFields was asymmetric: secondLastName already fell back to
the text-OCR vote map when the MRZ buffer was null, but firstName and lastName
did not. A back-side MRZ frame with the names line garbled would erase the
front-side seed, leading to null names in the final snapshot. All MRZ-sourced
fields (documentNumber, firstName, lastName, secondLastName, dateOfBirth,
expirationDate) now consistently fall back to the vote map.
Bug 3B — lockFromMrzFields now merges with the previous buffer #
Each call to lockFromMrzFields used to replace the entire buffer. If frame 1
captured all fields cleanly and frame 2 had any field come null (e.g. ML Kit
dropped a character on the names line), the buffer would be overwritten with the
partial frame and the lock would fire on incomplete data. The buffer now merges
field-by-field — a new null does not erase a previously captured value.
Tests #
Three new regression tests in test/data/ocr_consensus_test.dart:
- BUG 3A: snapshot falls back to vote map for
firstName/lastNamewhen buffer is null. - BUG 3B:
lockFromMrzFieldsmerges with previous buffer instead of overwriting. - End-to-end: front seed + 2 back-side frames with partial MRZ → snapshot is complete.
488/488 tests pass. flutter analyze clean.
0.6.0 (breaking changes) #
Breaking changes #
OcrExtractedFields.logger removed (global static → constructor injection)
Before:
OcrExtractedFields.logger = mySentryLogger;
After:
final extractor = OcrFieldExtractor(logger: mySentryLogger);
OcrExtractedFields.loggerstatic mutable field is removed.OcrFieldExtractoracceptslogger: OcrLoggerin its constructor (defaults toNoOpOcrLogger).OcrExtractedFields.merge()accepts an optionallogger:parameter.DniCameraControlleracceptslogger: OcrLoggerin its constructor.- Telemetry breadcrumbs are routed through the controller via
controller.emitBreadcrumb(...).
DocumentValidationResult.borderColor removed (domain → presentation)
Before:
final color = result.borderColor; // Color from domain result
After:
import 'package:dni_peru_ocr/dni_peru_ocr.dart';
final color = ValidationGateColors.colorFor(result.failingGate, theme);
DocumentValidationResult.borderColor(Color) is removed.failingGateis now typedValidationGate?(enum) instead ofString?.- New
ValidationGateenum with values:minBlocks,centering,fillHigh,fillLow,lineCount,tilt. - Each gate has a stable Sentry code via
gate.sentryCode. - New
ValidationGateColors.colorFor(ValidationGate?, KycTheme)in the presentation layer. DocumentValidationResult.evaluate(theme:)— thethemeparameter is now@Deprecatedand silently ignored. Remove it at your convenience. It will be removed in v0.7.0.
OcrConsensusBuilder → OcrConsensusAccumulator
Before:
final builder = OcrConsensusBuilder();
After:
final accumulator = OcrConsensusAccumulator();
// OcrConsensusBuilder is a deprecated typedef alias — still compiles,
// but remove it before v0.7.0.
OcrConsensusBuilderis@Deprecatedand will be removed in v0.7.0.OcrConsensusAccumulatoris the canonical class name.DniCameraController.onSideChanged()now owns the accumulator lifecycle. PassisBackSide: trueand optionallyfrontSideFields:to seed it.- New
DniCameraController.recordOcrFrame(OcrExtractedFields)— call per frame on back side. - New
DniCameraController.snapshotConsensus()— returns currentOcrConsensusResult?. DniCameraMaskno longer holds the accumulator directly.
New APIs #
ValidationGateenum — compile-time exhaustive gate identification.ValidationGateColors— presentation helper mappingValidationGate?toColor.OcrConsensusAccumulator— renamed accumulator with same behavior.DniCameraController.emitBreadcrumb(category, message, {data})— routes breadcrumbs through the injected logger.DniCameraController.recordOcrFrame(fields)→bool(consensus reached).DniCameraController.snapshotConsensus()→OcrConsensusResult?.
Internal (non-breaking) #
- Clean Architecture chapters 1–3 (file reorganization, strategy decomposition, controller/orchestrator extraction) already landed in v0.6.0 pre-releases.
0.5.0 #
KycTheme.darkDefaults()— OLED-tuned dark variant.KycTheme.fromMaterialTheme(ThemeData)— bridges Material 3 ColorScheme onto the KYC slots.KycTheme.copyWith(...)— partial overrides without redefining the whole theme.DniCameraMask: flash toggle moved next to the manual capture button (bottom-right) instead of top-right, so it sits closer to the primary CTA when the manual fallback panel surfaces.- All
debugPrintcalls are guarded withkDebugModeso release builds skip the string interpolation cost.
0.4.0 #
DniCameraMask— full DNI scanning widget. Renders the document hole, animated scan line, capture countdown, manual fallback panel, side-intro ribbon and the G.1 telemetry overlay (debug builds only). Hosts the MRZ + consensus pipeline.DocumentValidator— geometric + OCR-aware framing gate. Now takes aKycThemeso border colors stay configurable per app.UserVerificationData— small value type withmatchesTextfor OCR cross-checks.DetectorLifecycle— deterministicCompleter-based drain that prevents SIGSEGV when closing ML Kit detectors mid-inference.
0.3.0 #
KycTheme+KycThemeProvider— theming abstraction so the scanner widgets can be styled without leaking host-app color palettes.KycTheme.defaults()ships neutral defaults that you can override per app.CameraOverlayTuning— 13 named timing/animation/threshold constants previously hardcoded inside the camera widget (autoCaptureMs, gracePeriodMs, eye thresholds, hole ratios, animation durations, fade durations).BlinkLivenessTracker— pure state machine for face liveness (open → closed → open transitions). Detects static-photo spoofing because frozen probabilities never cross thresholds dynamically. Now reusable outside the camera widget.- Pure helpers extracted from the camera state class:
animatedSwitcherDedupeLayout,filterBlocksInHole,computeOvalInImagePx,initialGuideText,loadingMessage,consensusHasMinimumData,perfectSinceOnRecover,shouldClearPerfectSince,expirationIfPast.
0.2.0 #
- Added camera-side helpers:
TiltCalculator,ImageQualityGate,KycImageUtils,InputImageConverter,BreadcrumbThrottle.
0.1.0 #
- Initial release.
OcrFieldNormalizer— tilde-aware name denoise (Ñrecovery fromNXXML Kit corruption) and display normalization.OcrFieldExtractor— multi-strategy field extraction fromRecognizedTextwith noise filtering, label denylist for Peruvian DNI vocabulary, and structural anchor heuristic.OcrConsensusBuilder— temporal voting across frames with MRZ checksum lock and display-value preservation.StringSimilarity— Wagner-Fischer Levenshtein for fuzzy OCR comparison.OcrLogger— pluggable logging interface; default no-op implementation.