Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions src/Collage.php
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ public static function getCollageConfigPath(string $collageLayout, string $pictu
return self::getLegacyCollageConfigPath($layoutId, $pictureOrientation);
}

private static function isPrivateCollageConfigPath(string $absolutePath): bool
{
return str_starts_with(PathUtility::toProjectRelative($absolutePath), 'private/');
}

public static function createCollage(array $config, array $srcImagePaths, string $destImagePath, ?ImageFilterEnum $filter = null, ?CollageConfig $c = null): bool
{
if ($c === null) {
Expand All @@ -212,6 +217,8 @@ public static function createCollage(array $config, array $srcImagePaths, string
self::$pictureOrientation = $c->collageOrientation;

$collageConfigFilePath = self::getCollageConfigPath($c->collageLayout, self::$pictureOrientation);
$collageConfigIsPrivate = $collageConfigFilePath !== null
&& self::isPrivateCollageConfigPath($collageConfigFilePath);

// Save the original admin setting for text on collage
$adminTextOnCollageEnabled = $c->textOnCollageEnabled;
Expand Down Expand Up @@ -274,11 +281,15 @@ public static function createCollage(array $config, array $srcImagePaths, string
$c->textOnCollageLinespace = isset($collageJson['text_linespace']) ? $collageJson['text_linespace'] : $c->textOnCollageLinespace;
}

// JSON layout can only disable or customize text if admin has enabled it
// JSON layout can only disable or customize text if admin has enabled it.
// Layouts from private/ always respect their JSON text config,
// other layouts require allow_selection.
if ($adminTextOnCollageEnabled === 'enabled') {
if ($c->collageAllowSelection && isset($collageJson['text_disabled']) && $collageJson['text_disabled'] === true) {
$allowJsonTextOverrides = $c->collageAllowSelection || $collageConfigIsPrivate;

if ($allowJsonTextOverrides && isset($collageJson['text_disabled']) && $collageJson['text_disabled'] === true) {
$c->textOnCollageEnabled = 'disabled';
} elseif (isset($collageJson['text_alignment']) && is_array($collageJson['text_alignment'])) {
} elseif ($allowJsonTextOverrides && isset($collageJson['text_alignment']) && is_array($collageJson['text_alignment'])) {
$ta = $collageJson['text_alignment'];
$c->textOnCollageEnabled = 'enabled';

Expand All @@ -295,9 +306,12 @@ public static function createCollage(array $config, array $srcImagePaths, string
$c->textZonePadding = isset($ta['padding']) ? (float) Helper::doMath(str_replace(array_keys($replace), array_values($replace), $ta['padding'])) : 0;
$c->textZoneAlign = $ta['align'] ?? 'center';
$c->textZoneValign = $ta['valign'] ?? 'middle';
$c->textZoneRotation = isset($ta['rotation']) ? (int) $ta['rotation'] : 0;
$c->textZoneRotation = $c->textOnCollageRotation;
if ($collageConfigIsPrivate && isset($ta['rotation'])) {
$c->textZoneRotation = (int) $ta['rotation'];
}

// In zone mode: ignore admin X/Y/Rotation values
// In zone mode: ignore admin X/Y values
// Keep admin font, color, text lines, fontSize (as start), lineHeight (as factor)
} else {
// Legacy mode: calculate X/Y position based on alignment
Expand All @@ -310,7 +324,7 @@ public static function createCollage(array $config, array $srcImagePaths, string
$c->textOnCollageFontSize = (int) Helper::doMath(str_replace(array_keys($replace), array_values($replace), $ta['fontSize']));
}

if (isset($ta['rotation'])) {
if ($collageConfigIsPrivate && isset($ta['rotation'])) {
$c->textOnCollageRotation = (int) $ta['rotation'];
}

Expand Down
134 changes: 115 additions & 19 deletions src/Image.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ class Image
public string $textZoneValign = 'middle';

/**
* Rotation angle for zone text (currently only 0 supported)
* Rotation angle for zone text
*/
public int $textZoneRotation = 0;

Expand Down Expand Up @@ -986,8 +986,8 @@ private function applyTextInZone(GdImage $sourceResource, string $fontPath, int
$fontSize = $minFontSize;
}

// Recalculate with final font size
$lineHeight = (int)($fontSize * $lineHeightFactor);
// Recalculate with final font size, but ensure admin linespace is respected as minimum
$lineHeight = max((int)($fontSize * $lineHeightFactor), $this->textLineSpacing);
$blockHeight = (count($lines) - 1) * $lineHeight + $fontSize;

// Get ascent for baseline correction
Expand All @@ -1009,34 +1009,130 @@ private function applyTextInZone(GdImage $sourceResource, string $fontPath, int
break;
}

// Draw each line with individual horizontal alignment
foreach ($lines as $index => $line) {
// Measure this specific line
$bbox = @imagettfbbox($fontSize, 0, $fontPath, $line);
$lineWidth = $bbox !== false ? abs($bbox[2] - $bbox[0]) : 0;
// Calculate base X position for the text block
$rotation = $this->textZoneRotation;
$radians = deg2rad($rotation);

// Calculate X position based on align
if ($rotation != 0) {
// Measure all lines and collect widths
$maxLineWidth = 0;
$lineWidths = [];
foreach ($lines as $line) {
$bbox = @imagettfbbox($fontSize, 0, $fontPath, $line);
$w = $bbox !== false ? abs($bbox[2] - $bbox[0]) : 0;
$lineWidths[] = $w;
if ($w > $maxLineWidth) {
$maxLineWidth = $w;
}
}

$cosR = cos($radians);
$sinR = sin($radians);
$lineCount = count($lines);

// Calculate all draw positions (before zone centering) relative to origin (0,0)
// Each line is stacked perpendicular to text direction and centered along it
$positions = [];
foreach ($lines as $index => $line) {
$lineWidth = $lineWidths[$index];
$centerShift = ($maxLineWidth - $lineWidth) / 2;

// Perpendicular offset (stacking): direction (sin(θ), cos(θ))
$perpX = $index * $lineHeight * $sinR;
$perpY = $index * $lineHeight * $cosR;

// Parallel offset (centering): direction (cos(θ), -sin(θ))
$paraX = $centerShift * $cosR;
$paraY = -$centerShift * $sinR;

$positions[] = ['x' => $perpX + $paraX, 'y' => $perpY + $paraY];
}

// Calculate actual bounding box of all rendered text using imagettfbbox with rotation
$minBx = PHP_INT_MAX;
$minBy = PHP_INT_MAX;
$maxBx = PHP_INT_MIN;
$maxBy = PHP_INT_MIN;
foreach ($lines as $index => $line) {
$bbox = @imagettfbbox($fontSize, $rotation, $fontPath, $line);
if ($bbox !== false) {
$px = $positions[$index]['x'];
$py = $positions[$index]['y'];
// imagettfbbox returns 4 corners: ll, lr, ur, ul
for ($i = 0; $i < 8; $i += 2) {
$bx = $px + $bbox[$i];
$by = $py + $bbox[$i + 1];
$minBx = min($minBx, $bx);
$minBy = min($minBy, $by);
$maxBx = max($maxBx, $bx);
$maxBy = max($maxBy, $by);
}
}
}

$totalW = $maxBx - $minBx;
$totalH = $maxBy - $minBy;

// Calculate zone center offset for the text block
switch ($this->textZoneAlign) {
case 'right':
$drawX = (int)($zoneX + $zoneW - $lineWidth);
$offsetX = $zoneX + $zoneW - $totalW - $minBx;
break;
case 'center':
$drawX = (int)($zoneX + ($zoneW - $lineWidth) / 2);
$offsetX = $zoneX + ($zoneW - $totalW) / 2 - $minBx;
break;
case 'left':
default:
$drawX = (int)$zoneX;
$offsetX = $zoneX - $minBx;
break;
}

switch ($this->textZoneValign) {
case 'bottom':
$offsetY = $zoneY + $zoneH - $totalH - $minBy;
break;
case 'middle':
$offsetY = $zoneY + ($zoneH - $totalH) / 2 - $minBy;
break;
case 'top':
default:
$offsetY = $zoneY - $minBy;
break;
}

// Calculate Y position (baseline position)
// First line: startTopY + ascent (to position top of text at startTopY)
// Subsequent lines: add lineHeight for each
$drawY = (int)($startTopY + $ascent + ($index * $lineHeight));
// Draw each line at calculated position + zone offset
foreach ($lines as $index => $line) {
$drawX = (int)($positions[$index]['x'] + $offsetX);
$drawY = (int)($positions[$index]['y'] + $offsetY);

// Draw the text (rotation is 0 for zone mode)
if (!imagettftext($sourceResource, $fontSize, 0, $drawX, $drawY, $color, $fontPath, $line)) {
throw new \Exception('Could not add line ' . ($index + 1) . ' of text to resource.');
if (!imagettftext($sourceResource, $fontSize, $rotation, $drawX, $drawY, $color, $fontPath, $line)) {
throw new \Exception('Could not add line ' . ($index + 1) . ' of text to resource.');
}
}
} else {
// No rotation: per-line horizontal alignment
foreach ($lines as $index => $line) {
$bbox = @imagettfbbox($fontSize, 0, $fontPath, $line);
$lineWidth = $bbox !== false ? abs($bbox[2] - $bbox[0]) : 0;

switch ($this->textZoneAlign) {
case 'right':
$drawX = (int)($zoneX + $zoneW - $lineWidth);
break;
case 'center':
$drawX = (int)($zoneX + ($zoneW - $lineWidth) / 2);
break;
case 'left':
default:
$drawX = (int)$zoneX;
break;
}

$drawY = (int)($startTopY + $ascent + ($index * $lineHeight));

if (!imagettftext($sourceResource, $fontSize, 0, $drawX, $drawY, $color, $fontPath, $line)) {
throw new \Exception('Could not add line ' . ($index + 1) . ' of text to resource.');
}
}
}
}
Expand Down
Loading