Skip to main content

SVG-aware compile-time image pipeline

At a glance

When you run purgetss (the regular purge), it scans your views and controllers for SVG references and generates the matching multi-density PNGs. You do not need to call purgetss images separately or declare dimensions somewhere else. The cascade of w-* / h-* classes in your XML decides the size.

The pipeline runs as a post-step of purgetss, after app.tss is finalized. It only touches SVGs you actually reference from views or controllers. Files that sit in purgetss/images/ but are not referenced anywhere are ignored.

Why this exists

Titanium doesn't load .svg files natively in Ti.UI.ImageView. Without help, you have two options:

  1. Hand-rasterize every SVG into app/assets/{iphone,android}/images/.../*.png for every density. This is tedious, easy to get wrong, and easy to forget when you swap a logo.
  2. Run purgetss images manually after every SVG edit. Better, but still manual, and you have to know the dimensions in advance.

With the SVG pipeline, you drop the SVG into purgetss/images/, reference it from a view as image="/.../foo.svg", and let purgetss generate the PNGs.

How it works (the short version)

purgetss
├─ regular purge → writes app.tss with .w-32, .h-auto, .w-(300), etc.
└─ SVG post-step:
1. Parse app.tss → class → props map
2. Scan views (.xml) and controllers (.js) for SVG references
3. For each SVG, resolve width/height by applying its classes against app.tss
4. Sync images.files in config.cjs (autoSync ON)
5. Generate PNGs with Sharp, using a hash-based cache to skip unchanged files

The runtime trick: when a view requests image="/foo.svg" and Titanium cannot use that file as SVG, the image loader falls back to a .png with the same basename in the platform assets folder. If PurgeTSS writes foo.png, plus @2x, @3x, and Android densities, the view loads correctly even though your XML still references .svg.

What gets detected

XML views: any node attribute ending in .svg is captured along with its class="" attribute:

<ImageView class="w-32 h-auto" image="/images/logos/logo.svg" />
<ImageView class="w-(300) bg-red-500" image="/images/banners/hero.svg" />
<View backgroundImage="/images/textures/grain.svg" class="wh-screen" />

Controllers: objects passed to $.UI.create(...) (or similar) that mix image: '...svg' and classes: '...' are captured by an AST walker:

$.UI.create('ImageView', {
image: '/images/logos/logo.svg',
classes: 'w-(300) bg-red-500'
})

Dynamically composed paths ('/images/' + variant + '.svg') are not detected by the static scanner. For those, pin the dimensions manually under images.files.

How dimensions get resolved

For each SVG reference, the cascade of classes is applied against app.tss in declaration order (later-wins, matching Alloy semantics). The pipeline pulls the final width and height numbers:

Class on the viewWhat app.tss resolvesWhat the pipeline pins
class="w-32"width: 128widthDp = 128
class="w-40 h-12"width: 160, height: 48widthDp = 160, heightDp = 48
class="w-(300)"width: 300 (arbitrary value)widthDp = 300
class="h-44" (no w-*)height: 176heightDp = 176; width derived from viewBox at generation time
class="w-32 h-auto"width: 128, height: Ti.UI.SIZEwidthDp = 128; height derived from viewBox
class="w-full" (non-numeric)width: Ti.UI.FILLskipped with a warning; no usable dim

Auto-derived dimensions are not written to config.cjs. If you used h-auto (or no h-*), the height field does not appear in the images.files entry. The generator re-derives it from the SVG's viewBox every run. The same applies to width when only h-* is pinned. This keeps stale values out of config.

Multiple references to the same SVG

If two views reference the same SVG with different classes, the pipeline takes the max of each dimension across all references. The generated PNGs are then sharp enough for the largest use:

<!-- view A -->
<ImageView class="w-32" image="/images/logos/logo.svg" />
<!-- view B -->
<ImageView class="w-(800)" image="/images/logos/logo.svg" />

Resolved: widthDp = 800. The generated PNGs cover both views; the smaller one just downsamples at runtime.

How config.cjs is synced (autoSync ON)

After resolving dimensions, the pipeline upserts an entry in images.files:

./purgetss/config.cjs
images: {
autoSync: true,
files: [
{ filename: 'images/logos/logo.svg', width: 176 }
]
}

Policy in plain terms: the current run's cascade wins. If you change w-32 to w-40, the entry updates to width: 160. If you bump back to w-32, it updates back to 128. On every run, the entry mirrors what the views ask for.

This is not a high-water mark. The old policy used max(existing, derived), which froze sizes when you shrank a class. For example, h-52h-16 kept the 208 dp height. The new policy follows the cascade, so shrinks work as expected.

If a run is byte-identical to the previous one (no inserts, no updates), config.cjs is not rewritten. Its mtime does not change, so downstream rebuilds are not triggered for nothing.

Manual mode: autoSync: false

./purgetss/config.cjs
images: {
autoSync: false,
files: [
{ filename: 'images/logos/logo.svg', width: 1024 }
]
}

With autoSync: false:

  • The pipeline still derives dimensions and generates PNGs using the values you wrote in images.files, or the cascade fallback for files not listed.
  • config.cjs is never written. You own those entries.

Use this when you want to pin sizes by hand and not have PurgeTSS overwrite your work.

Force-PNG output

The SVG pipeline always emits .png, regardless of images.format. This is deliberate:

  1. Titanium's image="/foo.svg" fallback resolves to .png only. .webp, .jpeg, and .avif are not picked up.
  2. Having a sibling .webp (or any non-.png) next to .png on disk for the same basename breaks the fallback entirely. Titanium then shows nothing.

So even if you set format: 'webp' (which is honored by purgetss images for raster sources and unlisted SVGs), the post-purge pipeline emits .png for every SVG it processes. The output line in --debug says png (forced; ignores format: webp).

If you genuinely want a .webp of an SVG, reference it as image="/.../foo.webp" in your XML, not as .svg. The standalone purgetss images command will then generate .webp for that file.

The PNG cache

PurgeTSS keeps a cache at purgetss/.cache/svg-images.json:

{
"logos/logo.svg": {
"svgHash": "<sha1>",
"widthDp": 176,
"heightDp": null,
"targets": [
"app/assets/android/images/res-mdpi/logos/logo.png",
"app/assets/iphone/images/logos/logo@2x.png",
"..."
]
}
}

A cached entry is reused, and Sharp is not invoked, when all of these hold:

  • The SVG's bytes haven't changed (SHA-1 match).
  • widthDp resolved is the same as cached.
  • heightDp resolved is the same as cached.
  • Every target PNG path matches and exists on disk.

Otherwise, PurgeTSS regenerates the image. If you git clean, delete a PNG by hand, or change a class in XML/JS, the next run regenerates only the affected SVGs. Unaffected entries stay cached.

Add purgetss/.cache/ to .gitignore. It is a per-machine artifact.

Standalone command vs post-purge pipeline

Aspectpurgetss (post-purge SVG pipeline)purgetss images (standalone)
What it processesOnly SVGs referenced from XML/JSAll files under purgetss/images/
Source of dimensionsClass cascade in app.tssCLI --width > images.files > viewBox / source
Touches images.files?Yes (autoSync ON)No; only reads it as overrides
Output format for SVGsAlways PNGPNG for SVGs in files; format otherwise
When to runAutomatic on every purgetssManual, after raster source edits

Both share the same images.files array and the same generation engine (gen-scales.js). They do not fight each other. The pipeline edits images.files; the standalone command reads it. Run either, or both.

Troubleshooting

The pipeline says "no class resolved width or height to a number; skipping"

The SVG is referenced from a view with no resolvable w-* or h-* class (just w-full, w-screen, bg-*, etc.). Add a w-* or h-* utility, or pin the size manually in images.files:

files: [
{ filename: 'images/<sub>/<name>.svg', width: 256 }
]

My change to a class didn't update the PNG

Check that the class actually exists in app.tss after purging. Tailwind v3 spacing scale skips numbers like h-50; only h-48, h-52, h-56 are emitted by default. Add custom values under theme.extend.spacing if you need them, or use arbitrary values: h-(50), h-(200px), h-(12.5rem).

If the class is correct but the image did not refresh on device, it might be the Titanium simulator's image cache. Do a clean build.

The pipeline didn't detect my SVG reference

The static scanner sees literal string values, not computed paths. Concatenated strings ('/images/' + variant + '.svg') are not detected. For dynamic references, pin the entry in images.files manually so the pipeline still generates the PNGs.

I want to opt out for a specific run

Delete the SVG cache and the entry from images.files, or switch autoSync: false and manage the entries yourself. The pipeline still derives dimensions and generates the PNGs so views keep working, but it does not write to config.cjs.