/ tests / PdfProcessorTest.php
<?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");
    }
}
X