Initial commit

EBO <eric.bouhana@softalys.com> committé le 2026-07-22 14:09

13fa74bd1493ccff9cac10fc3d9b2040e5cb98ab

75 fichiers modifiés +6178 -0
A .gitignore
+41 -0
@@ -0,0 +1,41 @@
1 +# Specs locales éventuelles — les plans versionnés restent dans docs/plans/
2 +docs/*
3 +!docs/plans/
4 +
5 +# Scratch superpowers (ledger SDD, briefs, paquets de revue, maquettes brainstorm) — local
6 +.superpowers/
7 +AGENTS.md
8 +.cursor/
9 +.omc/
10 +
11 +# Modèles (volumineux — jamais committés)
12 +*.gguf
13 +models/
14 +.models/
15 +
16 +# OS / éditeurs
17 +.DS_Store
18 +*.swp
19 +.idea/
20 +.vscode/
21 +
22 +# Dépendances éventuelles
23 +node_modules/
24 +__pycache__/
25 +*.pyc
26 +
27 +.venv/
28 +
29 +# QR codes — regenerated on-site with real SSID/pass/IP values
30 +staging/*.png
31 +
32 +# Android
33 +android/.gradle/
34 +android/.kotlin/
35 +android/build/
36 +android/app/build/
37 +android/tessdata-cache/
38 +android/app/src/main/assets/tesseract/tessdata/*.traineddata
39 +android/local.properties
40 +android/.idea/
41 +*.iml
A LICENSE
+661 -0
@@ -0,0 +1,661 @@
1 + GNU AFFERO GENERAL PUBLIC LICENSE
2 + Version 3, 19 November 2007
3 +
4 + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5 + Everyone is permitted to copy and distribute verbatim copies
6 + of this license document, but changing it is not allowed.
7 +
8 + Preamble
9 +
10 + The GNU Affero General Public License is a free, copyleft license for
11 +software and other kinds of works, specifically designed to ensure
12 +cooperation with the community in the case of network server software.
13 +
14 + The licenses for most software and other practical works are designed
15 +to take away your freedom to share and change the works. By contrast,
16 +our General Public Licenses are intended to guarantee your freedom to
17 +share and change all versions of a program--to make sure it remains free
18 +software for all its users.
19 +
20 + When we speak of free software, we are referring to freedom, not
21 +price. Our General Public Licenses are designed to make sure that you
22 +have the freedom to distribute copies of free software (and charge for
23 +them if you wish), that you receive source code or can get it if you
24 +want it, that you can change the software or use pieces of it in new
25 +free programs, and that you know you can do these things.
26 +
27 + Developers that use our General Public Licenses protect your rights
28 +with two steps: (1) assert copyright on the software, and (2) offer
29 +you this License which gives you legal permission to copy, distribute
30 +and/or modify the software.
31 +
32 + A secondary benefit of defending all users' freedom is that
33 +improvements made in alternate versions of the program, if they
34 +receive widespread use, become available for other developers to
35 +incorporate. Many developers of free software are heartened and
36 +encouraged by the resulting cooperation. However, in the case of
37 +software used on network servers, this result may fail to come about.
38 +The GNU General Public License permits making a modified version and
39 +letting the public access it on a server without ever releasing its
40 +source code to the public.
41 +
42 + The GNU Affero General Public License is designed specifically to
43 +ensure that, in such cases, the modified source code becomes available
44 +to the community. It requires the operator of a network server to
45 +provide the source code of the modified version running there to the
46 +users of that server. Therefore, public use of a modified version, on
47 +a publicly accessible server, gives the public access to the source
48 +code of the modified version.
49 +
50 + An older license, called the Affero General Public License and
51 +published by Affero, was designed to accomplish similar goals. This is
52 +a different license, not a version of the Affero GPL, but Affero has
53 +released a new version of the Affero GPL which permits relicensing under
54 +this license.
55 +
56 + The precise terms and conditions for copying, distribution and
57 +modification follow.
58 +
59 + TERMS AND CONDITIONS
60 +
61 + 0. Definitions.
62 +
63 + "This License" refers to version 3 of the GNU Affero General Public License.
64 +
65 + "Copyright" also means copyright-like laws that apply to other kinds of
66 +works, such as semiconductor masks.
67 +
68 + "The Program" refers to any copyrightable work licensed under this
69 +License. Each licensee is addressed as "you". "Licensees" and
70 +"recipients" may be individuals or organizations.
71 +
72 + To "modify" a work means to copy from or adapt all or part of the work
73 +in a fashion requiring copyright permission, other than the making of an
74 +exact copy. The resulting work is called a "modified version" of the
75 +earlier work or a work "based on" the earlier work.
76 +
77 + A "covered work" means either the unmodified Program or a work based
78 +on the Program.
79 +
80 + To "propagate" a work means to do anything with it that, without
81 +permission, would make you directly or secondarily liable for
82 +infringement under applicable copyright law, except executing it on a
83 +computer or modifying a private copy. Propagation includes copying,
84 +distribution (with or without modification), making available to the
85 +public, and in some countries other activities as well.
86 +
87 + To "convey" a work means any kind of propagation that enables other
88 +parties to make or receive copies. Mere interaction with a user through
89 +a computer network, with no transfer of a copy, is not conveying.
90 +
91 + An interactive user interface displays "Appropriate Legal Notices"
92 +to the extent that it includes a convenient and prominently visible
93 +feature that (1) displays an appropriate copyright notice, and (2)
94 +tells the user that there is no warranty for the work (except to the
95 +extent that warranties are provided), that licensees may convey the
96 +work under this License, and how to view a copy of this License. If
97 +the interface presents a list of user commands or options, such as a
98 +menu, a prominent item in the list meets this criterion.
99 +
100 + 1. Source Code.
101 +
102 + The "source code" for a work means the preferred form of the work
103 +for making modifications to it. "Object code" means any non-source
104 +form of a work.
105 +
106 + A "Standard Interface" means an interface that either is an official
107 +standard defined by a recognized standards body, or, in the case of
108 +interfaces specified for a particular programming language, one that
109 +is widely used among developers working in that language.
110 +
111 + The "System Libraries" of an executable work include anything, other
112 +than the work as a whole, that (a) is included in the normal form of
113 +packaging a Major Component, but which is not part of that Major
114 +Component, and (b) serves only to enable use of the work with that
115 +Major Component, or to implement a Standard Interface for which an
116 +implementation is available to the public in source code form. A
117 +"Major Component", in this context, means a major essential component
118 +(kernel, window system, and so on) of the specific operating system
119 +(if any) on which the executable work runs, or a compiler used to
120 +produce the work, or an object code interpreter used to run it.
121 +
122 + The "Corresponding Source" for a work in object code form means all
123 +the source code needed to generate, install, and (for an executable
124 +work) run the object code and to modify the work, including scripts to
125 +control those activities. However, it does not include the work's
126 +System Libraries, or general-purpose tools or generally available free
127 +programs which are used unmodified in performing those activities but
128 +which are not part of the work. For example, Corresponding Source
129 +includes interface definition files associated with source files for
130 +the work, and the source code for shared libraries and dynamically
131 +linked subprograms that the work is specifically designed to require,
132 +such as by intimate data communication or control flow between those
133 +subprograms and other parts of the work.
134 +
135 + The Corresponding Source need not include anything that users
136 +can regenerate automatically from other parts of the Corresponding
137 +Source.
138 +
139 + The Corresponding Source for a work in source code form is that
140 +same work.
141 +
142 + 2. Basic Permissions.
143 +
144 + All rights granted under this License are granted for the term of
145 +copyright on the Program, and are irrevocable provided the stated
146 +conditions are met. This License explicitly affirms your unlimited
147 +permission to run the unmodified Program. The output from running a
148 +covered work is covered by this License only if the output, given its
149 +content, constitutes a covered work. This License acknowledges your
150 +rights of fair use or other equivalent, as provided by copyright law.
151 +
152 + You may make, run and propagate covered works that you do not
153 +convey, without conditions so long as your license otherwise remains
154 +in force. You may convey covered works to others for the sole purpose
155 +of having them make modifications exclusively for you, or provide you
156 +with facilities for running those works, provided that you comply with
157 +the terms of this License in conveying all material for which you do
158 +not control copyright. Those thus making or running the covered works
159 +for you must do so exclusively on your behalf, under your direction
160 +and control, on terms that prohibit them from making any copies of
161 +your copyrighted material outside their relationship with you.
162 +
163 + Conveying under any other circumstances is permitted solely under
164 +the conditions stated below. Sublicensing is not allowed; section 10
165 +makes it unnecessary.
166 +
167 + 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
168 +
169 + No covered work shall be deemed part of an effective technological
170 +measure under any applicable law fulfilling obligations under article
171 +11 of the WIPO copyright treaty adopted on 20 December 1996, or
172 +similar laws prohibiting or restricting circumvention of such
173 +measures.
174 +
175 + When you convey a covered work, you waive any legal power to forbid
176 +circumvention of technological measures to the extent such circumvention
177 +is effected by exercising rights under this License with respect to
178 +the covered work, and you disclaim any intention to limit operation or
179 +modification of the work as a means of enforcing, against the work's
180 +users, your or third parties' legal rights to forbid circumvention of
181 +technological measures.
182 +
183 + 4. Conveying Verbatim Copies.
184 +
185 + You may convey verbatim copies of the Program's source code as you
186 +receive it, in any medium, provided that you conspicuously and
187 +appropriately publish on each copy an appropriate copyright notice;
188 +keep intact all notices stating that this License and any
189 +non-permissive terms added in accord with section 7 apply to the code;
190 +keep intact all notices of the absence of any warranty; and give all
191 +recipients a copy of this License along with the Program.
192 +
193 + You may charge any price or no price for each copy that you convey,
194 +and you may offer support or warranty protection for a fee.
195 +
196 + 5. Conveying Modified Source Versions.
197 +
198 + You may convey a work based on the Program, or the modifications to
199 +produce it from the Program, in the form of source code under the
200 +terms of section 4, provided that you also meet all of these conditions:
201 +
202 + a) The work must carry prominent notices stating that you modified
203 + it, and giving a relevant date.
204 +
205 + b) The work must carry prominent notices stating that it is
206 + released under this License and any conditions added under section
207 + 7. This requirement modifies the requirement in section 4 to
208 + "keep intact all notices".
209 +
210 + c) You must license the entire work, as a whole, under this
211 + License to anyone who comes into possession of a copy. This
212 + License will therefore apply, along with any applicable section 7
213 + additional terms, to the whole of the work, and all its parts,
214 + regardless of how they are packaged. This License gives no
215 + permission to license the work in any other way, but it does not
216 + invalidate such permission if you have separately received it.
217 +
218 + d) If the work has interactive user interfaces, each must display
219 + Appropriate Legal Notices; however, if the Program has interactive
220 + interfaces that do not display Appropriate Legal Notices, your
221 + work need not make them do so.
222 +
223 + A compilation of a covered work with other separate and independent
224 +works, which are not by their nature extensions of the covered work,
225 +and which are not combined with it such as to form a larger program,
226 +in or on a volume of a storage or distribution medium, is called an
227 +"aggregate" if the compilation and its resulting copyright are not
228 +used to limit the access or legal rights of the compilation's users
229 +beyond what the individual works permit. Inclusion of a covered work
230 +in an aggregate does not cause this License to apply to the other
231 +parts of the aggregate.
232 +
233 + 6. Conveying Non-Source Forms.
234 +
235 + You may convey a covered work in object code form under the terms
236 +of sections 4 and 5, provided that you also convey the
237 +machine-readable Corresponding Source under the terms of this License,
238 +in one of these ways:
239 +
240 + a) Convey the object code in, or embodied in, a physical product
241 + (including a physical distribution medium), accompanied by the
242 + Corresponding Source fixed on a durable physical medium
243 + customarily used for software interchange.
244 +
245 + b) Convey the object code in, or embodied in, a physical product
246 + (including a physical distribution medium), accompanied by a
247 + written offer, valid for at least three years and valid for as
248 + long as you offer spare parts or customer support for that product
249 + model, to give anyone who possesses the object code either (1) a
250 + copy of the Corresponding Source for all the software in the
251 + product that is covered by this License, on a durable physical
252 + medium customarily used for software interchange, for a price no
253 + more than your reasonable cost of physically performing this
254 + conveying of source, or (2) access to copy the
255 + Corresponding Source from a network server at no charge.
256 +
257 + c) Convey individual copies of the object code with a copy of the
258 + written offer to provide the Corresponding Source. This
259 + alternative is allowed only occasionally and noncommercially, and
260 + only if you received the object code with such an offer, in accord
261 + with subsection 6b.
262 +
263 + d) Convey the object code by offering access from a designated
264 + place (gratis or for a charge), and offer equivalent access to the
265 + Corresponding Source in the same way through the same place at no
266 + further charge. You need not require recipients to copy the
267 + Corresponding Source along with the object code. If the place to
268 + copy the object code is a network server, the Corresponding Source
269 + may be on a different server (operated by you or a third party)
270 + that supports equivalent copying facilities, provided you maintain
271 + clear directions next to the object code saying where to find the
272 + Corresponding Source. Regardless of what server hosts the
273 + Corresponding Source, you remain obligated to ensure that it is
274 + available for as long as needed to satisfy these requirements.
275 +
276 + e) Convey the object code using peer-to-peer transmission, provided
277 + you inform other peers where the object code and Corresponding
278 + Source of the work are being offered to the general public at no
279 + charge under subsection 6d.
280 +
281 + A separable portion of the object code, whose source code is excluded
282 +from the Corresponding Source as a System Library, need not be
283 +included in conveying the object code work.
284 +
285 + A "User Product" is either (1) a "consumer product", which means any
286 +tangible personal property which is normally used for personal, family,
287 +or household purposes, or (2) anything designed or sold for incorporation
288 +into a dwelling. In determining whether a product is a consumer product,
289 +doubtful cases shall be resolved in favor of coverage. For a particular
290 +product received by a particular user, "normally used" refers to a
291 +typical or common use of that class of product, regardless of the status
292 +of the particular user or of the way in which the particular user
293 +actually uses, or expects or is expected to use, the product. A product
294 +is a consumer product regardless of whether the product has substantial
295 +commercial, industrial or non-consumer uses, unless such uses represent
296 +the only significant mode of use of the product.
297 +
298 + "Installation Information" for a User Product means any methods,
299 +procedures, authorization keys, or other information required to install
300 +and execute modified versions of a covered work in that User Product from
301 +a modified version of its Corresponding Source. The information must
302 +suffice to ensure that the continued functioning of the modified object
303 +code is in no case prevented or interfered with solely because
304 +modification has been made.
305 +
306 + If you convey an object code work under this section in, or with, or
307 +specifically for use in, a User Product, and the conveying occurs as
308 +part of a transaction in which the right of possession and use of the
309 +User Product is transferred to the recipient in perpetuity or for a
310 +fixed term (regardless of how the transaction is characterized), the
311 +Corresponding Source conveyed under this section must be accompanied
312 +by the Installation Information. But this requirement does not apply
313 +if neither you nor any third party retains the ability to install
314 +modified object code on the User Product (for example, the work has
315 +been installed in ROM).
316 +
317 + The requirement to provide Installation Information does not include a
318 +requirement to continue to provide support service, warranty, or updates
319 +for a work that has been modified or installed by the recipient, or for
320 +the User Product in which it has been modified or installed. Access to a
321 +network may be denied when the modification itself materially and
322 +adversely affects the operation of the network or violates the rules and
323 +protocols for communication across the network.
324 +
325 + Corresponding Source conveyed, and Installation Information provided,
326 +in accord with this section must be in a format that is publicly
327 +documented (and with an implementation available to the public in
328 +source code form), and must require no special password or key for
329 +unpacking, reading or copying.
330 +
331 + 7. Additional Terms.
332 +
333 + "Additional permissions" are terms that supplement the terms of this
334 +License by making exceptions from one or more of its conditions.
335 +Additional permissions that are applicable to the entire Program shall
336 +be treated as though they were included in this License, to the extent
337 +that they are valid under applicable law. If additional permissions
338 +apply only to part of the Program, that part may be used separately
339 +under those permissions, but the entire Program remains governed by
340 +this License without regard to the additional permissions.
341 +
342 + When you convey a copy of a covered work, you may at your option
343 +remove any additional permissions from that copy, or from any part of
344 +it. (Additional permissions may be written to require their own
345 +removal in certain cases when you modify the work.) You may place
346 +additional permissions on material, added by you to a covered work,
347 +for which you have or can give appropriate copyright permission.
348 +
349 + Notwithstanding any other provision of this License, for material you
350 +add to a covered work, you may (if authorized by the copyright holders of
351 +that material) supplement the terms of this License with terms:
352 +
353 + a) Disclaiming warranty or limiting liability differently from the
354 + terms of sections 15 and 16 of this License; or
355 +
356 + b) Requiring preservation of specified reasonable legal notices or
357 + author attributions in that material or in the Appropriate Legal
358 + Notices displayed by works containing it; or
359 +
360 + c) Prohibiting misrepresentation of the origin of that material, or
361 + requiring that modified versions of such material be marked in
362 + reasonable ways as different from the original version; or
363 +
364 + d) Limiting the use for publicity purposes of names of licensors or
365 + authors of the material; or
366 +
367 + e) Declining to grant rights under trademark law for use of some
368 + trade names, trademarks, or service marks; or
369 +
370 + f) Requiring indemnification of licensors and authors of that
371 + material by anyone who conveys the material (or modified versions of
372 + it) with contractual assumptions of liability to the recipient, for
373 + any liability that these contractual assumptions directly impose on
374 + those licensors and authors.
375 +
376 + All other non-permissive additional terms are considered "further
377 +restrictions" within the meaning of section 10. If the Program as you
378 +received it, or any part of it, contains a notice stating that it is
379 +governed by this License along with a term that is a further
380 +restriction, you may remove that term. If a license document contains
381 +a further restriction but permits relicensing or conveying under this
382 +License, you may add to a covered work material governed by the terms
383 +of that license document, provided that the further restriction does
384 +not survive such relicensing or conveying.
385 +
386 + If you add terms to a covered work in accord with this section, you
387 +must place, in the relevant source files, a statement of the
388 +additional terms that apply to those files, or a notice indicating
389 +where to find the applicable terms.
390 +
391 + Additional terms, permissive or non-permissive, may be stated in the
392 +form of a separately written license, or stated as exceptions;
393 +the above requirements apply either way.
394 +
395 + 8. Termination.
396 +
397 + You may not propagate or modify a covered work except as expressly
398 +provided under this License. Any attempt otherwise to propagate or
399 +modify it is void, and will automatically terminate your rights under
400 +this License (including any patent licenses granted under the third
401 +paragraph of section 11).
402 +
403 + However, if you cease all violation of this License, then your
404 +license from a particular copyright holder is reinstated (a)
405 +provisionally, unless and until the copyright holder explicitly and
406 +finally terminates your license, and (b) permanently, if the copyright
407 +holder fails to notify you of the violation by some reasonable means
408 +prior to 60 days after the cessation.
409 +
410 + Moreover, your license from a particular copyright holder is
411 +reinstated permanently if the copyright holder notifies you of the
412 +violation by some reasonable means, this is the first time you have
413 +received notice of violation of this License (for any work) from that
414 +copyright holder, and you cure the violation prior to 30 days after
415 +your receipt of the notice.
416 +
417 + Termination of your rights under this section does not terminate the
418 +licenses of parties who have received copies or rights from you under
419 +this License. If your rights have been terminated and not permanently
420 +reinstated, you do not qualify to receive new licenses for the same
421 +material under section 10.
422 +
423 + 9. Acceptance Not Required for Having Copies.
424 +
425 + You are not required to accept this License in order to receive or
426 +run a copy of the Program. Ancillary propagation of a covered work
427 +occurring solely as a consequence of using peer-to-peer transmission
428 +to receive a copy likewise does not require acceptance. However,
429 +nothing other than this License grants you permission to propagate or
430 +modify any covered work. These actions infringe copyright if you do
431 +not accept this License. Therefore, by modifying or propagating a
432 +covered work, you indicate your acceptance of this License to do so.
433 +
434 + 10. Automatic Licensing of Downstream Recipients.
435 +
436 + Each time you convey a covered work, the recipient automatically
437 +receives a license from the original licensors, to run, modify and
438 +propagate that work, subject to this License. You are not responsible
439 +for enforcing compliance by third parties with this License.
440 +
441 + An "entity transaction" is a transaction transferring control of an
442 +organization, or substantially all assets of one, or subdividing an
443 +organization, or merging organizations. If propagation of a covered
444 +work results from an entity transaction, each party to that
445 +transaction who receives a copy of the work also receives whatever
446 +licenses to the work the party's predecessor in interest had or could
447 +give under the previous paragraph, plus a right to possession of the
448 +Corresponding Source of the work from the predecessor in interest, if
449 +the predecessor has it or can get it with reasonable efforts.
450 +
451 + You may not impose any further restrictions on the exercise of the
452 +rights granted or affirmed under this License. For example, you may
453 +not impose a license fee, royalty, or other charge for exercise of
454 +rights granted under this License, and you may not initiate litigation
455 +(including a cross-claim or counterclaim in a lawsuit) alleging that
456 +any patent claim is infringed by making, using, selling, offering for
457 +sale, or importing the Program or any portion of it.
458 +
459 + 11. Patents.
460 +
461 + A "contributor" is a copyright holder who authorizes use under this
462 +License of the Program or a work on which the Program is based. The
463 +work thus licensed is called the contributor's "contributor version".
464 +
465 + A contributor's "essential patent claims" are all patent claims
466 +owned or controlled by the contributor, whether already acquired or
467 +hereafter acquired, that would be infringed by some manner, permitted
468 +by this License, of making, using, or selling its contributor version,
469 +but do not include claims that would be infringed only as a
470 +consequence of further modification of the contributor version. For
471 +purposes of this definition, "control" includes the right to grant
472 +patent sublicenses in a manner consistent with the requirements of
473 +this License.
474 +
475 + Each contributor grants you a non-exclusive, worldwide, royalty-free
476 +patent license under the contributor's essential patent claims, to
477 +make, use, sell, offer for sale, import and otherwise run, modify and
478 +propagate the contents of its contributor version.
479 +
480 + In the following three paragraphs, a "patent license" is any express
481 +agreement or commitment, however denominated, not to enforce a patent
482 +(such as an express permission to practice a patent or covenant not to
483 +sue for patent infringement). To "grant" such a patent license to a
484 +party means to make such an agreement or commitment not to enforce a
485 +patent against the party.
486 +
487 + If you convey a covered work, knowingly relying on a patent license,
488 +and the Corresponding Source of the work is not available for anyone
489 +to copy, free of charge and under the terms of this License, through a
490 +publicly available network server or other readily accessible means,
491 +then you must either (1) cause the Corresponding Source to be so
492 +available, or (2) arrange to deprive yourself of the benefit of the
493 +patent license for this particular work, or (3) arrange, in a manner
494 +consistent with the requirements of this License, to extend the patent
495 +license to downstream recipients. "Knowingly relying" means you have
496 +actual knowledge that, but for the patent license, your conveying the
497 +covered work in a country, or your recipient's use of the covered work
498 +in a country, would infringe one or more identifiable patents in that
499 +country that you have reason to believe are valid.
500 +
501 + If, pursuant to or in connection with a single transaction or
502 +arrangement, you convey, or propagate by procuring conveyance of, a
503 +covered work, and grant a patent license to some of the parties
504 +receiving the covered work authorizing them to use, propagate, modify
505 +or convey a specific copy of the covered work, then the patent license
506 +you grant is automatically extended to all recipients of the covered
507 +work and works based on it.
508 +
509 + A patent license is "discriminatory" if it does not include within
510 +the scope of its coverage, prohibits the exercise of, or is
511 +conditioned on the non-exercise of one or more of the rights that are
512 +specifically granted under this License. You may not convey a covered
513 +work if you are a party to an arrangement with a third party that is
514 +in the business of distributing software, under which you make payment
515 +to the third party based on the extent of your activity of conveying
516 +the work, and under which the third party grants, to any of the
517 +parties who would receive the covered work from you, a discriminatory
518 +patent license (a) in connection with copies of the covered work
519 +conveyed by you (or copies made from those copies), or (b) primarily
520 +for and in connection with specific products or compilations that
521 +contain the covered work, unless you entered into that arrangement,
522 +or that patent license was granted, prior to 28 March 2007.
523 +
524 + Nothing in this License shall be construed as excluding or limiting
525 +any implied license or other defenses to infringement that may
526 +otherwise be available to you under applicable patent law.
527 +
528 + 12. No Surrender of Others' Freedom.
529 +
530 + If conditions are imposed on you (whether by court order, agreement or
531 +otherwise) that contradict the conditions of this License, they do not
532 +excuse you from the conditions of this License. If you cannot convey a
533 +covered work so as to satisfy simultaneously your obligations under this
534 +License and any other pertinent obligations, then as a consequence you may
535 +not convey it at all. For example, if you agree to terms that obligate you
536 +to collect a royalty for further conveying from those to whom you convey
537 +the Program, the only way you could satisfy both those terms and this
538 +License would be to refrain entirely from conveying the Program.
539 +
540 + 13. Remote Network Interaction; Use with the GNU General Public License.
541 +
542 + Notwithstanding any other provision of this License, if you modify the
543 +Program, your modified version must prominently offer all users
544 +interacting with it remotely through a computer network (if your version
545 +supports such interaction) an opportunity to receive the Corresponding
546 +Source of your version by providing access to the Corresponding Source
547 +from a network server at no charge, through some standard or customary
548 +means of facilitating copying of software. This Corresponding Source
549 +shall include the Corresponding Source for any work covered by version 3
550 +of the GNU General Public License that is incorporated pursuant to the
551 +following paragraph.
552 +
553 + Notwithstanding any other provision of this License, you have
554 +permission to link or combine any covered work with a work licensed
555 +under version 3 of the GNU General Public License into a single
556 +combined work, and to convey the resulting work. The terms of this
557 +License will continue to apply to the part which is the covered work,
558 +but the work with which it is combined will remain governed by version
559 +3 of the GNU General Public License.
560 +
561 + 14. Revised Versions of this License.
562 +
563 + The Free Software Foundation may publish revised and/or new versions of
564 +the GNU Affero General Public License from time to time. Such new versions
565 +will be similar in spirit to the present version, but may differ in detail to
566 +address new problems or concerns.
567 +
568 + Each version is given a distinguishing version number. If the
569 +Program specifies that a certain numbered version of the GNU Affero General
570 +Public License "or any later version" applies to it, you have the
571 +option of following the terms and conditions either of that numbered
572 +version or of any later version published by the Free Software
573 +Foundation. If the Program does not specify a version number of the
574 +GNU Affero General Public License, you may choose any version ever published
575 +by the Free Software Foundation.
576 +
577 + If the Program specifies that a proxy can decide which future
578 +versions of the GNU Affero General Public License can be used, that proxy's
579 +public statement of acceptance of a version permanently authorizes you
580 +to choose that version for the Program.
581 +
582 + Later license versions may give you additional or different
583 +permissions. However, no additional obligations are imposed on any
584 +author or copyright holder as a result of your choosing to follow a
585 +later version.
586 +
587 + 15. Disclaimer of Warranty.
588 +
589 + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
590 +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
591 +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
592 +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
593 +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
594 +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
595 +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
596 +ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
597 +
598 + 16. Limitation of Liability.
599 +
600 + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
601 +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
602 +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
603 +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
604 +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
605 +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
606 +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
607 +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
608 +SUCH DAMAGES.
609 +
610 + 17. Interpretation of Sections 15 and 16.
611 +
612 + If the disclaimer of warranty and limitation of liability provided
613 +above cannot be given local legal effect according to their terms,
614 +reviewing courts shall apply local law that most closely approximates
615 +an absolute waiver of all civil liability in connection with the
616 +Program, unless a warranty or assumption of liability accompanies a
617 +copy of the Program in return for a fee.
618 +
619 + END OF TERMS AND CONDITIONS
620 +
621 + How to Apply These Terms to Your New Programs
622 +
623 + If you develop a new program, and you want it to be of the greatest
624 +possible use to the public, the best way to achieve this is to make it
625 +free software which everyone can redistribute and change under these terms.
626 +
627 + To do so, attach the following notices to the program. It is safest
628 +to attach them to the start of each source file to most effectively
629 +state the exclusion of warranty; and each file should have at least
630 +the "copyright" line and a pointer to where the full notice is found.
631 +
632 + <one line to give the program's name and a brief idea of what it does.>
633 + Copyright (C) <year> <name of author>
634 +
635 + This program is free software: you can redistribute it and/or modify
636 + it under the terms of the GNU Affero General Public License as published by
637 + the Free Software Foundation, either version 3 of the License, or
638 + (at your option) any later version.
639 +
640 + This program is distributed in the hope that it will be useful,
641 + but WITHOUT ANY WARRANTY; without even the implied warranty of
642 + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 + GNU Affero General Public License for more details.
644 +
645 + You should have received a copy of the GNU Affero General Public License
646 + along with this program. If not, see <https://www.gnu.org/licenses/>.
647 +
648 +Also add information on how to contact you by electronic and paper mail.
649 +
650 + If your software can interact with users remotely through a computer
651 +network, you should also make sure that it provides a way for users to
652 +get its source. For example, if your program is a web application, its
653 +interface could display a "Source" link that leads users to an archive
654 +of the code. There are many ways you could offer source, and different
655 +solutions will be better for different programs; see section 13 for the
656 +specific requirements.
657 +
658 + You should also get your employer (if you work as a programmer) or school,
659 +if any, to sign a "copyright disclaimer" for the program, if necessary.
660 +For more information on this, and how to apply and follow the GNU AGPL, see
661 +<https://www.gnu.org/licenses/>.
A README.md
+95 -0
@@ -0,0 +1,95 @@
1 +# Card2vcf
2 +
3 +Application Android **hors-ligne** : scan d’une carte de visite → OCR local (Tesseract) → brouillon éditable → contact Android **et** export `.vcf`.
4 +
5 +Aucune dépendance Google Play Services / ML Kit, aucun LLM, aucune permission Internet.
6 +
7 +[![Licence: AGPL v3](https://img.shields.io/badge/Licence-AGPL%20v3-blue.svg)](LICENSE)
8 +![Plateforme](https://img.shields.io/badge/Plateforme-Android%2012%2B-3DDC84?logo=android&logoColor=white)
9 +![100% on-device](https://img.shields.io/badge/100%25-on--device-success)
10 +
11 +## Fonctionnement
12 +
13 +1. Cadrez la carte et capturez (CameraX).
14 +2. Correction géométrique (OpenCV) + orientation EXIF.
15 +3. OCR Tesseract (7 langues) + post-traitement.
16 +4. Structuration **heuristique** (pas de modèle de langage).
17 +5. Vérifiez le brouillon, puis :
18 + - **Créer le contact** → `ACTION_INSERT` Contacts
19 + - **Exporter VCF** → partage d’un fichier `.vcf`
20 +
21 +## Langues OCR
22 +
23 +| Code | Langue |
24 +|------|--------|
25 +| `fra` | Français |
26 +| `deu` | Allemand |
27 +| `eng` | Anglais |
28 +| `spa` | Espagnol |
29 +| `por` | Portugais |
30 +| `ita` | Italien |
31 +| `pol` | Polonais |
32 +
33 +### Choix fast / full à la compilation
34 +
35 +| Variante | Propriété Gradle | Source | Taille approx. |
36 +|----------|------------------|--------|----------------|
37 +| **fast** (défaut) | `-Ptessdata=fast` | [tessdata_fast](https://github.com/tesseract-ocr/tessdata_fast) | ~18 Mo |
38 +| **full** | `-Ptessdata=full` | [tessdata](https://github.com/tesseract-ocr/tessdata) | ~80 Mo+ |
39 +
40 +```bash
41 +cd android
42 +./gradlew :app:assembleDebug # fast
43 +./gradlew :app:assembleDebug -Ptessdata=full # full
44 +```
45 +
46 +Premier build d’une variante : téléchargement dans `android/tessdata-cache/{fast,full}/` (gitignored), puis injection dans les assets générés. Réseau requis **uniquement** si le cache est vide.
47 +
48 +### Chargement ultérieur (sans rebuild / sans Internet dans l’APK)
49 +
50 +Oui. Au runtime, Tesseract lit `filesDir/tesseract/tessdata/` :
51 +
52 +1. Première exécution (ou changement fast↔full du APK) → copie depuis les assets embarqués.
53 +2. Ensuite → réutilisation des fichiers locaux.
54 +3. Sideload : déposer/remplacer les `*.traineddata` dans ce dossier (ex. `adb push`), puis éventuellement écrire le stamp `external` pour empêcher l’écrasement au prochain changement de variante APK :
55 +
56 +```text
57 +/data/data/fr.ebii.card2vcf/files/tesseract/tessdata/
58 + fra.traineddata
59 +
60 + .card2vcf-tessdata-variant # "fast" | "full" | "external"
61 +```
62 +
63 +Pas de téléchargement réseau dans l’app (pas de permission `INTERNET`).
64 +
65 +APK debug filtré `arm64-v8a`. Pour émulateur x86, retirez temporairement `ndk.abiFilters` dans `app/build.gradle.kts`.
66 +
67 +## Build
68 +
69 +```bash
70 +cd android
71 +./gradlew :app:assembleDebug
72 +./gradlew :app:testDebugUnitTest
73 +```
74 +
75 +APK debug : `android/app/build/outputs/apk/debug/app-debug.apk`
76 +
77 +Prérequis : JDK 21, Android SDK (API 35).
78 +
79 +## Limites (assumées)
80 +
81 +- **Pas de LLM** : la qualité du brouillon dépend des heuristiques + du texte OCR. Cartes très décoratives ou manuscrites peuvent échouer.
82 +- **Caméra obligatoire** (`android.hardware.camera` required).
83 +- Structuration sémantique avancée (homonymes, mises en page atypiques) hors périmètre.
84 +
85 +## Design
86 +
87 +UI N&B éditoriale (`design.md`) : coins carrés, hairlines, polices TTF embarquées (Playfair Display, Lora, Manrope — OFL, voir `docs/licenses/OFL-fonts.txt`). Icône : `icon.png`.
88 +
89 +## Origine du code
90 +
91 +Pipeline scan/OCR/contact adapté depuis le projet open source [luciole-mobile](https://github.com/OpenLLM-France/luciole-mobile) (AGPL), sans le module LLM (`cerveau`).
92 +
93 +## Licence
94 +
95 +[AGPL v3](LICENSE)
A android/app/build.gradle.kts
+118 -0
@@ -0,0 +1,118 @@
1 +import java.net.URI
2 +
3 +plugins {
4 + id("com.android.application")
5 + id("org.jetbrains.kotlin.android")
6 + id("org.jetbrains.kotlin.plugin.compose")
7 +}
8 +
9 +/** `fast` (défaut, ~18 Mo) ou `full` (~80 Mo+). Ex. : ./gradlew assembleDebug -Ptessdata=full */
10 +val tessdataVariant: String =
11 + (findProperty("tessdata") as String?)?.lowercase()?.trim().orEmpty().ifEmpty { "fast" }
12 +require(tessdataVariant == "fast" || tessdataVariant == "full") {
13 + "Propriété -Ptessdata= invalide (« $tessdataVariant ») — utiliser fast ou full"
14 +}
15 +
16 +val tessdataLanguages = listOf("fra", "deu", "eng", "spa", "por", "ita", "pol")
17 +val tessdataCacheDir = rootProject.layout.projectDirectory.dir("tessdata-cache/$tessdataVariant")
18 +val tessdataGeneratedAssets = layout.buildDirectory.dir("generated/tessdataAssets")
19 +
20 +android {
21 + namespace = "fr.ebii.card2vcf"
22 + compileSdk = 35
23 + defaultConfig {
24 + applicationId = "fr.ebii.card2vcf"
25 + minSdk = 31
26 + targetSdk = 35
27 + versionCode = 1
28 + versionName = "0.1.0"
29 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
30 + ndk {
31 + abiFilters += listOf("arm64-v8a")
32 + }
33 + }
34 + buildFeatures {
35 + compose = true
36 + }
37 + compileOptions {
38 + sourceCompatibility = JavaVersion.VERSION_21
39 + targetCompatibility = JavaVersion.VERSION_21
40 + }
41 + kotlinOptions { jvmTarget = "21" }
42 + testOptions {
43 + unitTests.isReturnDefaultValues = true
44 + unitTests.isIncludeAndroidResources = true
45 + }
46 + sourceSets.getByName("main").assets.srcDir(tessdataGeneratedAssets)
47 +}
48 +
49 +val prepareTessdata by tasks.registering {
50 + group = "build"
51 + description = "Télécharge (cache) et prépare les traineddata Tessdata ($tessdataVariant)"
52 + inputs.property("variant", tessdataVariant)
53 + inputs.property("languages", tessdataLanguages.joinToString(","))
54 + outputs.dir(tessdataGeneratedAssets)
55 + doLast {
56 + val cache = tessdataCacheDir.asFile.apply { mkdirs() }
57 + val outDir = tessdataGeneratedAssets.get().asFile
58 + .resolve("tesseract/tessdata")
59 + .apply { mkdirs() }
60 + val repo = if (tessdataVariant == "fast") "tessdata_fast" else "tessdata"
61 + for (lang in tessdataLanguages) {
62 + val cached = cache.resolve("$lang.traineddata")
63 + if (!cached.isFile || cached.length() < 10_000L) {
64 + val url = "https://github.com/tesseract-ocr/$repo/raw/main/$lang.traineddata"
65 + logger.lifecycle("Téléchargement $repo/$lang.traineddata …")
66 + URI(url).toURL().openStream().use { input ->
67 + cached.outputStream().use { output -> input.copyTo(output) }
68 + }
69 + check(cached.isFile && cached.length() >= 10_000L) {
70 + "Échec téléchargement $url"
71 + }
72 + }
73 + cached.copyTo(outDir.resolve("$lang.traineddata"), overwrite = true)
74 + }
75 + outDir.resolve("variant.txt").writeText(tessdataVariant)
76 + outDir.resolve("README.md").writeText(
77 + "Généré au build (variante=$tessdataVariant). Ne pas éditer.\n" +
78 + "Cache : android/tessdata-cache/$tessdataVariant/\n"
79 + )
80 + logger.lifecycle("Tessdata $tessdataVariant prêt (${tessdataLanguages.size} langues).")
81 + }
82 +}
83 +
84 +tasks.matching {
85 + it.name.startsWith("merge") && it.name.endsWith("Assets")
86 +}.configureEach {
87 + dependsOn(prepareTessdata)
88 +}
89 +tasks.named("preBuild").configure { dependsOn(prepareTessdata) }
90 +
91 +dependencies {
92 + implementation(platform("androidx.compose:compose-bom:2024.09.02"))
93 + implementation("androidx.compose.ui:ui")
94 + implementation("androidx.compose.material3:material3")
95 + implementation("androidx.compose.material:material-icons-core")
96 + implementation("androidx.compose.material:material-icons-extended")
97 + implementation("androidx.compose.ui:ui-tooling-preview")
98 + implementation("androidx.activity:activity-compose:1.9.2")
99 + implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
100 + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
101 + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
102 + implementation("androidx.camera:camera-camera2:1.4.1")
103 + implementation("androidx.camera:camera-lifecycle:1.4.1")
104 + implementation("androidx.camera:camera-view:1.4.1")
105 + implementation("org.opencv:opencv:4.10.0")
106 + implementation("com.github.adaptech-cz.Tesseract4Android:tesseract4android-openmp:4.9.0")
107 + implementation("androidx.exifinterface:exifinterface:1.3.7")
108 +
109 + testImplementation("junit:junit:4.13.2")
110 + testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.20")
111 + testImplementation("org.robolectric:robolectric:4.13")
112 + testImplementation("androidx.test:core:1.6.1")
113 + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
114 +
115 + androidTestImplementation("androidx.test.ext:junit:1.2.1")
116 + androidTestImplementation("androidx.test:runner:1.6.2")
117 + androidTestImplementation("androidx.test:rules:1.6.1")
118 +}
A android/app/src/androidTest/assets/cards/dimo_genoux.jpg
+0 -0
A android/app/src/androidTest/assets/cards/dimo_genoux_oriented.jpg
+0 -0
A android/app/src/androidTest/java/fr/ebii/card2vcf/ocr/DimoCardOcrInstrumentedTest.kt
+58 -0
@@ -0,0 +1,58 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.graphics.BitmapFactory
4 +import androidx.test.ext.junit.runners.AndroidJUnit4
5 +import androidx.test.platform.app.InstrumentationRegistry
6 +import fr.ebii.card2vcf.scan.ImageOrientation
7 +import fr.ebii.card2vcf.scan.OpenCvScanEngine
8 +import org.junit.Assert.assertEquals
9 +import org.junit.Assert.assertTrue
10 +import org.junit.Test
11 +import org.junit.runner.RunWith
12 +
13 +/**
14 + * Test instrumenté générique : JPEG brut + EXIF → orientation → scan → OCR.
15 + * Couvre le chemin réel utilisateur (photo téléphone avec EXIF Orientation=6).
16 + */
17 +@RunWith(AndroidJUnit4::class)
18 +class DimoCardOcrInstrumentedTest {
19 +
20 + @Test
21 + fun scanEtOcrAvecCorrectionExif() {
22 + val context = InstrumentationRegistry.getInstrumentation().targetContext
23 + OpenCvScanEngine.ensureOpenCv()
24 + val assets = InstrumentationRegistry.getInstrumentation().context.assets
25 +
26 + // JPEG brut (avec EXIF 6), pas la version déjà orientée.
27 + val jpeg = assets.open("cards/dimo_genoux.jpg").use { it.readBytes() }
28 + val oriented = ImageOrientation.fromJpegBytes(jpeg)!!
29 + assertTrue("EXIF doit corriger la photo dimo", oriented.fromExif)
30 + assertEquals(90, oriented.appliedRotationDegrees)
31 +
32 + val scanned = OpenCvScanEngine().scan(oriented.bitmap)
33 + val ocr = TesseractOcrEngine(context).recognize(scanned.bitmap)
34 +
35 + println("=== OCR RAW ===\n${ocr.rawText}")
36 + println("=== phones=${ocr.phones}")
37 + println("=== emails=${ocr.emails}")
38 + println("=== urls=${ocr.urls}")
39 +
40 + assertTrue("texte OCR trop court: ${ocr.rawText.length}", ocr.rawText.length > 20)
41 + assertTrue(
42 + "email attendu absent: ${ocr.emails}",
43 + ocr.emails.any { it.contains("dimosoftware", ignoreCase = true) }
44 + || ocr.rawText.contains("dimosoftware", ignoreCase = true),
45 + )
46 + }
47 +
48 + @Test
49 + fun fallbackCameraXSiExifNormal() {
50 + val assets = InstrumentationRegistry.getInstrumentation().context.assets
51 + // Version déjà orientée : EXIF normal → pas de rotation EXIF
52 + val jpeg = assets.open("cards/dimo_genoux_oriented.jpg").use { it.readBytes() }
53 + val withCam = ImageOrientation.fromJpegBytes(jpeg, cameraRotationDegrees = 0)!!
54 + assertEquals(0, withCam.appliedRotationDegrees)
55 + val bmp = BitmapFactory.decodeByteArray(jpeg, 0, jpeg.size)!!
56 + assertEquals(bmp.width, withCam.bitmap.width)
57 + }
58 +}
A android/app/src/main/AndroidManifest.xml
+30 -0
@@ -0,0 +1,30 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<manifest xmlns:android="http://schemas.android.com/apk/res/android">
3 + <uses-permission android:name="android.permission.CAMERA" />
4 + <uses-feature android:name="android.hardware.camera" android:required="true" />
5 +
6 + <application
7 + android:label="@string/app_name"
8 + android:icon="@mipmap/ic_launcher"
9 + android:roundIcon="@mipmap/ic_launcher_round"
10 + android:theme="@android:style/Theme.Material.Light.NoActionBar">
11 + <provider
12 + android:name="androidx.core.content.FileProvider"
13 + android:authorities="${applicationId}.fileprovider"
14 + android:exported="false"
15 + android:grantUriPermissions="true">
16 + <meta-data
17 + android:name="android.support.FILE_PROVIDER_PATHS"
18 + android:resource="@xml/file_paths" />
19 + </provider>
20 + <activity
21 + android:name=".MainActivity"
22 + android:exported="true"
23 + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density">
24 + <intent-filter>
25 + <action android:name="android.intent.action.MAIN" />
26 + <category android:name="android.intent.category.LAUNCHER" />
27 + </intent-filter>
28 + </activity>
29 + </application>
30 +</manifest>
A android/app/src/main/assets/tesseract/tessdata/.gitkeep
+0 -0
A android/app/src/main/java/fr/ebii/card2vcf/MainActivity.kt
+59 -0
@@ -0,0 +1,59 @@
1 +package fr.ebii.card2vcf
2 +
3 +import android.os.Bundle
4 +import androidx.activity.ComponentActivity
5 +import androidx.activity.compose.setContent
6 +import androidx.activity.enableEdgeToEdge
7 +import androidx.compose.foundation.background
8 +import androidx.compose.foundation.layout.Box
9 +import androidx.compose.foundation.layout.fillMaxSize
10 +import androidx.compose.foundation.layout.systemBarsPadding
11 +import androidx.compose.ui.Modifier
12 +import fr.ebii.card2vcf.contact.ContactInsertIntent
13 +import fr.ebii.card2vcf.contact.VcfShare
14 +import fr.ebii.card2vcf.ocr.TesseractOcrEngine
15 +import fr.ebii.card2vcf.scan.OpenCvScanEngine
16 +import fr.ebii.card2vcf.ui.ScanCarteScreen
17 +import fr.ebii.card2vcf.ui.ScanCarteViewModel
18 +import fr.ebii.card2vcf.ui.theme.Card2vcfTheme
19 +import fr.ebii.card2vcf.ui.theme.Canvas
20 +
21 +class MainActivity : ComponentActivity() {
22 + override fun onCreate(savedInstanceState: Bundle?) {
23 + super.onCreate(savedInstanceState)
24 + enableEdgeToEdge()
25 + OpenCvScanEngine.ensureOpenCv()
26 +
27 + val scanVm = ScanCarteViewModel(
28 + scanEngine = OpenCvScanEngine(),
29 + ocrEngine = TesseractOcrEngine(this),
30 + )
31 +
32 + setContent {
33 + Card2vcfTheme {
34 + Box(
35 + Modifier
36 + .fillMaxSize()
37 + .background(Canvas)
38 + .systemBarsPadding(),
39 + ) {
40 + ScanCarteScreen(
41 + viewModel = scanVm,
42 + onCreateContact = { card ->
43 + startActivity(ContactInsertIntent.build(card))
44 + },
45 + onExportVcf = { card ->
46 + startActivity(
47 + android.content.Intent.createChooser(
48 + VcfShare.shareIntent(this@MainActivity, card),
49 + getString(R.string.scan_exporter_vcf),
50 + )
51 + )
52 + },
53 + onBack = { /* activité unique */ },
54 + )
55 + }
56 + }
57 + }
58 + }
59 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/ContactCard.kt
+29 -0
@@ -0,0 +1,29 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +data class ContactCard(
4 + val fullName: String? = null,
5 + val firstName: String? = null,
6 + val lastName: String? = null,
7 + val company: String? = null,
8 + val jobTitle: String? = null,
9 + val phones: List<String> = emptyList(),
10 + val emails: List<String> = emptyList(),
11 + val website: String? = null,
12 + val address: String? = null,
13 + val note: String? = null,
14 +) {
15 + fun displayName(): String =
16 + fullName?.takeIf { it.isNotBlank() }
17 + ?: listOfNotNull(firstName, lastName).joinToString(" ").takeIf { it.isNotBlank() }
18 + ?: company.orEmpty()
19 +
20 + fun hasAnyField(): Boolean =
21 + displayName().isNotBlank()
22 + || company?.isNotBlank() == true
23 + || jobTitle?.isNotBlank() == true
24 + || phones.isNotEmpty()
25 + || emails.isNotEmpty()
26 + || website?.isNotBlank() == true
27 + || address?.isNotBlank() == true
28 + || note?.isNotBlank() == true
29 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/ContactDraftMerge.kt
+40 -0
@@ -0,0 +1,40 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import fr.ebii.card2vcf.ocr.OcrResult
4 +
5 +/**
6 + * Fusionne les sources, par priorité de fiabilité :
7 + * 1. Regex OCR (tél / email / url) — toujours prioritaire
8 + * 2. Carte LLM optionnelle (`llm`) si présente et non vide — Card2vcf passe `null`
9 + * 3. Heuristiques déterministes (nom / société / poste / adresse)
10 + */
11 +object ContactDraftMerge {
12 + fun merge(llm: ContactCard?, ocr: OcrResult): ContactCard {
13 + val heuristic = ContactHeuristicParser.parse(ocr)
14 + val base = llm ?: ContactCard()
15 + return ContactCard(
16 + fullName = prefer(base.fullName, heuristic.fullName),
17 + firstName = prefer(base.firstName, heuristic.firstName),
18 + lastName = prefer(base.lastName, heuristic.lastName),
19 + company = prefer(base.company, heuristic.company),
20 + jobTitle = prefer(base.jobTitle, heuristic.jobTitle),
21 + phones = mergeStrings(ocr.phones, base.phones + heuristic.phones),
22 + emails = mergeStrings(ocr.emails, base.emails + heuristic.emails),
23 + website = prefer(ocr.urls.firstOrNull(), prefer(base.website, heuristic.website)),
24 + address = prefer(base.address, heuristic.address),
25 + note = prefer(base.note, heuristic.note),
26 + )
27 + }
28 +
29 + private fun prefer(primary: String?, fallback: String?): String? =
30 + primary?.takeIf { it.isNotBlank() } ?: fallback?.takeIf { it.isNotBlank() }
31 +
32 + private fun mergeStrings(preferred: List<String>, extra: List<String>): List<String> {
33 + val seen = linkedSetOf<String>()
34 + (preferred + extra).forEach { v ->
35 + val n = v.trim()
36 + if (n.isNotEmpty()) seen.add(n)
37 + }
38 + return seen.toList()
39 + }
40 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/ContactHeuristicParser.kt
+178 -0
@@ -0,0 +1,178 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import fr.ebii.card2vcf.ocr.OcrResult
4 +
5 +/**
6 + * Structuration déterministe d'un texte OCR de carte de visite.
7 + * Source unique de structuration dans Card2vcf (pas de LLM).
8 + */
9 +object ContactHeuristicParser {
10 +
11 + private val jobKeywords = Regex(
12 + """(?i)\b(directeur|directrice|ceo|cto|cfo|coo|président|presidente|""" +
13 + """manager|responsable|ingénieur|ingenieur|commercial|consultan[te]?|""" +
14 + """associé|associe|fondateur|founder|head of|chef de|avocat|docteur|dr\.?)\b""",
15 + )
16 + private val addressKeywords = Regex(
17 + """(?i)\b(rue|avenue|av\.?|bd\.?|boulevard|all[ée]e|chemin|place|impasse|""" +
18 + """cedex|cs\b|bp\b|boîte postale|france|siège|siege)\b|\b\d{5}\b""",
19 + )
20 + private val companyKeywords = Regex(
21 + """(?i)\b(sas|sarl|sa\b|sasu|eurl|sci|ltd|inc|gmbh|software|solutions|""" +
22 + """group|groupe|technologies|tech|consulting)\b""",
23 + )
24 + private val skipLine = Regex(
25 + """(?i)^(mobile|tél\.?|tel\.?|phone|fax|email|e-mail|mail|www\.|http|""" +
26 + """siège social|siege social|linkedin|twitter)\b""",
27 + )
28 + private val emailOrUrlOrPhone = Regex(
29 + """@|www\.|https?://|\+?\d[\d\s.\-()]{7,}\d""",
30 + RegexOption.IGNORE_CASE,
31 + )
32 +
33 + fun parse(ocr: OcrResult): ContactCard {
34 + val lines = ocr.lines.ifEmpty { ocr.rawText.lines().map { it.trim() }.filter { it.isNotEmpty() } }
35 + val residual = lines.toMutableList()
36 +
37 + fun takeMatching(pred: (String) -> Boolean): String? {
38 + val idx = residual.indexOfFirst(pred)
39 + if (idx < 0) return null
40 + return residual.removeAt(idx)
41 + }
42 +
43 + val jobTitle = takeMatching { jobKeywords.containsMatchIn(it) && !emailOrUrlOrPhone.containsMatchIn(it) }
44 + val address = residual
45 + .filter { addressKeywords.containsMatchIn(it) && !emailOrUrlOrPhone.containsMatchIn(it) }
46 + .maxByOrNull { it.length }
47 + ?.also { residual.remove(it) }
48 +
49 + val personLine = residual
50 + .asSequence()
51 + .filter { looksLikePersonName(it) }
52 + .maxByOrNull { personScore(it) }
53 + ?.also { residual.remove(it) }
54 +
55 + val (firstName, lastName, fullName) = splitPersonName(personLine)
56 +
57 + val companyFromEmail = ocr.emails.firstOrNull()?.let { companyFromEmailDomain(it) }
58 + val companyLine = residual
59 + .asSequence()
60 + .filter { looksLikeCompany(it) }
61 + .maxByOrNull { companyScore(it) }
62 + ?.also { residual.remove(it) }
63 + val company = cleanCompany(companyLine) ?: companyFromEmail
64 +
65 + val website = ocr.urls.firstOrNull()
66 + val noteParts = residual.filter { line ->
67 + !skipLine.containsMatchIn(line)
68 + && !emailOrUrlOrPhone.containsMatchIn(line)
69 + && line.length > 2
70 + }
71 +
72 + return ContactCard(
73 + fullName = fullName,
74 + firstName = firstName,
75 + lastName = lastName,
76 + company = company,
77 + jobTitle = cleanJob(jobTitle),
78 + phones = ocr.phones,
79 + emails = ocr.emails,
80 + website = website,
81 + address = cleanAddress(address),
82 + note = noteParts.take(3).joinToString(" · ").ifBlank { null },
83 + )
84 + }
85 +
86 + internal fun looksLikePersonName(line: String): Boolean {
87 + val t = line.replace(Regex("""[^\p{L}\s\-']"""), " ").trim()
88 + if (t.length !in 4..60) return false
89 + if (jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t)) return false
90 + if (addressKeywords.containsMatchIn(t)) return false
91 + if (emailOrUrlOrPhone.containsMatchIn(line)) return false
92 + val parts = t.split(Regex("""\s+""")).filter { it.isNotBlank() }
93 + if (parts.size !in 2..4) return false
94 + // Au moins un token capitalisé / majuscules
95 + val caps = parts.count { it.first().isUpperCase() }
96 + return caps >= 2
97 + }
98 +
99 + internal fun personScore(line: String): Int {
100 + val parts = line.replace(Regex("""[^\p{L}\s\-']"""), " ").trim().split(Regex("""\s+"""))
101 + var s = parts.size * 10
102 + if (parts.any { it.length >= 2 && it == it.uppercase() && it.any { c -> c.isLetter() } }) s += 40
103 + if (parts.size in 2..3) s += 15
104 + return s
105 + }
106 +
107 + internal fun splitPersonName(line: String?): Triple<String?, String?, String?> {
108 + if (line.isNullOrBlank()) return Triple(null, null, null)
109 + val cleaned = line
110 + .replace('!', 'l') // Pau! → Paul (erreur OCR fréquente)
111 + .replace(Regex("""[^\p{L}\s\-']"""), " ")
112 + .replace(Regex("""\s+"""), " ")
113 + .trim()
114 + val parts = cleaned.split(' ').filter { it.isNotBlank() }
115 + if (parts.isEmpty()) return Triple(null, null, null)
116 + val lastCaps = parts.indexOfLast { it.length >= 2 && it == it.uppercase() && it.all { c -> !c.isLetter() || c.isUpperCase() } }
117 + fun titleWord(w: String) = w.lowercase().replaceFirstChar { it.titlecase() }
118 + return if (lastCaps > 0) {
119 + val last = titleWord(parts[lastCaps])
120 + val first = parts.take(lastCaps).joinToString(" ") { titleWord(it) }
121 + // Conserve les prénoms composés sans forcer une casse bizarre si déjà mixtes
122 + val firstKeep = parts.take(lastCaps).joinToString(" ")
123 + Triple(firstKeep, last, "$firstKeep $last")
124 + } else if (parts.size >= 2) {
125 + val last = titleWord(parts.last())
126 + val first = parts.dropLast(1).joinToString(" ")
127 + Triple(first, last, "$first $last")
128 + } else {
129 + Triple(null, null, cleaned)
130 + }
131 + }
132 +
133 + private fun looksLikeCompany(line: String): Boolean {
134 + if (line.length !in 2..50) return false
135 + if (emailOrUrlOrPhone.containsMatchIn(line)) return false
136 + if (jobKeywords.containsMatchIn(line)) return false
137 + if (addressKeywords.containsMatchIn(line)) return false
138 + if (looksLikePersonName(line)) return false
139 + return companyKeywords.containsMatchIn(line)
140 + || line == line.uppercase()
141 + || line.split(Regex("""\s+""")).size <= 3
142 + }
143 +
144 + private fun companyScore(line: String): Int {
145 + var s = 0
146 + if (companyKeywords.containsMatchIn(line)) s += 50
147 + if (line == line.uppercase()) s += 20
148 + if (line.contains("software", ignoreCase = true)) s += 30
149 + return s + (40 - line.length).coerceAtLeast(0)
150 + }
151 +
152 + private fun cleanCompany(line: String?): String? {
153 + if (line.isNullOrBlank()) return null
154 + return line
155 + .replace(Regex("""[&—–\-_|]+"""), " ")
156 + .replace(Regex("""\s+"""), " ")
157 + .trim()
158 + .ifBlank { null }
159 + }
160 +
161 + private fun cleanJob(line: String?): String? =
162 + line?.replace(Regex("""\s+"""), " ")?.trim()?.ifBlank { null }
163 +
164 + private fun cleanAddress(line: String?): String? =
165 + line?.replace(Regex("""\s+"""), " ")?.trim()?.ifBlank { null }
166 +
167 + internal fun companyFromEmailDomain(email: String): String? {
168 + val domain = email.substringAfter('@', "").substringBefore('.')
169 + if (domain.length < 3) return null
170 + // dimosoftware → Dimo Software (best-effort)
171 + val spaced = domain
172 + .replace(Regex("""(?<=[a-z])(?=[A-Z])"""), " ")
173 + .replace("software", " Software", ignoreCase = true)
174 + .replace("solutions", " Solutions", ignoreCase = true)
175 + .trim()
176 + return spaced.replaceFirstChar { it.titlecase() }.ifBlank { null }
177 + }
178 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/ContactInsertIntent.kt
+34 -0
@@ -0,0 +1,34 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import android.content.Intent
4 +import android.provider.ContactsContract
5 +
6 +object ContactInsertIntent {
7 + fun build(card: ContactCard): Intent {
8 + val intent = Intent(Intent.ACTION_INSERT).apply {
9 + type = ContactsContract.Contacts.CONTENT_TYPE
10 + }
11 + card.fullName?.takeIf { it.isNotBlank() }?.let {
12 + intent.putExtra(ContactsContract.Intents.Insert.NAME, it)
13 + }
14 + card.company?.takeIf { it.isNotBlank() }?.let {
15 + intent.putExtra(ContactsContract.Intents.Insert.COMPANY, it)
16 + }
17 + card.jobTitle?.takeIf { it.isNotBlank() }?.let {
18 + intent.putExtra(ContactsContract.Intents.Insert.JOB_TITLE, it)
19 + }
20 + card.phones.firstOrNull()?.let {
21 + intent.putExtra(ContactsContract.Intents.Insert.PHONE, it)
22 + }
23 + card.emails.firstOrNull()?.let {
24 + intent.putExtra(ContactsContract.Intents.Insert.EMAIL, it)
25 + }
26 + card.address?.takeIf { it.isNotBlank() }?.let {
27 + intent.putExtra(ContactsContract.Intents.Insert.POSTAL, it)
28 + }
29 + card.note?.takeIf { it.isNotBlank() }?.let {
30 + intent.putExtra(ContactsContract.Intents.Insert.NOTES, it)
31 + }
32 + return intent
33 + }
34 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/VCardSerializer.kt
+32 -0
@@ -0,0 +1,32 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +object VCardSerializer {
4 + fun toVCard3(card: ContactCard): String = buildString {
5 + appendLine("BEGIN:VCARD")
6 + appendLine("VERSION:3.0")
7 + card.fullName?.takeIf { it.isNotBlank() }?.let { appendLine("FN:${escape(it)}") }
8 + val n = buildNField(card)
9 + if (n != null) appendLine("N:$n")
10 + card.company?.takeIf { it.isNotBlank() }?.let { appendLine("ORG:${escape(it)}") }
11 + card.jobTitle?.takeIf { it.isNotBlank() }?.let { appendLine("TITLE:${escape(it)}") }
12 + card.phones.forEach { appendLine("TEL;TYPE=CELL:${escape(it)}") }
13 + card.emails.forEach { appendLine("EMAIL;TYPE=INTERNET:${escape(it)}") }
14 + card.website?.takeIf { it.isNotBlank() }?.let { appendLine("URL:${escape(it)}") }
15 + card.address?.takeIf { it.isNotBlank() }?.let { appendLine("ADR;TYPE=WORK:;;${escape(it)};;;;") }
16 + card.note?.takeIf { it.isNotBlank() }?.let { appendLine("NOTE:${escape(it)}") }
17 + appendLine("END:VCARD")
18 + }.trimEnd()
19 +
20 + private fun buildNField(card: ContactCard): String? {
21 + val last = card.lastName?.takeIf { it.isNotBlank() } ?: return null
22 + val first = card.firstName.orEmpty()
23 + return "${escape(last)};${escape(first)};;;"
24 + }
25 +
26 + internal fun escape(value: String): String =
27 + value.replace("\\", "\\\\")
28 + .replace(";", "\\;")
29 + .replace(",", "\\,")
30 + .replace("\n", "\\n")
31 + .replace("\r", "")
32 +}
A android/app/src/main/java/fr/ebii/card2vcf/contact/VcfShare.kt
+34 -0
@@ -0,0 +1,34 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import android.content.Context
4 +import android.content.Intent
5 +import androidx.core.content.FileProvider
6 +import java.io.File
7 +
8 +object VcfShare {
9 + fun fileName(card: ContactCard): String {
10 + val base = card.displayName().ifBlank { "contact" }
11 + .lowercase()
12 + .replace(Regex("[^a-z0-9]+"), "-")
13 + .trim('-')
14 + .ifBlank { "contact" }
15 + return "$base.vcf"
16 + }
17 +
18 + fun writeTempFile(context: Context, card: ContactCard): File {
19 + val dir = File(context.cacheDir, "vcf").apply { mkdirs() }
20 + val file = File(dir, fileName(card))
21 + file.writeText(VCardSerializer.toVCard3(card))
22 + return file
23 + }
24 +
25 + fun shareIntent(context: Context, card: ContactCard): Intent {
26 + val file = writeTempFile(context, card)
27 + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
28 + return Intent(Intent.ACTION_SEND).apply {
29 + type = "text/vcard"
30 + putExtra(Intent.EXTRA_STREAM, uri)
31 + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
32 + }
33 + }
34 +}
A android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrEngine.kt
+9 -0
@@ -0,0 +1,9 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.graphics.Bitmap
4 +
5 +interface OcrEngine {
6 + fun recognize(bitmap: Bitmap): OcrResult
7 +}
8 +
9 +class OcrException(message: String, cause: Throwable? = null) : Exception(message, cause)
A android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrOrientation.kt
+44 -0
@@ -0,0 +1,44 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.Matrix
5 +
6 +/**
7 + * Essaie plusieurs orientations et retient celle qui maximise
8 + * les indices de carte de visite (email, téléphone, URL, volume de texte).
9 + */
10 +object OcrOrientation {
11 + private val angles = intArrayOf(0, 90, 180, 270)
12 +
13 + fun recognizeBest(bitmap: Bitmap, recognize: (Bitmap) -> OcrResult): OcrResult {
14 + var best: OcrResult? = null
15 + var bestScore = Int.MIN_VALUE
16 + for (angle in angles) {
17 + val candidate = if (angle == 0) bitmap else rotate(bitmap, angle)
18 + val result = runCatching { recognize(candidate) }.getOrNull() ?: continue
19 + val score = score(result)
20 + if (score > bestScore) {
21 + bestScore = score
22 + best = result
23 + }
24 + if (candidate !== bitmap) candidate.recycle()
25 + // Assez bon : email + téléphone trouvés
26 + if (result.emails.isNotEmpty() && result.phones.isNotEmpty()) break
27 + }
28 + return best ?: throw OcrException("OCR vide — reprenez la photo")
29 + }
30 +
31 + fun score(result: OcrResult): Int {
32 + var s = result.rawText.length
33 + s += result.emails.size * 200
34 + s += result.phones.size * 150
35 + s += result.urls.size * 100
36 + if (result.rawText.contains('@')) s += 50
37 + return s
38 + }
39 +
40 + fun rotate(src: Bitmap, degrees: Int): Bitmap {
41 + val matrix = Matrix().apply { postRotate(degrees.toFloat()) }
42 + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true)
43 + }
44 +}
A android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrPostProcessor.kt
+72 -0
@@ -0,0 +1,72 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +object OcrPostProcessor {
4 + private val emailRegex = Regex("""[\w.+-]+@[\w.-]+\.\w{2,}""", RegexOption.IGNORE_CASE)
5 + private val urlRegex = Regex(
6 + """(?:https?://[\w./?#&=%+-]+)|(?:www\.[\w.-]+\.\w{2,}(?:/[\w./?#&=%+-]*)?)""",
7 + RegexOption.IGNORE_CASE,
8 + )
9 + private val phoneRegex = Regex("""(?:\+?\d[\d\s.\-()]{6,}\d)""")
10 +
11 + fun process(rawText: String): OcrResult {
12 + val lines = rawText.lines()
13 + .map { it.trim() }
14 + .filter { it.isNotEmpty() }
15 + val joined = lines.joinToString("\n")
16 + // Corrige O/0 uniquement dans les séquences numériques (erreurs OCR fréquentes).
17 + val phoneSource = joined
18 + .replace(Regex("""(?<=\d)O(?=\d)"""), "0")
19 + .replace(Regex("""\bO(?=\d)"""), "0")
20 + val phones = phoneRegex.findAll(phoneSource)
21 + .map { normalizePhone(it.value) }
22 + .filter { isPlausiblePhone(it) }
23 + .distinct()
24 + .toList()
25 + val emails = emailRegex.findAll(joined).map { it.value.lowercase() }.distinct().toList()
26 + val urls = urlRegex.findAll(joined)
27 + .map { normalizeUrl(it.value) }
28 + .distinct()
29 + .toList()
30 + return OcrResult(
31 + rawText = joined,
32 + lines = lines,
33 + phones = phones,
34 + emails = emails,
35 + urls = urls,
36 + )
37 + }
38 +
39 + fun enrich(base: OcrResult, rawText: String): OcrResult {
40 + val extra = process(rawText)
41 + return base.copy(
42 + rawText = extra.rawText.ifBlank { base.rawText },
43 + lines = extra.lines.ifEmpty { base.lines },
44 + phones = (base.phones + extra.phones).distinct(),
45 + emails = (base.emails + extra.emails).distinct(),
46 + urls = (base.urls + extra.urls).distinct(),
47 + )
48 + }
49 +
50 + internal fun normalizePhone(raw: String): String {
51 + var s = raw.filter { it.isDigit() || it == '+' }
52 + // +33(0)6... → +336...
53 + if (s.startsWith("+33") && s.length > 4 && s[3] == '0') {
54 + s = "+33" + s.substring(4)
55 + }
56 + if (s.startsWith("0033")) {
57 + s = "+33" + s.removePrefix("0033").removePrefix("0")
58 + }
59 + return s
60 + }
61 +
62 + private fun isPlausiblePhone(phone: String): Boolean {
63 + val digits = phone.filter { it.isDigit() }
64 + return digits.length in 10..15
65 + }
66 +
67 + private fun normalizeUrl(raw: String): String {
68 + val t = raw.trim().trimEnd('.', ',', ';')
69 + return if (t.startsWith("http://", true) || t.startsWith("https://", true)) t
70 + else "https://$t"
71 + }
72 +}
A android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrResult.kt
+10 -0
@@ -0,0 +1,10 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +data class OcrResult(
4 + val rawText: String,
5 + val lines: List<String> = emptyList(),
6 + val phones: List<String> = emptyList(),
7 + val emails: List<String> = emptyList(),
8 + val urls: List<String> = emptyList(),
9 + val confidence: Float? = null,
10 +)
A android/app/src/main/java/fr/ebii/card2vcf/ocr/TesseractOcrEngine.kt
+103 -0
@@ -0,0 +1,103 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import android.content.Context
4 +import android.graphics.Bitmap
5 +import com.googlecode.tesseract.android.TessBaseAPI
6 +import java.io.File
7 +import java.io.FileOutputStream
8 +
9 +class TesseractOcrEngine(
10 + private val context: Context,
11 + private val languages: String = "fra+deu+eng+spa+por+ita+pol",
12 +) : OcrEngine {
13 +
14 + override fun recognize(bitmap: Bitmap): OcrResult {
15 + val dataPath = ensureTessData()
16 + return OcrOrientation.recognizeBest(bitmap) { candidate ->
17 + recognizeOnce(candidate, dataPath)
18 + }
19 + }
20 +
21 + private fun recognizeOnce(bitmap: Bitmap, dataPath: String): OcrResult {
22 + val api = TessBaseAPI()
23 + try {
24 + if (!api.init(dataPath, languages)) {
25 + throw OcrException("Impossible d'initialiser Tesseract ($languages)")
26 + }
27 + api.pageSegMode = TessBaseAPI.PageSegMode.PSM_AUTO
28 + api.setImage(bitmap)
29 + val raw = api.utF8Text.orEmpty().trim()
30 + if (raw.isBlank()) throw OcrException("OCR vide")
31 + return OcrPostProcessor.process(raw)
32 + } finally {
33 + api.recycle()
34 + }
35 + }
36 +
37 + /**
38 + * Les `.traineddata` vivent dans `filesDir/tesseract/tessdata/` :
39 + * - 1ʳᵉ fois / changement `-Ptessdata=` → copie depuis les assets du APK
40 + * - ensuite → réutilisation locale (sideload / remplacement possible sans rebuild)
41 + * - stamp `external` → ne jamais écraser (chargement ultérieur manuel)
42 + *
43 + * La variante attendue est lue dans l'asset `tesseract/tessdata/variant.txt`
44 + * (écrit par la tâche Gradle `prepareTessdata`), pas via BuildConfig.
45 + */
46 + private fun ensureTessData(): String {
47 + val tessRoot = File(context.filesDir, "tesseract").apply { mkdirs() }
48 + val tessData = File(tessRoot, "tessdata").apply { mkdirs() }
49 + val stamp = File(tessData, VARIANT_STAMP)
50 + val langs = languages.split('+')
51 + val expectedVariant = packagedVariant()
52 + val stampValue = stamp.takeIf { it.isFile }?.readText()?.trim()
53 + val allPresent = langs.all { File(tessData, "$it.traineddata").isFile }
54 +
55 + val syncFromAssets = when {
56 + !allPresent -> true
57 + stampValue == null -> false.also { stamp.writeText(STAMP_EXTERNAL) }
58 + stampValue == STAMP_EXTERNAL -> false
59 + stampValue == expectedVariant -> false
60 + else -> true // ex. fast → full après réinstall
61 + }
62 +
63 + if (syncFromAssets) {
64 + for (lang in langs) {
65 + val assetPath = "tesseract/tessdata/$lang.traineddata"
66 + if (assetExists(assetPath)) {
67 + copyAsset(assetPath, File(tessData, "$lang.traineddata"))
68 + }
69 + }
70 + stamp.writeText(expectedVariant)
71 + }
72 +
73 + val missing = langs.filterNot { File(tessData, "$it.traineddata").isFile }
74 + if (missing.isNotEmpty()) {
75 + throw OcrException(
76 + "Modèle(s) OCR manquant(s) : ${missing.joinToString { "$it.traineddata" }}. " +
77 + "Recompilez avec -Ptessdata=fast|full, ou déposez les fichiers dans " +
78 + tessData.absolutePath
79 + )
80 + }
81 + return tessRoot.absolutePath
82 + }
83 +
84 + private fun packagedVariant(): String =
85 + runCatching {
86 + context.assets.open(VARIANT_ASSET).bufferedReader().use { it.readText().trim() }
87 + }.getOrDefault("fast")
88 +
89 + private fun assetExists(assetPath: String): Boolean =
90 + runCatching { context.assets.open(assetPath).close(); true }.getOrDefault(false)
91 +
92 + private fun copyAsset(assetPath: String, target: File) {
93 + context.assets.open(assetPath).use { input ->
94 + FileOutputStream(target).use { output -> input.copyTo(output) }
95 + }
96 + }
97 +
98 + companion object {
99 + private const val VARIANT_STAMP = ".card2vcf-tessdata-variant"
100 + private const val VARIANT_ASSET = "tesseract/tessdata/variant.txt"
101 + private const val STAMP_EXTERNAL = "external"
102 + }
103 +}
A android/app/src/main/java/fr/ebii/card2vcf/scan/ImageOrientation.kt
+148 -0
@@ -0,0 +1,148 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.BitmapFactory
5 +import android.graphics.Matrix
6 +import androidx.exifinterface.media.ExifInterface
7 +import java.io.ByteArrayInputStream
8 +import java.io.File
9 +import java.io.InputStream
10 +
11 +/**
12 + * Normalise l'orientation d'une photo avant scan/OCR.
13 + *
14 + * Cas couverts (difficultés utilisateur réelles) :
15 + * - EXIF Orientation 1/3/6/8 (0°, 180°, 90°, 270°)
16 + * - miroirs EXIF (FLIP_*)
17 + * - rotation CameraX [ImageInfo.rotationDegrees] si EXIF absent/normal
18 + * - pas de double rotation EXIF + CameraX
19 + * - JPEG décodé via [BitmapFactory.decodeByteArray] (ignore EXIF tout seul)
20 + */
21 +object ImageOrientation {
22 +
23 + data class OrientedBitmap(
24 + val bitmap: Bitmap,
25 + /** Degrés appliqués (0/90/180/270), hors miroirs. */
26 + val appliedRotationDegrees: Int,
27 + /** true si la correction vient de l'EXIF, false si fallback CameraX. */
28 + val fromExif: Boolean,
29 + val exifOrientation: Int,
30 + )
31 +
32 + fun fromJpegBytes(
33 + jpegBytes: ByteArray,
34 + cameraRotationDegrees: Int = 0,
35 + opts: BitmapFactory.Options? = null,
36 + ): OrientedBitmap? {
37 + val bitmap = BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size, opts) ?: return null
38 + val exif = runCatching {
39 + ExifInterface(ByteArrayInputStream(jpegBytes))
40 + }.getOrNull()
41 + return orient(bitmap, exif, cameraRotationDegrees)
42 + }
43 +
44 + fun fromFile(file: File, cameraRotationDegrees: Int = 0): OrientedBitmap? {
45 + val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return null
46 + val exif = runCatching { ExifInterface(file) }.getOrNull()
47 + return orient(bitmap, exif, cameraRotationDegrees)
48 + }
49 +
50 + fun fromStream(stream: InputStream, cameraRotationDegrees: Int = 0): OrientedBitmap? {
51 + val bytes = stream.readBytes()
52 + return fromJpegBytes(bytes, cameraRotationDegrees)
53 + }
54 +
55 + /**
56 + * Applique EXIF si présent et non-NORMAL, sinon la rotation caméra.
57 + * Les deux ne sont jamais cumulés.
58 + */
59 + fun orient(
60 + bitmap: Bitmap,
61 + exif: ExifInterface?,
62 + cameraRotationDegrees: Int = 0,
63 + ): OrientedBitmap {
64 + val orientation = exif?.getAttributeInt(
65 + ExifInterface.TAG_ORIENTATION,
66 + ExifInterface.ORIENTATION_UNDEFINED,
67 + ) ?: ExifInterface.ORIENTATION_UNDEFINED
68 +
69 + val exifUseful = orientation != ExifInterface.ORIENTATION_UNDEFINED
70 + && orientation != ExifInterface.ORIENTATION_NORMAL
71 +
72 + return if (exifUseful) {
73 + val transformed = applyExifTransform(bitmap, orientation)
74 + OrientedBitmap(
75 + bitmap = transformed,
76 + appliedRotationDegrees = exifRotationDegrees(orientation),
77 + fromExif = true,
78 + exifOrientation = orientation,
79 + )
80 + } else {
81 + val deg = normalizeDegrees(cameraRotationDegrees)
82 + val transformed = if (deg == 0) bitmap else rotate(bitmap, deg)
83 + OrientedBitmap(
84 + bitmap = transformed,
85 + appliedRotationDegrees = deg,
86 + fromExif = false,
87 + exifOrientation = orientation,
88 + )
89 + }
90 + }
91 +
92 + fun exifRotationDegrees(orientation: Int): Int = when (orientation) {
93 + ExifInterface.ORIENTATION_ROTATE_90,
94 + ExifInterface.ORIENTATION_TRANSPOSE,
95 + -> 90
96 + ExifInterface.ORIENTATION_ROTATE_180,
97 + ExifInterface.ORIENTATION_FLIP_VERTICAL,
98 + -> 180
99 + ExifInterface.ORIENTATION_ROTATE_270,
100 + ExifInterface.ORIENTATION_TRANSVERSE,
101 + -> 270
102 + else -> 0
103 + }
104 +
105 + fun normalizeDegrees(degrees: Int): Int {
106 + var d = degrees % 360
107 + if (d < 0) d += 360
108 + return when (d) {
109 + in 45 until 135 -> 90
110 + in 135 until 225 -> 180
111 + in 225 until 315 -> 270
112 + else -> 0
113 + }
114 + }
115 +
116 + internal fun applyExifTransform(src: Bitmap, orientation: Int): Bitmap {
117 + val matrix = Matrix()
118 + when (orientation) {
119 + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
120 + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
121 + ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
122 + matrix.setRotate(180f)
123 + matrix.postScale(-1f, 1f)
124 + }
125 + ExifInterface.ORIENTATION_TRANSPOSE -> {
126 + matrix.setRotate(90f)
127 + matrix.postScale(-1f, 1f)
128 + }
129 + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
130 + ExifInterface.ORIENTATION_TRANSVERSE -> {
131 + matrix.setRotate(270f)
132 + matrix.postScale(-1f, 1f)
133 + }
134 + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(270f)
135 + else -> return src
136 + }
137 + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true).also {
138 + if (it !== src && !src.isRecycled) {
139 + // ne recycle pas src : l'appelant peut encore le tenir
140 + }
141 + }
142 + }
143 +
144 + private fun rotate(src: Bitmap, degrees: Int): Bitmap {
145 + val matrix = Matrix().apply { postRotate(degrees.toFloat()) }
146 + return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true)
147 + }
148 +}
A android/app/src/main/java/fr/ebii/card2vcf/scan/OpenCvScanEngine.kt
+272 -0
@@ -0,0 +1,272 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.PointF
5 +import org.opencv.android.OpenCVLoader
6 +import org.opencv.android.Utils
7 +import org.opencv.core.Mat
8 +import org.opencv.core.MatOfPoint
9 +import org.opencv.core.MatOfPoint2f
10 +import org.opencv.core.Point
11 +import org.opencv.core.Size
12 +import org.opencv.imgproc.Imgproc
13 +import java.util.concurrent.atomic.AtomicBoolean
14 +import kotlin.math.hypot
15 +import kotlin.math.max
16 +
17 +class OpenCvScanEngine : ScanEngine {
18 +
19 + override fun scan(source: Bitmap, manualCorners: ScanCorners?): ScanResult {
20 + if (!ensureOpenCv()) throw ScanException("OpenCV non initialisé")
21 + val src = Mat()
22 + Utils.bitmapToMat(source, src)
23 + try {
24 + val working = resizeIfNeeded(src)
25 + val corners: MatOfPoint2f
26 + val detected: Boolean
27 + if (manualCorners != null) {
28 + corners = manualCorners.toMatPoints()
29 + detected = true
30 + } else {
31 + val found = detectDocumentCorners(working)
32 + if (found != null) {
33 + corners = found
34 + detected = true
35 + } else {
36 + corners = fullFrameCorners(working)
37 + detected = false
38 + }
39 + }
40 + // Pas de deskew via findNonZero : alloue des millions de points et tue l'app (OOM).
41 + val warped = perspectiveCorrect(working, corners)
42 + val outBitmap = Bitmap.createBitmap(warped.cols(), warped.rows(), Bitmap.Config.ARGB_8888)
43 + Utils.matToBitmap(warped, outBitmap)
44 + warped.release()
45 + if (working !== src) working.release()
46 + return ScanResult(
47 + bitmap = outBitmap,
48 + angleDegrees = estimateSkewAngle(corners),
49 + confidence = when {
50 + manualCorners != null -> 1f
51 + detected -> 0.8f
52 + else -> 0.4f
53 + },
54 + corners = corners.toScanCorners(),
55 + )
56 + } finally {
57 + src.release()
58 + }
59 + }
60 +
61 + private fun resizeIfNeeded(src: Mat): Mat {
62 + val maxDim = max(src.rows(), src.cols())
63 + if (maxDim <= MAX_DIM) return src.clone()
64 + val scale = MAX_DIM.toDouble() / maxDim
65 + val resized = Mat()
66 + Imgproc.resize(src, resized, Size(src.cols() * scale, src.rows() * scale))
67 + return resized
68 + }
69 +
70 + /** Retourne les coins détectés, ou null si aucune stratégie n'a convergé. */
71 + private fun detectDocumentCorners(src: Mat): MatOfPoint2f? {
72 + val gray = toGray(src)
73 + val imageArea = src.rows() * src.cols()
74 + try {
75 + detectViaAdaptiveThreshold(gray, imageArea)?.let { return it }
76 + detectViaCanny(gray, imageArea)?.let { return it }
77 + detectViaMinAreaRect(gray, imageArea)?.let { return it }
78 + return null
79 + } finally {
80 + gray.release()
81 + }
82 + }
83 +
84 + private fun detectViaAdaptiveThreshold(gray: Mat, imageArea: Int): MatOfPoint2f? {
85 + val blurred = Mat()
86 + val thresh = Mat()
87 + val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0))
88 + try {
89 + Imgproc.GaussianBlur(gray, blurred, Size(5.0, 5.0), 0.0)
90 + Imgproc.adaptiveThreshold(
91 + blurred, thresh, 255.0,
92 + Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C, Imgproc.THRESH_BINARY, 11, 2.0,
93 + )
94 + Imgproc.morphologyEx(thresh, thresh, Imgproc.MORPH_CLOSE, kernel)
95 + return findQuadContour(thresh, imageArea, minAreaRatio = 0.03, epsilons = listOf(0.02, 0.035, 0.05))
96 + } finally {
97 + blurred.release()
98 + thresh.release()
99 + kernel.release()
100 + }
101 + }
102 +
103 + private fun detectViaCanny(gray: Mat, imageArea: Int): MatOfPoint2f? {
104 + val edges = Mat()
105 + val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(3.0, 3.0))
106 + try {
107 + Imgproc.GaussianBlur(gray, edges, Size(5.0, 5.0), 0.0)
108 + Imgproc.Canny(edges, edges, 40.0, 120.0)
109 + Imgproc.dilate(edges, edges, kernel)
110 + return findQuadContour(edges, imageArea, minAreaRatio = 0.05, epsilons = listOf(0.02, 0.04, 0.06))
111 + } finally {
112 + edges.release()
113 + kernel.release()
114 + }
115 + }
116 +
117 + /** Dernier recours : boîte englobante orientée du plus grand contour. */
118 + private fun detectViaMinAreaRect(gray: Mat, imageArea: Int): MatOfPoint2f? {
119 + val thresh = Mat()
120 + try {
121 + Imgproc.threshold(gray, thresh, 0.0, 255.0, Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU)
122 + val contours = mutableListOf<MatOfPoint>()
123 + Imgproc.findContours(thresh, contours, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)
124 + val largest = contours.maxByOrNull { Imgproc.contourArea(it) } ?: return null
125 + val area = Imgproc.contourArea(largest)
126 + if (area < imageArea * 0.12) return null
127 + val rect = Imgproc.minAreaRect(MatOfPoint2f(*largest.toArray().map { Point(it.x, it.y) }.toTypedArray()))
128 + val box = MatOfPoint2f()
129 + Imgproc.boxPoints(rect, box)
130 + return orderCorners(box)
131 + } finally {
132 + thresh.release()
133 + }
134 + }
135 +
136 + private fun findQuadContour(
137 + binary: Mat,
138 + imageArea: Int,
139 + minAreaRatio: Double,
140 + epsilons: List<Double>,
141 + ): MatOfPoint2f? {
142 + val contours = mutableListOf<MatOfPoint>()
143 + Imgproc.findContours(binary, contours, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)
144 + contours.sortByDescending { Imgproc.contourArea(it) }
145 + val minArea = imageArea * minAreaRatio
146 + for (contour in contours.take(25)) {
147 + val area = Imgproc.contourArea(contour)
148 + if (area < minArea) continue
149 + val curve = MatOfPoint2f(*contour.toArray())
150 + val peri = Imgproc.arcLength(curve, true)
151 + for (eps in epsilons) {
152 + val approx = MatOfPoint2f()
153 + Imgproc.approxPolyDP(curve, approx, eps * peri, true)
154 + if (approx.total() == 4L && isConvexQuad(approx)) {
155 + return orderCorners(approx)
156 + }
157 + }
158 + }
159 + return null
160 + }
161 +
162 + private fun isConvexQuad(approx: MatOfPoint2f): Boolean {
163 + val pts = approx.toArray()
164 + if (pts.size != 4) return false
165 + var sign = 0
166 + for (i in pts.indices) {
167 + val a = pts[i]
168 + val b = pts[(i + 1) % 4]
169 + val c = pts[(i + 2) % 4]
170 + val cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x)
171 + if (kotlin.math.abs(cross) < 1.0) continue
172 + val s = if (cross > 0) 1 else -1
173 + if (sign == 0) sign = s else if (sign != s) return false
174 + }
175 + return true
176 + }
177 +
178 + private fun fullFrameCorners(src: Mat, marginRatio: Double = 0.02): MatOfPoint2f {
179 + val mx = src.cols() * marginRatio
180 + val my = src.rows() * marginRatio
181 + val w = src.cols() - 1.0
182 + val h = src.rows() - 1.0
183 + return MatOfPoint2f(
184 + Point(mx, my),
185 + Point(w - mx, my),
186 + Point(w - mx, h - my),
187 + Point(mx, h - my),
188 + )
189 + }
190 +
191 + private fun toGray(src: Mat): Mat {
192 + val gray = Mat()
193 + when (src.channels()) {
194 + 4 -> Imgproc.cvtColor(src, gray, Imgproc.COLOR_RGBA2GRAY)
195 + 3 -> Imgproc.cvtColor(src, gray, Imgproc.COLOR_BGR2GRAY)
196 + else -> src.copyTo(gray)
197 + }
198 + return gray
199 + }
200 +
201 + private fun perspectiveCorrect(src: Mat, corners: MatOfPoint2f): Mat {
202 + val pts = corners.toArray()
203 + val widthA = distance(pts[2], pts[3])
204 + val widthB = distance(pts[1], pts[0])
205 + val maxWidth = max(widthA, widthB).toInt().coerceAtLeast(1)
206 + val heightA = distance(pts[1], pts[2])
207 + val heightB = distance(pts[0], pts[3])
208 + val maxHeight = max(heightA, heightB).toInt().coerceAtLeast(1)
209 + val srcPts = MatOfPoint2f(pts[0], pts[1], pts[2], pts[3])
210 + val dstPts = MatOfPoint2f(
211 + Point(0.0, 0.0),
212 + Point(maxWidth - 1.0, 0.0),
213 + Point(maxWidth - 1.0, maxHeight - 1.0),
214 + Point(0.0, maxHeight - 1.0),
215 + )
216 + val matrix = Imgproc.getPerspectiveTransform(srcPts, dstPts)
217 + val out = Mat()
218 + Imgproc.warpPerspective(src, out, matrix, Size(maxWidth.toDouble(), maxHeight.toDouble()))
219 + matrix.release()
220 + return out
221 + }
222 +
223 + private fun orderCorners(approx: MatOfPoint2f): MatOfPoint2f {
224 + val pts = approx.toArray().sortedWith(compareBy({ it.y }, { it.x })).toMutableList()
225 + val top = pts.take(2).sortedBy { it.x }
226 + val bottom = pts.takeLast(2).sortedBy { it.x }
227 + return MatOfPoint2f(top[0], top[1], bottom[1], bottom[0])
228 + }
229 +
230 + private fun estimateSkewAngle(corners: MatOfPoint2f): Float {
231 + val pts = corners.toArray()
232 + val dx = pts[1].x - pts[0].x
233 + val dy = pts[1].y - pts[0].y
234 + return Math.toDegrees(kotlin.math.atan2(dy, dx)).toFloat()
235 + }
236 +
237 + private fun distance(a: Point, b: Point): Double = hypot(a.x - b.x, a.y - b.y)
238 +
239 + private fun ScanCorners.toMatPoints(): MatOfPoint2f =
240 + MatOfPoint2f(
241 + Point(topLeft.x.toDouble(), topLeft.y.toDouble()),
242 + Point(topRight.x.toDouble(), topRight.y.toDouble()),
243 + Point(bottomRight.x.toDouble(), bottomRight.y.toDouble()),
244 + Point(bottomLeft.x.toDouble(), bottomLeft.y.toDouble()),
245 + )
246 +
247 + private fun MatOfPoint2f.toScanCorners(): ScanCorners {
248 + val p = toArray()
249 + return ScanCorners(
250 + topLeft = PointF(p[0].x.toFloat(), p[0].y.toFloat()),
251 + topRight = PointF(p[1].x.toFloat(), p[1].y.toFloat()),
252 + bottomRight = PointF(p[2].x.toFloat(), p[2].y.toFloat()),
253 + bottomLeft = PointF(p[3].x.toFloat(), p[3].y.toFloat()),
254 + )
255 + }
256 +
257 + companion object {
258 + private const val MAX_DIM = 1500
259 + private val initialized = AtomicBoolean(false)
260 +
261 + fun ensureOpenCv(): Boolean {
262 + if (initialized.get()) return true
263 + synchronized(OpenCvScanEngine::class.java) {
264 + if (OpenCVLoader.initLocal()) {
265 + initialized.set(true)
266 + return true
267 + }
268 + }
269 + return false
270 + }
271 + }
272 +}
A android/app/src/main/java/fr/ebii/card2vcf/scan/ScanEngine.kt
+24 -0
@@ -0,0 +1,24 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.PointF
5 +
6 +data class ScanCorners(
7 + val topLeft: PointF,
8 + val topRight: PointF,
9 + val bottomRight: PointF,
10 + val bottomLeft: PointF,
11 +)
12 +
13 +data class ScanResult(
14 + val bitmap: Bitmap,
15 + val angleDegrees: Float = 0f,
16 + val confidence: Float = 1f,
17 + val corners: ScanCorners? = null,
18 +)
19 +
20 +interface ScanEngine {
21 + fun scan(source: Bitmap, manualCorners: ScanCorners? = null): ScanResult
22 +}
23 +
24 +class ScanException(message: String, cause: Throwable? = null) : Exception(message, cause)
A android/app/src/main/java/fr/ebii/card2vcf/ui/ContactDraftFields.kt
+73 -0
@@ -0,0 +1,73 @@
1 +package fr.ebii.card2vcf.ui
2 +
3 +import androidx.compose.foundation.layout.Arrangement
4 +import androidx.compose.foundation.layout.Column
5 +import androidx.compose.foundation.layout.fillMaxWidth
6 +import androidx.compose.material3.OutlinedTextField
7 +import androidx.compose.material3.Text
8 +import androidx.compose.runtime.Composable
9 +import androidx.compose.ui.Modifier
10 +import androidx.compose.ui.res.stringResource
11 +import androidx.compose.ui.unit.dp
12 +import fr.ebii.card2vcf.R
13 +import fr.ebii.card2vcf.contact.ContactCard
14 +
15 +@Composable
16 +fun ContactDraftFields(
17 + card: ContactCard,
18 + onChange: (ContactCard) -> Unit,
19 + modifier: Modifier = Modifier,
20 +) {
21 + // Pas de verticalScroll ici : le parent ContactDraftScreen scrolle déjà.
22 + Column(
23 + modifier,
24 + verticalArrangement = Arrangement.spacedBy(10.dp),
25 + ) {
26 + DraftField(stringResource(R.string.scan_champ_nom), card.fullName.orEmpty()) {
27 + onChange(card.copy(fullName = it.ifBlank { null }))
28 + }
29 + DraftField(stringResource(R.string.scan_champ_prenom), card.firstName.orEmpty()) {
30 + onChange(card.copy(firstName = it.ifBlank { null }))
31 + }
32 + DraftField(stringResource(R.string.scan_champ_nom_famille), card.lastName.orEmpty()) {
33 + onChange(card.copy(lastName = it.ifBlank { null }))
34 + }
35 + DraftField(stringResource(R.string.scan_champ_societe), card.company.orEmpty()) {
36 + onChange(card.copy(company = it.ifBlank { null }))
37 + }
38 + DraftField(stringResource(R.string.scan_champ_poste), card.jobTitle.orEmpty()) {
39 + onChange(card.copy(jobTitle = it.ifBlank { null }))
40 + }
41 + DraftField(stringResource(R.string.scan_champ_tel), card.phones.joinToString(", ")) {
42 + onChange(card.copy(phones = it.split(',', ';').map { p -> p.trim() }.filter { p -> p.isNotEmpty() }))
43 + }
44 + DraftField(stringResource(R.string.scan_champ_email), card.emails.joinToString(", ")) {
45 + onChange(card.copy(emails = it.split(',', ';').map { e -> e.trim() }.filter { e -> e.isNotEmpty() }))
46 + }
47 + DraftField(stringResource(R.string.scan_champ_site), card.website.orEmpty()) {
48 + onChange(card.copy(website = it.ifBlank { null }))
49 + }
50 + DraftField(stringResource(R.string.scan_champ_adresse), card.address.orEmpty()) {
51 + onChange(card.copy(address = it.ifBlank { null }))
52 + }
53 + DraftField(stringResource(R.string.scan_champ_note), card.note.orEmpty(), minLines = 2) {
54 + onChange(card.copy(note = it.ifBlank { null }))
55 + }
56 + }
57 +}
58 +
59 +@Composable
60 +private fun DraftField(
61 + label: String,
62 + value: String,
63 + minLines: Int = 1,
64 + onChange: (String) -> Unit,
65 +) {
66 + OutlinedTextField(
67 + value = value,
68 + onValueChange = onChange,
69 + label = { Text(label) },
70 + minLines = minLines,
71 + modifier = Modifier.fillMaxWidth(),
72 + )
73 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/ContactDraftScreen.kt
+103 -0
@@ -0,0 +1,103 @@
1 +package fr.ebii.card2vcf.ui
2 +
3 +import androidx.compose.foundation.background
4 +import androidx.compose.foundation.border
5 +import androidx.compose.foundation.layout.Arrangement
6 +import androidx.compose.foundation.layout.Box
7 +import androidx.compose.foundation.layout.Column
8 +import androidx.compose.foundation.layout.Row
9 +import androidx.compose.foundation.layout.fillMaxSize
10 +import androidx.compose.foundation.layout.fillMaxWidth
11 +import androidx.compose.foundation.layout.padding
12 +import androidx.compose.foundation.rememberScrollState
13 +import androidx.compose.foundation.shape.RoundedCornerShape
14 +import androidx.compose.foundation.verticalScroll
15 +import androidx.compose.material3.Button
16 +import androidx.compose.material3.ButtonDefaults
17 +import androidx.compose.material3.CircularProgressIndicator
18 +import androidx.compose.material3.MaterialTheme
19 +import androidx.compose.material3.OutlinedButton
20 +import androidx.compose.material3.Text
21 +import androidx.compose.runtime.Composable
22 +import androidx.compose.ui.Alignment
23 +import androidx.compose.ui.Modifier
24 +import androidx.compose.ui.res.stringResource
25 +import androidx.compose.ui.text.font.FontFamily
26 +import androidx.compose.ui.unit.dp
27 +import fr.ebii.card2vcf.R
28 +import fr.ebii.card2vcf.ui.theme.Bordure
29 +import fr.ebii.card2vcf.ui.theme.Encre
30 +import fr.ebii.card2vcf.ui.theme.Fond
31 +import fr.ebii.card2vcf.ui.theme.Ink
32 +import fr.ebii.card2vcf.ui.theme.OnPrimary
33 +import fr.ebii.card2vcf.ui.theme.Surface
34 +import fr.ebii.card2vcf.ui.theme.TexteFaible
35 +
36 +private val Square = RoundedCornerShape(0.dp)
37 +
38 +@Composable
39 +fun ContactDraftScreen(
40 + draft: ContactDraftUi,
41 + onCardChange: (fr.ebii.card2vcf.contact.ContactCard) -> Unit,
42 + onCreateContact: () -> Unit,
43 + onExportVcf: () -> Unit,
44 + onRetry: () -> Unit,
45 + modifier: Modifier = Modifier,
46 +) {
47 + Column(
48 + modifier.fillMaxSize().background(Fond).padding(horizontal = 18.dp, vertical = 16.dp),
49 + verticalArrangement = Arrangement.spacedBy(14.dp),
50 + ) {
51 + Text(stringResource(R.string.scan_brouillon_titre), style = MaterialTheme.typography.titleLarge, color = Encre)
52 + Text(stringResource(R.string.scan_brouillon_sous_titre), color = TexteFaible)
53 + Column(
54 + Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
55 + verticalArrangement = Arrangement.spacedBy(14.dp),
56 + ) {
57 + ContactDraftFields(card = draft.card, onChange = onCardChange)
58 + Text(stringResource(R.string.scan_ocr_brut), color = Encre)
59 + Box(
60 + Modifier.fillMaxWidth().background(Surface)
61 + .border(1.dp, Bordure).padding(12.dp),
62 + ) {
63 + Text(draft.rawOcrText, fontFamily = FontFamily.Monospace, color = TexteFaible)
64 + }
65 + }
66 + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
67 + Button(
68 + onClick = onCreateContact,
69 + modifier = Modifier.weight(1f),
70 + shape = Square,
71 + colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = OnPrimary),
72 + ) {
73 + Text(stringResource(R.string.scan_creer_contact))
74 + }
75 + OutlinedButton(
76 + onClick = onExportVcf,
77 + modifier = Modifier.weight(1f),
78 + shape = Square,
79 + ) {
80 + Text(stringResource(R.string.scan_exporter_vcf))
81 + }
82 + }
83 + OutlinedButton(
84 + onClick = onRetry,
85 + modifier = Modifier.fillMaxWidth(),
86 + shape = Square,
87 + ) {
88 + Text(stringResource(R.string.scan_reprendre))
89 + }
90 + }
91 +}
92 +
93 +@Composable
94 +fun ScanProgress(messageRes: Int) {
95 + Column(
96 + Modifier.fillMaxSize().background(Fond),
97 + verticalArrangement = Arrangement.Center,
98 + horizontalAlignment = Alignment.CenterHorizontally,
99 + ) {
100 + CircularProgressIndicator()
101 + Text(stringResource(messageRes), modifier = Modifier.padding(top = 16.dp), color = TexteFaible)
102 + }
103 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/ScanCarteScreen.kt
+208 -0
@@ -0,0 +1,208 @@
1 +package fr.ebii.card2vcf.ui
2 +
3 +import android.Manifest
4 +import android.content.pm.PackageManager
5 +import android.graphics.Bitmap
6 +import androidx.activity.compose.rememberLauncherForActivityResult
7 +import androidx.activity.result.contract.ActivityResultContracts
8 +import androidx.camera.core.CameraSelector
9 +import androidx.camera.core.ImageCapture
10 +import androidx.camera.core.ImageCaptureException
11 +import androidx.camera.core.ImageProxy
12 +import androidx.camera.core.Preview
13 +import androidx.camera.lifecycle.ProcessCameraProvider
14 +import androidx.camera.view.PreviewView
15 +import fr.ebii.card2vcf.scan.ImageOrientation
16 +import androidx.compose.foundation.background
17 +import androidx.compose.foundation.layout.Arrangement
18 +import androidx.compose.foundation.layout.Box
19 +import androidx.compose.foundation.layout.Column
20 +import androidx.compose.foundation.layout.fillMaxSize
21 +import androidx.compose.foundation.layout.fillMaxWidth
22 +import androidx.compose.foundation.layout.padding
23 +import androidx.compose.material.icons.Icons
24 +import androidx.compose.material.icons.automirrored.outlined.ArrowBack
25 +import androidx.compose.material3.Button
26 +import androidx.compose.material3.Icon
27 +import androidx.compose.material3.IconButton
28 +import androidx.compose.material3.MaterialTheme
29 +import androidx.compose.material3.OutlinedButton
30 +import androidx.compose.material3.Text
31 +import androidx.compose.runtime.Composable
32 +import androidx.compose.runtime.LaunchedEffect
33 +import androidx.compose.runtime.collectAsState
34 +import androidx.compose.runtime.getValue
35 +import androidx.compose.runtime.mutableStateOf
36 +import androidx.compose.runtime.remember
37 +import androidx.compose.runtime.setValue
38 +import androidx.compose.ui.Alignment
39 +import androidx.compose.ui.Modifier
40 +import androidx.compose.ui.platform.LocalContext
41 +import androidx.compose.ui.res.stringResource
42 +import androidx.compose.ui.unit.dp
43 +import androidx.compose.ui.viewinterop.AndroidView
44 +import androidx.core.content.ContextCompat
45 +import androidx.lifecycle.compose.LocalLifecycleOwner
46 +import fr.ebii.card2vcf.R
47 +import fr.ebii.card2vcf.ui.theme.Encre
48 +import fr.ebii.card2vcf.ui.theme.Fond
49 +import fr.ebii.card2vcf.ui.theme.Surface
50 +import fr.ebii.card2vcf.ui.theme.TexteFaible
51 +import java.util.concurrent.Executors
52 +
53 +@Composable
54 +fun ScanCarteScreen(
55 + viewModel: ScanCarteViewModel,
56 + onBack: () -> Unit,
57 + onCreateContact: (fr.ebii.card2vcf.contact.ContactCard) -> Unit,
58 + onExportVcf: (fr.ebii.card2vcf.contact.ContactCard) -> Unit,
59 + modifier: Modifier = Modifier,
60 +) {
61 + val state by viewModel.state.collectAsState()
62 + when (val s = state) {
63 + is ScanUiState.DraftReady -> ContactDraftScreen(
64 + draft = s.draft,
65 + onCardChange = viewModel::updateDraft,
66 + onCreateContact = { onCreateContact(s.draft.card) },
67 + onExportVcf = { onExportVcf(s.draft.card) },
68 + onRetry = viewModel::reset,
69 + modifier = modifier,
70 + )
71 + ScanUiState.Scanning -> ScanProgress(R.string.scan_en_cours)
72 + ScanUiState.OcrRunning -> ScanProgress(R.string.scan_ocr_en_cours)
73 + ScanUiState.Structuring -> ScanProgress(R.string.scan_structuration)
74 + is ScanUiState.Error -> ScanErrorScreen(s.message, onRetry = viewModel::reset, onBack = onBack)
75 + ScanUiState.Capturing, ScanUiState.Idle -> ScanCaptureScreen(viewModel, onBack, modifier)
76 + }
77 +}
78 +
79 +@Composable
80 +private fun ScanCaptureScreen(
81 + viewModel: ScanCarteViewModel,
82 + onBack: () -> Unit,
83 + modifier: Modifier = Modifier,
84 +) {
85 + val context = LocalContext.current
86 + val lifecycleOwner = LocalLifecycleOwner.current
87 + var hasCamera by remember {
88 + mutableStateOf(
89 + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
90 + )
91 + }
92 + val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
93 + hasCamera = it
94 + }
95 + LaunchedEffect(Unit) {
96 + if (!hasCamera) permissionLauncher.launch(Manifest.permission.CAMERA)
97 + }
98 +
99 + val imageCapture = remember {
100 + ImageCapture.Builder()
101 + .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
102 + .setJpegQuality(95)
103 + .build()
104 + }
105 + val executor = remember { Executors.newSingleThreadExecutor() }
106 +
107 + Column(modifier.fillMaxSize().background(Fond)) {
108 + RowTop(onBack)
109 + Text(
110 + stringResource(R.string.scan_instructions),
111 + modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
112 + color = TexteFaible,
113 + )
114 + if (hasCamera) {
115 + Box(Modifier.weight(1f).fillMaxWidth()) {
116 + AndroidView(
117 + factory = { ctx ->
118 + PreviewView(ctx).also { previewView ->
119 + val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
120 + cameraProviderFuture.addListener({
121 + val provider = cameraProviderFuture.get()
122 + val preview = Preview.Builder().build().also {
123 + it.surfaceProvider = previewView.surfaceProvider
124 + }
125 + provider.unbindAll()
126 + provider.bindToLifecycle(
127 + lifecycleOwner,
128 + CameraSelector.DEFAULT_BACK_CAMERA,
129 + preview,
130 + imageCapture,
131 + )
132 + }, ContextCompat.getMainExecutor(ctx))
133 + }
134 + },
135 + modifier = Modifier.fillMaxSize(),
136 + )
137 + }
138 + Button(
139 + onClick = {
140 + viewModel.onCaptureReady()
141 + imageCapture.takePicture(executor, object : ImageCapture.OnImageCapturedCallback() {
142 + override fun onCaptureSuccess(image: ImageProxy) {
143 + val bmp = imageProxyToBitmap(image)
144 + image.close()
145 + if (bmp != null) {
146 + viewModel.processCapture(bmp)
147 + } else {
148 + viewModel.onCaptureDecodeFailed()
149 + }
150 + }
151 + override fun onError(exception: ImageCaptureException) {
152 + viewModel.onCaptureDecodeFailed()
153 + }
154 + })
155 + },
156 + modifier = Modifier.fillMaxWidth().padding(18.dp),
157 + ) { Text(stringResource(R.string.scan_capturer)) }
158 + } else {
159 + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
160 + OutlinedButton(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
161 + Text(stringResource(R.string.scan_autoriser_camera))
162 + }
163 + }
164 + }
165 + }
166 +}
167 +
168 +@Composable
169 +private fun ScanErrorScreen(message: String, onRetry: () -> Unit, onBack: () -> Unit) {
170 + Column(
171 + Modifier.fillMaxSize().background(Fond).padding(18.dp),
172 + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
173 + horizontalAlignment = Alignment.CenterHorizontally,
174 + ) {
175 + Text(message, color = Encre)
176 + Button(onClick = onRetry) { Text(stringResource(R.string.scan_reprendre)) }
177 + OutlinedButton(onClick = onBack) { Text(stringResource(R.string.scan_retour)) }
178 + }
179 +}
180 +
181 +@Composable
182 +private fun RowTop(onBack: () -> Unit) {
183 + Box(
184 + Modifier.fillMaxWidth().background(Surface).padding(horizontal = 8.dp, vertical = 6.dp),
185 + ) {
186 + IconButton(onClick = onBack) {
187 + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = stringResource(R.string.scan_retour))
188 + }
189 + Text(
190 + stringResource(R.string.scan_titre),
191 + style = MaterialTheme.typography.titleMedium,
192 + modifier = Modifier.align(Alignment.Center),
193 + color = Encre,
194 + )
195 + }
196 +}
197 +
198 +private fun imageProxyToBitmap(image: ImageProxy): Bitmap? {
199 + val plane = image.planes.firstOrNull() ?: return null
200 + val buffer = plane.buffer
201 + val bytes = ByteArray(buffer.remaining())
202 + buffer.get(bytes)
203 + // EXIF prioritaire ; sinon rotation CameraX. Jamais les deux.
204 + return ImageOrientation.fromJpegBytes(
205 + jpegBytes = bytes,
206 + cameraRotationDegrees = image.imageInfo.rotationDegrees,
207 + )?.bitmap
208 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/ScanCarteViewModel.kt
+97 -0
@@ -0,0 +1,97 @@
1 +package fr.ebii.card2vcf.ui
2 +
3 +import android.graphics.Bitmap
4 +import android.util.Log
5 +import androidx.lifecycle.ViewModel
6 +import androidx.lifecycle.viewModelScope
7 +import fr.ebii.card2vcf.contact.ContactCard
8 +import fr.ebii.card2vcf.contact.ContactDraftMerge
9 +import fr.ebii.card2vcf.ocr.OcrEngine
10 +import fr.ebii.card2vcf.ocr.OcrException
11 +import fr.ebii.card2vcf.scan.ScanEngine
12 +import fr.ebii.card2vcf.scan.ScanException
13 +import kotlinx.coroutines.Dispatchers
14 +import kotlinx.coroutines.flow.MutableStateFlow
15 +import kotlinx.coroutines.flow.StateFlow
16 +import kotlinx.coroutines.flow.asStateFlow
17 +import kotlinx.coroutines.launch
18 +import kotlinx.coroutines.withContext
19 +
20 +sealed interface ScanUiState {
21 + data object Idle : ScanUiState
22 + data object Capturing : ScanUiState
23 + data object Scanning : ScanUiState
24 + data object OcrRunning : ScanUiState
25 + data object Structuring : ScanUiState
26 + data class DraftReady(val draft: ContactDraftUi) : ScanUiState
27 + data class Error(val message: String) : ScanUiState
28 +}
29 +
30 +data class ContactDraftUi(
31 + val card: ContactCard,
32 + val rawOcrText: String,
33 + val preview: Bitmap? = null,
34 +)
35 +
36 +class ScanCarteViewModel(
37 + private val scanEngine: ScanEngine,
38 + private val ocrEngine: OcrEngine,
39 +) : ViewModel() {
40 +
41 + private val _state = MutableStateFlow<ScanUiState>(ScanUiState.Idle)
42 + val state: StateFlow<ScanUiState> = _state.asStateFlow()
43 +
44 + fun onCaptureReady() {
45 + _state.value = ScanUiState.Capturing
46 + }
47 +
48 + fun processCapture(bitmap: Bitmap) {
49 + viewModelScope.launch {
50 + try {
51 + _state.value = ScanUiState.Scanning
52 + val scanned = withContext(Dispatchers.Default) { scanEngine.scan(bitmap) }
53 + _state.value = ScanUiState.OcrRunning
54 + val ocr = withContext(Dispatchers.Default) { ocrEngine.recognize(scanned.bitmap) }
55 + _state.value = ScanUiState.Structuring
56 + val merged = withContext(Dispatchers.Default) {
57 + ContactDraftMerge.merge(null, ocr)
58 + }
59 + _state.value = ScanUiState.DraftReady(
60 + ContactDraftUi(
61 + card = merged,
62 + rawOcrText = ocr.rawText,
63 + preview = scanned.bitmap,
64 + )
65 + )
66 + } catch (e: ScanException) {
67 + Log.e(TAG, "scan", e)
68 + _state.value = ScanUiState.Error(e.message ?: "scan_erreur")
69 + } catch (e: OcrException) {
70 + Log.e(TAG, "ocr", e)
71 + _state.value = ScanUiState.Error(e.message ?: "ocr_erreur")
72 + } catch (t: Throwable) {
73 + Log.e(TAG, "pipeline", t)
74 + _state.value = ScanUiState.Error(t.message ?: "scan_erreur")
75 + }
76 + }
77 + }
78 +
79 + fun updateDraft(card: ContactCard) {
80 + val current = _state.value
81 + if (current is ScanUiState.DraftReady) {
82 + _state.value = current.copy(draft = current.draft.copy(card = card))
83 + }
84 + }
85 +
86 + fun reset() {
87 + _state.value = ScanUiState.Idle
88 + }
89 +
90 + fun onCaptureDecodeFailed() {
91 + _state.value = ScanUiState.Error("Capture impossible — réessayez")
92 + }
93 +
94 + companion object {
95 + private const val TAG = "Card2vcfScan"
96 + }
97 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/theme/Color.kt
+21 -0
@@ -0,0 +1,21 @@
1 +package fr.ebii.card2vcf.ui.theme
2 +
3 +import androidx.compose.ui.graphics.Color
4 +
5 +// Palette Card2vcf — design.md (éditorial N&B, accent link réservé au texte inline).
6 +
7 +val Ink = Color(0xFF000000)
8 +val InkSoft = Color(0xFF1A1A1A)
9 +val Body = Color(0xFF757575)
10 +val Canvas = Color(0xFFFFFFFF)
11 +val CanvasSoft = Color(0xFFF5F5F5)
12 +val Hairline = Color(0xFFE0E0E0)
13 +val Link = Color(0xFF057DBC)
14 +val OnPrimary = Color(0xFFFFFFFF)
15 +
16 +// Alias pour écrans migrés (noms historiques Luciole → tokens design.md)
17 +val Encre = Ink
18 +val Fond = Canvas
19 +val Surface = Canvas
20 +val Bordure = Hairline
21 +val TexteFaible = Body
A android/app/src/main/java/fr/ebii/card2vcf/ui/theme/Theme.kt
+44 -0
@@ -0,0 +1,44 @@
1 +package fr.ebii.card2vcf.ui.theme
2 +
3 +import androidx.compose.foundation.shape.RoundedCornerShape
4 +import androidx.compose.material3.MaterialTheme
5 +import androidx.compose.material3.Shapes
6 +import androidx.compose.material3.lightColorScheme
7 +import androidx.compose.runtime.Composable
8 +import androidx.compose.ui.unit.dp
9 +
10 +private val Card2vcfColors = lightColorScheme(
11 + primary = Ink,
12 + onPrimary = OnPrimary,
13 + primaryContainer = CanvasSoft,
14 + onPrimaryContainer = Ink,
15 + secondary = InkSoft,
16 + onSecondary = OnPrimary,
17 + background = Canvas,
18 + onBackground = Ink,
19 + surface = Canvas,
20 + onSurface = Ink,
21 + surfaceVariant = CanvasSoft,
22 + onSurfaceVariant = Body,
23 + outline = Hairline,
24 + outlineVariant = Hairline,
25 +)
26 +
27 +/** Coins carrés — design.md `{rounded.none}`. */
28 +private val Card2vcfShapes = Shapes(
29 + extraSmall = RoundedCornerShape(0.dp),
30 + small = RoundedCornerShape(0.dp),
31 + medium = RoundedCornerShape(0.dp),
32 + large = RoundedCornerShape(0.dp),
33 + extraLarge = RoundedCornerShape(0.dp),
34 +)
35 +
36 +@Composable
37 +fun Card2vcfTheme(content: @Composable () -> Unit) {
38 + MaterialTheme(
39 + colorScheme = Card2vcfColors,
40 + typography = Card2vcfTypography,
41 + shapes = Card2vcfShapes,
42 + content = content,
43 + )
44 +}
A android/app/src/main/java/fr/ebii/card2vcf/ui/theme/Type.kt
+99 -0
@@ -0,0 +1,99 @@
1 +package fr.ebii.card2vcf.ui.theme
2 +
3 +import androidx.compose.material3.Typography
4 +import androidx.compose.ui.text.TextStyle
5 +import androidx.compose.ui.text.font.Font
6 +import androidx.compose.ui.text.font.FontFamily
7 +import androidx.compose.ui.text.font.FontWeight
8 +import androidx.compose.ui.unit.sp
9 +import fr.ebii.card2vcf.R
10 +
11 +/** Substitut WiredDisplay — titres display. */
12 +val Playfair = FontFamily(
13 + Font(R.font.playfair_display_regular, FontWeight.Normal),
14 +)
15 +
16 +/** Substitut BreveText — corps serif. */
17 +val Lora = FontFamily(
18 + Font(R.font.lora_regular, FontWeight.Normal),
19 + Font(R.font.lora_bold, FontWeight.Bold),
20 +)
21 +
22 +/** Substitut Apercu — labels, boutons, métadonnées. */
23 +val Manrope = FontFamily(
24 + Font(R.font.manrope_regular, FontWeight.Normal),
25 + Font(R.font.manrope_bold, FontWeight.Bold),
26 +)
27 +
28 +val Card2vcfTypography = Typography(
29 + displayLarge = TextStyle(
30 + fontFamily = Playfair,
31 + fontWeight = FontWeight.Normal,
32 + fontSize = 40.sp,
33 + lineHeight = 44.sp,
34 + letterSpacing = (-0.4).sp,
35 + ),
36 + displayMedium = TextStyle(
37 + fontFamily = Playfair,
38 + fontWeight = FontWeight.Normal,
39 + fontSize = 32.sp,
40 + lineHeight = 36.sp,
41 + letterSpacing = (-0.3).sp,
42 + ),
43 + headlineMedium = TextStyle(
44 + fontFamily = Playfair,
45 + fontWeight = FontWeight.Normal,
46 + fontSize = 26.sp,
47 + lineHeight = 28.sp,
48 + ),
49 + titleLarge = TextStyle(
50 + fontFamily = Playfair,
51 + fontWeight = FontWeight.Normal,
52 + fontSize = 22.sp,
53 + lineHeight = 26.sp,
54 + ),
55 + titleMedium = TextStyle(
56 + fontFamily = Manrope,
57 + fontWeight = FontWeight.Bold,
58 + fontSize = 16.sp,
59 + lineHeight = 20.sp,
60 + letterSpacing = 0.3.sp,
61 + ),
62 + bodyLarge = TextStyle(
63 + fontFamily = Lora,
64 + fontWeight = FontWeight.Normal,
65 + fontSize = 16.sp,
66 + lineHeight = 24.sp,
67 + ),
68 + bodyMedium = TextStyle(
69 + fontFamily = Manrope,
70 + fontWeight = FontWeight.Normal,
71 + fontSize = 14.sp,
72 + lineHeight = 18.sp,
73 + ),
74 + bodySmall = TextStyle(
75 + fontFamily = Manrope,
76 + fontWeight = FontWeight.Normal,
77 + fontSize = 12.sp,
78 + lineHeight = 16.sp,
79 + ),
80 + labelLarge = TextStyle(
81 + fontFamily = Manrope,
82 + fontWeight = FontWeight.Bold,
83 + fontSize = 16.sp,
84 + lineHeight = 20.sp,
85 + letterSpacing = 0.3.sp,
86 + ),
87 + labelMedium = TextStyle(
88 + fontFamily = Manrope,
89 + fontWeight = FontWeight.Bold,
90 + fontSize = 14.sp,
91 + lineHeight = 18.sp,
92 + ),
93 + labelSmall = TextStyle(
94 + fontFamily = Manrope,
95 + fontWeight = FontWeight.Bold,
96 + fontSize = 12.sp,
97 + lineHeight = 16.sp,
98 + ),
99 +)
A android/app/src/main/res/drawable/ic_launcher_foreground.png
+0 -0
A android/app/src/main/res/font/lora_bold.ttf
+0 -0
A android/app/src/main/res/font/lora_regular.ttf
+0 -0
A android/app/src/main/res/font/manrope_bold.ttf
+0 -0
A android/app/src/main/res/font/manrope_regular.ttf
+0 -0
A android/app/src/main/res/font/playfair_display_regular.ttf
+0 -0
A android/app/src/main/res/mipmap-hdpi/ic_launcher.png
+0 -0
A android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
+0 -0
A android/app/src/main/res/mipmap-mdpi/ic_launcher.png
+0 -0
A android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
+0 -0
A android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
+0 -0
A android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
+0 -0
A android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
+0 -0
A android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
+0 -0
A android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
+0 -0
A android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
+0 -0
A android/app/src/main/res/values-en/strings.xml
+29 -0
@@ -0,0 +1,29 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<resources>
3 + <string name="app_name">Card2vcf</string>
4 +
5 + <string name="scan_titre">Scan a card</string>
6 + <string name="scan_instructions">Frame the business card, then capture.</string>
7 + <string name="scan_capturer">Capture</string>
8 + <string name="scan_autoriser_camera">Allow camera</string>
9 + <string name="scan_en_cours">Correcting image…</string>
10 + <string name="scan_ocr_en_cours">Recognizing text…</string>
11 + <string name="scan_structuration">Analyzing contact…</string>
12 + <string name="scan_brouillon_titre">Review contact</string>
13 + <string name="scan_brouillon_sous_titre">Edit fields before creating the contact or exporting VCF.</string>
14 + <string name="scan_ocr_brut">Raw OCR text</string>
15 + <string name="scan_creer_contact">Create contact</string>
16 + <string name="scan_exporter_vcf">Export VCF</string>
17 + <string name="scan_reprendre">Capture again</string>
18 + <string name="scan_retour">Back</string>
19 + <string name="scan_champ_nom">Full name</string>
20 + <string name="scan_champ_prenom">First name</string>
21 + <string name="scan_champ_nom_famille">Last name</string>
22 + <string name="scan_champ_societe">Company</string>
23 + <string name="scan_champ_poste">Job title</string>
24 + <string name="scan_champ_tel">Phone(s)</string>
25 + <string name="scan_champ_email">Email(s)</string>
26 + <string name="scan_champ_site">Website</string>
27 + <string name="scan_champ_adresse">Address</string>
28 + <string name="scan_champ_note">Note</string>
29 +</resources>
A android/app/src/main/res/values/colors.xml
+4 -0
@@ -0,0 +1,4 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<resources>
3 + <color name="ic_launcher_background">#000000</color>
4 +</resources>
A android/app/src/main/res/values/strings.xml
+29 -0
@@ -0,0 +1,29 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<resources>
3 + <string name="app_name">Card2vcf</string>
4 +
5 + <string name="scan_titre">Scanner une carte</string>
6 + <string name="scan_instructions">Cadrez la carte de visite, puis capturez.</string>
7 + <string name="scan_capturer">Capturer</string>
8 + <string name="scan_autoriser_camera">Autoriser la caméra</string>
9 + <string name="scan_en_cours">Correction de l\'image…</string>
10 + <string name="scan_ocr_en_cours">Reconnaissance du texte…</string>
11 + <string name="scan_structuration">Analyse du contact…</string>
12 + <string name="scan_brouillon_titre">Vérifier le contact</string>
13 + <string name="scan_brouillon_sous_titre">Corrigez les champs avant de créer le contact ou d\'exporter le VCF.</string>
14 + <string name="scan_ocr_brut">Texte OCR brut</string>
15 + <string name="scan_creer_contact">Créer le contact</string>
16 + <string name="scan_exporter_vcf">Exporter VCF</string>
17 + <string name="scan_reprendre">Reprendre la capture</string>
18 + <string name="scan_retour">Retour</string>
19 + <string name="scan_champ_nom">Nom complet</string>
20 + <string name="scan_champ_prenom">Prénom</string>
21 + <string name="scan_champ_nom_famille">Nom de famille</string>
22 + <string name="scan_champ_societe">Société</string>
23 + <string name="scan_champ_poste">Poste</string>
24 + <string name="scan_champ_tel">Téléphone(s)</string>
25 + <string name="scan_champ_email">E-mail(s)</string>
26 + <string name="scan_champ_site">Site web</string>
27 + <string name="scan_champ_adresse">Adresse</string>
28 + <string name="scan_champ_note">Note</string>
29 +</resources>
A android/app/src/main/res/xml/file_paths.xml
+4 -0
@@ -0,0 +1,4 @@
1 +<?xml version="1.0" encoding="utf-8"?>
2 +<paths>
3 + <cache-path name="vcf" path="vcf/" />
4 +</paths>
A android/app/src/test/java/fr/ebii/card2vcf/contact/ContactCardTest.kt
+37 -0
@@ -0,0 +1,37 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertFalse
6 +import kotlin.test.assertTrue
7 +
8 +class ContactCardTest {
9 + @Test fun champsPartiels() {
10 + val c = ContactCard(phones = listOf("+33612345678"))
11 + assertEquals("", c.displayName())
12 + assertTrue(c.hasAnyField())
13 + }
14 +
15 + @Test fun displayNamePrioriseFullName() {
16 + val c = ContactCard(fullName = "Jean Dupont", firstName = "Jean", lastName = "Dupont")
17 + assertEquals("Jean Dupont", c.displayName())
18 + }
19 +
20 + @Test fun displayNameDepuisPrenomNom() {
21 + val c = ContactCard(firstName = "Jean", lastName = "Dupont")
22 + assertEquals("Jean Dupont", c.displayName())
23 + }
24 +
25 + @Test fun listesMultiples() {
26 + val c = ContactCard(
27 + phones = listOf("0612345678", "0145678901"),
28 + emails = listOf("a@b.fr", "c@d.fr"),
29 + )
30 + assertEquals(2, c.phones.size)
31 + assertEquals(2, c.emails.size)
32 + }
33 +
34 + @Test fun videSansChamp() {
35 + assertFalse(ContactCard().hasAnyField())
36 + }
37 +}
A android/app/src/test/java/fr/ebii/card2vcf/contact/ContactDraftMergeTest.kt
+34 -0
@@ -0,0 +1,34 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import fr.ebii.card2vcf.ocr.OcrResult
4 +import kotlin.test.Test
5 +import kotlin.test.assertEquals
6 +
7 +class ContactDraftMergeTest {
8 + @Test fun fusionneRegexEtLlm() {
9 + val llm = ContactCard(fullName = "Jean Dupont", phones = listOf("0612345678"))
10 + val ocr = OcrResult(
11 + rawText = "Jean Dupont\n0612345678\njean@acme.fr",
12 + phones = listOf("0612345678", "0145678901"),
13 + emails = listOf("jean@acme.fr"),
14 + urls = listOf("https://acme.fr"),
15 + )
16 + val merged = ContactDraftMerge.merge(llm, ocr)
17 + assertEquals("Jean Dupont", merged.fullName)
18 + assertEquals(listOf("0612345678", "0145678901"), merged.phones)
19 + assertEquals(listOf("jean@acme.fr"), merged.emails)
20 + assertEquals("https://acme.fr", merged.website)
21 + }
22 +
23 + @Test fun fallbackRegexSiLlmNull() {
24 + val ocr = OcrResult(
25 + rawText = "x",
26 + phones = listOf("0611111111"),
27 + emails = listOf("a@b.fr"),
28 + urls = listOf("https://x.fr"),
29 + )
30 + val merged = ContactDraftMerge.merge(null, ocr)
31 + assertEquals(listOf("0611111111"), merged.phones)
32 + assertEquals("https://x.fr", merged.website)
33 + }
34 +}
A android/app/src/test/java/fr/ebii/card2vcf/contact/ContactHeuristicParserTest.kt
+69 -0
@@ -0,0 +1,69 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import fr.ebii.card2vcf.ocr.OcrPostProcessor
4 +import fr.ebii.card2vcf.ocr.OcrResult
5 +import kotlin.test.Test
6 +import kotlin.test.assertEquals
7 +import kotlin.test.assertNotNull
8 +import kotlin.test.assertTrue
9 +
10 +class ContactHeuristicParserTest {
11 +
12 + @Test fun parseCarteDimoBruitee() {
13 + val text = """
14 + dimo
15 + SOFTWARE &—
16 + Jean Pau! GENOUX
17 + Directeur Général/CEO
18 + jpgenoux@dimosoftware.com
19 + Mobile : +33 (0)6 74 64 05 44 / Tél. : +33 (0)4 72 86 O1 92
20 + Siège Social
21 + 561, alée des Noisetiers - 69 760 Limonest - France
22 + Lyon - Paris - Nantes - Biarritz - Madrid — Toronto
23 + www.dimosoftware.fr
24 + """.trimIndent()
25 + val ocr = OcrPostProcessor.process(text)
26 + val card = ContactHeuristicParser.parse(ocr)
27 +
28 + assertEquals("Jean Paul Genoux", card.fullName)
29 + assertEquals("Jean Paul", card.firstName)
30 + assertEquals("Genoux", card.lastName)
31 + assertTrue(card.jobTitle!!.contains("Directeur", ignoreCase = true))
32 + assertTrue(card.company!!.contains("Software", ignoreCase = true) || card.company!!.contains("dimo", ignoreCase = true))
33 + assertTrue(card.emails.contains("jpgenoux@dimosoftware.com"))
34 + assertTrue(card.phones.any { it.endsWith("674640544") })
35 + assertTrue(card.phones.any { it.contains("472860192") })
36 + assertTrue(card.website!!.contains("dimosoftware.fr"))
37 + assertNotNull(card.address)
38 + assertTrue(card.address!!.contains("Noisetiers") || card.address!!.contains("Limonest"))
39 + }
40 +
41 + @Test fun splitNomAvecNomFamilleMajuscules() {
42 + val (first, last, full) = ContactHeuristicParser.splitPersonName("Marie CURIE")
43 + assertEquals("Marie", first)
44 + assertEquals("Curie", last)
45 + assertEquals("Marie Curie", full)
46 + }
47 +
48 + @Test fun societeDepuisEmail() {
49 + assertEquals("Dimo Software", ContactHeuristicParser.companyFromEmailDomain("jp@dimosoftware.com"))
50 + }
51 +
52 + @Test fun mergeSansLlmRemplitQuandMemeLesChamps() {
53 + val ocr = OcrPostProcessor.process(
54 + """
55 + Acme SAS
56 + Alice Martin
57 + CEO
58 + alice@acme.fr
59 + 01 23 45 67 89
60 + 10 rue de Paris 75001 Paris
61 + """.trimIndent()
62 + )
63 + val merged = ContactDraftMerge.merge(null, ocr)
64 + assertEquals("Alice Martin", merged.fullName)
65 + assertTrue(merged.emails.contains("alice@acme.fr"))
66 + assertTrue(merged.jobTitle!!.contains("CEO", ignoreCase = true))
67 + assertNotNull(merged.company)
68 + }
69 +}
A android/app/src/test/java/fr/ebii/card2vcf/contact/ContactInsertIntentTest.kt
+31 -0
@@ -0,0 +1,31 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import android.content.Intent
4 +import android.provider.ContactsContract
5 +import org.junit.runner.RunWith
6 +import org.robolectric.RobolectricTestRunner
7 +import org.robolectric.annotation.Config
8 +import kotlin.test.Test
9 +import kotlin.test.assertEquals
10 +
11 +@RunWith(RobolectricTestRunner::class)
12 +@Config(sdk = [31])
13 +class ContactInsertIntentTest {
14 + @Test fun extrasPrenomNomEtTelephone() {
15 + val card = ContactCard(
16 + fullName = "Jean Dupont",
17 + company = "Acme",
18 + jobTitle = "Directeur",
19 + phones = listOf("0612345678"),
20 + emails = listOf("jean@acme.fr"),
21 + address = "Paris",
22 + note = "note test",
23 + )
24 + val intent = ContactInsertIntent.build(card)
25 + assertEquals(Intent.ACTION_INSERT, intent.action)
26 + assertEquals("Jean Dupont", intent.getStringExtra(ContactsContract.Intents.Insert.NAME))
27 + assertEquals("0612345678", intent.getStringExtra(ContactsContract.Intents.Insert.PHONE))
28 + assertEquals("jean@acme.fr", intent.getStringExtra(ContactsContract.Intents.Insert.EMAIL))
29 + assertEquals("Acme", intent.getStringExtra(ContactsContract.Intents.Insert.COMPANY))
30 + }
31 +}
A android/app/src/test/java/fr/ebii/card2vcf/contact/VCardSerializerTest.kt
+43 -0
@@ -0,0 +1,43 @@
1 +package fr.ebii.card2vcf.contact
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertTrue
6 +
7 +class VCardSerializerTest {
8 + @Test fun vcardMinimal() {
9 + val vcf = VCardSerializer.toVCard3(ContactCard(fullName = "Jean Dupont"))
10 + assertTrue(vcf.contains("BEGIN:VCARD"))
11 + assertTrue(vcf.contains("FN:Jean Dupont"))
12 + assertTrue(vcf.contains("END:VCARD"))
13 + }
14 +
15 + @Test fun accentsEtEchappement() {
16 + val vcf = VCardSerializer.toVCard3(
17 + ContactCard(
18 + fullName = "Élodie Martin",
19 + note = "Ligne1\nLigne2",
20 + company = "Société; Générale",
21 + )
22 + )
23 + assertTrue(vcf.contains("FN:Élodie Martin"))
24 + assertTrue(vcf.contains("NOTE:Ligne1\\nLigne2"))
25 + assertTrue(vcf.contains("ORG:Société\\; Générale"))
26 + }
27 +
28 + @Test fun multiTelEtEmail() {
29 + val vcf = VCardSerializer.toVCard3(
30 + ContactCard(
31 + fullName = "Test",
32 + phones = listOf("0612345678", "0145678901"),
33 + emails = listOf("a@b.fr", "c@d.fr"),
34 + )
35 + )
36 + assertEquals(2, vcf.split('\n').count { it.startsWith("TEL;") })
37 + assertEquals(2, vcf.split('\n').count { it.startsWith("EMAIL;") })
38 + }
39 +
40 + @Test fun escapeUnitaire() {
41 + assertEquals("a\\;b", VCardSerializer.escape("a;b"))
42 + }
43 +}
A android/app/src/test/java/fr/ebii/card2vcf/ocr/OcrOrientationTest.kt
+17 -0
@@ -0,0 +1,17 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertTrue
5 +
6 +class OcrOrientationTest {
7 + @Test fun scorePrefereEmailEtTelephone() {
8 + val weak = OcrResult(rawText = "abc", phones = emptyList(), emails = emptyList())
9 + val strong = OcrResult(
10 + rawText = "Jean Paul GENOUX\njpgenoux@dimosoftware.com\n+33674640544",
11 + phones = listOf("+33674640544"),
12 + emails = listOf("jpgenoux@dimosoftware.com"),
13 + urls = listOf("https://www.dimosoftware.fr"),
14 + )
15 + assertTrue(OcrOrientation.score(strong) > OcrOrientation.score(weak))
16 + }
17 +}
A android/app/src/test/java/fr/ebii/card2vcf/ocr/OcrPostProcessingTest.kt
+50 -0
@@ -0,0 +1,50 @@
1 +package fr.ebii.card2vcf.ocr
2 +
3 +import kotlin.test.Test
4 +import kotlin.test.assertEquals
5 +import kotlin.test.assertTrue
6 +
7 +class OcrPostProcessingTest {
8 + @Test fun extraitEmailEtTelephone() {
9 + val text = """
10 + Jean Dupont
11 + Directeur
12 + 06 12 34 56 78
13 + jean.dupont@acme.fr
14 + https://acme.fr
15 + """.trimIndent()
16 + val r = OcrPostProcessor.process(text)
17 + assertTrue(r.emails.contains("jean.dupont@acme.fr"))
18 + assertTrue(r.phones.any { it.contains("0612345678") })
19 + assertTrue(r.urls.contains("https://acme.fr"))
20 + }
21 +
22 + @Test fun multiEmails() {
23 + val r = OcrPostProcessor.process("a@b.fr et c@d.fr")
24 + assertEquals(2, r.emails.size)
25 + }
26 +
27 + @Test fun lignesNettoyees() {
28 + val r = OcrPostProcessor.process(" ligne1 \n\n ligne2 ")
29 + assertEquals(listOf("ligne1", "ligne2"), r.lines)
30 + }
31 +
32 + @Test fun extraitCarteDimoCommeSurAppareil() {
33 + val text = """
34 + dimo
35 + SOFTWARE
36 + Jean Pau! GENOUX
37 + Directeur Général/CEO
38 + jpgenoux@dimosoftware.com
39 + Mobile : +33 (0)6 74 64 05 44 / Tél. : +33 (0)4 72 86 O1 92
40 + Siège Social
41 + 561, alée des Noisetiers - 69 760 Limonest - France
42 + www.dimosoftware.fr
43 + """.trimIndent()
44 + val r = OcrPostProcessor.process(text)
45 + assertEquals(listOf("jpgenoux@dimosoftware.com"), r.emails)
46 + assertTrue(r.phones.any { it == "+33674640544" || it.endsWith("674640544") })
47 + assertTrue(r.phones.any { it.contains("472860192") || it.endsWith("472860192") })
48 + assertTrue(r.urls.any { it.contains("dimosoftware.fr") })
49 + }
50 +}
A android/app/src/test/java/fr/ebii/card2vcf/scan/CapturePipelineRobustnessTest.kt
+74 -0
@@ -0,0 +1,74 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.BitmapFactory
4 +import fr.ebii.card2vcf.ocr.OcrPostProcessor
5 +import org.junit.runner.RunWith
6 +import org.robolectric.RobolectricTestRunner
7 +import org.robolectric.annotation.Config
8 +import kotlin.test.Test
9 +import kotlin.test.assertEquals
10 +import kotlin.test.assertTrue
11 +
12 +/**
13 + * Pipeline générique : EXIF → (scan simulé) → post-OCR,
14 + * calé sur les difficultés utilisateur observées (orientation, bruit OCR).
15 + */
16 +@RunWith(RobolectricTestRunner::class)
17 +@Config(sdk = [31])
18 +class CapturePipelineRobustnessTest {
19 +
20 + @Test fun photoDimoExifPuisExtractionContact() {
21 + val rawJpeg = javaClass.classLoader!!
22 + .getResourceAsStream("cards/dimo_genoux.jpg")!!
23 + .readBytes()
24 +
25 + val oriented = ImageOrientation.fromJpegBytes(rawJpeg)!!
26 + assertTrue(oriented.fromExif)
27 + assertEquals(90, oriented.appliedRotationDegrees)
28 +
29 + // Texte OCR représentatif de ce que Tesseract produit sur cette carte (mesuré sur appareil).
30 + val ocrBruite = """
31 + dimo
32 + SOFTWARE &—
33 + Jean Pau! GENOUX
34 + Directeur Général/CEO
35 + jpgenoux@dimosoftware.com
36 + Mobile : +33 (0)6 74 64 05 44 / Tél. : +33 (0)4 72 86 O1 92
37 + Siège Social
38 + 561, alée des Noisetiers - 69 760 Limonest - France
39 + www.dimosoftware.fr
40 + """.trimIndent()
41 +
42 + val fields = OcrPostProcessor.process(ocrBruite)
43 + assertEquals(listOf("jpgenoux@dimosoftware.com"), fields.emails)
44 + assertTrue(fields.phones.any { it.endsWith("674640544") })
45 + assertTrue(fields.phones.any { it.contains("472860192") })
46 + assertTrue(fields.urls.any { it.contains("dimosoftware.fr") })
47 + assertTrue(oriented.bitmap.height > oriented.bitmap.width)
48 + }
49 +
50 + @Test fun photoSansExifMaisRotationCameraResteLisible() {
51 + // Image déjà orientée (après transpose) : EXIF normal, CameraX dit 0.
52 + val orientedJpeg = javaClass.classLoader!!
53 + .getResourceAsStream("cards/dimo_genoux_oriented.jpg")!!
54 + .readBytes()
55 + val bmp = BitmapFactory.decodeByteArray(orientedJpeg, 0, orientedJpeg.size)!!
56 + val result = ImageOrientation.fromJpegBytes(orientedJpeg, cameraRotationDegrees = 0)!!
57 + assertEquals(bmp.width, result.bitmap.width)
58 + assertEquals(bmp.height, result.bitmap.height)
59 + }
60 +
61 + @Test fun toutesOrientationsExifPrincipalesSontGerees() {
62 + val cases = listOf(
63 + ExifCase(androidx.exifinterface.media.ExifInterface.ORIENTATION_NORMAL, 0, false),
64 + ExifCase(androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_90, 90, true),
65 + ExifCase(androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_180, 180, true),
66 + ExifCase(androidx.exifinterface.media.ExifInterface.ORIENTATION_ROTATE_270, 270, true),
67 + )
68 + for (c in cases) {
69 + assertEquals(c.degrees, ImageOrientation.exifRotationDegrees(c.orientation))
70 + }
71 + }
72 +
73 + private data class ExifCase(val orientation: Int, val degrees: Int, val useful: Boolean)
74 +}
A android/app/src/test/java/fr/ebii/card2vcf/scan/DimoCardScanTest.kt
+47 -0
@@ -0,0 +1,47 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.PointF
4 +import org.junit.runner.RunWith
5 +import org.robolectric.RobolectricTestRunner
6 +import org.robolectric.annotation.Config
7 +import kotlin.test.Test
8 +import kotlin.test.assertNotNull
9 +import kotlin.test.assertTrue
10 +
11 +@RunWith(RobolectricTestRunner::class)
12 +@Config(sdk = [31])
13 +class DimoCardScanTest {
14 +
15 + @Test fun scanApresCorrectionExif() {
16 + if (!OpenCvScanEngine.ensureOpenCv()) return
17 + val bytes = javaClass.classLoader!!
18 + .getResourceAsStream("cards/dimo_genoux.jpg")!!
19 + .readBytes()
20 + val oriented = ImageOrientation.fromJpegBytes(bytes)!!
21 + assertTrue(oriented.fromExif)
22 + val result = OpenCvScanEngine().scan(oriented.bitmap)
23 + assertNotNull(result.bitmap)
24 + assertTrue(result.bitmap.width > 100)
25 + assertTrue(result.bitmap.height > 100)
26 + println("SCAN dimo ${result.bitmap.width}x${result.bitmap.height} conf=${result.confidence}")
27 + }
28 +
29 + @Test fun scanAvecCoinsManuelsResteStable() {
30 + if (!OpenCvScanEngine.ensureOpenCv()) return
31 + val bytes = javaClass.classLoader!!
32 + .getResourceAsStream("cards/dimo_genoux_oriented.jpg")!!
33 + .readBytes()
34 + val oriented = ImageOrientation.fromJpegBytes(bytes)!!
35 + val w = oriented.bitmap.width.toFloat()
36 + val h = oriented.bitmap.height.toFloat()
37 + val margin = 0.05f
38 + val corners = ScanCorners(
39 + topLeft = PointF(w * margin, h * margin),
40 + topRight = PointF(w * (1 - margin), h * margin),
41 + bottomRight = PointF(w * (1 - margin), h * (1 - margin)),
42 + bottomLeft = PointF(w * margin, h * (1 - margin)),
43 + )
44 + val result = OpenCvScanEngine().scan(oriented.bitmap, corners)
45 + assertTrue(result.confidence >= 0.9f)
46 + }
47 +}
A android/app/src/test/java/fr/ebii/card2vcf/scan/ImageOrientationTest.kt
+130 -0
@@ -0,0 +1,130 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.Canvas
5 +import android.graphics.Color
6 +import android.graphics.Paint
7 +import androidx.exifinterface.media.ExifInterface
8 +import org.junit.runner.RunWith
9 +import org.robolectric.RobolectricTestRunner
10 +import org.robolectric.annotation.Config
11 +import java.io.ByteArrayOutputStream
12 +import java.io.File
13 +import kotlin.test.Test
14 +import kotlin.test.assertEquals
15 +import kotlin.test.assertFalse
16 +import kotlin.test.assertNotNull
17 +import kotlin.test.assertTrue
18 +
19 +/**
20 + * Tests génériques d'orientation : EXIF, fallback CameraX, anti double-rotation,
21 + * et fixture réelle (carte dimo photographiée en conditions utilisateur).
22 + */
23 +@RunWith(RobolectricTestRunner::class)
24 +@Config(sdk = [31])
25 +class ImageOrientationTest {
26 +
27 + @Test fun exif6FaitTourner90EtInverseDimensions() {
28 + val src = solidBitmap(200, 100, Color.RED)
29 + val jpeg = encodeJpeg(src)
30 + val file = writeJpegWithExif(jpeg, ExifInterface.ORIENTATION_ROTATE_90)
31 + val oriented = ImageOrientation.fromFile(file)!!
32 + assertTrue(oriented.fromExif)
33 + assertEquals(90, oriented.appliedRotationDegrees)
34 + assertEquals(100, oriented.bitmap.width)
35 + assertEquals(200, oriented.bitmap.height)
36 + }
37 +
38 + @Test fun exif3FaitTourner180SansChangerDimensions() {
39 + val src = solidBitmap(160, 80, Color.BLUE)
40 + val file = writeJpegWithExif(encodeJpeg(src), ExifInterface.ORIENTATION_ROTATE_180)
41 + val oriented = ImageOrientation.fromFile(file)!!
42 + assertEquals(180, oriented.appliedRotationDegrees)
43 + assertEquals(160, oriented.bitmap.width)
44 + assertEquals(80, oriented.bitmap.height)
45 + }
46 +
47 + @Test fun exif8FaitTourner270() {
48 + val src = solidBitmap(120, 60, Color.GREEN)
49 + val file = writeJpegWithExif(encodeJpeg(src), ExifInterface.ORIENTATION_ROTATE_270)
50 + val oriented = ImageOrientation.fromFile(file)!!
51 + assertEquals(270, oriented.appliedRotationDegrees)
52 + assertEquals(60, oriented.bitmap.width)
53 + assertEquals(120, oriented.bitmap.height)
54 + }
55 +
56 + @Test fun sansExifUtiliseRotationCameraX() {
57 + val src = solidBitmap(200, 100, Color.YELLOW)
58 + val jpeg = encodeJpeg(src) // EXIF normal / absent
59 + val oriented = ImageOrientation.fromJpegBytes(jpeg, cameraRotationDegrees = 90)!!
60 + assertFalse(oriented.fromExif)
61 + assertEquals(90, oriented.appliedRotationDegrees)
62 + assertEquals(100, oriented.bitmap.width)
63 + assertEquals(200, oriented.bitmap.height)
64 + }
65 +
66 + @Test fun exifPrioritaireSurCameraXPasDeDoubleRotation() {
67 + val src = solidBitmap(200, 100, Color.MAGENTA)
68 + val file = writeJpegWithExif(encodeJpeg(src), ExifInterface.ORIENTATION_ROTATE_90)
69 + // CameraX dirait aussi 90° : si on cumule → 180° et dimensions d'origine. On refuse ça.
70 + val oriented = ImageOrientation.fromFile(file, cameraRotationDegrees = 90)!!
71 + assertTrue(oriented.fromExif)
72 + assertEquals(90, oriented.appliedRotationDegrees)
73 + assertEquals(100, oriented.bitmap.width)
74 + assertEquals(200, oriented.bitmap.height)
75 + }
76 +
77 + @Test fun normalizeDegreesQuantifieAuxQuarts() {
78 + assertEquals(0, ImageOrientation.normalizeDegrees(10))
79 + assertEquals(90, ImageOrientation.normalizeDegrees(95))
80 + assertEquals(180, ImageOrientation.normalizeDegrees(190))
81 + assertEquals(270, ImageOrientation.normalizeDegrees(-90))
82 + }
83 +
84 + @Test fun carteDimoReelleOrientationExif6() {
85 + val bytes = javaClass.classLoader!!
86 + .getResourceAsStream("cards/dimo_genoux.jpg")!!
87 + .readBytes()
88 + val oriented = ImageOrientation.fromJpegBytes(bytes)!!
89 + assertNotNull(oriented.bitmap)
90 + // Photo OnePlus : EXIF orientation 6 → 90° → portrait 3008x4000
91 + assertTrue(oriented.fromExif, "la carte dimo doit être corrigée via EXIF")
92 + assertEquals(90, oriented.appliedRotationDegrees)
93 + assertEquals(3008, oriented.bitmap.width)
94 + assertEquals(4000, oriented.bitmap.height)
95 + assertTrue(oriented.bitmap.height > oriented.bitmap.width, "attendu portrait après EXIF")
96 + }
97 +
98 + @Test fun carteDimoNeDoitPasEtreDoubleTourneeSiCameraDitAussi90() {
99 + val bytes = javaClass.classLoader!!
100 + .getResourceAsStream("cards/dimo_genoux.jpg")!!
101 + .readBytes()
102 + val oriented = ImageOrientation.fromJpegBytes(bytes, cameraRotationDegrees = 90)!!
103 + assertEquals(3008, oriented.bitmap.width)
104 + assertEquals(4000, oriented.bitmap.height)
105 + }
106 +
107 + private fun solidBitmap(w: Int, h: Int, color: Int): Bitmap {
108 + val bmp = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
109 + Canvas(bmp).drawColor(color)
110 + // marqueur coin pour debug visuel
111 + Canvas(bmp).drawRect(0f, 0f, 8f, 8f, Paint().apply { this.color = Color.BLACK })
112 + return bmp
113 + }
114 +
115 + private fun encodeJpeg(bitmap: Bitmap): ByteArray {
116 + val out = ByteArrayOutputStream()
117 + bitmap.compress(Bitmap.CompressFormat.JPEG, 95, out)
118 + return out.toByteArray()
119 + }
120 +
121 + private fun writeJpegWithExif(jpeg: ByteArray, orientation: Int): File {
122 + val file = File.createTempFile("orient", ".jpg")
123 + file.writeBytes(jpeg)
124 + ExifInterface(file.absolutePath).apply {
125 + setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
126 + saveAttributes()
127 + }
128 + return file
129 + }
130 +}
A android/app/src/test/java/fr/ebii/card2vcf/scan/OpenCvScanEngineTest.kt
+66 -0
@@ -0,0 +1,66 @@
1 +package fr.ebii.card2vcf.scan
2 +
3 +import android.graphics.Bitmap
4 +import android.graphics.Canvas
5 +import android.graphics.Color
6 +import android.graphics.Paint
7 +import android.graphics.PointF
8 +import org.junit.runner.RunWith
9 +import org.robolectric.RobolectricTestRunner
10 +import org.robolectric.annotation.Config
11 +import kotlin.test.Test
12 +import kotlin.test.assertNotNull
13 +import kotlin.test.assertTrue
14 +
15 +@RunWith(RobolectricTestRunner::class)
16 +@Config(sdk = [31])
17 +class OpenCvScanEngineTest {
18 +
19 + @Test fun redresseDocumentRectangulaire() {
20 + if (!OpenCvScanEngine.ensureOpenCv()) return
21 + val bmp = cardBitmap(margin = 40)
22 + val engine = OpenCvScanEngine()
23 + val corners = ScanCorners(
24 + topLeft = PointF(40f, 40f),
25 + topRight = PointF(559f, 40f),
26 + bottomRight = PointF(559f, 339f),
27 + bottomLeft = PointF(40f, 339f),
28 + )
29 + val result = engine.scan(bmp, corners)
30 + assertNotNull(result.bitmap)
31 + assertTrue(result.bitmap.width > 0)
32 + }
33 +
34 + @Test fun autoDetectCarteBlancheSurFondNoir() {
35 + if (!OpenCvScanEngine.ensureOpenCv()) return
36 + val result = OpenCvScanEngine().scan(cardBitmap(margin = 40))
37 + assertNotNull(result.bitmap)
38 + assertTrue(result.bitmap.width > 100)
39 + assertTrue(result.confidence >= 0.4f)
40 + }
41 +
42 + @Test fun fallbackCadreCompletSiPasDeContour() {
43 + if (!OpenCvScanEngine.ensureOpenCv()) return
44 + // Image uniforme : aucun contour, le fallback cadre complet doit quand même produire une sortie.
45 + val bmp = Bitmap.createBitmap(200, 120, Bitmap.Config.ARGB_8888)
46 + Canvas(bmp).drawColor(Color.GRAY)
47 + val result = OpenCvScanEngine().scan(bmp)
48 + assertNotNull(result.bitmap)
49 + assertTrue(result.confidence <= 0.5f)
50 + }
51 +
52 + private fun cardBitmap(margin: Int): Bitmap {
53 + val bmp = Bitmap.createBitmap(600, 380, Bitmap.Config.ARGB_8888)
54 + val canvas = Canvas(bmp)
55 + canvas.drawColor(Color.BLACK)
56 + canvas.drawRect(
57 + margin.toFloat(), margin.toFloat(),
58 + (600 - margin).toFloat(), (380 - margin).toFloat(),
59 + Paint().apply { color = Color.WHITE },
60 + )
61 + val paint = Paint().apply { color = Color.BLACK; textSize = 28f }
62 + canvas.drawText("Jean Dupont", (margin + 20).toFloat(), (margin + 50).toFloat(), paint)
63 + canvas.drawText("0612345678", (margin + 20).toFloat(), (margin + 100).toFloat(), paint)
64 + return bmp
65 + }
66 +}
A android/app/src/test/resources/cards/dimo_genoux.jpg
+0 -0
A android/app/src/test/resources/cards/dimo_genoux_oriented.jpg
+0 -0
A android/build.gradle.kts
+5 -0
@@ -0,0 +1,5 @@
1 +plugins {
2 + id("com.android.application") version "8.6.0" apply false
3 + id("org.jetbrains.kotlin.android") version "2.0.20" apply false
4 + id("org.jetbrains.kotlin.plugin.compose") version "2.0.20" apply false
5 +}
A android/gradle.properties
+4 -0
@@ -0,0 +1,4 @@
1 +org.gradle.jvmargs=-Xmx2048m
2 +android.useAndroidX=true
3 +kotlin.code.style=official
4 +android.nonTransitiveRClass=true
A android/gradle/wrapper/gradle-wrapper.jar
+0 -0
A android/gradle/wrapper/gradle-wrapper.properties
+9 -0
@@ -0,0 +1,9 @@
1 +distributionBase=GRADLE_USER_HOME
2 +distributionPath=wrapper/dists
3 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
4 +networkTimeout=10000
5 +retries=0
6 +retryBackOffMs=500
7 +validateDistributionUrl=true
8 +zipStoreBase=GRADLE_USER_HOME
9 +zipStorePath=wrapper/dists
A android/gradlew
+248 -0
@@ -0,0 +1,248 @@
1 +#!/bin/sh
2 +
3 +#
4 +# Copyright © 2015 the original authors.
5 +#
6 +# Licensed under the Apache License, Version 2.0 (the "License");
7 +# you may not use this file except in compliance with the License.
8 +# You may obtain a copy of the License at
9 +#
10 +# https://www.apache.org/licenses/LICENSE-2.0
11 +#
12 +# Unless required by applicable law or agreed to in writing, software
13 +# distributed under the License is distributed on an "AS IS" BASIS,
14 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 +# See the License for the specific language governing permissions and
16 +# limitations under the License.
17 +#
18 +# SPDX-License-Identifier: Apache-2.0
19 +#
20 +
21 +##############################################################################
22 +#
23 +# gradlew start up script for POSIX generated by Gradle.
24 +#
25 +# Important for running:
26 +#
27 +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 +# noncompliant, but you have some other compliant shell such as ksh or
29 +# bash, then to run this script, type that shell name before the whole
30 +# command line, like:
31 +#
32 +# ksh gradlew
33 +#
34 +# Busybox and similar reduced shells will NOT work, because this script
35 +# requires all of these POSIX shell features:
36 +# * functions;
37 +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 +# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 +# * compound commands having a testable exit status, especially «case»;
40 +# * various built-in commands including «command», «set», and «ulimit».
41 +#
42 +# Important for patching:
43 +#
44 +# (2) This script targets any POSIX shell, so it avoids extensions provided
45 +# by Bash, Ksh, etc; in particular arrays are avoided.
46 +#
47 +# The "traditional" practice of packing multiple parameters into a
48 +# space-separated string is a well documented source of bugs and security
49 +# problems, so this is (mostly) avoided, by progressively accumulating
50 +# options in "$@", and eventually passing that to Java.
51 +#
52 +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 +# see the in-line comments for details.
55 +#
56 +# There are tweaks for specific operating systems such as AIX, CygWin,
57 +# Darwin, MinGW, and NonStop.
58 +#
59 +# (3) This script is generated from the Groovy template
60 +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 +# within the Gradle project.
62 +#
63 +# You can find Gradle at https://github.com/gradle/gradle/.
64 +#
65 +##############################################################################
66 +
67 +# Attempt to set APP_HOME
68 +
69 +# Resolve links: $0 may be a link
70 +app_path=$0
71 +
72 +# Need this for daisy-chained symlinks.
73 +while
74 + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 + [ -h "$app_path" ]
76 +do
77 + ls=$( ls -ld "$app_path" )
78 + link=${ls#*' -> '}
79 + case $link in #(
80 + /*) app_path=$link ;; #(
81 + *) app_path=$APP_HOME$link ;;
82 + esac
83 +done
84 +
85 +# This is normally unused
86 +# shellcheck disable=SC2034
87 +APP_BASE_NAME=${0##*/}
88 +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90 +
91 +# Use the maximum available, or set MAX_FD != -1 to use that value.
92 +MAX_FD=maximum
93 +
94 +warn () {
95 + echo "$*"
96 +} >&2
97 +
98 +die () {
99 + echo
100 + echo "$*"
101 + echo
102 + exit 1
103 +} >&2
104 +
105 +# OS specific support (must be 'true' or 'false').
106 +cygwin=false
107 +msys=false
108 +darwin=false
109 +nonstop=false
110 +case "$( uname )" in #(
111 + CYGWIN* ) cygwin=true ;; #(
112 + Darwin* ) darwin=true ;; #(
113 + MSYS* | MINGW* ) msys=true ;; #(
114 + NONSTOP* ) nonstop=true ;;
115 +esac
116 +
117 +
118 +
119 +# Determine the Java command to use to start the JVM.
120 +if [ -n "$JAVA_HOME" ] ; then
121 + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
122 + # IBM's JDK on AIX uses strange locations for the executables
123 + JAVACMD=$JAVA_HOME/jre/sh/java
124 + else
125 + JAVACMD=$JAVA_HOME/bin/java
126 + fi
127 + if [ ! -x "$JAVACMD" ] ; then
128 + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
129 +
130 +Please set the JAVA_HOME variable in your environment to match the
131 +location of your Java installation."
132 + fi
133 +else
134 + JAVACMD=java
135 + if ! command -v java >/dev/null 2>&1
136 + then
137 + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
138 +
139 +Please set the JAVA_HOME variable in your environment to match the
140 +location of your Java installation."
141 + fi
142 +fi
143 +
144 +# Increase the maximum file descriptors if we can.
145 +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
146 + case $MAX_FD in #(
147 + max*)
148 + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
149 + # shellcheck disable=SC2039,SC3045
150 + MAX_FD=$( ulimit -H -n ) ||
151 + warn "Could not query maximum file descriptor limit"
152 + esac
153 + case $MAX_FD in #(
154 + '' | soft) :;; #(
155 + *)
156 + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
157 + # shellcheck disable=SC2039,SC3045
158 + ulimit -n "$MAX_FD" ||
159 + warn "Could not set maximum file descriptor limit to $MAX_FD"
160 + esac
161 +fi
162 +
163 +# Collect all arguments for the java command, stacking in reverse order:
164 +# * args from the command line
165 +# * the main class name
166 +# * -classpath
167 +# * -D...appname settings
168 +# * --module-path (only if needed)
169 +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
170 +
171 +# For Cygwin or MSYS, switch paths to Windows format before running java
172 +if "$cygwin" || "$msys" ; then
173 + APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
174 +
175 + JAVACMD=$( cygpath --unix "$JAVACMD" )
176 +
177 + # Now convert the arguments - kludge to limit ourselves to /bin/sh
178 + for arg do
179 + if
180 + case $arg in #(
181 + -*) false ;; # don't mess with options #(
182 + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
183 + [ -e "$t" ] ;; #(
184 + *) false ;;
185 + esac
186 + then
187 + arg=$( cygpath --path --ignore --mixed "$arg" )
188 + fi
189 + # Roll the args list around exactly as many times as the number of
190 + # args, so each arg winds up back in the position where it started, but
191 + # possibly modified.
192 + #
193 + # NB: a `for` loop captures its iteration list before it begins, so
194 + # changing the positional parameters here affects neither the number of
195 + # iterations, nor the values presented in `arg`.
196 + shift # remove old arg
197 + set -- "$@" "$arg" # push replacement arg
198 + done
199 +fi
200 +
201 +
202 +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
203 +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
204 +
205 +# Collect all arguments for the java command:
206 +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
207 +# and any embedded shellness will be escaped.
208 +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
209 +# treated as '${Hostname}' itself on the command line.
210 +
211 +set -- \
212 + "-Dorg.gradle.appname=$APP_BASE_NAME" \
213 + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
214 + "$@"
215 +
216 +# Stop when "xargs" is not available.
217 +if ! command -v xargs >/dev/null 2>&1
218 +then
219 + die "xargs is not available"
220 +fi
221 +
222 +# Use "xargs" to parse quoted args.
223 +#
224 +# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
225 +#
226 +# In Bash we could simply go:
227 +#
228 +# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
229 +# set -- "${ARGS[@]}" "$@"
230 +#
231 +# but POSIX shell has neither arrays nor command substitution, so instead we
232 +# post-process each arg (as a line of input to sed) to backslash-escape any
233 +# character that might be a shell metacharacter, then use eval to reverse
234 +# that process (while maintaining the separation between arguments), and wrap
235 +# the whole thing up as a single "set" statement.
236 +#
237 +# This will of course break if any of these variables contains a newline or
238 +# an unmatched quote.
239 +#
240 +
241 +eval "set -- $(
242 + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
243 + xargs -n1 |
244 + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
245 + tr '\n' ' '
246 + )" '"$@"'
247 +
248 +exec "$JAVACMD" "$@"
A android/gradlew.bat
+82 -0
@@ -0,0 +1,82 @@
1 +@rem
2 +@rem Copyright 2015 the original author or authors.
3 +@rem
4 +@rem Licensed under the Apache License, Version 2.0 (the "License");
5 +@rem you may not use this file except in compliance with the License.
6 +@rem You may obtain a copy of the License at
7 +@rem
8 +@rem https://www.apache.org/licenses/LICENSE-2.0
9 +@rem
10 +@rem Unless required by applicable law or agreed to in writing, software
11 +@rem distributed under the License is distributed on an "AS IS" BASIS,
12 +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 +@rem See the License for the specific language governing permissions and
14 +@rem limitations under the License.
15 +@rem
16 +@rem SPDX-License-Identifier: Apache-2.0
17 +@rem
18 +
19 +@if "%DEBUG%"=="" @echo off
20 +@rem ##########################################################################
21 +@rem
22 +@rem gradlew startup script for Windows
23 +@rem
24 +@rem ##########################################################################
25 +
26 +@rem Set local scope for the variables, and ensure extensions are enabled
27 +setlocal EnableExtensions
28 +
29 +set DIRNAME=%~dp0
30 +if "%DIRNAME%"=="" set DIRNAME=.
31 +@rem This is normally unused
32 +set APP_BASE_NAME=%~n0
33 +set APP_HOME=%DIRNAME%
34 +
35 +@rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 +
38 +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 +
41 +@rem Find java.exe
42 +if defined JAVA_HOME goto findJavaFromJavaHome
43 +
44 +set JAVA_EXE=java.exe
45 +%JAVA_EXE% -version >NUL 2>&1
46 +if %ERRORLEVEL% equ 0 goto execute
47 +
48 +echo. 1>&2
49 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 +echo. 1>&2
51 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 +echo location of your Java installation. 1>&2
53 +
54 +"%COMSPEC%" /c exit 1
55 +
56 +:findJavaFromJavaHome
57 +set JAVA_HOME=%JAVA_HOME:"=%
58 +set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 +
60 +if exist "%JAVA_EXE%" goto execute
61 +
62 +echo. 1>&2
63 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 +echo. 1>&2
65 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 +echo location of your Java installation. 1>&2
67 +
68 +"%COMSPEC%" /c exit 1
69 +
70 +:execute
71 +@rem Setup the command line
72 +
73 +
74 +
75 +@rem Execute gradlew
76 +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
77 +@rem which allows us to clear the local environment before executing the java command
78 +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
79 +
80 +:exitWithErrorLevel
81 +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
82 +"%COMSPEC%" /c exit %ERRORLEVEL%
A android/settings.gradle.kts
+17 -0
@@ -0,0 +1,17 @@
1 +pluginManagement {
2 + repositories {
3 + google()
4 + mavenCentral()
5 + gradlePluginPortal()
6 + }
7 +}
8 +dependencyResolutionManagement {
9 + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
10 + repositories {
11 + google()
12 + mavenCentral()
13 + maven { url = uri("https://jitpack.io") }
14 + }
15 +}
16 +rootProject.name = "card2vcf"
17 +include(":app")
A design.md
+244 -0
@@ -0,0 +1,244 @@
1 +## Overview
2 +
3 +Wired is the flagship technology-magazine brand under Condé Nast — and the web surface refuses to dress itself as a SaaS marketing site. The page is unmistakably an editorial product: a white canvas, a strict black wordmark in the brand's proprietary `WiredDisplay` (a tall, narrow, high-contrast serif used at 64 px), and stacked story cards that read as a printed magazine grid ported to the screen. There is no atmospheric gradient, no decorative chrome, no chromatic accent — the brand's only colour beyond the black-and-white duet is the small `{colors.link}` (`#057dbc`) used for inline body links inside long-form articles.
4 +
5 +Type carries the entire identity. Three families ladder the system: `WiredDisplay` (the proprietary high-contrast serif) for hero / section headlines; `BreveText` (a humanist serif) for long-form body and bylines; and `Apercu` (a humanist sans) for metadata, captions, eyebrow tags, and buttons. The pairing is editorial-grade: serifs for narrative, sans for navigation and structural labels.
6 +
7 +Buttons are square. The brand uses `{rounded.none}` 0 px corners across the entire UI — newsletter sign-ups, login forms, "Read more" CTAs all render as sharp rectangles. The only circular shape is the `button-icon-circular` used for social-share affordances. There are no soft drop-shadows; the brand uses hairline borders for elevation when needed.
8 +
9 +**Key Characteristics:**
10 +- A strict black-and-white duet with no chromatic accent except the inline link blue `{colors.link}`. The brand reads as a printed magazine.
11 +- Three-face typographic system — `WiredDisplay` serif for display, `BreveText` serif for body, `Apercu` sans for metadata and buttons.
12 +- Square buttons (`{rounded.none}`) — the brand never softens corners on interactive elements.
13 +- A magazine-style story grid: large feature card at top, two-up secondary, then a vertical stack of bylined story rows separated by `{colors.hairline}` 1 px dividers.
14 +- The brand's only signature decorative move is the **masthead band** — a thin black strip with the wordmark centred, no other decoration.
15 +- A near-black `{colors.ink}` (`#000000`) footer band, no graphics, just text columns and the wordmark repeating.
16 +
17 +## Colors
18 +
19 +### Brand & Accent
20 +- **Ink Black** (`{colors.primary}` — `#000000`): The brand's only "accent." Used for wordmark, headlines, CTAs, footer fill. Pure black, never softened.
21 +
22 +### Surface
23 +- **Canvas** (`{colors.canvas}` — `#ffffff`): The default page background.
24 +- **Canvas Soft** (`{colors.canvas-soft}` — `#f5f5f5`): Rare tint used for the comment-section background and search-result row hover states (not in the main page rhythm).
25 +- **Hairline** (`{colors.hairline}` — `#e0e0e0`): 1 px dividers between story rows. The brand's only "line."
26 +
27 +### Text
28 +- **Ink** (`{colors.ink}` — `#000000`): Every headline, every body paragraph in BreveText.
29 +- **Ink Soft** (`{colors.ink-soft}` — `#1a1a1a`): A near-black variant used for caption-strong / footer link emphasis.
30 +- **Body** (`{colors.body}` — `#757575`): Secondary metadata — bylines, timestamps, supporting body lines.
31 +
32 +### Semantic
33 +The brand operates with one inline link colour and no separate error / success / warning palette in its marketing surface. Validation cues on form pages use the ink black + body grey hierarchy.
34 +
35 +- **Link** (`{colors.link}` — `#057dbc`): The inline body-link blue. Used only inside long-form article body copy, never on UI buttons or navigation.
36 +
37 +## Typography
38 +
39 +### Font Family
40 +Three families ladder the system:
41 +1. **WiredDisplay** — the proprietary tall-narrow high-contrast serif used exclusively for display headlines (64 px hero, scaling down to 26 px sub-display). The brand's most-recognisable typographic signature.
42 +2. **BreveText** — the proprietary humanist serif used for long-form body, bylines, and editorial captions. Used at 16 – 19 px line-height 1.45 – 1.50 for comfortable reading density.
43 +3. **Apercu** — a humanist sans used for nav, button labels, category eyebrows, metadata, and captions. Weights 400 / 700.
44 +
45 +Inter is loaded as a fourth fallback face for embedded utility surfaces (the comment section, account pages) but does not appear on the main marketing / article surface.
46 +
47 +### Hierarchy
48 +
49 +| Token | Size | Weight | Line Height | Letter Spacing | Use |
50 +|---|---|---|---|---|---|
51 +| `{typography.display-hero}` | 64px | 400 | 59.5px | -0.5px | Cover-story headline. |
52 +| `{typography.display-lg}` | 48px | 400 | 50.4px | -0.4px | Major section / feature headlines. |
53 +| `{typography.display-md}` | 32px | 400 | 35.2px | -0.3px | Story-card large variant. |
54 +| `{typography.display-sm}` | 26px | 400 | 28px | 0 | Sub-display headings inside long-form articles. |
55 +| `{typography.display-xs}` | 20px | 700 | 24px | -0.28px | Sans (Apercu) display micro-headings for category callouts. |
56 +| `{typography.body-serif-lg}` | 19px | 400 | 27.93px | 0.108px | Lead paragraph of an article (BreveText). |
57 +| `{typography.body-serif-md}` | 16px | 400 | 24px | 0.09px | Default article body (BreveText). |
58 +| `{typography.body-md}` | 17px | 400 | 20px | 0 | Sans body (Apercu) for nav / metadata. |
59 +| `{typography.body-md-strong}` | 17px | 700 | 22px | -0.144px | Bold sans body. |
60 +| `{typography.body-sm}` | 14px | 400 | 18px | 0.4px | Secondary sans body. |
61 +| `{typography.body-sm-strong}` | 14px | 700 | 18px | 0.4px | Bold caption / nav-link. |
62 +| `{typography.byline}` | 12.73px | 700 | 28px | 0.108px | Article byline (BreveText). |
63 +| `{typography.caption}` | 12px | 400 | 16px | 0 | Fine print, image captions. |
64 +| `{typography.button-md}` | 16px | 700 | 20px | 0.3px | Button label. |
65 +
66 +### Principles
67 +- **Serif for narrative, sans for structure.** The serif faces never carry button labels or nav text; the sans face never carries article body.
68 +- **Display weight 400** — the proprietary WiredDisplay reads as elegant by virtue of its thin-tall-narrow design at default weight, not via weight 700+.
69 +- **Bylines use BreveText weight 700 with relaxed line-height 2.2.** The vertical breathing is part of the editorial signature.
70 +
71 +### Note on Font Substitutes
72 +The three proprietary faces have no exact substitutes. Best open-source approximations:
73 +- **WiredDisplay** — *Playfair Display* weight 400 at large display sizes captures the high-contrast didone feel, though wider than the brand's tall-narrow proportions.
74 +- **BreveText** — *Lora* or *Source Serif Pro* at 16 – 19 px.
75 +- **Apercu** — *Inter* or *Manrope* weights 400 / 700.
76 +
77 +## Layout
78 +
79 +### Spacing System
80 +- **Base unit**: 4 px.
81 +- **Tokens**: `{spacing.xxs}` 2 px · `{spacing.xs}` 4 px · `{spacing.sm}` 8 px · `{spacing.md}` 12 px · `{spacing.lg}` 16 px · `{spacing.xl}` 20 px · `{spacing.2xl}` 24 px · `{spacing.3xl}` 32 px · `{spacing.4xl}` 48 px.
82 +- **Section padding**: hero / story grid use `{spacing.4xl}` 48 px top/bottom on desktop.
83 +- **Story row padding**: `{spacing.lg}` 16 px vertical between bylined story rows.
84 +
85 +### Grid & Container
86 +- Marketing content uses a wide container (~1400 px max).
87 +- Cover-story grid: 1 large hero + 2-up secondary stories + vertical stack.
88 +- Story-row stack: full-width single column with hairline dividers.
89 +
90 +### Responsive Strategy
91 +
92 +#### Breakpoints
93 +
94 +| Name | Width | Key Changes |
95 +|---|---|---|
96 +| Mobile | < 768px | Cover hero 64→40 px; all grids 1-up; hamburger nav. |
97 +| Tablet | 768–1023px | 2-up secondary story grid. |
98 +| Desktop | ≥ 1024px | Full magazine grid. |
99 +
100 +#### Touch Targets
101 +Button-primary renders ~44 px tall (12 vertical padding + 20 line). WCAG AAA at all widths.
102 +
103 +#### Collapsing Strategy
104 +- Nav: full link row + Subscribe CTA at desktop. Hamburger at mobile.
105 +- Magazine grid: hero stays full-width; 2-up secondary drops to 1-up at mobile.
106 +- Story rows: stay single-column at all viewports.
107 +
108 +#### Image Behavior
109 +- Cover images: full-bleed 16:9 hero / 4:3 secondary.
110 +- Article body images: full-width inside the article column.
111 +- Author avatars: small inline circular crops next to bylines.
112 +
113 +## Elevation & Depth
114 +
115 +| Level | Treatment | Use |
116 +|---|---|---|
117 +| Level 0 — Flat | No shadow, no border. | Default — almost every surface lives at this level. |
118 +| Level 1 — Hairline | 1 px solid `{colors.hairline}` border. | Story-row dividers, input borders. |
119 +| Level 2 — Heavy Black Border | 2 px solid `{colors.ink}` border. | Subscribe CTA on certain campaign moments. |
120 +
121 +The brand uses no drop-shadows. Surface contrast and hairline borders carry all visual hierarchy.
122 +
123 +## Shapes
124 +
125 +### Border Radius Scale
126 +
127 +| Token | Value | Use |
128 +|---|---|---|
129 +| `{rounded.none}` | 0px | Every interactive shape — buttons, inputs, cards. The brand's signature square geometry. |
130 +| `{rounded.full}` | 9999px | Circular icon containers only (social-share, account avatar). |
131 +
132 +### Photography Geometry
133 +- Cover stories: 16:9 hero, edge-to-edge.
134 +- Secondary story cards: 4:3 thumbnails.
135 +- Article body images: native aspect, full column width.
136 +- Bylines / avatars: circular `{rounded.full}` 28 px crops.
137 +
138 +## Components
139 +
140 +### Buttons
141 +
142 +**`button-primary`** — the square black CTA.
143 +- Background `{colors.primary}`, text `{colors.on-primary}`, label `{typography.button-md}` (Apercu 16 px / 700 / 0.3 px tracking), padding `{spacing.md} {spacing.xl}`, shape `{rounded.none}` 0 px.
144 +
145 +**`button-outline`** — the white outline CTA.
146 +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.ink}` border, same typography / padding / shape.
147 +
148 +**`button-icon-circular`** — the circular share-icon button.
149 +- Background `{colors.canvas}`, ink icon, shape `{rounded.full}`.
150 +
151 +### Cards & Containers
152 +
153 +**`story-card-large`** — the cover-story card with `{typography.display-md}` headline.
154 +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.lg}`. No border — the card lives on the canvas with the photo doing the work.
155 +
156 +**`story-card`** — the secondary story card.
157 +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md}`. Photo at top, sans display heading + body lead below.
158 +
159 +**`story-row`** — the bylined story list row.
160 +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.hairline}` bottom border, body in `{typography.body-md-strong}`, padding `{spacing.lg}` 0.
161 +
162 +### Inputs & Forms
163 +
164 +**`text-input`** — the standard text input.
165 +- Background `{colors.canvas}`, text `{colors.ink}`, 1 px solid `{colors.ink}` border, body in `{typography.body-md}`, padding `{spacing.md} {spacing.lg}`, shape `{rounded.none}`.
166 +
167 +### Navigation
168 +
169 +**`nav-bar`** — the top nav, light by default.
170 +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md} {spacing.xl}`. Layout: hamburger left, masthead centre, Subscribe right.
171 +
172 +**`nav-link`** — link items inside nav.
173 +- Text `{colors.ink}`, set in `{typography.body-sm-strong}` (Apercu 14 / 700).
174 +
175 +**`footer`** — the black footer band.
176 +- Background `{colors.primary}`, text `{colors.on-primary}`, padding `{spacing.4xl} {spacing.xl}`. Body in `{typography.body-sm}` (Apercu 14 / 400). Footer column eyebrows in `{typography.body-sm-strong}`.
177 +
178 +### Signature Components
179 +
180 +**`hero-band`** — the white hero band hosting the cover-story.
181 +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.4xl} {spacing.xl}`. Cover headline in `{typography.display-hero}` (WiredDisplay 64 px).
182 +
183 +**`masthead-band`** — the thin top band with the wordmark.
184 +- Background `{colors.canvas}`, text `{colors.ink}`, padding `{spacing.md} {spacing.xl}`. The wordmark sits centred; flanked by section nav.
185 +
186 +**`category-eyebrow`** — the small uppercase category label above story headlines.
187 +- Text `{colors.ink}`, set in `{typography.body-sm-strong}` (Apercu 14 / 700 — though some campaigns use ALL CAPS via CSS).
188 +
189 +**`byline-row`** — the article byline strip.
190 +- Background `{colors.canvas}`, text `{colors.body}`, body in `{typography.byline}` (BreveText 12.73 / 700 / line-height 2.2). Includes an author avatar + name + date.
191 +
192 +**`hairline-divider`** — the 1 px line between story rows.
193 +- 1 px solid `{colors.hairline}`.
194 +
195 +### Examples (illustrative)
196 +
197 +> Auto-derived kit-mirror demonstration surfaces (`scripts/derive-examples-block.mjs`). Each `ex-*` entry references brand-native primitives so downstream consumers (`/preview-design`, `/generate-kit`) re-skin the same 10 surfaces consistently. `TO_FILL` markers indicate missing primitives — resolve in the LLM judgment pass.
198 +
199 +**`ex-pricing-tier`** — Default Pricing tier card. Re-uses feature-card chrome with brand canvas-soft surface.
200 +- Properties: `backgroundColor`, `textColor`, `borderColor`, `rounded`, `padding`
201 +
202 +**`ex-pricing-tier-featured`** — Featured/highlighted tier — polarity-flipped surface (dark fill + light text in light mode, light fill + dark text in dark mode).
203 +- Properties: `backgroundColor`, `textColor`, `rounded`, `padding`
204 +
205 +**`ex-product-selector`** — What's Included summary card — re-purposed for SaaS / B2B verticals (NOT a literal product gallery).
206 +- Properties: `backgroundColor`, `rounded`, `padding`
207 +
208 +**`ex-cart-drawer`** — Subscription summary — re-purposed for SaaS / B2B (line items per add-on, not literal cart).
209 +- Properties: `backgroundColor`, `rounded`, `padding`, `item-divider`
210 +
211 +**`ex-app-shell-row`** — Sidebar nav row inside the App Shell example. Active state uses brand primary as the indicator.
212 +- Properties: `backgroundColor`, `activeIndicator`, `rounded`, `padding`
213 +
214 +**`ex-data-table-cell`** — Default data-table th + td chrome. Header uses mono-caps eyebrow typography; body uses body-sm.
215 +- Properties: `headerBackground`, `headerTypography`, `bodyTypography`, `cellPadding`, `rowBorder`
216 +
217 +**`ex-auth-form-card`** — Sign-in / sign-up card. Re-uses feature-card chrome with text-input primitives inside.
218 +- Properties: `backgroundColor`, `rounded`, `padding`
219 +
220 +**`ex-modal-card`** — Modal dialog surface — same chrome as feature-card with elevated shadow.
221 +- Properties: `backgroundColor`, `rounded`, `padding`
222 +
223 +**`ex-empty-state-card`** — Empty-state illustration frame.
224 +- Properties: `backgroundColor`, `rounded`, `padding`, `captionTypography`
225 +
226 +**`ex-toast`** — Toast notification surface — feature-card shape + medium shadow.
227 +- Properties: `backgroundColor`, `rounded`, `padding`, `typography`
228 +
229 +
230 +## Do's and Don'ts
231 +
232 +### Do
233 +- Reserve `{colors.primary}` black for the wordmark, every CTA, and the footer fill. The brand IS the strict black-on-white duet.
234 +- Set hero headlines in `{typography.display-hero}` (WiredDisplay 64 px weight 400). The proprietary serif IS the brand's typographic signature.
235 +- Use `{rounded.none}` 0 px on every button and form input. The brand reads as a printed magazine — square corners are non-negotiable.
236 +- Pair WiredDisplay (serif display) with BreveText (serif body) and Apercu (sans labels). Three faces, three roles.
237 +- Render story rows with `{colors.hairline}` 1 px dividers — the brand's only elevation cue.
238 +
239 +### Don't
240 +- Don't introduce a chromatic brand accent. The link blue is reserved for inline body links inside articles only.
241 +- Don't round button corners. The brand never softens its rectangular geometry.
242 +- Don't drop a soft drop-shadow on cards. Surface contrast and hairlines carry elevation.
243 +- Don't substitute the proprietary serif faces with a generic sans for display. The serif voice is the brand.
244 +- Don't promote display weight beyond 400. The brand's elegance is in the typeface design, not bold weight.
A docs/plans/ocr-carte-visite-vcf.md
+2019 -0
@@ -0,0 +1,2019 @@
1 +# Plan technique OCR carte de visite vers VCF
2 +
3 +> **Statut :** plan d'implémentation — itération documentaire
4 +> **Dernière mise à jour :** 2026-07-22
5 +> **Décision retenue :** `CameraX` + `OpenCV` + `Tesseract4Android` + `Luciole-1B`
6 +> **Philosophie :** produit fonctionnel complet — pas de MVP, pas de livraison partielle
7 +
8 +## Sommaire
9 +
10 +1. [Objectif](#objectif)
11 +2. [Philosophie produit](#philosophie-produit--pas-de-mvp)
12 +3. [Contexte du dépôt](#contexte-du-dépôt)
13 +4. [Contrainte principale](#contrainte-principale)
14 +5. [Workflow cible](#workflow-cible-recommandé)
15 +6. [Choix techniques retenus](#choix-techniques-retenus)
16 +7. [Problème qualité OCR](#problème-qualité-ocr-et-correction-géométrique)
17 +8. [Recherche technique des solutions](#recherche-technique-des-solutions-mobiles)
18 +9. [Matrice de décision](#matrice-de-décision-synthétique)
19 +10. [Stratégie qualité OCR](#stratégie-qualité-ocr-pour-luciole)
20 +11. [Interfaces techniques](#interfaces-techniques-cibles)
21 +12. [Tickets commit par commit](#tickets-commit-par-commit)
22 +13. [User stories](#plan-itératif-par-user-stories)
23 +14. [Recommandation finale](#recommandation-finale)
24 +15. [Sources](#sources-et-références)
25 +
26 +## Objectif
27 +
28 +Ajouter dans `Luciole` une fonctionnalité de scan de carte de visite qui reste locale sur le téléphone, réutilise `Luciole-1B` pour la structuration sémantique, puis permet :
29 +
30 +- le préremplissage d'un contact Android
31 +- l'export d'un fichier `.vcf`
32 +
33 +## Philosophie produit — pas de MVP
34 +
35 +Ce plan vise un **produit fonctionnel complet**, pas une démo partielle ni un prototype jetable.
36 +
37 +### Principes directeurs
38 +
39 +1. **Pas de livraison à moitié codée** : chaque brique livrée est réellement implémentée, testée et intégrée.
40 +2. **Pas de raccourcis en production** : aucun moteur factice, aucun `TODO` bloquant dans le chemin utilisateur, aucune fausse sortie contact.
41 +3. **Revue régulière obligatoire** : chaque étape se termine par une **porte de qualité** (tests verts + vérification manuelle) avant de passer à la suivante.
42 +4. **Tests d'abord sur la logique critique** : parseurs, scan, OCR, structuration, intents — pas seulement de l'UI.
43 +5. **Périmètre complet dès le départ** : scan dédié, chat, brouillon, Contacts Android, export VCF, benchmark, documentation.
44 +
45 +### Anti-patterns interdits
46 +
47 +| Interdit | Pourquoi |
48 +|---|---|
49 +| `FakeScanEngine` / `FakeOcrEngine` en prod | masque des lacunes d'implémentation |
50 +| retourner un contact hardcodé « pour tester » | fausse la chaîne complète |
51 +| reporter l'intégration chat « à plus tard » | livre un flux incomplet |
52 +| livrer Contacts sans VCF (ou l'inverse) | fonctionnalité métier inachevée |
53 +| commit `WIP` ou `fix later` | dette immédiate, perte de temps ensuite |
54 +| dépendance Google Play « temporaire » | contredit la contrainte produit |
55 +
56 +**Note tests unitaires :** `MockWebServer` (déjà utilisé dans le dépôt) est autorisé **uniquement** pour tester `CerveauServeur` hors appareil. Ce n'est pas un raccourci produit.
57 +
58 +### Portes de qualité entre étapes
59 +
60 +| Porte | Après tickets | Critère de passage |
61 +|---|---|---|
62 +| **R1** | C01-C08 | contrat `ContactCard` + VCF validés par tests, schéma JSON figé |
63 +| **R2** | C09-C18 | pipeline scan+OCR réel sur corpus, pas de stub, `./gradlew test` vert |
64 +| **R3** | C19-C22 | `extractContact()` fonctionne avec serveur local, non-régression 11 actions |
65 +| **R4** | C23-C30 | parcours UI complet capture → brouillon éditable, testé sur appareil |
66 +| **R5** | C31-C40 | Contacts + VCF + déclenchement chat opérationnels |
67 +| **R6** | C41-C43 | benchmark documenté, erreurs couvertes, README à jour |
68 +
69 +**Règle :** ne pas ouvrir l'étape N+1 tant que la porte R(N) n'est pas validée.
70 +
71 +## Contexte du dépôt
72 +
73 +Après lecture de la structure du projet et du `README.md`, les points suivants sont établis :
74 +
75 +- le modèle actuel est `Luciole-1B` appelé via `llama.cpp` sur `127.0.0.1:8080`
76 +- l'application Android est organisée autour de `cerveau`, `mains`, `model`, `ui`
77 +- le cerveau actuel ne traite que du texte et renvoie un `JSON d'action`
78 +- il n'existe aujourd'hui ni OCR, ni pipeline image, ni `vCard/VCF`, ni insertion de contact Android
79 +
80 +## Arborescence utile
81 +
82 +- `README.md`
83 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/Cerveau.kt`
84 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/CerveauServeur.kt`
85 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/SystemPrompt.kt`
86 +- `android/app/src/main/java/fr/openllm/luciole/model/Action.kt`
87 +- `android/app/src/main/java/fr/openllm/luciole/model/ActionJson.kt`
88 +- `android/app/src/main/java/fr/openllm/luciole/mains/Mains.kt`
89 +- `android/app/src/main/java/fr/openllm/luciole/mains/Contacts.kt`
90 +- `android/app/src/main/java/fr/openllm/luciole/MainActivity.kt`
91 +- `android/app/src/main/java/fr/openllm/luciole/ui/LucioleApp.kt`
92 +- `android/app/src/main/java/fr/openllm/luciole/ui/ChatScreen.kt`
93 +- `android/app/src/main/AndroidManifest.xml`
94 +- `android/app/build.gradle.kts`
95 +- `contract/actions.schema.json`
96 +
97 +## Contrainte principale
98 +
99 +`Luciole-1B` dans ce dépôt est un modèle texte, pas un modèle vision. Il ne peut donc pas faire seul un OCR image réel dans l'application telle qu'elle existe aujourd'hui.
100 +
101 +La conséquence est simple :
102 +
103 +- **non faisable en l'état** : `image -> Luciole seul -> OCR`
104 +- **faisable réalistement** : `image -> moteur OCR local -> texte -> Luciole -> contact structuré`
105 +
106 +## Workflow cible recommandé
107 +
108 +Le workflow cible recommandé est :
109 +
110 +`Image -> scan document local -> correction perspective/rotation -> OCR local -> texte brut -> Luciole -> JSON contact structuré -> écran de validation -> insertion Contacts Android / export VCF`
111 +
112 +### Pourquoi ne pas réutiliser le JSON d'action actuel
113 +
114 +Le `JSON d'action` existant dans le projet sert à représenter des intentions Android fermées, par exemple :
115 +
116 +- `appel`
117 +- `agenda`
118 +- `message`
119 +- `ouvrir`
120 +
121 +Une carte de visite ne produit pas naturellement une action ; elle produit un jeu de données structurées. Il faut donc créer un contrat distinct pour les contacts au lieu de forcer ces données dans `Action.kt`.
122 +
123 +## Contrat métier cible
124 +
125 +Le nouvel objet métier conseillé est un `ContactCard`, avec un JSON cible de ce type :
126 +
127 +```json
128 +{
129 + "full_name": "Jean Dupont",
130 + "first_name": "Jean",
131 + "last_name": "Dupont",
132 + "company": "Acme",
133 + "job_title": "Directeur commercial",
134 + "phones": ["+33612345678"],
135 + "emails": ["jean.dupont@acme.fr"],
136 + "website": "https://acme.fr",
137 + "address": "12 rue Exemple, 75000 Paris",
138 + "note": "Informations ambiguës ou texte résiduel"
139 +}
140 +```
141 +
142 +## Architecture cible
143 +
144 +```mermaid
145 +flowchart TD
146 + user[Utilisateur] --> scanFlow[ScanCarteScreen]
147 + user --> chatFlow[ChatLuciole]
148 + scanFlow --> scanEngine[ScanDocumentLocal]
149 + scanEngine --> correctedImage[ImageCorrigee]
150 + correctedImage --> ocrEngine[OcrLocal]
151 + ocrEngine --> rawText[TexteOCR]
152 + chatFlow --> intentRouter[CerveauAction]
153 + intentRouter --> openScan[ActionScannerCarte]
154 + rawText --> contactPrompt[PromptStructurationContact]
155 + contactPrompt --> luciole[Luciole1B]
156 + luciole --> contactJson[ContactCardJson]
157 + contactJson --> draft[ContactDraft]
158 + draft --> review[ValidationUtilisateur]
159 + review --> contactInsert[InsertionContactsAndroid]
160 + review --> vcfExport[ExportVCF]
161 +```
162 +
163 +## Choix techniques retenus
164 +
165 +Cette section fixe les décisions d'implémentation pour `Luciole`, avec un comparatif par couche afin d'expliquer pourquoi chaque choix a été retenu.
166 +
167 +### Contrainte directrice
168 +
169 +Le plan vise une **indépendance réelle** :
170 +
171 +- Android générique
172 +- sans `Google Play Services`
173 +- sans SDK propriétaire obligatoire
174 +- traitement local sur l'appareil
175 +
176 +Il n'y a donc **pas de variante Android standard** dans ce plan.
177 +
178 +### Pile retenue
179 +
180 +```text
181 +CameraX
182 +-> OpenCvScanEngine
183 +-> TesseractOcrEngine
184 +-> OcrPostProcessor
185 +-> Luciole-1B
186 +-> ContactCard
187 +-> validation utilisateur
188 +-> Contacts Android / export VCF
189 +```
190 +
191 +### Comparatif par couche
192 +
193 +#### 1. Capture caméra
194 +
195 +| Option | Sans Play Services | Open source | Maturité | Complexité | Décision |
196 +|---|---:|---:|---:|---:|---|
197 +| **CameraX** | oui | oui | excellente | moyenne | **retenu** |
198 +| Camera2 API directe | oui | oui | bonne | élevée | rejeté |
199 +| Intent système `IMAGE_CAPTURE` | oui | oui | bonne | faible | rejeté |
200 +
201 +**Pourquoi `CameraX`**
202 +
203 +- API Android moderne et maintenue
204 +- bonne intégration avec Compose et `ActivityResult`
205 +- pas de dépendance Google Play
206 +- plus simple et plus robuste qu'une implémentation Camera2 maison
207 +
208 +**Rejet des alternatives**
209 +
210 +- `Camera2` : plus de code bas niveau, plus fragile
211 +- intent système : moins de contrôle sur la qualité de capture et le flux UX
212 +
213 +#### 2. Scan documentaire / correction géométrique
214 +
215 +| Option | Sans Play Services | Open source | Perspective / deskew | Licence | Maturité | Décision |
216 +|---|---:|---:|---:|---|---:|---|
217 +| **OpenCV custom (`OpenCvScanEngine`)** | oui | oui | oui | Apache 2.0 | bonne | **retenu** |
218 +| `AndroidDocumentScanner` | oui | oui | oui | MIT | moyenne | inspiration / fallback |
219 +| `OpenNoteScanner` dérivé | oui | oui | oui | GPLv3 | bonne | rejeté |
220 +| `trudido-scanner` | oui | oui | oui | GPLv3 | faible | rejeté |
221 +| ML Kit Document Scanner | non | non | oui | propriétaire | excellente | hors cible |
222 +
223 +**Pourquoi `OpenCV` en implémentation maison**
224 +
225 +- seule brique réellement indépendante et maîtrisable
226 +- couvre le besoin critique : détection des bords, homographie, crop, rotation, deskew
227 +- s'intègre proprement derrière `ScanEngine`
228 +- évite une dépendance GPL ou un projet trop jeune
229 +
230 +**Rôle d'`AndroidDocumentScanner`**
231 +
232 +- ne pas l'intégrer directement dans la première itération si `OpenCvScanEngine` converge assez vite
233 +- s'en servir comme **référence d'implémentation** si `OpenCvScanEngine` tarde à converger
234 +- licence MIT plus souple si un jour on veut factoriser la détection de document
235 +
236 +**Rejet des alternatives**
237 +
238 +- `OpenNoteScanner` / `trudido-scanner` : utiles en référence, mais GPL ou maturité insuffisante pour une intégration produit directe
239 +- `ML Kit` : hors cible car dépendant de Google Play Services
240 +
241 +#### 3. OCR
242 +
243 +| Option | Sans Play Services | Open source | Qualité OCR | Taille / perf | Licence | Décision |
244 +|---|---:|---:|---:|---:|---|---|
245 +| **Tesseract4Android** | oui | oui | correcte | bonne | Apache 2.0 | **retenu** |
246 +| PaddleOCR Android | oui | oui | meilleure | plus lourde | Apache 2.0 | plan B |
247 +| OpenCV seul | oui | oui | non | — | — | rejeté |
248 +| ML Kit Text Recognition | non | non | bonne | bonne | propriétaire | hors cible |
249 +
250 +**Pourquoi `Tesseract4Android`**
251 +
252 +- wrapper Android moderne autour de Tesseract 5.x
253 +- licence Apache 2.0
254 +- intégration simple dans une app Kotlin
255 +- suffisant pour des cartes de visite, surtout si l'image est déjà corrigée en amont
256 +- cohérent avec l'architecture `OcrEngine`
257 +
258 +**Pourquoi pas `PaddleOCR` en premier choix**
259 +
260 +- meilleure qualité OCR potentielle
261 +- mais intégration plus lourde
262 +- modèles ONNX plus volumineux
263 +- ne résout pas à lui seul le problème de perspective / angle
264 +
265 +**Plan B**
266 +
267 +- si le benchmark montre que Tesseract est insuffisant sur le corpus réel, remplacer uniquement `TesseractOcrEngine` par `PaddleOcrEngine`
268 +
269 +#### 4. Structuration sémantique
270 +
271 +| Option | Rôle | Décision |
272 +|---|---|---|
273 +| **Luciole-1B** | transformer le texte OCR en `ContactCard` | **retenu** |
274 +| Regex / heuristiques seules | extraire téléphone, e-mail, URL | complément obligatoire |
275 +| `JSON d'action` existant | router des intents Android | rejeté pour ce flux |
276 +
277 +**Pourquoi `Luciole-1B`**
278 +
279 +- déjà présent dans l'application
280 +- bon pour distinguer nom, poste, société, adresse à partir d'un texte bruité
281 +- cohérent avec la promesse produit
282 +
283 +**Pourquoi pas le `JSON d'action` actuel**
284 +
285 +- le contrat actuel sert à piloter des actions Android, pas à représenter une fiche contact
286 +
287 +#### 5. Sortie contact
288 +
289 +| Option | Sans Play Services | Complexité | Décision |
290 +|---|---:|---:|---|
291 +| **Préremplissage `Intent.ACTION_INSERT` Contacts** | oui | faible | **retenu** |
292 +| **Export `.vcf` local** | oui | moyenne | **retenu** |
293 +| Création contact silencieuse via `ContentProvider` | oui | élevée | rejeté (validation utilisateur obligatoire) |
294 +
295 +**Pourquoi ce choix**
296 +
297 +- respecte le modèle de sécurité déjà visible dans `Mains.kt`
298 +- l'utilisateur valide avant insertion
299 +- **Contacts et VCF sont tous deux livrés** dans le même périmètre produit (ordre d'implémentation C31 puis C33, pas de report fonctionnel)
300 +
301 +### Décision finale synthétique
302 +
303 +| Couche | Choix retenu | Alternative écartée | Raison principale |
304 +|---|---|---|---|
305 +| Capture | `CameraX` | Camera2 / intent système | modernité et robustesse |
306 +| Scan / géométrie | `OpenCvScanEngine` | ML Kit, Scanbot, Dynamsoft | indépendance et contrôle |
307 +| OCR | `Tesseract4Android` | PaddleOCR (plan B), ML Kit | simplicité + licence + offline |
308 +| Structuration | `Luciole-1B` | heuristiques seules | sémantique contact |
309 +| Complément | `OcrPostProcessor` + regex | — | fiabiliser tel/email/url |
310 +| Sortie | Contacts puis VCF | insertion silencieuse | validation utilisateur |
311 +
312 +### Dépendances Gradle cibles
313 +
314 +```kotlin
315 +// Capture
316 +implementation("androidx.camera:camera-camera2:1.4.1")
317 +implementation("androidx.camera:camera-lifecycle:1.4.1")
318 +implementation("androidx.camera:camera-view:1.4.1")
319 +
320 +// Vision / scan documentaire
321 +implementation("org.opencv:opencv:4.10.0")
322 +
323 +// OCR
324 +implementation("cz.adaptech.tesseract4android:tesseract4android-openmp:4.9.0")
325 +```
326 +
327 +Notes :
328 +
329 +- les versions exactes seront figées au moment de l'implémentation
330 +- les `traineddata` Tesseract (`fra`, `eng`) seront embarqués ou installés localement par l'app
331 +- aucune dépendance `com.google.android.gms` ni `com.google.mlkit` ne doit être ajoutée
332 +
333 +### Modules Kotlin à créer en priorité
334 +
335 +| Module | Rôle |
336 +|---|---|
337 +| `scan/ScanEngine.kt` | contrat scan documentaire |
338 +| `scan/OpenCvScanEngine.kt` | détection bords, homographie, deskew |
339 +| `ocr/OcrEngine.kt` | contrat OCR |
340 +| `ocr/TesseractOcrEngine.kt` | OCR local |
341 +| `ocr/OcrPostProcessor.kt` | tri des lignes, regex, confiance |
342 +| `contact/ContactCard.kt` | modèle métier |
343 +| `contact/ContactCardJson.kt` | parseur JSON contact |
344 +| `cerveau/ContactPrompt.kt` | prompt Luciole dédié |
345 +| `ui/ScanCarteScreen.kt` | parcours utilisateur |
346 +| `ui/ContactDraftScreen.kt` | validation / correction |
347 +
348 +### Ordre d'implémentation technique
349 +
350 +1. `ContactCard` + `VCardSerializer`
351 +2. `OpenCvScanEngine` avec un corpus d'images de test
352 +3. `TesseractOcrEngine`
353 +4. `OcrPostProcessor`
354 +5. `Cerveau.extractContact()`
355 +6. écran scan + brouillon contact
356 +7. insertion Contacts Android
357 +8. export VCF
358 +9. déclenchement depuis le chat
359 +
360 +## Plan d'implémentation détaillé
361 +
362 +Cette section transforme le plan technique en feuille de route exécutable, alignée sur l'architecture décrite dans le `README.md` :
363 +
364 +- `cerveau` pour l'interprétation
365 +- `mains` pour les sorties déterministes
366 +- `ui` pour le parcours utilisateur
367 +
368 +Principe de mise en oeuvre :
369 +
370 +- construire d'abord le **contrat métier**
371 +- brancher ensuite le **pipeline scan + OCR**
372 +- raccorder après cela le **cerveau**
373 +- finir par la **sortie utilisateur** et l'**intégration chat**
374 +
375 +### Phase 0 - Préparation du chantier
376 +
377 +**But**
378 +
379 +Stabiliser le périmètre avant toute ligne de code applicatif.
380 +
381 +**Travaux**
382 +
383 +1. Valider les dépendances autorisées :
384 + - `CameraX`
385 + - `OpenCV`
386 + - `Tesseract4Android`
387 +2. Préparer un petit corpus local de cartes de visite de test :
388 + - droite
389 + - inclinée
390 + - ombrée
391 + - graphique
392 +3. Fixer le contrat `ContactCard` et le format VCF cible.
393 +4. Lister les nouvelles permissions ou capacités Android réellement nécessaires.
394 +
395 +**Livrables**
396 +
397 +- décision technique gelée
398 +- corpus de test minimal
399 +- contrat JSON contact stabilisé
400 +
401 +**Risque à lever**
402 +
403 +- coût d'intégration natif de `OpenCV`
404 +- stratégie d'embarquement des `traineddata` Tesseract
405 +
406 +### Phase 1 - Fondations métier et contrats
407 +
408 +**Stories couvertes**
409 +
410 +- Story 0
411 +- Story 1
412 +
413 +**Objectif**
414 +
415 +Créer les objets métier et les parseurs avant toute intégration caméra ou OCR.
416 +
417 +**Fichiers à créer**
418 +
419 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactCard.kt`
420 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactCardJson.kt`
421 +- `android/app/src/main/java/fr/openllm/luciole/contact/VCardSerializer.kt`
422 +
423 +**Tests à créer**
424 +
425 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactCardTest.kt`
426 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactCardJsonTest.kt`
427 +- `android/app/src/test/java/fr/openllm/luciole/contact/VCardSerializerTest.kt`
428 +
429 +**Tâches détaillées**
430 +
431 +1. Définir `ContactCard` avec champs simples et listes :
432 + - `fullName`
433 + - `firstName`
434 + - `lastName`
435 + - `company`
436 + - `jobTitle`
437 + - `phones`
438 + - `emails`
439 + - `website`
440 + - `address`
441 + - `note`
442 +2. Définir la politique de champs facultatifs :
443 + - `null` pour l'absence
444 + - listes vides pour multi-valeurs absentes
445 +3. Créer `ContactCardJson` :
446 + - parser tolérant
447 + - normalisation minimale
448 + - rejet propre des JSON invalides
449 +4. Créer `VCardSerializer` :
450 + - `VCARD 3.0`
451 + - échappement des séparateurs
452 + - multi-téléphones / multi-e-mails
453 +
454 +**Critères de validation**
455 +
456 +- le contrat contact est indépendant de `Action.kt`
457 +- le VCF produit est importable par Contacts Android
458 +- les tests couvrent caractères accentués, valeurs partielles et multi-champs
459 +
460 +### Phase 2 - Pipeline image local
461 +
462 +**Stories couvertes**
463 +
464 +- Story 2
465 +
466 +**Objectif**
467 +
468 +Obtenir un texte OCR exploitable localement à partir d'une photo de carte.
469 +
470 +**Fichiers à modifier**
471 +
472 +- `android/app/build.gradle.kts`
473 +- `android/app/src/main/AndroidManifest.xml`
474 +
475 +**Fichiers à créer**
476 +
477 +- `android/app/src/main/java/fr/openllm/luciole/scan/ScanEngine.kt`
478 +- `android/app/src/main/java/fr/openllm/luciole/scan/OpenCvScanEngine.kt`
479 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrEngine.kt`
480 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrResult.kt`
481 +- `android/app/src/main/java/fr/openllm/luciole/ocr/TesseractOcrEngine.kt`
482 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrPostProcessor.kt`
483 +
484 +**Tests à créer**
485 +
486 +- `android/app/src/test/java/fr/openllm/luciole/scan/OpenCvScanEngineTest.kt`
487 +- `android/app/src/test/java/fr/openllm/luciole/ocr/OcrPostProcessingTest.kt`
488 +
489 +**Tâches détaillées**
490 +
491 +1. Ajouter les dépendances Gradle et vérifier leur compatibilité avec `minSdk 31`.
492 +2. Définir `ScanEngine` avec une sortie explicite :
493 + - image corrigée
494 + - angle retenu
495 + - score ou drapeau de confiance
496 +3. Implémenter `OpenCvScanEngine` :
497 + - conversion bitmap/mat
498 + - détection du contour principal
499 + - approximation quadrilatère
500 + - homographie
501 + - rotation / deskew
502 +4. Définir `OcrEngine` et `OcrResult`.
503 +5. Implémenter `TesseractOcrEngine` :
504 + - initialisation moteur
505 + - sélection des langues
506 + - OCR bitmap local
507 +6. Implémenter `OcrPostProcessor` :
508 + - nettoyage des lignes
509 + - extraction regex téléphone / e-mail / URL
510 + - fusion des lignes proches si utile
511 +7. Définir les erreurs de pipeline :
512 + - document non détecté
513 + - OCR vide
514 + - modèles de langue absents
515 +
516 +**Critères de validation**
517 +
518 +- le pipeline fonctionne sans `Google Play Services`
519 +- une carte inclinée est redressée avant OCR
520 +- le texte OCR est récupéré même si la structuration Luciole n'est pas encore branchée
521 +
522 +### Phase 3 - Raccordement au cerveau
523 +
524 +**Stories couvertes**
525 +
526 +- Story 3
527 +
528 +**Objectif**
529 +
530 +Réutiliser `Luciole-1B` pour structurer le texte OCR, sans casser le routage des 11 actions existantes.
531 +
532 +**Fichiers à modifier**
533 +
534 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/Cerveau.kt`
535 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/CerveauServeur.kt`
536 +
537 +**Fichiers à créer**
538 +
539 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/ContactPrompt.kt`
540 +
541 +**Tests à créer**
542 +
543 +- `android/app/src/test/java/fr/openllm/luciole/cerveau/CerveauServeurContactTest.kt`
544 +
545 +**Tâches détaillées**
546 +
547 +1. Ajouter une nouvelle capacité :
548 + - `suggest()` reste inchangée pour le chat courant
549 + - `extractContact(rawText: String)` est ajoutée à côté
550 +2. Écrire un prompt séparé pour la structuration contact :
551 + - sortie JSON stricte
552 + - pas d'invention de champ absent
553 + - conservation du texte ambigu dans `note`
554 +3. Réutiliser la mécanique HTTP existante de `CerveauServeur`.
555 +4. Parser la réponse avec `ContactCardJson`.
556 +5. Définir une stratégie de fallback :
557 + - si JSON invalide, afficher un brouillon vide mais conserver le texte OCR
558 +
559 +**Critères de validation**
560 +
561 +- aucune régression sur le contrat des 11 actions existantes
562 +- le cerveau peut structurer un texte OCR bruité en `ContactCard`
563 +- le texte brut reste disponible si Luciole échoue
564 +
565 +### Phase 4 - Parcours UI dédié
566 +
567 +**Stories couvertes**
568 +
569 +- Story 4
570 +- Story 6
571 +
572 +**Objectif**
573 +
574 +Créer un parcours utilisateur clair, séparé du chat, cohérent avec la navigation actuelle observée dans `LucioleApp.kt`.
575 +
576 +**Fichiers à modifier**
577 +
578 +- `android/app/src/main/java/fr/openllm/luciole/ui/LucioleApp.kt`
579 +- `android/app/src/main/java/fr/openllm/luciole/MainActivity.kt`
580 +- `android/app/src/main/res/values/strings.xml`
581 +- `android/app/src/main/res/values-en/strings.xml`
582 +
583 +**Fichiers à créer**
584 +
585 +- `android/app/src/main/java/fr/openllm/luciole/ui/ScanCarteScreen.kt`
586 +- `android/app/src/main/java/fr/openllm/luciole/ui/ScanCarteViewModel.kt`
587 +- `android/app/src/main/java/fr/openllm/luciole/ui/ContactDraftScreen.kt`
588 +- `android/app/src/main/java/fr/openllm/luciole/ui/ContactDraftFields.kt`
589 +
590 +**Tâches détaillées**
591 +
592 +1. Ajouter un nouvel état d'écran dans `LucioleApp.kt`.
593 +2. Définir le `ScanCarteViewModel` avec états explicites :
594 + - `Idle`
595 + - `Capturing`
596 + - `Scanning`
597 + - `OcrRunning`
598 + - `Structuring`
599 + - `DraftReady`
600 + - `Error`
601 +3. Construire `ScanCarteScreen` :
602 + - capture CameraX
603 + - aperçu de l'image
604 + - relance en cas d'échec
605 +4. Construire `ContactDraftScreen` :
606 + - champs éditables
607 + - texte OCR brut visible
608 + - boutons `Créer le contact` et `Exporter VCF`
609 +5. Définir les messages d'erreur UX :
610 + - carte mal cadrée
611 + - OCR vide
612 + - structuration partielle
613 +
614 +**Critères de validation**
615 +
616 +- le flux complet est compréhensible sans passer par le chat
617 +- l'utilisateur peut corriger avant toute action de sortie
618 +- l'UI reste cohérente avec le style Compose existant
619 +
620 +### Phase 5 - Sorties Android et VCF
621 +
622 +**Stories couvertes**
623 +
624 +- Story 7
625 +
626 +**Objectif**
627 +
628 +Transformer le brouillon validé en action utile pour l'utilisateur.
629 +
630 +**Fichiers à créer**
631 +
632 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactInsertIntent.kt`
633 +- `android/app/src/main/java/fr/openllm/luciole/contact/VcfShare.kt`
634 +
635 +**Fichiers à modifier**
636 +
637 +- `android/app/src/main/java/fr/openllm/luciole/MainActivity.kt`
638 +- `android/app/src/main/AndroidManifest.xml`
639 +
640 +**Tests à créer**
641 +
642 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactInsertIntentTest.kt`
643 +
644 +**Tâches détaillées**
645 +
646 +1. Construire l'intent `Intent.ACTION_INSERT` prérempli.
647 +2. Mapper chaque champ `ContactCard` vers les extras Android pertinents.
648 +3. Générer un fichier `.vcf` temporaire partageable.
649 +4. Définir la stratégie de partage :
650 + - `FileProvider` si nécessaire
651 + - nom de fichier stable et lisible
652 +5. Garantir qu'aucune insertion silencieuse n'est faite (validation utilisateur obligatoire).
653 +
654 +**Critères de validation**
655 +
656 +- le contact Android s'ouvre prérempli
657 +- le VCF est partageable et réimportable
658 +- la validation utilisateur reste obligatoire
659 +
660 +### Phase 6 - Intégration au chat et finitions
661 +
662 +**Stories couvertes**
663 +
664 +- Story 5
665 +- Story 8
666 +
667 +**Objectif**
668 +
669 +Raccorder la nouvelle fonctionnalité à la démo existante sans brouiller la responsabilité du chat.
670 +
671 +**Fichiers à modifier**
672 +
673 +- `contract/actions.schema.json`
674 +- `android/app/src/main/java/fr/openllm/luciole/model/Action.kt`
675 +- `android/app/src/main/java/fr/openllm/luciole/model/ActionJson.kt`
676 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/SystemPrompt.kt`
677 +- `android/app/src/main/java/fr/openllm/luciole/mains/Mains.kt`
678 +- `android/app/src/main/java/fr/openllm/luciole/ui/ChatScreen.kt`
679 +- `README.md`
680 +
681 +**Tâches détaillées**
682 +
683 +1. Ajouter une action `scanner_carte` dans le contrat des actions.
684 +2. Faire en sorte que cette action **ouvre le flux dédié** sans porter les données contact.
685 +3. Ajouter les tests de non-régression sur le parsing d'actions.
686 +4. Mesurer le flux complet sur le corpus :
687 + - succès scan
688 + - précision champs
689 + - temps total
690 +5. Mettre à jour le `README.md` :
691 + - nouvelle fonctionnalité
692 + - limites connues
693 + - dépendances locales
694 +
695 +**Critères de validation**
696 +
697 +- le chat peut déclencher le scan sans devenir un OCR conversationnel
698 +- les 11 actions existantes restent compatibles
699 +- la documentation du dépôt reflète la nouvelle architecture
700 +
701 +### Définition de terminé (produit complet)
702 +
703 +Le chantier est terminé uniquement quand **toutes** les conditions suivantes sont réunies :
704 +
705 +1. une carte de visite peut être photographiée dans l'app
706 +2. l'image est corrigée localement avant OCR (scan réel, pas de placeholder)
707 +3. l'OCR local produit un texte brut exploitable
708 +4. `Luciole-1B` transforme ce texte en `ContactCard`
709 +5. l'utilisateur peut corriger le brouillon avec le texte OCR visible
710 +6. le contact peut être prérempli dans Android **et** exporté en `.vcf`
711 +7. le flux est déclenchable depuis l'écran dédié **et** depuis le chat (`scanner_carte`)
712 +8. le flux reste 100 % local et sans dépendance `Google Play Services`
713 +9. le chat existant ne régresse pas (11 actions + nouvelle action)
714 +10. le benchmark sur corpus est documenté et les cas d'erreur sont couverts par tests
715 +11. `./gradlew :app:testDebugUnitTest` passe sans régression
716 +
717 +## Tickets commit par commit
718 +
719 +Backlog atomique pour l'implémentation. Chaque ticket = **un commit**, **un objectif**, **une vérification**.
720 +
721 +**Tous les tickets C01 à C43 sont obligatoires** pour considérer la fonctionnalité terminée. Aucun n'est optionnel ni reportable.
722 +
723 +Convention de message (alignée sur l'historique du dépôt) :
724 +
725 +```text
726 +feat(scope): description courte
727 +test(scope): ...
728 +docs(scope): ...
729 +build(android): ...
730 +```
731 +
732 +Légende : `verify:` = commande ou contrôle manuel minimal avant de passer au ticket suivant.
733 +
734 +### Phase 0 — Cadrage (Story 0)
735 +
736 +| # | Commit | Fichiers | verify |
737 +|---|---|---|---|
738 +| C01 | `docs(ocr): documenter pipeline OCR local puis structuration Luciole` | `README.md` | le README explique que Luciole ne fait pas l'OCR image seul |
739 +| C02 | `docs(ocr): ajouter contrat JSON ContactCard de reference` | `contract/contact.schema.json` | schéma JSON valide, champs alignés avec le plan |
740 +
741 +### Phase 1 — Contrat contact (Story 1)
742 +
743 +| # | Commit | Fichiers | verify |
744 +|---|---|---|---|
745 +| C03 | `feat(contact): ajouter data class ContactCard` | `contact/ContactCard.kt` | compile |
746 +| C04 | `test(contact): couvrir ContactCard champs partiels et listes` | `ContactCardTest.kt` | `./gradlew :app:testDebugUnitTest --tests '*ContactCardTest'` |
747 +| C05 | `feat(contact): ajouter parseur ContactCardJson` | `contact/ContactCardJson.kt` | compile |
748 +| C06 | `test(contact): couvrir ContactCardJson cas valides et invalides` | `ContactCardJsonTest.kt` | tests verts |
749 +| C07 | `feat(contact): ajouter VCardSerializer VCARD 3.0` | `contact/VCardSerializer.kt` | compile |
750 +| C08 | `test(contact): couvrir VCardSerializer accents et multi-champs` | `VCardSerializerTest.kt` | tests verts, VCF importable manuellement |
751 +
752 +### Phase 2 — Dépendances et permissions (Story 2, début)
753 +
754 +| # | Commit | Fichiers | verify |
755 +|---|---|---|---|
756 +| C09 | `build(android): ajouter dependances CameraX OpenCV Tesseract` | `android/app/build.gradle.kts` | `./gradlew :app:assembleDebug` |
757 +| C10 | `feat(android): declarer permissions camera et stockage scan` | `AndroidManifest.xml` | manifeste valide, pas de GMS/ML Kit |
758 +
759 +### Phase 3 — Scan documentaire (Story 2)
760 +
761 +| # | Commit | Fichiers | verify |
762 +|---|---|---|---|
763 +| C11 | `feat(scan): ajouter interface ScanEngine et ScanResult` | `scan/ScanEngine.kt` | compile |
764 +| C12 | `feat(scan): implementer OpenCvScanEngine detection contour` | `scan/OpenCvScanEngine.kt` | compile |
765 +| C13 | `test(scan): valider redressement sur images de reference` | `OpenCvScanEngineTest.kt`, `src/test/resources/scan/*.jpg` | tests verts sur carte droite + inclinee |
766 +| C14 | `feat(scan): ajouter ajustement manuel des coins` | `OpenCvScanEngine.kt`, `scan/ScanCorners.kt` | image corrigee visible en debug |
767 +
768 +### Phase 4 — OCR local (Story 2)
769 +
770 +| # | Commit | Fichiers | verify |
771 +|---|---|---|---|
772 +| C15 | `feat(ocr): ajouter interface OcrEngine et OcrResult` | `ocr/OcrEngine.kt`, `ocr/OcrResult.kt` | compile |
773 +| C16 | `feat(ocr): implementer TesseractOcrEngine et bootstrap traineddata` | `ocr/TesseractOcrEngine.kt` | OCR texte sur bitmap de test |
774 +| C17 | `feat(ocr): ajouter OcrPostProcessor regex tel email url` | `ocr/OcrPostProcessor.kt` | compile |
775 +| C18 | `test(ocr): couvrir OcrPostProcessor extraction multi-champs` | `OcrPostProcessingTest.kt` | tests verts |
776 +
777 +### Phase 5 — Structuration Luciole (Story 3)
778 +
779 +| # | Commit | Fichiers | verify |
780 +|---|---|---|---|
781 +| C19 | `feat(cerveau): ajouter ContactPrompt structuration contact` | `cerveau/ContactPrompt.kt` | prompt JSON strict, pas d'action melangee |
782 +| C20 | `feat(cerveau): etendre Cerveau avec extractContact` | `cerveau/Cerveau.kt` | interface compile, suggest() inchange |
783 +| C21 | `feat(cerveau): implementer extractContact dans CerveauServeur` | `cerveau/CerveauServeur.kt` | appel HTTP local retourne ContactCard |
784 +| C22 | `test(cerveau): couvrir extractContact avec MockWebServer` | `CerveauServeurContactTest.kt` | tests verts, 11 actions existantes intactes |
785 +
786 +### Phase 6 — UI scan (Story 4)
787 +
788 +| # | Commit | Fichiers | verify |
789 +|---|---|---|---|
790 +| C23 | `feat(ui): ajouter ScanCarteViewModel et machine d etats` | `ui/ScanCarteViewModel.kt` | etats Idle/Scanning/Ocr/Structuring/Draft/Error |
791 +| C24 | `feat(ui): ajouter ScanCarteScreen avec preview CameraX` | `ui/ScanCarteScreen.kt` | capture live sur appareil |
792 +| C25 | `feat(ui): brancher pipeline scan OCR dans ScanCarteViewModel` | `ScanCarteViewModel.kt` | texte OCR affiche apres capture |
793 +| C26 | `feat(ui): ajouter entree navigation Scanner une carte` | `LucioleApp.kt`, `strings.xml`, `strings-en.xml` | ecran accessible depuis l'app |
794 +
795 +### Phase 7 — Brouillon editable (Story 6)
796 +
797 +| # | Commit | Fichiers | verify |
798 +|---|---|---|---|
799 +| C27 | `feat(ui): ajouter ContactDraftFields composables` | `ui/ContactDraftFields.kt` | champs editables nom tel email societe |
800 +| C28 | `feat(ui): ajouter ContactDraftScreen avec texte OCR brut` | `ui/ContactDraftScreen.kt` | correction manuelle possible |
801 +| C29 | `feat(ui): fusionner sortie Luciole et regex dans le brouillon` | `ScanCarteViewModel.kt` | tel/email regex presents meme si LLM partiel |
802 +| C30 | `test(contact): couvrir fusion brouillon OCR regex LLM` | `ContactDraftMergeTest.kt` | tests verts |
803 +
804 +### Phase 8 — Sorties contact (Story 7)
805 +
806 +| # | Commit | Fichiers | verify |
807 +|---|---|---|---|
808 +| C31 | `feat(contact): mapper ContactCard vers Intent ACTION_INSERT` | `contact/ContactInsertIntent.kt` | intent pre-rempli |
809 +| C32 | `test(contact): couvrir extras ContactInsertIntent` | `ContactInsertIntentTest.kt` | tests verts |
810 +| C33 | `feat(contact): ajouter VcfShare generation fichier vcf` | `contact/VcfShare.kt` | fichier .vcf genere |
811 +| C34 | `feat(android): configurer FileProvider pour partage VCF` | `AndroidManifest.xml`, `res/xml/file_paths.xml`, `MainActivity.kt` | partage .vcf vers autre app |
812 +
813 +### Phase 9 — Integration chat (Story 5)
814 +
815 +| # | Commit | Fichiers | verify |
816 +|---|---|---|---|
817 +| C35 | `feat(model): ajouter action scanner_carte au schema` | `contract/actions.schema.json`, `contract/actions.gbnf` | schema + grammaire alignes |
818 +| C36 | `feat(model): propager scanner_carte dans Action et ActionJson` | `model/Action.kt`, `model/ActionJson.kt` | compile |
819 +| C37 | `feat(cerveau): reconnaitre scanner_carte dans SystemPrompt` | `cerveau/SystemPrompt.kt` | prompt route vers action dediee |
820 +| C38 | `feat(mains): router scanner_carte vers ouverture flux scan` | `mains/Mains.kt` | nouvelle Sortie ou signal navigation |
821 +| C39 | `feat(ui): ouvrir ScanCarteScreen depuis le chat` | `ChatScreen.kt`, `MainActivity.kt`, `LucioleApp.kt` | phrase « scanner une carte » ouvre le flux |
822 +| C40 | `test(model): couvrir parsing action scanner_carte` | `ActionScanTest.kt`, `MainsScanTest.kt` | tests verts, non-regression 11 actions |
823 +
824 +### Phase 10 — Qualite et documentation (Story 8)
825 +
826 +| # | Commit | Fichiers | verify |
827 +|---|---|---|---|
828 +| C41 | `test(ocr): ajouter harness benchmark corpus cartes` | `OcrBenchmarkTest.kt`, `src/test/resources/cards/` | metriques scan/OCR mesurables |
829 +| C42 | `test(android): couvrir erreurs pipeline scan OCR vide` | tests cibles scan/ocr/ui | cas echec documentes |
830 +| C43 | `docs(readme): documenter scan carte visite et limites connues` | `README.md` | feature visible, contraintes offline |
831 +
832 +### Étapes de revue (remplace les « jalons démo »)
833 +
834 +| Étape | Tickets | Porte | Livrable validé |
835 +|---|---|---|---|
836 +| **E1 — Contrat** | C01-C08 | R1 | `ContactCard` + VCF + schéma JSON |
837 +| **E2 — Pipeline** | C09-C18 | R2 | scan + OCR réels sur corpus |
838 +| **E3 — Cerveau** | C19-C22 | R3 | structuration Luciole + non-régression |
839 +| **E4 — Parcours UI** | C23-C30 | R4 | capture → brouillon éditable sur appareil |
840 +| **E5 — Sorties** | C31-C34 | — | Contacts Android + export VCF |
841 +| **E6 — Chat** | C35-C40 | R5 | `scanner_carte` + flux complet depuis le chat |
842 +| **E7 — Qualité** | C41-C43 | R6 | benchmark, erreurs, README |
843 +
844 +### Règles de découpage commit
845 +
846 +1. **Un commit = une responsabilité** : pas de mélange parseur + UI + manifest.
847 +2. **Tests dans le commit suivant ou le même** si le commit ajoute une logique testable.
848 +3. **Pas de commit « WIP »** : chaque commit compile et les tests ajoutés passent.
849 +4. **Pas de dépendance Google** dans un commit, même transitoire.
850 +5. **Pas d'implémentation factice** : si une brique n'est pas prête, on ne merge pas le commit UI qui en dépend.
851 +6. Si un ticket dépasse ~200 lignes utiles, le scinder (ex. C12/C14 pour scan).
852 +
853 +### Ordre strict — séquence complète obligatoire
854 +
855 +```text
856 +C01 → C08 → [R1] → C09 → C18 → [R2] → C19 → C22 → [R3]
857 +→ C23 → C30 → [R4] → C31 → C34 → C35 → C40 → [R5] → C41 → C43 → [R6]
858 +```
859 +
860 +Aucune étape ne peut être sautée. L'intégration chat (C35-C40) et la qualité (C41-C43) font partie du produit final, pas d'une phase ultérieure optionnelle.
861 +
862 +## Plan itératif par user stories
863 +
864 +### Story 0 - Cadrer la faisabilité réelle
865 +
866 +**Objectif**
867 +
868 +Verrouiller dans la documentation et dans l'implémentation future que :
869 +
870 +- `Luciole-1B` reste le composant de structuration sémantique
871 +- l'OCR brut est assuré par une brique locale dédiée
872 +- l'OCR image avec `Luciole` seul n'est pas implémentable réalistement dans ce dépôt
873 +
874 +**Fichiers de référence**
875 +
876 +- `README.md`
877 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/CerveauServeur.kt`
878 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/SystemPrompt.kt`
879 +
880 +### Story 1 - Définir le modèle `ContactCard` et le générateur VCF
881 +
882 +**Objectif**
883 +
884 +Créer le contrat de données contact indépendant du contrat `Action`.
885 +
886 +**Fichiers à créer**
887 +
888 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactCard.kt`
889 +- `android/app/src/main/java/fr/openllm/luciole/contact/VCardSerializer.kt`
890 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactCardTest.kt`
891 +- `android/app/src/test/java/fr/openllm/luciole/contact/VCardSerializerTest.kt`
892 +
893 +**Résultat attendu**
894 +
895 +- une structure `ContactCard` réaliste
896 +- un export `VCARD 3.0` robuste
897 +- des tests sur champs partiels, caractères spéciaux et listes de téléphones/e-mails
898 +
899 +### Story 2 - Ajouter un moteur de scan + OCR local derrière des abstractions
900 +
901 +**Objectif**
902 +
903 +Lire le texte d'une carte photographiée sur téléphone, en passant d'abord par une étape de scan document qui corrige angle, perspective et orientation.
904 +
905 +**Décision technique retenue pour le produit**
906 +
907 +- `OpenCvScanEngine` pour la détection des bords, le crop, l'homographie, la rotation et le deskew
908 +- `TesseractOcrEngine` basé sur `Tesseract4Android`
909 +- `CameraX` pour la capture
910 +- aucun composant ne doit dépendre de `Google Play Services`
911 +- format de sortie : image corrigée locale, puis OCR local
912 +
913 +**Fichiers à créer/modifier**
914 +
915 +- `android/app/build.gradle.kts`
916 +- `android/app/src/main/AndroidManifest.xml`
917 +- `android/app/src/main/java/fr/openllm/luciole/scan/ScanEngine.kt`
918 +- `android/app/src/main/java/fr/openllm/luciole/scan/OpenCvScanEngine.kt`
919 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrEngine.kt`
920 +- `android/app/src/main/java/fr/openllm/luciole/ocr/TesseractOcrEngine.kt`
921 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrResult.kt`
922 +- `android/app/src/main/java/fr/openllm/luciole/ocr/OcrPostProcessor.kt`
923 +- `android/app/src/test/java/fr/openllm/luciole/ocr/OcrPostProcessingTest.kt`
924 +- `android/app/src/test/java/fr/openllm/luciole/scan/OpenCvScanEngineTest.kt`
925 +
926 +**Séquence d'implémentation**
927 +
928 +1. Ajouter les dépendances Gradle pour `CameraX`, `OpenCV` et `Tesseract4Android`.
929 +2. Créer `ScanEngine` / `OpenCvScanEngine` pour :
930 + - détecter le quadrilatère de la carte
931 + - permettre un ajustement manuel des coins
932 + - appliquer la correction de perspective
933 + - remettre le texte à l'horizontale
934 +3. Créer `OcrEngine` / `TesseractOcrEngine` qui traite l'image corrigée locale.
935 +4. Créer `OcrPostProcessor` pour trier les lignes, extraire e-mails/téléphones/URLs.
936 +5. Gérer les erreurs :
937 + - contour non détecté → proposer ajustement manuel ou recapture
938 + - OCR vide → proposer rescan
939 + - langue/modèles Tesseract absents → message clair et installation locale contrôlée par l'app
940 +
941 +**Résultat attendu**
942 +
943 +- le texte OCR est obtenu localement à partir d'une image **déjà corrigée**
944 +- la brique scan et la brique OCR sont encapsulées derrière des interfaces remplaçables
945 +- le post-traitement produit un `OcrResult` structuré prêt pour Luciole
946 +- aucune étape ne dépend d'un service Google ou d'un runtime externe propriétaire
947 +
948 +**Critères d'acceptation**
949 +
950 +- une carte prise en angle produit une image redressée avant OCR
951 +- le texte OCR contient au minimum les lignes visibles sur la carte
952 +- les e-mails et numéros sont extraits par regex même si Luciole échoue ensuite
953 +- le pipeline fonctionne sur un Android générique sans `Google Play Services`
954 +
955 +### Story 3 - Réutiliser `Luciole-1B` pour structurer le texte OCR
956 +
957 +**Objectif**
958 +
959 +Transformer le texte OCR en contact structuré.
960 +
961 +**Fichiers à créer/modifier**
962 +
963 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/Cerveau.kt`
964 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/CerveauServeur.kt`
965 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/ContactPrompt.kt`
966 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactCardJson.kt`
967 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactCardJsonTest.kt`
968 +- `android/app/src/test/java/fr/openllm/luciole/cerveau/CerveauServeurContactTest.kt`
969 +
970 +**Résultat attendu**
971 +
972 +- une capacité dédiée du cerveau, par exemple `extractContact(rawText: String)`
973 +- un prompt spécifique de structuration contact
974 +- un parseur JSON séparé de `ActionJson`
975 +
976 +### Story 4 - Créer un écran dédié "Scanner une carte"
977 +
978 +**Objectif**
979 +
980 +Offrir un parcours utilisateur propre, distinct du chat.
981 +
982 +**Fichiers à créer/modifier**
983 +
984 +- `android/app/src/main/java/fr/openllm/luciole/ui/LucioleApp.kt`
985 +- `android/app/src/main/java/fr/openllm/luciole/MainActivity.kt`
986 +- `android/app/src/main/java/fr/openllm/luciole/ui/ScanCarteScreen.kt`
987 +- `android/app/src/main/java/fr/openllm/luciole/ui/ScanCarteViewModel.kt`
988 +- `android/app/src/main/res/values/strings.xml`
989 +- `android/app/src/main/res/values-en/strings.xml`
990 +
991 +**Résultat attendu**
992 +
993 +- un point d'entrée dédié
994 +- une séquence `capture -> OCR -> structuration -> prévisualisation`
995 +
996 +### Story 5 - Ajouter le déclenchement depuis le chat
997 +
998 +**Objectif**
999 +
1000 +Permettre à l'utilisateur de lancer le flux dédié depuis Luciole sans mélanger extraction documentaire et routage d'intention.
1001 +
1002 +**Fichiers à créer/modifier**
1003 +
1004 +- `contract/actions.schema.json`
1005 +- `android/app/src/main/java/fr/openllm/luciole/model/Action.kt`
1006 +- `android/app/src/main/java/fr/openllm/luciole/model/ActionJson.kt`
1007 +- `android/app/src/main/java/fr/openllm/luciole/cerveau/SystemPrompt.kt`
1008 +- `android/app/src/main/java/fr/openllm/luciole/mains/Mains.kt`
1009 +- `android/app/src/main/java/fr/openllm/luciole/ui/ChatScreen.kt`
1010 +- `android/app/src/test/java/fr/openllm/luciole/model/ActionScanTest.kt`
1011 +- `android/app/src/test/java/fr/openllm/luciole/mains/MainsScanTest.kt`
1012 +
1013 +**Résultat attendu**
1014 +
1015 +- une action du type `scanner_carte`
1016 +- cette action ouvre le flux spécialisé sans porter elle-même les données contact
1017 +
1018 +### Story 6 - Ajouter un brouillon éditable de contact
1019 +
1020 +**Objectif**
1021 +
1022 +Éviter toute création erronée de contact à cause d'un OCR imparfait.
1023 +
1024 +**Fichiers à créer/modifier**
1025 +
1026 +- `android/app/src/main/java/fr/openllm/luciole/ui/ContactDraftScreen.kt`
1027 +- `android/app/src/main/java/fr/openllm/luciole/ui/ContactDraftFields.kt`
1028 +- `android/app/src/main/java/fr/openllm/luciole/ui/ScanCarteViewModel.kt`
1029 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactDraftReducerTest.kt`
1030 +
1031 +**Résultat attendu**
1032 +
1033 +- correction manuelle possible
1034 +- affichage du texte OCR brut pour contrôle
1035 +- validation explicite avant toute sortie
1036 +
1037 +### Story 7 - Ajouter l'insertion Android Contacts et l'export VCF
1038 +
1039 +**Objectif**
1040 +
1041 +Finaliser la fonctionnalité métier.
1042 +
1043 +**Fichiers à créer/modifier**
1044 +
1045 +- `android/app/src/main/java/fr/openllm/luciole/contact/ContactInsertIntent.kt`
1046 +- `android/app/src/main/java/fr/openllm/luciole/contact/VcfShare.kt`
1047 +- `android/app/src/main/java/fr/openllm/luciole/MainActivity.kt`
1048 +- `android/app/src/main/AndroidManifest.xml`
1049 +- `android/app/src/test/java/fr/openllm/luciole/contact/ContactInsertIntentTest.kt`
1050 +
1051 +**Résultat attendu**
1052 +
1053 +- préremplissage d'un contact Android
1054 +- export d'un fichier `.vcf` partageable
1055 +
1056 +### Story 8 - Robustesse, tests et documentation
1057 +
1058 +**Objectif**
1059 +
1060 +Valider la qualité du flux complet sans dégrader la démo actuelle.
1061 +
1062 +**Corpus de test qualité OCR**
1063 +
1064 +Constituer un jeu de **15 à 20 cartes réelles ou anonymisées** couvrant :
1065 +
1066 +| Cas | Objectif |
1067 +|---|---|
1068 +| carte droite, bonne lumière | baseline |
1069 +| carte inclinée 20-30° | valider correction perspective |
1070 +| carte inclinée 45°+ | limite du système |
1071 +| ombre sur un coin | valider le nettoyage et les filtres locaux |
1072 +| fond chargé (table bois, tissu) | valider edge detection |
1073 +| police fine | stress OCR |
1074 +| carte très graphique / logo dominant | stress OCR + structuration |
1075 +| deux numéros + un e-mail | valider extraction multi-champs |
1076 +| carte bilingue FR/EN | valider script latin |
1077 +| carte sans e-mail | valider champs partiels |
1078 +| photo floue légère | mesurer dégradation |
1079 +| import galerie (pas capture live) | valider parcours alternatif |
1080 +
1081 +**Métriques à mesurer**
1082 +
1083 +| Métrique | Description |
1084 +|---|---|
1085 +| `scan_success_rate` | % de cartes où le document est détecté et recadré |
1086 +| `ocr_char_accuracy` | comparaison manuelle texte OCR vs carte |
1087 +| `field_precision` | % de champs corrects (nom, tel, email, société) |
1088 +| `field_recall` | % de champs présents sur la carte qui sont retrouvés |
1089 +| `end_to_end_time` | capture -> contact prêt à valider |
1090 +| `user_correction_rate` | % de scans nécessitant édition manuelle |
1091 +
1092 +**Seuils de validation produit**
1093 +
1094 +- `scan_success_rate` ≥ 85 % sur corpus standard
1095 +- `field_precision` ≥ 70 % sur téléphone et e-mail
1096 +- `end_to_end_time` ≤ 12 s sur Pixel récent (hors premier chargement modèles)
1097 +
1098 +Ces seuils sont des **critères de finition**, pas une excuse pour livrer sans chat, sans VCF ou sans tests d'erreur.
1099 +
1100 +**Plan d'escalade si la qualité open source est insuffisante**
1101 +
1102 +1. Mesurer sur le corpus quels cas échouent (angle, ombre, police, fond).
1103 +2. Si échecs principalement géométriques → renforcer `OpenCvScanEngine` ou intégrer une bibliothèque open source de scan plus mature.
1104 +3. Si échecs principalement OCR → comparer `Tesseract4Android` et `PaddleOCR`.
1105 +4. Si échecs principalement sémantiques (nom/poste/société) → améliorer `ContactPrompt` et heuristiques, pas le scanner.
1106 +5. Conserver les interfaces `ScanEngine` / `OcrEngine` pour swap fournisseur sans refonte.
1107 +
1108 +**Points à couvrir**
1109 +
1110 +- carte floue
1111 +- plusieurs numéros
1112 +- plusieurs e-mails
1113 +- angle fort
1114 +- ombre
1115 +- OCR vide
1116 +- texte désordonné
1117 +- sortie Luciole partielle
1118 +- appareil modeste avec CPU limité
1119 +- modèles OCR ou données de langue non installés
1120 +
1121 +**Fichiers à mettre à jour**
1122 +
1123 +- `README.md`
1124 +- `docs/plans/ocr-carte-visite-vcf.md` (résultats benchmark)
1125 +- tests ciblés Android/Kotlin autour des nouveaux parseurs, intents et écrans
1126 +
1127 +## Ordre d'implémentation recommandé
1128 +
1129 +Séquence complète, sans report de périmètre :
1130 +
1131 +1. Story 0 — cadrage
1132 +2. Story 1 — contrat `ContactCard` + VCF
1133 +3. Story 2 — scan + OCR local
1134 +4. Story 3 — structuration Luciole
1135 +5. Story 4 — écran scan dédié
1136 +6. Story 6 — brouillon éditable
1137 +7. Story 7 — Contacts Android + export VCF
1138 +8. Story 5 — déclenchement depuis le chat
1139 +9. Story 8 — benchmark, robustesse, documentation
1140 +
1141 +## Périmètre produit complet
1142 +
1143 +La fonctionnalité n'est **pas livrable** tant que tous les éléments suivants ne sont pas en place :
1144 +
1145 +- scan documentaire avec correction géométrique réelle
1146 +- OCR local Tesseract opérationnel
1147 +- structuration `ContactCard` via Luciole-1B
1148 +- brouillon éditable avec texte OCR visible
1149 +- préremplissage Contacts Android
1150 +- export `.vcf` partageable
1151 +- action chat `scanner_carte`
1152 +- corpus de test + benchmark documenté
1153 +- non-régression sur les 11 actions existantes
1154 +
1155 +Parcours utilisateur cible :
1156 +
1157 +`je scanne (ou je dis « scanner une carte ») → l'app corrige l'image → OCR local → Luciole structure → je corrige → j'insère le contact ou j'exporte le VCF`
1158 +
1159 +## Problème qualité OCR et correction géométrique
1160 +
1161 +### Pourquoi l'angle fausse l'OCR
1162 +
1163 +Sur une carte de visite photographiée en main, l'échec OCR vient rarement du seul moteur de reconnaissance de caractères. Les causes dominantes sont :
1164 +
1165 +1. **perspective** : la carte n'est pas vue de face, les lignes de texte ne sont plus horizontales dans l'image
1166 +2. **rotation** : la photo est prise portrait/paysage sans que le texte soit remis à l'endroit
1167 +3. **recadrage insuffisant** : le fond, la main ou la table polluent la zone analysée
1168 +4. **qualité visuelle** : flou, ombre, reflet, faible contraste, police fine
1169 +
1170 +Conséquence pour `Luciole` : **il ne faut pas envoyer la photo brute directement à l'OCR**. Il faut d'abord produire une image « documentifiée » : redressée, recadrée, orientée correctement, puis seulement extraire le texte.
1171 +
1172 +### Pipeline qualité cible en deux étages
1173 +
1174 +```text
1175 +Étape A — Scan document (géométrie + nettoyage image)
1176 + capture -> détection bords -> correction perspective -> crop -> rotation -> amélioration visuelle
1177 +
1178 +Étape B — OCR texte (reconnaissance caractères)
1179 + image corrigée -> OCR local -> texte brut + blocs/lignes + confiance
1180 +
1181 +Étape C — Structuration sémantique
1182 + texte brut -> Luciole-1B -> ContactCard JSON
1183 +
1184 +Étape D — Validation humaine
1185 + brouillon éditable -> Contacts Android / export VCF
1186 +```
1187 +
1188 +### Exigences qualité minimales
1189 +
1190 +| Exigence | Cible |
1191 +|---|---|
1192 +| Carte inclinée jusqu'à ~30-45° | lecture exploitable après correction |
1193 +| Photo légèrement floue | dégradation acceptable, pas d'échec total |
1194 +| Ombre partielle | texte principal toujours lisible |
1195 +| Plusieurs numéros / e-mails | tous extraits ou au moins le principal |
1196 +| Carte bilingue FR/EN | OCR latin correct |
1197 +| Temps total utilisateur | viser < 8-12 s après capture sur appareil récent |
1198 +
1199 +### Ce que Luciole ne doit pas faire
1200 +
1201 +- corriger la perspective image
1202 +- détecter les bords du document
1203 +- pivoter l'image
1204 +- faire l'OCR caractère par caractère
1205 +
1206 +Luciole intervient **après** l'OCR, pour interpréter le texte : distinguer nom, poste, société, téléphone, e-mail, site, adresse.
1207 +
1208 +## Recherche technique des solutions mobiles
1209 +
1210 +Cette section synthétise une recherche web ciblée sur les solutions Android capables de gérer **angle, recadrage, rotation et deskew** avant OCR, en restant compatibles avec une app **100 % locale** comme `Luciole`.
1211 +
1212 +### Critères d'évaluation retenus
1213 +
1214 +| Critère | Poids | Description |
1215 +|---|---|---|
1216 +| Correction perspective / deskew | élevé | indispensable pour cartes prises en main |
1217 +| Rotation / horizontalité auto | élevé | texte remis à l'endroit avant OCR |
1218 +| OCR on-device | élevé | cohérence avec la promesse souveraineté du projet |
1219 +| Intégration Android/Kotlin/Compose | élevé | fit avec le dépôt actuel |
1220 +| Complexité d'intégration | moyen | temps jusqu'au premier prototype |
1221 +| Coût / licence | moyen | acceptable pour une démo vs produit |
1222 +| Dépendance Play Services | exclue | hors cible |
1223 +| Personnalisation UX | faible | requis pour le produit final |
1224 +
1225 +### Option 1 - ML Kit (Google, hors cible)
1226 +
1227 +**Références**
1228 +
1229 +- [ML Kit Document Scanner](https://developers.google.com/ml-kit/vision/doc-scanner)
1230 +- [ML Kit Document Scanner Android](https://developers.google.com/ml-kit/vision/doc-scanner/android)
1231 +- [ML Kit Text Recognition v2 Android](https://developers.google.com/ml-kit/vision/text-recognition/v2/android)
1232 +- [ML Kit release notes](https://developers.google.com/ml-kit/release-notes)
1233 +
1234 +**Pile recommandée**
1235 +
1236 +```kotlin
1237 +// Scan / correction géométrique
1238 +implementation("com.google.android.gms:play-services-mlkit-document-scanner:16.0.0")
1239 +
1240 +// OCR texte (script latin, on-device)
1241 +implementation("com.google.mlkit:text-recognition:16.0.1")
1242 +// ou variante Play Services :
1243 +// implementation("com.google.android.gms:play-services-mlkit-text-recognition:19.0.1")
1244 +```
1245 +
1246 +**Capacités documentées**
1247 +
1248 +| Fonctionnalité | Support |
1249 +|---|---|
1250 +| Détection automatique du document | oui |
1251 +| Capture automatique | oui |
1252 +| Edge detection | oui |
1253 +| Correction de perspective | oui |
1254 +| Rotation automatique | oui |
1255 +| Crop manuel utilisateur | oui |
1256 +| Filtres / nettoyage image | oui (`SCANNER_MODE_FULL`) |
1257 +| Import galerie | oui (configurable) |
1258 +| Traitement on-device | oui |
1259 +| Permission caméra dans l'app | non requise pour le scanner ML Kit |
1260 +
1261 +**Contraintes techniques**
1262 +
1263 +- `minSdk` Document Scanner : API 21+ (le projet est à 31, OK)
1264 +- RAM minimale : **1,7 Go** — sinon `MlKitException UNSUPPORTED`
1265 +- modèles et UI téléchargés via **Google Play Services** au premier usage
1266 +- impact APK annoncé : ~300 Ko pour le scanner
1267 +- Text Recognition v2 : recommandation Google d'au moins **16x16 px par caractère**
1268 +- Text Recognition v2 : retourne blocs, lignes, éléments, **score de confiance**, orientation
1269 +
1270 +**Modes scanner utiles**
1271 +
1272 +| Mode | Usage |
1273 +|---|---|
1274 +| `SCANNER_MODE_BASE` | crop, rotation, réordonnancement |
1275 +| `SCANNER_MODE_BASE_WITH_FILTER` | + filtres image |
1276 +| `SCANNER_MODE_FULL` | + nettoyage ML (taches, ombres, doigts) — **recommandé** |
1277 +
1278 +**Configuration cible pour Luciole**
1279 +
1280 +```kotlin
1281 +val options = GmsDocumentScannerOptions.Builder()
1282 + .setGalleryImportAllowed(true)
1283 + .setPageLimit(1)
1284 + .setResultFormats(GmsDocumentScannerOptions.RESULT_FORMAT_JPEG)
1285 + .setScannerMode(GmsDocumentScannerOptions.SCANNER_MODE_FULL)
1286 + .build()
1287 +```
1288 +
1289 +Puis, sur le `Uri` JPEG retourné :
1290 +
1291 +```kotlin
1292 +val image = InputImage.fromFilePath(context, uri)
1293 +val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
1294 +recognizer.process(image)
1295 +```
1296 +
1297 +**Forces**
1298 +
1299 +- bon ratio simplicité / qualité pour une intégration Android open source
1300 +- couvre exactement le besoin « angle + crop + rotation » sans réécrire CameraX/OpenCV
1301 +- s'intègre naturellement dans `MainActivity` via Activity Result API
1302 +- OCR et scan restent locaux
1303 +
1304 +**Faiblesses**
1305 +
1306 +- dépendance Google Play Services
1307 +- UI scanner fournie par Google, peu personnalisable
1308 +- qualité OCR perfectible sur polices très stylisées ou cartes très graphiques
1309 +- premier lancement peut nécessiter téléchargement des modèles
1310 +
1311 +**Verdict**
1312 +
1313 +Solution techniquement intéressante, mais hors cible pour ce plan.
1314 +
1315 +### Option 2 - Scanbot SDK (hors cible)
1316 +
1317 +**Références**
1318 +
1319 +- [Android Document Scanner SDK](https://scanbot.io/developer/android-document-scanner-sdk/)
1320 +- [Document Enhancer](https://docs.scanbot.io/android/document-scanner-sdk/document-enhancer/introduction/)
1321 +- [OCR module](https://docs.scanbot.io/android/data-capture-modules/ocr/introduction/)
1322 +- [Pricing](https://docs.scanbot.io/faq/pricing-and-licensing-model/)
1323 +
1324 +**Capacités documentées**
1325 +
1326 +| Fonctionnalité | Support |
1327 +|---|---|
1328 +| Auto-capture | oui |
1329 +| Edge detection | oui |
1330 +| Auto-crop | oui |
1331 +| Document straightening | oui |
1332 +| Correction perspective | oui |
1333 +| Contrôle qualité scan | oui |
1334 +| OCR offline | oui (`OcrEngine`) |
1335 +| 100 % on-device | oui |
1336 +
1337 +**Modèle de licence**
1338 +
1339 +- **forfait annuel fixe**, sans facturation au scan ni à l'utilisateur
1340 +- prix dépend du **nombre d'apps** et du **pack de fonctionnalités**
1341 +- essai gratuit via licence trial
1342 +
1343 +**Forces**
1344 +
1345 +- qualité documentaire très élevée
1346 +- excellent sur photos imparfaites, angles, ombres
1347 +- RTU UI mature
1348 +- bon candidat si la qualité scan devient un argument produit fort
1349 +
1350 +**Faiblesses**
1351 +
1352 +- SDK commercial, devis obligatoire
1353 +- intégration plus lourde
1354 +- dépendance fournisseur
1355 +
1356 +**Verdict**
1357 +
1358 +Solution premium documentée, mais hors cible de décision ici.
1359 +
1360 +### Option 3 - Dynamsoft Capture Vision (hors cible)
1361 +
1362 +**Références**
1363 +
1364 +- [Capture Vision overview](https://www.dynamsoft.com/capture-vision/overview/)
1365 +- [Android document scanner tutorial](https://www.dynamsoft.com/codepool/build-android-document-scanner-app-dynamsoft-capture-vision.html)
1366 +- [Document Normalizer Android guide](https://www.dynamsoft.com/document-normalizer/docs/mobile/programming/android/user-guide.html)
1367 +
1368 +**Capacités documentées**
1369 +
1370 +| Fonctionnalité | Support |
1371 +|---|---|
1372 +| Détection bords temps réel | oui |
1373 +| Normalisation document | oui |
1374 +| Correction perspective | oui |
1375 +| Deskew | oui |
1376 +| Stabilisation multi-frame | oui (`MultiFrameResultCrossFilter`) |
1377 +| Édition manuelle des coins | oui |
1378 +| Traitement on-device | oui |
1379 +
1380 +**Forces**
1381 +
1382 +- contrôle très fin du pipeline de capture
1383 +- robuste sur documents pris en angle
1384 +- bon pour workflows documentaires ambitieux
1385 +- auto-capture stabilisée par comparaison de quads sur plusieurs frames
1386 +
1387 +**Faiblesses**
1388 +
1389 +- commercial
1390 +- courbe d'intégration plus technique
1391 +- surdimensionné pour une simple feature carte de visite
1392 +
1393 +**Verdict**
1394 +
1395 +**Très bon choix pro**, surtout si l'on prévoit d'autres flux documentaires dans `Luciole`.
1396 +
1397 +### Option 4 - OpenCV + Tesseract (open source)
1398 +
1399 +**Références**
1400 +
1401 +- [Automatic Contact Importer from Business Cards (Stanford)](https://stacks.stanford.edu/file/druid:np318ty6250/Sharma_Fujii_Automatic_Contact_Importer.pdf) — pipeline historique OpenCV + Tesseract sur Android
1402 +- [OpenCV deskew tutorial (Dynamsoft)](https://www.dynamsoft.com/codepool/deskew-scanned-document.html)
1403 +
1404 +**Capacités**
1405 +
1406 +| Fonctionnalité | Support |
1407 +|---|---|
1408 +| Détection contour | oui, mais à coder |
1409 +| Homographie / perspective | oui, mais à coder |
1410 +| Deskew | oui, mais fragile |
1411 +| OCR | oui via Tesseract |
1412 +| 100 % open source | oui |
1413 +
1414 +**Forces**
1415 +
1416 +- aucune dépendance commerciale
1417 +- contrôle total
1418 +- bon pour recherche / prototypage académique
1419 +
1420 +**Faiblesses**
1421 +
1422 +- beaucoup de code custom (CameraX + OpenCV + JNI)
1423 +- qualité et maintenance élevées
1424 +- deskew/rotation moins robustes que les SDK documentaires modernes
1425 +- temps d'ingénierie important
1426 +
1427 +**Verdict**
1428 +
1429 +**Non recommandé pour le premier incrément** de `Luciole`, sauf contrainte licence stricte.
1430 +
1431 +## Matrice de décision synthétique
1432 +
1433 +| Solution | Perspective / deskew | Rotation auto | OCR local | Intégration | Coût | Fit Luciole |
1434 +|---|---:|---:|---:|---:|---:|---:|
1435 +| **OpenCV + Tesseract4Android** | bon | oui | oui | élevée | gratuit | **excellent** |
1436 +| **OpenCV + PaddleOCR** | bon | oui | oui | élevée | gratuit | très bon |
1437 +| **AndroidDocumentScanner + Tesseract4Android** | bon | oui | oui | moyenne | gratuit | très bon |
1438 +| **OpenNoteScanner dérivé + OCR** | bon | oui | oui | moyenne | gratuit | moyen |
1439 +
1440 +## Stratégie qualité OCR pour Luciole
1441 +
1442 +### Principe directeur
1443 +
1444 +La qualité finale ne dépend pas d'un seul composant. Elle résulte de la chaîne complète :
1445 +
1446 +`bonne capture -> image corrigée -> OCR fiable -> structuration Luciole -> validation humaine`
1447 +
1448 +### Règles produit à implémenter
1449 +
1450 +1. **Toujours passer par un scan document** avant OCR, jamais OCR sur photo brute si évitable.
1451 +2. **Limiter à 1 page / 1 carte** par scan (`pageLimit = 1`).
1452 +3. **Autoriser l'import galerie**, mais appliquer le même pipeline de correction si possible.
1453 +4. **Afficher le texte OCR brut** à l'utilisateur pour diagnostic.
1454 +5. **Ne jamais créer un contact automatiquement** sans validation.
1455 +6. **Compléter Luciole par des extracteurs déterministes** pour téléphone, e-mail, URL — comme le projet le fait déjà pour les numéros dans `Extraction.kt`.
1456 +
1457 +### Post-traitement OCR recommandé (Kotlin)
1458 +
1459 +Après OCR, avant Luciole :
1460 +
1461 +```kotlin
1462 +data class OcrBlock(
1463 + val text: String,
1464 + val confidence: Float?,
1465 + val boundingBox: Rect?,
1466 +)
1467 +
1468 +data class OcrResult(
1469 + val rawText: String,
1470 + val lines: List<String>,
1471 + val blocks: List<OcrBlock>,
1472 +)
1473 +```
1474 +
1475 +Règles de nettoyage :
1476 +
1477 +- fusionner les lignes dans l'ordre vertical (top -> bottom)
1478 +- supprimer les lignes vides
1479 +- dédupliquer les lignes identiques
1480 +- extraire par regex :
1481 + - e-mails : pattern standard
1482 + - téléphones : réutiliser la logique de `Extraction.extractPhone`
1483 + - sites web : `https?://` ou domaines
1484 +- conserver le texte brut intégral pour Luciole
1485 +
1486 +### Complément déterministe + Luciole
1487 +
1488 +Inspiré du pattern existant `cerveau route -> mains revalident` :
1489 +
1490 +| Champ | Source primaire | Source secondaire |
1491 +|---|---|---|
1492 +| `phones` | regex / heuristiques | Luciole |
1493 +| `emails` | regex | Luciole |
1494 +| `website` | regex URL | Luciole |
1495 +| `full_name` | Luciole | heuristique première ligne |
1496 +| `company` | Luciole | heuristique ligne sans @ ni chiffres |
1497 +| `job_title` | Luciole | — |
1498 +| `address` | Luciole | lignes multi-mots sans @ |
1499 +
1500 +Cela réduit la dépendance à la seule intelligence du SLM 1B.
1501 +
1502 +## Interfaces techniques cibles
1503 +
1504 +Pour permettre le remplacement de fournisseur sans casser le reste du pipeline :
1505 +
1506 +```kotlin
1507 +// android/.../scan/ScanEngine.kt
1508 +interface ScanEngine {
1509 + suspend fun scanDocument(): ScanResult
1510 +}
1511 +
1512 +data class ScanResult(
1513 + val imageUri: Uri,
1514 + val pageCount: Int = 1,
1515 +)
1516 +
1517 +// android/.../ocr/OcrEngine.kt
1518 +interface OcrEngine {
1519 + suspend fun recognize(imageUri: Uri): OcrResult
1520 +}
1521 +
1522 +// android/.../cerveau/Cerveau.kt — extension
1523 +interface Cerveau {
1524 + suspend fun suggest(phrase: String): Action
1525 + suspend fun extractContact(rawText: String): ContactCard
1526 +}
1527 +```
1528 +
1529 +Implémentations prévues :
1530 +
1531 +| Interface | Implémentation retenue | Implémentation alternative |
1532 +|---|---|---|
1533 +| `ScanEngine` | `OpenCvScanEngine` | `AndroidDocumentScannerAdapter`, custom |
1534 +| `OcrEngine` | `TesseractOcrEngine` | `PaddleOcrEngine`, custom |
1535 +| `Cerveau.extractContact` | `CerveauServeur` + `ContactPrompt` | futur `CerveauEmbarqué` |
1536 +
1537 +## Comparatif technique concret des briques OCR mobiles
1538 +
1539 +Le besoin réel ne se limite pas à l'OCR. Il inclut aussi :
1540 +
1541 +- détection des bords
1542 +- correction de perspective
1543 +- recadrage
1544 +- rotation automatique
1545 +- détection de l'horizontalité
1546 +- deskew avant OCR
1547 +
1548 +### Option 1 - ML Kit (hors cible)
1549 +
1550 +**Pile recommandée**
1551 +
1552 +- `ML Kit Document Scanner`
1553 +- `ML Kit Text Recognition`
1554 +
1555 +**Ce que la solution couvre bien**
1556 +
1557 +- détection automatique du document
1558 +- recadrage automatique
1559 +- correction de perspective
1560 +- rotation automatique
1561 +- amélioration visuelle
1562 +- suppression d'ombres et artefacts selon le mode choisi
1563 +- OCR local sur l'image déjà corrigée
1564 +
1565 +**Avantages**
1566 +
1567 +- très bon fit Android natif
1568 +- intégration simple
1569 +- faible quantité de code
1570 +- on-device
1571 +- cohérent avec l'architecture actuelle de `Luciole`
1572 +
1573 +**Inconvénients**
1574 +
1575 +- moins de contrôle fin qu'un SDK spécialisé
1576 +- dépendance à Google Play Services
1577 +- qualité parfois un peu moins maîtrisable qu'un SDK premium
1578 +
1579 +**Verdict**
1580 +
1581 +Solution simple pour Android avec Google, mais hors cible pour ce plan.
1582 +
1583 +### Option 2 - Scanbot SDK (hors cible)
1584 +
1585 +**Ce que la solution couvre bien**
1586 +
1587 +- auto-capture
1588 +- edge detection
1589 +- auto-crop
1590 +- document straightening
1591 +- correction de perspective
1592 +- OCR offline
1593 +- meilleure maîtrise qualité scan
1594 +
1595 +**Avantages**
1596 +
1597 +- excellente qualité documentaire
1598 +- très robuste sur documents photographiés en conditions imparfaites
1599 +- UX scanner mature
1600 +- offline
1601 +
1602 +**Inconvénients**
1603 +
1604 +- SDK commercial
1605 +- coût de licence
1606 +- intégration plus lourde
1607 +
1608 +**Verdict**
1609 +
1610 +Solution premium utile comme benchmark externe, mais hors cible ici.
1611 +
1612 +### Option 3 - Dynamsoft (hors cible)
1613 +
1614 +**Ce que la solution couvre bien**
1615 +
1616 +- détection temps réel des bords
1617 +- normalisation document
1618 +- correction de perspective
1619 +- deskew
1620 +- stabilisation multi-frame
1621 +- contrôle avancé du pipeline
1622 +
1623 +**Avantages**
1624 +
1625 +- très bon niveau technique
1626 +- excellent contrôle de la capture
1627 +- robuste sur scans pris en angle
1628 +- bon pour des workflows documentaires plus ambitieux
1629 +
1630 +**Inconvénients**
1631 +
1632 +- commercial
1633 +- intégration plus technique
1634 +- plus lourd qu'une intégration minimale
1635 +
1636 +**Verdict**
1637 +
1638 +Très bon choix pro si l'objectif va au-delà d'une simple feature OCR carte de visite.
1639 +
1640 +## Recommandation finale
1641 +
1642 +La décision d'implémentation est fixée dans [Choix techniques retenus](#choix-techniques-retenus).
1643 +
1644 +**Résumé en une ligne :** `CameraX` + `OpenCvScanEngine` + `Tesseract4Android` + `Luciole-1B` + validation utilisateur.
1645 +
1646 +### Abstractions à introduire dès le départ
1647 +
1648 +- `ScanEngine` — correction géométrique interchangeable
1649 +- `OcrEngine` — OCR interchangeable (`Tesseract` aujourd'hui, `PaddleOCR` demain si besoin)
1650 +- `ContactCard` — contrat métier séparé du `JSON d'action`
1651 +
1652 +Pipeline stable :
1653 +
1654 +`scan/correction -> OCR -> post-traitement -> Luciole -> ContactCard -> Contacts/VCF`
1655 +
1656 +### Quand changer de brique
1657 +
1658 +| Situation | Action |
1659 +|---|---|
1660 +| Cible générique indépendante | **OpenCV + Tesseract4Android** (choix actuel) |
1661 +| Qualité OCR insuffisante malgré bonne géométrie | remplacer `TesseractOcrEngine` par `PaddleOcrEngine` |
1662 +| `OpenCvScanEngine` trop lent ou fragile | s'inspirer de **AndroidDocumentScanner** (MIT) |
1663 +| Échecs surtout sémantiques (nom/poste) | améliorer `ContactPrompt`, pas le scanner |
1664 +
1665 +## Risques à surveiller
1666 +
1667 +- cartes très stylisées
1668 +- polices fines ou fantaisie
1669 +- photos très inclinées (> 45°)
1670 +- ombres fortes et reflets
1671 +- appareils modestes avec CPU limité
1672 +- qualité variable selon les `traineddata` Tesseract choisis
1673 +- temps global `scan -> OCR -> structuration`
1674 +- qualité de la séparation `nom / poste / société` par `Luciole-1B`
1675 +- confusion chiffres / lettres sur certaines polices (limitation connue OCR généraliste)
1676 +
1677 +## Sources et références
1678 +
1679 +| Ressource | URL |
1680 +|---|---|
1681 +| Tesseract4Android | https://github.com/adaptech-cz/Tesseract4Android/ |
1682 +| Releases Tesseract4Android | https://github.com/adaptech-cz/Tesseract4Android/releases |
1683 +| PaddleOCR Android deployment | https://www.paddleocr.ai/latest/en/version3.x/inference_deployment/cross_platform/android_deployment.html |
1684 +| PaddleOCR Android repo | https://github.com/PaddlePaddle/PaddleOCR/tree/main/deploy/ppocr-android |
1685 +| paddleocr4android | https://github.com/equationl/paddleocr4android |
1686 +| AndroidDocumentScanner | https://github.com/mayuce/AndroidDocumentScanner |
1687 +| OpenNoteScanner | https://github.com/allgood/OpenNoteScanner |
1688 +| OSS-DocumentScanner | https://github.com/ossappscollective/OSS-DocumentScanner/ |
1689 +| trudido-scanner | https://github.com/dominikmuellr/trudido-scanner |
1690 +| OpenCV deskew (référence technique) | https://www.dynamsoft.com/codepool/deskew-scanned-document.html |
1691 +| Business card OCR OpenCV+Tesseract (académique) | https://stacks.stanford.edu/file/druid:np318ty6250/Sharma_Fujii_Automatic_Contact_Importer.pdf |
1692 +
1693 +## Recherche technique des solutions mobiles sans Google Play
1694 +
1695 +### Nouveau cadrage
1696 +
1697 +`Luciole` doit fonctionner sur des appareils Android **sans Google Play Services**.
1698 +
1699 +Conséquence :
1700 +
1701 +- il n'y a **pas de variante Android standard** dans ce plan
1702 +- la **cible unique** repose sur des composants **open source** ou intégrables sans dépendance Play Services
1703 +
1704 +Dans ce cadrage, il faut séparer très explicitement :
1705 +
1706 +1. **scan documentaire / correction géométrique**
1707 +2. **OCR**
1708 +3. **structuration sémantique par Luciole**
1709 +
1710 +### Critères spécifiques "sans Google Play"
1711 +
1712 +| Critère | Importance | Commentaire |
1713 +|---|---|---|
1714 +| Fonctionne sans Play Services | critique | exigence principale |
1715 +| Open source ou source intégrable | critique | pour Android générique |
1716 +| Correction perspective / recadrage | critique | l'angle fausse l'OCR |
1717 +| Rotation / deskew | critique | horizontalité du texte |
1718 +| OCR offline | critique | cohérence avec la promesse locale |
1719 +| Intégration Kotlin/Android | élevée | fit avec le dépôt actuel |
1720 +| Maturité / maintenance | élevée | éviter une stack morte |
1721 +| Complexité d'intégration | moyenne | acceptable si gain réel |
1722 +| Licence compatible produit | élevée | attention GPL vs MIT/Apache |
1723 +
1724 +### Option A - OpenCV + Tesseract4Android
1725 +
1726 +**Références**
1727 +
1728 +- [Tesseract4Android](https://github.com/adaptech-cz/Tesseract4Android/)
1729 +- [Releases Tesseract4Android](https://github.com/adaptech-cz/Tesseract4Android/releases)
1730 +
1731 +**Architecture**
1732 +
1733 +```text
1734 +CameraX -> photo
1735 +-> OpenCV (détection quadrilatère, crop, homographie, deskew, rotation)
1736 +-> bitmap corrigé
1737 +-> Tesseract4Android
1738 +-> texte OCR
1739 +-> Luciole-1B
1740 +-> ContactCard
1741 +```
1742 +
1743 +**Ce que couvre la stack**
1744 +
1745 +| Fonctionnalité | Support |
1746 +|---|---|
1747 +| OCR offline | oui |
1748 +| Sans Google Play | oui |
1749 +| Open source | oui |
1750 +| Tesseract moderne Android | oui |
1751 +| Perspective correction | oui, mais à développer avec OpenCV |
1752 +| Deskew / rotation | oui, mais à développer avec OpenCV |
1753 +
1754 +**Points techniques utiles**
1755 +
1756 +- `Tesseract4Android` est sous **Apache 2.0**
1757 +- wrapper JNI moderne autour de **Tesseract 5.5.1**
1758 +- variantes standard et `openmp`
1759 +- intégration Android actuelle via dépendance :
1760 +
1761 +```gradle
1762 +implementation("cz.adaptech.tesseract4android:tesseract4android:4.9.0")
1763 +// ou
1764 +implementation("cz.adaptech.tesseract4android:tesseract4android-openmp:4.9.0")
1765 +```
1766 +
1767 +**Avantages**
1768 +
1769 +- véritable stack sans Google Play
1770 +- OCR open source mature
1771 +- bon contrôle sur la chaîne complète
1772 +- licence globalement favorable côté OCR
1773 +
1774 +**Inconvénients**
1775 +
1776 +- la partie la plus difficile reste à votre charge : détection de la carte, correction de perspective, deskew
1777 +- Tesseract seul n'est pas la meilleure solution sur cartes très graphiques ou polices fines
1778 +- beaucoup plus d'ingénierie qu'une solution managée
1779 +
1780 +**Verdict**
1781 +
1782 +C'est la **base open source la plus réaliste** si l'objectif est Android générique sans Google Play.
1783 +
1784 +### Option B - PaddleOCR Android
1785 +
1786 +**Références**
1787 +
1788 +- [PaddleOCR Android deployment](https://www.paddleocr.ai/latest/en/version3.x/inference_deployment/cross_platform/android_deployment.html)
1789 +- [PaddleOCR Android repo](https://github.com/PaddlePaddle/PaddleOCR/tree/main/deploy/ppocr-android)
1790 +- [paddleocr4android](https://github.com/equationl/paddleocr4android)
1791 +
1792 +**Architecture**
1793 +
1794 +```text
1795 +CameraX / import image
1796 +-> correction géométrique (OpenCV ou composant scanner dédié)
1797 +-> PaddleOCR Android SDK
1798 +-> texte OCR
1799 +-> Luciole-1B
1800 +-> ContactCard
1801 +```
1802 +
1803 +**Ce que couvre la stack**
1804 +
1805 +| Fonctionnalité | Support |
1806 +|---|---|
1807 +| OCR offline | oui |
1808 +| Sans Google Play | oui |
1809 +| Open source | oui |
1810 +| Détection + reconnaissance end-to-end | oui |
1811 +| Perspective correction | partiellement, souvent à compléter |
1812 +| Deskew / scan doc | pas la brique principale |
1813 +
1814 +**Points techniques utiles**
1815 +
1816 +- démo Android officielle `PP-OCRv6`
1817 +- architecture `SDK + Demo`
1818 +- déploiement Android basé sur **ONNX Runtime**
1819 +- `minSdk 26`
1820 +- dépendances supplémentaires typiques :
1821 +
1822 +```gradle
1823 +implementation(files("libs/ppocr-sdk-release.aar"))
1824 +implementation("com.microsoft.onnxruntime:onnxruntime-android:1.21.1")
1825 +implementation("com.quickbirdstudios:opencv:4.5.3")
1826 +```
1827 +
1828 +**Avantages**
1829 +
1830 +- OCR open source plus moderne que Tesseract dans beaucoup de cas
1831 +- pipeline OCR mobile plus ambitieux
1832 +- pas de dépendance Play Services
1833 +
1834 +**Inconvénients**
1835 +
1836 +- intégration plus lourde
1837 +- tailles de modèles et dépendances plus conséquentes
1838 +- pour la carte de visite, il faut souvent tout de même une vraie brique de scan géométrique en amont
1839 +- `minSdk 26`, alors que le projet actuel supporte `31`, donc compatible ici, mais moins universel
1840 +
1841 +**Verdict**
1842 +
1843 +Très intéressant si la **qualité OCR pure** devient prioritaire, mais pas suffisant seul pour régler le problème d'angle. Il faut souvent l'associer à OpenCV.
1844 +
1845 +### Option C - Bibliothèques / projets open source de scan documentaire Android
1846 +
1847 +**Références**
1848 +
1849 +- [OpenNoteScanner](https://github.com/allgood/OpenNoteScanner)
1850 +- [AndroidDocumentScanner](https://github.com/mayuce/AndroidDocumentScanner)
1851 +- [OSS-DocumentScanner](https://github.com/ossappscollective/OSS-DocumentScanner/)
1852 +- [trudido-scanner](https://github.com/dominikmuellr/trudido-scanner)
1853 +
1854 +Ces projets sont utiles non seulement comme bibliothèque potentielle, mais aussi comme **référence d'implémentation** pour la détection des bords, le crop manuel et la correction de perspective.
1855 +
1856 +#### C1 - OpenNoteScanner
1857 +
1858 +**Caractéristiques**
1859 +
1860 +- projet Android open source historique
1861 +- scan documentaire avec OpenCV
1862 +- détection de bords
1863 +- correction de perspective
1864 +- export image/PDF
1865 +- disponible aussi via F-Droid
1866 +
1867 +**Attention**
1868 +
1869 +- historique de dépendance à `OpenCV Manager`
1870 +- licence **GPLv3**
1871 +
1872 +**Verdict**
1873 +
1874 +Très utile comme **référence technique**, mais moins idéale comme dépendance directe dans une app si la contrainte GPL est problématique.
1875 +
1876 +#### C2 - AndroidDocumentScanner
1877 +
1878 +**Caractéristiques**
1879 +
1880 +- bibliothèque Android basée sur OpenCV
1881 +- licence **MIT**
1882 +- objectif explicite : scanner des documents façon CamScanner
1883 +
1884 +**Verdict**
1885 +
1886 +Candidat plus simple que OpenNoteScanner pour réutiliser une brique de scan open source avec licence plus souple.
1887 +
1888 +#### C3 - trudido-scanner
1889 +
1890 +**Caractéristiques**
1891 +
1892 +- bibliothèque Android récente
1893 +- `CameraX + OpenCV + C++17/JNI`
1894 +- sans Play Services
1895 +- détection de coins de document
1896 +- crop interactif
1897 +- module AAR réutilisable
1898 +
1899 +**Attention**
1900 +
1901 +- projet très récent
1902 +- peu de recul / peu d'adoption
1903 +- licence **GPLv3**
1904 +
1905 +**Verdict**
1906 +
1907 +Intéressant comme inspiration moderne, mais maturité encore faible.
1908 +
1909 +#### C4 - OSS-DocumentScanner
1910 +
1911 +**Caractéristiques**
1912 +
1913 +- projet actif
1914 +- licence **MIT**
1915 +- utilise OpenCV et Tesseract
1916 +- orienté application plus que petite bibliothèque Android native pure
1917 +
1918 +**Verdict**
1919 +
1920 +Très utile comme base d'étude pour le pipeline document + OCR offline, mais probablement trop gros pour être intégré tel quel.
1921 +
1922 +### Matrice comparative "sans Google Play"
1923 +
1924 +| Solution | Sans Play | Open source | Scan géométrique | OCR | Licence | Complexité | Recommandation |
1925 +|---|---:|---:|---:|---:|---:|---:|---|
1926 +| **OpenCV + Tesseract4Android** | oui | oui | à coder | oui | Apache 2.0 + dépendances | élevée | **base recommandée** |
1927 +| **OpenCV + PaddleOCR** | oui | oui | à coder | oui | open source | élevée | très bon si priorité OCR |
1928 +| **AndroidDocumentScanner + Tesseract4Android** | oui | oui | oui | oui | MIT + Apache | moyenne | **très bon compromis** |
1929 +| **OpenNoteScanner + Tesseract** | oui | oui | oui | possible | GPLv3 | moyenne | bon pour étude, moins bon pour intégration produit |
1930 +| **trudido-scanner + Tesseract/PaddleOCR** | oui | oui | oui | à ajouter | GPLv3 | moyenne/élevée | prometteur mais immature |
1931 +
1932 +## Décision révisée pour Android générique
1933 +
1934 +Si la contrainte devient :
1935 +
1936 +> « fonctionner sans Google Play, sur Android générique, avec des composants open source »
1937 +
1938 +alors la recommandation change.
1939 +
1940 +### Nouvelle recommandation cible
1941 +
1942 +#### Recommandation 1 - compromis le plus réaliste
1943 +
1944 +```text
1945 +CameraX
1946 +-> ScanEngine basé sur OpenCV / bibliothèque open source de scan
1947 +-> OcrEngine basé sur Tesseract4Android
1948 +-> OcrPostProcessor
1949 +-> Luciole-1B
1950 +-> ContactCard
1951 +```
1952 +
1953 +Option concrète à étudier en priorité :
1954 +
1955 +- `AndroidDocumentScanner` ou implémentation OpenCV maison pour le scan
1956 +- `Tesseract4Android` pour l'OCR
1957 +
1958 +#### Recommandation 2 - si la qualité OCR brute est prioritaire
1959 +
1960 +```text
1961 +CameraX
1962 +-> OpenCV / scan doc
1963 +-> PaddleOCR Android
1964 +-> OcrPostProcessor
1965 +-> Luciole-1B
1966 +-> ContactCard
1967 +```
1968 +
1969 +### Pourquoi cette révision
1970 +
1971 +- les solutions dépendantes de Google Play ne correspondent pas à la cible
1972 +- le problème principal reste la **géométrie de l'image**
1973 +- il faut une stack qui garde la maîtrise sur :
1974 + - perspective
1975 + - rotation
1976 + - crop
1977 + - deskew
1978 + - OCR offline
1979 +
1980 +## Impact sur le plan d'implémentation
1981 +
1982 +### Story 2 révisée pour la cible unique Android générique
1983 +
1984 +La story 2 doit prévoir une **seule pile fournisseur** :
1985 +
1986 +- `OpenCvScanEngine`
1987 +- `TesseractOcrEngine`
1988 +
1989 +avec possibilité ultérieure de remplacer uniquement `TesseractOcrEngine` par `PaddleOcrEngine` si les mesures le justifient.
1990 +
1991 +### Interfaces à figer impérativement
1992 +
1993 +```kotlin
1994 +interface ScanEngine {
1995 + suspend fun scanDocument(): ScanResult
1996 +}
1997 +
1998 +interface OcrEngine {
1999 + suspend fun recognize(imageUri: Uri): OcrResult
2000 +}
2001 +```
2002 +
2003 +C'est ce découplage qui permettra de garder le reste du plan inchangé :
2004 +
2005 +`scan -> OCR -> Luciole -> ContactCard -> Contacts/VCF`
2006 +
2007 +## Conclusion
2008 +
2009 +Le plan réaliste pour `Luciole` est :
2010 +
2011 +1. **scanner et corriger l'image localement** (`CameraX` + `OpenCvScanEngine`)
2012 +2. **effectuer l'OCR localement** sur l'image corrigée (`Tesseract4Android`)
2013 +3. **post-traiter** le texte (regex + heuristiques, sur le modèle de `Extraction.kt`)
2014 +4. **utiliser `Luciole-1B`** pour structurer intelligemment le texte en `ContactCard`
2015 +5. **faire valider** le résultat par l'utilisateur avant insertion contact ou export VCF
2016 +
2017 +Ce choix respecte à la fois l'architecture actuelle du dépôt, les limites réelles du modèle, le besoin de qualité OCR sur photos en angle, et l'objectif produit demandé.
2018 +
2019 +La qualité ne viendra pas d'un seul composant : elle dépendra surtout de la **correction géométrique avant OCR**, puis de la **validation humaine** et du **complément déterministe** autour de Luciole.
A docs/plans/ocr_standalone_app_7aad0922.plan.md
+150 -0
@@ -0,0 +1,150 @@
1 +---
2 +name: Card2vcf OCR standalone
3 +overview: "App Android Card2vcf dans EBII_mobileVCF : scan carte → OCR local (7 langues) → ContactCard heuristique → Contacts + VCF, sans LLM. Code inspiré de luciole-mobile (AGPL)."
4 +todos:
5 + - id: s00-repo-plan
6 + content: Plan versionné + décisions figées Card2vcf
7 + status: completed
8 + - id: s01-scaffold
9 + content: Scaffold Android Compose minSdk 31, package fr.ebii.card2vcf
10 + status: pending
11 + - id: s02-deps
12 + content: "Gradle: CameraX, OpenCV, Tesseract JitPack, exif + fonts TTF + tessdata 7 langues"
13 + status: pending
14 + - id: s03-contact
15 + content: Migrer ContactCard, VCard, Insert, Share + tests
16 + status: pending
17 + - id: s04-scan
18 + content: Migrer ImageOrientation + OpenCvScanEngine + tests EXIF
19 + status: pending
20 + - id: s05-ocr
21 + content: Migrer Tesseract/Orientation/PostProcessor + tessdata 7 langues
22 + status: pending
23 + - id: s06-heuristics
24 + content: Heuristiques + DraftMerge(null) + tests dimo
25 + status: pending
26 + - id: s07-ui
27 + content: ScanCarteViewModel sans LLM + thème design.md + icon.png
28 + status: pending
29 + - id: s08-manifest
30 + content: Manifest CAMERA + FileProvider + LICENSE AGPL
31 + status: pending
32 + - id: s09-tests
33 + content: Unit + instrumented corpus dimo verts
34 + status: pending
35 + - id: s10-readme
36 + content: "README Card2vcf: usage, tessdata, limites sans LLM"
37 + status: pending
38 +isProject: true
39 +---
40 +
41 +# Plan Card2vcf (OCR standalone sans LLM)
42 +
43 +## Décisions figées
44 +
45 +- **Dépôt cible** : `/home/monsieurb/Documents/devel/EBII_mobileVCF` (pas un dépôt `luciole-ocr` séparé).
46 +- **Nom produit** : **Card2vcf**.
47 +- **Licence** : AGPL v3 (remote Git géré par le mainteneur).
48 +- **Package / applicationId** : `fr.ebii.card2vcf`.
49 +- **Périmètre produit** : Capture → EXIF → OpenCV → Tesseract → heuristiques `ContactCard` → brouillon éditable → `ACTION_INSERT` Contacts **et** export `.vcf`.
50 +- **Zéro LLM** : aucun `cerveau`, aucun OkHttp, aucune grammaire GBNF.
51 +- **minSdk** : 31 (Android 12+), compile/target 35. Contrainte produit hors-GMS / Android 14+ pour l’usage cible ; minSdk aligné luciole-mobile pour réutiliser le code validé.
52 +- **UI** : `design.md` (N&B éditorial, coins carrés, hairlines) + `icon.png` comme launcher.
53 +- **Polices** : TTF **embarquées** localement — Playfair Display (display), Lora (body serif), Manrope (sans UI).
54 +- **OCR langues** : `fra+deu+eng+spa+por+ita+pol` (7 `traineddata`).
55 +- **État UI** : conserver l’étape **Structuring** (« Analyse du contact… ») même sans LLM.
56 +- **Source code** : extraire / adapter depuis [luciole-mobile](file:///home/monsieurb/Documents/devel/luciole-mobile) (open source), packages renommés vers `fr.ebii.card2vcf`.
57 +
58 +## Workflow cible
59 +
60 +```mermaid
61 +flowchart LR
62 + cam[CameraX] --> exif[ImageOrientation]
63 + exif --> scan[OpenCvScanEngine]
64 + scan --> tess[TesseractOcrEngine]
65 + tess --> orient[OcrOrientation]
66 + orient --> post[OcrPostProcessor]
67 + post --> heur[ContactHeuristicParser]
68 + heur --> merge[ContactDraftMerge_null]
69 + merge --> draft[ContactDraftScreen]
70 + draft --> insert[ContactInsertIntent]
71 + draft --> vcf[VcfShare]
72 +```
73 +
74 +Remplacement Luciole :
75 +
76 +```kotlin
77 +val merged = ContactDraftMerge.merge(null, ocr)
78 +```
79 +
80 +## Arborescence cible
81 +
82 +```text
83 +EBII_mobileVCF/
84 +├── README.md
85 +├── LICENSE # AGPL v3
86 +├── AGENTS.md
87 +├── design.md
88 +├── icon.png
89 +├── docs/plans/…
90 +└── android/
91 + ├── settings.gradle.kts # + jitpack
92 + ├── build.gradle.kts
93 + └── app/
94 + ├── build.gradle.kts
95 + └── src/main/
96 + ├── AndroidManifest.xml
97 + ├── java/fr/ebii/card2vcf/
98 + │ ├── MainActivity.kt
99 + │ ├── contact/
100 + │ ├── scan/
101 + │ ├── ocr/
102 + │ └── ui/ (+ theme design.md)
103 + ├── assets/tesseract/tessdata/{fra,deu,eng,spa,por,ita,pol}.traineddata
104 + └── res/
105 + ├── font/{playfair_*,lora_*,manrope_*}
106 + ├── mipmap/ (depuis icon.png)
107 + ├── xml/file_paths.xml
108 + └── values/{strings,colors,themes}
109 +```
110 +
111 +## Exclusions strictes
112 +
113 +- Tout `cerveau/`, OkHttp, ZXing, GMS, ML Kit, kotlinx-serialization (sauf besoin réel).
114 +- `ContactCardJson.kt` exclu par défaut.
115 +
116 +## Gradle (app)
117 +
118 +```kotlin
119 +namespace = "fr.ebii.card2vcf"
120 +applicationId = "fr.ebii.card2vcf"
121 +minSdk = 31
122 +// CameraX 1.4.1, OpenCV 4.10.0, Tesseract4Android-openmp 4.9.0, exifinterface 1.3.7
123 +// PAS okhttp, PAS serialization obligatoire
124 +```
125 +
126 +## Tickets commit atomiques
127 +
128 +| # | Commit | Vérification |
129 +|---|---|---|
130 +| S00 | `docs(plan): Card2vcf OCR standalone sans LLM` | plan à jour |
131 +| S01 | `chore: scaffold Android Kotlin Compose Card2vcf` | assembleDebug |
132 +| S02 | `build: CameraX OpenCV Tesseract fonts tessdata` | resolve deps |
133 +| S03 | `feat(contact): ContactCard + VCard + Insert + Share` | tests contact |
134 +| S04 | `feat(scan): ImageOrientation + OpenCvScanEngine` | tests EXIF |
135 +| S05 | `feat(ocr): Tesseract 7 langues + PostProcessor` | OcrPostProcessingTest |
136 +| S06 | `feat(contact): HeuristicParser + DraftMerge(null)` | tests dimo |
137 +| S07 | `feat(ui): Scan + Draft thème design.md` | assemble + smoke |
138 +| S08 | `feat(android): Manifest CAMERA + FileProvider + icône` | install APK |
139 +| S09 | `test: corpus dimo unit + instrumented` | tests verts |
140 +| S10 | `docs(readme): Card2vcf usage, tessdata, limites` | README |
141 +
142 +## Critères d’acceptation
143 +
144 +1. Photo carte (EXIF 6 type dimo) → brouillon email + ≥1 téléphone sans crash.
145 +2. `ContactDraftMerge.merge(null, ocr)` remplit nom/poste/société/adresse sur fixture dimo.
146 +3. « Créer le contact » → Contacts prérempli ; « Exporter VCF » → `.vcf` partageable.
147 +4. Aucune dépendance cerveau / OkHttp / GMS / ML Kit.
148 +5. Tests unitaires verts ; instrumented dimo si appareil dispo.
149 +6. Offline total (pas de permission INTERNET).
150 +7. UI conforme design.md (N&B, coins 0, polices embarquées).
A icon.png
+0 -0