tien_nemo

demo

<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Bus\DispatchesJobs;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
use AuthorizesRequests, DispatchesJobs, ValidatesRequests;
}
<?php
namespace App\Http\Controllers;
use App\Models\DtTemplate;
use App\Models\MstTemplate;
use Illuminate\Http\Request;
class OcrController extends Controller
{
public function index()
{
return view('ocr.index');
}
public function store(Request $request)
{
$request->validate([
'customer_name_text' => 'required|string',
'customer_name_xy' => 'required|string',
'tpl_name' => 'unique:mst_template',
]);
// Lưu vào bảng mst_template
$mst = MstTemplate::create([
'tpl_name' => $request->template_name,
'tpl_text' => $request->customer_name_text,
'tpl_xy' => $request->customer_name_xy,
]);
// Lưu các field khác vào dt_template
foreach ($request->fields as $field) {
DtTemplate::create([
'tpl_id' => $mst->id,
'field_name' => $field['name'],
'field_xy' => $field['xy'],
]);
}
return response()->json(['success' => true, 'message' => 'Lưu template thành công']);
}
public function getData()
{
// Giả sử file OCR JSON & ảnh nằm trong storage/app/public/image/
$jsonPath = public_path("image/data_picking_detail_1754967679.json");
$imgPath = ("image/data_picking_detail_1754967679.jpg");
$templateName = 'nemo_4';
/// Lấy từ request hoặc mặc định
if (!file_exists($jsonPath)) {
return response()->json(['error' => 'File not found'], 404);
}
$ocrData = json_decode(file_get_contents($jsonPath), true);
$formData = [];
if ($templateName) {
$mst = MstTemplate::where('tpl_name', $templateName)->first();
if ($mst) {
// Lấy detail của template
$details = DtTemplate::where('tpl_id', $mst->id)->get();
foreach ($details as $detail) {
$coords = array_map('intval', explode(',', $detail->field_xy));
// coords = [x1, y1, x2, y2]
// Tìm text OCR nằm trong bbox này
$text = $this->findTextInBBox($ocrData, $coords);
// field_name => text
$formData[$detail->field_name] = $text;
}
} else{
$formData = [
'export_date' => "",
'order_code' => "",
'customer' => "",
'address' => "",
'staff' => "",
'customer_name' => ""
];
}
}
return response()->json([
'ocrData' => $ocrData,
'pdfImageUrl' => $imgPath,
'formData' => $formData,
'fieldOptions' => [
[ 'value' => 'template_name', 'label' => 'Tên Mẫu PDF' ],
[ 'value' => 'customer_name', 'label' => 'Tên khách hàng' ],
[ 'value' => 'export_date', 'label' => 'Ngày xuất' ],
[ 'value' => 'order_code', 'label' => 'Mã đơn hàng' ],
[ 'value' => 'customer', 'label' => 'Khách hàng' ],
[ 'value' => 'address', 'label' => 'Địa chỉ' ],
[ 'value' => 'staff', 'label' => 'Nhân viên' ],
]
]);
}
private function findTextInBBox($ocrData, $coords)
{
[$x1, $y1, $x2, $y2] = $coords;
foreach ($ocrData as $item) {
[$ix1, $iy1, $ix2, $iy2] = $item['bbox'];
// Kiểm tra nếu bbox OCR nằm trong vùng bbox template
if ($ix1 >= $x1 && $iy1 >= $y1 && $ix2 <= $x2 && $iy2 <= $y2) {
return $item['text'];
}
}
return '';
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class DtTemplate extends Model
{
public $timestamps = false;
protected $table = 'dt_template';
protected $guarded = [];
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class MstTemplate extends Model
{
public $timestamps = false;
protected $table = 'mst_template';
protected $guarded = [];
}
<?php
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
define('LARAVEL_START', microtime(true));
/*
|--------------------------------------------------------------------------
| Check If The Application Is Under Maintenance
|--------------------------------------------------------------------------
|
| If the application is in maintenance / demo mode via the "down" command
| we will load this file so that any pre-rendered content can be shown
| instead of starting the framework, which could cause an exception.
|
*/
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
require $maintenance;
}
/*
|--------------------------------------------------------------------------
| Register The Auto Loader
|--------------------------------------------------------------------------
|
| Composer provides a convenient, automatically generated class loader for
| this application. We just need to utilize it! We'll simply require it
| into the script here so we don't need to manually load our classes.
|
*/
require __DIR__.'/../vendor/autoload.php';
/*
|--------------------------------------------------------------------------
| Run The Application
|--------------------------------------------------------------------------
|
| Once we have the application, we can handle the incoming request using
| the application's HTTP kernel. Then, we will send the response back
| to this client's browser, allowing them to enjoy our application.
|
*/
$app = require_once __DIR__.'/../bootstrap/app.php';
$kernel = $app->make(Kernel::class);
$response = $kernel->handle(
$request = Request::capture()
)->send();
$kernel->terminate($request, $response);
<html lang="en"><head>
<meta charset="UTF-8">
<title>OCR Mapping with Manual Select Tool</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2/dist/vue.js"></script>
<style>
body { font-family: sans-serif; background: #f5f5f5; }
#app { display: flex; gap: 20px; padding: 20px; }
.left-panel {
width: 500px; background: #fff; padding: 15px;
border-radius: 8px; box-shadow: 0 0 5px rgba(0,0,0,0.1);
}
.form-group { margin-bottom: 15px; }
.form-group label { font-weight: bold; display: block; margin-bottom: 5px; }
.form-group input { width: 100%; padding: 6px; border: 1px solid #ccc; border-radius: 4px; }
.right-panel { flex: 1; position: relative; background: #eee; border-radius: 8px; overflow: hidden; user-select: none; }
.pdf-container { position: relative; display: inline-block; }
.bbox {
position: absolute;
border: 2px solid #ff5252;
/*background-color: rgba(255, 82, 82, 0.2);*/
cursor: pointer;
}
.bbox.active {
/*border-color: #2196F3;*/
background-color: rgb(33 243 132 / 30%);
}
select {
position: absolute;
z-index: 10;
background: #fff;
border: 1px solid #ccc;
}
.select-box {
position: absolute;
/*border: 2px dashed #2196F3;*/
background-color: rgba(33, 150, 243, 0.2);
pointer-events: none;
z-index: 5;
}
.delete-btn {
position: absolute;
bottom: -10px;
right: -10px;
background: #ff4d4d;
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 14px;
padding: 3px 6px;
z-index: 20;
}
</style>
</head>
<body>
<meta name="csrf-token" content="{{ csrf_token() }}">
<div id="app">
<!-- Right: PDF viewer + select tool -->
<div class="right-panel" >
<div class="pdf-container" ref="pdfContainer"
@mousedown="startSelect"
@mousemove="onSelect"
@mouseup="endSelect">
<img
ref="pdfImage"
:src="pdfImageUrl"
@load="onImageLoad"
style="width: 100%; height: auto;pointer-events: none;"
/>
<!-- Vùng kéo chọn -->
<div v-if="selectBox.show" class="select-box"
:style="{ left: selectBox.x + 'px', top: selectBox.y + 'px', width: selectBox.width + 'px', height: selectBox.height + 'px' }"></div>
<!-- Vẽ bbox OCR -->
<div
v-for="(item, index) in ocrData"
:key="index"
v-if="!item.isDeleted"
class="bbox"
:class="{ active: index === activeIndex }"
:data-field="item.field"
:style="getBoxStyle(item, index)"
@click="selectingIndex = index">
<button v-if="item.isManual && item.showDelete"
class="delete-btn"
@click.stop="deleteBox(index)">🗑</button>
</div>
<!-- Dropdown OCR -->
<select v-if="selectingIndex !== null"
:style="getSelectStyle(ocrData[selectingIndex])"
v-model="ocrData[selectingIndex].field"
@change="applyMapping"
>
<option disabled value="">-- Chọn trường dữ liệu --</option>
<option v-for="field in fieldData" :value="field.value">@{{ field.label }}</option>
</select>
<!-- Dropdown thủ công -->
<select v-if="selectBox.showDropdown"
:style="{ left: selectBox.x + 'px', top: (selectBox.y + selectBox.height) + 'px' }"
v-model="manualField"
@change="applyManualMapping"
@click.stop
>
<option disabled value="">-- Chọn trường dữ liệu --</option>
<option v-for="field in fieldData" :value="field.value">@{{ field.label }}</option>
</select>
</div>
</div>
<!-- Left: Form inputs -->
<div class="left-panel">
<div v-for="field in fieldOptions" :key="field.value" class="form-group">
<label>@{{ field.label }}</label>
<input v-model="formData[field.value]"
@focus="highlightField(field.value)"
:readonly="field.value === 'customer_name' && !hasCustomerNameXY"
>
</div>
<button @click="saveTemplate">💾Save</button>
</div>
</div>
<script>
new Vue({
el: '#app',
data() {
return {
pdfImageUrl: "",
selectingIndex: null,
isMappingManually: false,
isSelecting: false,
activeIndex: null,
manualField: "",
formData: {},
fieldOptions: [],
customer_name_xy: '',
hasCustomerNameXY: false,
ocrData: [],
selectBox: { show: false, showDropdown: false, x: 0, y: 0, width: 0, height: 0, startX: 0, startY: 0 },
manualIndex: null
}
},
created() {
// Chỉ tạo formData cho các field cần mapping
this.fieldOptions
.filter(f => f.value !== "template_name")
.forEach(f => {
this.$set(this.formData, f.value, "");
});
},
mounted() {
this.loadOCRData();
},
computed: {
fieldData() {
// Lọc bỏ template_name nếu không cần cho phần form mapping
return this.fieldOptions.filter(f => f.value !== "template_name");
}
},
methods: {
assignFieldToBox(index, fieldName, text = null) {
if (index == null) return;
// Xóa fieldName ở box khác
this.ocrData.forEach((box, i) => {
if (i !== index && box.field === fieldName) {
box.field = null;
box.field_xy = null;
}
});
// Nếu box này từng gán field khác thì bỏ
const prev = this.ocrData[index].field;
if (prev && prev !== fieldName) {
if (prev === 'customer_name') {
this.hasCustomerNameXY = false;
this.customer_name_xy = '';
}
this.ocrData[index].field = null;
this.ocrData[index].field_xy = null;
}
// Gán field mới
const bbox = this.ocrData[index].bbox; // tọa độ OCR gốc [x1, y1, x2, y2]
console.log('1111111111111111',bbox);
const x1 = bbox[0];
const y1 = bbox[1];
const w = bbox[2];
const h = bbox[3];
const xyStr = `${x1},${y1},${w},${h}`;
this.ocrData[index].field = fieldName;
this.ocrData[index].field_xy = xyStr;
// Set text
this.formData[fieldName] = (text !== null ? text : (this.ocrData[index].text || '')).trim();
// Active index
this.activeIndex = index;
// Nếu là customer_name
if (fieldName === 'customer_name') {
this.hasCustomerNameXY = true;
this.customer_name_xy = xyStr;
}
},
async saveTemplate() {
if (!this.hasCustomerNameXY) {
alert("Bạn phải map customer_name (quét/select) trước khi lưu.");
return;
}
// build fields array: lấy những box có field gán, map unique by field name -> sử dụng field_xy
const fieldsByName = {};
this.ocrData.forEach(box => {
if (box.field && !box.isDeleted) {
// chỉ giữ 1 bản ghi cuối cùng cho mỗi field (box gần nhất)
fieldsByName[box.field] = {
name: box.field,
xy: box.field_xy || ''
};
}
});
// convert to array
const fields = Object.values(fieldsByName);
const payload = {
customer_name_text: this.formData.customer_name || '',
template_name: this.formData.template_name || this.formData.customer_name,
customer_name_xy: this.customer_name_xy,
fields: fields
};
console.log(fields);
try {
const res = await fetch('/ocr/save-template', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify(payload)
});
const json = await res.json();
if (json.success) {
alert(json.message);
} else {
alert('Save failed');
}
} catch (err) {
console.error(err);
alert('Save error');
}
},
deleteBox(index) {
const item = this.ocrData[index];
if (item.isManual) {
const manualBbox = item.bbox;
// Hiện lại border các box OCR gốc nằm trong vùng thủ công
this.ocrData.forEach(o => {
if (!o.isManual && this.isBoxInside(o.bbox, manualBbox)) {
o.hideBorder = false;
}
});
// Đánh dấu xoá vùng thủ công
this.ocrData[index].isDeleted = true;
this.ocrData[index].showDelete = false;
// Reset trạng thái nếu đây là vùng đang chọn
if (this.manualIndex === index) {
this.isMappingManually = false;
this.selectBox.show = false;
this.selectBox.showDropdown = false;
this.manualField = "";
this.manualIndex = null;
}
}
},
async loadOCRData() {
const res = await fetch(`/ocr/data-list`);
const data = await res.json();
if (data.error) {
console.error(data.error);
return;
}
this.ocrData = data.ocrData;
this.pdfImageUrl = data.pdfImageUrl;
this.formData = data.formData;
this.fieldOptions = data.fieldOptions;
},
onImageLoad() {
const img = this.$refs.pdfImage;
this.imageWidth = img.naturalWidth;
this.imageHeight = img.naturalHeight;
},
getBoxStyle(item, index) {
if (!this.imageWidth || !this.imageHeight || !this.$refs.pdfImage) return {};
const [x1, y1, x2, y2] = item.bbox;
const displayedWidth = this.$refs.pdfImage.clientWidth;
const displayedHeight = this.$refs.pdfImage.clientHeight;
const scaleX = displayedWidth / this.imageWidth;
const scaleY = displayedHeight / this.imageHeight;
return {
position: 'absolute',
left: `${Math.round(x1 * scaleX)}px`,
top: `${Math.round(y1 * scaleY)}px`,
width: `${Math.round((x2 - x1) * scaleX)}px`,
height: `${Math.round((y2 - y1) * scaleY)}px`,
border: item.hideBorder ? 'none' : '2px solid ' + (index === this.activeIndex ? '#199601' : '#ff5252'),
//backgroundColor: item.hideBorder ? 'transparent' : (this.activeIndex === item.field ? 'rgba(33,150,243,0.3)' : 'rgba(255,82,82,0.2)'),
boxSizing: 'border-box',
cursor: 'pointer',
zIndex: item.isManual ? 30 : 10
};
},
highlightField(field) {
let idx = -1;
for (let i = this.ocrData.length - 1; i >= 0; i--) {
const it = this.ocrData[i];
if (!it.isDeleted && it.field === field) {
idx = i;
break;
}
}
this.activeIndex = idx === -1 ? null : idx;
},
startSelect(e) {
if (this.isMappingManually || e.button !== 0) return;
this.isSelecting = true;
const rect = this.$refs.pdfContainer.getBoundingClientRect();
this.selectBox.startX = e.clientX - rect.left;
this.selectBox.startY = e.clientY - rect.top;
this.selectBox.x = this.selectBox.startX;
this.selectBox.y = this.selectBox.startY;
this.selectBox.width = 0;
this.selectBox.height = 0;
this.selectBox.show = true;
this.selectBox.showDropdown = false;
this.manualField = "";
},
onSelect(e) {
if (!this.isSelecting) return;
const rect = this.$refs.pdfContainer.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
this.selectBox.x = Math.min(currentX, this.selectBox.startX);
this.selectBox.y = Math.min(currentY, this.selectBox.startY);
this.selectBox.width = Math.abs(currentX - this.selectBox.startX);
this.selectBox.height = Math.abs(currentY - this.selectBox.startY);
},
endSelect(e) {
if (!this.isSelecting) return;
this.isSelecting = false;
if (this.selectBox.width < 10 || this.selectBox.height < 10) {
this.selectBox.show = false;
return;
}
// displayed coords (như hiện tại, dùng để hiển thị select overlay)
const dispX1 = this.selectBox.x;
const dispY1 = this.selectBox.y;
const dispX2 = this.selectBox.x + this.selectBox.width;
const dispY2 = this.selectBox.y + this.selectBox.height;
// scale: displayed -> original
const displayedWidth = this.$refs.pdfImage.clientWidth;
const displayedHeight = this.$refs.pdfImage.clientHeight;
const scaleX = this.imageWidth / displayedWidth;
const scaleY = this.imageHeight / displayedHeight;
// bbox ở hệ gốc (original image pixels) — dùng để so sánh với ocrData và lưu vào ocrData
const origBbox = [
Math.round(dispX1 * scaleX),
Math.round(dispY1 * scaleY),
Math.round(dispX2 * scaleX),
Math.round(dispY2 * scaleY)
];
// Ẩn border các box OCR gốc nằm giao nhau với vùng thủ công (dùng coords gốc)
this.ocrData.forEach(item => {
if (!item.isManual && this.isBoxInside(item.bbox, origBbox)) {
item.hideBorder = true;
}
});
// Thêm box thủ công (lưu theo coords gốc)
this.ocrData.push({
text: "",
bbox: origBbox,
field: "",
isManual: true,
showDelete: true,
isDeleted: false,
hideBorder: false
});
this.manualIndex = this.ocrData.length - 1;
this.isMappingManually = true;
this.selectBox.showDropdown = true;
e.stopPropagation();
e.preventDefault();
}
,
applyMapping() {
const item = this.ocrData[this.selectingIndex];
if (item && item.isManual) {
this.manualIndex = this.selectingIndex;
this.manualField = item.field || "";
this.applyManualMapping();
return;
}
if (item.field) {
// this.formData[item.field] = item.text;
// this.activeIndex = this.selectingIndex;
this.assignFieldToBox(this.selectingIndex, item.field, item.text);
}
this.selectingIndex = null;
},
applyManualMapping() {
if (!this.manualField) return;
const manualIndex = this.manualIndex;
const newBbox = this.ocrData[manualIndex].bbox;
let combinedText = [];
this.ocrData.forEach(item => {
if (!item.isManual && this.isBoxInside(item.bbox, newBbox) && item.text.trim()) {
const partial = this.getPartialText(item.text, item.bbox, newBbox);
if (partial) combinedText.push(partial);
// combinedText.push(item.text.trim());
}
});
const finalText = combinedText.join(" ");
// this.ocrData[manualIndex].field = this.manualField;
// this.formData[this.manualField] = finalText;
// this.activeIndex = manualIndex;
this.assignFieldToBox(manualIndex, this.manualField, finalText);
// Reset trạng thái chọn
this.isMappingManually = false;
this.selectBox.show = false;
this.selectBox.showDropdown = false;
// this.manualField = "";
// this.manualIndex = null;
},
isBoxInside(inner, outer) {
return !(
inner[2] < outer[0] || // box bên trái vùng chọn
inner[0] > outer[2] || // box bên phải vùng chọn
inner[3] < outer[1] || // box phía trên vùng chọn
inner[1] > outer[3] // box phía dưới vùng chọn
);
},
getPartialText(text, bbox, selectBbox) {
const [x1, y1, x2, y2] = bbox;
const [sx1, sy1, sx2, sy2] = selectBbox;
// Chiều rộng box OCR
const boxWidth = x2 - x1;
// Vị trí start và end tương đối trong text
let startRatio = Math.max(0, (sx1 - x1) / boxWidth);
let endRatio = Math.min(1, (sx2 - x1) / boxWidth);
const startIndex = Math.floor(startRatio * text.length);
const endIndex = Math.ceil(endRatio * text.length);
return text.substring(startIndex, endIndex).trim();
},
getSelectStyle(item) {
if (!this.imageWidth) return { position: 'absolute' };
const [x1, y1, x2, y2] = item.bbox;
const displayedWidth = this.$refs.pdfImage.clientWidth;
const displayedHeight = this.$refs.pdfImage.clientHeight;
const scaleX = displayedWidth / this.imageWidth;
const scaleY = displayedHeight / this.imageHeight;
return {
position: 'absolute',
left: `${Math.round(x1 * scaleX)}px`,
top: `${Math.round(y2 * scaleY)}px`,
zIndex: 9999
};
}
}
});
</script>
</body></html>
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\OcrController;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
Route::get('/', function () {
return view('welcome');
});
Route::get('/mapping', [OcrController::class, 'index']);
Route::get('/ocr/data-list', [OcrController::class, 'getData']);
Route::post('/ocr/save-template', [OcrController::class, 'store'])->name('store');