From edd10ccc427c57a8ff685caa3b0736d5b215556c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Nov 2022 15:41:46 +0100 Subject: [PATCH 01/19] Extract colors using color quantization Ported from https://github.com/lokesh/color-thief --- src/main/php/de/thekid/dialog/color/Box.php | 146 ++++++++++++++++++ .../php/de/thekid/dialog/color/Colors.php | 99 ++++++++++++ .../php/de/thekid/dialog/color/Histogram.php | 40 +++++ .../de/thekid/dialog/color/PriorityQueue.php | 29 ++++ 4 files changed, 314 insertions(+) create mode 100755 src/main/php/de/thekid/dialog/color/Box.php create mode 100755 src/main/php/de/thekid/dialog/color/Colors.php create mode 100755 src/main/php/de/thekid/dialog/color/Histogram.php create mode 100755 src/main/php/de/thekid/dialog/color/PriorityQueue.php diff --git a/src/main/php/de/thekid/dialog/color/Box.php b/src/main/php/de/thekid/dialog/color/Box.php new file mode 100755 index 0000000..5dd8605 --- /dev/null +++ b/src/main/php/de/thekid/dialog/color/Box.php @@ -0,0 +1,146 @@ +colors() as $color) { + foreach ($color as $i => $component) { + if ($component < $min[$i]) $min[$i]= $component; + if ($component > $max[$i]) $max[$i]= $component; + } + } + return new self([$min[0], $max[0], $min[1], $max[1], $min[2], $max[2]], $histogram); + } + + /** Creates a copy of this box */ + public function copy(): self { + return new self($this->components, $this->histogram); + } + + /** Volume */ + public function volume(): int { + return ( + ($this->components[1] - $this->components[0] + 1) * + ($this->components[3] - $this->components[2] + 1) * + ($this->components[5] - $this->components[4] + 1) + ); + } + + /** Total color count in this box */ + public function count(): int { + [$rl, $ru, $gl, $gu, $bl, $bu]= $this->components; + + $n= 0; + if ($this->volume() > $this->histogram->size()) { + foreach ($this->histogram->colors() as $count => $c) { + if ($c[0] >= $rl && $c[0] <= $ru && $c[1] >= $gl && $c[1] <= $gu && $c[2] >= $bl && $c[2] <= $bu) { + $n+= $count; + } + } + } else { + for ($r= $rl; $r <= $ru; $r++) { + for ($g= $gl; $g <= $gu; $g++) { + for ($b= $bl; $b <= $bu; $b++) { + $n+= $this->histogram->frequency($r, $g, $b); + } + } + } + } + + return $n; + } + + /** Returns average color for this box */ + public function average(): array { + static $m= 1 << 3; + + [$rl, $ru, $gl, $gu, $bl, $bu]= $this->components; + $n= $rs= $gs= $bs= 0; + for ($r= $rl; $r <= $ru; $r++) { + for ($g= $gl; $g <= $gu; $g++) { + for ($b= $bl; $b <= $bu; $b++) { + $h= $this->histogram->frequency($r, $g, $b); + $n+= $h; + $rs+= ($h * ($r + 0.5) * $m); + $gs+= ($h * ($g + 0.5) * $m); + $bs+= ($h * ($b + 0.5) * $m); + } + } + } + + if ($n > 0) { + return [(int)($rs / $n), (int)($gs / $n), (int)($bs / $n)]; + } else { + return [ + min((int)($m * ($rl + $ru + 1) / 2), 255), + min((int)($m * ($gl + $gu + 1) / 2), 255), + min((int)($m * ($bl + $bu + 1) / 2), 255), + ]; + } + } + + /** Returns median boxes or NULL */ + public function median(): ?array { + if (1 === $this->count()) return [$this->copy()]; + + // Cut using longest axis + $r= $this->components[1] - $this->components[0]; + $g= $this->components[3] - $this->components[2]; + $b= $this->components[5] - $this->components[4]; + + // Rearrange colors + if ($r >= $g && $r >= $b) { + [$c1, $c2]= [0, 1]; + [$il, $iu, $jl, $ju, $kl, $ku]= $this->components; + $c= [&$i, &$j, &$k]; + } else if ($g >= $r && $g >= $b) { + [$c1, $c2]= [2, 3]; + [$jl, $ju, $il, $iu, $kl, $ku]= $this->components; + $c= [&$j, &$i, &$k]; + } else { + [$c1, $c2]= [4, 5]; + [$jl, $ju, $kl, $ku, $il, $iu]= $this->components; + $c= [&$j, &$k, &$i]; + } + + $total= 0; + $partial= []; + for ($i= $il; $i <= $iu; $i++) { + $sum= 0; + for ($j= $jl; $j <= $ju; $j++) { + for ($k= $kl; $k <= $ku; $k++) { + $sum+= $this->histogram->frequency(...$c); + } + } + $total+= $sum; + $partial[$i]= $total; + } + + for ($i= $il, $h= $total / 2; $i <= $iu; $i++) { + if ($partial[$i] <= $h) continue; + + $push= $this->copy(); + $add= $this->copy(); + + // Choose the cut plane + $l= $i - $il; + $r= $iu - $i; + $d= $l <= $r ? min($iu - 1, (int)($i + $r / 2)) : max($il, (int)($i - 1 - $l / 2)); + + while (empty($partial[$d])) $d++; + while ($partial[$d] >= $total && !empty($partial[$d - 1])) $d--; + + $push->components[$c2]= $d; + $add->components[$c1]= $d + 1; + return [$push, $add]; + } + + return null; + } +} diff --git a/src/main/php/de/thekid/dialog/color/Colors.php b/src/main/php/de/thekid/dialog/color/Colors.php new file mode 100755 index 0000000..2630197 --- /dev/null +++ b/src/main/php/de/thekid/dialog/color/Colors.php @@ -0,0 +1,99 @@ +size(); + $iteration= 0; + do { + if ($colors >= $target) return; + + $box= $queue->pop(); + if (0 === $box->count()) { + $queue->push($box); + continue; + } + + [$push, $add]= $box->median(); + $queue->push($push); + if ($add) { + $queue->push($add); + $colors++; + } + } while ($iteration++ < self::MAX_ITERATIONS); + } + + /** Color quantization */ + private function quantize(Histogram $histogram, int $colors): PriorityQueue { + $queue= new PriorityQueue(); + $queue->push(Box::from($histogram)); + + // First set of colors, sorted by population + $this->iterate( + $queue->comparing(fn($a, $b) => $a->count() <=> $b->count()), + self::FRACT_BY_POPULATIONS * $colors + ); + + // Next set - generate the median cuts using the (npix * vol) sorting. + $this->iterate( + $queue->comparing(fn($a, $b) => ($a->count() * $a->volume()) <=> ($b->count() * $b->volume())), + $colors + ); + + return $queue; + } + + /** + * Returns palette for a given image + * + * @throws lang.IllegalArgumentException if no palette can be computed + */ + public function palette(Image $source, int $size= 10): array { + + // Area to examine = complete image + $x= 0; + $y= 0; + $w= $source->getWidth(); + $h= $source->getHeight(); + + // Create histogram + $histogram= new Histogram(); + for ($i= 0, $n= $w * $h; $i < $n; $i+= $this->quality) { + $color= $source->colorAt($x + ($i % $w), (int)($y + $i / $w)); + $histogram->add($color->red, $color->green, $color->blue); + } + + // Check border case, e.g. for empty images + if ($histogram->empty()) { + throw new IllegalArgumentException('Cannot compute color palette'); + } + + // Quantize, then yield colors + $queue= $this->quantize($histogram, $size); + $colors= []; + while ($box= $queue->pop()) { + $colors[]= new Color(...$box->average()); + } + return $colors; + } +} \ No newline at end of file diff --git a/src/main/php/de/thekid/dialog/color/Histogram.php b/src/main/php/de/thekid/dialog/color/Histogram.php new file mode 100755 index 0000000..5e77c75 --- /dev/null +++ b/src/main/php/de/thekid/dialog/color/Histogram.php @@ -0,0 +1,40 @@ + self::THRESHOLD && $g > self::THRESHOLD && $b > self::THRESHOLD) return; + + $i= (($r >> 3) << 10) | (($g >> 3) << 5) | ($b >> 3); + $this->colors[$i]= ($this->colors[$i] ?? 0) + 1; + } + + /** Returns whether this histogram is empty */ + public function empty(): bool { + return empty($this->colors); + } + + /** Returns this histogram's size */ + public function size(): int { + return sizeof($this->colors); + } + + /** Returns the frequency of a given color */ + public function frequency(int $r, int $g, int $b): int { + return $this->colors[($r << 10) | ($g << 5) | $b] ?? 0; + } + + public function colors() { + foreach ($this->colors as $i => $count) { + yield $count => [($i >> 10) & 31, ($i >> 5) & 31, $i & 31]; + } + } +} diff --git a/src/main/php/de/thekid/dialog/color/PriorityQueue.php b/src/main/php/de/thekid/dialog/color/PriorityQueue.php new file mode 100755 index 0000000..fb43dbc --- /dev/null +++ b/src/main/php/de/thekid/dialog/color/PriorityQueue.php @@ -0,0 +1,29 @@ +comparator= $comparator; + return $this; + } + + public function size(): int { + return sizeof($this->elements); + } + + public function push($element): void { + $this->elements[]= $element; + $this->sorted= false; + } + + public function pop() { + if (!$this->sorted && $this->comparator) { + usort($this->elements, $this->comparator); + $this->sorted= true; + } + return array_pop($this->elements); + } +} From 2b3609ac022e651bdf018ce0b98100ce33428b70 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Nov 2022 16:06:56 +0100 Subject: [PATCH 02/19] Add tests for PriorityQueue --- .../de/thekid/dialog/color/PriorityQueue.php | 8 +- .../dialog/unittest/PriorityQueueTest.php | 75 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) create mode 100755 src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php diff --git a/src/main/php/de/thekid/dialog/color/PriorityQueue.php b/src/main/php/de/thekid/dialog/color/PriorityQueue.php index fb43dbc..a683a76 100755 --- a/src/main/php/de/thekid/dialog/color/PriorityQueue.php +++ b/src/main/php/de/thekid/dialog/color/PriorityQueue.php @@ -1,5 +1,6 @@ elements); } + /** Pushes an element */ public function push($element): void { $this->elements[]= $element; $this->sorted= false; } + /** Pops an element */ public function pop() { - if (!$this->sorted && $this->comparator) { - usort($this->elements, $this->comparator); + if (!$this->sorted) { + $this->comparator ? usort($this->elements, $this->comparator) : sort($this->elements); $this->sorted= true; } return array_pop($this->elements); diff --git a/src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php b/src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php new file mode 100755 index 0000000..9cca7cb --- /dev/null +++ b/src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php @@ -0,0 +1,75 @@ +size()); + } + + #[Test] + public function size_after_pushing() { + $queue= new PriorityQueue(); + $queue->push('Test'); + + Assert::equals(1, $queue->size()); + } + + #[Test] + public function pop_on_empty_queue() { + Assert::null(new PriorityQueue()->pop()); + } + + #[Test] + public function push_and_pop_roundtrip() { + $queue= new PriorityQueue(); + $queue->push('Test'); + + Assert::equals('Test', $queue->pop()); + } + + #[Test] + public function pop_after_end() { + $queue= new PriorityQueue(); + $queue->push('Test'); + $queue->pop(); + + Assert::null($queue->pop()); + } + + #[Test] + public function pop_returns_elements_according_to_their_sort_order() { + $queue= new PriorityQueue(); + $queue->push('B'); + $queue->push('A'); + $queue->push('C'); + + $elements= []; + while (null !== $element= $queue->pop()) { + $elements[]= $element; + } + Assert::equals(['C', 'B', 'A'], $elements); + } + + #[Test] + public function using_comparator() { + $queue= new PriorityQueue()->comparing(fn($a, $b) => $b <=> $a); + $queue->push('B'); + $queue->push('A'); + $queue->push('C'); + + $elements= []; + while (null !== $element= $queue->pop()) { + $elements[]= $element; + } + Assert::equals(['A', 'B', 'C'], $elements); + } +} \ No newline at end of file From e94afd4487e9cc88014ff6f4f82e1eaec8ed5585 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Nov 2022 17:11:19 +0100 Subject: [PATCH 03/19] Fix "Undefined array key 1" --- src/main/php/de/thekid/dialog/color/Box.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/de/thekid/dialog/color/Box.php b/src/main/php/de/thekid/dialog/color/Box.php index 5dd8605..41aaea6 100755 --- a/src/main/php/de/thekid/dialog/color/Box.php +++ b/src/main/php/de/thekid/dialog/color/Box.php @@ -87,7 +87,7 @@ public function average(): array { /** Returns median boxes or NULL */ public function median(): ?array { - if (1 === $this->count()) return [$this->copy()]; + if (1 === $this->count()) return [$this->copy(), null]; // Cut using longest axis $r= $this->components[1] - $this->components[0]; From 53cf4774feb07322f2ffcbaec280ab85d89bbcd7 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Nov 2022 17:34:44 +0100 Subject: [PATCH 04/19] Make quality public --- src/main/php/de/thekid/dialog/color/Colors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/de/thekid/dialog/color/Colors.php b/src/main/php/de/thekid/dialog/color/Colors.php index 2630197..a88f139 100755 --- a/src/main/php/de/thekid/dialog/color/Colors.php +++ b/src/main/php/de/thekid/dialog/color/Colors.php @@ -19,7 +19,7 @@ class Colors { const FRACT_BY_POPULATIONS= 0.75; /** Creates a new instance with a given quality */ - public function __construct(private $quality= 10) { } + public function __construct(public int $quality= 10) { } /** Helper for quantize() */ private function iterate(PriorityQueue $queue, float $target): void { From 67a172cd3a573df9e08e0bdfe87f119712f1ad20 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 4 Nov 2022 18:01:32 +0100 Subject: [PATCH 05/19] Add histogram() and color() --- .../php/de/thekid/dialog/color/Colors.php | 70 ++++++++++++------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/src/main/php/de/thekid/dialog/color/Colors.php b/src/main/php/de/thekid/dialog/color/Colors.php index a88f139..8f86674 100755 --- a/src/main/php/de/thekid/dialog/color/Colors.php +++ b/src/main/php/de/thekid/dialog/color/Colors.php @@ -15,6 +15,7 @@ * @see https://github.com/olivierlesnicki/quantize */ class Colors { + const DOMINANT_PALETTE= 5; const MAX_ITERATIONS= 1000; const FRACT_BY_POPULATIONS= 0.75; @@ -43,8 +44,18 @@ private function iterate(PriorityQueue $queue, float $target): void { } while ($iteration++ < self::MAX_ITERATIONS); } - /** Color quantization */ - private function quantize(Histogram $histogram, int $colors): PriorityQueue { + /** + * Color quantization from a given histogram + * + * @throws lang.IllegalArgumentException if no palette can be computed + */ + private function quantize(Histogram $histogram, int $colors): array { + + // Check border case, e.g. for empty images + if ($histogram->empty()) { + throw new IllegalArgumentException('Cannot quantize using an empty histogram'); + } + $queue= new PriorityQueue(); $queue->push(Box::from($histogram)); @@ -60,40 +71,49 @@ private function quantize(Histogram $histogram, int $colors): PriorityQueue { $colors ); - return $queue; + $colors= []; + while ($box= $queue->pop()) { + $colors[]= new Color(...$box->average()); + } + return $colors; } /** - * Returns palette for a given image - * - * @throws lang.IllegalArgumentException if no palette can be computed + * Returns histogram for a given image. Uses complete image by default + * but may be given a 4-element array as follows: `[x, y, w, h]`. + * + * The computed histogram for an image can be recycled to derive palette + * and dominant color. */ - public function palette(Image $source, int $size= 10): array { - - // Area to examine = complete image - $x= 0; - $y= 0; - $w= $source->getWidth(); - $h= $source->getHeight(); + public function histogram(Image $source, ?array $area= null): Histogram { + [$x, $y, $w, $h]= $area ?? [0, 0, ...$source->getDimensions()]; - // Create histogram $histogram= new Histogram(); for ($i= 0, $n= $w * $h; $i < $n; $i+= $this->quality) { $color= $source->colorAt($x + ($i % $w), (int)($y + $i / $w)); $histogram->add($color->red, $color->green, $color->blue); } + return $histogram; + } - // Check border case, e.g. for empty images - if ($histogram->empty()) { - throw new IllegalArgumentException('Cannot compute color palette'); - } + /** + * Returns the dominant color in an image or histogram + * + * @throws lang.IllegalArgumentException if palette is empty + */ + public function color(Image|Histogram $source): ?Color { + return current($this->quantize( + $source instanceof Histogram ? $source : $this->histogram($source), + self::DOMINANT_PALETTE + )); + } - // Quantize, then yield colors - $queue= $this->quantize($histogram, $size); - $colors= []; - while ($box= $queue->pop()) { - $colors[]= new Color(...$box->average()); - } - return $colors; + /** + * Returns a palette with a given size for an image or histogram + * + * @throws lang.IllegalArgumentException if palette is empty + */ + public function palette(Image|Histogram $source, int $size= 10): array { + return $this->quantize($source instanceof Histogram ? $source : $this->histogram($source), $size); } } \ No newline at end of file From 5945de853220eac9dd20b0bf5e2aaed0a0e8230d Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Nov 2022 21:04:31 +0100 Subject: [PATCH 06/19] Use variables for scroll link color in light theme Fixes #37 --- ChangeLog.md | 4 ++++ src/main/handlebars/layout.handlebars | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index e514b0b..c479874 100755 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -3,6 +3,10 @@ Dialog change log ## ?.?.? / ????-??-?? +## 1.8.4 / 2022-11-05 + +* Fixed issue #37: Scroll links in light theme not readable - @thekid + ## 1.8.3 / 2022-11-03 * Fixed errors in the browser console concerning REST API response diff --git a/src/main/handlebars/layout.handlebars b/src/main/handlebars/layout.handlebars index 6c03a52..88787e8 100755 --- a/src/main/handlebars/layout.handlebars +++ b/src/main/handlebars/layout.handlebars @@ -56,6 +56,7 @@ --meta-color: #ccc; --border-color: #ccc; --button-color: #444; + --segment-color: rgb(255 255 255 / .2); background-color: var(--page-color); color: var(--text-color); @@ -70,6 +71,7 @@ --meta-color: #666; --border-color: #ddd; --button-color: #666; + --segment-color: #efefef; } h1 { @@ -417,7 +419,7 @@ ul.summary { list-style: none; line-height: 2rem; - background-color: rgb(255 255 255 / .2); + background-color: var(--segment-color); border-radius: .25rem; padding: .25rem 0.75rem; margin-top: 2rem; @@ -429,7 +431,7 @@ } .summary a { - color: white; + color: var(--text-color); padding-left: .5ch; text-decoration: none; line-height: rem; @@ -441,13 +443,13 @@ .summary .date { float: right; - color: #ccc; + color: var(--meta-color); font-size: .9rem; } a.scroll { text-decoration: none; - color: white; + color: var(--text-color); } a.scroll::after { From 8260bf5d9e1ed53d0c6f99644271ca4265944afa Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Mon, 7 Nov 2022 00:07:33 +0100 Subject: [PATCH 07/19] Use generics --- composer.json | 5 +- .../de/thekid/dialog/color/PriorityQueue.php | 8 ++-- .../dialog/unittest/PriorityQueueTest.php | 47 ++++++++++++------- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/composer.json b/composer.json index 5590511..29ffb77 100755 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "license": "bsd", "require": { - "xp-framework/compiler": "^8.5", + "xp-framework/compiler": "^8.6", "xp-framework/imaging": "^10.1", "xp-framework/command": "^11.0", "xp-framework/networking": "^10.4", @@ -17,7 +17,8 @@ "xp-forge/markdown": "^6.0", "xp-forge/yaml": "^6.0", "xp-forge/hashing": "^2.1", - "xp-forge/mongodb": "^1.6" + "xp-forge/mongodb": "^1.6", + "xp-lang/xp-generics": "^0.4" }, "require-dev": { diff --git a/src/main/php/de/thekid/dialog/color/PriorityQueue.php b/src/main/php/de/thekid/dialog/color/PriorityQueue.php index a683a76..b51d03f 100755 --- a/src/main/php/de/thekid/dialog/color/PriorityQueue.php +++ b/src/main/php/de/thekid/dialog/color/PriorityQueue.php @@ -1,12 +1,12 @@ { private $elements= []; private $sorted= true; private $comparator= null; - public function comparing(function(mixed, mixed): int $comparator): self { + public function comparing(?function(E, E): int $comparator): self { $this->comparator= $comparator; return $this; } @@ -17,13 +17,13 @@ public function size(): int { } /** Pushes an element */ - public function push($element): void { + public function push(E $element): void { $this->elements[]= $element; $this->sorted= false; } /** Pops an element */ - public function pop() { + public function pop(): ?E { if (!$this->sorted) { $this->comparator ? usort($this->elements, $this->comparator) : sort($this->elements); $this->sorted= true; diff --git a/src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php b/src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php index 9cca7cb..4ab05e7 100755 --- a/src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php +++ b/src/test/php/de/thekid/dialog/unittest/PriorityQueueTest.php @@ -5,19 +5,28 @@ class PriorityQueueTest { + /** Returns all elements in a given queue */ + private function all(PriorityQueue $queue): array { + $elements= []; + while (null !== $element= $queue->pop()) { + $elements[]= $element; + } + return $elements; + } + #[Test] public function can_create() { - new PriorityQueue(); + new PriorityQueue(); } #[Test] public function initially_empty() { - Assert::equals(0, new PriorityQueue()->size()); + Assert::equals(0, new PriorityQueue()->size()); } #[Test] public function size_after_pushing() { - $queue= new PriorityQueue(); + $queue= new PriorityQueue(); $queue->push('Test'); Assert::equals(1, $queue->size()); @@ -25,12 +34,12 @@ public function size_after_pushing() { #[Test] public function pop_on_empty_queue() { - Assert::null(new PriorityQueue()->pop()); + Assert::null(new PriorityQueue()->pop()); } #[Test] public function push_and_pop_roundtrip() { - $queue= new PriorityQueue(); + $queue= new PriorityQueue(); $queue->push('Test'); Assert::equals('Test', $queue->pop()); @@ -38,7 +47,7 @@ public function push_and_pop_roundtrip() { #[Test] public function pop_after_end() { - $queue= new PriorityQueue(); + $queue= new PriorityQueue(); $queue->push('Test'); $queue->pop(); @@ -47,29 +56,31 @@ public function pop_after_end() { #[Test] public function pop_returns_elements_according_to_their_sort_order() { - $queue= new PriorityQueue(); + $queue= new PriorityQueue(); $queue->push('B'); $queue->push('A'); $queue->push('C'); - $elements= []; - while (null !== $element= $queue->pop()) { - $elements[]= $element; - } - Assert::equals(['C', 'B', 'A'], $elements); + Assert::equals(['C', 'B', 'A'], $this->all($queue)); } #[Test] public function using_comparator() { - $queue= new PriorityQueue()->comparing(fn($a, $b) => $b <=> $a); + $queue= new PriorityQueue()->comparing(fn(string $a, string $b): int => $b <=> $a); $queue->push('B'); $queue->push('A'); $queue->push('C'); - $elements= []; - while (null !== $element= $queue->pop()) { - $elements[]= $element; - } - Assert::equals(['A', 'B', 'C'], $elements); + Assert::equals(['A', 'B', 'C'], $this->all($queue)); + } + + #[Test] + public function using_default_comparator() { + $queue= new PriorityQueue()->comparing(null); + $queue->push('B'); + $queue->push('A'); + $queue->push('C'); + + Assert::equals(['C', 'B', 'A'], $this->all($queue)); } } \ No newline at end of file From 5c2bc15966fb8334b8a0fb017149e6ca3281f817 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Mon, 7 Nov 2022 00:50:14 +0100 Subject: [PATCH 08/19] Use PriorityQueue --- src/main/php/de/thekid/dialog/color/Colors.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/php/de/thekid/dialog/color/Colors.php b/src/main/php/de/thekid/dialog/color/Colors.php index 8f86674..78b28d8 100755 --- a/src/main/php/de/thekid/dialog/color/Colors.php +++ b/src/main/php/de/thekid/dialog/color/Colors.php @@ -23,7 +23,7 @@ class Colors { public function __construct(public int $quality= 10) { } /** Helper for quantize() */ - private function iterate(PriorityQueue $queue, float $target): void { + private function iterate(PriorityQueuey $queue, float $target): void { $colors= $queue->size(); $iteration= 0; do { @@ -56,7 +56,7 @@ private function quantize(Histogram $histogram, int $colors): array { throw new IllegalArgumentException('Cannot quantize using an empty histogram'); } - $queue= new PriorityQueue(); + $queue= new PriorityQueue(); $queue->push(Box::from($histogram)); // First set of colors, sorted by population From ab842bf9d8a97fc74477d41c6b4b5fa9c0986ab8 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 8 Nov 2022 19:25:04 +0100 Subject: [PATCH 09/19] QA: Adjust example to ES6 class refactoring --- src/main/php/de/thekid/dialog/Scripts.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/de/thekid/dialog/Scripts.php b/src/main/php/de/thekid/dialog/Scripts.php index e07fc8f..4fe04f0 100755 --- a/src/main/php/de/thekid/dialog/Scripts.php +++ b/src/main/php/de/thekid/dialog/Scripts.php @@ -12,7 +12,7 @@ * ```html * * ``` */ From c8b0574bcaca71c2856177a1c1442e879e01efd0 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 8 Nov 2022 19:34:09 +0100 Subject: [PATCH 10/19] Rename color() -> dominant() --- src/main/php/de/thekid/dialog/color/Colors.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/de/thekid/dialog/color/Colors.php b/src/main/php/de/thekid/dialog/color/Colors.php index 78b28d8..ff87d32 100755 --- a/src/main/php/de/thekid/dialog/color/Colors.php +++ b/src/main/php/de/thekid/dialog/color/Colors.php @@ -101,7 +101,7 @@ public function histogram(Image $source, ?array $area= null): Histogram { * * @throws lang.IllegalArgumentException if palette is empty */ - public function color(Image|Histogram $source): ?Color { + public function dominant(Image|Histogram $source): ?Color { return current($this->quantize( $source instanceof Histogram ? $source : $this->histogram($source), self::DOMINANT_PALETTE From 0b03dd5c7c5569ee857a086cde4aa311701938e2 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 8 Nov 2022 20:18:32 +0100 Subject: [PATCH 11/19] Rename Colors::DOMINANT_PALETTE -> Colors::DOMINANT --- src/main/php/de/thekid/dialog/color/Colors.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/php/de/thekid/dialog/color/Colors.php b/src/main/php/de/thekid/dialog/color/Colors.php index ff87d32..105aa32 100755 --- a/src/main/php/de/thekid/dialog/color/Colors.php +++ b/src/main/php/de/thekid/dialog/color/Colors.php @@ -15,7 +15,7 @@ * @see https://github.com/olivierlesnicki/quantize */ class Colors { - const DOMINANT_PALETTE= 5; + const DOMINANT= 5; const MAX_ITERATIONS= 1000; const FRACT_BY_POPULATIONS= 0.75; @@ -104,7 +104,7 @@ public function histogram(Image $source, ?array $area= null): Histogram { public function dominant(Image|Histogram $source): ?Color { return current($this->quantize( $source instanceof Histogram ? $source : $this->histogram($source), - self::DOMINANT_PALETTE + self::DOMINANT )); } From 937f0c4888d923dc78db1d1a61b3a637991b9b7c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 8 Nov 2022 20:20:15 +0100 Subject: [PATCH 12/19] Pass meta information as JSON (in "meta-inf") or array (in "meta", for BC) --- src/main/php/de/thekid/dialog/api/Entries.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/php/de/thekid/dialog/api/Entries.php b/src/main/php/de/thekid/dialog/api/Entries.php index 5e8a00d..fe969fa 100755 --- a/src/main/php/de/thekid/dialog/api/Entries.php +++ b/src/main/php/de/thekid/dialog/api/Entries.php @@ -2,6 +2,7 @@ use de\thekid\dialog\Repository; use io\{Path, Folder, File}; +use text\json\Json; use util\Date; use web\rest\{Async, Delete, Entity, Put, Resource, Request, Response, Value}; @@ -62,6 +63,7 @@ public function upload(#[Value] $user, string $id, string $name, #[Request] $req foreach ($multipart->files() as $file) { yield from $file->transmit(new File($f, $file->name())); } + $meta= $req->param('meta') ?? Json::read($req->param('meta-inf', '{}')); // Fetch entry again, it might have changed in the meantime! $images= $this->repository->entry($id, published: false)['images'] ?? []; @@ -71,7 +73,7 @@ public function upload(#[Value] $user, string $id, string $name, #[Request] $req $image= [ 'name' => $name, 'modified' => time(), - 'meta' => (array)$req->param('meta') + ['dateTime' => gmdate('c')], + 'meta' => (array)$meta + ['dateTime' => gmdate('c')], 'is' => [$is => true] ]; foreach ($images ?? [] as $i => $existing) { From be0baa57667646025c96a87b54133487e58ef008 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Tue, 8 Nov 2022 20:20:39 +0100 Subject: [PATCH 13/19] Extract color palette and pass as meta information --- .../thekid/dialog/import/LocalDirectory.php | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/php/de/thekid/dialog/import/LocalDirectory.php b/src/main/php/de/thekid/dialog/import/LocalDirectory.php index 84124c5..2723ad7 100755 --- a/src/main/php/de/thekid/dialog/import/LocalDirectory.php +++ b/src/main/php/de/thekid/dialog/import/LocalDirectory.php @@ -1,11 +1,13 @@ api, $resource->request('PUT')->waiting(read: 3600)); - foreach ($meta($source) as $key => $value) { - $upload->pass('meta['.$key.']', $value); + // Extract meta information, aggregating palette from preview image + $info= [...$meta($source)]; + try { + $palette= $this->colors->palette( + Image::loadFrom(new JpegStreamReader($transfer['preview'])), + Colors::DOMINANT + ); + $info['palette']= array_map(fn($c) => (int)hexdec($c->toHex()), $palette); + } catch ($e) { + // Ignore palette } + + $upload= new RestUpload($this->api, $resource->request('PUT')->waiting(read: 3600)); + $upload->pass('meta-inf', Json::of($info)); foreach ($transfer as $kind => $file) { $upload->transfer($kind, $file->in(), $file->filename); } From d1fc7fbc3630df0da5e7147d8cebb3557fc26505 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Wed, 9 Nov 2022 01:17:37 +0100 Subject: [PATCH 14/19] Store colors as HSL --- .../thekid/dialog/import/LocalDirectory.php | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/main/php/de/thekid/dialog/import/LocalDirectory.php b/src/main/php/de/thekid/dialog/import/LocalDirectory.php index 2723ad7..4517562 100755 --- a/src/main/php/de/thekid/dialog/import/LocalDirectory.php +++ b/src/main/php/de/thekid/dialog/import/LocalDirectory.php @@ -138,6 +138,31 @@ private function execute(string $command, array $args, $redirect= null): throw new IllegalStateException($p->getCommandLine().' exited with exit code '.$r); } + private function hsl($color) { + $r= $color->red / 255; + $g= $color->green / 255; + $b= $color->blue / 255; + + $max= max($r, $g, $b); + $min= min($r, $g, $b); + + $lum = ($max + $min) / 2; + + if ($max === $min) { + $hue= $sat= 0; + } else { + $c= $max - $min; + $sat= $c / (1 - abs(2 * $lum - 1)); + $hue= match ($max) { + $r => ($g - $b) / $c + ($g < $b ? 6 : 0), + $g => ($b - $r) / $c + 2, + $b => ($r - $g) / $c + 4, + }; + } + + return ['h' => round($hue * 60), 's' => round($sat * 100), 'l' => round($lum * 100)]; + } + /** Runs this command */ public function run(): int { $publish= time(); @@ -207,13 +232,15 @@ public function run(): int { } // Extract meta information, aggregating palette from preview image - $info= [...$meta($source)]; + $info= [...$meta($source)] + ['palette' => []]; try { $palette= $this->colors->palette( Image::loadFrom(new JpegStreamReader($transfer['preview'])), Colors::DOMINANT ); - $info['palette']= array_map(fn($c) => (int)hexdec($c->toHex()), $palette); + foreach ($palette as $color) { + $info['palette'][]= $this->hsl($color); + } } catch ($e) { // Ignore palette } From 55140d9d13c881216151880fabc30bd3844efb40 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 13 Nov 2022 10:29:28 +0100 Subject: [PATCH 15/19] First prototypical search by color --- src/main/handlebars/layout.handlebars | 25 +++++++++++ .../handlebars/partials/images.handlebars | 8 +++- src/main/php/de/thekid/dialog/Helpers.php | 9 ++++ src/main/php/de/thekid/dialog/Repository.php | 2 +- src/main/php/de/thekid/dialog/web/Search.php | 43 +++++++++++++++++++ 5 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/main/handlebars/layout.handlebars b/src/main/handlebars/layout.handlebars index ac498fe..6ec60c1 100755 --- a/src/main/handlebars/layout.handlebars +++ b/src/main/handlebars/layout.handlebars @@ -501,6 +501,10 @@ aspect-ratio: auto; } + .image { + position: relative; + } + .image img, .image video { aspect-ratio: 15 / 10; object-fit: cover; @@ -508,6 +512,27 @@ box-shadow: .25rem .25rem 1rem rgb(0 0 0 / .2); } + .image .palette { + position: absolute; + bottom: 1rem; + left: 1rem; + display: flex; + gap: .5rem; + transform: scale(0, 0); + transition: transform ease-in-out 70ms; + } + + .image:hover .palette { + transform: scale(1, 1); + } + + .image .palette > * { + width: 2rem; + height: 2rem; + border-radius: 50%; + box-shadow: .25rem .25rem 1rem rgb(0 0 0 / .2); + } + #map { margin-top: 1rem; width: 100%; diff --git a/src/main/handlebars/partials/images.handlebars b/src/main/handlebars/partials/images.handlebars index c385477..3ce877e 100755 --- a/src/main/handlebars/partials/images.handlebars +++ b/src/main/handlebars/partials/images.handlebars @@ -7,9 +7,15 @@ {{else}} - {{title}}, {{date meta.dateTime format='d.m.Y H:i'}} + {{title}}, {{date meta.dateTime format='d.m.Y H:i'}} {{/if}} + +
+ {{#each meta.palette}} + + {{/each}} +
{{/each}} diff --git a/src/main/php/de/thekid/dialog/Helpers.php b/src/main/php/de/thekid/dialog/Helpers.php index 4cd836b..bb2ecb4 100755 --- a/src/main/php/de/thekid/dialog/Helpers.php +++ b/src/main/php/de/thekid/dialog/Helpers.php @@ -29,5 +29,14 @@ public function helpers() { return 'content/'.$entry['slug']; } }; + yield 'color' => function($node, $context, $options) { + $input= $options[0]; + return sprintf( + '#%02x%02x%02x', + ($input >> 16) & 0xff, + ($input >> 8) & 0xff, + $input & 0xff, + ); + }; } } \ No newline at end of file diff --git a/src/main/php/de/thekid/dialog/Repository.php b/src/main/php/de/thekid/dialog/Repository.php index 271b26a..db54147 100755 --- a/src/main/php/de/thekid/dialog/Repository.php +++ b/src/main/php/de/thekid/dialog/Repository.php @@ -8,7 +8,7 @@ class Repository { private $passwords= Hashing::sha256(); - public function __construct(private Database $database) { } + public function __construct(public readonly Database $database) { } /** Authenticates a given user, returning NULL on failure */ public function authenticate(string $user, Secret $secret): ?Document { diff --git a/src/main/php/de/thekid/dialog/web/Search.php b/src/main/php/de/thekid/dialog/web/Search.php index 4cdaa2f..1a321d2 100755 --- a/src/main/php/de/thekid/dialog/web/Search.php +++ b/src/main/php/de/thekid/dialog/web/Search.php @@ -13,6 +13,49 @@ public function __construct(private Repository $repository) { } #[Get] public function search(#[Param] $q, #[Param] $page= 1) { + if ('(' === ($q[0] ?? null)) { + $hsl= sscanf($q, '(%d,%d,%d)'); + $range= [ + 'h' => [max(0, $hsl[0] - 5), min(360, $hsl[0] + 5)], + 's' => [max(0, $hsl[1] - 20), min(100, $hsl[1] + 20)], + 'l' => [max(0, $hsl[2] - 20), min(100, $hsl[2] + 20)], + ]; + + $cursor= $this->repository->database->collection('entries')->aggregate([ + ['$match' => [ + 'images.meta.palette.h' => ['$gte' => $range['h'][0], '$lte' => $range['h'][1]], + 'images.meta.palette.s' => ['$gte' => $range['s'][0], '$lte' => $range['s'][1]], + 'images.meta.palette.l' => ['$gte' => $range['l'][0], '$lte' => $range['l'][1]], + ]] + ]); + + $images= fn() => { + foreach ($cursor as $result) { + foreach ($result['images'] as $image) { + $color= $image['meta']['palette'][0]; + if ( + ($color['h'] >= $range['h'][0] && $color['h'] <= $range['h'][1]) && + ($color['s'] >= $range['s'][0] && $color['s'] <= $range['s'][1]) && + ($color['l'] >= $range['l'][0] && $color['l'] <= $range['l'][1]) + ) { + yield $image + ['in' => ['slug' => $result['slug']], 'matching' => $color]; + } + } + } + }; + + // Search all of these + $colors= []; + $colors[]= ['h' => $range['h'][0], 's' => $range['s'][0], 'l' => $range['l'][0]]; + $colors[]= ['h' => $range['h'][0], 's' => $range['s'][0], 'l' => $hsl[2]]; + $colors[]= ['h' => $range['h'][0], 's' => $hsl[1], 'l' => $hsl[2]]; + $colors[]= ['h' => $hsl[0], 's' => $hsl[1], 'l' => $hsl[2]]; + $colors[]= ['h' => $range['h'][1], 's' => $hsl[1], 'l' => $hsl[2]]; + $colors[]= ['h' => $range['h'][1], 's' => $range['s'][1], 'l' => $hsl[2]]; + $colors[]= ['h' => $range['h'][1], 's' => $range['s'][1], 'l' => $range['l'][1]]; + return View::named('colors')->with(['results' => ['images' => [...$images()]], 'colors' => $colors]); + } + $this->timer->start(); $result= $this->repository->search(trim($q), $this->pagination, $page); $this->timer->stop(); From a86ae0d257e62348479bacf76560d2a29f0817ab Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Fri, 9 Dec 2022 21:15:49 +0100 Subject: [PATCH 16/19] Fix missing import statement for Colors --- src/main/php/de/thekid/dialog/import/LocalDirectory.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/php/de/thekid/dialog/import/LocalDirectory.php b/src/main/php/de/thekid/dialog/import/LocalDirectory.php index bfa1a34..f5bccb0 100755 --- a/src/main/php/de/thekid/dialog/import/LocalDirectory.php +++ b/src/main/php/de/thekid/dialog/import/LocalDirectory.php @@ -1,5 +1,6 @@ Date: Fri, 9 Dec 2022 21:17:40 +0100 Subject: [PATCH 17/19] Fix meta extraction --- src/main/php/de/thekid/dialog/import/LocalDirectory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/de/thekid/dialog/import/LocalDirectory.php b/src/main/php/de/thekid/dialog/import/LocalDirectory.php index f5bccb0..70b2eda 100755 --- a/src/main/php/de/thekid/dialog/import/LocalDirectory.php +++ b/src/main/php/de/thekid/dialog/import/LocalDirectory.php @@ -154,7 +154,7 @@ public function run(): int { } // Extract meta information, aggregating palette from preview image - $info= [...$meta($source)] + ['palette' => []]; + $info= [...$processing->meta($source)] + ['palette' => []]; try { $palette= $this->colors->palette( Image::loadFrom(new JpegStreamReader($transfer['preview'])), From 2eb6a9eccfab496ce37c405c98795ae76dec0663 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 4 Nov 2023 15:00:23 +0100 Subject: [PATCH 18/19] Bump dependency on xp-framework/imaging See https://github.com/xp-framework/imaging/releases/tag/v10.2.0 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index e85adae..15c3b55 100755 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "require": { "xp-framework/compiler": "^8.6", - "xp-framework/imaging": "^10.1", + "xp-framework/imaging": "^10.2", "xp-framework/command": "^11.0", "xp-framework/networking": "^10.4", "xp-forge/web": "^3.8", From db07a965037d116019e6ecb8fd61c6fb57aaf3cb Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 4 Nov 2023 15:02:46 +0100 Subject: [PATCH 19/19] Bump dependency on xp-lang/xp-generics --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 15c3b55..0708187 100755 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "xp-forge/yaml": "^6.0", "xp-forge/hashing": "^2.1", "xp-forge/mongodb": "^1.6", - "xp-lang/xp-generics": "^0.4", + "xp-lang/xp-generics": "^0.7", "xp-forge/inject": "^5.4", "xp-forge/mongodb": "^2.0" },