= AR_i -> r_x = 1 r_y = AR_i / AR_c * AR_c < AR_i -> r_x = AR_c / AR_i r_y = 1 * * Absolute pixelmaten verdwijnen volledig uit de vergelijking. Daarom kan deze * berekening server-side gebeuren zodra de doelverhouding bekend is. * * ── De safe area ──────────────────────────────────────────────────────────── * * Voor een safe area [S1, S2] op een as, met venster [u, u+r]: * * a = S2 - r de meest rechtse u waarbij S2 nog net in beeld valt * b = S1 de meest linkse u waarbij S1 nog net in beeld valt * * a <= b : de safe area PAST (Sw <= r). Het venster moet hem omvatten -> u in [a, b] * a > b : de safe area is TE BREED. Het venster moet er juist in liggen -> u in [b, a] * * Beide gevallen zijn [min(a,b), max(a,b)]. Een uitdrukking, geen if. * * Doorsnijden met het fysieke bereik [0, 1-r] geeft lo en hi. Dat die * doorsnede nooit leeg is, volgt uit S1 >= 0 en S2 <= 1: * * - a = S2 - r <= 1 - r, dus min(a,b) <= 1-r * - b = S1 >= 0, dus max(a,b) >= 0 * * De engine kan dus geen ongeldige uitkomst produceren. */ final class Crop { /** * Numerieke marge. Onder deze drempel beschouwen we een as als ongesneden. */ public const EPSILON = 1e-9; /** * Hoe ver het onderwerp buiten de tekstzone wordt gezet, in containerfractie. * Precies op de rand is technisch "erbuiten" maar visueel er nog steeds tegenaan. */ public const ZONE_MARGIN = 0.04; /** * Waarde die wordt teruggegeven wanneer er niets zinnigs te berekenen valt. */ public const NEUTRAL = 50.0; /** * Niet instantieerbaar: dit is een verzameling pure functies. */ private function __construct() {} /** * Klemt een waarde binnen een bereik. * * @param float $value Waarde. * @param float $min Ondergrens. * @param float $max Bovengrens. * @return float */ public static function clamp( float $value, float $min, float $max ): float { if ( $value < $min ) { return $min; } return $value > $max ? $max : $value; } /** * Welke fractie van de afbeelding blijft op elke as zichtbaar onder `cover`? * * Hangt uitsluitend af van de twee beeldverhoudingen. Exact een van beide * waarden is 1 (geen crop op die as); bij gelijke verhoudingen zijn het er twee. * * @param float $image_w Breedte van de afbeelding. * @param float $image_h Hoogte van de afbeelding. * @param float $box_w Breedte van de container. * @param float $box_h Hoogte van de container. * @return array{rx: float, ry: float} Zichtbare fracties, elk in (0, 1]. */ public static function visible_fractions( float $image_w, float $image_h, float $box_w, float $box_h ): array { // Een dimensie van nul komt voor bij elementen die nog niet gelayout zijn. // Dan valt er niets te croppen en is alles zichtbaar. if ( $image_w <= 0.0 || $image_h <= 0.0 || $box_w <= 0.0 || $box_h <= 0.0 ) { return array( 'rx' => 1.0, 'ry' => 1.0, ); } $image_ratio = $image_w / $image_h; $box_ratio = $box_w / $box_h; if ( $box_ratio >= $image_ratio ) { // Container relatief breder: er wordt verticaal gesneden. return array( 'rx' => 1.0, 'ry' => $image_ratio / $box_ratio, ); } // Container relatief hoger: er wordt horizontaal gesneden. return array( 'rx' => $box_ratio / $image_ratio, 'ry' => 1.0, ); } /** * Berekent de optimale object-position voor een enkele as. * * @param float $focus Focuscoordinaat, 0..1, in beeldruimte. * @param float $visible Zichtbare fractie op deze as, 0..1. * @param float|null $safe_start Begin van de safe area op deze as, 0..1. Null = geen safe area. * @param float|null $safe_size Grootte van de safe area op deze as, 0..1. * @return array{percent: float, coverage: float, fits: bool} */ public static function axis_position( float $focus, float $visible, ?float $safe_start = null, ?float $safe_size = null ): array { $axis = self::axis_window( $focus, $visible, $safe_start, $safe_size ); return array( 'percent' => $axis['percent'], 'coverage' => $axis['coverage'], 'fits' => $axis['fits'], ); } /** * Als axis_position(), maar met het interne venster erbij: de gekozen * vensterpositie u en de safe-area-grenzen [lo, hi] waarbinnen hij mag * schuiven. De tekstzone-logica heeft die nodig om het venster te kunnen * verplaatsen zónder de safe-area-garantie te breken. * * @param float $focus Focuscoordinaat, 0..1, in beeldruimte. * @param float $visible Zichtbare fractie op deze as, 0..1. * @param float|null $safe_start Begin van de safe area op deze as. * @param float|null $safe_size Grootte van de safe area op deze as. * @return array{percent: float, coverage: float, fits: bool, u: float, lo: float, hi: float, span: float} */ public static function axis_window( float $focus, float $visible, ?float $safe_start = null, ?float $safe_size = null ): array { $has_safe = ( null !== $safe_start && null !== $safe_size && $safe_size > 0.0 ); // Geen crop op deze as: elke waarde rendert identiek. We geven het // focuspunt terug in plaats van 50%, zodat het getal continu meebeweegt // wanneer de container van verhouding verandert. Een sprong op het // moment dat het snijden begint zou in de live preview zichtbaar schokken. if ( $visible >= 1.0 - self::EPSILON ) { return array( 'percent' => self::clamp( $focus, 0.0, 1.0 ) * 100.0, 'coverage' => 1.0, 'fits' => true, 'u' => 0.0, 'lo' => 0.0, 'hi' => 0.0, 'span' => 0.0, ); } $span = 1.0 - $visible; $position = $focus - ( $visible / 2.0 ); // Ideaal: focuspunt dead centre. $coverage = 1.0; $fits = true; $lo = 0.0; $hi = $span; if ( $has_safe ) { $fits = ( $safe_size <= $visible + self::EPSILON ); $a = ( $safe_start + $safe_size ) - $visible; $b = $safe_start; $lo = max( min( $a, $b ), 0.0 ); $hi = min( max( $a, $b ), $span ); // Bewezen niet-leeg; de max() is een gordel naast de bretels // tegen afrondingsruis bij extreme verhoudingen. if ( $hi < $lo ) { $hi = $lo; } $position = self::clamp( $position, $lo, $hi ); } $position = self::clamp( $position, 0.0, $span ); if ( $has_safe ) { $coverage = self::axis_coverage( $position, $visible, (float) $safe_start, (float) $safe_size ); } return array( 'percent' => ( $position / $span ) * 100.0, 'coverage' => $coverage, 'fits' => $fits, 'u' => $position, 'lo' => $lo, 'hi' => $hi, 'span' => $span, ); } /** * Welk deel van de safe area is bij vensterpositie u zichtbaar? * * @param float $u Vensterpositie op deze as. * @param float $visible Zichtbare fractie. * @param float $safe_start Begin van de safe area. * @param float $safe_size Grootte van de safe area. * @return float 0..1. */ private static function axis_coverage( float $u, float $visible, float $safe_start, float $safe_size ): float { if ( $safe_size <= 0.0 ) { return 1.0; } $overlap = min( $u + $visible, $safe_start + $safe_size ) - max( $u, $safe_start ); return self::clamp( max( 0.0, $overlap ) / $safe_size, 0.0, 1.0 ); } /** * Berekent de object-position voor een afbeelding in een container. * * Dit is de publieke ingang van de engine. * * @param float $image_w Breedte van de afbeelding in pixels. * @param float $image_h Hoogte van de afbeelding in pixels. * @param float $box_w Breedte van de container. * @param float $box_h Hoogte van de container. * @param array $focus array{x: float, y: float}, genormaliseerd 0..1. * @param array|null $safe_area array{x,y,width,height}, genormaliseerd 0..1. Null = geen. * @return array{ * x: float, y: float, rx: float, ry: float, * coverage: float, fits: bool, * window: array{x1: float, x2: float, y1: float, y2: float} * } */ public static function object_position( float $image_w, float $image_h, float $box_w, float $box_h, array $focus, ?array $safe_area = null, ?array $text_zone = null ): array { $fx = self::clamp( (float) ( $focus['x'] ?? 0.5 ), 0.0, 1.0 ); $fy = self::clamp( (float) ( $focus['y'] ?? 0.5 ), 0.0, 1.0 ); $fractions = self::visible_fractions( $image_w, $image_h, $box_w, $box_h ); $sx = null; $sw = null; $sy = null; $sh = null; if ( is_array( $safe_area ) && isset( $safe_area['width'], $safe_area['height'] ) ) { $sx = (float) ( $safe_area['x'] ?? 0.0 ); $sy = (float) ( $safe_area['y'] ?? 0.0 ); $sw = (float) $safe_area['width']; $sh = (float) $safe_area['height']; } $axis_x = self::axis_window( $fx, $fractions['rx'], $sx, $sw ); $axis_y = self::axis_window( $fy, $fractions['ry'], $sy, $sh ); // ── De tekstzone ──────────────────────────────────────────────────── // // Een rechthoek in CONTAINERruimte (fracties van de box) waar het thema // tekst over de foto legt. Valt het focuspunt erin, dan schuiven we het // venster — binnen de safe-area-grenzen — zó dat het onderwerp er op // één as naast komt te staan. Eén as is genoeg: naast de tekst is // naast de tekst. $clear = true; $zone = self::zone_bounds( $text_zone ); if ( null !== $zone ) { $cx = self::container_position( $fx, $fractions['rx'], $axis_x['u'] ); $cy = self::container_position( $fy, $fractions['ry'], $axis_y['u'] ); $inside = $cx > $zone['x1'] && $cx < $zone['x2'] && $cy > $zone['y1'] && $cy < $zone['y2']; if ( $inside ) { $clear = false; $best = null; foreach ( array( array( 'axis' => 'x', 'f' => $fx, 'r' => $fractions['rx'], 'win' => $axis_x, 'c' => $cx, 'z1' => $zone['x1'], 'z2' => $zone['x2'] ), array( 'axis' => 'y', 'f' => $fy, 'r' => $fractions['ry'], 'win' => $axis_y, 'c' => $cy, 'z1' => $zone['y1'], 'z2' => $zone['y2'] ), ) as $try ) { foreach ( self::zone_escapes( $try ) as $candidate ) { if ( null === $best || ( $candidate['escaped'] && ! $best['escaped'] ) || ( $candidate['escaped'] === $best['escaped'] && $candidate['cost'] < $best['cost'] - self::EPSILON ) ) { $best = $candidate; } } } if ( null !== $best ) { $clear = $best['escaped']; if ( 'x' === $best['axis'] ) { $axis_x = self::reposition_axis( $axis_x, $best['u'], $fractions['rx'], $sx, $sw ); } else { $axis_y = self::reposition_axis( $axis_y, $best['u'], $fractions['ry'], $sy, $sh ); } } } } $ux = ( $axis_x['percent'] / 100.0 ) * ( 1.0 - $fractions['rx'] ); $uy = ( $axis_y['percent'] / 100.0 ) * ( 1.0 - $fractions['ry'] ); return array( 'x' => $axis_x['percent'], 'y' => $axis_y['percent'], 'rx' => $fractions['rx'], 'ry' => $fractions['ry'], 'coverage' => min( $axis_x['coverage'], $axis_y['coverage'] ), 'fits' => ( $axis_x['fits'] && $axis_y['fits'] ), 'clear' => $clear, 'window' => array( 'x1' => $ux, 'x2' => $ux + $fractions['rx'], 'y1' => $uy, 'y2' => $uy + $fractions['ry'], ), ); } /** * Waar in de container (0..1) verschijnt beeldcoordinaat f? * * @param float $focus Coordinaat in beeldruimte. * @param float $visible Zichtbare fractie op deze as. * @param float $u Vensterpositie op deze as. * @return float */ private static function container_position( float $focus, float $visible, float $u ): float { // Ongesneden as: het beeld vult de container exact, f verschijnt op f. if ( $visible >= 1.0 - self::EPSILON ) { return $focus; } return ( $focus - $u ) / $visible; } /** * Genormaliseerde zone-grenzen, of null als er geen bruikbare zone is. * * @param array|null $text_zone array{x,y,width,height} in containerfracties. * @return array{x1: float, x2: float, y1: float, y2: float}|null */ private static function zone_bounds( ?array $text_zone ): ?array { if ( ! is_array( $text_zone ) || ! isset( $text_zone['width'], $text_zone['height'] ) ) { return null; } $x = self::clamp( (float) ( $text_zone['x'] ?? 0.0 ), 0.0, 1.0 ); $y = self::clamp( (float) ( $text_zone['y'] ?? 0.0 ), 0.0, 1.0 ); $w = (float) $text_zone['width']; $h = (float) $text_zone['height']; if ( $w <= self::EPSILON || $h <= self::EPSILON ) { return null; } return array( 'x1' => $x, 'x2' => self::clamp( $x + $w, 0.0, 1.0 ), 'y1' => $y, 'y2' => self::clamp( $y + $h, 0.0, 1.0 ), ); } /** * De ontsnappingskandidaten voor één as: links/boven en rechts/onder langs * de zone, elk met de vensterpositie die daarvoor nodig is. * * @param array $try De as-context (f, r, win, c, z1, z2). * @return array */ private static function zone_escapes( array $try ): array { $r = (float) $try['r']; // Ongesneden as: het venster kan hier niet schuiven. if ( $r >= 1.0 - self::EPSILON ) { return array(); } $out = array(); foreach ( array( array( 'target' => (float) $try['z1'] - self::ZONE_MARGIN, 'side' => -1.0 ), array( 'target' => (float) $try['z2'] + self::ZONE_MARGIN, 'side' => 1.0 ), ) as $escape ) { $target = self::clamp( $escape['target'], 0.0, 1.0 ); // u zó kiezen dat f op containerpositie target verschijnt, geklemd // binnen de safe-area-grenzen en het fysieke bereik. $u = (float) $try['f'] - $target * $r; $u = self::clamp( $u, (float) $try['win']['lo'], (float) $try['win']['hi'] ); $u = self::clamp( $u, 0.0, (float) $try['win']['span'] ); $c = ( (float) $try['f'] - $u ) / $r; $escaped = $escape['side'] < 0.0 ? $c <= (float) $try['z1'] + self::EPSILON : $c >= (float) $try['z2'] - self::EPSILON; // Het onderwerp mag de tekst ontwijken, maar niet het beeld uit: // buiten de container geschoven is geen ontsnapping. if ( $c < -self::EPSILON || $c > 1.0 + self::EPSILON ) { $escaped = false; } $out[] = array( 'axis' => (string) $try['axis'], 'u' => $u, 'escaped' => $escaped, 'cost' => abs( $c - (float) $try['c'] ), ); } return $out; } /** * Herpositioneert een as op een nieuwe vensterpositie en rekent de * safe-area-dekking opnieuw uit. * * @param array $axis Het resultaat van axis_window(). * @param float $u De nieuwe vensterpositie. * @param float $visible Zichtbare fractie. * @param float|null $safe_start Begin van de safe area. * @param float|null $safe_size Grootte van de safe area. * @return array */ private static function reposition_axis( array $axis, float $u, float $visible, ?float $safe_start, ?float $safe_size ): array { $span = (float) $axis['span']; if ( $span <= self::EPSILON ) { return $axis; } $axis['u'] = $u; $axis['percent'] = ( $u / $span ) * 100.0; if ( null !== $safe_start && null !== $safe_size && $safe_size > 0.0 ) { $axis['coverage'] = self::axis_coverage( $u, $visible, $safe_start, $safe_size ); } return $axis; } /** * Formatteert een object-position als CSS-waarde. * * @param float $x Horizontaal percentage. * @param float $y Verticaal percentage. * @param int $decimals Aantal decimalen. * @return string Bijvoorbeeld "68.51% 28%". */ public static function to_css( float $x, float $y, int $decimals = 2 ): string { return self::format_percent( $x, $decimals ) . ' ' . self::format_percent( $y, $decimals ); } /** * Formatteert een percentage compact: geen overbodige nullen achter de komma. * * @param float $value Waarde 0..100. * @param int $decimals Maximaal aantal decimalen. * @return string */ public static function format_percent( float $value, int $decimals = 2 ): string { $rounded = round( $value, $decimals ); // Altijd punt als decimaalscheiding: dit is CSS, geen mensentaal. $formatted = number_format( $rounded, $decimals, '.', '' ); if ( str_contains( $formatted, '.' ) ) { $formatted = rtrim( rtrim( $formatted, '0' ), '.' ); } return ( '' === $formatted ? '0' : $formatted ) . '%'; } /** * De aspectratio-onafhankelijke positie: het focuspunt zelf. * * Dit is wat tier 1 uit het architectuurdocument uitserveert. Met * `object-position: fx% fy%` verschijnt het focuspunt op precies fx% van de * container, ongeacht de verhouding - het valt dus nooit buiten beeld. Wat * deze tier niet garandeert is dat de OMGEVING van dat punt meekomt; daar * is de safe area voor. * * @param array $focus array{x: float, y: float}. * @return string Bijvoorbeeld "62% 28%". */ public static function focus_to_css( array $focus ): string { return self::to_css( self::clamp( (float) ( $focus['x'] ?? 0.5 ), 0.0, 1.0 ) * 100.0, self::clamp( (float) ( $focus['y'] ?? 0.5 ), 0.0, 1.0 ) * 100.0 ); } }