diff --git a/.github/workflows/coverage-main.yml b/.github/workflows/coverage-main.yml index 78d9910..e372a67 100644 --- a/.github/workflows/coverage-main.yml +++ b/.github/workflows/coverage-main.yml @@ -1,17 +1,22 @@ name: Coverage Main on: + push: + branches: [main] workflow_dispatch: jobs: coverage: runs-on: ubuntu-latest + permissions: + contents: write + steps: - uses: actions/checkout@v4 - name: Install libvips - run: sudo apt-get update && sudo apt-get install -y libvips + run: sudo apt-get update && sudo apt-get install -y libvips librsvg2-2 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -35,3 +40,43 @@ jobs: with: name: main-coverage-reports path: coverage/ + + - name: Generate coverage badge JSON + run: | + mkdir -p /tmp/badge + ruby <<'RUBY' > /tmp/badge/coverage.json + require "json" + data = JSON.parse(File.read("coverage/.last_run.json")) + line = data.dig("result", "line").to_f + color = + case line + when 90.. then "brightgreen" + when 75.. then "green" + when 60.. then "yellow" + when 40.. then "orange" + else "red" + end + puts JSON.generate( + schemaVersion: 1, + label: "coverage", + message: format("%.2f%%", line), + color: color + ) + RUBY + cat /tmp/badge/coverage.json + + - name: Publish badge to badges branch + if: github.ref == 'refs/heads/main' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -e + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + cd /tmp/badge + git init -q --initial-branch=badges + git add coverage.json + git commit -q -m "ci: update coverage badge [skip ci]" + git push --force --quiet \ + "https://x-access-token:${GH_TOKEN}@github.com/${{ github.repository }}.git" \ + badges:badges diff --git a/.rubocop.yml b/.rubocop.yml index 68c2ac7..245fa4a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.1 NewCops: enable SuggestExtensions: false Exclude: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c85403..d1ba5b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,62 @@ # Changelog +## Unreleased + +### Added +- Security warning in README clarifying that LogoSoup is intended for trusted + asset pipelines only — not for user uploads or other untrusted sources. +- `Dockerfile` for development and CI, with Ruby 3.3 + libvips + librsvg. +- Auto-updating coverage badge. The `Coverage Main` workflow now runs on + every push to `main`, generates a shields.io endpoint JSON from + `coverage/.last_run.json`, and publishes it to an orphan `badges` branch. + The README badge points to that file via `shields.io/endpoint`; no external + coverage service signup required. + +### Changed +- `LogoSoup.style` now warns to stderr when callers pass unknown option keys + (typos like `align:` instead of `align_by:`), surfacing what used to be a + silent footgun. +- `image_bytes:` that is neither a `String` nor an IO-like object now raises + `ArgumentError` instead of producing a cryptic libvips error. +- SVG bytes coming in via `image_bytes:` + `content_type: "image/svg+xml"` are + now safely transcoded to UTF-8 (replacing invalid sequences) instead of being + bare `force_encoding`'d, so SVGs with a BOM or in a non-UTF-8 encoding parse + without crashing. +- Internal refactor: replaced `Tempfile` round-trips with + `Vips::Image.new_from_buffer` for SVG and raster bytes; removed a duplicate + vips decode in `handle_image_path`; switched pixel loops from + `Array` (`String#bytes`) to binary `String` + `String#getbyte` to + reduce per-call allocations. +- Bumped `.rubocop.yml` `TargetRubyVersion` from 2.7 to 3.1 to match the + gemspec's `required_ruby_version`. + +### Fixed +- `SvgDimensions.numeric_dimension` no longer calls `String#blank?` from + ActiveSupport (a dev-only dep); replaced with stdlib `nil? || empty?`. The + previous code would raise `NoMethodError` at runtime for SVGs without a + viewBox. +- `ImageLoader.ensure_rgba_uchar` no longer crashes on a theoretical 0-band + image; behaviour for 0-band input now matches the original implementation + (no-op). + +## 0.1.3 + +- Use isotype logo for better light/dark mode README compatibility. +- Add Codeminer42 logo and reference to the original Logo Soup article. + +## 0.1.2 + +- Bump version (no behavioural change). +- Relax nokogiri version constraint to allow 1.19. + +## 0.1.1 + +- Rename gem entry point from `logo_soup` to `logosoup` and migrate lib paths. +- Pin explicit version constraints for `rake`, `rubocop-github`, and + `rubocop-performance`. + ## 0.1.0 -- Initial gem scaffold -- `LogoSoup.style` supports SVG strings and raster image paths +- Initial gem scaffold. +- `LogoSoup.style` supports SVG strings, raster image paths, and image bytes. +- Visual-center alignment, density-aware sizing, and graceful fallback. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2a61453 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Development image for LogoSoup. +# Provides Ruby + libvips + librsvg so contributors can run specs and develop +# without installing system dependencies locally. +# +# Build: docker build -t logosoup-dev . +# Test: docker run --rm -v "$PWD":/app logosoup-dev bundle exec rspec +# Shell: docker run --rm -it -v "$PWD":/app logosoup-dev + +FROM ruby:3.3-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + BUNDLE_PATH=/usr/local/bundle + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + libvips42 \ + libvips-dev \ + librsvg2-2 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Bake gem dependencies into the image. BUNDLE_PATH lives outside /app, so +# they survive when the working tree is bind-mounted over /app at runtime. +# version.rb is required because the gemspec loads it during install. +COPY Gemfile Gemfile.lock logosoup.gemspec ./ +COPY lib/logosoup/version.rb ./lib/logosoup/version.rb + +RUN bundle install + +CMD ["bash"] diff --git a/Gemfile.lock b/Gemfile.lock index c08173b..72ec3d3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,8 +1,8 @@ PATH remote: . specs: - logosoup (0.1.1) - nokogiri (>= 1.15, <= 1.19) + logosoup (0.1.3) + nokogiri (>= 1.15, < 2) ruby-vips (>= 2.2, < 3) GEM @@ -149,10 +149,10 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - activesupport (>= 6.1, < 7.1) + activesupport (>= 6.1, < 8) brakeman (~> 6.1) logosoup! - minitest (>= 5, < 5.26) + minitest (~> 5.1) rake (~> 13.0) rspec (~> 3.12) rubocop (~> 1.50) diff --git a/README.md b/README.md index e01724b..b0a0bf9 100644 --- a/README.md +++ b/README.md @@ -3,32 +3,142 @@ -[![Version](https://img.shields.io/badge/version-0.1.0-blue)](CHANGELOG.md) -[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE.txt) -[![Coverage](https://img.shields.io/badge/coverage-86.14%25-brightgreen)](coverage/index.html) +[![Version](https://img.shields.io/badge/version-0.1.3-blue)](CHANGELOG.md) +[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE) +[![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Codeminer42/ruby-logo-soup/badges/coverage.json)](https://github.com/Codeminer42/ruby-logo-soup/actions/workflows/coverage-main.yml) -Framework-agnostic Ruby gem for **normalizing logo rendering**. +**Make a row of mismatched logos look like they belong together.** -Given an input logo (SVG or raster), LogoSoup returns an **inline CSS style string** that you can apply to an `` (or equivalent) so different logos render with a consistent perceived size, with optional visual-center alignment. +LogoSoup is a framework-agnostic Ruby gem that turns any logo — SVG, PNG, JPG, +WebP, GIF, or TIFF — into an inline CSS string sized so the logo *looks* the +same visual weight as its neighbors, regardless of its intrinsic dimensions, +padding, or how dense the artwork is. -This gem is a Ruby port of the original [Logo Soup](https://github.com/auroris/logo-soup) React library, inspired by the article [The logo soup problem (and how to solve it)](https://www.sanity.io/blog/the-logo-soup-problem) by Rostislav Melkumyan. +```ruby +LogoSoup.style(svg: File.read("logo.svg"), base_size: 48) +# => "width: 48px; height: 48px; object-fit: contain; display: block; transform: translate(0px, -1.2px);" +``` + +LogoSoup handles: + +- **Aspect-ratio normalization** — a 200×100 logo and a 100×200 logo end up with comparable on-screen area, not the same bounding box. +- **Density-aware sizing** — sparse outline logos grow slightly; dense filled logos shrink slightly, so neither dominates. +- **Visual-center alignment** — computes a `translate()` so the logo's perceived center sits where its geometric box says it should, fixing top-heavy or bottom-heavy artwork. +- **Mixed input formats** — SVG strings, file paths, or in-memory bytes with a content type. +- **Graceful degradation** — when libvips can't decode the input, returns a sane fallback style instead of raising. + +This gem is a Ruby port of the original +[Logo Soup](https://github.com/auroris/logo-soup) React library, inspired by the +article +[The logo soup problem (and how to solve it)](https://www.sanity.io/blog/the-logo-soup-problem) +by Rostislav Melkumyan. + +## Security — trusted inputs only + +> [!WARNING] +> **LogoSoup is designed for logo assets you control.** Do **not** feed it +> images uploaded by end users, fetched from arbitrary URLs, or anything else +> originating from an untrusted source. + +LogoSoup invokes `libvips` and `Nokogiri` directly on the inputs you pass. +Neither call site enforces a maximum input size, maximum decoded pixel count, +strict MIME-type validation, or path-traversal protection — by design, so the +gem stays a thin, predictable wrapper over the underlying libraries. + +Concrete consequences of using LogoSoup on untrusted input: + +- **Decompression bombs.** A small malicious PNG, TIFF, or WebP can decode to + multiple gigabytes. `Vips::Image.new_from_buffer` / `new_from_file` will + decode the header (and depending on format, large portions of the body) + before LogoSoup has a chance to downsample. +- **SVG payloads.** SVG is XML. While the parser blocks network entity + resolution (`Nokogiri::XML(...) { |cfg| cfg.nonet }`), an attacker can still + ship arbitrarily large SVG strings or deeply nested structures that consume + CPU and memory. +- **Path handling.** `image_path:` is passed directly to libvips. If the + caller supplies a path derived from user input, traversal and arbitrary file + read by libvips become the caller's problem. + +**Recommended usage:** + +- Bundle logo SVGs and rasters as static assets in your repository, or pull + them from a trusted internal source (a CMS you operate, a logo vendor API + with allowlisted hosts, etc.). +- If you absolutely must accept user-supplied images, validate file size, + MIME type, and decoded pixel dimensions **before** calling LogoSoup, and run + the call inside an isolated process with a memory and CPU limit. + +For brand directories, marketing pages, partner showcases, design systems, +and "logo cloud" layouts populated from your own asset pipeline, LogoSoup is +exactly the right tool. For user-generated content, it is not. + +## Contents + +- [Security — trusted inputs only](#security--trusted-inputs-only) +- [Why](#why) +- [Installation](#installation) +- [Requirements](#requirements) +- [Quick start](#quick-start) +- [Inputs](#inputs) + - [SVG string](#svg-string) + - [Raster file path](#raster-file-path) + - [Raster bytes (IO or String)](#raster-bytes-io-or-string) +- [Options reference](#options-reference) + - [`base_size`](#base_size) + - [`scale_factor`](#scale_factor) + - [`density_aware` / `density_factor`](#density_aware--density_factor) + - [`align_by`](#align_by) + - [`contrast_threshold`](#contrast_threshold) + - [`pixel_budget`](#pixel_budget) + - [`on_error`](#on_error) +- [Output](#output) +- [Recipes](#recipes) + - [Plain ERB / HTML](#plain-erb--html) + - [Rails view helper](#rails-view-helper) + - [Phlex / ViewComponent](#phlex--viewcomponent) + - [Caching the style](#caching-the-style) +- [How it works](#how-it-works) + - [SVG pipeline](#svg-pipeline) + - [Raster pipeline](#raster-pipeline) + - [Sizing math](#sizing-math) + - [Visual-center math](#visual-center-math) +- [Performance](#performance) +- [Error handling](#error-handling) +- [Testing](#testing) +- [Development](#development) +- [Contributing](#contributing) +- [Changelog](#changelog) +- [License](#license) ## Why -Logos often have different intrinsic sizes, padding, and visual weight. If you render them at the same width/height, they still *look* inconsistent. +Logos arrive with wildly different intrinsic dimensions, padding, transparency, +and visual weight. If you drop them into a `` grid, +the result feels uneven: the airy circular logo looks tiny, the chunky +wordmark feels oversized, and a tall logo collides with the row above. -LogoSoup aims to: +LogoSoup tackles this with three complementary normalizations: -- Normalize sizing so logos look consistent at a given `base_size`. -- Optionally align by visual center (e.g., Y axis) for better baseline alignment. -- Stay framework-agnostic (no Rails dependencies). +1. **Sizing** — interpolate between "same width" and "same height" based on a + `scale_factor`, so the *area* a logo occupies stays roughly constant across + different aspect ratios. +2. **Density-aware correction** — analyze how much of the image is actual + artwork (versus background) and gently shrink dense logos or grow sparse + ones to equalize perceived weight. +3. **Visual centering** — compute the artwork's center of mass and emit a CSS + `transform: translate(...)` so off-center artwork aligns visually rather + than by its bounding box. + +The output is a plain CSS string, so you can drop it into any framework — ERB, +Phlex, ViewComponent, Hanami, Roda, Sinatra, raw Rack — without pulling in +template helpers. ## Installation Add this line to your application's Gemfile: ```ruby -gem 'logosoup' +gem "logosoup" ``` Then: @@ -45,127 +155,483 @@ bundle add logosoup ## Requirements -- Ruby: `>= 2.7`, `< 4.0` -- System dependency: **libvips** (required for raster analysis) +- **Ruby** `>= 3.1`, `< 4.0` +- **libvips** for raster analysis +- **librsvg** if you want raster feature measurement on SVG inputs (i.e. when + `align_by: "visual-center*"` is used with SVG; viewBox-only sizing does not + need it) -### Installing libvips +### Installing system dependencies -- macOS (Homebrew): +**macOS (Homebrew):** ```sh -brew install vips +brew install vips librsvg ``` -- Ubuntu/Debian: +**Ubuntu / Debian:** ```sh -sudo apt-get update && sudo apt-get install -y libvips +sudo apt-get update +sudo apt-get install -y libvips42 librsvg2-2 ``` -## Usage +**Alpine:** + +```sh +apk add vips librsvg +``` -LogoSoup exposes a single entrypoint: `LogoSoup.style`. +**Docker:** see [Development → Docker](#docker) — the bundled `Dockerfile` +already includes everything. -### SVG (string) +## Quick start ```ruby -style = LogoSoup.style( +require "logosoup" + +# SVG string +LogoSoup.style(svg: File.read("logo.svg"), base_size: 48) - svg: File.read('logo.svg'), +# Raster file +LogoSoup.style(image_path: "logo.png", base_size: 48) + +# Raster bytes (e.g. from an HTTP response or an upload) +LogoSoup.style( + image_bytes: response.body, + content_type: "image/webp", base_size: 48 ) +``` + +The return value is an inline CSS string ready to drop into a `style=""` +attribute: -# => "width: 48px; height: 48px; object-fit: contain; display: block; transform: translate(0px, 0px);" +```erb + ``` -### Raster image (file path) +## Inputs + +LogoSoup accepts exactly one of `svg:`, `image_path:`, or `image_bytes:` per +call. If none are given, you get the [fallback style](#error-handling). + +### SVG string ```ruby -style = LogoSoup.style( - image_path: 'logo.png', +LogoSoup.style( + svg: '...', base_size: 48 ) ``` -### Bytes (IO/String) +Intrinsic dimensions come from `viewBox`, then `width`/`height` attributes +(stripped of unit suffixes like `px`, `pt`, etc.). When the document has no +parseable dimensions, LogoSoup returns a square fallback at `base_size`. + +When `align_by:` is a visual-center mode, the SVG is also rasterized by +libvips (via librsvg) so pixel-based features can be measured. With +`align_by: "bounds"` (or any non-visual-center mode), no rasterization +happens — only the XML is parsed. + +### Raster file path ```ruby -bytes = File.binread('logo.webp') +LogoSoup.style(image_path: "/var/uploads/logo.png", base_size: 48) +``` + +Any format libvips can decode is supported: PNG, JPG/JPEG, WebP, GIF, TIFF, +HEIC, AVIF, ICO, BMP, and more depending on your libvips build. + +### Raster bytes (IO or String) -style = LogoSoup.style( - image_bytes: bytes, - content_type: 'image/webp', +```ruby +LogoSoup.style( + image_bytes: File.binread("logo.webp"), + content_type: "image/webp", base_size: 48 ) ``` -If `content_type` includes `svg` (e.g. `image/svg+xml`), `image_bytes:` is treated as SVG and handled by the SVG pipeline. +`image_bytes:` accepts a binary `String` or any IO that responds to `#read`. +If `content_type` contains the substring `svg` (e.g. `image/svg+xml`), the +bytes are routed through the SVG pipeline instead. Otherwise the bytes are +handed directly to libvips's buffer loader — no temp file is created. + +## Options reference + +All options below can be passed to `LogoSoup.style`. Defaults live in +[`LogoSoup::Style::DEFAULTS`](lib/logosoup/style.rb). + +### `base_size` -## API +**Required.** Target rendering size in pixels (Integer or Numeric). -### `LogoSoup.style` +This is both the normalization target *and* the fallback size — when LogoSoup +can't read the input, you get `width: base_size; height: base_size;`. ```ruby -LogoSoup.style( - svg: nil, - image_path: nil, - image_bytes: nil, - content_type: nil, - base_size:, - on_error: nil, - **options -) +LogoSoup.style(svg: svg, base_size: 32) # small badge +LogoSoup.style(svg: svg, base_size: 64) # default-ish +LogoSoup.style(svg: svg, base_size: 128) # hero ``` -#### Inputs (choose one) +### `scale_factor` + +**Default:** `0.5` — Float in `[0.0, 1.0]`. -- `svg:` String containing SVG XML -- `image_path:` filesystem path to an image (PNG/JPG/WebP/GIF/TIFF, etc.) -- `image_bytes:` String/IO of image bytes +Interpolates between "same width" and "same height" for logos of different +aspect ratios: -#### Required +| `scale_factor` | Effect | Description | +| --- | --- | --- | +| `0.0` | Same width | Every logo gets `width = base_size`; height varies. | +| `0.5` | Same area *(default)* | Width and height scale with `√aspect` so the on-screen area stays roughly constant. | +| `1.0` | Same height | Every logo gets `height = base_size`; width varies. | -- `base_size:` Integer (pixels). Used as the normalization target and also as fallback width/height. +For a wall of logos, `0.5` gives the most uniform visual impression. For a +strict grid where horizontal alignment matters more than area, use `0.0`. For +a row where vertical rhythm matters, use `1.0`. -#### Error handling +```ruby +LogoSoup.style(svg: svg, base_size: 48, scale_factor: 0.0) # width-locked +LogoSoup.style(svg: svg, base_size: 48, scale_factor: 0.5) # area-locked +LogoSoup.style(svg: svg, base_size: 48, scale_factor: 1.0) # height-locked +``` -- `on_error: nil` (default): return a fallback style (`width/height = base_size`, no transform) -- `on_error: :raise`: re-raise the original exception +### `density_aware` / `density_factor` -#### Options (with defaults) +**Defaults:** `density_aware: true`, `density_factor: 0.5`. + +When enabled, LogoSoup measures how much of the image is real artwork (as +opposed to background or transparent pixels) and applies a multiplicative +scale that's clamped to **`[0.5, 2.0]`** to avoid extreme corrections. + +- Dense logos (lots of filled pixels) → shrunk slightly so they don't + dominate. +- Sparse logos (outline icons, lots of whitespace) → grown slightly so they + read at comparable weight. + +The strength of the correction is controlled by `density_factor`: + +- `0.0` — no correction (equivalent to `density_aware: false`). +- `0.5` — half-strength (default; gentle nudge). +- `1.0` — full-strength. + +```ruby +# Disable density correction entirely: +LogoSoup.style(svg: svg, base_size: 48, density_aware: false) + +# Or via factor: +LogoSoup.style(svg: svg, base_size: 48, density_factor: 0.0) + +# Stronger correction (good when the input mix is wildly inconsistent): +LogoSoup.style(svg: svg, base_size: 48, density_factor: 1.0) +``` -These map directly to `LogoSoup::Style::DEFAULTS`: +The reference density is calibrated so a "typical" logo (~35 % artwork +coverage at full opacity) gets no correction. -- `scale_factor:` `0.5` -- `density_aware:` `true` -- `density_factor:` `0.5` -- `contrast_threshold:` `10` -- `align_by:` `'visual-center-y'` -- `pixel_budget:` `2048` +### `align_by` -Notes: +**Default:** `"visual-center-y"`. -- Raster images are analyzed with libvips to estimate features (e.g. pixel density / content box / visual center offsets) that inform sizing and transforms. -- For SVG input, LogoSoup currently uses intrinsic SVG dimensions and skips raster feature measurement. +Controls whether and how LogoSoup emits a `transform: translate(...)` to +correct artwork that's off-center within its bounding box. + +| Value | Effect | +| --- | --- | +| `"visual-center-y"` *(default)* | Translate vertically only. Fixes top-heavy / bottom-heavy logos in a horizontal row. | +| `"visual-center-x"` | Translate horizontally only. Useful when logos are stacked vertically. | +| `"visual-center"` | Translate on both axes. | +| `"bounds"` | No transform. Logos are aligned by their bounding box. | +| `""` (empty), `nil` | Same as `"bounds"` — no transform. | + +The translate is computed from a **weighted center of mass** of the artwork +(see [Visual-center math](#visual-center-math)) and scaled to the final +rendered size. If the artwork is already centered (the offset rounds to zero), +no `transform:` is emitted at all. + +```ruby +LogoSoup.style(svg: svg, base_size: 48, align_by: "visual-center-y") # default +LogoSoup.style(svg: svg, base_size: 48, align_by: "visual-center") # both axes +LogoSoup.style(svg: svg, base_size: 48, align_by: "bounds") # no transform +``` + +### `contrast_threshold` + +**Default:** `10` — Integer in `[0, 255]`. + +When detecting the artwork's content box and visual center, pixels are +classified as "background" or "artwork" by comparing them to the detected +background color. `contrast_threshold` is the per-channel distance below which +a pixel counts as background. + +- Lower (e.g. `5`) — more sensitive; counts faint anti-aliased edges as + artwork. Use for logos with subtle gradients. +- Higher (e.g. `30`) — more tolerant; ignores noisy borders, JPEG halos, or + near-white backgrounds. Use for logos rasterized poorly. + +For images with an alpha channel, alpha is used directly and this threshold +applies to the alpha cutoff instead of RGB distance. + +```ruby +LogoSoup.style(image_path: "noisy.jpg", base_size: 48, contrast_threshold: 30) +``` + +### `pixel_budget` + +**Default:** `2048` — Integer (max pixels sampled per raster analysis). + +Raster analysis is performed on a downsampled copy of the input. The downsample +target is whichever resize keeps the total pixel count at or below +`pixel_budget`. Higher budgets give marginally more accurate density and +center-of-mass estimates, at proportional CPU and memory cost. + +For most logo-grid use cases, the default 2048 (≈ 45×45) is plenty. + +```ruby +LogoSoup.style(image_path: "hero.png", base_size: 256, pixel_budget: 8192) +``` + +### `on_error` + +**Default:** `nil` — `nil` or `:raise`. + +- `nil` *(default)* — any exception during analysis is swallowed and a + fallback style is returned (`width: base_size; height: base_size; ...` with + no transform). Suitable for user-facing rendering paths where a broken logo + shouldn't break the page. +- `:raise` — re-raise the original exception. Suitable for ingestion + pipelines, tests, or anywhere you'd rather see the failure. + +```ruby +# Default: never raises, returns a usable style even for garbage input. +LogoSoup.style(svg: "", base_size: 48) +# => "width: 48px; height: 48px; object-fit: contain; display: block;" + +# Strict mode: +LogoSoup.style(svg: "", base_size: 48, on_error: :raise) +# raises whatever Nokogiri / libvips threw +``` ## Output -The return value is a single inline CSS string including (at least): +The return value is always a single inline CSS string of the form: + +``` +width: ...px; height: ...px; object-fit: contain; display: block; transform: translate(...); +``` + +The `transform:` property is omitted when the computed offset is zero or when +`align_by` disables it. `object-fit: contain` and `display: block` are always +emitted, so the same string works on ``, ``, `<%= logo.name %> +``` + +### Rails view helper + +```ruby +# app/helpers/logo_helper.rb +module LogoHelper + def normalized_logo_tag(logo, size: 48, **options) + style = LogoSoup.style( + svg: logo.svg_content, + base_size: size, + **options + ) + image_tag(logo.url, alt: logo.name, style: style) + end +end +``` + +```erb +<%= normalized_logo_tag(@logo, size: 64, align_by: "visual-center") %> +``` + +### Phlex / ViewComponent + +```ruby +class LogoComponent < Phlex::HTML + def initialize(logo, size: 48) + @logo = logo + @style = LogoSoup.style(svg: logo.svg, base_size: size) + end + + def view_template + img(src: @logo.url, alt: @logo.name, style: @style) + end +end +``` + +### Caching the style + +LogoSoup recomputes from scratch on every call. For high-traffic pages, cache +the result keyed by `(logo_id, base_size, options)`: -- `width: ...px;` -- `height: ...px;` -- `object-fit: contain;` -- `display: block;` -- `transform: ...` (only when alignment produces a non-nil transform) +```ruby +Rails.cache.fetch(["logosoup", logo.cache_key, base_size, options], expires_in: 1.week) do + LogoSoup.style(svg: logo.svg, base_size: base_size, **options) +end +``` + +Since the output is a small string, the cache footprint is negligible compared +to the cost of rasterizing the logo each time. + +## How it works + +### SVG pipeline + +1. **Parse the XML** with Nokogiri to extract intrinsic dimensions from + `viewBox` (preferred) or `width`/`height`. +2. **If a visual-center mode is requested**, rasterize the SVG via libvips + (`new_from_buffer`, no temp file) and run it through the raster pipeline to + estimate density, content box, and center-of-mass. +3. **Scale the raster-derived features** back into the SVG's intrinsic + coordinate space, so the transform reads in the user's logical pixels. + +### Raster pipeline + +1. **Decode** with libvips (from path or in-memory buffer). +2. **Downsample** so total pixels ≤ `pixel_budget`, then normalize to 4-band + RGBA uchar. +3. **Detect background.** Sample perimeter pixels and bucket them into a + coarse RGB histogram (3-bit-per-channel quantization, 512 buckets total). + If more than 10 % of the perimeter is transparent (alpha < 128), treat the + image as having an alpha-only background. Otherwise pick the most-populous + color bucket as the background RGB. +4. **Measure features.** Iterate every sampled pixel: + - For alpha-only images, weight by `alpha²`. + - Otherwise, treat each pixel's RGB distance from the background (and + `contrast_threshold` floor) as its weight. + - Accumulate weighted center of mass, content bounding box, and average + opacity. From these derive **pixel density** = coverage × average + opacity. +5. **Return** density, content box, and visual-center offsets in source + coordinates. + +### Sizing math + +For intrinsic dimensions `(w, h)` and target `base`: + +``` +aspect = w / h +normalized_width = aspect^scale_factor * base +normalized_height = normalized_width / aspect +``` + +When density-aware: + +``` +density_ratio = measured_density / 0.35 # 0.35 is the reference density +density_scale = (1 / density_ratio) ^ (density_factor * 0.5) +density_scale = density_scale.clamp(0.5, 2.0) +normalized_width *= density_scale +normalized_height *= density_scale +``` -This is designed to be applied directly to an `` tag or any element that supports these properties. +Both dimensions are finally rounded to whole pixels. + +### Visual-center math + +The visual-center pipeline returns a `(offset_x, offset_y)` pair in the source +image's coordinate space — the vector pointing from the content box's +*geometric* center to its *weighted* center of mass. To produce a CSS +transform, LogoSoup scales the offset to the rendered size: + +``` +scale_x = normalized_width / content_box_width +scale_y = normalized_height / content_box_height +translate(-offset_x * scale_x px, -offset_y * scale_y px) +``` + +Concretely: a logo with ink biased toward the top of its bounding box +produces a positive `Y` translate, which slides the rendered image *down* so +its ink sits at the bounding box's geometric center — i.e. where a viewer +expects the visual center to be. Ink biased downward produces a negative +`Y` translate. For axis-restricted modes (`"visual-center-x"` / +`"visual-center-y"`), the other axis is zeroed before the translate is +emitted. + +## Performance + +A typical `LogoSoup.style` call costs: + +- **SVG (no visual-center)**: one Nokogiri XML parse. Microseconds. +- **SVG (with visual-center)**: one libvips SVG decode + one rasterization + + one pixel-budget sweep. Sub-millisecond to low single-digit milliseconds. +- **Raster path/bytes**: one libvips decode + downsample + pixel-budget sweep. + Similar to the SVG case. + +Knobs you have: + +- Lower `pixel_budget` for cheaper analysis. The default 2048 is tuned for + accuracy; halving it roughly halves the pixel-sweep cost (libvips decode + and overhead aside) and produces nearly identical results for typical + logos. Measure before tuning. +- Set `align_by: "bounds"` to skip raster feature measurement entirely on the + SVG path — useful when you only need normalized sizing. +- [Cache the output](#caching-the-style) for hot paths. + +Memory-wise, LogoSoup keeps the downsampled pixel buffer as a binary `String` +(no intermediate `Array` materialization) and reads pixels via +`String#getbyte`, so per-call memory is bounded by `pixel_budget * 4 bytes` +plus libvips internals. + +## Error handling + +By default, every failure inside `LogoSoup.style` produces a fallback style +instead of raising: + +```ruby +LogoSoup.style(svg: nil, base_size: 48) +# => "width: 48px; height: 48px; object-fit: contain; display: block;" + +LogoSoup.style(image_path: "/does/not/exist.png", base_size: 48) +# => "width: 48px; height: 48px; object-fit: contain; display: block;" + +LogoSoup.style(svg: '', base_size: 48) +# => "width: 48px; height: 48px; object-fit: contain; display: block;" +``` + +For strict environments (background jobs, validators, tests), opt into the +raising mode: + +```ruby +LogoSoup.style(image_path: "/does/not/exist.png", base_size: 48, on_error: :raise) +# raises Vips::Error +``` + +The fallback is intentionally minimal — it never includes a `transform:` — +so the page renders the logo at its requested size even when LogoSoup's +analysis can't run. ## Testing -Run the test suite: +Run the full check suite (specs + RuboCop + Brakeman): + +```sh +bundle exec rake +``` + +Or just RSpec: ```sh bundle exec rake spec +# or +bundle exec rspec ``` ### Coverage @@ -176,24 +642,96 @@ Generate a local coverage report: bundle exec rake spec:coverage ``` -This writes HTML reports to `coverage/index.html`. +This writes HTML reports to `coverage/index.html` and an LCOV file at +`coverage/lcov.info`. -The badge at the top of this README reflects the last recorded SimpleCov result in `coverage/.last_run.json` (current line coverage: **86.14%**; branch coverage in that file: **53.17%**). +The coverage badge at the top of this README is generated automatically by the +[`Coverage Main`](.github/workflows/coverage-main.yml) GitHub Actions workflow +on every push to `main`. The workflow runs the suite with SimpleCov enabled, +extracts the line-coverage percentage from `coverage/.last_run.json`, and +publishes a shields.io endpoint JSON to an orphan `badges` branch. The README +badge URL points to that file via `shields.io/endpoint`, so it always reflects +the latest measurement without any external service signup. ## Development +### Local + ```sh bundle install bundle exec rake spec ``` +You'll need libvips and (for the SVG specs) librsvg installed locally — +see [Requirements](#requirements). + +### Docker + +A `Dockerfile` is provided so you can develop and run the test suite without +installing Ruby or libvips locally. The image bundles Ruby 3.3, libvips, and +librsvg. + +Build the image once: + +```sh +docker build -t logosoup-dev . +``` + +Run the test suite: + +```sh +docker run --rm -v "$PWD":/app logosoup-dev bundle exec rspec +``` + +Run all default checks (specs + RuboCop + Brakeman): + +```sh +docker run --rm -v "$PWD":/app logosoup-dev bundle exec rake +``` + +Open an interactive shell (irb, individual specs, ad-hoc commands): + +```sh +docker run --rm -it -v "$PWD":/app logosoup-dev +``` + +Gems are baked into the image at build time, so a fresh `docker run` doesn't +need to `bundle install`. Rebuild the image (`docker build -t logosoup-dev .`) +whenever `Gemfile` or `Gemfile.lock` changes. + +### Project layout + +``` +lib/logosoup.rb # public entry point: LogoSoup.style +lib/logosoup/style.rb # input dispatch + option plumbing +lib/logosoup/core/ + css.rb # tiny CSS string formatter + svg_dimensions.rb # viewBox / width / height parsing + dimension_calculator.rb # aspect + density sizing math + visual_center_transform.rb # translate(...) emission + image_loader.rb # vips decode + downsample + RGBA normalize + background_detector.rb # perimeter histogram → bg color / alpha mode + pixel_analyzer.rb # weighted center of mass + content box + feature_measurer.rb # orchestrates the raster pipeline +spec/ # RSpec tests +script/coverage_diff.rb # CI helper: PR vs main coverage delta +``` + ## Contributing -Bug reports and pull requests are welcome. +Bug reports and pull requests are welcome on GitHub at +. + +- Keep changes focused. One concern per PR. +- Add or update specs for behavior changes. +- Update `CHANGELOG.md` for user-visible changes. +- Run `bundle exec rake` before opening a PR — RuboCop and Brakeman are part + of CI. + +## Changelog -- Keep changes focused and add specs where it makes sense. -- If you change behavior, update `CHANGELOG.md` +See [`CHANGELOG.md`](CHANGELOG.md). ## License -Released under the MIT License. See `LICENSE.txt`. +Released under the MIT License. See [`LICENSE`](LICENSE). diff --git a/lib/logosoup.rb b/lib/logosoup.rb index 7a99fb7..f45f40a 100644 --- a/lib/logosoup.rb +++ b/lib/logosoup.rb @@ -15,9 +15,9 @@ require_relative "logosoup/style" module LogoSoup - class Error < StandardError; end + class Error < StandardError; end - def self.style(**kwargs) - Style.call(**kwargs) - end + def self.style(**kwargs) + Style.call(**kwargs) + end end diff --git a/lib/logosoup/core/background_detector.rb b/lib/logosoup/core/background_detector.rb index 0816baf..13692db 100644 --- a/lib/logosoup/core/background_detector.rb +++ b/lib/logosoup/core/background_detector.rb @@ -9,7 +9,7 @@ class BackgroundDetector ALPHA_TRANSPARENCY_THRESHOLD = 128 TRANSPARENT_PERIMETER_RATIO_THRESHOLD = 0.1 - # @param bytes [Array] RGBA bytes + # @param bytes [String] binary RGBA bytes # @param width [Integer] # @param height [Integer] # @return [Array(Boolean, Integer, Integer, Integer)] [alpha_only, bg_r, bg_g, bg_b] @@ -27,7 +27,7 @@ def self.call(bytes, width, height) sample = lambda do |x, y| idx = ((y * width) + x) * 4 - a = bytes[idx + 3] + a = bytes.getbyte(idx + 3) return if a.nil? if a < ALPHA_TRANSPARENCY_THRESHOLD @@ -36,9 +36,9 @@ def self.call(bytes, width, height) end opaque_count += 1 - r = bytes[idx] - g = bytes[idx + 1] - b = bytes[idx + 2] + r = bytes.getbyte(idx) + g = bytes.getbyte(idx + 1) + b = bytes.getbyte(idx + 2) key = ((((r >> QUANTIZATION_SHIFT) * levels) + (g >> QUANTIZATION_SHIFT)) * levels) + (b >> QUANTIZATION_SHIFT) bucket_counts[key] += 1 @@ -61,14 +61,7 @@ def self.call(bytes, width, height) transparent = total_perimeter.positive? && transparent_count > total_perimeter * TRANSPARENT_PERIMETER_RATIO_THRESHOLD return [true, 0, 0, 0] if transparent - best_idx = 0 - best_count = 0 - bucket_counts.each_with_index do |count, i| - next unless count > best_count - - best_count = count - best_idx = i - end + best_count, best_idx = bucket_counts.each_with_index.max_by { |count, _| count } if best_count.positive? bg_r = (bucket_r[best_idx].to_f / best_count).round diff --git a/lib/logosoup/core/css.rb b/lib/logosoup/core/css.rb index c5561da..83db59c 100644 --- a/lib/logosoup/core/css.rb +++ b/lib/logosoup/core/css.rb @@ -16,11 +16,10 @@ def fmt_tenth_px(value) rounded.to_i == rounded ? rounded.to_i.to_s : rounded.to_s end - # Builds an inline style string from a hash. + # Builds an inline style string from keyword arguments. # - # @param styles [Hash{Symbol=>String,nil}] # @return [String] - def style_string(styles) + def style_string(**styles) styles.compact.map { |key, val| "#{key.to_s.tr('_', '-')}: #{val};" }.join(" ") end end diff --git a/lib/logosoup/core/dimension_calculator.rb b/lib/logosoup/core/dimension_calculator.rb index de39035..0400781 100644 --- a/lib/logosoup/core/dimension_calculator.rb +++ b/lib/logosoup/core/dimension_calculator.rb @@ -30,8 +30,7 @@ def self.call(width:, height:, base_size:, scale_factor:, density_factor: 0.0, p if df.positive? && pixel_density density_ratio = pixel_density.to_f / REFERENCE_DENSITY if density_ratio.positive? - density_scale = (1.0 / density_ratio)**(df * 0.5) - clamped_scale = [[density_scale, MAX_DENSITY_SCALE].min, MIN_DENSITY_SCALE].max + clamped_scale = ((1.0 / density_ratio)**(df * 0.5)).clamp(MIN_DENSITY_SCALE, MAX_DENSITY_SCALE) normalized_width *= clamped_scale normalized_height *= clamped_scale end diff --git a/lib/logosoup/core/feature_measurer.rb b/lib/logosoup/core/feature_measurer.rb index f3d1d49..e87ad5b 100644 --- a/lib/logosoup/core/feature_measurer.rb +++ b/lib/logosoup/core/feature_measurer.rb @@ -10,18 +10,19 @@ module Core class FeatureMeasurer DEFAULT_PIXEL_BUDGET = 2_048 - # @param path [String] + # @param path [String, nil] file path; mutually exclusive with buffer + # @param buffer [String, nil] binary image bytes; mutually exclusive with path # @param contrast_threshold [Integer] # @param pixel_budget [Integer] # @param on_error [:raise, nil] - # @return [Hash] - def self.call(path:, contrast_threshold:, pixel_budget: DEFAULT_PIXEL_BUDGET, on_error: nil) - payload = ImageLoader.call(path: path, pixel_budget: pixel_budget, on_error: on_error) + # @return [Hash] features plus :source_width / :source_height + def self.call(contrast_threshold:, path: nil, buffer: nil, pixel_budget: DEFAULT_PIXEL_BUDGET, on_error: nil) + payload = ImageLoader.call(path: path, buffer: buffer, pixel_budget: pixel_budget, on_error: on_error) bytes = payload.fetch(:bytes) sample_width = payload.fetch(:sample_width) sample_height = payload.fetch(:sample_height) - original_width = payload.fetch(:original_width) - original_height = payload.fetch(:original_height) + source_width = payload.fetch(:original_width) + source_height = payload.fetch(:original_height) alpha_only, bg_r, bg_g, bg_b = BackgroundDetector.call(bytes, sample_width, sample_height) @@ -29,8 +30,8 @@ def self.call(path:, contrast_threshold:, pixel_budget: DEFAULT_PIXEL_BUDGET, on bytes: bytes, sample_width: sample_width, sample_height: sample_height, - original_width: original_width, - original_height: original_height, + original_width: source_width, + original_height: source_height, contrast_threshold: contrast_threshold, alpha_only: alpha_only, bg_r: bg_r, @@ -38,7 +39,8 @@ def self.call(path:, contrast_threshold:, pixel_budget: DEFAULT_PIXEL_BUDGET, on bg_b: bg_b ) - measured || default_features(original_width, original_height) + features = measured || default_features(source_width, source_height) + features.merge(source_width: source_width, source_height: source_height) end def self.default_features(w, h) diff --git a/lib/logosoup/core/image_loader.rb b/lib/logosoup/core/image_loader.rb index dc5a288..af4d1c8 100644 --- a/lib/logosoup/core/image_loader.rb +++ b/lib/logosoup/core/image_loader.rb @@ -8,19 +8,25 @@ module Core class ImageLoader RGBA_CHANNELS = 4 - # @param path [String] + # @param path [String, nil] file path; mutually exclusive with buffer + # @param buffer [String, nil] binary image bytes; mutually exclusive with path # @param pixel_budget [Integer] # @param on_error [:raise, nil] # @return [Hash] - def self.call(path:, pixel_budget:, on_error: nil) - image = Vips::Image.new_from_file(path, access: :sequential) + def self.call(pixel_budget:, path: nil, buffer: nil, on_error: nil) + image = + if buffer + Vips::Image.new_from_buffer(buffer, "", access: :sequential) + else + Vips::Image.new_from_file(path, access: :sequential) + end original_width = image.width original_height = image.height sample_width, sample_height, image_small = downsample(image, pixel_budget: pixel_budget) rgba = ensure_rgba_uchar(image_small, on_error: on_error) - bytes = rgba.write_to_memory.bytes + bytes = rgba.write_to_memory raise "Empty image bytes" if bytes.empty? { @@ -65,22 +71,22 @@ def self.ensure_rgba_uchar(image, on_error: nil) img = img.cast("uchar") - if img.bands > RGBA_CHANNELS - img = img.extract_band(0, n: RGBA_CHANNELS) - elsif img.bands == 3 - img = img.bandjoin(255) - elsif img.bands == 2 + case img.bands + when 0 + img + when 1 + img.bandjoin(img).bandjoin(img).bandjoin(255) + when 2 gray = img.extract_band(0) alpha = img.extract_band(1) - rgb = gray.bandjoin(gray).bandjoin(gray) - img = rgb.bandjoin(alpha) - elsif img.bands == 1 - gray = img - rgb = gray.bandjoin(gray).bandjoin(gray) - img = rgb.bandjoin(255) + gray.bandjoin(gray).bandjoin(gray).bandjoin(alpha) + when 3 + img.bandjoin(255) + when RGBA_CHANNELS + img + else + img.extract_band(0, n: RGBA_CHANNELS) end - - img end def self.vips_error?(error) diff --git a/lib/logosoup/core/pixel_analyzer.rb b/lib/logosoup/core/pixel_analyzer.rb index 399ce5e..052241a 100644 --- a/lib/logosoup/core/pixel_analyzer.rb +++ b/lib/logosoup/core/pixel_analyzer.rb @@ -38,10 +38,10 @@ def self.call( pixel_count = sw * sh pixel_count.times do |i| base = i * 4 - r = bytes[base] - g = bytes[base + 1] - b = bytes[base + 2] - a = bytes[base + 3] + r = bytes.getbyte(base) + g = bytes.getbyte(base + 1) + b = bytes.getbyte(base + 2) + a = bytes.getbyte(base + 3) next if a.nil? || a <= contrast_threshold if alpha_only @@ -59,7 +59,7 @@ def self.call( end x = i % sw - y = (i - x) / sw + y = i / sw min_x = x if x < min_x max_x = x if x > max_x @@ -88,10 +88,10 @@ def self.call( cb_x = (min_x * scale_x).floor cb_y = (min_y * scale_y).floor - cb_right = [[((max_x + 1) * scale_x).ceil.to_i, w].min, 0].max - cb_bottom = [[((max_y + 1) * scale_y).ceil.to_i, h].min, 0].max - content_box_width = [[cb_right - cb_x, 1].max, w].min - content_box_height = [[cb_bottom - cb_y, 1].max, h].min + cb_right = ((max_x + 1) * scale_x).ceil.to_i.clamp(0, w) + cb_bottom = ((max_y + 1) * scale_y).ceil.to_i.clamp(0, h) + content_box_width = (cb_right - cb_x).clamp(1, w) + content_box_height = (cb_bottom - cb_y).clamp(1, h) if total_weight <= 0 offset_x = 0.0 diff --git a/lib/logosoup/core/svg_dimensions.rb b/lib/logosoup/core/svg_dimensions.rb index ce4ad32..a7b2420 100644 --- a/lib/logosoup/core/svg_dimensions.rb +++ b/lib/logosoup/core/svg_dimensions.rb @@ -39,7 +39,7 @@ def self.numeric_dimension(value, on_error: nil) return nil if value.nil? num = value.to_s.strip[/[-+]?\d*\.?\d+/, 0] - return nil if num.blank? + return nil if num.nil? || num.empty? Float(num) rescue ArgumentError, RangeError => e diff --git a/lib/logosoup/core/visual_center_transform.rb b/lib/logosoup/core/visual_center_transform.rb index d10fbbd..adfad58 100644 --- a/lib/logosoup/core/visual_center_transform.rb +++ b/lib/logosoup/core/visual_center_transform.rb @@ -6,6 +6,15 @@ module LogoSoup module Core # Computes a CSS translate() transform to align by visual center. class VisualCenterTransform + MODE_BOUNDS = "bounds" + MODES_VC_X = %w[visual-center visual-center-x].freeze + MODES_VC_Y = %w[visual-center visual-center-y].freeze + MODES_VC_ANY = (MODES_VC_X | MODES_VC_Y).freeze + + def self.visual_center?(align_by) + MODES_VC_ANY.include?(align_by.to_s.strip) + end + # @param features [Hash] # @param normalized_width [Numeric] # @param normalized_height [Numeric] @@ -15,7 +24,7 @@ class VisualCenterTransform # @return [String, nil] def self.call(features:, normalized_width:, normalized_height:, align_by:, intrinsic_width:, intrinsic_height:) mode = align_by.to_s.strip - return nil if mode.empty? || mode == "bounds" + return nil if mode.empty? || mode == MODE_BOUNDS offset_x = features[:visual_center_offset_x] offset_y = features[:visual_center_offset_y] @@ -30,8 +39,8 @@ def self.call(features:, normalized_width:, normalized_height:, align_by:, intri scale_x = normalized_width.to_f / content_w scale_y = normalized_height.to_f / content_h - dx = %w[visual-center visual-center-x].include?(mode) ? (-offset_x.to_f * scale_x) : 0.0 - dy = %w[visual-center visual-center-y].include?(mode) ? (-offset_y.to_f * scale_y) : 0.0 + dx = MODES_VC_X.include?(mode) ? (-offset_x.to_f * scale_x) : 0.0 + dy = MODES_VC_Y.include?(mode) ? (-offset_y.to_f * scale_y) : 0.0 dx_fmt = Css.fmt_tenth_px(dx) dy_fmt = Css.fmt_tenth_px(dy) diff --git a/lib/logosoup/style.rb b/lib/logosoup/style.rb index 483b710..644aea9 100644 --- a/lib/logosoup/style.rb +++ b/lib/logosoup/style.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "tempfile" require "vips" module LogoSoup @@ -12,7 +11,7 @@ class Style density_factor: 0.5, contrast_threshold: 10, align_by: "visual-center-y", - pixel_budget: 2_048 + pixel_budget: Core::FeatureMeasurer::DEFAULT_PIXEL_BUDGET }.freeze # @param on_error [:raise, nil] error handling strategy @@ -20,6 +19,7 @@ class Style # - :raise: re-raise the original exception # @return [String] inline CSS style def self.call(base_size:, svg: nil, image_path: nil, image_bytes: nil, content_type: nil, on_error: nil, **options) + warn_unknown_options(options) opts = DEFAULTS.merge(options).merge(base_size: base_size) if svg @@ -35,11 +35,19 @@ def self.call(base_size:, svg: nil, image_path: nil, image_bytes: nil, content_t handle_error(e, opts: opts, on_error: on_error) end + def self.warn_unknown_options(options) + unknown = options.keys - DEFAULTS.keys + return if unknown.empty? + + warn "[LogoSoup] ignoring unknown option(s): #{unknown.join(', ')} " \ + "(known: #{DEFAULTS.keys.join(', ')})" + end + def self.handle_svg(svg_string, opts:, on_error:) intrinsic_w, intrinsic_h = Core::SvgDimensions.call(svg_string, on_error: on_error) || [0.0, 0.0] features = - if wants_visual_center?(opts.fetch(:align_by, nil)) + if Core::VisualCenterTransform.visual_center?(opts.fetch(:align_by, nil)) measure_svg_features(svg_string, intrinsic_width: intrinsic_w, intrinsic_height: intrinsic_h, opts: opts, on_error: on_error) else empty_features @@ -54,48 +62,44 @@ def self.handle_svg(svg_string, opts:, on_error:) end def self.handle_image_path(image_path, opts:, on_error:) - # Raster analysis is required; libvips must be installed. - image = Vips::Image.new_from_file(image_path, access: :sequential) - intrinsic_w = image.width - intrinsic_h = image.height + analyze_raster(path: image_path, opts: opts, on_error: on_error) + end + + def self.handle_image_bytes(image_bytes, content_type:, opts:, on_error:) + bytes = image_bytes.respond_to?(:read) ? image_bytes.read : image_bytes + unless bytes.is_a?(String) + raise ArgumentError, "image_bytes must be a String or an IO-like object (got #{bytes.class})" + end + if content_type.to_s.include?("svg") + return handle_svg(coerce_to_utf8(bytes), opts: opts, on_error: on_error) + end + + analyze_raster(buffer: bytes, opts: opts, on_error: on_error) + end + + def self.coerce_to_utf8(bytes) + bytes.dup.force_encoding(Encoding::BINARY) + .encode(Encoding::UTF_8, invalid: :replace, undef: :replace) + end + + def self.analyze_raster(opts:, on_error:, path: nil, buffer: nil) features = Core::FeatureMeasurer.call( - path: image_path, + path: path, + buffer: buffer, contrast_threshold: opts.fetch(:contrast_threshold).to_i, pixel_budget: opts.fetch(:pixel_budget).to_i, on_error: on_error ) build_style( - intrinsic_width: intrinsic_w, - intrinsic_height: intrinsic_h, + intrinsic_width: features[:source_width], + intrinsic_height: features[:source_height], features: features, **opts ) end - def self.handle_image_bytes(image_bytes, content_type:, opts:, on_error:) - bytes = image_bytes.respond_to?(:read) ? image_bytes.read : image_bytes - bytes = bytes.to_s - - if content_type.to_s.include?("svg") - svg_string = bytes.dup.force_encoding("UTF-8") - return handle_svg(svg_string, opts: opts, on_error: on_error) - end - - file = nil - ext = file_extension_for(content_type) - file = Tempfile.new(["logosoup", ext]) - file.binmode - file.write(bytes) - file.flush - file.close - - handle_image_path(file.path, opts: opts, on_error: on_error) - ensure - file.unlink if file - end - def self.handle_error(error, opts:, on_error:) case on_error when :raise @@ -105,59 +109,39 @@ def self.handle_error(error, opts:, on_error:) end end - def self.wants_visual_center?(align_by) - mode = align_by.to_s.strip - %w[visual-center visual-center-x visual-center-y].include?(mode) - end - def self.measure_svg_features(svg_string, intrinsic_width:, intrinsic_height:, opts:, on_error:) return empty_features if intrinsic_width.to_f <= 0 || intrinsic_height.to_f <= 0 - file = Tempfile.new(["logosoup", ".svg"]) - file.binmode - file.write(svg_string.to_s) - file.flush - file.close - - rendered = Vips::Image.new_from_file(file.path, access: :sequential) - rendered_w = rendered.width.to_f - rendered_h = rendered.height.to_f - return empty_features if rendered_w <= 0 || rendered_h <= 0 - + buffer = svg_string.to_s measured = Core::FeatureMeasurer.call( - path: file.path, + buffer: buffer, contrast_threshold: opts.fetch(:contrast_threshold).to_i, pixel_budget: opts.fetch(:pixel_budget).to_i, on_error: on_error ) + rendered_w = measured[:source_width].to_f + rendered_h = measured[:source_height].to_f + return empty_features if rendered_w <= 0 || rendered_h <= 0 + scale_x = intrinsic_width.to_f / rendered_w scale_y = intrinsic_height.to_f / rendered_h + scales = { + content_box_width: scale_x, + content_box_height: scale_y, + visual_center_offset_x: scale_x, + visual_center_offset_y: scale_y + } scaled = measured.dup - scaled[:content_box_width] = scaled[:content_box_width].to_f * scale_x if scaled[:content_box_width].is_a?(Numeric) - scaled[:content_box_height] = scaled[:content_box_height].to_f * scale_y if scaled[:content_box_height].is_a?(Numeric) - scaled[:visual_center_offset_x] = scaled[:visual_center_offset_x].to_f * scale_x if scaled[:visual_center_offset_x].is_a?(Numeric) - scaled[:visual_center_offset_y] = scaled[:visual_center_offset_y].to_f * scale_y if scaled[:visual_center_offset_y].is_a?(Numeric) + scales.each do |key, scale| + scaled[key] = scaled[key].to_f * scale if scaled[key].is_a?(Numeric) + end scaled rescue StandardError => e raise e if on_error == :raise empty_features - ensure - file.unlink if file - end - - def self.file_extension_for(content_type) - case content_type.to_s - when "image/png" then ".png" - when "image/jpeg", "image/jpg" then ".jpg" - when "image/webp" then ".webp" - when "image/gif" then ".gif" - when "image/tiff" then ".tif" - else - ".img" - end end def self.build_style( @@ -222,15 +206,16 @@ def self.empty_features } end - private_class_method :build_style, + private_class_method :analyze_raster, + :build_style, + :coerce_to_utf8, :fallback_style, :empty_features, - :file_extension_for, :handle_error, :handle_svg, :handle_image_path, :handle_image_bytes, :measure_svg_features, - :wants_visual_center? + :warn_unknown_options end end diff --git a/logosoup.gemspec b/logosoup.gemspec index 4c1b6e9..8f4430f 100644 --- a/logosoup.gemspec +++ b/logosoup.gemspec @@ -40,13 +40,14 @@ Gem::Specification.new do |spec| spec.add_development_dependency "simplecov", "~> 0.22" spec.add_development_dependency "simplecov-lcov", "~> 0.8" spec.add_development_dependency "simplecov-console", "~> 0.9" - spec.add_development_dependency "minitest", ">= 5", "< 5.26" + spec.add_development_dependency "minitest", "~> 5.1" spec.add_development_dependency "rubocop", "~> 1.50" spec.add_development_dependency "rubocop-github", "~> 0.26" spec.add_development_dependency "rubocop-performance", "~> 1.26" - # Keep rubocop-rails transitive dependencies compatible with Ruby 2.7/3.0 CI jobs. - spec.add_development_dependency "activesupport", ">= 6.1", "< 7.1" + # rubocop-rails pulls activesupport transitively; the explicit pin only fixes + # the resolver to the same major. Bump the upper bound when bumping Ruby. + spec.add_development_dependency "activesupport", ">= 6.1", "< 8" spec.add_development_dependency "brakeman", "~> 6.1" end diff --git a/script/coverage_diff.rb b/script/coverage_diff.rb index 1d0ceda..16ce960 100644 --- a/script/coverage_diff.rb +++ b/script/coverage_diff.rb @@ -29,11 +29,7 @@ def total_line_coverage(run) covered = 0 coverage.each_value do |file_cov| - lines = if file_cov.is_a?(Hash) && file_cov.key?("lines") - file_cov["lines"] - else - file_cov - end + lines = file_cov.is_a?(Hash) && file_cov.key?("lines") ? file_cov["lines"] : file_cov Array(lines).each do |hit| next if hit.nil? @@ -86,46 +82,31 @@ def total_branch_coverage(run) pr_percent, pr_covered, pr_total = total_line_coverage(pr_run) pr_branch_percent, pr_br_covered, pr_br_total = total_branch_coverage(pr_run) +base_percent = base_covered = base_total = nil +base_branch_percent = base_br_covered = base_br_total = nil +diff = branch_diff = nil + if baseline_run base_percent, base_covered, base_total = total_line_coverage(baseline_run) base_branch_percent, base_br_covered, base_br_total = total_branch_coverage(baseline_run) diff = pr_percent - base_percent branch_diff = pr_branch_percent - base_branch_percent -else - base_percent = nil - base_covered = nil - base_total = nil - base_branch_percent = nil - base_br_covered = nil - base_br_total = nil - diff = nil - branch_diff = nil end -status = if diff.nil? - "ℹ️" -elsif diff >= 0 - "✅" -else - "❌" -end +status = + if diff.nil? + "ℹ️" + elsif diff >= 0 + "✅" + else + "❌" + end lines = [] lines << "## Coverage Report" -lines << - if base_percent - "Main: #{format('%.2f', base_percent)}% (#{base_covered}/#{base_total})" - else - "Main: (baseline not available)" - end +lines << (base_percent ? "Main: #{format('%.2f', base_percent)}% (#{base_covered}/#{base_total})" : "Main: (baseline not available)") lines << "PR: #{format('%.2f', pr_percent)}% (#{pr_covered}/#{pr_total})" - -lines << - if base_branch_percent - "Main (branches): #{format('%.2f', base_branch_percent)}% (#{base_br_covered}/#{base_br_total})" - else - "Main (branches): (baseline not available)" - end +lines << (base_branch_percent ? "Main (branches): #{format('%.2f', base_branch_percent)}% (#{base_br_covered}/#{base_br_total})" : "Main (branches): (baseline not available)") lines << "PR (branches): #{format('%.2f', pr_branch_percent)}% (#{pr_br_covered}/#{pr_br_total})" if diff diff --git a/spec/logo_soup/style_spec.rb b/spec/logo_soup/style_spec.rb index a59d7ae..f67d541 100644 --- a/spec/logo_soup/style_spec.rb +++ b/spec/logo_soup/style_spec.rb @@ -114,5 +114,17 @@ described_class.style(image_path: path, base_size: 48, on_error: :raise) end.to raise_error(StandardError) end + + it "warns when given an unknown option key" do + expect do + described_class.style(base_size: 48, align: "visual-center") + end.to output(/unknown option/).to_stderr + end + + it "raises ArgumentError when image_bytes is not a String or IO-like" do + expect do + described_class.style(base_size: 48, image_bytes: 42, content_type: "image/png", on_error: :raise) + end.to raise_error(ArgumentError, /image_bytes/) + end end end