Files
alrahma_sunday_school/app/Libraries/AssessmentDocxReader.php
T
root ce504c933e
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 51s
Tests / PHPUnit (push) Failing after 1m26s
fix assessment import and exams draft
2026-09-12 02:39:47 -04:00

309 lines
9.9 KiB
PHP

<?php
namespace App\Libraries;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
use RuntimeException;
use ZipArchive;
/**
* Extracts question/answer pairs from a Word document without executing macros
* or importing embedded content.
*/
class AssessmentDocxReader
{
/**
* @param array<int, array{id:mixed, text:mixed}> $questions
* @return array{answers: array<int, string>, missing: array<int, string>, block_count: int}
*/
public function read(string $path, array $questions): array
{
return $this->mapAnswers($this->extractBlocks($path), $questions);
}
/**
* @return list<string>
*/
public function extractBlocks(string $path): array
{
if (! is_file($path) || ! is_readable($path)) {
throw new RuntimeException("DOCX file is not readable: {$path}");
}
if (strtolower((string) pathinfo($path, PATHINFO_EXTENSION)) !== 'docx') {
throw new RuntimeException('The input file must have a .docx extension.');
}
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException('The input is not a readable DOCX archive.');
}
try {
$xml = $zip->getFromName('word/document.xml');
} finally {
$zip->close();
}
if (! is_string($xml) || $xml === '') {
throw new RuntimeException('The DOCX does not contain word/document.xml.');
}
$previous = libxml_use_internal_errors(true);
try {
$document = new DOMDocument();
if (! $document->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) {
throw new RuntimeException('The DOCX document XML is invalid.');
}
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
$xpath = new DOMXPath($document);
$xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
$body = $xpath->query('/w:document/w:body')->item(0);
if (! $body instanceof DOMElement) {
throw new RuntimeException('The DOCX has no document body.');
}
$blocks = [];
foreach ($body->childNodes as $node) {
if (! $node instanceof DOMElement) {
continue;
}
if ($node->localName === 'p') {
$this->appendBlock($blocks, $this->nodeText($xpath, $node));
continue;
}
if ($node->localName !== 'tbl') {
continue;
}
foreach ($xpath->query('.//w:tr/w:tc', $node) as $cell) {
$paragraphs = [];
foreach ($xpath->query('./w:p', $cell) as $paragraph) {
$text = trim($this->nodeText($xpath, $paragraph));
if ($text !== '') {
$paragraphs[] = $text;
}
}
$this->appendBlock($blocks, implode("\n", $paragraphs));
}
}
return $blocks;
}
/**
* @param list<string> $blocks
* @param array<int, array{id:mixed, text:mixed}> $questions
* @return array{answers: array<int, string>, missing: array<int, string>, block_count: int}
*/
public function mapAnswers(array $blocks, array $questions): array
{
$indexedQuestions = [];
foreach ($questions as $question) {
$id = (int) ($question['id'] ?? 0);
$text = trim((string) ($question['text'] ?? ''));
if ($id > 0 && $text !== '') {
$indexedQuestions[$id] = [
'text' => $text,
'tokens' => $this->tokens($text),
];
}
}
$answerParts = [];
$currentQuestionId = null;
foreach ($blocks as $block) {
$block = $this->cleanText($block);
if ($block === '') {
continue;
}
if ($currentQuestionId !== null && $this->isTerminalHeading($block)) {
break;
}
$match = $this->questionAtStart($block, $indexedQuestions);
if ($match !== null) {
$currentQuestionId = $match['id'];
$answerParts[$currentQuestionId] ??= [];
if ($match['answer'] !== '') {
$answerParts[$currentQuestionId][] = $match['answer'];
}
continue;
}
if ($currentQuestionId !== null) {
$answerParts[$currentQuestionId][] = $block;
}
}
$answers = [];
$missing = [];
foreach ($indexedQuestions as $id => $question) {
$answer = trim(implode("\n", $answerParts[$id] ?? []));
if ($answer === '') {
$missing[$id] = $question['text'];
} else {
$answers[$id] = $answer;
}
}
return ['answers' => $answers, 'missing' => $missing, 'block_count' => count($blocks)];
}
private function nodeText(DOMXPath $xpath, DOMNode $node): string
{
$text = '';
foreach ($xpath->query('.//w:t | .//w:tab | .//w:br | .//w:cr', $node) as $part) {
$text .= match ($part->localName) {
'tab' => "\t",
'br', 'cr' => "\n",
default => $part->textContent,
};
}
return $text;
}
/** @param list<string> $blocks */
private function appendBlock(array &$blocks, string $text): void
{
$text = $this->cleanText($text);
if ($text !== '') {
$blocks[] = $text;
}
}
private function cleanText(string $text): string
{
$text = str_replace(["\u{00A0}", "\r\n", "\r"], [' ', "\n", "\n"], $text);
$lines = preg_split('/\n/u', $text) ?: [];
$lines = array_map(static fn (string $line): string => trim((string) preg_replace('/[\t ]+/u', ' ', $line)), $lines);
return trim(implode("\n", array_filter($lines, static fn (string $line): bool => $line !== '')));
}
/**
* @param array<int, array{text:string, tokens:list<string>}> $questions
* @return array{id:int, answer:string}|null
*/
private function questionAtStart(string $block, array $questions): ?array
{
preg_match_all('/[\p{L}\p{N}]+(?:[\'\x{2019}][\p{L}\p{N}]+)*/u', $block, $matches, PREG_OFFSET_CAPTURE);
$blockTokens = $matches[0] ?? [];
if ($blockTokens === []) {
return null;
}
$start = 0;
if (isset($blockTokens[0]) && strtolower($blockTokens[0][0]) === 'question') {
$start = 1;
}
if (isset($blockTokens[$start]) && preg_match('/^\d+$/', $blockTokens[$start][0]) === 1) {
$start++;
}
$best = null;
foreach ($questions as $id => $question) {
$questionTokens = $question['tokens'];
if ($questionTokens === []) {
continue;
}
$lastMatchedIndex = $this->questionPrefixEnd($blockTokens, $start, $questionTokens);
if ($lastMatchedIndex === null) {
continue;
}
$lastToken = $blockTokens[$lastMatchedIndex];
$answerOffset = $lastToken[1] + strlen($lastToken[0]);
$answer = preg_replace('/^[\s?!.,:;\-\x{2013}\x{2014}\)\]]+/u', '', substr($block, $answerOffset));
$candidate = ['id' => (int) $id, 'answer' => trim((string) $answer), 'length' => count($questionTokens)];
if ($best === null || $candidate['length'] > $best['length']) {
$best = $candidate;
}
}
if ($best === null) {
return null;
}
unset($best['length']);
return $best;
}
/**
* Allows one accidentally omitted word in shorter questions and two in
* long questions while still requiring at least a 90% token match.
*
* @param array<int, array{0:string, 1:int}> $blockTokens
* @param list<string> $questionTokens
*/
private function questionPrefixEnd(array $blockTokens, int $start, array $questionTokens): ?int
{
if (! isset($blockTokens[$start]) || $this->normalizeToken($blockTokens[$start][0]) !== $questionTokens[0]) {
return null;
}
$blockIndex = $start;
$matched = 0;
$omitted = 0;
$lastMatchedIndex = null;
$allowedOmissions = count($questionTokens) >= 16 ? 2 : 1;
foreach ($questionTokens as $expected) {
if (isset($blockTokens[$blockIndex]) && $this->normalizeToken($blockTokens[$blockIndex][0]) === $expected) {
$lastMatchedIndex = $blockIndex;
$blockIndex++;
$matched++;
continue;
}
$omitted++;
if ($omitted > $allowedOmissions) {
return null;
}
}
if ($lastMatchedIndex === null || $matched < 6 || ($matched / count($questionTokens)) < 0.9) {
return null;
}
return $lastMatchedIndex;
}
private function isTerminalHeading(string $block): bool
{
$normalized = implode(' ', $this->tokens($block));
return in_array($normalized, [
'principal notes',
'interviewer notes',
'assessment notes',
'education committee notes',
'final admission decision',
], true);
}
/** @return list<string> */
private function tokens(string $text): array
{
preg_match_all('/[\p{L}\p{N}]+(?:[\'\x{2019}][\p{L}\p{N}]+)*/u', $text, $matches);
return array_map(fn (string $token): string => $this->normalizeToken($token), $matches[0] ?? []);
}
private function normalizeToken(string $token): string
{
return mb_strtolower(str_replace("\u{2019}", "'", $token));
}
}