<?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);
}
}