Skip to content

Commit 1e3271b

Browse files
author
Callin Mullaney
committed
fix: normalize favicon SVG sources
1 parent 7945f59 commit 1e3271b

5 files changed

Lines changed: 82 additions & 16 deletions

File tree

.github/scripts/favicon-portability-smoke.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,17 @@ function emulsify_favicon_run_sanitizer_matrix(FaviconPackageGenerator $generato
161161
emulsify_favicon_assert(!str_contains(strtolower((string) $analysis['sanitized_svg']), 'onclick='), 'Inline event handlers should be stripped.');
162162
emulsify_favicon_assert(!str_contains(strtolower((string) $analysis['sanitized_svg']), 'onload='), 'Root event handlers should be stripped.');
163163

164-
// Hard rejects protect package generation from non-square or excessive input.
165-
emulsify_favicon_assert_invalid(
166-
fn() => $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32"><rect width="64" height="32"/></svg>', FALSE),
167-
'square viewBox',
164+
$analysis = $generator->validateSourceSvg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 32"><rect width="64" height="32"/></svg>', FALSE);
165+
emulsify_favicon_assert(
166+
($analysis['view_box'] ?? []) === [0.0, -16.0, 64.0, 64.0],
167+
'Non-square SVG sources should be centered on a square viewBox.',
168+
);
169+
emulsify_favicon_assert(
170+
str_contains((string) $analysis['sanitized_svg'], 'width="64" height="64"'),
171+
'Non-square SVG sources should receive square root dimensions.',
168172
);
169173

174+
// Hard rejects protect package generation from excessive input.
170175
$oversized_svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><desc>' . str_repeat('x', FaviconPackageGenerator::MAX_FILE_SIZE) . '</desc></svg>';
171176
emulsify_favicon_assert_invalid(
172177
fn() => $generator->validateSourceSvg($oversized_svg, FALSE),

docs/favicon-generation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Generated PNG and ICO assets require both PHP extensions:
99
- GD
1010
- Imagick
1111

12-
The uploaded source must be an SVG file with a square `viewBox`. If root `width` and `height` values are present, they must also describe a square. The generator accepts embedded raster image data inside the SVG, but the theme settings UI warns that fully vector sources usually scale more cleanly.
12+
The uploaded source must be an SVG file with a `viewBox`. Non-square sources are centered on a square canvas before package generation so the original aspect ratio is preserved across browser, iOS, and Android assets. The generator accepts embedded raster image data inside the SVG, but the theme settings UI warns that fully vector sources usually scale more cleanly.
1313

1414
Source uploads are limited to 5 MB. The sanitized portable SVG copy is stored in theme config for portability; copies larger than 256 KB are allowed but flagged as review noise because they make config exports harder to inspect.
1515

src/Favicon/FaviconHeadBuilder.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,26 +41,26 @@ public function apply(array &$attachments, array $settings): void {
4141
'rel' => 'icon',
4242
'href' => $this->fileUrlGenerator->generateString($package_path . '/favicon.ico'),
4343
'sizes' => 'any',
44-
], 'emulsify_favicon_ico',
44+
], FALSE,
4545
];
4646

4747
$attachments['#attached']['html_head_link'][] = [[
4848
'rel' => 'icon',
4949
'type' => 'image/svg+xml',
5050
'href' => $this->fileUrlGenerator->generateString($package_path . '/favicon.svg'),
51-
], 'emulsify_favicon_svg',
51+
], FALSE,
5252
];
5353

5454
$attachments['#attached']['html_head_link'][] = [[
5555
'rel' => 'apple-touch-icon',
5656
'href' => $this->fileUrlGenerator->generateString($package_path . '/apple-touch-icon.png'),
57-
], 'emulsify_favicon_ios',
57+
], FALSE,
5858
];
5959

6060
$attachments['#attached']['html_head_link'][] = [[
6161
'rel' => 'manifest',
6262
'href' => $this->fileUrlGenerator->generateString($package_path . '/site.webmanifest'),
63-
], 'emulsify_favicon_manifest',
63+
], FALSE,
6464
];
6565

6666
$attachments['#attached']['html_head'][] = [[

src/Favicon/FaviconPackageGenerator.php

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -620,18 +620,21 @@ private function inspectSvgMarkup(string $source_data): array {
620620
if ($view_box === NULL) {
621621
throw new \InvalidArgumentException('The uploaded SVG must define a viewBox.');
622622
}
623-
if (!$this->isSquare($view_box[2], $view_box[3])) {
624-
throw new \InvalidArgumentException('The uploaded SVG must use a square viewBox.');
625-
}
626623

627624
$declared_dimensions = $this->extractDeclaredDimensions($root);
628-
if ($declared_dimensions['width'] !== NULL && $declared_dimensions['height'] !== NULL && !$this->isSquare($declared_dimensions['width'], $declared_dimensions['height'])) {
629-
throw new \InvalidArgumentException('The uploaded SVG must declare square width and height values.');
630-
}
625+
$view_box_was_normalized = !$this->isSquare($view_box[2], $view_box[3]);
626+
$dimensions_were_normalized = $declared_dimensions['width'] !== NULL
627+
&& $declared_dimensions['height'] !== NULL
628+
&& !$this->isSquare($declared_dimensions['width'], $declared_dimensions['height']);
631629

632630
$has_embedded_raster_images = $this->documentHasRasterImages($document);
633631
$uses_transparency = $this->documentUsesTransparency($document);
634632

633+
if ($view_box_was_normalized || $dimensions_were_normalized) {
634+
$view_box = $this->normalizeSvgCanvas($root, $view_box);
635+
$declared_dimensions = $this->extractDeclaredDimensions($root);
636+
}
637+
635638
$this->stripDisallowedSvgNodes($document);
636639
$this->stripDisallowedSvgAttributes($document);
637640

@@ -641,6 +644,9 @@ private function inspectSvgMarkup(string $source_data): array {
641644
}
642645

643646
$warnings = [];
647+
if ($view_box_was_normalized || $dimensions_were_normalized) {
648+
$warnings[] = 'This SVG was centered on a square canvas so generated favicon assets keep the original aspect ratio.';
649+
}
644650
if ($uses_transparency) {
645651
$warnings[] = 'This SVG appears to use transparency. Check the browser, iOS, and Android previews against their configured backgrounds.';
646652
}
@@ -659,6 +665,43 @@ private function inspectSvgMarkup(string $source_data): array {
659665
];
660666
}
661667

668+
/**
669+
* Centers a non-square SVG root on a square canvas.
670+
*
671+
* @param float[] $view_box
672+
* Parsed min-x, min-y, width, and height values.
673+
*
674+
* @return float[]
675+
* The normalized square viewBox.
676+
*/
677+
private function normalizeSvgCanvas(\DOMElement $root, array $view_box): array {
678+
$size = max($view_box[2], $view_box[3]);
679+
$normalized_view_box = [
680+
$view_box[0] - (($size - $view_box[2]) / 2),
681+
$view_box[1] - (($size - $view_box[3]) / 2),
682+
$size,
683+
$size,
684+
];
685+
$formatted_size = $this->formatSvgNumber($size);
686+
687+
$root->setAttribute('viewBox', implode(' ', array_map(
688+
fn (float $value): string => $this->formatSvgNumber($value),
689+
$normalized_view_box,
690+
)));
691+
$root->setAttribute('width', $formatted_size);
692+
$root->setAttribute('height', $formatted_size);
693+
694+
return $normalized_view_box;
695+
}
696+
697+
/**
698+
* Formats an SVG number without unnecessary trailing zeroes.
699+
*/
700+
private function formatSvgNumber(float $value): string {
701+
$formatted = rtrim(rtrim(sprintf('%.6F', $value), '0'), '.');
702+
return $formatted === '-0' ? '0' : $formatted;
703+
}
704+
662705
/**
663706
* Loads an SVG document with safe libxml settings.
664707
*/

src/Favicon/FaviconSettingsForm.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ public function alter(array &$form, FormStateInterface $form_state): void {
252252
'extensions' => 'svg',
253253
],
254254
],
255-
'#description' => $this->t('Use a square SVG with a square viewBox. Embedded base64 raster image data is allowed, but it may scale less cleanly than a pure vector source.'),
255+
'#description' => $this->t('Use an SVG icon file. Non-square sources are centered on a square canvas. Embedded base64 raster image data is allowed, but it may scale less cleanly than a pure vector source.'),
256256
];
257257
$form['emulsify_favicon']['source']['portable_source_notice'] = [
258258
'#type' => 'item',
@@ -466,6 +466,10 @@ private function registerCleanValueKeys(FormStateInterface $form_state): void {
466466
* Validates the generated favicon package settings.
467467
*/
468468
private function doValidateFaviconSettings(FormStateInterface $form_state): void {
469+
if ($this->isFaviconSourceRemoveRequest($form_state)) {
470+
return;
471+
}
472+
469473
$site_name = $this->getSiteName();
470474
$settings = FaviconSettings::normalize($form_state->getValues(), $site_name);
471475
$form_state->setValue('favicon_theme_color', $settings['favicon_android_background_color']);
@@ -497,6 +501,20 @@ private function doValidateFaviconSettings(FormStateInterface $form_state): void
497501
}
498502
}
499503

504+
/**
505+
* Returns whether the managed file remove button submitted this rebuild.
506+
*/
507+
private function isFaviconSourceRemoveRequest(FormStateInterface $form_state): bool {
508+
$trigger = $form_state->getTriggeringElement();
509+
if (!is_array($trigger)) {
510+
return FALSE;
511+
}
512+
513+
$array_parents = $trigger['#array_parents'] ?? [];
514+
return in_array('favicon_source_fid', $array_parents, TRUE)
515+
&& end($array_parents) === 'remove_button';
516+
}
517+
500518
/**
501519
* Generates the favicon package when the theme settings form is saved.
502520
*/

0 commit comments

Comments
 (0)