<?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;
use seekquarry\yioop\library\processors\JpgProcessor;
/**
* PdfProcessor used to create crawl summary information for PDF files
* @author Chris Pollett
*/
class PdfProcessor extends TextProcessor
{
/**
* PAGE_WIDE 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;
/**
* PAGE_HIGH how tall a page is taken to be when it does not say.
*/
const PAGE_HIGH = 792;
/**
* PLAIN_SIZE what size words are taken to be set at when a page does not
* say.
*/
const PLAIN_SIZE = 12;
/**
* MOST_STREAMS 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;
/**
* HEAD_LOOK how far back from a bundle's mark to look for what it says
* about itself.
*/
const HEAD_LOOK = 300;
/**
* MOST_STEPS how far down a page tree to follow before giving up.
*/
const MOST_STEPS = 32;
/**
* MOST_SHADE the largest a color may be.
*/
const MOST_SHADE = 255;
/**
* DEVICE_SPACES are the color spaces every reader knows without
* being told: a shade of gray, the three lights of a screen, and
* the four inks of a press. A number set in any other space is a
* tint of one ink rather than a shade.
* @var array
*/
const DEVICE_SPACES = ["DeviceGray", "DeviceRGB", "DeviceCMYK",
"G", "RGB", "CMYK", "Pattern"];
/**
* NAME_LOOK_BACK is how far back through a page's drawing to look
* for the name written just before a word, in characters. A name
* and the word that uses it sit side by side, so a short look back
* is enough.
* @var int
*/
const NAME_LOOK_BACK = 60;
/**
* PIECE_ROUNDS is how many times over a page's drawing is looked
* through for pieces kept elsewhere. A piece may hand off to
* another, and stopping after a few rounds keeps a piece that names
* itself from going round forever.
* @var int
*/
const PIECE_ROUNDS = 4;
/**
* CURVE_STEPS how many straight steps a curve is drawn in when a shape is
* filled.
*/
const CURVE_STEPS = 6;
/**
* MOST_NUMBERS how many numbers to keep before an instruction, which is
* more than any instruction takes.
*/
const MOST_NUMBERS = 8;
/**
* __construct 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);
/* The kinds of file this reader handles are named here so
that a crawler knows to hand it a document. */
self::$indexed_file_types[] = "pdf";
self::$mime_processor["application/pdf"] = "PdfProcessor";
}
/**
* process 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;
}
/**
* createThumb makes a small picture of a document's first page and
* writes it into a folder of such pictures, so that a media list
* can show the document without opening it. 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.
*
* The picture is built under a name of its own and moved into place
* once it is whole, so a document this cannot read keeps whatever
* picture it already had.
*
* @param string $folder The folder the document sits in.
* @param string $thumb_folder The folder to write the picture into.
* @param string $file_name The name of the document.
* @param int $width How wide the picture should be, in pixels.
* @param int $height How tall it should be, in pixels.
*/
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";
/* The thumb that is there is left where it is until a new one
has been made. Deleting it first meant that a document this
could not read lost the thumb it already had, and the work
was tried again on every listing from then on. */
$being_made = "$thumb_path.making";
$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. */
/* A page drawn as several pictures side by side is built from
all of them, since any one of them is a corner of the page
rather than the page. */
$held = self::packedObjects($document);
$whole = self::firstPage($document);
$stitched = ($whole === false) ? false :
self::pageDrawnFromItsParts($document, $held, $whole,
$width, $height);
if ($stitched !== false) {
@imagewebp($stitched, $being_made);
clearstatcache(true, $being_made);
if (file_exists($being_made)) {
rename($being_made, $thumb_path);
clearstatcache(true, $thumb_path);
return;
}
}
$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, $being_made,
$width, $height, "webp", $runs,
($page === false) ? null : $page)) {
rename($being_made, $thumb_path);
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, $being_made, $width, $height);
if (file_exists($being_made)) {
rename($being_made, $thumb_path);
}
clearstatcache(true, $thumb_path);
}
/**
* getEncodingTitle 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];
}
/**
* getText 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;
}
/**
* getNextObject hands back what lies between the words that
* open and close one numbered piece of a document, starting
* from where the reading has reached.
* @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");
}
/**
* objectDictionaryHas 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;
}
/**
* getObjectDictionary 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;
}
/**
* getObjectStream 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;
}
/**
* parseText 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;
}
/**
* parseBrackets 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];
}
/**
* parseParentheses 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];
}
/**
* convertChar 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 "";
}
/**
* pictureInDocument 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;
}
/**
* firstPage 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)) {
/* A page names its box by two corners, and the format
does not say which corner comes first. A document that
names the far corner first left the width and the height
below zero, and everything drawn on it came out upside
down and mirrored, which is what a cover whose name read
backwards was showing. */
$one_across = (float)$found[1];
$one_down = (float)$found[2];
$other_across = (float)$found[3];
$other_down = (float)$found[4];
$from_across = min($one_across, $other_across);
$from_down = min($one_down, $other_down);
$wide = abs($other_across - $one_across);
$high = abs($other_down - $one_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);
}
/* The page itself is handed back with its drawing, since what
the drawing names -- its pictures and its fonts -- is only
listed on that page. A document of several pages has a page
object each, and looking one up separately picked whichever
came first in the file, so a cover was drawn with pictures
belonging to a later page. */
/* A page may hand its drawing off to a piece kept elsewhere,
and a cover often keeps all of its type in one such piece.
Those are written into the drawing here, so that what they
draw is walked with everything else. */
$drawing = self::piecesWrittenIn($document, $held, $page,
$drawing);
return ["drawing" => $drawing, "wide" => $wide, "high" => $high,
"from_across" => $from_across, "from_down" => $from_down,
"object" => $page,
"shapes" => self::shapesOnPage($drawing)];
}
/**
* streamOfObject 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);
/* A stream may be written out as base-85 lettering so the file
holds only printing characters, with the packed bytes inside
it. The lettering is opened first where the object names it,
since inflating the lettering itself reads as damage and gave
back nothing, which is how a page whose drawing was written
this way came back empty and only a lone picture stood for
it. */
if (strpos($object, "ASCII85Decode") !== false) {
$raw = self::bytesFromBase85($raw);
}
if (strpos($object, "FlateDecode") === false) {
return $raw;
}
$opened = @gzuncompress($raw);
if ($opened === false) {
$opened = @gzinflate($raw);
}
return ($opened === false) ? "" : $opened;
}
/**
* bytesFromBase85 turns the base-85 lettering a stream may be written
* out as back into the bytes it holds. Five letters carry four bytes,
* each letter a digit between exclamation point and the letter u; a
* lone z stands for four zero bytes, and a tilde with a wedge closes
* the lettering. A short last group carries one byte fewer than its
* letters. Whitespace between letters says nothing and is passed
* over.
*
* @param string $lettering the base-85 text as it stands in the file
* @return string the bytes the lettering holds
*/
public static function bytesFromBase85($lettering)
{
$close = strpos($lettering, "~>");
if ($close !== false) {
$lettering = substr($lettering, 0, $close);
}
$bytes = "";
$group = [];
$count = strlen($lettering);
for ($i = 0; $i < $count; $i++) {
$letter = $lettering[$i];
if ($letter === "z" && empty($group)) {
$bytes .= "\x00\x00\x00\x00";
continue;
}
$place = ord($letter) - 33;
if ($place < 0 || $place > 84) {
continue;
}
$group[] = $place;
if (count($group) == 5) {
$held = 0;
foreach ($group as $digit) {
$held = $held * 85 + $digit;
}
$bytes .= pack("N", $held);
$group = [];
}
}
$left = count($group);
if ($left > 1) {
/* A short group is read as though filled out with the
largest digit, and gives one byte fewer than it has
letters. */
for ($i = $left; $i < 5; $i++) {
$group[] = 84;
}
$held = 0;
foreach ($group as $digit) {
$held = $held * 85 + $digit;
}
$bytes .= substr(pack("N", $held), 0, $left - 1);
}
return $bytes;
}
/**
* packedObjects 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 as it stands, or it may bundle groups of them
* together and pack the bundle down. Looking through the file for a as it
* stands
* 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;
}
/**
* unpackBundle 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);
}
}
/**
* objectNumbered hands back what a numbered object holds, looking both
* among the objects written out as it stands 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 "";
}
/**
* piecesWrittenIn writes the drawing of every piece a page hands
* off to into the page's own drawing, in the place the page put it.
* A cover often keeps all of its type in one such piece, and a
* reader that treats the piece as a picture draws nothing of it.
*
* @param string $document the whole document
* @param array $held the objects packed within the document
* @param string $page the page's own entry
* @param string $drawing the page's drawing
* @return string the drawing with those pieces written into it
*/
public static function piecesWrittenIn($document, $held, $page,
$drawing)
{
$listing = self::pageResourceEntries($document, $held, $page,
"XObject");
if ($listing === "") {
return $drawing;
}
$numbered = [];
preg_match_all('/\\/([^\\s\\/]+)\\s+(\\d+)\\s+\\d+\\s+R/', $listing,
$pairs, PREG_SET_ORDER);
foreach ($pairs as $one) {
$numbered[$one[1]] = (int)$one[2];
}
/* A piece may hand off to another, so the writing in is done a
few times over, and stops there in case one names itself. */
for ($round = 0; $round < self::PIECE_ROUNDS; $round++) {
$written = 0;
$drawing = preg_replace_callback('/\\/([^\\s\\/]+)\\s+Do\\b/',
function ($found) use ($document, $held, $numbered,
&$written) {
$name = $found[1];
if (empty($numbered[$name])) {
return $found[0];
}
$said = self::objectNumbered($document, $held,
$numbered[$name]);
/* Only a piece that says outright that it is one
is written in. Looking for the word anywhere in
the entry would take a picture whose settings
happen to carry it and splice its bytes into the
drawing, which loses the picture. */
if ($said === false ||
!preg_match('/\/Subtype\s*\/Form\b/', $said)) {
return $found[0];
}
$inside = self::streamOfObject($document, $held,
$numbered[$name]);
if ($inside === "") {
return $found[0];
}
$written++;
$frame = "";
if (preg_match('/\\/Matrix\\s*\\[([^\\]]*)\\]/',
$said, $six)) {
$frame = trim($six[1]) . " cm ";
}
return "q " . $frame . $inside . " Q";
}, $drawing);
if ($written == 0) {
break;
}
}
return $drawing;
}
/**
* firstPageObject 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('/.{0,400}\/Type\s*\/Catalog.{0,400}/s', $document,
$found)) {
/* The keys of a dictionary come in any order, and a writer
that sorts them puts Pages before Type. Reading only what
follows the type missed the pages reference, so the walk
never began and a plainly written page tree went
unread. */
$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 self::lowestNumberedPageObject($held);
}
$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 self::lowestNumberedPageObject($held);
}
/**
* INK_PARTS how many numbers a page gives when it sets a color as the
* colors ink is mixed from
* @var int
*/
const INK_PARTS = 4;
/**
* SCREEN_PARTS how many numbers a page gives when it sets a color as the
* colors a screen mixes
* @var int
*/
const SCREEN_PARTS = 3;
/**
* LEAST_POINTS how many points a loop needs before it can be filled: fewer
* than three cannot enclose anything
* @var int
*/
const LEAST_POINTS = 3;
/**
* EM_PARTS how many parts a font divides an em into, an em being the size
* the page sets the words at
* @var int
*/
const EM_PARTS = 1000;
/**
* DRAWN_LARGER how many times larger than it is wanted a page is drawn,
* before being brought down to size so its edges come out smooth
* @var int
*/
const DRAWN_LARGER = 4;
/**
* pageDrawnFromItsParts builds one picture of a page from the pieces it is
* drawn with, where a page is drawn as several pictures laid side by side
* rather than as one. A magazine cover is often kept that way, and taking
* any single piece of it gives whatever happens to be in that corner of the
* page: for one cover that was the black sky above the earth. Each piece is
* placed by the page's own drawing, which says how wide and tall it stands
* and where its lower left corner sits. Those places are in the page's own
* measure, so they are scaled to the picture being built.
* @param string $document the document's bytes
* @param array $held the document's packed objects, by number
* @param array $page the first page, its drawing and its size
* @param int $wanted_width how wide the picture should be
* @param int $wanted_height how tall
* @return mixed the picture, or false where the page is not drawn from
* pieces or they cannot be read
*/
public static function pageDrawnFromItsParts($document, $held, $page,
$wanted_width, $wanted_height)
{
/* The page is drawn several times larger than it is wanted and
then brought down to size. A filled shape has hard edges, so a
letter drawn straight at thumbnail size comes out with steps
along its curves; drawn large and shrunk, each edge pixel is
averaged from many and the steps go. */
/* A thumbnail keeps the shape of the page it is made from. A
cover is taller than it is wide, and drawing it into a square
stretched it sideways, so a page that stands 585 by 756 came
out 256 by 256. The longer side is what the caller asked for
and the shorter is worked out from the page. */
$shape = (empty($page["high"])) ? 1 :
$page["wide"] / $page["high"];
if ($shape < 1) {
$wanted_width = max(1, (int)round($wanted_height * $shape));
} else if ($shape > 1) {
$wanted_height = max(1, (int)round($wanted_width / $shape));
}
$width = $wanted_width * self::DRAWN_LARGER;
$height = $wanted_height * self::DRAWN_LARGER;
$runs = empty($page["drawing"]) ? [] :
self::wordsOnPage($page["drawing"]);
if (empty($page["drawing"]) || empty($page["wide"]) ||
empty($page["high"]) || !function_exists("imagecreatetruecolor")) {
return false;
}
$object = $page["object"] ?? self::lowestNumberedPageObject($held);
$listing = ($object === false) ? "" :
self::pageResourceEntries($document, $held, $object, "XObject");
if ($listing === "") {
return false;
}
$numbered = [];
preg_match_all('/\/([^\s\/]+)\s+(\d+)\s+\d+\s+R/', $listing,
$pairs, PREG_SET_ORDER);
foreach ($pairs as $one) {
$numbered[$one[1]] = (int)$one[2];
}
/* A page that places no picture at all is still drawn this
way, since its background, its boxes and its type are drawn
from the same walk. Refusing such a page here sent it to the
older way, which finds the largest picture in the whole
document and lays words over it, and that way cannot put
anything where the page puts it. */
$canvas = imagecreatetruecolor($width, $height);
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 255, 255, 255));
$across = $width / $page["wide"];
$down = $height / $page["high"];
$from_across = $page["from_across"] ?? 0;
$from_down = $page["from_down"] ?? 0;
/* The page is drawn in the order it says: its background, its
pictures, and the shapes that stand over them, so that a title
drawn as outlines above a photograph lands above it here too.
Drawing the pictures first and the shapes after covered the
photograph with the white rectangle the page begins with. */
$laid = 0;
foreach ($page["shapes"] as $part) {
if (empty($part["picture"])) {
self::fillShapeOnPicture($canvas, $part, $page, $across, $down);
continue;
}
$name = $part["picture"];
$ink = $part["ink"] ?? [0, 0, 0];
if (empty($numbered[$name])) {
continue;
}
$clip = $part["clip"] ?? [];
$stencil = self::stencilFromXObject($document, $held,
$numbered[$name]);
if ($stencil !== false) {
self::layStencilOnPicture($canvas, $stencil, $ink,
((float)$part["left"] - $from_across) * $across,
$height - (((float)$part["foot"] - $from_down) * $down) -
(float)$part["high"] * $down,
(float)$part["wide"] * $across,
(float)$part["high"] * $down);
$laid++;
continue;
}
$bytes = self::streamOfObject($document, $held,
$numbered[$name]);
$piece = self::pictureFromBytes($bytes);
if ($piece === false) {
/* A picture may be kept as its values outright rather
than in one of the packed forms a drawing library
knows. A magazine's masthead is drawn from a dozen
such pictures, one to a letter. */
$piece = self::pictureFromSamples($bytes,
self::objectNumbered($document, $held,
$numbered[$name]),
self::colorsListed($document, $held,
self::objectNumbered($document, $held,
$numbered[$name])));
}
if ($piece === false) {
continue;
}
$piece_wide = (float)$part["wide"] * $across;
$piece_high = (float)$part["high"] * $down;
$left = ((float)$part["left"] - $from_across) * $across;
/* A page is measured from its foot upward and a picture from
its head downward, so where a piece sits is turned over. */
$top = $height - (((float)$part["foot"] - $from_down) * $down) -
$piece_high;
imagealphablending($canvas, true);
/* Where the page said which part of the picture may show,
the picture is laid through that shape rather than over
the whole of its box. A cover's masthead is a block of
color with its letters cut out of it this way, and laying
the block whole painted over the letters beneath. */
if (!empty($clip)) {
self::layPictureThroughClip($canvas, $piece, $clip,
$page, $across, $down, $left, $top, $piece_wide,
$piece_high);
$laid++;
continue;
}
imagecopyresampled($canvas, $piece, (int)round($left),
(int)round($top), 0, 0, (int)round($piece_wide),
(int)round($piece_high), imagesx($piece), imagesy($piece));
$laid++;
}
/* One picture laid is enough to draw the page this way. Asking
for two was meant to tell a page tiled from many pictures from
a page that is one picture, but a cover may be one photograph
with its type over it, and refusing that threw away the type
along with the shapes the page draws. */
/* A page that laid nothing at all, neither picture nor shape,
has nothing to show and goes back to the caller for the older
way. */
if ($laid < 1 && empty($page["shapes"])) {
return false;
}
/* A cover carries words over its picture as well, so the page's
own runs go on last. */
$faces = self::fontsNamedByPage($document, $held,
$page["object"] ?? self::lowestNumberedPageObject($held));
foreach ($runs as $run) {
$words = trim($run["words"] ?? "");
$tall = (int)round(($run["size"] ?? 0) * $across);
if ($words === "" || $tall < self::LEAST_DRAWN) {
continue;
}
if ($tall > $height) {
continue;
}
/* The letters the document carries are used where it carries
them, so the words come out in the face the page sets them
in. Only where a font cannot be read are they drawn with
letters of our own, which stretch a small picture of each
letter and come out square-edged. */
$face = $faces[$run["face"] ?? ""] ?? null;
if ($face !== null && self::drawTextRunWithFont($canvas, $run,
$face, $across, $down, $page)) {
continue;
}
/* Nothing was drawn from the document's own letters, so
the words themselves are drawn instead. A run whose
letters are not letters cannot be drawn that way: a
document that numbers its own letters hands back control
characters, and drawing those laid a solid box in the
headline's color over the picture. */
if (!self::wordsCanBeDrawn($words)) {
continue;
}
$left = (int)round((($run["across"] ?? 0) - $from_across) *
$across);
$top = (int)round(($page["high"] -
(($run["down"] ?? 0) - $from_down)) * $down) - $tall;
if ($top > $height || $left > $width) {
continue;
}
TextProcessor::drawWordsAt($canvas, $words, $left, $top, $tall,
$run["ink"] ?? [0, 0, 0]);
}
$smaller = imagecreatetruecolor($wanted_width, $wanted_height);
imagecopyresampled($smaller, $canvas, 0, 0, 0, 0, $wanted_width,
$wanted_height, $width, $height);
return $smaller;
}
/**
* LEAST_READABLE_SHARE is how much of a run of words must be
* letters a reader could recognize before the run is drawn. A run
* of control characters comes from a font whose letters the
* document numbers itself, which this cannot read.
* @var float
*/
const LEAST_READABLE_SHARE = 0.5;
/**
* wordsCanBeDrawn says whether a run of words holds letters a
* reader could recognize. A document may set its headline in a font
* whose letters it numbers itself, and what comes back from such a
* run is control characters rather than words.
*
* @param string $words the run's words
* @return bool true where the run holds letters worth drawing
*/
public static function wordsCanBeDrawn($words)
{
$letters = 0;
$length = strlen($words);
for ($at = 0; $at < $length; $at++) {
$value = ord($words[$at]);
if ($value >= 32 && $value != 127) {
$letters++;
}
}
return $letters > 0 &&
$letters >= $length * self::LEAST_READABLE_SHARE;
}
/**
* pictureFromSamples builds a picture from values kept one to a
* place, which is how a document keeps a picture it has not packed
* into one of the forms a drawing library knows. The entry beside
* the values says how wide and how tall the picture is and how many
* values stand for one place.
*
* @param string $bytes the values, unpacked
* @param string $said the document's entry for the picture
* @param array $listed the colors its values stand for, where the
* picture keeps places in a list rather than shades
* @return mixed the picture, or false where it cannot be built
*/
public static function pictureFromSamples($bytes, $said,
$listed = [])
{
if ($bytes === "" || $said === false) {
return false;
}
if (!preg_match('/\/Width\s+(\d+)/', $said, $across) ||
!preg_match('/\/Height\s+(\d+)/', $said, $down)) {
return false;
}
$wide = (int)$across[1];
$high = (int)$down[1];
if ($wide < 1 || $high < 1) {
return false;
}
$each = intdiv(strlen($bytes), $wide * $high);
if ($each < 1 || $each > self::INK_PARTS) {
return false;
}
/* A page may say that the values run the other way round, so
that nothing means all of the ink rather than none of it. */
$upside_down = (bool)preg_match('/\/Decode\s*\[\s*1\s+0/',
$said);
$picture = imagecreatetruecolor($wide, $high);
for ($row = 0; $row < $high; $row++) {
for ($column = 0; $column < $wide; $column++) {
$at = ($row * $wide + $column) * $each;
$value = ord($bytes[$at]);
if ($upside_down) {
$value = self::MOST_SHADE - $value;
}
if (!empty($listed)) {
/* The value is not a shade but a place in the
list of colors the picture was given. */
$shade = $listed[ord($bytes[$at])] ??
$listed[0];
} else if ($each >= self::SCREEN_PARTS) {
$shade = [ord($bytes[$at]), ord($bytes[$at + 1]),
ord($bytes[$at + 2])];
} else {
$shade = [$value, $value, $value];
}
imagesetpixel($picture, $column, $row,
($shade[0] << 16) | ($shade[1] << 8) | $shade[2]);
}
}
return $picture;
}
/**
* colorsListed reads the list of colors a picture's values stand
* for, where the picture was given one. Such a picture keeps a
* place in a list at each of its points rather than a color, which
* is how a document keeps a picture drawn in one or two colors.
*
* @param string $document the whole document
* @param array $held the objects packed within the document
* @param string $said the document's entry for the picture
* @return array the colors, in the order the list gives them
*/
public static function colorsListed($document, $held, $said)
{
if ($said === false || !preg_match('/\/ColorSpace\s+(\d+)/',
$said, $where)) {
return [];
}
$space = self::objectNumbered($document, $held, (int)$where[1]);
if ($space === false || strpos($space, "/Indexed") === false) {
return [];
}
if (!preg_match('/\/Indexed\s*(?:\/(\w+)|(\d+)\s+\d+\s+R)\s+(\d+)' .
'\s+(\d+)\s+\d+\s+R/', $space, $found)) {
return [];
}
$base = $found[1];
if ($base === "" && ($found[2] ?? "") !== "") {
$named = self::objectNumbered($document, $held,
(int)$found[2]);
$inks = 0;
if ($named !== false &&
preg_match('/\/N\s+(\d+)/', $named, $counted)) {
$inks = (int)$counted[1];
} else if ($named !== false &&
preg_match('/\/ICCBased\s+(\d+)/', $named, $points)) {
$held_by = self::objectNumbered($document, $held,
(int)$points[1]);
if ($held_by !== false &&
preg_match('/\/N\s+(\d+)/', $held_by, $counted)) {
$inks = (int)$counted[1];
}
}
$base = ($inks == self::INK_PARTS) ? "DeviceCMYK" :
(($inks == self::SCREEN_PARTS) ? "DeviceRGB" : "DeviceGray");
}
$each = ($base == "DeviceCMYK") ? self::INK_PARTS :
(($base == "DeviceRGB") ? self::SCREEN_PARTS : 1);
$table = self::streamOfObject($document, $held, (int)$found[4]);
if ($table === "") {
return [];
}
$colors = [];
for ($at = 0; $at + $each <= strlen($table); $at += $each) {
if ($each == self::INK_PARTS) {
$black = self::MOST_SHADE - ord($table[$at + 3]);
$colors[] = [
(int)((self::MOST_SHADE - ord($table[$at])) *
$black / self::MOST_SHADE),
(int)((self::MOST_SHADE - ord($table[$at + 1])) *
$black / self::MOST_SHADE),
(int)((self::MOST_SHADE - ord($table[$at + 2])) *
$black / self::MOST_SHADE)];
continue;
}
if ($each == self::SCREEN_PARTS) {
$colors[] = [ord($table[$at]), ord($table[$at + 1]),
ord($table[$at + 2])];
continue;
}
$colors[] = [ord($table[$at]), ord($table[$at]),
ord($table[$at])];
}
return $colors;
}
/**
* layPictureThroughClip lays a picture onto the page showing only
* where the page said it may show. A page may build a path, not
* paint it, and use it to say which part of what follows is seen:
* a magazine's masthead is a block of color with its letters cut
* out of it that way.
*
* @param object $canvas the picture being built
* @param object $piece the picture to lay
* @param array $clip the loops saying where it may show
* @param array $page the page, for where its corner sits
* @param float $across how much to shrink across
* @param float $down how much to shrink down
* @param float $left where the picture goes across the page
* @param float $top where it goes down the page
* @param float $wide how wide it goes
* @param float $high how tall it goes
*/
public static function layPictureThroughClip($canvas, $piece, $clip,
$page, $across, $down, $left, $top, $wide, $high)
{
$canvas_wide = imagesx($canvas);
$canvas_high = imagesy($canvas);
$sheet = imagecreatetruecolor($canvas_wide, $canvas_high);
imagealphablending($sheet, false);
imagesavealpha($sheet, true);
$clear = imagecolorallocatealpha($sheet, 0, 0, 0, 127);
imagefill($sheet, 0, 0, $clear);
/* The picture is laid on a sheet of its own, and then every
part of that sheet outside the shape is rubbed out. */
imagealphablending($sheet, true);
imagecopyresampled($sheet, $piece, (int)round($left),
(int)round($top), 0, 0, (int)round($wide), (int)round($high),
imagesx($piece), imagesy($piece));
imagealphablending($sheet, false);
$mask = imagecreatetruecolor($canvas_wide, $canvas_high);
imagefill($mask, 0, 0, imagecolorallocate($mask, 0, 0, 0));
$from_across = $page["from_across"] ?? 0;
$from_down = $page["from_down"] ?? 0;
/* A loop that lies wholly inside another is the middle of a
letter rather than a letter of its own: the o of Economist
is two loops, and filling both left the o solid white. Those
are filled back to nothing after the outer loops are laid
down. */
$flat = [];
foreach ($clip as $which => $loop) {
if (count($loop) < self::LEAST_POINTS) {
continue;
}
$corners = [];
foreach ($loop as $corner) {
$corners[] = (int)round(($corner[0] - $from_across) *
$across);
$corners[] = (int)round(($page["high"] -
($corner[1] - $from_down)) * $down);
}
$flat[$which] = $corners;
}
$shown = imagecolorallocate($mask, 255, 255, 255);
$hidden = imagecolorallocate($mask, 0, 0, 0);
$middles = [];
foreach ($flat as $which => $corners) {
$inside = false;
foreach ($flat as $other => $around) {
if ($other !== $which &&
self::loopWithinLoop($corners, $around)) {
$inside = true;
break;
}
}
if ($inside) {
$middles[] = $corners;
continue;
}
imagefilledpolygon($mask, $corners, $shown);
}
foreach ($middles as $corners) {
imagefilledpolygon($mask, $corners, $hidden);
}
for ($at = 0; $at < $canvas_wide; $at++) {
for ($step = 0; $step < $canvas_high; $step++) {
if ((imagecolorat($mask, $at, $step) & 255) < 128) {
imagesetpixel($sheet, $at, $step, $clear);
}
}
}
imagealphablending($canvas, true);
imagecopy($canvas, $sheet, 0, 0, 0, 0, $canvas_wide,
$canvas_high);
}
/**
* loopWithinLoop says whether one loop lies wholly inside another,
* which is how the middle of a letter is told from a letter of its
* own. The corners of both are given flat, across then down, as a
* drawing library takes them.
*
* @param array $inner the loop that may lie inside
* @param array $outer the loop it may lie inside of
* @return bool true where the first lies within the second
*/
public static function loopWithinLoop($inner, $outer)
{
if (count($inner) < 6 || count($outer) < 6) {
return false;
}
$left = $right = $inner[0];
$top = $foot = $inner[1];
for ($at = 0; $at + 1 < count($inner); $at += 2) {
$left = min($left, $inner[$at]);
$right = max($right, $inner[$at]);
$top = min($top, $inner[$at + 1]);
$foot = max($foot, $inner[$at + 1]);
}
$other_left = $other_right = $outer[0];
$other_top = $other_foot = $outer[1];
for ($at = 0; $at + 1 < count($outer); $at += 2) {
$other_left = min($other_left, $outer[$at]);
$other_right = max($other_right, $outer[$at]);
$other_top = min($other_top, $outer[$at + 1]);
$other_foot = max($other_foot, $outer[$at + 1]);
}
return $left >= $other_left && $right <= $other_right &&
$top >= $other_top && $foot <= $other_foot &&
($right - $left) < ($other_right - $other_left);
}
/**
* fillShapeOnPicture fills one shape of a page onto the picture being
* built, in the color and at the place the page gives it.
* @param object $canvas the picture being built
* @param array $shape its corners and its color
* @param array $page the page, for where its corner sits
* @param float $across how much to shrink across
* @param float $down how much to shrink down
*/
public static function fillShapeOnPicture($canvas, $shape, $page, $across,
$down)
{
/* The corners here are points, not the flat run of numbers a
polygon is filled from, so three of them are enough. Asking for
six points instead dropped every shape drawn as a rectangle,
which is what a letter like an I is. */
if (empty($shape["corners"]) ||
count($shape["corners"]) < self::LEAST_POINTS) {
return;
}
$from_across = $page["from_across"] ?? 0;
$from_down = $page["from_down"] ?? 0;
$flatten = function ($loop) use ($from_across, $from_down, $page,
$across, $down) {
$corners = [];
foreach ($loop as $corner) {
$corners[] = (int)round(($corner[0] - $from_across) *
$across);
$corners[] = (int)round(($page["high"] -
($corner[1] - $from_down)) * $down);
}
return $corners;
};
$ink = imagecolorallocate($canvas, $shape["ink"][0],
$shape["ink"][1], $shape["ink"][2]);
if (empty($shape["holes"])) {
imagefilledpolygon($canvas, $flatten($shape["corners"]), $ink);
return;
}
/* The loops after the first are the letter's middles. They are
punched out on a sheet of their own, which is then laid over
the picture, so what is beneath shows through them. */
$wide = imagesx($canvas);
$high = imagesy($canvas);
$sheet = imagecreatetruecolor($wide, $high);
imagealphablending($sheet, false);
imagesavealpha($sheet, true);
$clear = imagecolorallocatealpha($sheet, 0, 0, 0, 127);
imagefill($sheet, 0, 0, $clear);
imagefilledpolygon($sheet, $flatten($shape["corners"]),
imagecolorallocate($sheet, $shape["ink"][0], $shape["ink"][1],
$shape["ink"][2]));
foreach ($shape["holes"] as $hole) {
imagefilledpolygon($sheet, $flatten($hole), $clear);
}
imagealphablending($canvas, true);
imagecopy($canvas, $sheet, 0, 0, 0, 0, $wide, $high);
}
/**
* areaOfLoopBounds gives how much ground a loop covers, as the area of the
* smallest box around it. Used to tell a letter from the middles inside it,
* since the letter is the one that covers the most.
* @param array $loop the corners of the loop
* @return float how much ground it covers
*/
public static function areaOfLoopBounds($loop)
{
$lefts = array_column($loop, 0);
$foots = array_column($loop, 1);
if (empty($lefts) || empty($foots)) {
return 0;
}
return (max($lefts) - min($lefts)) * (max($foots) - min($foots));
}
/**
* stencilFromXObject gives the stencil a page draws, where the picture is
* one: a mark saying only where ink goes, coded either the way a fax is
* coded or the way scanned text is. Nothing comes back for an ordinary
* picture.
* @param string $document the document's bytes
* @param array $held the document's packed objects, by number
* @param int $number which object holds the picture
* @return mixed rows of 0 for ink and 1 for none, with how wide and tall
* they are, or false where the picture is not a stencil
*/
public static function stencilFromXObject($document, $held, $number)
{
$said = self::objectNumbered($document, $held, $number);
$fax = (strpos($said, "CCITTFaxDecode") !== false);
$scanned = (strpos($said, "JBIG2Decode") !== false);
if ((!$fax && !$scanned) ||
!preg_match('/\/Width\s+(\d+)/', $said, $across) ||
!preg_match('/\/Height\s+(\d+)/', $said, $down)) {
return false;
}
$bytes = self::streamOfObject($document, $held, $number);
if ($bytes === "") {
$rows = [];
} else if ($fax) {
$rows = L\CcittFax::decode($bytes, (int)$across[1],
(int)$down[1], strpos($said, "/BlackIs1 true") !== false);
} else {
$rows = L\JBig2::decode($bytes, (int)$across[1],
(int)$down[1]);
}
/* A picture may say its two values are to be read the other way
round, which for a stencil turns the ink and the blank over. */
if (!empty($rows) && preg_match('/\/Decode\s*\[\s*1\s+0/',
$said)) {
foreach ($rows as $row_at => $row) {
foreach ($row as $step => $one) {
$rows[$row_at][$step] = ($one == 0) ? 1 : 0;
}
}
}
if (empty($rows)) {
return false;
}
return ["rows" => $rows, "wide" => (int)$across[1],
"high" => (int)$down[1]];
}
/**
* layStencilOnPicture lays a stencil onto the picture being built, in one
* color, working out for each place on the picture how much of the
* stencil's ink falls there and mixing that much color into what is already
* there. Shrinking a stencil as though it were a picture loses it: a mark
* one pixel wide becomes a faint smear, and two marks near each other
* merge, so lettering comes back as a blur in the right color. Asking
* instead how much ink covers each place keeps a thin stroke dark and keeps
* the gap between two strokes open.
* @param object $canvas the picture being built
* @param array $stencil its rows and how wide and tall they are
* @param array $ink the color to lay it in
* @param float $left where it begins across the picture
* @param float $top where it begins down the picture
* @param float $wide how wide it stands on the picture
* @param float $high how tall it stands
*/
public static function layStencilOnPicture($canvas, $stencil, $ink, $left,
$top, $wide, $high)
{
$canvas_wide = imagesx($canvas);
$canvas_high = imagesy($canvas);
$from = max(0, (int)floor($left));
$to = min($canvas_wide - 1, (int)ceil($left + $wide));
$begins = max(0, (int)floor($top));
$ends = min($canvas_high - 1, (int)ceil($top + $high));
if ($wide <= 0 || $high <= 0) {
return;
}
$across_each = $stencil["wide"] / $wide;
$down_each = $stencil["high"] / $high;
imagealphablending($canvas, false);
for ($down = $begins; $down <= $ends; $down++) {
$row_from = (int)floor(($down - $top) * $down_each);
$row_to = (int)ceil(($down + 1 - $top) * $down_each);
for ($step = $from; $step <= $to; $step++) {
$col_from = (int)floor(($step - $left) * $across_each);
$col_to = (int)ceil(($step + 1 - $left) * $across_each);
$marked = 0;
$looked = 0;
for ($row = max(0, $row_from);
$row < min($stencil["high"], max($row_to,
$row_from + 1)); $row++) {
for ($column = max(0, $col_from);
$column < min($stencil["wide"], max($col_to,
$col_from + 1)); $column++) {
$looked++;
if ($stencil["rows"][$row][$column] == 0) {
$marked++;
}
}
}
if ($looked < 1 || $marked < 1) {
continue;
}
$part = $marked / $looked;
$under = imagecolorat($canvas, $step, $down);
$mixed = imagecolorallocate($canvas,
(int)round((($under >> 16) & 255) * (1 - $part) +
$ink[0] * $part),
(int)round((($under >> 8) & 255) * (1 - $part) +
$ink[1] * $part),
(int)round(($under & 255) * (1 - $part) +
$ink[2] * $part));
imagesetpixel($canvas, $step, $down, $mixed);
}
}
}
/**
* pageResourceEntries gives what a page keeps under one of its resource
* headings, such as the pictures it draws or the fonts it sets words in. A
* page may write the heading out where it stands or point at another object
* holding it, and both are read here: following only the written-out kind
* left a page's pictures unfindable and its cover drawn blank.
* @param string $document the document's bytes
* @param array $held the document's packed objects, by number
* @param string $page_object the page
* @param string $heading which resource, such as XObject or Font
* @return string what the page keeps under it, empty where it keeps nothing
*/
public static function pageResourceEntries($document, $held, $page_object,
$heading)
{
/* A page may write its resources out where it stands, or name
another object that holds them. Reading only the first left a
page whose resources live elsewhere with no pictures at all, so
its thumbnail came from the older way and lost the cover art.
Where the page names one, that object is read and the heading
looked for inside it. */
if (preg_match('/\/Resources\s+(\d+)\s+\d+\s+R/', $page_object,
$elsewhere)) {
$kept = self::objectNumbered($document, $held,
(int)$elsewhere[1]);
if ($kept !== false) {
$page_object = $kept;
}
}
if (preg_match('/\/' . $heading . '\s*<<(.*?)>>/s', $page_object,
$found)) {
return $found[1];
}
if (preg_match('/\/' . $heading . '\s+(\d+)\s+\d+\s+R/',
$page_object, $named)) {
$listing = self::objectNumbered($document, $held,
(int)$named[1]);
if (preg_match('/<<(.*)>>/s', $listing, $inside)) {
return $inside[1];
}
return $listing;
}
return "";
}
/**
* fontsNamedByPage gives the letter shapes a page carries, by the name the
* page calls each font. A page names a font when it sets words in it, and
* the shapes live in a program carried with the document; without them
* words are drawn with letters of our own and come back in the wrong face.
* @param string $document the document's bytes
* @param array $held the document's packed objects, by number
* @param string $page_object the page, which names its fonts
* @return array a font reader by the name the page uses
*/
public static function fontsNamedByPage($document, $held, $page_object)
{
$faces = [];
$listing = self::pageResourceEntries($document, $held, $page_object,
"Font");
if ($listing === "") {
return $faces;
}
preg_match_all('/\/([^\s\/]+)\s+(\d+)\s+\d+\s+R/', $listing,
$pairs, PREG_SET_ORDER);
foreach ($pairs as $one) {
$font = self::objectNumbered($document, $held, (int)$one[2]);
if (!preg_match('/\/FontDescriptor\s+(\d+)/', $font, $says)) {
continue;
}
$describes = self::objectNumbered($document, $held,
(int)$says[1]);
if (!preg_match('/\/FontFile3\s+(\d+)/', $describes,
$where)) {
continue;
}
$program = self::streamOfObject($document, $held,
(int)$where[1]);
if ($program === "") {
continue;
}
$face = new L\CompactFontFormat($program);
/* A font made for one document numbers its letters itself
and the page says, in a table of differences, which
number stands for which letter. Without that table the
words of a headline come back as control characters and
the headline is not drawn. */
$face->names_by_code = self::namesByCode($document, $held,
$font);
/* How wide each letter is stands in the page's own font
entry, which is plainer than working it out of the font
program and is what the page laid the words out by. The
first character the list covers is given beside it. */
if (preg_match('/\/FirstChar\s+(\d+)/', $font, $first) &&
preg_match('/\/Widths\s*\[([^\]]*)\]/s', $font,
$listed)) {
$wides = preg_split('/\s+/', trim($listed[1]));
$code = (int)$first[1];
foreach ($wides as $wide) {
if ($wide !== "") {
$face->stated_widths[$code] = (float)$wide;
$code++;
}
}
}
$faces[$one[1]] = $face;
}
return $faces;
}
/**
* drawTextRunWithFont draws one run of words on a picture using the letter
* shapes the document carries, so the words come out in the face the page
* sets them in rather than in letters of our own.
* @param object $canvas the picture being built
* @param array $run the words, where they sit, how large and in what
* @param object $face the font the run is set in
* @param float $across how much to shrink across
* @param float $down how much to shrink down
* @param array $page the page, for where its corner sits
* @return bool whether anything was drawn
*/
public static function drawTextRunWithFont($canvas, $run, $face, $across,
$down, $page)
{
/* The place is left in the page's own measure, since the shape
filler takes the page's corner off and turns the page over
itself. Taking it off here as well moved every letter by the
width of the margin and most of them off the picture. */
$size = $run["size"] ?? 0;
$left = $run["across"] ?? 0;
$foot = $run["down"] ?? 0;
$said = $run["words"] ?? "";
$ink = $run["ink"] ?? [0, 0, 0];
$drew = false;
$length = strlen($said);
for ($step = 0; $step < $length; $step++) {
$letter = $said[$step];
/* The page's own table of differences is asked first, since
a font made for one document numbers its letters itself
and those numbers are not the ones a standard letter goes
by. */
$name = $face->names_by_code[ord($letter)] ??
self::glyphNameForLetter($letter);
$loops = $face->outlineOf($name);
$code = ord($letter);
$wide = $face->stated_widths[$code] ??
$face->widthOf($name);
if (!empty($loops)) {
$shape = self::shapeFromGlyphLoops($loops, $left, $foot, $size,
$ink);
self::fillShapeOnPicture($canvas, $shape, $page,
$across, $down);
$drew = true;
}
$left += $wide * $size / self::EM_PARTS;
}
return $drew;
}
/**
* pictureFromBytes turns the bytes of a picture into something that
* can be drawn, reading it the way the kind of picture it is asks
* for.
*
* A picture printed for paper is often stored in the four inks a
* press uses rather than in the three lights a screen uses. The
* drawing library reads such a picture without complaint and drops
* its black ink, which leaves a cover pale and low in contrast, so
* those are read by the reader that knows the four inks.
*
* @param string $bytes The picture as the document stored it.
* @return mixed The picture, or false where it cannot be read.
*/
public static function pictureFromBytes($bytes)
{
if ($bytes === "") {
return false;
}
/* A picture written in the four inks a press uses is read by
the reader that knows those inks, since the drawing library
drops the black one and hands back a washed out picture. */
/* A picture in a form the drawing library cannot read is
passed over rather than warned about. The warning went into
the page a reader was being sent, and a page whose body
should be one thing does not read once a warning stands in
front of it. */
$picture = JpgProcessor::toImage($bytes);
if ($picture !== false) {
return $picture;
}
/* Where neither reading works, the drawing library is asked
once more on its own, since a picture it can open is better
than a gap in the page. It is handed only bytes that begin
the way a picture it knows begins, since anything else raises
a warning that lands in the page being built. */
return JpgProcessor::toImage($bytes);
}
/**
* shapeFromGlyphLoops turns a letter's loops, which a font draws in
* thousandths of the width of the letter m, into a shape on the
* page at a place and a size.
*
* @param array $loops the letter's loops
* @param float $left where the letter begins across the page
* @param float $foot where the letter sits up the page
* @param float $size how large it is set
* @param array $ink what color it is
* @return array the shape, with the widest loop as the letter
*/
public static function shapeFromGlyphLoops($loops, $left, $foot,
$size, $ink)
{
$placed = [];
foreach ($loops as $loop) {
$one = [];
foreach ($loop as $point) {
$one[] = [$left + $point[0] * $size / self::EM_PARTS,
$foot + $point[1] * $size / self::EM_PARTS];
}
$placed[] = $one;
}
$widest = 0;
$most = -1;
foreach ($placed as $which => $loop) {
$room = self::areaOfLoopBounds($loop);
if ($room > $most) {
$most = $room;
$widest = $which;
}
}
$outline = $placed[$widest];
unset($placed[$widest]);
return ["corners" => $outline, "holes" => array_values($placed),
"ink" => $ink];
}
/**
* namesByCode reads the table in which a page says which number
* stands for which letter of a font. A font made for one document
* numbers its letters itself, and the page carries the table under
* the name of differences.
*
* @param string $document the whole document
* @param array $held the objects packed within the document
* @param string $font the page's entry for this font
* @return array the name of the letter each number stands for
*/
public static function namesByCode($document, $held, $font)
{
if (!preg_match('/\/Encoding\s+(\d+)\s+\d+\s+R/', $font,
$where)) {
return [];
}
$said = self::objectNumbered($document, $held, (int)$where[1]);
if (!preg_match('/\/Differences\s*\[(.*?)\]/s', $said,
$listed)) {
return [];
}
$names = [];
$code = 0;
preg_match_all('/(\d+)|\/([^\s\/\]]+)/', $listed[1], $pieces,
PREG_SET_ORDER);
foreach ($pieces as $piece) {
if (($piece[1] ?? "") !== "") {
$code = (int)$piece[1];
continue;
}
$names[$code] = $piece[2];
$code++;
}
return $names;
}
/**
* glyphNameForLetter gives the name a font knows a letter by.
* @param string $letter the letter as the page wrote it
* @return string the name the font uses
*/
public static function glyphNameForLetter($letter)
{
$named = [" " => "space", "." => "period", "," => "comma",
"-" => "hyphen", "\'" => "quoteright", "/" => "slash",
"0" => "zero", "1" => "one", "2" => "two", "3" => "three",
"4" => "four", "5" => "five", "6" => "six", "7" => "seven",
"8" => "eight", "9" => "nine"];
return $named[$letter] ?? $letter;
}
/**
* inkFromColorOperator reads a color from the numbers a page gave and the
* word it used to set them, so a color set inside a run of text is read the
* same way as one set for a shape.
* @param string $said the numbers, as they were written
* @param string $how which word set them: scn, sc, g, rg or k
* @return array the color as screen values
*/
public static function inkFromColorOperator($said, $how)
{
$numbers = [];
foreach (preg_split('/\s+/', trim($said)) as $one) {
if ($one !== "") {
$numbers[] = (float)$one;
}
}
if ($how === "g") {
return self::inkFromGray($numbers);
}
if ($how === "rg") {
return self::inkFromScreen($numbers);
}
if ($how === "k") {
return self::inkFromInk($numbers);
}
return self::inkFromColorSpaceValues($numbers);
}
/**
* inkFromColorSpaceValues reads a color a page set through a color space of
* its own. How many numbers were given says what they mean: one is a gray,
* three are screen colors, and four are the colors ink is mixed from.
* @param array $numbers what the page last said
* @return array the color as screen values
*/
public static function inkFromColorSpaceValues($numbers)
{
$count = count($numbers);
if ($count >= self::INK_PARTS) {
return self::inkFromInk($numbers);
}
if ($count >= self::SCREEN_PARTS) {
return self::inkFromScreen($numbers);
}
if ($count >= 1) {
return self::inkFromGray($numbers);
}
return [0, 0, 0];
}
/**
* spaceJustNamed gives the name of the color space a page has just
* set, which is written just before the word that sets it.
*
* @param string $drawing the page's drawing
* @param int $at where the reading has reached
* @return string the name, empty where none was written
*/
public static function spaceJustNamed($drawing, $at)
{
$before = substr($drawing, max(0, $at - self::NAME_LOOK_BACK),
min($at, self::NAME_LOOK_BACK));
if (preg_match('/\/([^\s\/]+)\s+(?:cs|CS)\s*$/', $before,
$found)) {
return $found[1];
}
return "";
}
/**
* inkFromTint reads a color said as one number in a space of the
* page's own. Such a number is how much of a single ink is laid
* down, so one is that ink at full and zero is none of it, which is
* the other way round from a shade of gray.
*
* @param array $numbers the numbers gathered before the instruction
* @return array the color
*/
public static function inkFromTint($numbers)
{
$tint = (float)end($numbers);
if ($tint < 0) {
$tint = 0;
}
if ($tint > 1) {
$tint = 1;
}
$shade = (int)round((1 - $tint) * self::MOST_SHADE);
return [$shade, $shade, $shade];
}
/**
* lowestNumberedPageObject hands back the first thing among a document's
* packed objects that says it is a page. Walking down from the catalog is
* the proper way to the first page, but a document whose tree cannot be
* followed still keeps its pages, and one of them is better than none:
* without this the reader fell back to guessing at the page size and had no
* list of what the page draws with.
* @param array $held the document's packed objects, by number
* @return string|bool the page, or false where none says it is one
*/
public static function lowestNumberedPageObject($held)
{
$lowest = 0;
foreach ($held as $number => $object) {
if (preg_match('/\/Type\s*\/Page[^s]/', $object) &&
($lowest == 0 || $number < $lowest)) {
$lowest = $number;
}
}
return ($lowest == 0) ? false : $held[$lowest];
}
/**
* firstDrawing 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;
}
/**
* wordsOnPage 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 = self::plainFrame();
/* Black until the page says otherwise, which is what a page means
by saying nothing. */
$ink = [0, 0, 0];
/* The face and size in force, carried from block to block: a
page sets its font once and keeps writing in it, so a block
that names none writes in the last one set. */
$type_state = null;
$put_aside = [];
$number = '[-0-9.]+';
/* All six numbers of a frame are kept, not four. Keeping only
the two that scale and the two that move meant a frame that
turns or leans came back as one that flattens everything to
nothing, and every run of words inside it was then set at a
size of zero and never drawn. */
$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[8] ?? "") !== "") {
/* A frame set inside another stands on it, so what they
say is combined rather than replaced. */
$frame = self::frameWithin(["a" => (float)$one[3],
"b" => (float)$one[4], "c" => (float)$one[5],
"d" => (float)$one[6], "e" => (float)$one[7],
"f" => (float)$one[8]], $frame);
continue;
}
if (($one[11] ?? "") === "g") {
$shade = (int)round((float)$one[10] * self::MOST_SHADE);
$ink = [$shade, $shade, $shade];
continue;
}
if (($one[15] ?? "") === "rg") {
$ink = [
(int)round((float)$one[12] * self::MOST_SHADE),
(int)round((float)$one[13] * self::MOST_SHADE),
(int)round((float)$one[14] * self::MOST_SHADE)];
continue;
}
if (($one[20] ?? "") === "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[19];
$ink = [
(int)round((1 - (float)$one[16]) * $black *
self::MOST_SHADE),
(int)round((1 - (float)$one[17]) * $black *
self::MOST_SHADE),
(int)round((1 - (float)$one[18]) * $black *
self::MOST_SHADE)];
continue;
}
if (($one[9] ?? "") !== "") {
foreach (self::runsInBlock($one[9], $frame, $type_state)
as $run) {
/* A run written in a color of its own keeps it; the
page's color stands in only where the block set
none. Writing over it left white type on a dark
cover black and so invisible. */
if (empty($run["ink"])) {
$run["ink"] = $ink;
}
$runs[] = $run;
}
}
}
return $runs;
}
/**
* shapesOnPage 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 = self::plainFrame();
$put_aside = [];
$ink = [0, 0, 0];
$inks = [];
/* The last name the page wrote. A page names a thing and then
says what to do with it, so the name of a picture is the one
written just before the word that places it. */
$last_name = "";
/* Where what comes next may show, as a path the page built and
did not paint. Empty means the whole page. */
$clip = [];
$clips = [];
/* Whether the color space in force is one of the page's own,
in which a single number is a tint of one ink rather than a
shade of gray. */
$tinted = false;
$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;
}
if (strncmp($token, "/", 1) === 0) {
$last_name = substr($token, 1);
continue;
}
switch ($token) {
case "q":
$put_aside[] = $frame;
$inks[] = $ink;
$clips[] = $clip;
break;
case "Q":
if (!empty($put_aside)) {
$frame = array_pop($put_aside);
}
if (!empty($inks)) {
$ink = array_pop($inks);
}
if (!empty($clips)) {
$clip = array_pop($clips);
}
break;
case "cm":
if (count($numbers) >= 6) {
$six = array_slice($numbers, -6);
$frame = self::frameWithin(["a" => $six[0],
"b" => $six[1], "c" => $six[2],
"d" => $six[3], "e" => $six[4],
"f" => $six[5]], $frame);
}
break;
case "g":
$ink = self::inkFromGray($numbers);
break;
case "rg":
$ink = self::inkFromScreen($numbers);
break;
case "k":
$ink = self::inkFromInk($numbers);
break;
case "cs":
case "CS":
/* A page may name a color space of its own before
setting a color in it. One number in such a space
is a tint of a single ink: one means the ink at
full, which is dark, where one in gray means
white. Reading a tint as gray drew a barcode's
bars in white on a white box. */
$named = self::spaceJustNamed($drawing, $at);
$tinted = ($named !== "" &&
!in_array($named, self::DEVICE_SPACES, true));
break;
case "sc":
case "scn":
if ($tinted && count($numbers) == 1) {
$ink = self::inkFromTint($numbers);
break;
}
/* A page may set a color through a color space of its
own rather than by naming gray, screen or ink
outright. How many numbers it gives says which of
the three it means. Reading none of these left
every shape black, so a page whose background is a
white rectangle painted itself out. */
$ink = self::inkFromColorSpaceValues($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 = [];
}
/* A filled path may be several loops at once, and a
letter is: an A is its outline and the triangle
inside it. Kept apart, both were filled solid and
the letter lost its middle, so the loops of one
fill are kept together and the ones after the first
are punched out of it. */
$loops = [];
foreach ($path as $one) {
if (count($one) >= self::LEAST_POINTS) {
$loops[] = $one;
}
}
if (!empty($loops)) {
/* Which loop is the letter and which are its
middles is not the order they are written in,
so the one covering the most ground is taken as
the letter. Taking the first instead punched
the letter out of itself and an A or an o
vanished. */
$widest = 0;
$most = -1;
foreach ($loops as $which => $loop) {
$room = self::areaOfLoopBounds($loop);
if ($room > $most) {
$most = $room;
$widest = $which;
}
}
$outline = $loops[$widest];
unset($loops[$widest]);
$shapes[] = ["corners" => $outline,
"holes" => array_values($loops), "ink" => $ink];
}
$path = [];
break;
case "Do":
/* A picture is placed here, and it belongs in the same
order as the shapes around it: a page draws its
background, then its pictures, then whatever is
meant to sit over them. Keeping one ordered list
lets the whole page be drawn in the order it says.
The name of the picture is the last name the page
wrote, which the walk keeps as it goes. Reading it
back out of the characters before the token failed
wherever the token sat near the start of the
drawing, since the window then ran past it: a two
page document that placed its cover forty
characters in lost the cover. */
if ($last_name !== "") {
$named = [1 => $last_name];
/* A picture is drawn into the square from
zero to one, so where its corners land is
the frame in force put through those two
corners. */
$corner = self::throughFrame($frame, 0, 0);
$far = self::throughFrame($frame, 1, 1);
$shapes[] = ["picture" => $named[1],
"wide" => abs($far[0] - $corner[0]),
"high" => abs($far[1] - $corner[1]),
"left" => min($corner[0], $far[0]),
"foot" => min($corner[1], $far[1]),
"ink" => $ink, "clip" => $clip];
}
break;
case "W":
case "W*":
/* The path just built is not painted: it says where
what comes next may show. A cover's masthead is
drawn this way, with the letters cut out of a
block of color, so ignoring it painted that block
over the letters. */
if (!empty($line)) {
$path[] = $line;
$line = [];
}
$clip = $path;
break;
case "n":
case "S":
case "s":
$path = [];
$line = [];
break;
}
$numbers = [];
}
return $shapes;
}
/**
* nextToken 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 === "/") {
/* A name is handed back with its slash, since the word that
follows it often works on the thing the name stands for:
a page names a picture and then says to place it. */
$from = $at;
$at++;
while ($at < $length &&
strpos(" \t\r\n()<>[]/", $drawing[$at]) === false) {
$at++;
}
return substr($drawing, $from, $at - $from);
}
if ($one === "(" || $one === "<" || $one === "[" || $one === "]" ||
$one === ">" || $one === ")") {
/* Text 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);
}
/**
* placeOnPage 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 self::throughFrame($frame, 0, 0);
}
return self::throughFrame($frame, $pair[0], $pair[1]);
}
/**
* throughFrame puts one point of the page's own measure through the
* frame in force and hands back where it lands.
*
* A page draws inside frames that may be moved, scaled, turned and
* leaned, and it says so with six numbers a drawing library calls a
* matrix. Keeping only a scale and an offset, as this did before,
* put every turned or leaned drawing in the wrong place: a cover
* whose title sits in a rotated box drew that title at the foot of
* the page.
*
* @param array $frame The six numbers of the frame in force.
* @param float $across How far across the point sits in the page's
* own measure.
* @param float $down How far up the page the point sits, since a
* page is measured from its foot.
* @return array Where the point lands, across then up.
*/
public static function throughFrame($frame, $across, $down)
{
return [$frame["a"] * $across + $frame["c"] * $down +
$frame["e"], $frame["b"] * $across + $frame["d"] * $down +
$frame["f"]];
}
/**
* frameWithin puts one frame inside another and hands back the one
* that does the work of both. A page opens frames within frames, and
* what is drawn in the innermost is placed by all of them together.
*
* @param array $inner The frame being opened.
* @param array $outer The frame it is opened inside.
* @return array The frame that does the work of both.
*/
public static function frameWithin($inner, $outer)
{
return [
"a" => $inner["a"] * $outer["a"] + $inner["b"] * $outer["c"],
"b" => $inner["a"] * $outer["b"] + $inner["b"] * $outer["d"],
"c" => $inner["c"] * $outer["a"] + $inner["d"] * $outer["c"],
"d" => $inner["c"] * $outer["b"] + $inner["d"] * $outer["d"],
"e" => $inner["e"] * $outer["a"] + $inner["f"] * $outer["c"]
+ $outer["e"],
"f" => $inner["e"] * $outer["b"] + $inner["f"] * $outer["d"]
+ $outer["f"],
];
}
/**
* plainFrame gives the frame that changes nothing, which is where a
* page starts before it opens any frame of its own.
*
* @return array The frame that leaves a point where it is.
*/
public static function plainFrame()
{
return ["a" => 1.0, "b" => 0.0, "c" => 0.0, "d" => 1.0,
"e" => 0.0, "f" => 0.0];
}
/**
* addCurve 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 = self::throughFrame($frame, $six[0], $six[1]);
$second = self::throughFrame($frame, $six[2], $six[3]);
$to = self::throughFrame($frame, $six[4], $six[5]);
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]];
}
}
/**
* addBox 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);
$left = $four[0];
$foot = $four[1];
$wide = $four[2];
$high = $four[3];
/* The four corners go through the frame one at a time, so that a
box drawn in a turned or leaned frame comes out turned or
leaned rather than square. */
$path[] = [
self::throughFrame($frame, $left, $foot),
self::throughFrame($frame, $left + $wide, $foot),
self::throughFrame($frame, $left + $wide, $foot + $high),
self::throughFrame($frame, $left, $foot + $high)];
}
/**
* inkFromGray 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];
}
/**
* inkFromScreen 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)];
}
/**
* inkFromInk 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)];
}
/**
* runsInBlock 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
* @param array &$type_state the face, size and line-drop last set,
* handed in from the block before and handed back changed, since
* a page sets its font once and keeps writing in it across
* blocks; null means no block has set one yet
* @return array each run of words with its place and size
*/
public static function runsInBlock($block, $frame = null,
&$type_state = null)
{
$runs = [];
/* The font a block writes in is the one last set, whether that
was in this block or an earlier one: a page sets its face and
size once and keeps writing in it across blocks. Starting each
block back at the plain size set a block that names no font of
its own to twelve, and the text matrix then multiplied that
twelve, so a one-point font in a frame scaled forty-one-fold
came back at four hundred ninety-two and one giant pair of
letters covered the thumbnail. The caller hands the state in
and it is handed back changed. */
if ($type_state === null) {
$type_state = ["size" => self::PLAIN_SIZE, "face" => "",
"leading" => 0.0];
}
$size = $type_state["size"];
$face = $type_state["face"];
/* A marked-content point names its properties in a dictionary,
and a language note like /Lang (en-GB) carries a string. Left
in place that string read as words the page wrote, so en-GB
rode along after real lines and stood alone as lines of its
own. The dictionaries say nothing about what the page draws,
so they are dropped whole; a pair of hex-string angles never
opens with two. */
$block = preg_replace('/<<.*?>>/s', ' ', $block);
$frame = ($frame === null) ? self::plainFrame() : $frame;
$scale = sqrt(abs($frame["a"] * $frame["d"] -
$frame["b"] * $frame["c"]));
$corner = self::throughFrame($frame, 0, 0);
$across = $corner[0];
$down = $corner[1];
$from_across = $across;
$from_down = $down;
$said = "";
$ink = null;
/* How far the writing drops for a new line, which a block may
set once and then use for every line after it. */
$leading = $type_state["leading"];
$number = '[-0-9.]+';
/* A font's name may hold more than letters and digits: a name
like T1_0 is ordinary, and not matching the underscore left
the size at its default, which the text matrix then multiplied
until one line covered the page. */
$pattern = '/\/([^\s\/<>\[\]()]+)\s+(' . $number . ')\s+Tf' .
'|(' . $number . ')\s+' . $number . '\s+' . $number .
'\s+(' . $number . ')\s+(' . $number . ')\s+(' . $number .
')\s+Tm' .
'|(' . $number . ')\s+(' . $number . ')\s+(?:TD|Td)' .
'|(' . $number . ')\s+(TL)' .
'|(T\*)' .
'|((?:' . $number . '[ ]+){1,4})(scn|sc|g|rg|k)[\s]' .
'|\(([^)]*)\)' .
'|<([0-9A-Fa-f\s]+)>/s';
preg_match_all($pattern, $block, $found, PREG_SET_ORDER);
foreach ($found as $one) {
if (($one[2] ?? "") !== "") {
/* Which font the words that follow are set in, so their
real letter shapes can be found in the program the
document carries. */
self::keepRun($runs, $said, $across, $down, $size * $scale,
$ink, $face);
$size = (float)$one[2];
$face = $one[1];
continue;
}
if (($one[6] ?? "") !== "") {
self::keepRun($runs, $said, $across, $down, $size * $scale,
$ink, $face);
/* A text matrix says outright how large the text is set,
rather than nudging whatever the page was already
scaled by. Standing it on the page's frame multiplied
the two together, so a line set at twelve points in a
frame scaled twelvefold came back at a hundred and
forty-four and covered the page. */
/* Where a run of words says where it sits, it says so
in the page's own measure, so the place goes through
the frame in force rather than being added to the
frame's corner. */
$scale = abs((float)$one[4]);
$place = self::throughFrame($frame, (float)$one[5],
(float)$one[6]);
$across = $place[0];
$down = $place[1];
continue;
}
if (($one[8] ?? "") !== "") {
/* A move to the next line is said in the writing's own
measure, so it grows with the size the writing is set
at. Adding it unscaled moved a fifty point headline
by less than a point, and its lines landed on one
another. */
self::keepRun($runs, $said, $across, $down, $size * $scale,
$ink, $face);
$said = "";
$across += (float)$one[7] * $scale;
$down += (float)$one[8] * $scale;
$leading = abs((float)$one[8]) * $scale;
continue;
}
if (($one[10] ?? "") === "TL") {
/* How far the writing drops when the block asks for a
next line, set once and used by every T* after it. */
$leading = abs((float)$one[9]) * $scale;
continue;
}
if (($one[11] ?? "") === "T*") {
self::keepRun($runs, $said, $across, $down,
$size * $scale, $ink, $face);
$said = "";
$down -= $leading;
continue;
}
if (($one[13] ?? "") !== "") {
/* A color set inside the text says what the words that
follow are written in. Reading only the color the page
was in left every line black, so white type on a dark
cover came out invisible. */
self::keepRun($runs, $said, $across, $down, $size * $scale,
$ink, $face);
$ink = self::inkFromColorOperator($one[12], $one[13]);
continue;
}
if (($one[15] ?? "") !== "") {
/* A page may write its letters as pairs of hex digits
rather than between brackets, which is what a font
that numbers its own letters is usually written
with. */
$said .= self::lettersFromHex($one[15]);
continue;
}
if (($one[14] ?? "") !== "") {
$said .= stripcslashes($one[14]);
}
}
self::keepRun($runs, $said, $across, $down, $size * $scale, $ink,
$face);
$type_state = ["size" => $size, "face" => $face,
"leading" => $leading];
return $runs;
}
/**
* lettersFromHex turns the pairs of hex digits a page may write its
* letters as into those letters. A page setting a headline in a
* font that numbers its own letters writes them this way.
*
* @param string $digits the digits between the angle brackets
* @return string the letters they stand for
*/
public static function lettersFromHex($digits)
{
$digits = preg_replace('/\s+/', "", $digits);
if (strlen($digits) % 2 == 1) {
$digits .= "0";
}
$letters = "";
for ($at = 0; $at + 1 < strlen($digits); $at += 2) {
$letters .= chr(hexdec(substr($digits, $at, 2)));
}
return $letters;
}
/**
* keepRun 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
* @param array $ink the color the words are written in, or null
* @param string $face which font the words are set in
*/
public static function keepRun(&$runs, &$said, $across, $down, $size,
$ink = null, $face = "")
{
if (trim($said) !== "") {
$runs[] = ["across" => $across, "down" => $down,
"size" => $size, "words" => trim($said), "ink" => $ink,
"face" => $face];
}
$said = "";
}
}