Showing
7 changed files
with
772 additions
and
0 deletions
app/Http/Controllers/Controller.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +namespace App\Http\Controllers; | ||
| 4 | + | ||
| 5 | +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; | ||
| 6 | +use Illuminate\Foundation\Bus\DispatchesJobs; | ||
| 7 | +use Illuminate\Foundation\Validation\ValidatesRequests; | ||
| 8 | +use Illuminate\Routing\Controller as BaseController; | ||
| 9 | + | ||
| 10 | +class Controller extends BaseController | ||
| 11 | +{ | ||
| 12 | + use AuthorizesRequests, DispatchesJobs, ValidatesRequests; | ||
| 13 | +} |
app/Http/Controllers/OcrController.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +namespace App\Http\Controllers; | ||
| 4 | + | ||
| 5 | +use App\Models\DtTemplate; | ||
| 6 | +use App\Models\MstTemplate; | ||
| 7 | +use Illuminate\Http\Request; | ||
| 8 | + | ||
| 9 | +class OcrController extends Controller | ||
| 10 | +{ | ||
| 11 | + public function index() | ||
| 12 | + { | ||
| 13 | + return view('ocr.index'); | ||
| 14 | + } | ||
| 15 | + | ||
| 16 | + | ||
| 17 | + public function store(Request $request) | ||
| 18 | + { | ||
| 19 | + | ||
| 20 | + $request->validate([ | ||
| 21 | + 'customer_name_text' => 'required|string', | ||
| 22 | + 'customer_name_xy' => 'required|string', | ||
| 23 | + 'tpl_name' => 'unique:mst_template', | ||
| 24 | + ]); | ||
| 25 | + | ||
| 26 | + // Lưu vào bảng mst_template | ||
| 27 | + $mst = MstTemplate::create([ | ||
| 28 | + 'tpl_name' => $request->template_name, | ||
| 29 | + 'tpl_text' => $request->customer_name_text, | ||
| 30 | + 'tpl_xy' => $request->customer_name_xy, | ||
| 31 | + ]); | ||
| 32 | + | ||
| 33 | + // Lưu các field khác vào dt_template | ||
| 34 | + foreach ($request->fields as $field) { | ||
| 35 | + DtTemplate::create([ | ||
| 36 | + 'tpl_id' => $mst->id, | ||
| 37 | + 'field_name' => $field['name'], | ||
| 38 | + 'field_xy' => $field['xy'], | ||
| 39 | + ]); | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + return response()->json(['success' => true, 'message' => 'Lưu template thành công']); | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + | ||
| 46 | + public function getData() | ||
| 47 | + { | ||
| 48 | + // Giả sử file OCR JSON & ảnh nằm trong storage/app/public/image/ | ||
| 49 | + $jsonPath = public_path("image/data_picking_detail_1754967679.json"); | ||
| 50 | + $imgPath = ("image/data_picking_detail_1754967679.jpg"); | ||
| 51 | + | ||
| 52 | + | ||
| 53 | + $templateName = 'nemo_4'; | ||
| 54 | + /// Lấy từ request hoặc mặc định | ||
| 55 | + | ||
| 56 | + | ||
| 57 | + if (!file_exists($jsonPath)) { | ||
| 58 | + return response()->json(['error' => 'File not found'], 404); | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + $ocrData = json_decode(file_get_contents($jsonPath), true); | ||
| 62 | + $formData = []; | ||
| 63 | + | ||
| 64 | + if ($templateName) { | ||
| 65 | + $mst = MstTemplate::where('tpl_name', $templateName)->first(); | ||
| 66 | + | ||
| 67 | + if ($mst) { | ||
| 68 | + // Lấy detail của template | ||
| 69 | + $details = DtTemplate::where('tpl_id', $mst->id)->get(); | ||
| 70 | + | ||
| 71 | + foreach ($details as $detail) { | ||
| 72 | + $coords = array_map('intval', explode(',', $detail->field_xy)); | ||
| 73 | + // coords = [x1, y1, x2, y2] | ||
| 74 | + | ||
| 75 | + // Tìm text OCR nằm trong bbox này | ||
| 76 | + $text = $this->findTextInBBox($ocrData, $coords); | ||
| 77 | + | ||
| 78 | + // field_name => text | ||
| 79 | + $formData[$detail->field_name] = $text; | ||
| 80 | + } | ||
| 81 | + } else{ | ||
| 82 | + $formData = [ | ||
| 83 | + 'export_date' => "", | ||
| 84 | + 'order_code' => "", | ||
| 85 | + 'customer' => "", | ||
| 86 | + 'address' => "", | ||
| 87 | + 'staff' => "", | ||
| 88 | + 'customer_name' => "" | ||
| 89 | + ]; | ||
| 90 | + } | ||
| 91 | + } | ||
| 92 | + return response()->json([ | ||
| 93 | + 'ocrData' => $ocrData, | ||
| 94 | + 'pdfImageUrl' => $imgPath, | ||
| 95 | + 'formData' => $formData, | ||
| 96 | + 'fieldOptions' => [ | ||
| 97 | + [ 'value' => 'template_name', 'label' => 'Tên Mẫu PDF' ], | ||
| 98 | + [ 'value' => 'customer_name', 'label' => 'Tên khách hàng' ], | ||
| 99 | + [ 'value' => 'export_date', 'label' => 'Ngày xuất' ], | ||
| 100 | + [ 'value' => 'order_code', 'label' => 'Mã đơn hàng' ], | ||
| 101 | + [ 'value' => 'customer', 'label' => 'Khách hàng' ], | ||
| 102 | + [ 'value' => 'address', 'label' => 'Địa chỉ' ], | ||
| 103 | + [ 'value' => 'staff', 'label' => 'Nhân viên' ], | ||
| 104 | + ] | ||
| 105 | + ]); | ||
| 106 | + } | ||
| 107 | + | ||
| 108 | + private function findTextInBBox($ocrData, $coords) | ||
| 109 | + { | ||
| 110 | + [$x1, $y1, $x2, $y2] = $coords; | ||
| 111 | + foreach ($ocrData as $item) { | ||
| 112 | + [$ix1, $iy1, $ix2, $iy2] = $item['bbox']; | ||
| 113 | + // Kiểm tra nếu bbox OCR nằm trong vùng bbox template | ||
| 114 | + if ($ix1 >= $x1 && $iy1 >= $y1 && $ix2 <= $x2 && $iy2 <= $y2) { | ||
| 115 | + return $item['text']; | ||
| 116 | + } | ||
| 117 | + } | ||
| 118 | + return ''; | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + | ||
| 122 | +} |
app/Models/DtTemplate.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +namespace App\Models; | ||
| 4 | + | ||
| 5 | +use Illuminate\Database\Eloquent\Factories\HasFactory; | ||
| 6 | +use Illuminate\Database\Eloquent\Model; | ||
| 7 | + | ||
| 8 | +class DtTemplate extends Model | ||
| 9 | +{ | ||
| 10 | + public $timestamps = false; | ||
| 11 | + | ||
| 12 | + protected $table = 'dt_template'; | ||
| 13 | + protected $guarded = []; | ||
| 14 | + | ||
| 15 | + | ||
| 16 | +} |
app/Models/MstTemplate.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +namespace App\Models; | ||
| 4 | + | ||
| 5 | +use Illuminate\Database\Eloquent\Factories\HasFactory; | ||
| 6 | +use Illuminate\Database\Eloquent\Model; | ||
| 7 | + | ||
| 8 | +class MstTemplate extends Model | ||
| 9 | +{ | ||
| 10 | + public $timestamps = false; | ||
| 11 | + | ||
| 12 | + protected $table = 'mst_template'; | ||
| 13 | + | ||
| 14 | + protected $guarded = []; | ||
| 15 | +} |
public/index.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +use Illuminate\Contracts\Http\Kernel; | ||
| 4 | +use Illuminate\Http\Request; | ||
| 5 | + | ||
| 6 | +define('LARAVEL_START', microtime(true)); | ||
| 7 | + | ||
| 8 | +/* | ||
| 9 | +|-------------------------------------------------------------------------- | ||
| 10 | +| Check If The Application Is Under Maintenance | ||
| 11 | +|-------------------------------------------------------------------------- | ||
| 12 | +| | ||
| 13 | +| If the application is in maintenance / demo mode via the "down" command | ||
| 14 | +| we will load this file so that any pre-rendered content can be shown | ||
| 15 | +| instead of starting the framework, which could cause an exception. | ||
| 16 | +| | ||
| 17 | +*/ | ||
| 18 | + | ||
| 19 | +if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) { | ||
| 20 | + require $maintenance; | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +/* | ||
| 24 | +|-------------------------------------------------------------------------- | ||
| 25 | +| Register The Auto Loader | ||
| 26 | +|-------------------------------------------------------------------------- | ||
| 27 | +| | ||
| 28 | +| Composer provides a convenient, automatically generated class loader for | ||
| 29 | +| this application. We just need to utilize it! We'll simply require it | ||
| 30 | +| into the script here so we don't need to manually load our classes. | ||
| 31 | +| | ||
| 32 | +*/ | ||
| 33 | + | ||
| 34 | +require __DIR__.'/../vendor/autoload.php'; | ||
| 35 | + | ||
| 36 | +/* | ||
| 37 | +|-------------------------------------------------------------------------- | ||
| 38 | +| Run The Application | ||
| 39 | +|-------------------------------------------------------------------------- | ||
| 40 | +| | ||
| 41 | +| Once we have the application, we can handle the incoming request using | ||
| 42 | +| the application's HTTP kernel. Then, we will send the response back | ||
| 43 | +| to this client's browser, allowing them to enjoy our application. | ||
| 44 | +| | ||
| 45 | +*/ | ||
| 46 | + | ||
| 47 | +$app = require_once __DIR__.'/../bootstrap/app.php'; | ||
| 48 | + | ||
| 49 | +$kernel = $app->make(Kernel::class); | ||
| 50 | + | ||
| 51 | +$response = $kernel->handle( | ||
| 52 | + $request = Request::capture() | ||
| 53 | +)->send(); | ||
| 54 | + | ||
| 55 | +$kernel->terminate($request, $response); |
resources/views/ocr/index.blade.php
0 → 100644
| 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 | +<meta name="csrf-token" content="{{ csrf_token() }}"> | ||
| 58 | +<div id="app"> | ||
| 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 fieldData" :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 fieldData" :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]" | ||
| 122 | + @focus="highlightField(field.value)" | ||
| 123 | + :readonly="field.value === 'customer_name' && !hasCustomerNameXY" | ||
| 124 | + > | ||
| 125 | + </div> | ||
| 126 | + <button @click="saveTemplate">💾Save</button> | ||
| 127 | + </div> | ||
| 128 | + | ||
| 129 | +</div> | ||
| 130 | + | ||
| 131 | + | ||
| 132 | +<script> | ||
| 133 | + new Vue({ | ||
| 134 | + el: '#app', | ||
| 135 | + data() { | ||
| 136 | + return { | ||
| 137 | + pdfImageUrl: "", | ||
| 138 | + selectingIndex: null, | ||
| 139 | + isMappingManually: false, | ||
| 140 | + isSelecting: false, | ||
| 141 | + activeIndex: null, | ||
| 142 | + manualField: "", | ||
| 143 | + formData: {}, | ||
| 144 | + fieldOptions: [], | ||
| 145 | + customer_name_xy: '', | ||
| 146 | + hasCustomerNameXY: false, | ||
| 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 | + created() { | ||
| 153 | + // Chỉ tạo formData cho các field cần mapping | ||
| 154 | + this.fieldOptions | ||
| 155 | + .filter(f => f.value !== "template_name") | ||
| 156 | + .forEach(f => { | ||
| 157 | + this.$set(this.formData, f.value, ""); | ||
| 158 | + }); | ||
| 159 | + }, | ||
| 160 | + mounted() { | ||
| 161 | + this.loadOCRData(); | ||
| 162 | + }, | ||
| 163 | + computed: { | ||
| 164 | + fieldData() { | ||
| 165 | + // Lọc bỏ template_name nếu không cần cho phần form mapping | ||
| 166 | + return this.fieldOptions.filter(f => f.value !== "template_name"); | ||
| 167 | + } | ||
| 168 | + }, | ||
| 169 | + methods: { | ||
| 170 | + assignFieldToBox(index, fieldName, text = null) { | ||
| 171 | + if (index == null) return; | ||
| 172 | + | ||
| 173 | + // Xóa fieldName ở box khác | ||
| 174 | + this.ocrData.forEach((box, i) => { | ||
| 175 | + if (i !== index && box.field === fieldName) { | ||
| 176 | + box.field = null; | ||
| 177 | + box.field_xy = null; | ||
| 178 | + } | ||
| 179 | + }); | ||
| 180 | + | ||
| 181 | + // Nếu box này từng gán field khác thì bỏ | ||
| 182 | + const prev = this.ocrData[index].field; | ||
| 183 | + if (prev && prev !== fieldName) { | ||
| 184 | + if (prev === 'customer_name') { | ||
| 185 | + this.hasCustomerNameXY = false; | ||
| 186 | + this.customer_name_xy = ''; | ||
| 187 | + } | ||
| 188 | + this.ocrData[index].field = null; | ||
| 189 | + this.ocrData[index].field_xy = null; | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + // Gán field mới | ||
| 193 | + const bbox = this.ocrData[index].bbox; // tọa độ OCR gốc [x1, y1, x2, y2] | ||
| 194 | + | ||
| 195 | + console.log('1111111111111111',bbox); | ||
| 196 | + const x1 = bbox[0]; | ||
| 197 | + const y1 = bbox[1]; | ||
| 198 | + const w = bbox[2]; | ||
| 199 | + const h = bbox[3]; | ||
| 200 | + | ||
| 201 | + const xyStr = `${x1},${y1},${w},${h}`; | ||
| 202 | + | ||
| 203 | + this.ocrData[index].field = fieldName; | ||
| 204 | + this.ocrData[index].field_xy = xyStr; | ||
| 205 | + | ||
| 206 | + // Set text | ||
| 207 | + this.formData[fieldName] = (text !== null ? text : (this.ocrData[index].text || '')).trim(); | ||
| 208 | + | ||
| 209 | + // Active index | ||
| 210 | + this.activeIndex = index; | ||
| 211 | + | ||
| 212 | + // Nếu là customer_name | ||
| 213 | + if (fieldName === 'customer_name') { | ||
| 214 | + this.hasCustomerNameXY = true; | ||
| 215 | + this.customer_name_xy = xyStr; | ||
| 216 | + } | ||
| 217 | + }, | ||
| 218 | + | ||
| 219 | + | ||
| 220 | + async saveTemplate() { | ||
| 221 | + | ||
| 222 | + if (!this.hasCustomerNameXY) { | ||
| 223 | + alert("Bạn phải map customer_name (quét/select) trước khi lưu."); | ||
| 224 | + return; | ||
| 225 | + } | ||
| 226 | + | ||
| 227 | + // build fields array: lấy những box có field gán, map unique by field name -> sử dụng field_xy | ||
| 228 | + const fieldsByName = {}; | ||
| 229 | + this.ocrData.forEach(box => { | ||
| 230 | + if (box.field && !box.isDeleted) { | ||
| 231 | + // chỉ giữ 1 bản ghi cuối cùng cho mỗi field (box gần nhất) | ||
| 232 | + fieldsByName[box.field] = { | ||
| 233 | + name: box.field, | ||
| 234 | + xy: box.field_xy || '' | ||
| 235 | + }; | ||
| 236 | + } | ||
| 237 | + }); | ||
| 238 | + // convert to array | ||
| 239 | + const fields = Object.values(fieldsByName); | ||
| 240 | + | ||
| 241 | + const payload = { | ||
| 242 | + customer_name_text: this.formData.customer_name || '', | ||
| 243 | + template_name: this.formData.template_name || this.formData.customer_name, | ||
| 244 | + customer_name_xy: this.customer_name_xy, | ||
| 245 | + fields: fields | ||
| 246 | + }; | ||
| 247 | + console.log(fields); | ||
| 248 | + try { | ||
| 249 | + const res = await fetch('/ocr/save-template', { | ||
| 250 | + method: 'POST', | ||
| 251 | + headers: { | ||
| 252 | + 'Content-Type': 'application/json', | ||
| 253 | + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content') | ||
| 254 | + }, | ||
| 255 | + body: JSON.stringify(payload) | ||
| 256 | + }); | ||
| 257 | + const json = await res.json(); | ||
| 258 | + if (json.success) { | ||
| 259 | + alert(json.message); | ||
| 260 | + } else { | ||
| 261 | + alert('Save failed'); | ||
| 262 | + } | ||
| 263 | + } catch (err) { | ||
| 264 | + console.error(err); | ||
| 265 | + alert('Save error'); | ||
| 266 | + } | ||
| 267 | + }, | ||
| 268 | + | ||
| 269 | + | ||
| 270 | + deleteBox(index) { | ||
| 271 | + const item = this.ocrData[index]; | ||
| 272 | + if (item.isManual) { | ||
| 273 | + const manualBbox = item.bbox; | ||
| 274 | + | ||
| 275 | + // Hiện lại border các box OCR gốc nằm trong vùng thủ công | ||
| 276 | + this.ocrData.forEach(o => { | ||
| 277 | + if (!o.isManual && this.isBoxInside(o.bbox, manualBbox)) { | ||
| 278 | + o.hideBorder = false; | ||
| 279 | + } | ||
| 280 | + }); | ||
| 281 | + | ||
| 282 | + // Đánh dấu xoá vùng thủ công | ||
| 283 | + this.ocrData[index].isDeleted = true; | ||
| 284 | + this.ocrData[index].showDelete = false; | ||
| 285 | + | ||
| 286 | + // Reset trạng thái nếu đây là vùng đang chọn | ||
| 287 | + if (this.manualIndex === index) { | ||
| 288 | + this.isMappingManually = false; | ||
| 289 | + this.selectBox.show = false; | ||
| 290 | + this.selectBox.showDropdown = false; | ||
| 291 | + this.manualField = ""; | ||
| 292 | + this.manualIndex = null; | ||
| 293 | + } | ||
| 294 | + } | ||
| 295 | + }, | ||
| 296 | + | ||
| 297 | + | ||
| 298 | + async loadOCRData() { | ||
| 299 | + | ||
| 300 | + const res = await fetch(`/ocr/data-list`); | ||
| 301 | + const data = await res.json(); | ||
| 302 | + | ||
| 303 | + if (data.error) { | ||
| 304 | + console.error(data.error); | ||
| 305 | + return; | ||
| 306 | + } | ||
| 307 | + | ||
| 308 | + this.ocrData = data.ocrData; | ||
| 309 | + this.pdfImageUrl = data.pdfImageUrl; | ||
| 310 | + this.formData = data.formData; | ||
| 311 | + this.fieldOptions = data.fieldOptions; | ||
| 312 | + }, | ||
| 313 | + onImageLoad() { | ||
| 314 | + const img = this.$refs.pdfImage; | ||
| 315 | + this.imageWidth = img.naturalWidth; | ||
| 316 | + this.imageHeight = img.naturalHeight; | ||
| 317 | + }, | ||
| 318 | + getBoxStyle(item, index) { | ||
| 319 | + if (!this.imageWidth || !this.imageHeight || !this.$refs.pdfImage) return {}; | ||
| 320 | + | ||
| 321 | + const [x1, y1, x2, y2] = item.bbox; | ||
| 322 | + const displayedWidth = this.$refs.pdfImage.clientWidth; | ||
| 323 | + const displayedHeight = this.$refs.pdfImage.clientHeight; | ||
| 324 | + | ||
| 325 | + const scaleX = displayedWidth / this.imageWidth; | ||
| 326 | + const scaleY = displayedHeight / this.imageHeight; | ||
| 327 | + | ||
| 328 | + return { | ||
| 329 | + position: 'absolute', | ||
| 330 | + left: `${Math.round(x1 * scaleX)}px`, | ||
| 331 | + top: `${Math.round(y1 * scaleY)}px`, | ||
| 332 | + width: `${Math.round((x2 - x1) * scaleX)}px`, | ||
| 333 | + height: `${Math.round((y2 - y1) * scaleY)}px`, | ||
| 334 | + border: item.hideBorder ? 'none' : '2px solid ' + (index === this.activeIndex ? '#199601' : '#ff5252'), | ||
| 335 | + //backgroundColor: item.hideBorder ? 'transparent' : (this.activeIndex === item.field ? 'rgba(33,150,243,0.3)' : 'rgba(255,82,82,0.2)'), | ||
| 336 | + boxSizing: 'border-box', | ||
| 337 | + cursor: 'pointer', | ||
| 338 | + zIndex: item.isManual ? 30 : 10 | ||
| 339 | + }; | ||
| 340 | + }, | ||
| 341 | + | ||
| 342 | + highlightField(field) { | ||
| 343 | + let idx = -1; | ||
| 344 | + for (let i = this.ocrData.length - 1; i >= 0; i--) { | ||
| 345 | + const it = this.ocrData[i]; | ||
| 346 | + if (!it.isDeleted && it.field === field) { | ||
| 347 | + idx = i; | ||
| 348 | + break; | ||
| 349 | + } | ||
| 350 | + } | ||
| 351 | + this.activeIndex = idx === -1 ? null : idx; | ||
| 352 | + }, | ||
| 353 | + startSelect(e) { | ||
| 354 | + if (this.isMappingManually || e.button !== 0) return; | ||
| 355 | + this.isSelecting = true; | ||
| 356 | + const rect = this.$refs.pdfContainer.getBoundingClientRect(); | ||
| 357 | + this.selectBox.startX = e.clientX - rect.left; | ||
| 358 | + this.selectBox.startY = e.clientY - rect.top; | ||
| 359 | + this.selectBox.x = this.selectBox.startX; | ||
| 360 | + this.selectBox.y = this.selectBox.startY; | ||
| 361 | + this.selectBox.width = 0; | ||
| 362 | + this.selectBox.height = 0; | ||
| 363 | + this.selectBox.show = true; | ||
| 364 | + this.selectBox.showDropdown = false; | ||
| 365 | + this.manualField = ""; | ||
| 366 | + }, | ||
| 367 | + | ||
| 368 | + onSelect(e) { | ||
| 369 | + if (!this.isSelecting) return; | ||
| 370 | + const rect = this.$refs.pdfContainer.getBoundingClientRect(); | ||
| 371 | + const currentX = e.clientX - rect.left; | ||
| 372 | + const currentY = e.clientY - rect.top; | ||
| 373 | + this.selectBox.x = Math.min(currentX, this.selectBox.startX); | ||
| 374 | + this.selectBox.y = Math.min(currentY, this.selectBox.startY); | ||
| 375 | + this.selectBox.width = Math.abs(currentX - this.selectBox.startX); | ||
| 376 | + this.selectBox.height = Math.abs(currentY - this.selectBox.startY); | ||
| 377 | + }, | ||
| 378 | + | ||
| 379 | + endSelect(e) { | ||
| 380 | + if (!this.isSelecting) return; | ||
| 381 | + this.isSelecting = false; | ||
| 382 | + | ||
| 383 | + if (this.selectBox.width < 10 || this.selectBox.height < 10) { | ||
| 384 | + this.selectBox.show = false; | ||
| 385 | + return; | ||
| 386 | + } | ||
| 387 | + | ||
| 388 | + // displayed coords (như hiện tại, dùng để hiển thị select overlay) | ||
| 389 | + const dispX1 = this.selectBox.x; | ||
| 390 | + const dispY1 = this.selectBox.y; | ||
| 391 | + const dispX2 = this.selectBox.x + this.selectBox.width; | ||
| 392 | + const dispY2 = this.selectBox.y + this.selectBox.height; | ||
| 393 | + | ||
| 394 | + // scale: displayed -> original | ||
| 395 | + const displayedWidth = this.$refs.pdfImage.clientWidth; | ||
| 396 | + const displayedHeight = this.$refs.pdfImage.clientHeight; | ||
| 397 | + const scaleX = this.imageWidth / displayedWidth; | ||
| 398 | + const scaleY = this.imageHeight / displayedHeight; | ||
| 399 | + | ||
| 400 | + // bbox ở hệ gốc (original image pixels) — dùng để so sánh với ocrData và lưu vào ocrData | ||
| 401 | + const origBbox = [ | ||
| 402 | + Math.round(dispX1 * scaleX), | ||
| 403 | + Math.round(dispY1 * scaleY), | ||
| 404 | + Math.round(dispX2 * scaleX), | ||
| 405 | + Math.round(dispY2 * scaleY) | ||
| 406 | + ]; | ||
| 407 | + | ||
| 408 | + // Ẩn border các box OCR gốc nằm giao nhau với vùng thủ công (dùng coords gốc) | ||
| 409 | + this.ocrData.forEach(item => { | ||
| 410 | + if (!item.isManual && this.isBoxInside(item.bbox, origBbox)) { | ||
| 411 | + item.hideBorder = true; | ||
| 412 | + } | ||
| 413 | + }); | ||
| 414 | + | ||
| 415 | + // Thêm box thủ công (lưu theo coords gốc) | ||
| 416 | + this.ocrData.push({ | ||
| 417 | + text: "", | ||
| 418 | + bbox: origBbox, | ||
| 419 | + field: "", | ||
| 420 | + isManual: true, | ||
| 421 | + showDelete: true, | ||
| 422 | + isDeleted: false, | ||
| 423 | + hideBorder: false | ||
| 424 | + }); | ||
| 425 | + | ||
| 426 | + this.manualIndex = this.ocrData.length - 1; | ||
| 427 | + this.isMappingManually = true; | ||
| 428 | + this.selectBox.showDropdown = true; | ||
| 429 | + | ||
| 430 | + e.stopPropagation(); | ||
| 431 | + e.preventDefault(); | ||
| 432 | + } | ||
| 433 | + , | ||
| 434 | + applyMapping() { | ||
| 435 | + const item = this.ocrData[this.selectingIndex]; | ||
| 436 | + | ||
| 437 | + if (item && item.isManual) { | ||
| 438 | + this.manualIndex = this.selectingIndex; | ||
| 439 | + this.manualField = item.field || ""; | ||
| 440 | + this.applyManualMapping(); | ||
| 441 | + return; | ||
| 442 | + } | ||
| 443 | + | ||
| 444 | + if (item.field) { | ||
| 445 | + // this.formData[item.field] = item.text; | ||
| 446 | + // this.activeIndex = this.selectingIndex; | ||
| 447 | + this.assignFieldToBox(this.selectingIndex, item.field, item.text); | ||
| 448 | + } | ||
| 449 | + this.selectingIndex = null; | ||
| 450 | + }, | ||
| 451 | + applyManualMapping() { | ||
| 452 | + if (!this.manualField) return; | ||
| 453 | + const manualIndex = this.manualIndex; | ||
| 454 | + const newBbox = this.ocrData[manualIndex].bbox; | ||
| 455 | + | ||
| 456 | + let combinedText = []; | ||
| 457 | + this.ocrData.forEach(item => { | ||
| 458 | + if (!item.isManual && this.isBoxInside(item.bbox, newBbox) && item.text.trim()) { | ||
| 459 | + const partial = this.getPartialText(item.text, item.bbox, newBbox); | ||
| 460 | + if (partial) combinedText.push(partial); | ||
| 461 | + // combinedText.push(item.text.trim()); | ||
| 462 | + } | ||
| 463 | + }); | ||
| 464 | + | ||
| 465 | + const finalText = combinedText.join(" "); | ||
| 466 | + // this.ocrData[manualIndex].field = this.manualField; | ||
| 467 | + // this.formData[this.manualField] = finalText; | ||
| 468 | + // this.activeIndex = manualIndex; | ||
| 469 | + | ||
| 470 | + this.assignFieldToBox(manualIndex, this.manualField, finalText); | ||
| 471 | + | ||
| 472 | + // Reset trạng thái chọn | ||
| 473 | + this.isMappingManually = false; | ||
| 474 | + this.selectBox.show = false; | ||
| 475 | + this.selectBox.showDropdown = false; | ||
| 476 | + // this.manualField = ""; | ||
| 477 | + // this.manualIndex = null; | ||
| 478 | + }, | ||
| 479 | + | ||
| 480 | + isBoxInside(inner, outer) { | ||
| 481 | + return !( | ||
| 482 | + inner[2] < outer[0] || // box bên trái vùng chọn | ||
| 483 | + inner[0] > outer[2] || // box bên phải vùng chọn | ||
| 484 | + inner[3] < outer[1] || // box phía trên vùng chọn | ||
| 485 | + inner[1] > outer[3] // box phía dưới vùng chọn | ||
| 486 | + ); | ||
| 487 | + }, | ||
| 488 | + | ||
| 489 | + | ||
| 490 | + | ||
| 491 | + getPartialText(text, bbox, selectBbox) { | ||
| 492 | + const [x1, y1, x2, y2] = bbox; | ||
| 493 | + const [sx1, sy1, sx2, sy2] = selectBbox; | ||
| 494 | + | ||
| 495 | + // Chiều rộng box OCR | ||
| 496 | + const boxWidth = x2 - x1; | ||
| 497 | + | ||
| 498 | + // Vị trí start và end tương đối trong text | ||
| 499 | + let startRatio = Math.max(0, (sx1 - x1) / boxWidth); | ||
| 500 | + let endRatio = Math.min(1, (sx2 - x1) / boxWidth); | ||
| 501 | + | ||
| 502 | + const startIndex = Math.floor(startRatio * text.length); | ||
| 503 | + const endIndex = Math.ceil(endRatio * text.length); | ||
| 504 | + | ||
| 505 | + return text.substring(startIndex, endIndex).trim(); | ||
| 506 | + }, | ||
| 507 | + getSelectStyle(item) { | ||
| 508 | + if (!this.imageWidth) return { position: 'absolute' }; | ||
| 509 | + | ||
| 510 | + const [x1, y1, x2, y2] = item.bbox; | ||
| 511 | + const displayedWidth = this.$refs.pdfImage.clientWidth; | ||
| 512 | + const displayedHeight = this.$refs.pdfImage.clientHeight; | ||
| 513 | + const scaleX = displayedWidth / this.imageWidth; | ||
| 514 | + const scaleY = displayedHeight / this.imageHeight; | ||
| 515 | + | ||
| 516 | + return { | ||
| 517 | + position: 'absolute', | ||
| 518 | + left: `${Math.round(x1 * scaleX)}px`, | ||
| 519 | + top: `${Math.round(y2 * scaleY)}px`, | ||
| 520 | + zIndex: 9999 | ||
| 521 | + }; | ||
| 522 | + } | ||
| 523 | + | ||
| 524 | + } | ||
| 525 | + }); | ||
| 526 | + | ||
| 527 | +</script> | ||
| 528 | + | ||
| 529 | + | ||
| 530 | +</body></html> |
routes/web.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +use Illuminate\Support\Facades\Route; | ||
| 4 | +use App\Http\Controllers\OcrController; | ||
| 5 | +/* | ||
| 6 | +|-------------------------------------------------------------------------- | ||
| 7 | +| Web Routes | ||
| 8 | +|-------------------------------------------------------------------------- | ||
| 9 | +| | ||
| 10 | +| Here is where you can register web routes for your application. These | ||
| 11 | +| routes are loaded by the RouteServiceProvider within a group which | ||
| 12 | +| contains the "web" middleware group. Now create something great! | ||
| 13 | +| | ||
| 14 | +*/ | ||
| 15 | + | ||
| 16 | +Route::get('/', function () { | ||
| 17 | + return view('welcome'); | ||
| 18 | +}); | ||
| 19 | +Route::get('/mapping', [OcrController::class, 'index']); | ||
| 20 | +Route::get('/ocr/data-list', [OcrController::class, 'getData']); | ||
| 21 | +Route::post('/ocr/save-template', [OcrController::class, 'store'])->name('store'); |
-
Please register or sign in to post a comment