$questions * @return array{answers: array, missing: array, block_count: int} */ public function read(string $path, array $questions): array { return $this->mapAnswers($this->extractBlocks($path), $questions); } /** * @return list */ 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 $blocks * @param array $questions * @return array{answers: array, missing: array, 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 $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}> $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 $blockTokens * @param list $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 */ 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)); } }