Guide

# Images and SVG

JPEG, PNG and lossless WebP images with aspect-preserving sizing, and the SVG subset icon sets use — drawn as vectors.

## Formats

Decoded in Ruby, cached per process.

Images are **JPEG** (grey, RGB, CMYK), **PNG** (every colour type, alpha as a soft mask, palette transparency) and lossless **WebP**. The source is a file path or any IO (`StringIO`, an ActiveStorage download, …). Parsed fonts and images are cached per process, keyed by content, so the same logo on every page is embedded once.

A WebP is decoded in Ruby (the VP8L bitstream, in a plain or an extended container; colour profile and metadata chunks are skipped) and embedded like a PNG: RGB, with the alpha channel as a soft mask when a pixel is not opaque. Decoding takes 0.2 to 0.4 s per megapixel, once per process for the same bytes. Lossy and animated WebP raise `UnsupportedImage` naming which it is, as does a file that is cut short or damaged; convert those to JPEG, PNG or lossless WebP (`cwebp -lossless`).

A JPEG is embedded in a PDF byte for byte and decoded in Ruby only where pixels are needed: `to_png`, and a `monochrome` render, which dithers it. Baseline, extended and progressive JPEGs decode (every chroma subsampling, restart intervals, grey, RGB, YCbCr, CMYK and YCCK) to the pixels libjpeg-turbo gives, a photo drawn small at a half, a quarter or an eighth of its size. A lossless, arithmetic-coded, hierarchical or 12-bit JPEG, or one of more than 33 megapixels, is not decoded: `to_png` draws a crossed box (`SkippedImage`) and monochrome embeds it as it is (`NotMonochrome`). The EXIF orientation is not applied, in a picture as in the PDF.

## Sizing and fit

`image(source, width: nil, height: nil, fit: nil, align: nil, opacity: nil, radius: 0, rotate: 0, max_ppi: 300, downscale: false, float: nil, margin: nil)`

| Given | Result |
| --- | --- |
| nothing | one pixel is one point |
| `width:` or `height:` | the other side follows the aspect ratio |
| both | exactly that size |
| `fit: [w, h]` | scaled to fit inside the box, aspect preserved |
| `fit: :cover` with `width:` and `height:` | scaled to fill the box, aspect preserved, the excess cropped around the centre (CSS `object-fit: cover`) |
| `radius:` | corners rounded by that many points (the image is clipped) |
| `rotate:` | turned that many degrees clockwise around its centre; the space it takes up does not change (CSS `transform: rotate`) |
| `float: :left` or `:right` | taken to that side of the flow; the text and the lists that follow wrap beside it, `margin:` away (CSS `float`) |

An image is never wider than the space it is given; it scales down to the column. `align:` (`:left`, `:center`, `:right`) positions a narrower image and is not read on a floated one. `margin:` is a number (kept on the sides that face the text) or names the sides as `padding:` does; the rules for what wraps and what moves below are on [Elements](https://stationery.zoolutions.llc/docs/elements#floats).

```ruby
image "logo.png", height: 34, align: :right
image StringIO.new(product.photo.download), fit: [120, 80]
# text wraps beside the photo and continues below it
image photo, float: :left, width: 0.4, margin: { right: 14, bottom: 8 }, alt: "The bay at dawn"
# a tilted, rounded snapshot cropped to 4:3
image photo, width: 160, height: 120, fit: :cover, radius: 8, rotate: -3
```

A rotated image paints outside its rectangle at the corners, so leave room around it (a `box(padding:)`).

## Resolution

Bitmaps keep their pixels.

A JPEG is embedded byte for byte and a PNG or WebP re-encoded losslessly, so a 1600 px photo drawn 160 pt wide ships all 1600 px at 720 ppi. Print needs about 300 ppi (`width_in_points / 72 * 300` pixels: a 160 pt photo wants about 670 px) and screen PDFs half that.

Every image is checked when it is painted: drawn at more than twice `max_ppi:` it is reported as a [`Warnings::OversizedImage`](https://stationery.zoolutions.llc/docs/warnings) naming the file, its pixel width and the resolution it lands at, so `strict` catches it.

```ruby
class Flyer < Stationery::Document
  images max_ppi: 300, downscale: true   # document defaults; nil switches the check off
end

image "photo.png", width: 160                 # a 1600 px PNG is resampled to 667 px
image "photo.jpg", width: 160, max_ppi: nil   # this one is fine as it is
```

`downscale: true` resamples a **PNG** or a **WebP** to `max_ppi` at its drawn size with a box filter (alpha and palette transparency included) and embeds that; the layout, the tagged `Figure` and the text around it do not change. A **JPEG** is never re-encoded, so it only warns: size JPEGs before you embed them. With ActiveStorage, make a JPEG variant per drawn size (`resize_to_limit`, quality 75) and preprocess it, so a render only downloads. A 10-page flyer with 18 photos went from 3.3 MB to under 1 MB that way with no visible difference.

## SVG

Icons and simple drawings as vectors.

`svg(source_or_path, width: nil, height: nil, color: "#000000", align: nil)` takes SVG markup or a path to a `.svg` file. `currentColor` takes `color:`, so one icon file serves every theme colour.

```ruby
CHECK = <<~SVG
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
    <circle cx="12" cy="12" r="11" fill="currentColor"/>
    <path d="M7 12.5l3 3 7-7" fill="none" stroke="#fff" stroke-width="2.2"
          stroke-linecap="round" stroke-linejoin="round"/>
  </svg>
SVG

row { column(width: 16) { svg CHECK, width: 14, color: "#0F766E" }; column { text "Shipped" } }
```

**Supported:** `path` (every command, including arcs), `rect` (with `rx`/`ry`), `circle`, `ellipse`, `line`, `polyline`, `polygon` and `g`, with fill, stroke, line caps and joins, `fill-rule`, opacity, inline `style` attributes, `<style>` stylesheets (element, `.class`, `#id`, `element.class`, comma lists and `*` selectors; presentation attributes < rules by specificity < inline `style`; `display: none` and `visibility: hidden` skip elements), `transform` (`matrix`, `translate`, `scale`, `rotate`, `skewX`, `skewY`), `currentColor`, `linearGradient`/`radialGradient` fills (stops, `href` chains, both gradient units, `gradientTransform`), and `text`/`tspan` (`x`, `y`, `dx`, `dy`, `font-family`, `font-size`, `font-weight`, `font-style`, `text-anchor`, `fill`, `opacity`). SVG text uses the document's fonts: the first `font-family` the document knows (registered, bundled or an installed pack), otherwise its default family.

**Sprites and viewports:** `use` (`href` or `xlink:href`) draws a shape, a group, a text, a `symbol` or another `use` at its `x`/`y`, under its own `transform`; its presentation attributes are inherited by what it draws, where the referenced element's own attributes win. A `symbol` is drawn only through `use`, into a viewport of the use's `width`/`height` (the viewport around it without them): its `viewBox` is fitted by `preserveAspectRatio` (`none`, `xMin`/`xMid`/`xMax` with `YMin`/`YMid`/`YMax`, `meet` or `slice`) and what overflows is clipped unless `overflow="visible"`. A nested `svg` is the same viewport at its own `x`/`y`. The `color` property sets what `currentColor` means from an element down, so one symbol serves every colour:

```ruby
SPRITE = <<~SVG
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 24">
    <symbol id="check" viewBox="0 0 24 24">
      <path d="M4 12l6 6L20 6" fill="none" stroke="currentColor" stroke-width="2"/>
    </symbol>
    <use href="#check" width="24" height="24"/>
    <use href="#check" x="36" y="4" width="16" height="16" color="#16A34A"/>
  </svg>
SVG

svg SPRITE, width: 120, color: "#111827"
```

**Clipping:** `clip-path="url(#id)"` (attribute or style) on a shape, a group, a text or a `use` paints it inside the `clipPath`'s shapes: `path`, `rect`, `circle`, `ellipse`, `polygon`, `polyline` and `use` children that refer to one, each under its own `transform` and the clip path's, with `clip-rule`. `clipPathUnits="objectBoundingBox"` scales them to the box of the clipped element, and a `clip-path` on the `clipPath` itself intersects. The shapes join into one clipping path, so overlapping shapes wound in opposite directions cancel where they overlap; text inside a `clipPath` is skipped and reported.

Gradients are approximated in three ways: `reflect` and `repeat` spreads are drawn as `pad`, a gradient whose stops differ in opacity uses the first stop's for all of it, and a gradient stroke is drawn in the gradient's middle colour. Text glyphs stay upright: a transform moves the text's origin and scales its size uniformly, so rotated or skewed text is approximated, and `dominant-baseline` is ignored.

**Not supported:** `image`, `mask`, `pattern`, `filter`, `textPath`, and stylesheet rules with combinators (`g path`, `a > b`), which are ignored and reported. So are a `use` whose target is missing or that refers back to itself (`use: #id not found`, `use: circular reference #id`) and a `clip-path` without its `clipPath` (`clip-path: #id not found`, drawn unclipped).

## Warnings

Nothing is fetched, nothing silently vanishes.

- An SVG using elements that cannot be drawn (`mask`, `pattern`, …) still renders what it can; the elements are listed in `SVG::Document#unsupported` and reported as an `UnsupportedSvg` warning, as are references to missing gradients (`url(#id)`) and gradient spreads drawn as `pad`.
- In `html` and `markdown`, images come from `images:` or from files under `base_path:`. Sources that resolve nowhere, point outside `base_path` or are remote URLs are skipped with a `SkippedImage` warning. Nothing is ever fetched over the network.

See [Warnings and strict mode](https://stationery.zoolutions.llc/docs/warnings) to turn these into failures.