svgRoundQuietzone.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. <?php
  2. /**
  3. * @see https://github.com/chillerlan/php-qrcode/discussions/137
  4. *
  5. * @created 09.07.2022
  6. * @author smiley <smiley@chillerlan.net>
  7. * @copyright 2022 smiley
  8. * @license MIT
  9. *
  10. * @noinspection PhpIllegalPsrClassPathInspection
  11. */
  12. use chillerlan\QRCode\Common\EccLevel;
  13. use chillerlan\QRCode\Data\QRMatrix;
  14. use chillerlan\QRCode\Output\{QROutputInterface, QRMarkupSVG};
  15. use chillerlan\QRCode\{QRCode, QRCodeException, QROptions};
  16. require_once __DIR__.'/../vendor/autoload.php';
  17. /*
  18. * Class definition
  19. */
  20. /**
  21. * Create SVG QR Codes with embedded logos (that are also SVG),
  22. * randomly colored dots and a round quiet zone with added circle
  23. */
  24. class RoundQuietzoneSVGoutput extends QRMarkupSVG{
  25. /**
  26. * @inheritDoc
  27. */
  28. protected function createMarkup(bool $saveToFile):string{
  29. // some Pythagorean magick
  30. $diameter = sqrt(2 * pow(($this->moduleCount + $this->options->additionalModules), 2));
  31. // calculate the quiet zone size, add 1 to it as the outer circle stroke may go outside of it
  32. $quietzoneSize = ((int)ceil(($diameter - $this->moduleCount) / 2) + 1);
  33. // add the quiet zone to fill the circle
  34. $this->matrix->setQuietZone($quietzoneSize);
  35. // update the matrix dimensions to avoid errors in subsequent calculations
  36. // the moduleCount is now QR Code matrix + 2x quiet zone
  37. $this->setMatrixDimensions();
  38. // color the quiet zone
  39. $this->colorQuietzone($quietzoneSize, ($diameter / 2));
  40. // calculate the logo space
  41. $logoSpaceSize = (int)ceil($this->moduleCount * $this->options->svgLogoScale);
  42. // we're calling QRMatrix::setLogoSpace() manually, so QROptions::$addLogoSpace has no effect here
  43. $this->matrix->setLogoSpace($logoSpaceSize);
  44. // start SVG output
  45. $svg = $this->header();
  46. if(!empty($this->options->svgDefs)){
  47. $svg .= sprintf('<defs>%1$s%2$s</defs>%2$s', $this->options->svgDefs, $this->options->eol);
  48. }
  49. $svg .= $this->paths();
  50. $svg .= $this->getLogo();
  51. $svg .= $this->addCircle($diameter / 2);
  52. // close svg
  53. $svg .= sprintf('%1$s</svg>%1$s', $this->options->eol);
  54. // transform to data URI only when not saving to file
  55. if(!$saveToFile && $this->options->imageBase64){
  56. $svg = $this->toBase64DataURI($svg, 'image/svg+xml');
  57. }
  58. return $svg;
  59. }
  60. /**
  61. * @inheritDoc
  62. */
  63. protected function path(string $path, int $M_TYPE):string{
  64. // omit the "fill" and "opacity" attributes on the path element
  65. return sprintf('<path class="%s" d="%s"/>', $this->getCssClass($M_TYPE), $path);
  66. }
  67. /**
  68. * Sets random modules of the quiet zone to dark
  69. */
  70. protected function colorQuietzone(int $quietzoneSize, float $radius):void{
  71. $l1 = ($quietzoneSize - 1);
  72. $l2 = ($this->moduleCount - $quietzoneSize);
  73. // substract 1/2 stroke width and module radius from the circle radius to not cut off modules
  74. $r = ($radius - $this->options->circleRadius * 2);
  75. foreach($this->matrix->getMatrix() as $y => $row){
  76. foreach($row as $x => $value){
  77. // skip anything that's not quiet zone
  78. if($value !== QRMatrix::M_QUIETZONE){
  79. continue;
  80. }
  81. // leave one row of quiet zone around the matrix
  82. if(
  83. ($x === $l1 && $y >= $l1 && $y <= $l2)
  84. || ($x === $l2 && $y >= $l1 && $y <= $l2)
  85. || ($y === $l1 && $x >= $l1 && $x <= $l2)
  86. || ($y === $l2 && $x >= $l1 && $x <= $l2)
  87. ){
  88. continue;
  89. }
  90. // we need to add 0.5 units to the check values since we're calculating the element centers
  91. // ($x/$y is the element's assumed top left corner)
  92. if($this->checkIfInsideCircle(($x + 0.5), ($y + 0.5), $r)){
  93. $this->matrix->set($x, $y, (bool)rand(0, 1), QRMatrix::M_QUIETZONE);
  94. }
  95. }
  96. }
  97. }
  98. /**
  99. * @see https://stackoverflow.com/a/7227057
  100. */
  101. protected function checkIfInsideCircle(float $x, float $y, float $radius):bool{
  102. $dx = abs($x - $this->moduleCount / 2);
  103. $dy = abs($y - $this->moduleCount / 2);
  104. if(($dx + $dy) <= $radius){
  105. return true;
  106. }
  107. if($dx > $radius || $dy > $radius){
  108. return false;
  109. }
  110. if((pow($dx, 2) + pow($dy, 2)) <= pow($radius, 2)){
  111. return true;
  112. }
  113. return false;
  114. }
  115. /**
  116. * add a solid circle around the matrix
  117. */
  118. protected function addCircle(float $radius):string{
  119. return sprintf(
  120. '%4$s<circle id="circle" cx="%1$s" cy="%1$s" r="%2$s" stroke-width="%3$s"/>',
  121. ($this->moduleCount / 2),
  122. round($radius, 5),
  123. ($this->options->circleRadius * 2),
  124. $this->options->eol
  125. );
  126. }
  127. /**
  128. * returns a <g> element that contains the SVG logo and positions it properly within the QR Code
  129. *
  130. * @see https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g
  131. * @see https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform
  132. */
  133. protected function getLogo():string{
  134. // @todo: customize the <g> element to your liking (css class, style...)
  135. return sprintf(
  136. '%5$s<g transform="translate(%1$s %1$s) scale(%2$s)" class="%3$s">%5$s %4$s%5$s</g>',
  137. (($this->moduleCount - ($this->moduleCount * $this->options->svgLogoScale)) / 2),
  138. $this->options->svgLogoScale,
  139. $this->options->svgLogoCssClass,
  140. file_get_contents($this->options->svgLogo),
  141. $this->options->eol
  142. );
  143. }
  144. /**
  145. * @inheritDoc
  146. */
  147. protected function collectModules(Closure $transform):array{
  148. $paths = [];
  149. // collect the modules for each type
  150. foreach($this->matrix->getMatrix() as $y => $row){
  151. foreach($row as $x => $M_TYPE){
  152. $M_TYPE_LAYER = $M_TYPE;
  153. if(!$this->matrix->checkTypeIn($x, $y, $this->options->excludeFromConnect)){
  154. // to connect paths we'll redeclare the $M_TYPE_LAYER to data only
  155. $M_TYPE_LAYER = QRMatrix::M_DATA;
  156. if($this->matrix->check($x, $y)){
  157. $M_TYPE_LAYER |= QRMatrix::IS_DARK;
  158. }
  159. }
  160. // randomly assign another $M_TYPE_LAYER for the given types
  161. // note that the layer id has to be an integer value,
  162. // ideally outside the several bitmask values
  163. if($M_TYPE_LAYER === QRMatrix::M_DATA_DARK){
  164. $M_TYPE_LAYER = array_rand($this->options->dotColors);
  165. }
  166. // collect the modules per $M_TYPE
  167. $module = $transform($x, $y, $M_TYPE, $M_TYPE_LAYER);
  168. if(!empty($module)){
  169. $paths[$M_TYPE_LAYER][] = $module;
  170. }
  171. }
  172. }
  173. // beautify output
  174. ksort($paths);
  175. return $paths;
  176. }
  177. }
  178. /**
  179. * the augmented options class
  180. */
  181. class RoundQuietzoneOptions extends QROptions{
  182. /**
  183. * The amount of additional modules to be used in the circle diameter calculation
  184. *
  185. * Note that the middle of the circle stroke goes through the (assumed) outer corners
  186. * or centers of the QR Code (excluding quiet zone)
  187. *
  188. * Example:
  189. *
  190. * - a value of -1 would go through the center of the outer corner modules of the finder patterns
  191. * - a value of 0 would go through the corner of the outer modules of the finder patterns
  192. * - a value of 3 would go through the center of the module outside next to the finder patterns, in a 45-degree angle
  193. */
  194. protected int $additionalModules = 0;
  195. /**
  196. * a map of $M_TYPE_LAYER => color
  197. *
  198. * @see \array_rand()
  199. */
  200. protected array $dotColors = [];
  201. // path to svg logo
  202. protected string $svgLogo;
  203. // logo scale in % of QR Code size, clamped to 10%-30%
  204. protected float $svgLogoScale = 0.20;
  205. // css class for the logo (defined in $svgDefs)
  206. protected string $svgLogoCssClass = '';
  207. // check logo
  208. protected function set_svgLogo(string $svgLogo):void{
  209. if(!file_exists($svgLogo) || !is_readable($svgLogo)){
  210. throw new QRCodeException('invalid svg logo');
  211. }
  212. // @todo: validate svg
  213. $this->svgLogo = $svgLogo;
  214. }
  215. // clamp logo scale
  216. protected function set_svgLogoScale(float $svgLogoScale):void{
  217. $this->svgLogoScale = max(0.05, min(0.3, $svgLogoScale));
  218. }
  219. }
  220. /*
  221. * Runtime
  222. */
  223. $dotColors = [
  224. 111 => '#e2453c',
  225. 222 => '#e07e39',
  226. 333 => '#e5d667',
  227. 444 => '#51b95b',
  228. 555 => '#1e72b7',
  229. 666 => '#6f5ba7',
  230. ];
  231. $layerColors = '';
  232. foreach($dotColors as $layer => $color){
  233. $layerColors .= sprintf("\n\t\t.qr-%s{ fill: %s; }", $layer, $color);
  234. }
  235. // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/linearGradient
  236. // please forgive me for I have committed colorful crimes
  237. $svgDefs = '
  238. <linearGradient id="blurple" x1="100%" y2="100%">
  239. <stop stop-color="#D70071" offset="0"/>
  240. <stop stop-color="#9C4E97" offset="0.5"/>
  241. <stop stop-color="#0035A9" offset="1"/>
  242. </linearGradient>
  243. <linearGradient id="rainbow" x1="100%" y2="100%">
  244. <stop stop-color="#e2453c" offset="2.5%"/>
  245. <stop stop-color="#e07e39" offset="21.5%"/>
  246. <stop stop-color="#e5d667" offset="40.5%"/>
  247. <stop stop-color="#51b95b" offset="59.5%"/>
  248. <stop stop-color="#1e72b7" offset="78.5%"/>
  249. <stop stop-color="#6f5ba7" offset="97.5%"/>
  250. </linearGradient>
  251. <style><![CDATA[
  252. .light{ fill: #dedede; }
  253. .dark{ fill: url(#rainbow); }
  254. .logo{ fill: url(#blurple); }
  255. #circle{ fill: none; stroke: url(#blurple); }
  256. '.$layerColors.'
  257. ]]></style>';
  258. $options = new RoundQuietzoneOptions([
  259. // custom dot options
  260. 'additionalModules' => 5,
  261. 'dotColors' => $dotColors,
  262. // custom SVG logo options (see extended class below)
  263. 'svgLogo' => __DIR__.'/github.svg', // logo from: https://github.com/simple-icons/simple-icons
  264. 'svgLogoScale' => 0.2,
  265. 'svgLogoCssClass' => 'logo',
  266. // common QRCode options
  267. 'version' => 7,
  268. 'eccLevel' => EccLevel::H, // maximum error correction capacity, esp. for print
  269. 'addQuietzone' => false, // we're not adding a quiet zone, this is done internally in our own module
  270. 'imageBase64' => false, // avoid base64 URI output
  271. 'outputType' => QROutputInterface::CUSTOM,
  272. 'outputInterface' => RoundQuietzoneSVGoutput::class, // load our own output class
  273. 'drawLightModules' => false, // set to true to add the light modules
  274. // common SVG options
  275. 'svgDefs' => $svgDefs,
  276. // 'connectPaths' => true, // this has been set to "always on" internally
  277. 'excludeFromConnect' => [
  278. QRMatrix::M_FINDER_DARK,
  279. QRMatrix::M_FINDER_DOT,
  280. QRMatrix::M_ALIGNMENT_DARK,
  281. (QRMatrix::M_QUIETZONE | QRMatrix::IS_DARK),
  282. ],
  283. 'drawCircularModules' => true,
  284. 'circleRadius' => 0.4,
  285. 'keepAsSquare' => [
  286. QRMatrix::M_FINDER_DARK,
  287. QRMatrix::M_FINDER_DOT,
  288. QRMatrix::M_ALIGNMENT_DARK,
  289. ],
  290. ]);
  291. $qrcode = (new QRCode($options))->render('https://www.youtube.com/watch?v=dQw4w9WgXcQ');
  292. if(php_sapi_name() !== 'cli'){
  293. header('Content-type: image/svg+xml');
  294. if(extension_loaded('zlib')){
  295. header('Vary: Accept-Encoding');
  296. header('Content-Encoding: gzip');
  297. $qrcode = gzencode($qrcode, 9);
  298. }
  299. }
  300. echo $qrcode;
  301. exit;