<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\ComputerVision;
use seekquarry\yioop\library\CrawlConstants;
use seekquarry\yioop\library\processors\JpgProcessor;
use seekquarry\yioop\library\processors\PdfProcessor;
use seekquarry\yioop\library\UnitTest;
/**
* UnitTest for the PdfProcessor class. A PdfProcessor is used to process
* a .pdf file and extract summary from it. This
* class tests the processing of an .pdf file.
*
* @author Chris Pollett
*/
class PdfProcessorTest extends UnitTest implements CrawlConstants
{
/**
* Creates a new PdfProcessor object using the test.pdf
* file made from the seekquarry landing page
*/
public function setUp()
{
}
/**
* Delete any files associated with our test on PdfProcessor (in this case
* none)
*/
public function tearDown()
{
}
/**
* Test case to check whether words known to be in the PDF were extracted
* is retrieved correctly.
*/
public function wordExtractionTestCase()
{
$pdf_object = new PdfProcessor();
$url = "http://www.yioop.com/test.pdf";
$filename = C\PARENT_DIR . "/tests/test_files/test.pdf";
$page = file_get_contents($filename);
$summary = $pdf_object->process($page, $url);
$words = explode(" ", $summary[self::DESCRIPTION]);
$this->assertTrue(in_array("Documentation", $words),
"Word Extraction 1");
$this->assertTrue(in_array("Yioop", $words),
"Word Extraction 2");
$this->assertTrue(in_array("Open", $words),
"Word Extraction 3");
}
/**
* textFromImageTestCase checks the reading of words out of a picture
* by the reading program Yioop calls where one is installed, so a
* document kept as pictures of its pages is still indexed by what it
* says. The case does nothing where that program is absent.
*/
public function textFromImageTestCase()
{
if (ComputerVision::ocrEnabled()) {
$pdf_object = new PdfProcessor();
$url = "http://www.yioop.com/test2.pdf";
$filename = C\PARENT_DIR . "/tests/test_files/test2.pdf";
$page = file_get_contents($filename);
$summary = $pdf_object->process($page, $url);
$words = explode(" ", $summary[self::DESCRIPTION]);
$this->assertTrue(in_array("Maureen", $words),
"Word From Image Extraction 1");
$this->assertTrue(in_array("Phantom", $words),
"Word From Image Extraction 2");
$this->assertTrue(in_array("playing", $words),
"Word From Image Extraction 3");
}
}
/**
* Where a case that writes files puts them.
* @var string
*/
public $where = "";
/**
* The jpeg reader written here reads an ordinary jpeg the same as the
* one PHP comes with, which is known right. That is what says the
* reading is sound; the four-color case then differs from it only in
* how the values are turned into colors.
*/
public function readsAnOrdinaryJpegTheSameWayTestCase()
{
$made = imagecreatetruecolor(64, 48);
for ($down = 0; $down < 48; $down++) {
for ($across = 0; $across < 64; $across++) {
imagesetpixel($made, $across, $down,
imagecolorallocate($made, $across * 4, $down * 5, 128));
}
}
ob_start();
imagejpeg($made, null, 100);
$jpeg = ob_get_clean();
$read = JpgProcessor::readJpeg($jpeg);
$this->assertTrue($read !== false, "the jpeg is read");
$this->assertEqual($read["width"], 64, "at the right width");
$this->assertEqual(count($read["planes"]), 3, "with three colors");
$theirs = imagecreatefromstring($jpeg);
$worst = 0;
for ($down = 0; $down < 48; $down += 3) {
for ($across = 0; $across < 64; $across += 3) {
$said = imagecolorat($theirs, $across, $down);
$at = $down * 64 + $across;
$light = $read["planes"][0][$at];
$blueness = $read["planes"][1][$at] - 128;
$redness = $read["planes"][2][$at] - 128;
$mine = [max(0, min(255,
(int)round($light + 1.402 * $redness))),
max(0, min(255, (int)round($light -
0.344136 * $blueness - 0.714136 * $redness))),
max(0, min(255,
(int)round($light + 1.772 * $blueness)))];
$theirs_now = [($said >> 16) & 255, ($said >> 8) & 255,
$said & 255];
for ($which = 0; $which < 3; $which++) {
$worst = max($worst,
abs($mine[$which] - $theirs_now[$which]));
}
}
}
$this->assertTrue($worst <= 3,
"every color within rounding of what PHP's reader gives, " .
"worst was $worst");
}
/**
* Makes a picture of a given size to test with.
*
* @param int $width how wide
* @param int $height how tall
* @return string the picture's bytes as a jpeg
*/
public function aPicture($width, $height)
{
$made = imagecreatetruecolor($width, $height);
imagefill($made, 0, 0, imagecolorallocate($made, 30, 90, 200));
ob_start();
imagejpeg($made);
$bytes = ob_get_clean();
return $bytes;
}
/**
* The first picture kept whole inside a portable document is found,
* which is what a scanned page is, and a document holding none says
* so rather than handing back something that is not a picture.
*/
public function pictureInsideADocumentIsFoundTestCase()
{
$picture = $this->aPicture(300, 200);
$document = "%PDF-1.4\n5 0 obj<</Filter/DCTDecode>>stream\n" .
$picture . "\nendstream endobj";
$found = PdfProcessor::pictureInDocument($document);
$this->assertTrue($found !== false, "the picture is found");
$this->assertEqual(substr($found, 0, 2), "\xFF\xD8",
"and it is the picture rather than the words around it");
$this->assertTrue(PdfProcessor::pictureInDocument(
"%PDF-1.4\n5 0 obj<</Filter/FlateDecode>>stream\nxx\nendstream"
) === false, "a document with no such picture says so");
}
/**
* nameIsHandedBackWithTheWordThatUsesItTestCase checks that a name
* a page writes comes back from the walk with its slash, so the word
* that follows it knows which thing it works on. The name used to be
* read back out of the characters before that word, which failed
* wherever the word sat near the start of a drawing: a document of
* two pages that placed its cover thirty five characters in lost the
* cover.
*/
public function nameIsHandedBackWithTheWordThatUsesItTestCase()
{
$drawing = "q 576 0 0 783 0 0 cm /X3 Do Q";
$at = 0;
$seen = [];
while ($at < strlen($drawing)) {
$token = PdfProcessor::nextToken($drawing, $at);
if ($token === false) {
break;
}
if ($token !== "") {
$seen[] = $token;
}
}
$this->assertTrue(in_array("/X3", $seen),
"the name of the picture is one of the words read");
$where = array_search("/X3", $seen);
$this->assertEqual("Do", $seen[$where + 1] ?? "",
"and the word that places it follows the name");
$at = 0;
$token = PdfProcessor::nextToken("/X3 Do", $at);
$this->assertEqual("/X3", $token,
"a name at the very start of a drawing is read as well");
}
/**
* pointGoesThroughTheFrameInForceTestCase checks that a point of a
* page's own measure lands where the frame in force puts it. A page
* draws inside frames that may be moved, scaled, turned and leaned,
* and keeping only a scale and an offset put a turned drawing in the
* wrong place.
*/
public function pointGoesThroughTheFrameInForceTestCase()
{
$plain = PdfProcessor::plainFrame();
$where = PdfProcessor::throughFrame($plain, 7, 11);
$this->assertEqual(7, $where[0],
"a plain frame leaves a point where it is across");
$this->assertEqual(11, $where[1],
"a plain frame leaves it where it is up the page");
$turned = ["a" => 0.0, "b" => 1.0, "c" => -1.0, "d" => 0.0,
"e" => 0.0, "f" => 0.0];
$where = PdfProcessor::throughFrame($turned, 3, 0);
$this->assertEqual(0, round($where[0]),
"a quarter turn takes it off the across line");
$this->assertEqual(3, round($where[1]),
"and puts it that far up the page instead");
}
/**
* frameInsideAnotherStandsOnItTestCase checks that opening a frame
* inside another gives the frame that does the work of both, so what
* is drawn in the innermost is placed by all of them together.
*/
public function frameInsideAnotherStandsOnItTestCase()
{
$outer = ["a" => 2.0, "b" => 0.0, "c" => 0.0, "d" => 2.0,
"e" => 10.0, "f" => 20.0];
$inner = ["a" => 1.0, "b" => 0.0, "c" => 0.0, "d" => 1.0,
"e" => 5.0, "f" => 0.0];
$both = PdfProcessor::frameWithin($inner, $outer);
$where = PdfProcessor::throughFrame($both, 0, 0);
$this->assertEqual(20, round($where[0]),
"the inner move is scaled by the outer frame");
$this->assertEqual(20, round($where[1]),
"and the outer move still stands");
}
/**
* lettersWrittenAsHexDigitsAreReadTestCase checks that letters a
* page writes as pairs of hex digits come back as those letters. A
* page setting a headline in a font that numbers its own letters
* writes them that way, and reading only the ones between brackets
* left such a headline undrawn.
*/
public function lettersWrittenAsHexDigitsAreReadTestCase()
{
$this->assertEqual("AB", PdfProcessor::lettersFromHex("4142"),
"two pairs of digits are two letters");
$this->assertEqual("AB", PdfProcessor::lettersFromHex("41 42"),
"spaces between the pairs are passed over");
$this->assertEqual(3, strlen(PdfProcessor::lettersFromHex(
"41424")), "an odd digit is filled out rather than dropped");
}
/**
* tableOfDifferencesIsReadTestCase checks that the table a page
* carries beside a font, saying which number stands for which
* letter, is read. A font made for one document numbers its letters
* itself, and without the table a headline comes back as control
* characters.
*/
public function tableOfDifferencesIsReadTestCase()
{
$entry = "<< /Type /Font /Encoding 9 0 R >>";
$document = "%PDF-1.4\n9 0 obj\n<< /BaseEncoding " .
"/WinAnsiEncoding /Differences [ 14 /four /one /hyphen " .
"30 /O /M ] /Type /Encoding >>\nendobj\n";
$names = PdfProcessor::namesByCode($document, [], $entry);
$this->assertEqual("four", $names[14] ?? "",
"the first name goes with the number beside it");
$this->assertEqual("hyphen", $names[16] ?? "",
"the names after it follow one number at a time");
$this->assertEqual("O", $names[30] ?? "",
"a number in the middle starts the counting again");
}
/**
* runThisCannotReadIsNotDrawnTestCase checks that a run of words
* holding no letters a reader could recognize is turned away.
* Drawing such a run laid a solid box in the headline's color over
* the picture beneath it.
*/
public function runThisCannotReadIsNotDrawnTestCase()
{
$this->assertTrue(PdfProcessor::wordsCanBeDrawn("TRUMP WAS"),
"ordinary words are drawn");
$this->assertTrue(!PdfProcessor::wordsCanBeDrawn("\x0c\x0c\x0c"),
"a run of control characters is not");
$this->assertTrue(!PdfProcessor::wordsCanBeDrawn(""),
"and neither is a run with nothing in it");
}
/**
* loopInsideAnotherIsTheMiddleOfALetterTestCase checks that the
* inner loop of a letter such as an o is told from a letter of its
* own. Filling both left the o solid where its middle should show
* what lies beneath.
*/
public function loopInsideAnotherIsTheMiddleOfALetterTestCase()
{
$outside = [0, 0, 100, 0, 100, 100, 0, 100];
$middle = [30, 30, 70, 30, 70, 70, 30, 70];
$beside = [200, 0, 300, 0, 300, 100, 200, 100];
$this->assertTrue(PdfProcessor::loopWithinLoop($middle, $outside),
"a loop within the bounds of another is its middle");
$this->assertTrue(!PdfProcessor::loopWithinLoop($beside,
$outside), "a loop beside another is a letter of its own");
$this->assertTrue(!PdfProcessor::loopWithinLoop($outside,
$middle), "and the larger of two is never the middle");
}
/**
* tintOfOneInkIsDarkAtFullTestCase checks that a color said as one
* number in a space of the page's own is read as ink laid down
* rather than as a shade of gray. Read as gray, a barcode's bars
* came out white on a white box.
*/
public function tintOfOneInkIsDarkAtFullTestCase()
{
$full = PdfProcessor::inkFromTint([1]);
$none = PdfProcessor::inkFromTint([0]);
$this->assertEqual(0, $full[0], "the ink at full is dark");
$this->assertEqual(255, $none[0], "none of it is white");
}
/**
* onlyAPieceThatSaysSoIsWrittenInTestCase checks that a page's
* drawing takes in the drawing of a piece it hands off to, and that
* a picture whose settings happen to carry the word is left alone. A
* picture read as a piece has its bytes spliced into the drawing and
* is lost.
*/
public function onlyAPieceThatSaysSoIsWrittenInTestCase()
{
$page = "<< /Resources << /XObject << /X2 2 0 R /X3 3 0 R >> " .
">> >>";
$document = "%PDF-1.4\n" .
"2 0 obj\n<< /Subtype /Form /Length 8 >>\nstream\n" .
"1 0 0 rg\nendstream\nendobj\n" .
"3 0 obj\n<< /Subtype /Image /Filter /DCTDecode " .
"/Name /Form >>\nstream\nxxxx\nendstream\nendobj\n";
$drawing = PdfProcessor::piecesWrittenIn($document, [], $page,
"q /X2 Do Q q /X3 Do Q");
$this->assertTrue(strpos($drawing, "1 0 0 rg") !== false,
"the drawing of a piece is written in");
$this->assertTrue(strpos($drawing, "/X3 Do") !== false,
"a picture that only carries the word is left where it is");
}
/**
* fontCarriesOverAndPropertyStringsStayOutTestCase
* checks two things a magazine cover showed. A block that names no
* font of its own writes in the one the block before it set, so its
* words come back at the size they are drawn at; starting the block
* over at the plain size multiplied by the text matrix made a
* one-point font in a forty-one-fold frame read as four hundred
* ninety-two, and two giant letters covered the thumbnail. And a
* marked-content property list like /Lang (en-GB) is a note about
* the page, not words on it, so nothing of it may ride into the
* words.
*/
public function fontCarriesOverAndPropertyStringsStayOutTestCase()
{
$drawing = "/P <</Lang (en-GB)/MCID 1 >>BDC\n" .
"BT\n/T1_0 1 Tf\n41 0 0 41 194 402 Tm\n" .
"[(Could )1 (A)7 (I)5 (s)]TJ\nET\nEMC\n" .
"BT\n/P <</Lang (en-GB)/MCID 2 >>BDC\n" .
"41 0 0 41 214 364 Tm\n[(bec)7 (ome)]TJ\nEMC\nET\n";
$runs = PdfProcessor::wordsOnPage($drawing);
$sizes = [];
foreach ($runs as $run) {
$this->assertTrue(
strpos($run["words"], "en-GB") === false,
"no property-list string rides into the words");
$sizes[trim($run["words"])] = $run["size"];
}
$this->assertTrue(isset($sizes["become"]),
"the block with no font of its own still yields its words");
$this->assertEqual(41.0, $sizes["become"] ?? 0,
"and they carry the size the block before set, " .
"not the plain size multiplied by the frame");
}
/**
* base85LetteringOpensBackIntoItsBytesTestCase checks the
* base-85 lettering a stream may be written out as is turned back
* into its bytes: the known five letters that carry the word Man
* and a space, the lone z that stands for four zero bytes, and a
* short last group carrying one byte fewer than its letters.
*/
public function base85LetteringOpensBackIntoItsBytesTestCase()
{
$this->assertEqual("Man ",
PdfProcessor::bytesFromBase85("9jqo^~>"),
"five letters open into their four bytes");
$this->assertEqual("\x00\x00\x00\x00",
PdfProcessor::bytesFromBase85("z~>"),
"a lone z stands for four zero bytes");
$this->assertEqual("hi",
PdfProcessor::bytesFromBase85("BP@~>"),
"a short group gives one byte fewer than its letters");
}
/**
* sortedCatalogAndBase85DrawingStillReadTestCase walks the two
* faults a newspaper front page showed. Its catalog writes Pages
* before Type, so reading only what follows the type missed the
* pages reference and the page tree went unread; and its drawing
* is written as base-85 lettering around the packed bytes, which
* inflating as it stood read as damage and gave back nothing. The
* page here is built the same two ways, and its one word has to
* come back at its drawn size.
*/
public function sortedCatalogAndBase85DrawingStillReadTestCase()
{
$lettering =
"Garg^;:'MC<%q8W8Q.;t<%n;=+D#3d66WlH)Sr7q.>a=Y\$6`OZXs!f\$`X7h";
$document = "%PDF-1.4\n" .
"1 0 obj\n<< /Pages 2 0 R /Type /Catalog >>\nendobj\n" .
"2 0 obj\n<< /Count 1 /Kids [ 3 0 R ] /Type /Pages >>\n" .
"endobj\n" .
"3 0 obj\n<< /Contents 4 0 R /MediaBox [ 0 0 612 792 ]" .
" /Parent 2 0 R /Type /Page >>\nendobj\n" .
"4 0 obj\n<< /Filter [ /ASCII85Decode /FlateDecode ]" .
" /Length " . strlen($lettering) . " >>\nstream\n" .
$lettering . "~>\nendstream\nendobj\n" .
"trailer\n<< /Root 1 0 R >>\n%%EOF\n";
$page = PdfProcessor::firstPage($document);
$this->assertTrue($page !== false &&
strpos($page["drawing"], "Sunday") !== false,
"the sorted catalog is walked and the lettering opens " .
"into the drawing");
$runs = PdfProcessor::wordsOnPage($page["drawing"]);
$found_size = 0;
foreach ($runs as $run) {
if (trim($run["words"]) === "Sunday") {
$found_size = $run["size"];
}
}
$this->assertEqual(41.0, $found_size,
"and the page's one word comes back at its drawn size");
}
}