tien_nemo

demo

1 +from paddleocr import PaddleOCR
2 +from pdf2image import convert_from_path
3 +import os
4 +import time
5 +import numpy as np
6 +import json
7 +
8 +# ==== Config ====
9 +pdf_path = "D:/Learning_Tien/OCR/PaddleOCR/pdf/data_picking_detail.pdf"
10 +output_folder = "D:/Learning_Tien/OCR/ocr-mapping/public/image"
11 +os.makedirs(output_folder, exist_ok=True)
12 +
13 +pdf_name = "data_picking_detail"
14 +timestamp = int(time.time())
15 +img_base_name = f"{pdf_name}_{timestamp}"
16 +
17 +# ==== OCR Init ====
18 +ocr = PaddleOCR(
19 + use_doc_orientation_classify=False,
20 + use_doc_unwarping=False,
21 + use_textline_orientation=False
22 +)
23 +
24 +# ==== PDF to Image ====
25 +pages = convert_from_path(pdf_path, first_page=1, last_page=1)
26 +image_path = os.path.join(output_folder, f"{img_base_name}.jpg")
27 +pages[0].save(image_path, "JPEG")
28 +
29 +# ==== Run OCR ====
30 +image_np = np.array(pages[0])
31 +results = ocr.predict(image_np)
32 +
33 +# ==== Convert polygon to bbox ====
34 +def poly_to_bbox(poly):
35 + xs = [p[0] for p in poly]
36 + ys = [p[1] for p in poly]
37 + return [int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))]
38 +
39 +# ==== Build ocrData ====
40 +ocr_data_list = []
41 +for res in results:
42 + for text, poly in zip(res['rec_texts'], res['rec_polys']):
43 + bbox = poly_to_bbox(poly)
44 + ocr_data_list.append({
45 + "text": text,
46 + "bbox": bbox,
47 + "field": "",
48 + "hideBorder": False
49 + })
50 +
51 +# ==== Save JSON ====
52 +json_path = os.path.join(output_folder, f"{pdf_name}_{timestamp}.json")
53 +with open(json_path, "w", encoding="utf-8") as f:
54 + json.dump(ocr_data_list, f, ensure_ascii=False, indent=2)
55 +
56 +print(f"Saved OCR data JSON to: {json_path}")
1 +[
2 + {
3 + "text": "出庫指示書",
4 + "bbox": [
5 + 65,
6 + 73,
7 + 449,
8 + 128
9 + ],
10 + "field": "",
11 + "hideBorder": false
12 + },
13 + {
14 + "text": "出庫指示No.",
15 + "bbox": [
16 + 1303,
17 + 76,
18 + 1472,
19 + 111
20 + ],
21 + "field": "",
22 + "hideBorder": false
23 + },
24 + {
25 + "text": "391189",
26 + "bbox": [
27 + 1498,
28 + 78,
29 + 1604,
30 + 110
31 + ],
32 + "field": "",
33 + "hideBorder": false
34 + },
35 + {
36 + "text": "2025/06/24",
37 + "bbox": [
38 + 952,
39 + 94,
40 + 1106,
41 + 121
42 + ],
43 + "field": "",
44 + "hideBorder": false
45 + },
46 + {
47 + "text": "18:57迄",
48 + "bbox": [
49 + 1139,
50 + 89,
51 + 1250,
52 + 124
53 + ],
54 + "field": "",
55 + "hideBorder": false
56 + },
57 + {
58 + "text": "PAGE1/1",
59 + "bbox": [
60 + 1473,
61 + 121,
62 + 1594,
63 + 153
64 + ],
65 + "field": "",
66 + "hideBorder": false
67 + },
68 + {
69 + "text": "運送形態",
70 + "bbox": [
71 + 83,
72 + 145,
73 + 239,
74 + 184
75 + ],
76 + "field": "",
77 + "hideBorder": false
78 + },
79 + {
80 + "text": "30西濃運輸",
81 + "bbox": [
82 + 234,
83 + 144,
84 + 485,
85 + 185
86 + ],
87 + "field": "",
88 + "hideBorder": false
89 + },
90 + {
91 + "text": "得意先",
92 + "bbox": [
93 + 84,
94 + 206,
95 + 202,
96 + 246
97 + ],
98 + "field": "",
99 + "hideBorder": false
100 + },
101 + {
102 + "text": "42031(株)フジカケ",
103 + "bbox": [
104 + 243,
105 + 206,
106 + 556,
107 + 244
108 + ],
109 + "field": "",
110 + "hideBorder": false
111 + },
112 + {
113 + "text": "ミタケ",
114 + "bbox": [
115 + 536,
116 + 205,
117 + 707,
118 + 247
119 + ],
120 + "field": "",
121 + "hideBorder": false
122 + },
123 + {
124 + "text": "住所",
125 + "bbox": [
126 + 84,
127 + 266,
128 + 179,
129 + 298
130 + ],
131 + "field": "",
132 + "hideBorder": false
133 + },
134 + {
135 + "text": "〒5050100岐阜県可児郡御嵩町中2411-7",
136 + "bbox": [
137 + 207,
138 + 267,
139 + 834,
140 + 297
141 + ],
142 + "field": "",
143 + "hideBorder": false
144 + },
145 + {
146 + "text": "電話番号",
147 + "bbox": [
148 + 88,
149 + 308,
150 + 214,
151 + 340
152 + ],
153 + "field": "",
154 + "hideBorder": false
155 + },
156 + {
157 + "text": "0574673181",
158 + "bbox": [
159 + 215,
160 + 310,
161 + 382,
162 + 338
163 + ],
164 + "field": "",
165 + "hideBorder": false
166 + },
167 + {
168 + "text": "出庫者",
169 + "bbox": [
170 + 925,
171 + 331,
172 + 1008,
173 + 367
174 + ],
175 + "field": "",
176 + "hideBorder": false
177 + },
178 + {
179 + "text": "検品者",
180 + "bbox": [
181 + 1176,
182 + 330,
183 + 1261,
184 + 366
185 + ],
186 + "field": "",
187 + "hideBorder": false
188 + },
189 + {
190 + "text": "包者",
191 + "bbox": [
192 + 1431,
193 + 331,
194 + 1516,
195 + 367
196 + ],
197 + "field": "",
198 + "hideBorder": false
199 + },
200 + {
201 + "text": "担当者",
202 + "bbox": [
203 + 86,
204 + 344,
205 + 182,
206 + 381
207 + ],
208 + "field": "",
209 + "hideBorder": false
210 + },
211 + {
212 + "text": "NAS00240渡邊雅章",
213 + "bbox": [
214 + 239,
215 + 343,
216 + 518,
217 + 380
218 + ],
219 + "field": "",
220 + "hideBorder": false
221 + },
222 + {
223 + "text": "摘要",
224 + "bbox": [
225 + 83,
226 + 386,
227 + 183,
228 + 429
229 + ],
230 + "field": "",
231 + "hideBorder": false
232 + },
233 + {
234 + "text": "棚番",
235 + "bbox": [
236 + 34,
237 + 515,
238 + 97,
239 + 554
240 + ],
241 + "field": "",
242 + "hideBorder": false
243 + },
244 + {
245 + "text": "品",
246 + "bbox": [
247 + 345,
248 + 519,
249 + 378,
250 + 552
251 + ],
252 + "field": "",
253 + "hideBorder": false
254 + },
255 + {
256 + "text": "名",
257 + "bbox": [
258 + 423,
259 + 519,
260 + 456,
261 + 551
262 + ],
263 + "field": "",
264 + "hideBorder": false
265 + },
266 + {
267 + "text": "規",
268 + "bbox": [
269 + 870,
270 + 517,
271 + 909,
272 + 552
273 + ],
274 + "field": "",
275 + "hideBorder": false
276 + },
277 + {
278 + "text": "格",
279 + "bbox": [
280 + 948,
281 + 517,
282 + 986,
283 + 552
284 + ],
285 + "field": "",
286 + "hideBorder": false
287 + },
288 + {
289 + "text": "数量",
290 + "bbox": [
291 + 1110,
292 + 516,
293 + 1174,
294 + 554
295 + ],
296 + "field": "",
297 + "hideBorder": false
298 + },
299 + {
300 + "text": "受注番号",
301 + "bbox": [
302 + 1393,
303 + 519,
304 + 1505,
305 + 551
306 + ],
307 + "field": "",
308 + "hideBorder": false
309 + },
310 + {
311 + "text": "B0504",
312 + "bbox": [
313 + 39,
314 + 567,
315 + 124,
316 + 600
317 + ],
318 + "field": "",
319 + "hideBorder": false
320 + },
321 + {
322 + "text": "ダービー",
323 + "bbox": [
324 + 297,
325 + 565,
326 + 419,
327 + 600
328 + ],
329 + "field": "",
330 + "hideBorder": false
331 + },
332 + {
333 + "text": "斜ニッパー",
334 + "bbox": [
335 + 444,
336 + 564,
337 + 600,
338 + 602
339 + ],
340 + "field": "",
341 + "hideBorder": false
342 + },
343 + {
344 + "text": "#30 150MM",
345 + "bbox": [
346 + 861,
347 + 568,
348 + 1014,
349 + 602
350 + ],
351 + "field": "",
352 + "hideBorder": false
353 + },
354 + {
355 + "text": "2",
356 + "bbox": [
357 + 1146,
358 + 568,
359 + 1176,
360 + 606
361 + ],
362 + "field": "",
363 + "hideBorder": false
364 + },
365 + {
366 + "text": "(",
367 + "bbox": [
368 + 1232,
369 + 566,
370 + 1256,
371 + 601
372 + ],
373 + "field": "",
374 + "hideBorder": false
375 + },
376 + {
377 + "text": ")",
378 + "bbox": [
379 + 1363,
380 + 567,
381 + 1385,
382 + 600
383 + ],
384 + "field": "",
385 + "hideBorder": false
386 + },
387 + {
388 + "text": "250430015",
389 + "bbox": [
390 + 1419,
391 + 567,
392 + 1562,
393 + 598
394 + ],
395 + "field": "",
396 + "hideBorder": false
397 + },
398 + {
399 + "text": "4562144610607",
400 + "bbox": [
401 + 295,
402 + 611,
403 + 474,
404 + 638
405 + ],
406 + "field": "",
407 + "hideBorder": false
408 + },
409 + {
410 + "text": "3220060",
411 + "bbox": [
412 + 567,
413 + 610,
414 + 668,
415 + 638
416 + ],
417 + "field": "",
418 + "hideBorder": false
419 + },
420 + {
421 + "text": "C3101",
422 + "bbox": [
423 + 40,
424 + 654,
425 + 121,
426 + 687
427 + ],
428 + "field": "",
429 + "hideBorder": false
430 + },
431 + {
432 + "text": "タジマ",
433 + "bbox": [
434 + 296,
435 + 653,
436 + 389,
437 + 685
438 + ],
439 + "field": "",
440 + "hideBorder": false
441 + },
442 + {
443 + "text": "スーパー墨汁",
444 + "bbox": [
445 + 414,
446 + 654,
447 + 599,
448 + 685
449 + ],
450 + "field": "",
451 + "hideBorder": false
452 + },
453 + {
454 + "text": "180ML PSB2-180",
455 + "bbox": [
456 + 787,
457 + 656,
458 + 1013,
459 + 687
460 + ],
461 + "field": "",
462 + "hideBorder": false
463 + },
464 + {
465 + "text": "3",
466 + "bbox": [
467 + 1145,
468 + 655,
469 + 1176,
470 + 693
471 + ],
472 + "field": "",
473 + "hideBorder": false
474 + },
475 + {
476 + "text": "(",
477 + "bbox": [
478 + 1232,
479 + 653,
480 + 1257,
481 + 687
482 + ],
483 + "field": "",
484 + "hideBorder": false
485 + },
486 + {
487 + "text": ")",
488 + "bbox": [
489 + 1362,
490 + 653,
491 + 1386,
492 + 686
493 + ],
494 + "field": "",
495 + "hideBorder": false
496 + },
497 + {
498 + "text": "250430015",
499 + "bbox": [
500 + 1420,
501 + 655,
502 + 1563,
503 + 686
504 + ],
505 + "field": "",
506 + "hideBorder": false
507 + },
508 + {
509 + "text": "4975364054074",
510 + "bbox": [
511 + 295,
512 + 698,
513 + 474,
514 + 725
515 + ],
516 + "field": "",
517 + "hideBorder": false
518 + },
519 + {
520 + "text": "550207",
521 + "bbox": [
522 + 567,
523 + 697,
524 + 655,
525 + 726
526 + ],
527 + "field": "",
528 + "hideBorder": false
529 + },
530 + {
531 + "text": "C3101",
532 + "bbox": [
533 + 40,
534 + 741,
535 + 122,
536 + 774
537 + ],
538 + "field": "",
539 + "hideBorder": false
540 + },
541 + {
542 + "text": "タジマ",
543 + "bbox": [
544 + 295,
545 + 738,
546 + 390,
547 + 774
548 + ],
549 + "field": "",
550 + "hideBorder": false
551 + },
552 + {
553 + "text": "雨の日墨汁",
554 + "bbox": [
555 + 414,
556 + 740,
557 + 570,
558 + 774
559 + ],
560 + "field": "",
561 + "hideBorder": false
562 + },
563 + {
564 + "text": "PSB3-180",
565 + "bbox": [
566 + 879,
567 + 743,
568 + 1013,
569 + 774
570 + ],
571 + "field": "",
572 + "hideBorder": false
573 + },
574 + {
575 + "text": "2",
576 + "bbox": [
577 + 1146,
578 + 742,
579 + 1176,
580 + 780
581 + ],
582 + "field": "",
583 + "hideBorder": false
584 + },
585 + {
586 + "text": "(",
587 + "bbox": [
588 + 1232,
589 + 740,
590 + 1257,
591 + 774
592 + ],
593 + "field": "",
594 + "hideBorder": false
595 + },
596 + {
597 + "text": ")",
598 + "bbox": [
599 + 1361,
600 + 740,
601 + 1386,
602 + 774
603 + ],
604 + "field": "",
605 + "hideBorder": false
606 + },
607 + {
608 + "text": "250430015",
609 + "bbox": [
610 + 1419,
611 + 741,
612 + 1562,
613 + 772
614 + ],
615 + "field": "",
616 + "hideBorder": false
617 + },
618 + {
619 + "text": "49270501",
620 + "bbox": [
621 + 294,
622 + 783,
623 + 406,
624 + 814
625 + ],
626 + "field": "",
627 + "hideBorder": false
628 + },
629 + {
630 + "text": "548140",
631 + "bbox": [
632 + 567,
633 + 783,
634 + 655,
635 + 812
636 + ],
637 + "field": "",
638 + "hideBorder": false
639 + },
640 + {
641 + "text": "明细行数= 3",
642 + "bbox": [
643 + 882,
644 + 822,
645 + 1136,
646 + 871
647 + ],
648 + "field": "",
649 + "hideBorder": false
650 + }
651 +]
...\ No newline at end of file ...\ No newline at end of file
1 +<html lang="en"><head>
2 + <meta charset="UTF-8">
3 + <title>OCR Mapping with Manual Select Tool</title>
4 + <script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
5 + <style>
6 + body { font-family: sans-serif; background: #f5f5f5; }
7 + #app { display: flex; gap: 20px; padding: 20px; }
8 + .left-panel {
9 + width: 500px; background: #fff; padding: 15px;
10 + border-radius: 8px; box-shadow: 0 0 5px rgba(0,0,0,0.1);
11 + }
12 + .form-group { margin-bottom: 15px; }
13 + .form-group label { font-weight: bold; display: block; margin-bottom: 5px; }
14 + .form-group input { width: 100%; padding: 6px; border: 1px solid #ccc; border-radius: 4px; }
15 + .right-panel { flex: 1; position: relative; background: #eee; border-radius: 8px; overflow: hidden; user-select: none; }
16 + .pdf-container { position: relative; display: inline-block; }
17 + .bbox {
18 + position: absolute;
19 + border: 2px solid #ff5252;
20 + /*background-color: rgba(255, 82, 82, 0.2);*/
21 + cursor: pointer;
22 + }
23 + .bbox.active {
24 + /*border-color: #2196F3;*/
25 + background-color: rgb(33 243 132 / 30%);
26 + }
27 + select {
28 + position: absolute;
29 + z-index: 10;
30 + background: #fff;
31 + border: 1px solid #ccc;
32 + }
33 + .select-box {
34 + position: absolute;
35 + /*border: 2px dashed #2196F3;*/
36 + background-color: rgba(33, 150, 243, 0.2);
37 + pointer-events: none;
38 + z-index: 5;
39 + }
40 + .delete-btn {
41 + position: absolute;
42 + bottom: -10px;
43 + right: -10px;
44 + background: #ff4d4d;
45 + color: #fff;
46 + border: none;
47 + border-radius: 50%;
48 + cursor: pointer;
49 + font-size: 14px;
50 + padding: 3px 6px;
51 + z-index: 20;
52 + }
53 + </style>
54 +
55 +</head>
56 +<body>
57 +<div id="app">
58 +
59 + <!-- Right: PDF viewer + select tool -->
60 + <div class="right-panel" >
61 + <div class="pdf-container" ref="pdfContainer"
62 + @mousedown="startSelect"
63 + @mousemove="onSelect"
64 + @mouseup="endSelect">
65 + <img
66 + ref="pdfImage"
67 + :src="pdfImageUrl"
68 + @load="onImageLoad"
69 + style="width: 100%; height: auto;pointer-events: none;"
70 + />
71 +
72 +
73 + <!-- Vùng kéo chọn -->
74 + <div v-if="selectBox.show" class="select-box"
75 + :style="{ left: selectBox.x + 'px', top: selectBox.y + 'px', width: selectBox.width + 'px', height: selectBox.height + 'px' }"></div>
76 +
77 + <!-- Vẽ bbox OCR -->
78 + <div
79 + v-for="(item, index) in ocrData"
80 + :key="index"
81 + v-if="!item.isDeleted"
82 + class="bbox"
83 + :class="{ active: index === activeIndex }"
84 + :data-field="item.field"
85 + :style="getBoxStyle(item, index)"
86 + @click="selectingIndex = index">
87 +
88 + <button v-if="item.isManual && item.showDelete"
89 + class="delete-btn"
90 + @click.stop="deleteBox(index)">🗑</button>
91 + </div>
92 +
93 +
94 + <!-- Dropdown OCR -->
95 + <select v-if="selectingIndex !== null"
96 + :style="getSelectStyle(ocrData[selectingIndex])"
97 + v-model="ocrData[selectingIndex].field"
98 + @change="applyMapping"
99 + >
100 + <option disabled value="">-- Chọn trường dữ liệu --</option>
101 + <option v-for="field in fieldOptions" :value="field.value">{{ field.label }}</option>
102 + </select>
103 +
104 + <!-- Dropdown thủ công -->
105 + <select v-if="selectBox.showDropdown"
106 + :style="{ left: selectBox.x + 'px', top: (selectBox.y + selectBox.height) + 'px' }"
107 + v-model="manualField"
108 + @change="applyManualMapping"
109 + @click.stop
110 + >
111 + <option disabled value="">-- Chọn trường dữ liệu --</option>
112 + <option v-for="field in fieldOptions" :value="field.value">{{ field.label }}</option>
113 + </select>
114 + </div>
115 + </div>
116 +
117 + <!-- Left: Form inputs -->
118 + <div class="left-panel">
119 + <div v-for="field in fieldOptions" :key="field.value" class="form-group">
120 + <label>{{ field.label }}</label>
121 + <input v-model="formData[field.value]" @focus="highlightField(field.value)">
122 + </div>
123 + </div>
124 +
125 +</div>
126 +
127 +
128 +<script>
129 + new Vue({
130 + el: '#app',
131 + data() {
132 + return {
133 + pdfImageUrl: "",
134 + selectingIndex: null,
135 + isMappingManually: false,
136 + isSelecting: false,
137 + activeField: null,
138 + manualField: "",
139 + formData: { export_date: "", order_code: "", customer: "", address: "", staff: "" },
140 + fieldOptions: [
141 + { value: "export_date", label: "Ngày xuất" },
142 + { value: "order_code", label: "Mã đơn hàng" },
143 + { value: "customer", label: "Khách hàng" },
144 + { value: "address", label: "Địa chỉ" },
145 + { value: "staff", label: "Nhân viên" }
146 + ],
147 + ocrData: [],
148 + selectBox: { show: false, showDropdown: false, x: 0, y: 0, width: 0, height: 0, startX: 0, startY: 0 },
149 + manualIndex: null
150 + }
151 + },
152 + mounted() {
153 + this.pdfImageUrl = "/public/image/data_picking_detail_1754967679.jpg"; // ảnh xuất từ Python
154 + this.initData();
155 + },
156 + methods: {
157 + deleteBox(index) {
158 + const item = this.ocrData[index];
159 + if (item.isManual) {
160 + const manualBbox = item.bbox;
161 +
162 + // Hiện lại border các box OCR gốc nằm trong vùng thủ công
163 + this.ocrData.forEach(o => {
164 + if (!o.isManual && this.isBoxInside(o.bbox, manualBbox)) {
165 + o.hideBorder = false;
166 + }
167 + });
168 +
169 + // Đánh dấu xoá vùng thủ công
170 + this.ocrData[index].isDeleted = true;
171 + this.ocrData[index].showDelete = false;
172 +
173 + // Reset trạng thái nếu đây là vùng đang chọn
174 + if (this.manualIndex === index) {
175 + this.isMappingManually = false;
176 + this.selectBox.show = false;
177 + this.selectBox.showDropdown = false;
178 + this.manualField = "";
179 + this.manualIndex = null;
180 + }
181 + }
182 + },
183 +
184 + async initData() {
185 + await this.loadOCRData();
186 + },
187 + async loadOCRData() {
188 + const res = await fetch("/public/image/data_picking_detail_1754967679.json");
189 + this.ocrData = await res.json();
190 + },
191 + onImageLoad() {
192 + const img = this.$refs.pdfImage;
193 + this.imageWidth = img.naturalWidth;
194 + this.imageHeight = img.naturalHeight;
195 + },
196 + getBoxStyle(item) {
197 + if (!this.imageWidth || !this.imageHeight || !this.$refs.pdfImage) return {};
198 +
199 + const [x1, y1, x2, y2] = item.bbox;
200 + const displayedWidth = this.$refs.pdfImage.clientWidth;
201 + const displayedHeight = this.$refs.pdfImage.clientHeight;
202 +
203 + const scaleX = displayedWidth / this.imageWidth;
204 + const scaleY = displayedHeight / this.imageHeight;
205 +
206 + return {
207 + position: 'absolute',
208 + left: `${Math.round(x1 * scaleX)}px`,
209 + top: `${Math.round(y1 * scaleY)}px`,
210 + width: `${Math.round((x2 - x1) * scaleX)}px`,
211 + height: `${Math.round((y2 - y1) * scaleY)}px`,
212 + border: item.hideBorder ? 'none' : '2px solid ' + (this.activeField === item.field ? '#199601' : '#ff5252'),
213 + //backgroundColor: item.hideBorder ? 'transparent' : (this.activeField === item.field ? 'rgba(33,150,243,0.3)' : 'rgba(255,82,82,0.2)'),
214 + boxSizing: 'border-box',
215 + cursor: 'pointer',
216 + zIndex: item.isManual ? 30 : 10
217 + };
218 + },
219 +
220 + highlightField(field) {
221 + this.activeField = field;
222 + },
223 +
224 + startSelect(e) {
225 + if (this.isMappingManually || e.button !== 0) return;
226 + this.isSelecting = true;
227 + const rect = this.$refs.pdfContainer.getBoundingClientRect();
228 + this.selectBox.startX = e.clientX - rect.left;
229 + this.selectBox.startY = e.clientY - rect.top;
230 + this.selectBox.x = this.selectBox.startX;
231 + this.selectBox.y = this.selectBox.startY;
232 + this.selectBox.width = 0;
233 + this.selectBox.height = 0;
234 + this.selectBox.show = true;
235 + this.selectBox.showDropdown = false;
236 + this.manualField = "";
237 + },
238 +
239 + onSelect(e) {
240 + if (!this.isSelecting) return;
241 + const rect = this.$refs.pdfContainer.getBoundingClientRect();
242 + const currentX = e.clientX - rect.left;
243 + const currentY = e.clientY - rect.top;
244 + this.selectBox.x = Math.min(currentX, this.selectBox.startX);
245 + this.selectBox.y = Math.min(currentY, this.selectBox.startY);
246 + this.selectBox.width = Math.abs(currentX - this.selectBox.startX);
247 + this.selectBox.height = Math.abs(currentY - this.selectBox.startY);
248 + },
249 +
250 + endSelect(e) {
251 + if (!this.isSelecting) return;
252 + this.isSelecting = false;
253 +
254 + if (this.selectBox.width < 10 || this.selectBox.height < 10) {
255 + this.selectBox.show = false;
256 + return;
257 + }
258 +
259 + // displayed coords (như hiện tại, dùng để hiển thị select overlay)
260 + const dispX1 = this.selectBox.x;
261 + const dispY1 = this.selectBox.y;
262 + const dispX2 = this.selectBox.x + this.selectBox.width;
263 + const dispY2 = this.selectBox.y + this.selectBox.height;
264 +
265 + // scale: displayed -> original
266 + const displayedWidth = this.$refs.pdfImage.clientWidth;
267 + const displayedHeight = this.$refs.pdfImage.clientHeight;
268 + const scaleX = this.imageWidth / displayedWidth;
269 + const scaleY = this.imageHeight / displayedHeight;
270 +
271 + // bbox ở hệ gốc (original image pixels) — dùng để so sánh với ocrData và lưu vào ocrData
272 + const origBbox = [
273 + Math.round(dispX1 * scaleX),
274 + Math.round(dispY1 * scaleY),
275 + Math.round(dispX2 * scaleX),
276 + Math.round(dispY2 * scaleY)
277 + ];
278 +
279 + // Ẩn border các box OCR gốc nằm giao nhau với vùng thủ công (dùng coords gốc)
280 + this.ocrData.forEach(item => {
281 + if (!item.isManual && this.isBoxInside(item.bbox, origBbox)) {
282 + item.hideBorder = true;
283 + }
284 + });
285 +
286 + // Thêm box thủ công (lưu theo coords gốc)
287 + this.ocrData.push({
288 + text: "",
289 + bbox: origBbox,
290 + field: "",
291 + isManual: true,
292 + showDelete: true,
293 + isDeleted: false,
294 + hideBorder: false
295 + });
296 +
297 + this.manualIndex = this.ocrData.length - 1;
298 + this.isMappingManually = true;
299 + this.selectBox.showDropdown = true;
300 +
301 + e.stopPropagation();
302 + e.preventDefault();
303 + }
304 + ,
305 + applyMapping() {
306 + const item = this.ocrData[this.selectingIndex];
307 +
308 + if (item && item.isManual) {
309 + this.manualIndex = this.selectingIndex;
310 + this.manualField = item.field; // đảm bảo sync field hiện tại
311 + this.applyManualMapping();
312 + return;
313 + }
314 +
315 + if (item.field) {
316 + this.formData[item.field] = item.text;
317 + this.activeField = item.field;
318 + }
319 + this.selectingIndex = null;
320 + },
321 + applyManualMapping() {
322 + if (!this.manualField) return;
323 + const manualIndex = this.manualIndex;
324 + const newBbox = this.ocrData[manualIndex].bbox;
325 +
326 + let combinedText = [];
327 + this.ocrData.forEach(item => {
328 + if (!item.isManual && this.isBoxInside(item.bbox, newBbox) && item.text.trim()) {
329 + const partial = this.getPartialText(item.text, item.bbox, newBbox);
330 + if (partial) combinedText.push(partial);
331 + // combinedText.push(item.text.trim());
332 + }
333 + });
334 +
335 + const finalText = combinedText.join(" ");
336 + this.ocrData[manualIndex].field = this.manualField;
337 + this.formData[this.manualField] = finalText;
338 + this.activeField = this.manualField;
339 +
340 + console.log('manualField',this.manualField,this.manualIndex)
341 + // Reset trạng thái chọn
342 + this.isMappingManually = false;
343 + this.selectBox.show = false;
344 + this.selectBox.showDropdown = false;
345 + // this.manualField = "";
346 + // this.manualIndex = null;
347 + },
348 +
349 + isBoxInside(inner, outer) {
350 + return !(
351 + inner[2] < outer[0] || // box bên trái vùng chọn
352 + inner[0] > outer[2] || // box bên phải vùng chọn
353 + inner[3] < outer[1] || // box phía trên vùng chọn
354 + inner[1] > outer[3] // box phía dưới vùng chọn
355 + );
356 + },
357 +
358 + getPartialText(text, bbox, selectBbox) {
359 + const [x1, y1, x2, y2] = bbox;
360 + const [sx1, sy1, sx2, sy2] = selectBbox;
361 +
362 + // Chiều rộng box OCR
363 + const boxWidth = x2 - x1;
364 +
365 + // Vị trí start và end tương đối trong text
366 + let startRatio = Math.max(0, (sx1 - x1) / boxWidth);
367 + let endRatio = Math.min(1, (sx2 - x1) / boxWidth);
368 +
369 + const startIndex = Math.floor(startRatio * text.length);
370 + const endIndex = Math.ceil(endRatio * text.length);
371 +
372 + return text.substring(startIndex, endIndex).trim();
373 + },
374 + getSelectStyle(item) {
375 + if (!this.imageWidth) return { position: 'absolute' };
376 +
377 + const [x1, y1, x2, y2] = item.bbox;
378 + const displayedWidth = this.$refs.pdfImage.clientWidth;
379 + const displayedHeight = this.$refs.pdfImage.clientHeight;
380 + const scaleX = displayedWidth / this.imageWidth;
381 + const scaleY = displayedHeight / this.imageHeight;
382 +
383 + return {
384 + position: 'absolute',
385 + left: `${Math.round(x1 * scaleX)}px`,
386 + top: `${Math.round(y2 * scaleY)}px`,
387 + zIndex: 9999
388 + };
389 + }
390 +
391 + }
392 + });
393 +
394 +</script>
395 +
396 +
397 +</body></html>