<?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\CrawlConstants;
use seekquarry\yioop\library\processors\JpgProcessor;
use seekquarry\yioop\library\processors\PdfProcessor;
use seekquarry\yioop\library\CrawlConstants as CC;
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]);
/* The page's menu word Documentation left the description once the
second page's text was read too; crawling is from that page. */
$this->assertTrue(in_array("crawling", $words),
"Word Extraction 1");
$this->assertTrue(in_array("Yioop", $words),
"Word Extraction 2");
$this->assertTrue(in_array("Open", $words),
"Word Extraction 3");
}
/**
* faxCodedPictureIsWrittenAsShadesTestCase writes a picture packed by
* fax coding, as a page scanned in black and white often is, to a
* file tesseract can read. The picture is 16 points across and 2 down;
* its coding gives each row 8 points of white then 8 of black. Read a
* byte at a time, as before, its bits made a blank picture, and the
* words of such a page were never read.
*/
public function faxCodedPictureIsWrittenAsShadesTestCase()
{
$said = "<< /Type /XObject /Subtype /Image /Width 16 /Height 2 " .
"/BitsPerComponent 1 /Filter /CCITTFaxDecode " .
"/DecodeParms << /K -1 /Columns 16 >> >>";
$object = $said . "\nstream\n" . hex2bin("3317001001") .
"\nendstream";
$path = PdfProcessor::pictureForReading($said, $object,
C\TEST_DIR . "/test_files/fax_test");
$written = ($path === "") ? "" : file_get_contents($path);
@unlink($path);
$row = str_repeat("\xff", 8) . str_repeat("\x00", 8);
$this->assertEqual($written, "P5\n16 2\n255\n" . $row . $row,
"each row is 8 white points then 8 black ones");
}
/**
* pictureInInksIsWrittenAsGrayTestCase writes a picture kept in the
* four inks of a press, two points across: one with no ink, one with
* full black ink. Such pictures were not written for tesseract at
* all; now the first point comes out white and the second black.
*/
public function pictureInInksIsWrittenAsGrayTestCase()
{
$said = "<< /Type /XObject /Subtype /Image /Width 2 /Height 1 " .
"/ColorSpace /DeviceCMYK /BitsPerComponent 8 >>";
$object = $said . "\nstream\n\x00\x00\x00\x00\x00\x00\x00\xff" .
"\nendstream";
$path = PdfProcessor::pictureForReading($said, $object,
C\TEST_DIR . "/test_files/inks_test");
$written = ($path === "") ? "" : file_get_contents($path);
@unlink($path);
$this->assertEqual($written, "P5\n2 1\n255\n\xff\x00",
"no ink is white and full black ink is black");
}
/**
* 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");
}
/**
* makePicture draws a plain jpeg of the width and height asked for,
* for a case that needs a picture whose size it knows.
*
* @param int $width how wide
* @param int $height how tall
* @return string the picture's bytes as a jpeg
*/
public function makePicture($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->makePicture(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");
}
/**
* A font that numbers its letters writes each as two bytes, and the
* first byte of a low numbered letter is zero. A run of such words
* keeps that zero byte, since cutting it read every letter one byte
* out of step and left a cover's headline undrawn.
*/
public function runKeepsTheZeroByteOfANumberedLetterTestCase()
{
$runs = PdfProcessor::wordsOnPage(
"BT /F6 52 Tf 23 491 Td <001C0002> Tj ET");
$this->assertEqual("001c0002", bin2hex($runs[0]["words"] ?? ""),
"a run keeps the zero byte that begins a numbered letter");
}
/**
* A color set inside a saved state ends when that state is restored,
* so words drawn after the restore take the color from before it. A
* cover set its masthead's blue inside such a state and its headline
* in white after, and reading the blue on through drew the headline
* blue.
*/
public function restoredStateBringsBackItsColorTestCase()
{
$runs = PdfProcessor::wordsOnPage("0 0 0 0 k q .736 .432 0 .074 k " .
"q Q Q BT /F6 52 Tf 23 491 Td <001C0002> Tj ET");
$this->assertEqual([255, 255, 255], $runs[0]["ink"] ?? null,
"words after a restore are in the color set before the save");
}
/**
* A composite font lists how wide its letters are as a table of runs
* and ranges, and both forms read into the face.
*/
public function widthTableReadsRunsAndRangesTestCase()
{
$face = new \stdClass();
$face->stated_widths = [];
PdfProcessor::widthsFromLetterTable($face,
" 1 [ 647 603 727 ] 10 12 500 ");
$this->assertEqual([1 => 647.0, 2 => 603.0, 3 => 727.0,
10 => 500.0, 11 => 500.0, 12 => 500.0], $face->stated_widths,
"a run of widths and a range with one width both read");
}
/**
* A page may set its letters tighter or looser than their own widths
* with a spacing operator, and the run carries that spacing so the
* letters are drawn as far apart as the page shows them.
*/
public function runCarriesTheLetterSpacingSetTestCase()
{
$runs = PdfProcessor::wordsOnPage(
"BT /F6 52 Tf 23 491 Td -13 Tc <001C0002> Tj ET");
$this->assertEqual(-13.0, $runs[0]["spacing"] ?? null,
"a run carries the letter spacing in force when it was set");
}
/**
* A page may draw a form that keeps its own resources, and a picture
* named only inside that form is still one of the page's pictures. A
* cover that was one photograph inside one form came out blank when
* only the page's own list was read.
*/
public function pictureNamedInsideAFormIsFoundTestCase()
{
$document = "1 0 obj << /Type /Page /Resources << /XObject << " .
"/Fm1 2 0 R >> >> >> endobj\n" .
"2 0 obj << /Type /XObject /Subtype /Form /Resources << " .
"/XObject << /X2 3 0 R >> >> /Length 0 >> stream\n" .
"endstream endobj\n";
$page = "<< /Type /Page /Resources << /XObject << /Fm1 2 0 R >> " .
">> >>";
$listing = PdfProcessor::pageResourceEntries($document, [], $page,
"XObject");
$this->assertTrue(strpos($listing, "/X2 3 0 R") !== false,
"a picture named inside a form the page draws is listed");
}
/**
* spacedTitleIsReadTestCase checks that a title written with a space
* before its parenthesis, as /Title (Annual Report) is in the sample,
* is read, and that the sample's page text, written in base-85
* lettering, is read too.
*/
public function spacedTitleIsReadTestCase()
{
$processor = new PdfProcessor();
$summary = $processor->process(file_get_contents(__DIR__ .
"/test_files/spaced_title.pdf"),
"https://example.com/spaced_title.pdf");
$this->assertTrue($summary[CC::TITLE] == "Annual Report",
"the title is read");
$this->assertTrue(str_contains($summary[CC::DESCRIPTION],
"Nitrate levels fell"), "the text of a page written in " .
"base-85 lettering after a /Type /Pages object is read");
}
/**
* embeddedFontIsNotReadAsTextTestCase checks that the bytes of an
* embedded font program, marked by /Length1, are not read as words,
* and that the text of both pages is read. The file's made-up font
* program is text drawing "fontnoise" if it were read as a page.
*/
public function embeddedFontIsNotReadAsTextTestCase()
{
$processor = new PdfProcessor();
$summary = $processor->process(file_get_contents(__DIR__ .
"/test_files/embedded_font.pdf"),
"https://example.com/embedded_font.pdf");
$this->assertTrue(str_contains($summary[CC::DESCRIPTION],
"First page about rivers"), "the first page's text is read");
$this->assertTrue(str_contains($summary[CC::DESCRIPTION],
"Second page about glaciers"), "the second page's text is read");
$this->assertFalse(str_contains($summary[CC::DESCRIPTION],
"fontnoise"), "the font program is not read as words");
}
/**
* subsetFontLettersAreReadTestCase checks that text shown in a font
* that numbers its own letters is read through the font's ToUnicode
* map, both its single codes and its ranges, one range listing each
* letter and one counting up from a first. The sample shows Crème as
* 01 02 03 04 05, мир as 06 07 08, and abc as 09 0A 0B.
*/
public function subsetFontLettersAreReadTestCase()
{
$processor = new PdfProcessor();
$summary = $processor->process(file_get_contents(__DIR__ .
"/test_files/subset_font.pdf"),
"https://example.com/subset_font.pdf");
foreach (["Crème", "мир", "abc"] as $word) {
$this->assertTrue(str_contains($summary[CC::DESCRIPTION], $word),
"$word is read");
}
}
/**
* thinPictureIsNotReadTestCase checks that getText does not hand
* tesseract a picture one pixel high, which is how a slide deck keeps
* the strips of a shaded background. A deck from a crawl held 843 of
* them, and reading them took 34 seconds and gave no words.
*/
public function thinPictureIsNotReadTestCase()
{
$document = "%PDF-1.4\n";
for ($i = 1; $i <= 3; $i++) {
$document .= "$i 0 obj\n<< /Type /XObject /Subtype /Image " .
"/Width 300 /Height 1 /ColorSpace /DeviceGray " .
"/BitsPerComponent 8 /Length 300 >>\nstream\n" .
str_repeat("\x80", 300) . "\nendstream\nendobj\n";
}
$before = L\ComputerVision::$ocr_time;
PdfProcessor::getText($document, "https://example.com/thin.pdf");
$this->assertEqual($before, L\ComputerVision::$ocr_time,
"no time is spent reading pictures one pixel high");
}
/**
* runAfterAChangeOfFontFollowsTestCase checks that words a page writes
* on from where the pen stopped, after a change of font with no move
* between, are marked as following the run before them, and that
* words after a move are not. A cover set its title's S, I and G in
* one font and its A in another, and the A was drawn over the S.
*/
public function runAfterAChangeOfFontFollowsTestCase()
{
$runs = PdfProcessor::wordsOnPage("BT /T1_1 1 Tf " .
"62 0 0 62 320 674 Tm (SIG)Tj /T1_2 1 Tf (A)Tj " .
"2.079 0 Td (C)Tj ET");
$follows = [];
foreach ($runs as $run) {
$follows[$run["words"]] = $run["follows"] ?? null;
}
$this->assertEqual(["SIG" => false, "A" => true, "C" => false],
$follows, "only the run written on from the pen follows");
}
/**
* colorSetInABlockCarriesToTheNextTestCase checks that a color set
* inside one block of text is still in force in the next. A table of
* contents set an orange page number, then black inside the same
* block for its title, and the title's next block came out orange.
*/
public function colorSetInABlockCarriesToTheNextTestCase()
{
$runs = PdfProcessor::wordsOnPage("0 0.64 0.95 0 k " .
"BT /F1 10 Tf 1 0 0 1 10 10 Tm (1)Tj 0 0 0 1 k (Title)Tj ET " .
"BT 1 0 0 1 50 10 Tm (rest)Tj ET");
$inks = [];
foreach ($runs as $run) {
$inks[$run["words"]] = $run["ink"];
}
$this->assertEqual([255, 92, 13], $inks["1"] ?? null,
"the page number is orange");
$this->assertEqual([0, 0, 0], $inks["rest"] ?? null,
"the next block keeps the black set before it");
}
/**
* letterCodedAsALineEndIsKeptTestCase checks that a run whose letter
* is coded as a carriage return is kept. A font that numbers its own
* letters used that code for t, and cutting it as blank space left a
* gap wherever a t stood alone between two moves.
*/
public function letterCodedAsALineEndIsKeptTestCase()
{
$runs = PdfProcessor::wordsOnPage("BT /T1_0 10 Tf 10 10 Td " .
"(\\r)Tj ET");
$this->assertEqual("\r", $runs[0]["words"] ?? "",
"the letter coded as a carriage return is kept");
}
/**
* pageWithNoPicturesIsDrawnFromItsShapesTestCase checks that a page
* naming no pictures among its resources is still drawn from its
* shapes. Such a page was refused, and the thumbnail was made from a
* photograph on a later page with the cover's words laid over it.
*/
public function pageWithNoPicturesIsDrawnFromItsShapesTestCase()
{
$page = ["drawing" => "1 0 0 rg 0 0 100 100 re f", "wide" => 100,
"high" => 100, "object" => "<< /Type /Page >>",
"shapes" => [["corners" => [[0, 0], [100, 0], [100, 100],
[0, 100]], "holes" => [], "ink" => [255, 0, 0]]]];
$picture = PdfProcessor::pageDrawnFromItsParts("%PDF-1.4\n", [],
$page, 20, 20);
$this->assertTrue($picture !== false, "the page is drawn");
if ($picture === false) {
return;
}
$middle = imagecolorat($picture, 10, 10);
$this->assertEqual([255, 0, 0], [($middle >> 16) & 0xff,
($middle >> 8) & 0xff, $middle & 0xff],
"in the color its shape is filled with");
}
/**
* pageWhoseWordsCannotBeDrawnGoesBackTestCase checks that a page with
* no pictures, whose words can be drawn neither in the document's
* letters nor in our own, is handed back for the older way. A slide
* turned on its side, in fonts that could not be read, came out as a
* blank white page.
*/
public function pageWhoseWordsCannotBeDrawnGoesBackTestCase()
{
$page = ["drawing" => "BT /TT2 1 Tf 0 44 -44 0 226 105 Tm " .
"(Source)Tj ET", "wide" => 612, "high" => 792,
"object" => "<< /Type /Page >>", "shapes" => [["corners" =>
[[0, 0], [612, 0], [612, 792], [0, 792]], "holes" => [],
"ink" => [255, 255, 255]]]];
$this->assertTrue(PdfProcessor::pageDrawnFromItsParts(
"%PDF-1.4\n", [], $page, 20, 20) === false,
"the page is handed back rather than drawn blank");
}
/**
* letterWithAMiddleKeepsItsMiddleClearTestCase checks a letter with a
* middle, drawn on a sheet the size of its box, against points of the
* picture: the ring is in the letter's ink, the middle shows what was
* there before, and what lies outside the letter is untouched. The
* letter runs off the left of the picture, so the box is cut to the
* picture there.
*/
public function letterWithAMiddleKeepsItsMiddleClearTestCase()
{
$canvas = imagecreatetruecolor(20, 20);
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 0, 0, 255));
$page = ["wide" => 20, "high" => 20];
PdfProcessor::fillShapeOnPicture($canvas, ["corners" => [[-4, 4],
[12, 4], [12, 16], [-4, 16]], "holes" => [[[2, 8], [8, 8],
[8, 12], [2, 12]]], "ink" => [255, 0, 0]], $page, 1, 1);
$shade = function ($across, $down) use ($canvas) {
$color = imagecolorat($canvas, $across, $down);
return [($color >> 16) & 0xff, ($color >> 8) & 0xff,
$color & 0xff];
};
$this->assertEqual([255, 0, 0], $shade(0, 10),
"the ring at the picture's edge is in the letter's ink");
$this->assertEqual([255, 0, 0], $shade(10, 10),
"the ring on the other side is in the letter's ink");
$this->assertEqual([0, 0, 255], $shade(5, 10),
"the middle shows what was beneath");
$this->assertEqual([0, 0, 255], $shade(15, 10),
"past the letter the picture is untouched");
$this->assertEqual([0, 0, 255], $shade(5, 1),
"above the letter the picture is untouched");
}
/**
* bundleReadsOnlyItsOwnDictionaryTestCase checks that two bundles of
* objects written one right after the other are each cut by their
* own /First and /N. The second bundle's look back reached the first
* one's dictionary, whose numbers were read in place of its own, and
* a magazine's page tree came apart so that a later page was taken
* for the cover.
*/
public function bundleReadsOnlyItsOwnDictionaryTestCase()
{
$first_bundle = gzcompress("7 0 8 5 (one)(two)");
$second_list = "9 0 10 22 ";
$second_bundle = gzcompress($second_list .
"<</Type/Pages/Kids[]>><</Type/Page>>");
$document = "%PDF-1.5\n1 0 obj\n<</Filter/FlateDecode/First 8" .
"/Length " . strlen($first_bundle) . "/N 2/Type/ObjStm>>" .
"stream\n" . $first_bundle . "\nendstream\nendobj\n" .
"2 0 obj\n<</Filter/FlateDecode/First " . strlen($second_list) .
"/Length " . strlen($second_bundle) . "/N 2/Type/ObjStm>>" .
"stream\n" . $second_bundle . "\nendstream\nendobj\n";
$held = PdfProcessor::packedObjects($document);
$this->assertEqual("(one)", $held[7] ?? "",
"the first bundle's first object is read whole");
$this->assertEqual("<</Type/Pages/Kids[]>>", $held[9] ?? "",
"the second bundle's first object is cut by its own /First");
$this->assertEqual("<</Type/Page>>", $held[10] ?? "",
"and so is its second");
}
}