<?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;
ini_set("gd.jpeg_ignore_warning", 1);
/**
* Used to create crawl summary information
* for JPEG files
*
* @author Chris Pollett
*/
class JpgProcessor extends ImageProcessor
{
/**
* 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 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,
$summarizer_option = self::BASIC_SUMMARIZER)
{
parent::__construct($plugins, $max_description_len, $summarizer_option);
/** Register File Types We Handle*/
self::$indexed_file_types[] = "jpg";
self::$indexed_file_types[] = "jpeg";
self::$image_types[] = "jpg";
self::$image_types[] = "jpeg";
self::$mime_processor["image/jpeg"] = "JpgProcessor";
}
/**
* {@inheritDoc}
*
* @param string $page the image represented as a character string
* @param string $url the url where the image was downloaded from
* @return array summary information including a thumbnail and a
* description (where the description is just the url)
*/
public function process($page, $url)
{
if (is_string($page)) {
set_error_handler(null);
$image = @imagecreatefromstring($page);
restore_error_handler();
$summary = [];
$this->addWidthHeightSummary($summary, $page);
if (!empty($image)) {
$summary[self::AVERAGE_COLOR] = $this->averageColor($image);
$summary[self::IS_BLACK_AND_WHITE] =
$this->isBlackAndWhite($image);
}
$summary[self::TITLE] = "";
$summary[self::LANG] = "mul";
$file_name = UrlParser::getWordsLastPathPartUrl($url);
$summary[self::DESCRIPTION] = $file_name . "\n";
if (ComputerVision::ocrEnabled()) {
set_error_handler(null);
$temp_file = $this->saveTempFile($page, $url, "jpg");
$lang = UrlParser::getLang($url);
$ocr_data = ComputerVision::recognizeText($temp_file, [$lang]);
if (!empty($ocr_data)) {
$summary[self::DESCRIPTION] .= $ocr_data;
}
@unlink($temp_file);
restore_error_handler();
}
if (function_exists("exif_read_data")) {
set_error_handler(null);
$temp_file = $this->saveTempFile($page, $url, "jpg");
$summary[self::DESCRIPTION] .= "\nEXIF DATA\n" .
print_r(@exif_read_data($temp_file), true);
@unlink($temp_file);
restore_error_handler();
} else {
$summary[self::DESCRIPTION] = $file_name;
}
$xmp_data = $this->getXmpData($page);
if ($xmp_data) {
$summary[self::DESCRIPTION] .= "\nXMP Data\n".
$xmp_data;
}
$summary[self::LINKS] = [];
$summary[self::PAGE] =
"<html><body><div><img src='data:image/jpeg;base64," .
base64_encode($page)."' alt='". $file_name .
"' ></div></body></html>";
if ($image) {
$summary[self::WIDTH] = imagesx($image);
$summary[self::HEIGHT] = imagesy($image);
$golden_ratio = 1.618; //approx
$ratio = min($golden_ratio,
$summary[self::WIDTH]/$summary[self::HEIGHT]);
$thumb_string = self::createThumb($image,
intval($ratio * C\THUMB_DIM), C\THUMB_DIM);
}
if (!empty($thumb_string)) {
$summary[self::THUMB] = 'data:image/webp;base64,'.
base64_encode($thumb_string);
}
}
return $summary;
}
/**
* How many values a side of one of the blocks a jpeg is built from
* holds. Everything about a jpeg is counted in these blocks.
*/
const BLOCK_SIDE = 8;
/**
* How many values a whole block holds.
*/
const BLOCK_VALUES = 64;
/**
* The longest a run of bits standing for one value may be.
*/
const LONGEST_RUN = 16;
/**
* What is added back to every value at the end. A jpeg keeps its
* values either side of nought and this puts them back into the range
* a color is said in.
*/
const MIDDLE_SHADE = 128;
/**
* The largest a color may be.
*/
const MOST_SHADE = 255;
/**
* How the eight by eight blocks a jpeg is built from are read out: not
* row by row but along diagonals, which is how they were written.
* @var array
*/
public static $zigzag = [
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5,
12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28,
35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51,
58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63];
/**
* Turns a jpeg into an image, whatever colors it was written in.
*
* The image reader that comes with PHP understands the three colors a
* screen uses and nothing else, so a jpeg written for print, in the
* four colors ink uses, comes back from it as a black rectangle. Such
* a jpeg is taken apart here instead and its colors worked out, and
* everything else is left to the reader as before.
*
* @param string $bytes the jpeg's bytes
* @return object|bool the image, or false if it cannot be read
*/
public static function toImage($bytes)
{
$about = @getimagesizefromstring($bytes);
if ($about !== false && ($about["channels"] ?? 3) == 4) {
$worked_out = self::fromInkColors($bytes);
if ($worked_out !== false) {
return $worked_out;
}
}
return @imagecreatefromstring($bytes);
}
/**
* Works out an image from a jpeg written in the four colors ink uses.
*
* @param string $bytes the jpeg's bytes
* @return object|bool the image, or false if it cannot be read
*/
public static function fromInkColors($bytes)
{
$read = self::readJpeg($bytes);
if ($read === false || count($read["planes"]) != 4) {
return false;
}
$wide = $read["width"];
$high = $read["height"];
$image = imagecreatetruecolor($wide, $high);
for ($down = 0; $down < $high; $down++) {
for ($across = 0; $across < $wide; $across++) {
$at = $down * $wide + $across;
/* Each value says how much ink is laid down, the fourth
being black. Paper with no ink on it lets all the light
through, so what the eye sees is what is left after
each ink takes its share, and the black ink takes its
share of whatever the others left. */
$black = 255 - $read["planes"][3][$at];
$red = (int)((255 - $read["planes"][0][$at]) * $black / 255);
$green = (int)((255 - $read["planes"][1][$at]) * $black /
255);
$blue = (int)((255 - $read["planes"][2][$at]) * $black /
255);
imagesetpixel($image, $across, $down,
($red << 16) | ($green << 8) | $blue);
}
}
return $image;
}
/**
* Reads a jpeg written the plain way, one pass over the whole picture,
* and hands back one plane of values for each color it carries.
*
* @param string $bytes the jpeg's bytes
* @return array|bool the width, the height, and a plane per color, or
* false if it is written some way this cannot read
*/
public static function readJpeg($bytes)
{
$quantities = [];
$trees = [];
$frame = null;
$restart = 0;
$at = 2;
$length = strlen($bytes);
while ($at < $length - 3) {
if ($bytes[$at] !== "\xFF") {
return false;
}
$marker = ord($bytes[$at + 1]);
if ($marker == 0xD8 || $marker == 0x01 ||
($marker >= 0xD0 && $marker <= 0xD7)) {
$at += 2;
continue;
}
$size = (ord($bytes[$at + 2]) << 8) | ord($bytes[$at + 3]);
$body = substr($bytes, $at + 4, $size - 2);
if ($marker == 0xDB) {
self::readQuantities($body, $quantities);
} else if ($marker == 0xC4) {
self::readTrees($body, $trees);
} else if ($marker == 0xDD) {
$restart = (ord($body[0]) << 8) | ord($body[1]);
} else if ($marker == 0xC0 || $marker == 0xC1) {
$frame = self::readFrame($body);
} else if ($marker >= 0xC2 && $marker <= 0xCF &&
$marker != 0xC4 && $marker != 0xC8 && $marker != 0xCC) {
/* written a way this does not read, such as the kind that
arrives a little at a time */
return false;
} else if ($marker == 0xDA) {
if ($frame === null) {
return false;
}
return self::readScan($bytes, $at + 2 + $size, $body,
$frame, $quantities, $trees, $restart);
}
$at += 2 + $size;
}
return false;
}
/**
* Reads the tables saying how much detail was thrown away, which is
* what the values have to be multiplied back up by.
*
* @param string $body the marker's bytes
* @param array &$quantities the tables read so far
*/
public static function readQuantities($body, &$quantities)
{
$at = 0;
while ($at < strlen($body)) {
$precision = ord($body[$at]) >> 4;
$which = ord($body[$at]) & 15;
$at++;
$table = [];
for ($index = 0; $index < self::BLOCK_VALUES; $index++) {
if ($precision == 0) {
$table[self::$zigzag[$index]] = ord($body[$at]);
$at++;
} else {
$table[self::$zigzag[$index]] =
(ord($body[$at]) << 8) | ord($body[$at + 1]);
$at += 2;
}
}
$quantities[$which] = $table;
}
}
/**
* Reads the tables saying which runs of bits stand for which values,
* turning each into a lookup from a run of bits to what it means.
*
* @param string $body the marker's bytes
* @param array &$trees the tables read so far
*/
public static function readTrees($body, &$trees)
{
$at = 0;
while ($at < strlen($body)) {
$kind = ord($body[$at]) >> 4;
$which = ord($body[$at]) & 15;
$at++;
$counts = [];
$total = 0;
for ($size = 1; $size <= self::LONGEST_RUN; $size++) {
$counts[$size] = ord($body[$at]);
$total += $counts[$size];
$at++;
}
$lookup = [];
$code = 0;
for ($size = 1; $size <= self::LONGEST_RUN; $size++) {
for ($index = 0; $index < $counts[$size]; $index++) {
$lookup[$size][$code] = ord($body[$at]);
$code++;
$at++;
}
$code <<= 1;
}
$trees[$kind][$which] = $lookup;
}
}
/**
* Reads how large the picture is and which colors it carries.
*
* @param string $body the marker's bytes
* @return array the height, width and colors
*/
public static function readFrame($body)
{
$height = (ord($body[1]) << 8) | ord($body[2]);
$width = (ord($body[3]) << 8) | ord($body[4]);
$count = ord($body[5]);
$colors = [];
for ($index = 0; $index < $count; $index++) {
$at = 6 + $index * 3;
$colors[] = ["id" => ord($body[$at]),
"wide" => ord($body[$at + 1]) >> 4,
"high" => ord($body[$at + 1]) & 15,
"quantity" => ord($body[$at + 2])];
}
return ["height" => $height, "width" => $width,
"colors" => $colors];
}
/**
* Reads the picture itself: the long run of bits after the last
* table, one eight by eight block at a time, until every block of
* every color has been read.
*
* @param string $bytes the whole jpeg
* @param int $from where the picture's bits begin
* @param string $body the scan marker's bytes
* @param array $frame how large the picture is and its colors
* @param array $quantities the tables of thrown-away detail
* @param array $trees the tables of what runs of bits mean
* @param int $restart how many blocks between restarts, zero for none
* @return array|bool the width, height and a plane per color
*/
public static function readScan($bytes, $from, $body, $frame,
$quantities, $trees, $restart)
{
$count = ord($body[0]);
$picked = [];
for ($index = 0; $index < $count; $index++) {
$id = ord($body[1 + $index * 2]);
$tables = ord($body[2 + $index * 2]);
foreach ($frame["colors"] as $place => $color) {
if ($color["id"] == $id) {
$picked[$place] = ["values" => $tables >> 4,
"runs" => $tables & 15];
}
}
}
$widest = 1;
$tallest = 1;
foreach ($frame["colors"] as $color) {
$widest = max($widest, $color["wide"]);
$tallest = max($tallest, $color["high"]);
}
$side = self::BLOCK_SIDE;
$across_groups = (int)ceil($frame["width"] / ($side * $widest));
$down_groups = (int)ceil($frame["height"] / ($side * $tallest));
$planes = [];
$plane_widths = [];
foreach ($frame["colors"] as $place => $color) {
$plane_widths[$place] = $across_groups * $side *
$color["wide"];
$planes[$place] = array_fill(0, $plane_widths[$place] *
$down_groups * $side * $color["high"], 0);
}
$reader = ["bytes" => $bytes, "at" => $from, "bits" => 0,
"held" => 0];
$carried = array_fill(0, count($frame["colors"]), 0);
$done = 0;
for ($down = 0; $down < $down_groups; $down++) {
for ($across = 0; $across < $across_groups; $across++) {
if ($restart > 0 && $done > 0 && $done % $restart == 0) {
self::skipToRestart($reader);
$carried = array_fill(0, count($frame["colors"]), 0);
}
$done++;
foreach ($frame["colors"] as $place => $color) {
for ($tall = 0; $tall < $color["high"]; $tall++) {
for ($wide = 0; $wide < $color["wide"]; $wide++) {
$block = self::readBlock($reader,
$trees[0][$picked[$place]["values"]],
$trees[1][$picked[$place]["runs"]],
$quantities[$color["quantity"]],
$carried[$place]);
if ($block === false) {
return false;
}
self::placeBlock($planes[$place],
$plane_widths[$place], $block,
($across * $color["wide"] + $wide) *
$side,
($down * $color["high"] + $tall) * $side);
}
}
}
}
}
return self::spreadOut($frame, $planes, $plane_widths, $widest,
$tallest);
}
/**
* Steps over a restart mark and starts reading bits afresh.
*
* @param array &$reader where the reading has got to
*/
public static function skipToRestart(&$reader)
{
$reader["bits"] = 0;
$reader["held"] = 0;
$bytes = $reader["bytes"];
while ($reader["at"] < strlen($bytes) - 1) {
if ($bytes[$reader["at"]] === "\xFF") {
$marker = ord($bytes[$reader["at"] + 1]);
if ($marker >= 0xD0 && $marker <= 0xD7) {
$reader["at"] += 2;
return;
}
}
$reader["at"]++;
}
}
/**
* Takes one bit from the picture's bits. A byte of all ones is
* followed by a nothing byte, which is stepped over.
*
* @param array &$reader where the reading has got to
* @return int the bit, or minus one at the end
*/
public static function nextBit(&$reader)
{
if ($reader["bits"] == 0) {
if ($reader["at"] >= strlen($reader["bytes"])) {
return -1;
}
$held = ord($reader["bytes"][$reader["at"]]);
$reader["at"]++;
if ($held == 0xFF) {
$next = ord($reader["bytes"][$reader["at"]] ?? "\x00");
if ($next == 0x00) {
$reader["at"]++;
} else {
return -1;
}
}
$reader["held"] = $held;
$reader["bits"] = 8;
}
$reader["bits"]--;
return ($reader["held"] >> $reader["bits"]) & 1;
}
/**
* Reads bits until they match one of the runs in a table, and hands
* back what that run stands for.
*
* @param array &$reader where the reading has got to
* @param array $tree the table of runs
* @return int what the run stands for, or minus one at the end
*/
public static function readCode(&$reader, $tree)
{
$code = 0;
for ($size = 1; $size <= self::LONGEST_RUN; $size++) {
$bit = self::nextBit($reader);
if ($bit < 0) {
return -1;
}
$code = ($code << 1) | $bit;
if (isset($tree[$size][$code])) {
return $tree[$size][$code];
}
}
return -1;
}
/**
* Reads a given number of bits as a number that may be negative, the
* way jpeg writes the difference between one value and the next.
*
* @param array &$reader where the reading has got to
* @param int $size how many bits
* @return int the number
*/
public static function readNumber(&$reader, $size)
{
if ($size == 0) {
return 0;
}
$value = 0;
for ($index = 0; $index < $size; $index++) {
$bit = self::nextBit($reader);
if ($bit < 0) {
return 0;
}
$value = ($value << 1) | $bit;
}
if ($value < (1 << ($size - 1))) {
$value -= (1 << $size) - 1;
}
return $value;
}
/**
* Reads one eight by eight block: its values, multiplied back up by
* what was thrown away, then turned from the waves they were written
* as back into the light and dark of the block.
*
* @param array &$reader where the reading has got to
* @param array $values_tree the table for the block's first value
* @param array $runs_tree the table for the rest
* @param array $quantity what was thrown away
* @param int &$carried the running first value for this color
* @return array|bool the block's sixty-four values, or false
*/
public static function readBlock(&$reader, $values_tree, $runs_tree,
$quantity, &$carried)
{
$side = self::BLOCK_SIDE;
$values = self::BLOCK_VALUES;
$block = array_fill(0, $values, 0);
$size = self::readCode($reader, $values_tree);
if ($size < 0) {
return false;
}
$carried += self::readNumber($reader, $size);
$block[0] = $carried * $quantity[0];
$index = 1;
while ($index < $values) {
$told = self::readCode($reader, $runs_tree);
if ($told < 0) {
return false;
}
$skip = $told >> 4;
$size = $told & 15;
if ($size == 0) {
if ($skip != 15) {
break;
}
$index += self::LONGEST_RUN;
continue;
}
$index += $skip;
if ($index > $values - 1) {
break;
}
$where = self::$zigzag[$index];
$block[$where] = self::readNumber($reader, $size) *
$quantity[$where];
$index++;
}
return self::fromWaves($block);
}
/**
* Turns a block written as waves back into light and dark, by adding
* up what each wave contributes at each of the sixty-four places.
*
* @param array $block the block's values as waves
* @return array the block's light and dark, nought to two hundred and
* fifty five
*/
public static function fromWaves($block)
{
$side = self::BLOCK_SIDE;
$values = self::BLOCK_VALUES;
$middle = self::MIDDLE_SHADE;
$most = self::MOST_SHADE;
static $waves = null;
if ($waves === null) {
$waves = [];
for ($place = 0; $place < $side; $place++) {
for ($which = 0; $which < $side; $which++) {
$waves[$place][$which] = (($which == 0) ?
sqrt(1 / $side) : sqrt(2 / $side)) *
cos((2 * $place + 1) * $which * M_PI /
(2 * $side));
}
}
}
/* A block whose only value is its first is one flat shade, which
is what most of a picture is made of. Working that out the long
way costs a thousand multiplications for an answer that is the
same at all sixty-four places. */
$flat = true;
for ($index = 1; $index < $values; $index++) {
if ($block[$index] != 0) {
$flat = false;
break;
}
}
if ($flat) {
$shade = max(0, min($most,
(int)round($block[0] / $side) + $middle));
return array_fill(0, $values, $shade);
}
$rows = [];
for ($down = 0; $down < $side; $down++) {
/* A row with nothing in it past its first value is flat too,
and the same saving applies along the row. */
$only_first = true;
for ($which = 1; $which < $side; $which++) {
if ($block[$down * $side + $which] != 0) {
$only_first = false;
break;
}
}
if ($only_first) {
$shade = $waves[0][0] * $block[$down * $side];
for ($across = 0; $across < $side; $across++) {
$rows[$down * $side + $across] = $shade;
}
continue;
}
for ($across = 0; $across < $side; $across++) {
$total = 0;
for ($which = 0; $which < $side; $which++) {
$total += $waves[$across][$which] *
$block[$down * $side + $which];
}
$rows[$down * $side + $across] = $total;
}
}
$out = [];
for ($across = 0; $across < $side; $across++) {
for ($down = 0; $down < $side; $down++) {
$total = 0;
for ($which = 0; $which < $side; $which++) {
$total += $waves[$down][$which] *
$rows[$which * $side + $across];
}
$value = (int)round($total) + $middle;
$out[$down * $side + $across] =
max(0, min($most, $value));
}
}
return $out;
}
/**
* Puts a read block into its place in a color's plane.
*
* @param array &$plane the color's plane
* @param int $plane_width how wide that plane is
* @param array $block the block's values
* @param int $across where the block begins across
* @param int $down where it begins down
*/
public static function placeBlock(&$plane, $plane_width, $block,
$across, $down)
{
$side = self::BLOCK_SIDE;
for ($row = 0; $row < $side; $row++) {
$at = ($down + $row) * $plane_width + $across;
for ($column = 0; $column < $side; $column++) {
$plane[$at + $column] = $block[$row * $side + $column];
}
}
}
/**
* Spreads each color's plane back out to the picture's own size. A
* color kept at half the detail of another covers twice as much
* ground per value, so its values are repeated to fill it.
*
* @param array $frame how large the picture is and its colors
* @param array $planes the planes as read
* @param array $plane_widths how wide each plane is
* @param int $widest the most detail any color has across
* @param int $tallest the most any color has down
* @return array the width, height and a plane per color
*/
public static function spreadOut($frame, $planes, $plane_widths,
$widest, $tallest)
{
$wide = $frame["width"];
$high = $frame["height"];
$spread = [];
foreach ($frame["colors"] as $place => $color) {
$across_step = $widest / $color["wide"];
$down_step = $tallest / $color["high"];
$out = array_fill(0, $wide * $high, 0);
for ($down = 0; $down < $high; $down++) {
$from_row = (int)($down / $down_step) *
$plane_widths[$place];
for ($across = 0; $across < $wide; $across++) {
$out[$down * $wide + $across] =
$planes[$place][$from_row +
(int)($across / $across_step)] ?? 0;
}
}
$spread[$place] = $out;
}
return ["width" => $wide, "height" => $high, "planes" => $spread];
}
}