diff --git a/README.md b/README.md index b97c5e300..085291fd1 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ It also features a QR Code reader based on a [PHP port](https://github.com/khana - String types: JSON, plain text, etc. - Encapsulated Postscript (EPS) - PDF via [FPDF](https://github.com/setasign/fpdf) + - Portable Bitmap ([PBM](https://en.wikipedia.org/wiki/Netpbm)) - QR Code reader (via GD and ImageMagick) diff --git a/docs/Usage/Overview.md b/docs/Usage/Overview.md index fbbc6467e..e80a81e67 100644 --- a/docs/Usage/Overview.md +++ b/docs/Usage/Overview.md @@ -23,6 +23,7 @@ It also features a QR Code reader based on a [PHP port](https://github.com/khana - String types: JSON, plain text, etc. - Encapsulated Postscript (EPS) - PDF via [FPDF](https://github.com/setasign/fpdf) + - Portable Bitmap ([PBM](https://en.wikipedia.org/wiki/Netpbm)) - QR Code reader (via GD and ImageMagick) diff --git a/src/Output/QRNetpbmAbstract.php b/src/Output/QRNetpbmAbstract.php new file mode 100644 index 000000000..af4074527 --- /dev/null +++ b/src/Output/QRNetpbmAbstract.php @@ -0,0 +1,63 @@ +options->netpbmPlain ? static::HEADER_ASCII : static::HEADER_BINARY; + } + + protected function getHeader():string{ + $comment = 'created by https://github.com/chillerlan/php-qrcode'; + + return sprintf( + "%s\n# %s\n%s %s\n%s", + $this->getMagicNumber(), + $comment, + $this->length, + $this->length, + $this->getMaxValueHeaderString(), + ); + } + + protected function getMaxValueHeaderString():string { + return $this->options->netpbmMaxValue."\n"; + } + + abstract protected function getBodyASCII():string; + abstract protected function getBodyBinary():string; + + public function dump(string|null $file = null):string{ + $qrString = $this->getHeader(); + + $qrString .= $this->options->netpbmPlain + ? $this->getBodyASCII() + : $this->getBodyBinary(); + + $this->saveToFile($qrString, $file); + + if($this->options->outputBase64){ + $qrString = $this->toBase64DataURI($qrString); + } + + return $qrString; + } + +} diff --git a/src/Output/QRNetpbmBitmap.php b/src/Output/QRNetpbmBitmap.php new file mode 100644 index 000000000..db133264b --- /dev/null +++ b/src/Output/QRNetpbmBitmap.php @@ -0,0 +1,95 @@ +matrix->getBooleanMatrix() as $row){ + $line = ''; + + foreach($row as $isDark){ + $line .= str_repeat($isDark ? '1' : '0', $this->scale); + } + // Lines should not be longer than 70 chars + $line = implode("\n", str_split($line,70))."\n"; + + $body .= str_repeat($line, $this->scale); + } + + return $body; + } + + protected function getBodyBinary():string{ + $body = ''; + + foreach($this->matrix->getBooleanMatrix() as $row){ + $rowdata = array_fill(0, (int)ceil($this->length / 8), 0); + $byte = 0; + $bit = 0b10000000; + + foreach($row as $isDark){ + for($i = 0; $i < $this->scale; $i++){ + if($bit <= 0){ + $bit = 0b10000000; + $byte++; + } + if($isDark){ + $rowdata[$byte] |= $bit; + } + $bit >>= 1; + } + } + + $rowdataString = pack('C*', ...$rowdata); + + $body .= str_repeat($rowdataString, $this->scale); + } + return $body; + } +} diff --git a/src/Output/QRNetpbmGraymap.php b/src/Output/QRNetpbmGraymap.php new file mode 100644 index 000000000..c9d67a8cc --- /dev/null +++ b/src/Output/QRNetpbmGraymap.php @@ -0,0 +1,83 @@ + $this->options->netpbmMaxValue ) { + return $this->options->netpbmMaxValue; + } + return $value; + } + + protected function getDefaultModuleValue(bool $isDark):mixed{ + return $isDark ? 0 : $this->options->netpbmMaxValue; + } + + public static function moduleValueIsValid(mixed $value):bool{ + return is_int($value) && $value >= 0 && $value < 65536; + } + + protected function getBodyASCII():string{ + $body = ''; + $maxLength = (70 - strlen(' '.(string)$this->options->netpbmMaxValue) + 1); + + foreach($this->matrix->getMatrix() as $row){ + $line = ''; + $rowString = ''; + foreach ($row as $module) { + for ($i = 0; $i < $this->scale; $i++) { + $line .= $this->getModuleValue($module); + if (strlen($line) >= $maxLength) { + $rowString .= $line."\n"; + $line = ''; + } else { + $line .= ' '; + } + } + } + $body .= str_repeat(trim($rowString.$line)."\n", $this->scale); + } + + return $body; + } + + protected function getBodyBinary():string{ + $format = $this->options->netpbmMaxValue > 255 ? 'n' : 'C'; + + $body = ''; + foreach ($this->matrix->getMatrix() as $row) { + $line = ''; + foreach($row as $module) { + $line .= str_repeat( + pack($format,$this->getModuleValue($module)), + $this->scale + ); + } + $body .= str_repeat($line, $this->scale); + } + return $body; + } +} diff --git a/src/Output/QRNetpbmPixmap.php b/src/Output/QRNetpbmPixmap.php new file mode 100644 index 000000000..af258cb2b --- /dev/null +++ b/src/Output/QRNetpbmPixmap.php @@ -0,0 +1,101 @@ + $this->options->netpbmMaxValue ) { + $rgbValue = $this->options->netpbmMaxValue; + } + $newValue []= $rgbValue; + } + return $newValue; + } + + protected function getDefaultModuleValue(bool $isDark):mixed{ + return array_fill(0, 3, $isDark ? 0 : $this->options->netpbmMaxValue); + } + + public static function moduleValueIsValid(mixed $value):bool{ + if ( !is_array($value) || count($value) !== 3 ) { + return false; + } + foreach ($value as $rgbVal) { + if (!is_int($rgbVal) || $rgbVal < 0 || $rgbVal >= 65536) { + return false; + } + } + return true; + } + + protected function getBodyASCII():string{ + $body = ''; + $maxLength = (70 - strlen(' '.(string)$this->options->netpbmMaxValue) + 1); + + foreach($this->matrix->getMatrix() as $row){ + $line = ''; + $rowString = ''; + foreach ($row as $module) { + for ($i = 0; $i < $this->scale; $i++) { + foreach($this->getModuleValue($module) as $rgbValue) { + $line .= $rgbValue; + if (strlen($line) >= $maxLength) { + $rowString .= $line."\n"; + $line = ''; + } else { + $line .= ' '; + } + } + } + } + $body .= str_repeat(trim($rowString.$line)."\n", $this->scale); + } + + return $body; + } + + protected function getBodyBinary():string{ + $format = $this->options->netpbmMaxValue > 255 ? 'n*' : 'C*'; + $body = ''; + foreach ($this->matrix->getMatrix() as $row) { + $line = ''; + foreach($row as $module) { + $m = $this->getModuleValue($module); + $f = pack($format, ...$m); + $line .= str_repeat( + $f, + $this->scale + ); + } + $body .= str_repeat($line, $this->scale); + } + return $body; + } +} diff --git a/src/QROptionsTrait.php b/src/QROptionsTrait.php index 6b7c2a73f..520ce2365 100644 --- a/src/QROptionsTrait.php +++ b/src/QROptionsTrait.php @@ -396,6 +396,27 @@ trait QROptionsTrait{ protected string $imagickFormat = 'png32'; + /* + * QRNetpbm settings + */ + + /** + * Use netpbm plain (ascii) output instead of binary output + * + * @see https://netpbm.sourceforge.net/doc/#commonoptions + * @see https://github.com/chillerlan/php-qrcode/pull/323 + */ + protected bool $netpbmPlain = false; + + /** + * Netpbm max value ( For graymap and pixmap output formats ) + * Should be between 0 and 65536. + * + * @see https://netpbm.sourceforge.net/doc/pgm.html + * @see https://netpbm.sourceforge.net/doc/ppm.html + */ + protected int $netpbmMaxValue = 255; + /* * Common markup output settings (QRMarkupSVG, QRMarkupHTML) */ @@ -616,4 +637,8 @@ protected function set_circleRadius(float $circleRadius):void{ $this->circleRadius = max(0.1, min(0.75, $circleRadius)); } + protected function set_netpbmMaxValue(int $netpbmMaxValue):void{ + $this->netpbmMaxValue = min(65535,max(1,$netpbmMaxValue)); + } + } diff --git a/tests/Output/QRNetpbmBitmapTest.php b/tests/Output/QRNetpbmBitmapTest.php new file mode 100644 index 000000000..4ea34acc4 --- /dev/null +++ b/tests/Output/QRNetpbmBitmapTest.php @@ -0,0 +1,66 @@ + + */ + public static function moduleValueProvider():array{ + return [ + 'invalid: wrong type: array' => [[], false], + 'invalid: wrong type: string' => ['abc', false], + 'valid: true' => [true, true], + 'valid: false' => [false, true], + ]; + } + + #[Test] + public function setModuleValues():void{ + $this->options->moduleValues = [ + // data + QRMatrix::M_DATA_DARK => true, + QRMatrix::M_DATA => false, + ]; + + $this->options->outputBase64 = false; + $this->options->netpbmPlain = true; + $this->outputInterface = $this->getOutputInterface($this->options, $this->matrix); + $data = $this->outputInterface->dump(); + + $this::assertStringContainsString('1', $data); + $this::assertStringContainsString('0', $data); + } + + #[Test] + public function renderToCacheFilePlain() { + $this->options->netpbmPlain = true; + $this->renderToCacheFile(); + } +} diff --git a/tests/Output/QRNetpbmGraymapTest.php b/tests/Output/QRNetpbmGraymapTest.php new file mode 100644 index 000000000..93c630b2d --- /dev/null +++ b/tests/Output/QRNetpbmGraymapTest.php @@ -0,0 +1,69 @@ + + */ + public static function moduleValueProvider():array{ + return [ + 'invalid: wrong type: array' => [[], false], + 'invalid: wrong type: string' => ['abc', false], + 'invalid: wrong type: bool' => [true, false], + 'invalid: out of bounds: negative' => [-1, false], + 'invalid: out of bounds: too big' => [70000, false], + 'valid: dark' => [0, true], + 'valid: light' => [65000, true], + ]; + } + + #[Test] + public function setModuleValues():void{ + $this->options->moduleValues = [ + // data + QRMatrix::M_DATA_DARK => 33, + QRMatrix::M_DATA => 99, + ]; + + $this->options->netpbmPlain = true; + $this->options->outputBase64 = false; + $this->outputInterface = $this->getOutputInterface($this->options, $this->matrix); + $data = $this->outputInterface->dump(); + + $this::assertStringContainsString('33', $data); + $this::assertStringContainsString('99', $data); + } + + #[Test] + public function renderToCacheFilePlain() { + $this->options->netpbmPlain = true; + $this->renderToCacheFile(); + } +} diff --git a/tests/Output/QRNetpbmPixmapTest.php b/tests/Output/QRNetpbmPixmapTest.php new file mode 100644 index 000000000..edb418933 --- /dev/null +++ b/tests/Output/QRNetpbmPixmapTest.php @@ -0,0 +1,77 @@ + + */ + public static function moduleValueProvider():array{ + return [ + 'invalid: wrong type: string' => ['abc', false], + 'invalid: wrong type: bool' => [true, false], + 'invalid: wrong type: integer' => [0, false], + 'invalid: wrong size: empty' => [[], false], + 'invalid: wrong size: 1' => [[1], false], + 'invalid: wrong size: too big' => [[1,2,3,4], false], + 'invalid: value out of bounds: negative' => [[-1,0,0], false], + 'invalid: value out of bounds: too big' => [[0,70000,0], false], + 'valid: dark' => [[0,0,0], true], + 'valid: colored' => [[0,150,3000], true], + 'valid: white' => [[255,255,255], true], + ]; + } + + #[Test] + public function setModuleValues():void{ + $this->options->moduleValues = [ + // data + QRMatrix::M_DATA_DARK => [11,12,13], + QRMatrix::M_DATA => [250,251,252], + ]; + + $this->options->netpbmPlain = true; + $this->options->outputBase64 = false; + $this->outputInterface = $this->getOutputInterface($this->options, $this->matrix); + $data = $this->outputInterface->dump(); + + $this::assertStringContainsString('11', $data); + $this::assertStringContainsString('12', $data); + $this::assertStringContainsString('13', $data); + $this::assertStringContainsString('250', $data); + $this::assertStringContainsString('251', $data); + $this::assertStringContainsString('252', $data); + } + + #[Test] + public function renderToCacheFilePlain() { + $this->options->netpbmPlain = true; + $this->renderToCacheFile(); + } +}