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 | +[](LICENSE) | |
| 8 | + | |
| 9 | + | |
| 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
GitRust