/ src / library / processors / PdfProcessor.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\library\processors;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\Library as L;
use seekquarry\yioop\library\ComputerVision;
use seekquarry\yioop\library\UrlParser;

/**
 * Used to create crawl summary information
 * for PDF files
 *
 * @author Chris Pollett
 */
class PdfProcessor extends TextProcessor
{
    /**
     * How wide a page is taken to be when it does not say, in the units a
     * document measures in, which is a letter-sized page.
     */
    const PAGE_WIDE = 612;
    /**
     * How tall a page is taken to be when it does not say.
     */
    const PAGE_HIGH = 792;
    /**
     * What size words are taken to be set at when a page does not say.
     */
    const PLAIN_SIZE = 12;
    /**
     * How many packed-down things to look through for the first page's
     * instructions before giving up. A document may hold thousands, and
     * the front of it is at the front.
     */
    const MOST_STREAMS = 400;
    /**
     * How far back from a bundle's mark to look for what it says about
     * itself.
     */
    const HEAD_LOOK = 300;
    /**
     * How far down a page tree to follow before giving up.
     */
    const MOST_STEPS = 32;
    /**
     * The largest a color may be.
     */
    const MOST_SHADE = 255;
    /**
     * How many straight steps a curve is drawn in when a shape is filled.
     */
    const CURVE_STEPS = 6;
    /**
     * How many numbers to keep before an instruction, which is more than
     * any instruction takes.
     */
    const MOST_NUMBERS = 8;
    /**
     * Set-ups the any indexing plugins associated with this page
     * processor
     *
     * @param array $plugins an array of indexing plugins which might
     *     do further processing on the data handles by this page
     *     processor
     * @param int $max_description_len maximal length of a page summary
     * @param int $max_links_to_extract maximum number of links to extract
     *      from a single document
     * @param string $summarizer_option CRAWL_CONSTANT specifying what kind
     *      of summarizer to use self::BASIC_SUMMARIZER,
     *      self::GRAPH_BASED_SUMMARIZER and self::CENTROID_SUMMARIZER
     *      self::CENTROID_SUMMARIZER
     */
    public function __construct($plugins = [], $max_description_len = null,
        $max_links_to_extract = null,
        $summarizer_option = self::BASIC_SUMMARIZER)
    {
        parent::__construct($plugins, $max_description_len,
            $max_links_to_extract,$summarizer_option);
        /** Register File Types We Handle*/
        self::$indexed_file_types[] = "pdf";
        self::$mime_processor["application/pdf"] = "PdfProcessor";
    }
    /**
     * Used to extract the title, description and links from
     * a string consisting of PDF data.
     *
     * @param $page  a string consisting of web-page contents
     * @param $url  the url where the page contents came from,
     *    used to canonicalize relative links
     *
     * @return a summary of the contents of the page
     *
     */
    public function process($page, $url)
    {
        $text = "";
        if (is_string($page)) {
            list($encoding, $title) = self::getEncodingTitle($page);
            $text =  self::getText($page, $url, $encoding);
        }
        if ($text == "") {
            $text = $url;
        }
        $summary = parent::process($text, $url);
        if ($title) {
            $summary[self::TITLE] = $title;
        }
        return $summary;
    }
    /**
     * Used to create a thumbnail file in a thumb folder from a PDF file.
     * A page kept as one picture, which is what a scanned page is, has
     * that picture scaled down; a page of words has its opening words
     * drawn. Neither needs any program outside Yioop.
     *
     * @param string $folder with pdf in it
     * @param string $thumb_folder folder to generate
     * @param string $file_name of pdf file in $folder
     * @param int $width = width in pixels of thumb
     * @param int $height = height in pixels of thumb
     */
    public static function createThumb($folder, $thumb_folder, $file_name,
        $width = C\THUMB_DIM, $height = C\THUMB_DIM)
    {
        if (!function_exists("imagecreatetruecolor")) {
            return;
        }
        $thumb_path = "$thumb_folder/$file_name.webp";
        if (file_exists($thumb_path)) {
            @unlink($thumb_path);
        }
        $document = @file_get_contents("$folder/$file_name");
        if ($document === false) {
            return;
        }
        /* A page kept as one picture is what the thumbnail is made from
           when there is one, with the page's own words laid over it, since
           a cover carries its title and headlines on top of its picture. A
           page with no picture has its words drawn on their own. */
        $picture = self::pictureInDocument($document);
        /* Only the first page is read. Asking for the whole document's
           words runs a reader over every picture in it looking for more,
           which for a magazine is hundreds of pictures and minutes of
           work, all of it thrown away for a thumbnail of the front. */
        $page = self::firstPage($document);
        $runs = ($page === false) ? [] :
            self::wordsOnPage($page["drawing"]);
        $words = "";
        foreach ($runs as $run) {
            $words .= $run["words"] . " ";
        }
        if ($picture !== false && self::scaleTo($picture, $thumb_path,
            $width, $height, "webp", $runs,
            ($page === false) ? null : $page)) {
            clearstatcache(true, $thumb_path);
            return;
        }
        /* Neither a picture that can be read nor any words to draw means
           there is nothing to show. No thumbnail is written at all, since
           a blank square says less than the absence of one. */
        if (trim($words) === "") {
            return;
        }
        self::drawText($words, $thumb_path, $width, $height);
        clearstatcache(true, $thumb_path);
    }
    /**
     * Returns the first encoding format information found in the PDF document
     *
     * @param string $pdf_string a string representing the PDF document
     * @return array [encoding, title] which of the default (if any) PDF
     * encoding formats is being used: MacRomanEncoding, WinAnsiEncoding,
     * PDFDocEncoding, etc as well as a title for the document if found
     *
     */
    public static function getEncodingTitle($pdf_string)
    {
        $len = strlen($pdf_string);
        $cur_pos = 0;
        $out = "";
        $i = 0;
        set_error_handler(null);
        $encoding = "";
        $title = "";
        while($cur_pos < $len && (!$encoding || !$title)) {
            list($cur_pos, $object_string) =
                self::getNextObject($pdf_string, $cur_pos);
            $object_dictionary = self::getObjectDictionary($object_string);
            if (preg_match("/\/(\w+Encoding)\b/", $object_dictionary,
                $match) != false) {
                $encoding = $match[1];
            }
            if (preg_match("/\/Title\(([^\)]+)\)/", $object_dictionary,
                $match) != false) {
                $title = $match[1];
            }
        }
        restore_error_handler();
        return [$encoding, $title];
    }
    /**
     * Gets the text out of a PDF document
     *
     * @param string $pdf_string a string representing the PDF document
     * @param $url  the url where the page contents came from,
     *    used to canonicalize relative links
     * @param string $encoding which of the default (if any) PDF encoding
     *    formats is being used: MacRomanEncoding, WinAnsiEncoding,
     *    PDFDocEncoding, etc.
     * @return string text extracted from the document
     */
    public static function getText($pdf_string, $url, $encoding = "")
    {
        $len = strlen($pdf_string);
        $cur_pos = 0;
        $out = "";
        $i = 0;
        set_error_handler(null);
        $state = "text";
        $temp_dir = C\TEMP_DIR . "/";
        if (!file_exists($temp_dir)) {
             mkdir($temp_dir);
        }
        if (!file_exists($temp_dir)) {
            return null;
        }
        $lang = UrlParser::getLang($url);
        while($cur_pos < $len) {
            list($cur_pos, $object_string) =
                self::getNextObject($pdf_string, $cur_pos);
            $object_dictionary = self::getObjectDictionary($object_string);
            if (ComputerVision::ocrEnabled() &&
                self::objectDictionaryHas($object_dictionary, ["Image"]) &&
                self::objectDictionaryHas($object_dictionary, ["XObject"]) &&
                self::objectDictionaryHas($object_dictionary, ["Width"]) &&
                self::objectDictionaryHas($object_dictionary, ["Height"]) &&
                !self::objectDictionaryHas($object_dictionary, ["ImageMask"])) {
                $stream_data = ltrim(self::getObjectStream($object_string));
                preg_match("/\/Width\s+(\d+)\b/", $object_dictionary, $matches);
                $width = $matches[1] ?? 0;
                preg_match("/\/Height\s+(\d+)\b/", $object_dictionary,
                    $matches);
                $height = $matches[1] ?? 0;
                preg_match("/\/BitsPerComponent\s+(\d+)\b/", $object_dictionary,
                    $matches);
                $bits_per_component = $matches[1] ?? 8;
                preg_match("/\/ColorSpace\s+(Device)?(Gray|RGB|CMYK)\b/",
                    $object_dictionary, $matches);
                $color_space = $matches[2] ?? "RGB";
                $is_jpeg = preg_match("/\/Filter\s+\/DCTDecode\b/",
                    $object_dictionary);
                if (!$width || !$height || $color_space == "CMYK") {
                    continue;
                }
                $is_rgb = ($color_space == "RGB");
                if (self::objectDictionaryHas($object_dictionary,
                    ["FlateDecode"])) {
                    $stream_data = @gzuncompress($stream_data);
                }
                if ($is_jpeg) {
                    // if pdf corrupted this can also through an error
                    @$image  = imagecreatefromstring($stream_data);
                } else {
                    $image  = imagecreatetruecolor($width, $height);
                    $pix_loc = 0;
                    for($y = 0; $y < $height; $y++) {
                        for($x = 0; $x < $width; $x++) {
                            if ($is_rgb) {
                                $r = empty($stream_data[$pix_loc]) ? 255 :
                                    ord($stream_data[$pix_loc]);
                                $g = empty($stream_data[$pix_loc + 1]) ? 255 :
                                    ord($stream_data[$pix_loc + 1]);
                                $b = empty($stream_data[$pix_loc + 2]) ? 255 :
                                    ord($stream_data[$pix_loc + 2]);
                                $pix_loc += 3;
                            } else {
                                $r = empty($stream_data[$pix_loc]) ? 255 :
                                    ord($stream_data[$pix_loc]);
                                $g = $r;
                                $b = $r;
                                $pix_loc++;
                            }
                            $color = imagecolorallocate($image, $r, $g, $b);
                            imagesetpixel($image, $x, $y, $color);
                        }
                    }
                }
                $temp_file = $temp_dir . L\crawlHash($stream_data) . ".png";
                if ($image) {
                    @imagepng($image, $temp_file);
                    $ocr_data = ComputerVision::recognizeText($temp_file,
                        [$lang]);
                    if (!empty($ocr_data)) {
                        $out  .= $ocr_data;
                    }
                    @unlink($temp_file);
                }
            } else if (self::objectDictionaryHas(
                $object_dictionary, ["Type", "Font", "FontDescriptor"])) {
                $state = "font";
                continue;
            }
            if (!self::objectDictionaryHas(
                $object_dictionary, ["Image", "Catalog"])) {
                $stream_data =
                    rtrim(ltrim(self::getObjectStream($object_string)));
                /* The object after a font describes it, and is skipped;
                   the state is then put back so that everything following
                   is read. It was being compared rather than set, so once
                   a font had been seen every object after it was skipped
                   and a document with a font near its start gave up no
                   words at all. */
                if ($state == 'font') {
                    $state = 'text';
                    continue;
                }
                if (self::objectDictionaryHas(
                    $object_dictionary, ["FlateDecode"])) {
                    $opened = @gzuncompress($stream_data);
                    if ($opened === false) {
                        /* Written without the wrapper the usual call
                           expects, which is allowed and does happen. */
                        $opened = @gzinflate($stream_data);
                    }
                    $stream_data = ($opened === false) ? "" : $opened;
                    if (strpos($stream_data, "PS-AdobeFont")) {
                        $out .= $stream_data . "\n\n";
                    }
                    $text = self::parseText($stream_data, $encoding);
                    $out .= $text. "\n\n";
                } else {
                    $text = self::parseText($stream_data, $encoding);
                    if (strpos($stream_data, "PS-AdobeFont")){
                        $out .= $stream_data . "\n\n";
                    }
                    $out .= $text . "\n\n";
                }
            }
        }
        restore_error_handler();
        $font_pos = strpos($out, "PS-AdobeFont");
        if (!$font_pos) {
            $font_pos = strlen($out);
        }
        $out = substr($out, 0, $font_pos);
        return $out;
    }
    /**
     * Gets between an obj and endobj tag at the current position in a PDF
     * document
     *
     * @param string $pdf_string astring of a PDF document
     * @param int $cur_pos a integer position in that string
     * @return string the contents of the PDF object located at $cur_pos
     */
    public static function getNextObject($pdf_string, $cur_pos)
    {
        return self::getBetweenTags($pdf_string, $cur_pos, "obj", "endobj");
    }
    /**
     * Checks if the PDF object's object dictionary is in a list of types
     *
     * @param string $object_dictionary the object dictionary to check
     * @param array $type_array the list of types to check against
     * @return whether it is in or not
     */
    public static function objectDictionaryHas($object_dictionary, $type_array)
    {
        foreach ($type_array as $type) {
            if (strstr($object_dictionary, $type)) {
                return true;
            }
        }
        return false;
    }
    /**
     * Gets the object dictionary portion of the current PDF object
     * @param string $object_string represents the contents of a PDF object
     * @return string the object dictionary for the object
     */
    public static function getObjectDictionary($object_string)
    {
        list( , $object_dictionary) =
            self::getBetweenTags($object_string, 0, '<<', '>>');
        return $object_dictionary;
    }
    /**
     * Gets the object stream portion of the current PDF object
     *
     * @param string $object_string represents the contents of a PDF object
     * @return string the object stream for the object
     */
    public static function getObjectStream($object_string)
    {
        list( , $stream_data) =
            self::getBetweenTags($object_string, 0, 'stream', 'endstream');
        return $stream_data;
    }
    /**
     * Extracts text from PDF data, getting rid of non printable data,
     * square brackets and parenthesis and converting char codes to their
     * values.
     *
     * @param string $data source to extract character data from
     * @param string $encoding which of the default (if any) PDF encoding
     *    formats is being used: MacRomanEncoding, WinAnsiEncoding,
     *    PDFDocEncoding, etc.
     * @return string extracted text
     */
    public static function parseText($data, $encoding = "")
    {
        /* Words written between the marks that begin and end a piece of
           text are read a block at a time, the way the first page is read
           for a thumbnail. Reading them any other way splits a word
           wherever the instructions happen to break a line. */
        if (strpos($data, "BT") !== false) {
            $said = "";
            foreach (self::wordsOnPage($data) as $run) {
                $said .= $run["words"] . "\n";
            }
            if (trim($said) !== "") {
                return $said;
            }
        }
        $cur_pos = 0;
        //replace ASCII codes in decimal with their value
        $data = preg_replace_callback('/\\\(\d{3})/',
            function($matches) {
                return chr(intval($matches[1]));
            },
            $data);
        //replace ASCII codes in hex with their value
        $data = preg_replace_callback('/\<([0-9A-F]{2})\>/',
            function($matches) {
                return chr(hexdec($matches[1]));
            },
            $data);
        $len = strlen($data);
        $out = "";
        $escape_flag = false;
        while($cur_pos < $len) {
            $cur_char = $data[$cur_pos];
            if ($cur_char == '[' && !$escape_flag) {
                list($cur_pos, $text) = self::parseBrackets($data, $cur_pos,
                    $encoding);
                $cur_pos--;
                $out .= " ". $text;
            }
            if ($cur_char == '\\') {
                $escape_flag = true;
            } else {
                $escape_flag = false;
            }
            $cur_pos++;
        }
        return $out;
    }
    /**
     * Extracts text till the next close brackets
     *
     * @param string $data source to extract character data from
     * @param int $cur_pos position to start in $data
     * @param string $encoding which of the default (if any) PDF encoding
     *    formats is being used: MacRomanEncoding, WinAnsiEncoding,
     *    PDFDocEncoding, etc.
     * @return array pair consisting of the final position in $data as well
     *     as extracted text
     */
    public static function parseBrackets($data, $cur_pos, $encoding = "")
    {
        $cur_pos++;
        $len = strlen($data);
        $out = "";
        $escape_flag =false;
        $cur_char = "";
        while($cur_pos < $len && ($cur_char != "]")) {
            $cur_char = $data[$cur_pos];
            if ($cur_char == '(') {
                list($cur_pos, $text) = self::parseParentheses($data, $cur_pos,
                    $encoding);
                $cur_pos --;
                $out .= $text;
            }
            $cur_pos++;
        }
        if (isset($data[$cur_pos]) && isset($data[$cur_pos + 1]) &&
            ord($data[$cur_pos]) == ord('T') &&
                ord($data[$cur_pos + 1]) == ord('J') ) {
            if (isset($data[$cur_pos + 3]) &&
                ord($data[$cur_pos + 3]) != ord('F')) {
                $out .= " ";
            } else {
                $out .= "\n";
            }
        }
        return [$cur_pos, $out];
    }
    /**
     * Extracts ASCII text till the next close parenthesis
     *
     * @param string $data source to extract character data from
     * @param int $cur_pos position to start in $data
     * @param string $encoding which of the default (if any) PDF encoding
     *    formats is being used: MacRomanEncoding, WinAnsiEncoding,
     *    PDFDocEncoding, etc.
     * @return array pair consisting of the final position in $data as well
     *     as extracted text
     */
    public static function parseParentheses($data, $cur_pos, $encoding)
    {
        $cur_pos++;
        $len = strlen($data);
        $out = "";
        $escape_flag =false;
        $cur_char = "";
        while($cur_pos < $len && ($cur_char != ")" || $escape_flag)) {
            $cur_char = $data[$cur_pos];
            if ($cur_char == '\\' && !$escape_flag) {
                $escape_flag = true;
            } else {
                if ($escape_flag || $cur_char !=")") {
                    $out .= self::convertChar($cur_char, $encoding);
                }
                $escape_flag = false;
            }
            $cur_pos++;
        }
        $check_positioning = substr($data, $cur_pos, 4);
        if (preg_match("/\-\d{3}/", $check_positioning) > 0 ) {
            $out .= " ";
        }
        return [$cur_pos, $out];
    }
    /**
     * Used to convert characters from one of the built in PDF
     * encodings to UTF-8
     * @param char $cur_char character to convert
     * @param string $encoding which of the default (if any) PDF encoding
     *    formats is being used: MacRomanEncoding, WinAnsiEncoding,
     *    PDFDocEncoding, etc.
     * @return string resultign converted string for character
     */
    public static function convertChar($cur_char, $encoding)
    {
        $ascii = ord($cur_char);
        if ((9 <= $ascii && $ascii <= 13) ||
            (32 <= $ascii && $ascii <= 126)) {
            return $cur_char;
        }
        if ($encoding == "MacRomanEncoding") {
            return match ($ascii) {
                190 => 'ae',
                198 => 'AE',
                206 => 'OE',
                207 => 'oe',
                222 => 'fi',
                223 => 'fl',
                default => "",
            };
        } else if ($encoding == "WinAnsiEncoding") {
            return match ($ascii) {
                140 => 'OE',
                156 => 'oe',
                198 => 'AE',
                230 => 'ae',
                default => "",
            };
        }
        return "";
    }
    /**
     * Finds the first picture stored whole inside a portable document.
     * A scanned page is kept as one such picture, so this is what a
     * scanned document's thumbnail is made from. Only pictures kept in a
     * form a browser also understands are taken; anything else is left to
     * the words.
     *
     * @param string $document the portable document's bytes
     * @return string|bool the picture's bytes, or false if there is none
     */
    public static function pictureInDocument($document)
    {
        $at = 0;
        while (($at = strpos($document, "/DCTDecode", $at)) !== false) {
            $begins = strpos($document, "stream", $at);
            if ($begins === false) {
                return false;
            }
            $begins += strlen("stream");
            while (isset($document[$begins]) &&
                ($document[$begins] === "\r" ||
                $document[$begins] === "\n")) {
                $begins++;
            }
            $ends = strpos($document, "endstream", $begins);
            if ($ends === false) {
                return false;
            }
            $picture = substr($document, $begins, $ends - $begins);
            /* A picture kept this way is a jpeg, which begins with two
               settled bytes; anything else here is something else. One
               written for print, in the four colors ink uses, is taken
               too: the jpeg reader works those out rather than handing
               back a black rectangle as PHP's own reader does. */
            if (substr($picture, 0, 2) === "\xFF\xD8") {
                if (@getimagesizefromstring($picture) !== false) {
                    return $picture;
                }
            }
            $at = $ends;
        }
        return false;
    }
    /**
     * Finds the first page of a document and hands back what draws it:
     * the instructions for that page and how large the page is.
     *
     * Only the first page is looked at. A thumbnail shows the front of a
     * document, so reading the rest of it, and running a reader over every
     * picture in it looking for words, is work whose answer is thrown
     * away.
     *
     * @param string $document the document's bytes
     * @return array|bool the instructions and the page's size, or false
     */
    public static function firstPage($document)
    {
        $held = self::packedObjects($document);
        $page = self::firstPageObject($document, $held);
        if ($page === false) {
            return self::firstDrawing($document);
        }
        /* What a reader is shown is the cropped part of the page when it
           says one, since a page may carry more than it shows. */
        $wide = self::PAGE_WIDE;
        $high = self::PAGE_HIGH;
        $from_across = 0;
        $from_down = 0;
        $box = '/(?:Crop|Media)Box\s*\[\s*([-\d.]+)\s+([-\d.]+)\s+' .
            '([-\d.]+)\s+([-\d.]+)/';
        if (preg_match(str_replace("(?:Crop|Media)", "Crop", $box), $page,
            $found) ||
            preg_match(str_replace("(?:Crop|Media)", "Media", $box), $page,
            $found)) {
            $from_across = (float)$found[1];
            $from_down = (float)$found[2];
            $wide = (float)$found[3] - $from_across;
            $high = (float)$found[4] - $from_down;
        }
        /* A page's drawing may be written as several pieces, which follow
           one another as though they were one. */
        $drawing = "";
        if (preg_match('/\/Contents\s*\[([^\]]*)\]/', $page, $listed)) {
            preg_match_all('/(\d+)\s+\d+\s+R/', $listed[1], $each);
            foreach ($each[1] as $number) {
                $drawing .= self::streamOfObject($document, $held,
                    (int)$number) . "\n";
            }
        } else if (preg_match('/\/Contents\s+(\d+)\s+\d+\s+R/', $page,
            $named)) {
            $drawing = self::streamOfObject($document, $held,
                (int)$named[1]);
        }
        if (trim($drawing) === "") {
            return self::firstDrawing($document);
        }
        return ["drawing" => $drawing, "wide" => $wide, "high" => $high,
            "from_across" => $from_across, "from_down" => $from_down,
            "shapes" => self::shapesOnPage($drawing)];
    }
    /**
     * Hands back the run of instructions a numbered object holds, opened
     * if it was packed down.
     *
     * @param string $document the document's bytes
     * @param array $held what each bundled object holds
     * @param int $number which object
     * @return string what it holds, empty if it cannot be opened
     */
    public static function streamOfObject($document, $held, $number)
    {
        $object = self::objectNumbered($document, $held, $number);
        $at = strpos($object, "stream");
        if ($at === false) {
            return "";
        }
        $at += strlen("stream");
        while (isset($object[$at]) &&
            ($object[$at] === "\r" || $object[$at] === "\n")) {
            $at++;
        }
        $ends = strpos($object, "endstream", $at);
        $raw = ($ends === false) ? substr($object, $at) :
            substr($object, $at, $ends - $at);
        if (strpos($object, "FlateDecode") === false) {
            return $raw;
        }
        $opened = @gzuncompress($raw);
        if ($opened === false) {
            $opened = @gzinflate($raw);
        }
        return ($opened === false) ? "" : $opened;
    }
    /**
     * Opens the packed-down bundles a document keeps most of its objects
     * in, and hands back what each numbered object holds.
     *
     * A document may write every object out plainly, or it may bundle
     * groups of them together and pack the bundle down. Looking through
     * the file for a plainly written object finds nothing at all in the
     * second case, which is why the page tree was not being found.
     *
     * @param string $document the document's bytes
     * @return array what each numbered object holds, by number
     */
    public static function packedObjects($document)
    {
        $held = [];
        $at = 0;
        while (($found = strpos($document, "/ObjStm", $at)) !== false) {
            $begins = strpos($document, "stream", $found);
            if ($begins === false) {
                break;
            }
            $head = substr($document, max(0, $found - self::HEAD_LOOK),
                $begins - max(0, $found - self::HEAD_LOOK));
            $begins += strlen("stream");
            while (isset($document[$begins]) &&
                ($document[$begins] === "\r" ||
                $document[$begins] === "\n")) {
                $begins++;
            }
            $ends = strpos($document, "endstream", $begins);
            if ($ends === false) {
                break;
            }
            $at = $ends;
            $raw = substr($document, $begins, $ends - $begins);
            $opened = @gzuncompress($raw);
            if ($opened === false) {
                $opened = @gzinflate($raw);
            }
            if ($opened === false ||
                !preg_match('/\/N\s+(\d+)/', $head, $count) ||
                !preg_match('/\/First\s+(\d+)/', $head, $first)) {
                continue;
            }
            self::unpackBundle($opened, (int)$count[1], (int)$first[1],
                $held);
        }
        return $held;
    }
    /**
     * Takes the objects out of one opened bundle. The bundle begins with
     * a list saying which object each is and where in the bundle it
     * starts, and the objects themselves follow.
     *
     * @param string $bundle the opened bundle
     * @param int $count how many objects it holds
     * @param int $first where the objects themselves begin
     * @param array &$held what each numbered object holds, added to
     */
    public static function unpackBundle($bundle, $count, $first, &$held)
    {
        $list = substr($bundle, 0, $first);
        if (!preg_match_all('/(\d+)\s+(\d+)/', $list, $pairs,
            PREG_SET_ORDER)) {
            return;
        }
        $places = array_slice($pairs, 0, $count);
        foreach ($places as $which => $pair) {
            $from = $first + (int)$pair[2];
            $to = isset($places[$which + 1]) ?
                $first + (int)$places[$which + 1][2] : strlen($bundle);
            $held[(int)$pair[1]] = substr($bundle, $from, $to - $from);
        }
    }
    /**
     * Hands back what a numbered object holds, looking both among the
     * objects written out plainly and among those kept in bundles.
     *
     * @param string $document the document's bytes
     * @param array $held what each bundled object holds
     * @param int $number which object
     * @return string what it holds, empty if it cannot be found
     */
    public static function objectNumbered($document, $held, $number)
    {
        if (isset($held[$number])) {
            return $held[$number];
        }
        if (preg_match('/(?:^|[^\d])' . $number . '\s+0\s+obj/',
            $document, $found, PREG_OFFSET_CAPTURE)) {
            $start = $found[0][1];
            $end = strpos($document, "endobj", $start);
            if ($end !== false) {
                return substr($document, $start, $end - $start);
            }
        }
        return "";
    }
    /**
     * Follows a document's own table of contents down to its first page:
     * the catalog says where the page tree is, the tree's first child is
     * followed down until a page is reached.
     *
     * @param string $document the document's bytes
     * @param array $held what each bundled object holds
     * @return string|bool what the first page says about itself, or false
     */
    public static function firstPageObject($document, $held)
    {
        $catalog = "";
        foreach ($held as $object) {
            if (strpos($object, "/Catalog") !== false) {
                $catalog = $object;
                break;
            }
        }
        if ($catalog === "" &&
            preg_match('/\/Type\s*\/Catalog.{0,400}/s', $document, $found)) {
            $catalog = $found[0];
        }
        $node = "";
        if (preg_match('/\/Pages\s+(\d+)\s+\d+\s+R/', $catalog,
            $named)) {
            $node = self::objectNumbered($document, $held,
                (int)$named[1]);
        }
        if ($node === "") {
            /* A document that keeps no catalog where it can be found
               still keeps its page tree, and the tree's root is the
               lowest-numbered node of that kind. */
            $lowest = 0;
            foreach ($held as $number => $object) {
                if (preg_match('/\/Type\s*\/Pages\b/', $object) &&
                    ($lowest == 0 || $number < $lowest)) {
                    $lowest = $number;
                }
            }
            if ($lowest == 0) {
                return false;
            }
            $node = $held[$lowest];
        }
        $steps = 0;
        while ($node !== "" && $steps < self::MOST_STEPS) {
            $steps++;
            if (preg_match('/\/Type\s*\/Page[^s]/', $node)) {
                return $node;
            }
            if (!preg_match('/\/Kids\s*\[\s*(\d+)\s+\d+\s+R/', $node,
                $kid)) {
                return false;
            }
            $node = self::objectNumbered($document, $held, (int)$kid[1]);
        }
        return false;
    }
    /**
     * Finds the instructions that draw the first page by looking for the
     * first packed-down thing in the document that draws any words.
     *
     * @param string $document the document's bytes
     * @return array|bool the instructions and a page size, or false
     */
    public static function firstDrawing($document)
    {
        $at = 0;
        $looked = 0;
        while (($found = strpos($document, "/FlateDecode", $at)) !== false
            && $looked < self::MOST_STREAMS) {
            $looked++;
            $begins = strpos($document, "stream", $found);
            if ($begins === false) {
                break;
            }
            $begins += strlen("stream");
            while (isset($document[$begins]) &&
                ($document[$begins] === "\r" ||
                $document[$begins] === "\n")) {
                $begins++;
            }
            $ends = strpos($document, "endstream", $begins);
            if ($ends === false) {
                break;
            }
            $raw = substr($document, $begins, $ends - $begins);
            $opened = @gzuncompress($raw);
            if ($opened === false) {
                $opened = @gzinflate($raw);
            }
            $at = $ends;
            if ($opened === false || (strpos($opened, "Tj") === false &&
                strpos($opened, "TJ") === false)) {
                continue;
            }
            return ["drawing" => $opened, "wide" => self::PAGE_WIDE,
                "high" => self::PAGE_HIGH];
        }
        return false;
    }
    /**
     * Hands back what a numbered object holds, opened if it was packed
     * down.
     *
     * @param string $document the document's bytes
     * @param int $number which object
     * @return string what it holds, empty if it cannot be found or opened
     */
    public static function objectContents($document, $number)
    {
        if (!preg_match('/(?:^|[^\d])' . $number . '\s+0\s+obj/',
            $document, $found, PREG_OFFSET_CAPTURE)) {
            return "";
        }
        $start = $found[0][1];
        $end = strpos($document, "endobj", $start);
        if ($end === false) {
            return "";
        }
        $object = substr($document, $start, $end - $start);
        $at = strpos($object, "stream");
        if ($at === false) {
            return "";
        }
        $at += strlen("stream");
        while (isset($object[$at]) &&
            ($object[$at] === "\r" || $object[$at] === "\n")) {
            $at++;
        }
        $raw = substr($object, $at);
        if (strpos($object, "FlateDecode") === false) {
            return $raw;
        }
        $opened = @gzuncompress($raw);
        if ($opened === false) {
            $opened = @gzinflate($raw);
        }
        return ($opened === false) ? "" : $opened;
    }
    /**
     * Reads the words a page draws, each with where on the page it sits
     * and how large it is set, so a thumbnail can put them back where the
     * page had them rather than in a heap at the top.
     *
     * The instructions are read a block of text at a time rather than a
     * line at a time. A page writes its words between a mark that begins
     * text and one that ends it, and within that the operators run across
     * lines however they please, so reading by lines broke single words
     * into pieces and lost where they sat.
     *
     * @param string $drawing the page's instructions
     * @return array each run of words with its place and size
     */
    public static function wordsOnPage($drawing)
    {
        $runs = [];
        /* A page mostly does not say where a piece of text goes inside the
           text itself. It sets the frame first, with a matrix, and then
           draws into it, so the frame in force when a piece of text begins
           is what says where that text sits. The frames stack: one can be
           put aside and taken up again. */
        $frame = ["scale" => 1, "across" => 0, "down" => 0];
        /* Black until the page says otherwise, which is what a page means
           by saying nothing. */
        $ink = [0, 0, 0];
        $put_aside = [];
        $number = '[-0-9.]+';
        $steps = '/(q)\s|(Q)\s|(' . $number . ')\s+' . $number .
            '\s+' . $number . '\s+(' . $number . ')\s+(' . $number .
            ')\s+(' . $number . ')\s+cm|BT(.*?)ET' .
            '|(' . $number . ')\s+(g)\b' .
            '|(' . $number . ')\s+(' . $number . ')\s+(' . $number .
            ')\s+(rg)\b' .
            '|(' . $number . ')\s+(' . $number . ')\s+(' . $number .
            ')\s+(' . $number . ')\s+(k)\b/s';
        preg_match_all($steps, $drawing, $found, PREG_SET_ORDER);
        foreach ($found as $one) {
            if (($one[1] ?? "") !== "") {
                $put_aside[] = $frame;
                continue;
            }
            if (($one[2] ?? "") !== "") {
                if (!empty($put_aside)) {
                    $frame = array_pop($put_aside);
                }
                continue;
            }
            if (($one[6] ?? "") !== "") {
                /* A frame set inside another stands on it, so what they
                   say is combined rather than replaced. */
                $frame = ["scale" => $frame["scale"] * abs((float)$one[4]),
                    "across" => $frame["across"] + (float)$one[5],
                    "down" => $frame["down"] + (float)$one[6]];
                continue;
            }
            if (($one[9] ?? "") === "g") {
                $shade = (int)round((float)$one[8] * self::MOST_SHADE);
                $ink = [$shade, $shade, $shade];
                continue;
            }
            if (($one[13] ?? "") === "rg") {
                $ink = [
                    (int)round((float)$one[10] * self::MOST_SHADE),
                    (int)round((float)$one[11] * self::MOST_SHADE),
                    (int)round((float)$one[12] * self::MOST_SHADE)];
                continue;
            }
            if (($one[18] ?? "") === "k") {
                /* Said in the four colors ink uses, where nothing at all
                   is white and only the fourth full is black. */
                $black = 1 - (float)$one[17];
                $ink = [
                    (int)round((1 - (float)$one[14]) * $black *
                    self::MOST_SHADE),
                    (int)round((1 - (float)$one[15]) * $black *
                    self::MOST_SHADE),
                    (int)round((1 - (float)$one[16]) * $black *
                    self::MOST_SHADE)];
                continue;
            }
            if (($one[7] ?? "") !== "") {
                foreach (self::runsInBlock($one[7], $frame) as $run) {
                    $run["ink"] = $ink;
                    $runs[] = $run;
                }
            }
        }
        return $runs;
    }
    /**
     * Reads the shapes a page draws and fills, each with the color it is
     * filled in and where on the page it sits.
     *
     * A page may set a word as type or draw it as an outline. A magazine's
     * nameplate is usually drawn, which is why no reading of the text
     * finds it; following the drawing is the only way to show it.
     *
     * @param string $drawing the page's instructions
     * @return array each shape as a list of corners and a color
     */
    public static function shapesOnPage($drawing)
    {
        $shapes = [];
        $frame = ["scale" => 1, "across" => 0, "down" => 0];
        $put_aside = [];
        $ink = [0, 0, 0];
        $inks = [];
        $path = [];
        $line = [];
        $at = 0;
        $length = strlen($drawing);
        $numbers = [];
        while ($at < $length) {
            $token = self::nextToken($drawing, $at);
            if ($token === false) {
                break;
            }
            if (is_numeric($token)) {
                $numbers[] = (float)$token;
                if (count($numbers) > self::MOST_NUMBERS) {
                    array_shift($numbers);
                }
                continue;
            }
            switch ($token) {
                case "q":
                    $put_aside[] = $frame;
                    $inks[] = $ink;
                    break;
                case "Q":
                    if (!empty($put_aside)) {
                        $frame = array_pop($put_aside);
                    }
                    if (!empty($inks)) {
                        $ink = array_pop($inks);
                    }
                    break;
                case "cm":
                    if (count($numbers) >= 6) {
                        $six = array_slice($numbers, -6);
                        $frame = ["scale" => $frame["scale"] * abs($six[3]),
                            "across" => $frame["across"] + $six[4],
                            "down" => $frame["down"] + $six[5]];
                    }
                    break;
                case "g":
                    $ink = self::inkFromGray($numbers);
                    break;
                case "rg":
                    $ink = self::inkFromScreen($numbers);
                    break;
                case "k":
                    $ink = self::inkFromInk($numbers);
                    break;
                case "m":
                    if (!empty($line)) {
                        $path[] = $line;
                    }
                    $line = [self::placeOnPage($numbers, $frame, 2)];
                    break;
                case "l":
                    $line[] = self::placeOnPage($numbers, $frame, 2);
                    break;
                case "c":
                    self::addCurve($line, $numbers, $frame);
                    break;
                case "h":
                    if (!empty($line)) {
                        $path[] = $line;
                        $line = [];
                    }
                    break;
                case "re":
                    if (count($numbers) >= 4) {
                        self::addBox($path, $numbers, $frame);
                    }
                    break;
                case "f":
                case "F":
                case "f*":
                case "b":
                case "b*":
                case "B":
                case "B*":
                    if (!empty($line)) {
                        $path[] = $line;
                        $line = [];
                    }
                    foreach ($path as $one) {
                        if (count($one) >= 3) {
                            $shapes[] = ["corners" => $one, "ink" => $ink];
                        }
                    }
                    $path = [];
                    break;
                case "n":
                case "S":
                case "s":
                    $path = [];
                    $line = [];
                    break;
            }
            $numbers = [];
        }
        return $shapes;
    }
    /**
     * Takes the next word or number from a page's instructions.
     *
     * @param string $drawing the instructions
     * @param int &$at where the reading has got to, moved past what is
     *      handed back
     * @return string|bool the next word or number, or false at the end
     */
    public static function nextToken($drawing, &$at)
    {
        $length = strlen($drawing);
        while ($at < $length && strpos(" \t\r\n", $drawing[$at]) !== false) {
            $at++;
        }
        if ($at >= $length) {
            return false;
        }
        $one = $drawing[$at];
        if ($one === "(" || $one === "<" || $one === "[" || $one === "]" ||
            $one === ">" || $one === ")" || $one === "/") {
            /* Text, names and lists are read elsewhere; here they are
               stepped over so the shapes are not confused by them. */
            $at++;
            return "";
        }
        $from = $at;
        while ($at < $length &&
            strpos(" \t\r\n()<>[]/", $drawing[$at]) === false) {
            $at++;
        }
        return substr($drawing, $from, $at - $from);
    }
    /**
     * Turns a pair of numbers into a place on the page, in the frame in
     * force.
     *
     * @param array $numbers the numbers gathered before the instruction
     * @param array $frame the frame in force
     * @param int $take how many of the last numbers make the place
     * @return array the place across and up the page
     */
    public static function placeOnPage($numbers, $frame, $take)
    {
        $pair = array_slice($numbers, -$take);
        if (count($pair) < 2) {
            return [$frame["across"], $frame["down"]];
        }
        return [$frame["across"] + $pair[0] * $frame["scale"],
            $frame["down"] + $pair[1] * $frame["scale"]];
    }
    /**
     * Adds a curve to a line by walking along it in short steps. A filled
     * shape is drawn from corner to corner, so a curve is kept as enough
     * corners to look like one.
     *
     * @param array &$line the line being built
     * @param array $numbers the six numbers describing the curve
     * @param array $frame the frame in force
     */
    public static function addCurve(&$line, $numbers, $frame)
    {
        if (count($numbers) < 6 || empty($line)) {
            return;
        }
        $six = array_slice($numbers, -6);
        $from = $line[count($line) - 1];
        $first = [$frame["across"] + $six[0] * $frame["scale"],
            $frame["down"] + $six[1] * $frame["scale"]];
        $second = [$frame["across"] + $six[2] * $frame["scale"],
            $frame["down"] + $six[3] * $frame["scale"]];
        $to = [$frame["across"] + $six[4] * $frame["scale"],
            $frame["down"] + $six[5] * $frame["scale"]];
        for ($step = 1; $step <= self::CURVE_STEPS; $step++) {
            $along = $step / self::CURVE_STEPS;
            $left = 1 - $along;
            $line[] = [
                $left * $left * $left * $from[0] +
                3 * $left * $left * $along * $first[0] +
                3 * $left * $along * $along * $second[0] +
                $along * $along * $along * $to[0],
                $left * $left * $left * $from[1] +
                3 * $left * $left * $along * $first[1] +
                3 * $left * $along * $along * $second[1] +
                $along * $along * $along * $to[1]];
        }
    }
    /**
     * Adds a four-cornered shape to a path.
     *
     * @param array &$path the shapes gathered so far
     * @param array $numbers the four numbers describing it
     * @param array $frame the frame in force
     */
    public static function addBox(&$path, $numbers, $frame)
    {
        $four = array_slice($numbers, -4);
        $across = $frame["across"] + $four[0] * $frame["scale"];
        $down = $frame["down"] + $four[1] * $frame["scale"];
        $wide = $four[2] * $frame["scale"];
        $high = $four[3] * $frame["scale"];
        $path[] = [[$across, $down], [$across + $wide, $down],
            [$across + $wide, $down + $high], [$across, $down + $high]];
    }
    /**
     * A shade of gray said as one number, as a screen's three colors.
     *
     * @param array $numbers the numbers gathered before the instruction
     * @return array the color
     */
    public static function inkFromGray($numbers)
    {
        if (empty($numbers)) {
            return [0, 0, 0];
        }
        $shade = (int)round(end($numbers) * self::MOST_SHADE);
        return [$shade, $shade, $shade];
    }
    /**
     * A color said as a screen's own three.
     *
     * @param array $numbers the numbers gathered before the instruction
     * @return array the color
     */
    public static function inkFromScreen($numbers)
    {
        if (count($numbers) < 3) {
            return [0, 0, 0];
        }
        $three = array_slice($numbers, -3);
        return [(int)round($three[0] * self::MOST_SHADE),
            (int)round($three[1] * self::MOST_SHADE),
            (int)round($three[2] * self::MOST_SHADE)];
    }
    /**
     * A color said in the four colors ink uses, where nothing at all is
     * white and only the fourth full is black.
     *
     * @param array $numbers the numbers gathered before the instruction
     * @return array the color
     */
    public static function inkFromInk($numbers)
    {
        if (count($numbers) < 4) {
            return [0, 0, 0];
        }
        $four = array_slice($numbers, -4);
        $black = 1 - $four[3];
        return [(int)round((1 - $four[0]) * $black * self::MOST_SHADE),
            (int)round((1 - $four[1]) * $black * self::MOST_SHADE),
            (int)round((1 - $four[2]) * $black * self::MOST_SHADE)];
    }
    /**
     * Reads one block of text, which may set the place and the size more
     * than once and draw words after each, so each drawing is taken with
     * whatever place and size were last given before it.
     *
     * @param string $block what stands between the marks beginning and
     *      ending a piece of text
     * @param array $frame the frame the page was in when the text began,
     *      which is where the text sits unless it says otherwise
     * @return array each run of words with its place and size
     */
    public static function runsInBlock($block, $frame = null)
    {
        $runs = [];
        $size = self::PLAIN_SIZE;
        $scale = ($frame === null) ? 1 : $frame["scale"];
        $across = ($frame === null) ? 0 : $frame["across"];
        $down = ($frame === null) ? 0 : $frame["down"];
        $from_across = $across;
        $from_down = $down;
        $said = "";
        $number = '[-0-9.]+';
        $pattern = '/\/[A-Za-z0-9]+\s+(' . $number . ')\s+Tf' .
            '|(' . $number . ')\s+' . $number . '\s+' . $number .
            '\s+(' . $number . ')\s+(' . $number . ')\s+(' . $number .
            ')\s+Tm' .
            '|(' . $number . ')\s+(' . $number . ')\s+Td' .
            '|\(([^)]*)\)/s';
        preg_match_all($pattern, $block, $found, PREG_SET_ORDER);
        foreach ($found as $one) {
            if (($one[1] ?? "") !== "") {
                $size = (float)$one[1];
                continue;
            }
            if (($one[5] ?? "") !== "") {
                self::keepRun($runs, $said, $across, $down, $size * $scale);
                /* A matrix inside the text stands on the frame the page
                   was in, rather than replacing it. */
                $scale = (($frame === null) ? 1 : $frame["scale"]) *
                    abs((float)$one[3]);
                $across = $from_across + (float)$one[4];
                $down = $from_down + (float)$one[5];
                continue;
            }
            if (($one[7] ?? "") !== "") {
                self::keepRun($runs, $said, $across, $down, $size * $scale);
                $across += (float)$one[6];
                $down += (float)$one[7];
                continue;
            }
            if (($one[8] ?? "") !== "") {
                $said .= stripcslashes($one[8]);
            }
        }
        self::keepRun($runs, $said, $across, $down, $size * $scale);
        return $runs;
    }
    /**
     * Keeps a run of words if there are any, and starts the next one.
     *
     * @param array &$runs the runs kept so far
     * @param string &$said the words gathered since the last one
     * @param float $across where they sit across the page
     * @param float $down where they sit up the page
     * @param float $size how large they are set
     */
    public static function keepRun(&$runs, &$said, $across, $down, $size)
    {
        if (trim($said) !== "") {
            $runs[] = ["across" => $across, "down" => $down,
                "size" => $size, "words" => trim($said)];
        }
        $said = "";
    }
    /**
     * Pulls the words out of one instruction, whether they were written
     * as one piece or as a list of pieces with spacing between them.
     *
     * @param string $line the instruction
     * @return string the words, empty if it draws none
     */
    public static function wordsInLine($line)
    {
        if (strpos($line, "Tj") === false &&
            strpos($line, "TJ") === false) {
            return "";
        }
        preg_match_all('/\((?:[^()\\\\]|\\\\.)*\)/', $line, $pieces);
        $said = "";
        foreach ($pieces[0] as $piece) {
            $said .= stripcslashes(substr($piece, 1, -1));
        }
        return trim($said);
    }
}
X