SVG-aware compile-time image pipeline
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:
- Hand-rasterize every SVG into
app/assets/{iphone,android}/images/.../*.pngfor every density. This is tedious, easy to get wrong, and easy to forget when you swap a logo. - Run
purgetss imagesmanually 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 view | What app.tss resolves | What the pipeline pins |
|---|---|---|
class="w-32" | width: 128 | widthDp = 128 |
class="w-40 h-12" | width: 160, height: 48 | widthDp = 160, heightDp = 48 |
class="w-(300)" | width: 300 (arbitrary value) | widthDp = 300 |
class="h-44" (no w-*) | height: 176 | heightDp = 176; width derived from viewBox at generation time |
class="w-32 h-auto" | width: 128, height: Ti.UI.SIZE | widthDp = 128; height derived from viewBox |
class="w-full" (non-numeric) | width: Ti.UI.FILL | skipped 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:
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-52 → h-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
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.cjsis 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:
- Titanium's
image="/foo.svg"fallback resolves to.pngonly..webp,.jpeg, and.avifare not picked up. - Having a sibling
.webp(or any non-.png) next to.pngon 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).
widthDpresolved is the same as cached.heightDpresolved 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
| Aspect | purgetss (post-purge SVG pipeline) | purgetss images (standalone) |
|---|---|---|
| What it processes | Only SVGs referenced from XML/JS | All files under purgetss/images/ |
| Source of dimensions | Class cascade in app.tss | CLI --width > images.files > viewBox / source |
Touches images.files? | Yes (autoSync ON) | No; only reads it as overrides |
| Output format for SVGs | Always PNG | PNG for SVGs in files; format otherwise |
| When to run | Automatic on every purgetss | Manual, 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.