diff --git a/config/default/image.style.banner_featured.yml b/config/default/image.style.banner_featured.yml new file mode 100644 index 00000000..e82fe9f1 --- /dev/null +++ b/config/default/image.style.banner_featured.yml @@ -0,0 +1,23 @@ +uuid: fb579dc8-21da-4a99-887b-920afb0cde42 +langcode: en +status: true +dependencies: + module: + - focal_point +name: banner_featured +label: 'Banner featured image (1090x818, WebP)' +effects: + 24958aa6-09b2-48bd-b29e-b38b76f1168d: + uuid: 24958aa6-09b2-48bd-b29e-b38b76f1168d + id: focal_point_scale_and_crop + weight: 1 + data: + width: 1090 + height: 818 + crop_type: focal_point + a405d584-d557-469f-bdc0-9e7fb2b290d9: + uuid: a405d584-d557-469f-bdc0-9e7fb2b290d9 + id: image_convert + weight: 2 + data: + extension: webp diff --git a/config/default/image.style.divider.yml b/config/default/image.style.divider.yml new file mode 100644 index 00000000..10104c18 --- /dev/null +++ b/config/default/image.style.divider.yml @@ -0,0 +1,21 @@ +uuid: 12950d43-9e28-4023-af34-1ad10d79a313 +langcode: en +status: true +dependencies: { } +name: divider +label: 'Divider (480 tall, WebP)' +effects: + 8bef4bca-0946-4c77-b443-d3e6bdecf045: + uuid: 8bef4bca-0946-4c77-b443-d3e6bdecf045 + id: image_scale + weight: 1 + data: + width: 1090 + height: 480 + upscale: false + bd7d2701-0350-48a9-8961-3697c2dfd7c6: + uuid: bd7d2701-0350-48a9-8961-3697c2dfd7c6 + id: image_convert + weight: 2 + data: + extension: webp diff --git a/docs/performance.md b/docs/performance.md index 4d283892..6a381dd3 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -9,11 +9,17 @@ An image style does two things here: it caps the pixel dimensions, and it conver | Style | Used for | |---|---| | `banner_background` | The banner background: scaled to 1920 wide, converted to WebP | +| `banner_featured` | The banner featured image: cropped to 1090x818 around the focal point, converted to WebP | | `civictheme_*` | Cards, campaigns and slides: cropped by the CivicTheme sizes, converted to WebP | -| `wide` | The banner featured image | +| `divider` | The divider graphic: scaled to fit within 1090x480, converted to WebP | +| `wide` | An image placed in content and rendered as a figure | | `social_share` | Share cards, deliberately **not** converted, because some social crawlers still handle WebP badly | -CivicTheme resolves both banner images with no image style at all, which yields the URL of the original upload. `_drevops_banner_apply_image_styles()` re-resolves them. It covers the block field and the node field, because the node's value wins when both are set. +CivicTheme resolves several of these with no image style at all, which yields the URL of the original upload. Three preprocessors here re-resolve them: `_drevops_banner_apply_image_styles()` for the two banner images, `drevops_preprocess_paragraph__divider()` for the divider, and `drevops_preprocess_media__civictheme_image()` for the figure. The banner one covers the block field and the node field, because the node's value wins when both are set. + +The featured image sits in a box 40% of the viewport wide and no more than 600px tall, filled with `object-fit: cover`. Its ratio moves with the viewport and with how tall the banner's own content makes it, so the browser trims a different part of the image on every screen. A focal point crop puts the subject at the centre of the derivative, which is the part `cover` keeps whichever way it trims. + +`drevops_preprocess_media__civictheme_image()` also clears the width and height CivicTheme measured on the source file, because they describe a different image once a style has resized it. When adding an image style that renders photographic content, copy the `image_convert` effect from `image.style.large.yml`. A style with only a crop effect ships whatever format was uploaded. diff --git a/web/modules/custom/do_base/tests/src/Unit/ImageStyleConfigTest.php b/web/modules/custom/do_base/tests/src/Unit/ImageStyleConfigTest.php new file mode 100644 index 00000000..c41906f8 --- /dev/null +++ b/web/modules/custom/do_base/tests/src/Unit/ImageStyleConfigTest.php @@ -0,0 +1,174 @@ +loadConfig('image.style.' . $name . '.yml'); + + // Assert. + $this->assertSame($name, $style['name']); + } + + /** + * Data provider for testStyleNamedByThemeIsExported. + */ + public static function dataProviderStyleNamedByThemeIsExported(): \Iterator { + yield from static::styleNameCases(); + } + + /** + * Tests that the featured image is cropped around the editor's focal point. + * + * The banner sizes the image with CSS, so the browser trims everything + * outside a centred box. A focal point crop places the subject at that + * centre. + */ + public function testFeaturedImageCropsOnTheFocalPoint(): void { + // Act. + $effect = $this->effect(static::FEATURED_STYLE, 'focal_point_scale_and_crop'); + + // Assert. + $this->assertNotNull($effect, 'The featured image style does not crop on a focal point.'); + $this->assertSame('focal_point', $effect['data']['crop_type']); + $this->assertGreaterThan(0, $effect['data']['width']); + $this->assertGreaterThan(0, $effect['data']['height']); + } + + /** + * Tests that the divider graphic is capped at the size it is drawn at. + * + * CSS bounds the rendered height, so an uncapped derivative sends pixels the + * page never draws. Upscaling a small graphic to the cap blurs it instead. + */ + public function testDividerIsScaledWithinBounds(): void { + // Act. + $effect = $this->effect(static::DIVIDER_STYLE, 'image_scale'); + + // Assert. + $this->assertNotNull($effect, 'The divider style does not scale.'); + $this->assertGreaterThan(0, $effect['data']['width']); + $this->assertGreaterThan(0, $effect['data']['height']); + $this->assertFalse($effect['data']['upscale']); + } + + /** + * Tests that the styles rendering uploaded imagery convert to WebP. + * + * The theme renders only photographic content through a style: brand renders + * and generated art uploaded as multi-megabyte PNGs. A style without a + * convert effect serves the uploaded format. + */ + #[DataProvider('dataProviderStyleNamedByThemeConvertsToWebp')] + public function testStyleNamedByThemeConvertsToWebp(string $name): void { + // Act. + $effect = $this->effect($name, 'image_convert'); + + // Assert. + $this->assertNotNull($effect, sprintf('Style "%s" does not convert.', $name)); + $this->assertSame('webp', $effect['data']['extension']); + } + + /** + * Data provider for testStyleNamedByThemeConvertsToWebp. + */ + public static function dataProviderStyleNamedByThemeConvertsToWebp(): \Iterator { + yield from static::styleNameCases(); + } + + /** + * Returns an effect of a style, or NULL when the style does not have one. + */ + protected function effect(string $name, string $effect_id): ?array { + $effects = $this->loadConfig('image.style.' . $name . '.yml')['effects'] ?? []; + + foreach ($effects as $effect) { + if ($effect['id'] === $effect_id) { + return $effect; + } + } + + return NULL; + } + + /** + * Yields one case per style name, keyed by the name. + */ + protected static function styleNameCases(): \Iterator { + foreach (static::themeStyleNames() as $name) { + yield $name => [$name]; + } + } + + /** + * Lists the style names the theme passes to the image render helpers. + * + * Names are read from the theme rather than listed here, so a component that + * starts rendering through a style of its own is covered without editing this + * test. + */ + protected static function themeStyleNames(): array { + $helpers = [ + 'civictheme_media_image_get_variables', + '_civictheme_preprocess_paragraph__paragraph_field__image', + '_civictheme_preprocess_paragraph__node_field__image', + ]; + + $pattern = sprintf('~(?:%s)\([^)]*,\s*\'([a-z0-9_]+)\'\s*\)~', implode('|', $helpers)); + + $names = []; + + foreach (glob(dirname(__DIR__, 7) . '/web/themes/custom/drevops/includes/*.inc') ?: [] as $file) { + if (preg_match_all($pattern, (string) file_get_contents($file), $matches) === 0) { + continue; + } + + $names = array_merge($names, $matches[1]); + } + + $names = array_unique($names); + sort($names); + + if ($names === []) { + throw new \RuntimeException('No image styles found in the theme; the scanned helper names are out of date.'); + } + + return $names; + } + +} diff --git a/web/themes/custom/drevops/components/01-atoms/image/image.component.yml b/web/themes/custom/drevops/components/01-atoms/image/image.component.yml index b46e3ef6..8db340f8 100644 --- a/web/themes/custom/drevops/components/01-atoms/image/image.component.yml +++ b/web/themes/custom/drevops/components/01-atoms/image/image.component.yml @@ -33,3 +33,7 @@ props: type: string title: Modifier classes description: Additional CSS classes. + attributes: + type: Drupal\Core\Template\Attribute + title: Additional HTML attributes + description: Additional HTML attributes to add to the component. diff --git a/web/themes/custom/drevops/components/03-organisms/divider/divider.scss b/web/themes/custom/drevops/components/03-organisms/divider/divider.scss index 2e51b7e2..8a2f691c 100644 --- a/web/themes/custom/drevops/components/03-organisms/divider/divider.scss +++ b/web/themes/custom/drevops/components/03-organisms/divider/divider.scss @@ -9,6 +9,9 @@ border-bottom: solid ct-particle(0.125); img { + // The dimension attributes set a width, so clamping the height alone would + // distort the image. + width: auto; height: 100%; max-width: 100%; } diff --git a/web/themes/custom/drevops/components/03-organisms/divider/divider.twig b/web/themes/custom/drevops/components/03-organisms/divider/divider.twig index 2d7de551..03f74326 100644 --- a/web/themes/custom/drevops/components/03-organisms/divider/divider.twig +++ b/web/themes/custom/drevops/components/03-organisms/divider/divider.twig @@ -24,6 +24,13 @@