Add PDF Survey Importer with smart Thai questionnaire parser and preview editor

This commit is contained in:
pongpon pitisuk
2026-09-13 15:44:22 +07:00
parent a68bddb5c0
commit e0ed82fa74
75 changed files with 15407 additions and 10 deletions
+558
View File
@@ -0,0 +1,558 @@
<?php
/**
* Import Survey from PDF API
* POST ?action=parsePdf - Upload and parse PDF file to survey JSON
* POST ?action=confirmImport - Save parsed survey JSON into database
*/
require_once __DIR__ . '/../config/database.php';
require_once __DIR__ . '/../config/auth.php';
require_once __DIR__ . '/../vendor/autoload.php';
use Smalot\PdfParser\Parser;
header('Content-Type: application/json; charset=utf-8');
if (php_sapi_name() !== 'cli') {
requireLogin();
}
if (isset($_GET['action'])) {
$action = $_GET['action'] ?? '';
switch ($action) {
case 'parsePdf':
parsePdf();
break;
case 'confirmImport':
confirmImport();
break;
default:
jsonResponse(['error' => 'Invalid action'], 400);
}
}
/**
* Handle PDF upload and extraction
*/
function parsePdf() {
if (!isset($_FILES['pdf_file']) || $_FILES['pdf_file']['error'] !== UPLOAD_ERR_OK) {
$errorMsg = 'กรุณาเลือกไฟล์ PDF';
if (isset($_FILES['pdf_file']['error'])) {
switch ($_FILES['pdf_file']['error']) {
case UPLOAD_ERR_INI_SIZE:
case UPLOAD_ERR_FORM_SIZE:
$errorMsg = 'ขนาดไฟล์ใหญ่เกินขีดจำกัดของเซิร์ฟเวอร์';
break;
case UPLOAD_ERR_NO_FILE:
$errorMsg = 'ไม่มีไฟล์ถูกอัปโหลด';
break;
}
}
jsonResponse(['error' => $errorMsg], 400);
}
$file = $_FILES['pdf_file'];
$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
if ($ext !== 'pdf') {
jsonResponse(['error' => 'รองรับเฉพาะไฟล์เอกสาร PDF เท่านั้น'], 400);
}
$tmpPath = $file['tmp_name'];
try {
$parser = new Parser();
$pdf = $parser->parseFile($tmpPath);
$rawText = $pdf->getText();
} catch (\Throwable $e) {
jsonResponse(['error' => 'ไม่สามารถอ่านไฟล์ PDF ได้: ' . $e->getMessage()], 500);
}
$rawText = trim($rawText);
if (empty($rawText)) {
jsonResponse(['error' => 'ไม่พบข้อความในไฟล์ PDF (อาจเป็นไฟล์สแกนรูปภาพล้วนที่ไม่มี Text Layer)'], 400);
}
// Parse structured survey from raw text
$parsedSurvey = parseThaiSurveyText($rawText, pathinfo($file['name'], PATHINFO_FILENAME));
jsonResponse([
'success' => true,
'message' => 'อ่านและวิเคราะห์โครงสร้างแบบประเมินจาก PDF สำเร็จ',
'raw_text_preview' => mb_substr($rawText, 0, 500, 'UTF-8') . '...',
'survey' => $parsedSurvey
]);
}
/**
* Save finalized survey to DB
*/
function confirmImport() {
$input = json_decode(file_get_contents('php://input'), true);
$projectId = !empty($input['project_id']) ? (int)$input['project_id'] : null;
$surveyData = $input['survey_data'] ?? null;
if (!$surveyData || empty($surveyData['title'])) {
jsonResponse(['error' => 'ข้อมูลแบบประเมินไม่ถูกต้อง หรือไม่มีชื่อแบบประเมิน'], 400);
}
$db = getDB();
$uniqueLink = bin2hex(random_bytes(16));
$db->beginTransaction();
try {
$stmt = $db->prepare("INSERT INTO surveys (user_id, project_id, title, description, unique_link, status) VALUES (?, ?, ?, ?, ?, 'active')");
$stmt->execute([
$_SESSION['user_id'] ?? 1,
$projectId,
$surveyData['title'],
$surveyData['description'] ?? '',
$uniqueLink
]);
$surveyId = $db->lastInsertId();
$sections = $surveyData['sections'] ?? [];
$secNum = 1;
foreach ($sections as $sec) {
$stmt = $db->prepare("INSERT INTO survey_sections (survey_id, section_number, title, description, category) VALUES (?, ?, ?, ?, ?)");
$secCategory = $sec['category'] ?? 'rating';
$stmt->execute([
$surveyId,
$secNum,
$sec['title'] ?? ('ตอนที่ ' . $secNum),
$sec['description'] ?? '',
$secCategory
]);
$sectionId = $db->lastInsertId();
$orderNum = 1;
// Direct questions in this section
if (!empty($sec['questions'])) {
foreach ($sec['questions'] as $q) {
insertQuestionRecord($db, $sectionId, null, $q, $orderNum++);
}
}
// Headings with sub-questions (e.g. ด้านวิทยากร, ด้านสถานที่)
if (!empty($sec['headings'])) {
$headingOrder = 1;
foreach ($sec['headings'] as $h) {
$stmtH = $db->prepare("INSERT INTO survey_headings (section_id, title, description, order_num) VALUES (?, ?, ?, ?)");
$stmtH->execute([
$sectionId,
$h['title'] ?? ('ด้านที่ ' . $headingOrder),
$h['description'] ?? '',
$headingOrder++
]);
$headingId = $db->lastInsertId();
if (!empty($h['questions'])) {
foreach ($h['questions'] as $hq) {
insertQuestionRecord($db, $sectionId, $headingId, $hq, $orderNum++);
}
}
}
}
$secNum++;
}
$db->commit();
jsonResponse([
'success' => true,
'message' => 'สร้างแบบประเมินจาก PDF เรียบร้อยแล้ว',
'survey_id' => $surveyId,
'unique_link' => $uniqueLink,
'title' => $surveyData['title']
]);
} catch (\Throwable $e) {
$db->rollBack();
jsonResponse(['error' => 'บันทึกข้อมูลไม่สำเร็จ: ' . $e->getMessage()], 500);
}
}
function insertQuestionRecord($db, $sectionId, $headingId, $q, $orderNum) {
$qText = trim($q['question_text'] ?? '');
if (empty($qText)) return;
$qType = $q['question_type'] ?? 'radio';
$isRequired = isset($q['is_required']) ? (int)$q['is_required'] : 1;
$options = !empty($q['options']) && is_array($q['options']) ? json_encode(array_values($q['options']), JSON_UNESCAPED_UNICODE) : null;
$scaleMin = $q['scale_min'] ?? 1;
$scaleMax = $q['scale_max'] ?? 5;
$desc = $q['description'] ?? '';
$stmt = $db->prepare("INSERT INTO survey_questions (section_id, heading_id, question_text, question_type, is_required, order_num, options, scale_min, scale_max, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([
$sectionId,
$headingId,
$qText,
$qType,
$isRequired,
$orderNum,
$options,
$scaleMin,
$scaleMax,
$desc
]);
}
/**
* Intelligent Thai Survey Pattern Parser
*/
function parseThaiSurveyText($text, $defaultTitle = 'แบบประเมินความพึงพอใจ') {
// 1. Normalize line endings and whitespace
$text = str_replace(["\r\n", "\r"], "\n", $text);
// Replace special unicode bullets/boxes with standard marks
$text = preg_replace('/[\x{25A0}\x{25A1}\x{25CB}\x{25CF}\x{2610}\x{2611}\x{274F}\x{25EF}]/u', '[ ]', $text);
$rawLines = explode("\n", $text);
$lines = [];
foreach ($rawLines as $line) {
$trimmed = trim($line);
if ($trimmed !== '') {
$lines[] = $trimmed;
}
}
// 2. Extract Document Title and Description
$title = '';
$description = '';
$startIndex = 0;
$titleLines = [];
// Filter out standard scale headers/garbage lines like "5 4 3 2 1", "มากที่สุด มาก ปานกลาง..."
$ignoredPatterns = [
'/^ระดับความพึงพอใจ/u',
'/^ระดับความคิดเห็น/u',
'/^เกณฑ์การประเมิน/u',
'/^5\s+4\s+3\s+2\s+1/u',
'/^หมายเหตุ\s*:/u',
'/^หน้า\s*\d+/u',
'/^page\s*\d+/i'
];
for ($i = 0; $i < min(8, count($lines)); $i++) {
$line = $lines[$i];
// If we hit "ตอนที่" or "ส่วนที่" or "คำชี้แจง", stop title accumulation
if (preg_match('/^(ตอนที่|ส่วนที่|คำชี้แจง|คำอธิบาย|ข้อชี้แจง)/u', $line)) {
$startIndex = $i;
break;
}
if (preg_match('/(แบบประเมิน|แบบสอบถาม|โครงการ|การอบรม|รายงานการประเมิน|แบบสำรวจ)/u', $line)) {
$titleLines[] = $line;
$startIndex = $i + 1;
} elseif (empty($titleLines) && $i === 0) {
$titleLines[] = $line;
$startIndex = 1;
}
}
$title = !empty($titleLines) ? implode(' ', $titleLines) : ($defaultTitle ?: 'แบบประเมินความพึงพอใจ');
// 3. Scan for "คำชี้แจง" (Description)
for ($i = $startIndex; $i < min($startIndex + 6, count($lines)); $i++) {
$line = $lines[$i];
if (preg_match('/^(คำชี้แจง|คำอธิบาย|ข้อชี้แจง)[\s\:\.\-–]*(.*)/u', $line, $m)) {
$description = trim($m[2]);
if (empty($description) && isset($lines[$i + 1])) {
$description = $lines[$i + 1];
$startIndex = $i + 2;
} else {
$startIndex = $i + 1;
}
break;
}
}
// 4. Partition lines into Sections
$sections = [];
$currentSection = null;
for ($i = $startIndex; $i < count($lines); $i++) {
$line = $lines[$i];
// Check if line is a Section Header: "ตอนที่ 1...", "ส่วนที่ 1...", "ตอนที่ 2...", "ตอนที่ 3..."
if (preg_match('/^(ตอนที่|ส่วนที่)\s*([0-9๑-๙]+|หนึ่ง|สอง|สาม|สี่|ห้า)[\s\:\.\-–]*(.*)/u', $line, $matches)) {
if ($currentSection !== null) {
$sections[] = $currentSection;
}
$rawSecNum = $matches[2];
$secNumMap = ['หนึ่ง' => 1, 'สอง' => 2, 'สาม' => 3, 'สี่' => 4, '๑' => 1, '๒' => 2, '๓' => 3, '๔' => 4];
$secNum = $secNumMap[$rawSecNum] ?? (int)$rawSecNum;
if ($secNum <= 0) $secNum = count($sections) + 1;
$secTitle = trim($matches[0]);
$secCategory = 'rating';
if (preg_match('/(ข้อมูลทั่วไป|สถานภาพ|ข้อมูลส่วนตัว|สถานะ)/u', $secTitle)) {
$secCategory = 'general';
} elseif (preg_match('/(ข้อเสนอแนะ|ความคิดเห็น|ความเห็นเพิ่มเติม|ข้อคิดเห็น)/u', $secTitle)) {
$secCategory = 'suggestion';
} elseif (preg_match('/(ความพึงพอใจ|ระดับความพึงพอใจ|การประเมินผล|เกณฑ์)/u', $secTitle)) {
$secCategory = 'rating';
}
$currentSection = [
'section_number' => $secNum,
'title' => $secTitle,
'description' => '',
'category' => $secCategory,
'raw_lines' => []
];
continue;
}
if ($currentSection === null) {
// Default section 1 if not explicitly declared
$currentSection = [
'section_number' => 1,
'title' => 'ตอนที่ 1 ข้อมูลทั่วไปและข้อประเมิน',
'description' => '',
'category' => 'general',
'raw_lines' => []
];
}
$currentSection['raw_lines'][] = $line;
}
if ($currentSection !== null) {
$sections[] = $currentSection;
}
// 5. Parse each section's contents (Headings, Questions, Options, Rating Scales)
$parsedSections = [];
$secCounter = 1;
foreach ($sections as $sec) {
$secCategory = $sec['category'];
$rawSecLines = $sec['raw_lines'];
$headings = [];
$questions = [];
// Check if section contains sub-headings (e.g. ด้านที่ 1..., 1. ด้าน..., 2. ด้าน...)
$currentHeading = null;
$currentQuestion = null;
for ($j = 0; $j < count($rawSecLines); $j++) {
$line = $rawSecLines[$j];
// Ignore table scale legends / headers
$shouldSkip = false;
foreach ($ignoredPatterns as $pat) {
if (preg_match($pat, $line)) {
$shouldSkip = true;
break;
}
}
if ($shouldSkip) continue;
// Check if line is a Heading / Dimension:
// "ด้านที่ 1 ด้านวิทยากร", "1. ด้านวิทยากร", "ด้านกระบวนการขั้นตอน", "1. ด้านเนื้อหา"
if (preg_match('/^(ด้านที่\s*[0-9๑-๙]+|[0-9๑-๙]+\.\s*ด้าน|ด้าน[ก-๙]+)[\s\:\.\-–]*(.*)/u', $line) && !preg_match('/\[\s*\]/u', $line)) {
// Finalize previous question
if ($currentQuestion !== null) {
if ($currentHeading !== null) {
$currentHeading['questions'][] = $currentQuestion;
} else {
$questions[] = $currentQuestion;
}
$currentQuestion = null;
}
if ($currentHeading !== null) {
$headings[] = $currentHeading;
}
$currentHeading = [
'title' => $line,
'description' => '',
'questions' => []
];
continue;
}
// Check if line is a question
// e.g. "1. เพศ", "1.1 วิทยากรมีความรู้", "2. สถานภาพ", "• ความเหมาะสมของสถานที่"
// or starting with number: "1.1", "1.", "2)", "(1)"
$isNumberedQuestion = preg_match('/^([0-9๑-๙]+\.[0-9๑-๙]+[\.\)]?\s*|[0-9๑-๙]+[\.\)]\s*|[\-\•\*]\s+)(.*)/u', $line, $qMatch);
// Or general question prompt: "เพศ :", "ระดับการศึกษา :", "ชั้นปี :"
$isPromptQuestion = preg_match('/^(เพศ|สถานะ|สถานภาพ|อายุ|ระดับการศึกษา|ระดับชั้น|สาขาวิชา|แผนกวิชา|สังกัด|ตำแหน่ง|อาชีพ)[\s\:\.\-–]*/u', $line);
if ($isNumberedQuestion || ($isPromptQuestion && !preg_match('/\[\s*\]/u', $line))) {
// Save previous question
if ($currentQuestion !== null) {
if ($currentHeading !== null) {
$currentHeading['questions'][] = $currentQuestion;
} else {
$questions[] = $currentQuestion;
}
$currentQuestion = null;
}
$qText = $isNumberedQuestion ? trim($qMatch[2]) : $line;
// Remove numbering from question text for clean display if desired, or keep clear text
if (empty($qText)) $qText = $line;
// Determine default question type based on section or content
$qType = 'radio';
$scaleMin = 1;
$scaleMax = 5;
$options = null;
if ($secCategory === 'suggestion') {
$qType = 'text';
} elseif ($secCategory === 'rating' || $currentHeading !== null || preg_match('/^[0-9๑-๙]+\.[0-9๑-๙]+/u', $line)) {
$qType = 'radio';
$options = null; // Rating scale 1-5
} elseif ($secCategory === 'general') {
$qType = 'radio';
$options = [];
}
// Check if options are inline on the same line (e.g. "[ ] ชาย [ ] หญิง")
$inlineOptions = extractOptionsFromLine($line);
if (!empty($inlineOptions)) {
$options = $inlineOptions;
// Clean question text of options
$qText = preg_replace('/(\[\s*\]|\(\s*\)|[1-4]\)).*/u', '', $qText);
$qText = trim($qText, " :.-–");
if (empty($qText)) $qText = $line;
}
$currentQuestion = [
'question_text' => $qText,
'question_type' => $qType,
'is_required' => 1,
'description' => '',
'options' => $options,
'scale_min' => $scaleMin,
'scale_max' => $scaleMax
];
continue;
}
// Check if line contains options for the current question
// e.g. "[ ] ชาย [ ] หญิง", "( ) 1. นักเรียน ( ) 2. ครู", "1) ปวช. 2) ปวส."
if ($currentQuestion !== null) {
$extractedOptions = extractOptionsFromLine($line);
if (!empty($extractedOptions)) {
if (!is_array($currentQuestion['options'])) {
$currentQuestion['options'] = [];
}
foreach ($extractedOptions as $opt) {
if (!in_array($opt, $currentQuestion['options'])) {
$currentQuestion['options'][] = $opt;
}
}
$currentQuestion['question_type'] = 'radio';
continue;
}
// If this is a continuation of question text
if (empty($currentQuestion['options']) && $currentQuestion['question_type'] === 'radio' && $secCategory !== 'general') {
// Could be multi-line question text
if (mb_strlen($line, 'UTF-8') < 100 && !preg_match('/^[0-9]/', $line)) {
$currentQuestion['question_text'] .= ' ' . $line;
continue;
}
}
}
}
// Add last question
if ($currentQuestion !== null) {
if ($currentHeading !== null) {
$currentHeading['questions'][] = $currentQuestion;
} else {
$questions[] = $currentQuestion;
}
}
// Add last heading
if ($currentHeading !== null) {
$headings[] = $currentHeading;
}
// If section has neither questions nor headings, check if raw lines can be converted to text questions
if (empty($questions) && empty($headings) && !empty($rawSecLines)) {
foreach ($rawSecLines as $raw) {
if (mb_strlen($raw, 'UTF-8') > 3) {
$questions[] = [
'question_text' => $raw,
'question_type' => ($secCategory === 'suggestion') ? 'text' : 'radio',
'is_required' => 1,
'description' => '',
'options' => null,
'scale_min' => 1,
'scale_max' => 5
];
}
}
}
$parsedSections[] = [
'section_number' => $secCounter++,
'title' => $sec['title'],
'description' => $sec['description'],
'category' => $secCategory,
'headings' => $headings,
'questions' => $questions
];
}
// Default safety fallback if empty
if (empty($parsedSections)) {
$parsedSections[] = [
'section_number' => 1,
'title' => 'ตอนที่ 1 ข้อมูลและข้อคำถาม',
'description' => '',
'category' => 'general',
'headings' => [],
'questions' => []
];
}
return [
'title' => $title,
'description' => $description ?: 'แบบประเมินความพึงพอใจ นำเข้าจากไฟล์ PDF',
'sections' => $parsedSections
];
}
/**
* Extract multiple choice options from a line
*/
function extractOptionsFromLine($line) {
$options = [];
// Pattern 1: [ ] ชาย [ ] หญิง or ( ) 1. นักเรียน ( ) 2. ครู
if (preg_match_all('/(?:\[\s*\]|\(\s*\)|□|○)\s*([^\(\)\[\]□○\t\n]+)/u', $line, $matches)) {
foreach ($matches[1] as $opt) {
$cleanOpt = trim(preg_replace('/^[0-9๑-๙]+[\.\)]\s*/u', '', trim($opt)));
if (!empty($cleanOpt)) {
$options[] = $cleanOpt;
}
}
}
// Pattern 2: 1) ชาย 2) หญิง 3) อื่นๆ
if (empty($options) && preg_match_all('/(?:^|\s+)[0-9๑-๙]+[\)]\s*([^\d\)]+)/u', $line, $matches)) {
foreach ($matches[1] as $opt) {
$cleanOpt = trim($opt);
if (!empty($cleanOpt) && mb_strlen($cleanOpt, 'UTF-8') < 60) {
$options[] = $cleanOpt;
}
}
}
return $options;
}