Browse Source

G-135: platform-neutral icon generator + per-asset assetorigin sidecars

make-ico.tcl (any tclsh 8.6+, rsvg-convert on PATH) replaces make-ico.ps1
in place: pure-Tcl PNG decode (core zlib inflate + row unfilter) and
DIB/ICONDIR assembly take over from System.Drawing, with an embedded
sha256 (NIST-vector validated, cross-checked against Get-FileHash).
Byte-identity proven before the ps1 removal: 'all -check' regenerates all
seven recorded artifacts (punk-mark.ico, punk-mark-solid.ico,
project-default/icon.ico on the DIB+PNG256 profile; favicon.ico all-PNG;
apple-touch/192/512 rasters) byte-identical to the committed files under
both mingw tclsh 8.6.11 and native Tcl 9.0.3.

Every artifact now carries its <name>.assetorigin.toml sidecar (schema 1:
artifact sha256, source + source hash, role, generator + invocation
options), (re)written by every generator subcommand so regeneration
cannot leave a stale record. The 'all' manifest embeds the invocation
profiles; 'all -check' is the no-write drift probe (exit 1 on any
difference). src/runtime/punkshell.ico gains a derived-copy sidecar (hash
equals source_hash, source ../assets/logo/punk-mark.ico); the three vfs
copies stay unrecorded pending the user call on shipping sidecars inside
baked kits.

src/assets/logo/AGENTS.md: assetorigin schema 1 format documentation
(canonical), Tcl-generator work guidance, drift-probe verification line.
favicon source recorded as web/icon.svg (renders byte-identical to
punk-mark.svg at 16/32/48; the same-directory primary web icon chosen).

Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com
master
Julian Noble 2 days ago
parent
commit
f529f44ea7
  1. 78
      src/assets/logo/AGENTS.md
  2. 9
      src/assets/logo/alternative/punk-mark-solid.ico.assetorigin.toml
  3. 111
      src/assets/logo/make-ico.ps1
  4. 475
      src/assets/logo/make-ico.tcl
  5. 9
      src/assets/logo/project-default/icon.ico.assetorigin.toml
  6. 9
      src/assets/logo/punk-mark.ico.assetorigin.toml
  7. 9
      src/assets/logo/web/apple-touch-icon.png.assetorigin.toml
  8. 9
      src/assets/logo/web/favicon.ico.assetorigin.toml
  9. 9
      src/assets/logo/web/icon-192.png.assetorigin.toml
  10. 9
      src/assets/logo/web/icon-512.png.assetorigin.toml
  11. 9
      src/runtime/punkshell.ico.assetorigin.toml

78
src/assets/logo/AGENTS.md

@ -18,7 +18,10 @@ elsewhere in the tree is a derived copy or a build input, never an original.
`src/vfs/mkzipfix.vfs/` and `src/vfs/punk8_statictwapi.vfs/`. Those four were `src/vfs/mkzipfix.vfs/` and `src/vfs/punk8_statictwapi.vfs/`. Those four were
set from `punk-mark.ico` on 2026-07-28 at the user's direction. Propagating a set from `punk-mark.ico` on 2026-07-28 at the user's direction. Propagating a
later change to them is build machinery owned by G-057 — not a manual step to later change to them is build machinery owned by G-057 — not a manual step to
repeat from here. repeat from here. The `src/runtime/` copy carries an assetorigin sidecar
recording the derivation (hash equals source_hash: a byte-identical copy);
the three vfs copies stay deliberately unrecorded pending the user call on
shipping sidecars inside baked kits (G-135 notes).
## Local Contracts ## Local Contracts
@ -67,20 +70,66 @@ elsewhere in the tree is a derived copy or a build input, never an original.
baked in and the mark inset ~18% to clear iOS's rounded-rect mask. Do not baked in and the mark inset ~18% to clear iOS's rounded-rect mask. Do not
"fix" it by making it transparent. "fix" it by making it transparent.
## Provenance records (assetorigin sidecars)
Canonical format documentation for `*.assetorigin.toml` sidecars (G-135).
- `<asset-filename>.assetorigin.toml` beside an asset records what that asset
was derived from. The recorded asset is bound by the sidecar's own filename -
suffix stripped, same directory; there is no path key. Files ending in
`.assetorigin.toml` are records, never assets. Create, copy, replace and
remove the two as a pair.
- Records are OPTIONAL and advisory. No sidecar means `unrecorded` - a fully
supported, silent state, never a warning. Nothing may read these records to
decide whether a build proceeds.
- Schema 1 is one flat TOML root table:
- `schema = 1` - integer format version. A reader ignores a record whose
schema it does not recognise (the asset then classifies as unrecorded) and
skips unknown keys, so a future writer cannot break an older reader.
- `hash = "sha256:<64 lowercase hex>"` - the asset's own hash; the only
required field. A hash-only sidecar means "I know this file, I do not know
where it came from" and still gives change detection.
- `source = "<path>"` - optional; relative to the sidecar's directory
(`../` allowed).
- `source_hash = "sha256:..."` - optional; the source's hash at generation
time. A source named without a source hash contributes only a presence
check and can never produce `stale`.
- `role = "pe-resource" | "web" | "generic"` - optional consumer hint. `web`
marks all-PNG files (`web/favicon.ico`) that GDI+ and tklib's `ico`
package cannot read; an embedding step can refuse them.
- `generator` / `options` - optional; the tool and invocation profile that
produced the artifact.
- Hash values are `sha256:`-prefixed in schema 1; a reader treats an
unrecognised prefix as if no hash were recorded.
- The checker (`tclsh scriptlib/developer/assetorigin_check.tcl`) classifies
each examined asset as exactly one of `unrecorded`, `artifact-absent`,
`replaced`, `source-absent`, `stale` or `verified`, first match in that
order. `stale` = recorded source changed since the artifact was generated;
`replaced` = artifact changed since it was recorded. Advisory only - it
never fails a build.
## Work Guidance ## Work Guidance
- Regenerate after any SVG edit: - Regenerate after any SVG edit: `tclsh src/assets/logo/make-ico.tcl all`
`pwsh src/assets/logo/make-ico.ps1 -Svg <in.svg> -Ico <out.ico> -Work <tmpdir>` rebuilds every recorded artifact (three PE icos, the web favicon, the web
It rasterizes with `rsvg-convert` (librsvg, true alpha) and assembles the rasters) from the manifest inside the script, rewriting files only when
ICONDIR by hand. ImageMagick is not used for assembly — its ICO writer picks bytes change and refreshing each artifact's assetorigin sidecar. `all
its own entry formats. -check` builds everything but writes nothing and exits 1 on any difference -
- `-Sizes 16,32,48` selects which entries to emit; `-AllPng` makes every entry the drift probe. Runs under any tclsh 8.6+ with `rsvg-convert` (librsvg,
true alpha, cross-platform) on PATH; PNG decode and ICONDIR assembly are
pure Tcl - no System.Drawing, and ImageMagick is not used for assembly (its
ICO writer picks its own entry formats).
- One-off builds: `tclsh make-ico.tcl ico -svg <in.svg> -ico <out.ico>
?-sizes {16 32 48}? ?-allpng?` and
`tclsh make-ico.tcl raster -svg <in.svg> -png <out.png> -size <n>`.
`-sizes` selects which entries to emit; `-allpng` makes every entry
PNG-compressed. The web favicon is built with both; the PE icons take the PNG-compressed. The web favicon is built with both; the PE icons take the
defaults. Passing `-Sizes` without `256` yields an all-DIB file for consumers defaults. A `-sizes` list without `256` yields an all-DIB file for consumers
that cannot read PNG entries. that cannot read PNG entries. Every subcommand (re)writes the produced
- Regenerate the web rasters from `web/icon-opaque.svg`: artifact's sidecar - regeneration cannot leave a stale record.
`rsvg-convert -w <n> -h <n> -o <out.png> web/icon-opaque.svg` at 180 - The web rasters (`apple-touch-icon.png`, `icon-192.png`, `icon-512.png`) are
(apple-touch-icon), 192 and 512 (webmanifest). built from `web/icon-opaque.svg` by the `all` manifest raster rows (180, 192,
512).
- Do not rasterize by screenshotting a browser: it bakes in a background and - Do not rasterize by screenshotting a browser: it bakes in a background and
loses the alpha channel the icon depends on. loses the alpha channel the icon depends on.
- Site markup for the web set (paths assume the files are served from the site - Site markup for the web set (paths assume the files are served from the site
@ -93,6 +142,11 @@ elsewhere in the tree is a derived copy or a build input, never an original.
## Verification ## Verification
- Drift: `tclsh src/assets/logo/make-ico.tcl all -check` regenerates every
recorded artifact to a temp area and compares bytes (artifacts and sidecars);
exit 0 clean. `tclsh scriptlib/developer/assetorigin_check.tcl check`
verifies every sidecar-carrying asset against its record without a
rasterizer.
- Structure: parse the ICONDIR back and confirm each entry's offset+length lies - Structure: parse the ICONDIR back and confirm each entry's offset+length lies
inside the file, each DIB header's height is twice its declared height, and inside the file, each DIB header's height is twice its declared height, and
each DIB payload length equals `40 + w*h*4 + ceil(w/32)*4*h`. each DIB payload length equals `40 + w*h*4 + ceil(w/32)*4*h`.

9
src/assets/logo/alternative/punk-mark-solid.ico.assetorigin.toml

@ -0,0 +1,9 @@
# assetorigin provenance sidecar (schema 1) for the asset named by this file
# with the .assetorigin.toml suffix stripped. Optional and advisory.
schema = 1
role = "pe-resource"
hash = "sha256:2e91ae70f1f48105e66e4b51a84b8206a3deeee0cadc9036637e0475c2a2ed76"
source = "punk-mark-solid.svg"
source_hash = "sha256:39021d4c0a170d52adcd599f55540f22e1529ecf28f7a2215ed8775aa128bda5"
generator = "make-ico.tcl"
options = "ico -sizes {16 24 32 48 64 128 256}"

111
src/assets/logo/make-ico.ps1

@ -1,111 +0,0 @@
# Build a multi-resolution Windows .ico from an SVG.
# <=128 px entries : uncompressed 32-bit BGRA DIB (BITMAPINFOHEADER + XOR + AND mask)
# 256 px entry : PNG-compressed (Vista+ convention)
# Both forms are legal RT_ICON payloads; the ICONDIR is built to match.
# -AllPng makes every entry PNG-compressed (web favicons: ~10x smaller, but
# unreadable by GDI+ and tklib's ico package - never use it for a PE resource).
param(
[Parameter(Mandatory=$true)][string]$Svg,
[Parameter(Mandatory=$true)][string]$Ico,
[Parameter(Mandatory=$true)][string]$Work,
[int[]]$Sizes = @(16,24,32,48,64,128,256),
[switch]$AllPng
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Drawing
$sizes = $Sizes
New-Item -ItemType Directory -Force $Work | Out-Null
# --- rasterize each size with librsvg (true alpha, no background) ---
foreach ($s in $sizes) {
$png = Join-Path $Work "$s.png"
& rsvg-convert -w $s -h $s -o $png $Svg
if ($LASTEXITCODE -ne 0) { throw "rsvg-convert failed at ${s}px" }
}
# --- build per-entry payloads ---
$entries = @()
foreach ($s in $sizes) {
$png = Join-Path $Work "$s.png"
$bmp = [System.Drawing.Bitmap]::FromFile($png)
try {
if ($bmp.Width -ne $s -or $bmp.Height -ne $s) { throw "rasterizer returned $($bmp.Width)x$($bmp.Height) for ${s}px" }
if ($AllPng -or $s -eq 256) {
# PNG entry: raw file bytes verbatim
$payload = [System.IO.File]::ReadAllBytes($png)
} else {
# DIB entry: BITMAPINFOHEADER with doubled height, bottom-up BGRA, then AND mask
$ms = New-Object System.IO.MemoryStream
$bw = New-Object System.IO.BinaryWriter($ms)
$bw.Write([uint32]40) # biSize
$bw.Write([int32]$s) # biWidth
$bw.Write([int32]($s * 2)) # biHeight = XOR + AND
$bw.Write([uint16]1) # biPlanes
$bw.Write([uint16]32) # biBitCount
$bw.Write([uint32]0) # biCompression = BI_RGB
$bw.Write([uint32]($s * $s * 4))
$bw.Write([int32]0); $bw.Write([int32]0)
$bw.Write([uint32]0); $bw.Write([uint32]0)
# XOR: bottom-up rows, BGRA
$rect = New-Object System.Drawing.Rectangle 0,0,$s,$s
$data = $bmp.LockBits($rect, [System.Drawing.Imaging.ImageLockMode]::ReadOnly,
[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
try {
$stride = $data.Stride
$buf = New-Object byte[] ($stride * $s)
[System.Runtime.InteropServices.Marshal]::Copy($data.Scan0, $buf, 0, $buf.Length)
} finally { $bmp.UnlockBits($data) }
for ($y = $s - 1; $y -ge 0; $y--) {
$bw.Write($buf, $y * $stride, $s * 4)
}
# AND mask: fully transparent pixels set; rows padded to 4 bytes
$maskRow = [math]::Ceiling($s / 32.0) * 4
for ($y = $s - 1; $y -ge 0; $y--) {
$row = New-Object byte[] $maskRow
for ($x = 0; $x -lt $s; $x++) {
$a = $buf[$y * $stride + $x * 4 + 3]
if ($a -eq 0) { $row[[math]::Floor($x / 8)] = $row[[math]::Floor($x / 8)] -bor (0x80 -shr ($x % 8)) }
}
$bw.Write($row, 0, $maskRow)
}
$bw.Flush()
$payload = $ms.ToArray()
$bw.Dispose(); $ms.Dispose()
}
$entries += [pscustomobject]@{ Size = $s; Bytes = $payload }
} finally { $bmp.Dispose() }
}
# --- assemble ICONDIR + ICONDIRENTRY[] + payloads ---
$out = New-Object System.IO.MemoryStream
$w = New-Object System.IO.BinaryWriter($out)
$w.Write([uint16]0) # reserved
$w.Write([uint16]1) # type 1 = icon
$w.Write([uint16]$entries.Count)
$offset = 6 + (16 * $entries.Count)
foreach ($e in $entries) {
$dim = if ($e.Size -ge 256) { 0 } else { $e.Size } # 0 means 256
$w.Write([byte]$dim) # width
$w.Write([byte]$dim) # height
$w.Write([byte]0) # colour count (0 = truecolour)
$w.Write([byte]0) # reserved
$w.Write([uint16]1) # planes
$w.Write([uint16]32) # bit count
$w.Write([uint32]$e.Bytes.Length)
$w.Write([uint32]$offset)
$offset += $e.Bytes.Length
}
foreach ($e in $entries) { $w.Write($e.Bytes, 0, $e.Bytes.Length) }
$w.Flush()
[System.IO.File]::WriteAllBytes($Ico, $out.ToArray())
$w.Dispose(); $out.Dispose()
Write-Output ("wrote {0} ({1:N0} bytes, {2} entries: {3})" -f $Ico, (Get-Item $Ico).Length, $entries.Count, ($sizes -join ','))

475
src/assets/logo/make-ico.tcl

@ -0,0 +1,475 @@
# make-ico.tcl - build multi-resolution Windows .ico files and web rasters from
# the SVG masters in this directory, with no Windows-only component (G-135).
#
# Replaces make-ico.ps1: rasterization is rsvg-convert (librsvg, true alpha,
# cross-platform); PNG decode is pure Tcl (core zlib inflate + row unfilter);
# DIB and ICONDIR assembly is pure [binary format]. ImageMagick is not used for
# assembly - its ICO writer picks its own entry formats.
#
# Entry formats (unchanged from make-ico.ps1):
# <=128 px entries : uncompressed 32-bit BGRA DIB (BITMAPINFOHEADER + XOR + AND mask)
# 256 px entry : PNG-compressed (Vista+ convention)
# -allpng makes every entry PNG-compressed (web favicons: ~10x smaller, but
# unreadable by GDI+ and tklib's ico package - never use it for a PE resource).
#
# Every artifact produced gets its <artifact>.assetorigin.toml sidecar
# (re)written - artifact and source sha256 plus the invocation options - so
# regeneration cannot leave a stale record. Format: see AGENTS.md here.
#
# usage (any tclsh 8.6+, rsvg-convert on PATH):
# tclsh make-ico.tcl all ?-check?
# Regenerate every recorded artifact from the MANIFEST below into place
# (files are rewritten only when bytes change). -check builds everything
# but writes nothing: reports match/DIFFER per artifact and sidecar,
# exit 1 on any difference - the drift probe.
# tclsh make-ico.tcl ico -svg <in.svg> -ico <out.ico> ?-sizes {16 24 32 48 64 128 256}? ?-allpng? ?-role <role>? ?-work <dir>?
# tclsh make-ico.tcl raster -svg <in.svg> -png <out.png> -size <n> ?-role <role>?
set MANIFEST {
{ico punk-mark.svg punk-mark.ico {16 24 32 48 64 128 256} 0 pe-resource}
{ico alternative/punk-mark-solid.svg alternative/punk-mark-solid.ico {16 24 32 48 64 128 256} 0 pe-resource}
{ico project-default/icon.svg project-default/icon.ico {16 24 32 48 64 128 256} 0 pe-resource}
{ico web/icon.svg web/favicon.ico {16 32 48} 1 web}
{raster web/icon-opaque.svg web/apple-touch-icon.png 180 {} web}
{raster web/icon-opaque.svg web/icon-192.png 192 {} web}
{raster web/icon-opaque.svg web/icon-512.png 512 {} web}
}
# ---------------------------------------------------------------- file helpers
proc readbin {path} {
set f [open $path rb]
set data [read $f]
close $f
return $data
}
proc writebin {path data} {
set f [open $path wb]
puts -nonewline $f $data
close $f
}
# Relative path from directory basedir to path, forward slashes.
proc relpath {basedir path} {
set base [file split [file normalize $basedir]]
set p [file split [file normalize $path]]
set caseless [expr {$::tcl_platform(platform) eq "windows"}]
set i 0
while {$i < [llength $base] && $i < [llength $p]} {
set a [lindex $base $i]
set b [lindex $p $i]
if {$caseless} {
set a [string tolower $a]
set b [string tolower $b]
}
if {$a ne $b} break
incr i
}
set rel [concat [lrepeat [expr {[llength $base] - $i}] ..] [lrange $p $i end]]
if {![llength $rel]} {return .}
return [join $rel /]
}
# ---------------------------------------------------------------- sha256 (pure Tcl)
# Self-contained so the script runs under any stock tclsh; the checker
# (scriptlib/developer/assetorigin_check.tcl) embeds the same algorithm.
namespace eval sha256 {
variable K {
0x428a2f98 0x71374491 0xb5c0fbcf 0xe9b5dba5 0x3956c25b 0x59f111f1 0x923f82a4 0xab1c5ed5
0xd807aa98 0x12835b01 0x243185be 0x550c7dc3 0x72be5d74 0x80deb1fe 0x9bdc06a7 0xc19bf174
0xe49b69c1 0xefbe4786 0x0fc19dc6 0x240ca1cc 0x2de92c6f 0x4a7484aa 0x5cb0a9dc 0x76f988da
0x983e5152 0xa831c66d 0xb00327c8 0xbf597fc7 0xc6e00bf3 0xd5a79147 0x06ca6351 0x14292967
0x27b70a85 0x2e1b2138 0x4d2c6dfc 0x53380d13 0x650a7354 0x766a0abb 0x81c2c92e 0x92722c85
0xa2bfe8a1 0xa81a664b 0xc24b8b70 0xc76c51a3 0xd192e819 0xd6990624 0xf40e3585 0x106aa070
0x19a4c116 0x1e376c08 0x2748774c 0x34b0bcb5 0x391c0cb3 0x4ed8aa4a 0x5b9cca4f 0x682e6ff3
0x748f82ee 0x78a5636f 0x84c87814 0x8cc70208 0x90befffa 0xa4506ceb 0xbef9a3f7 0xc67178f2
}
proc hex {data} {
variable K
set bitlen [expr {wide([string length $data]) * 8}]
append data \x80
set pad [expr {(56 - [string length $data] % 64 + 64) % 64}]
append data [string repeat \x00 $pad] [binary format W $bitlen]
set h0 0x6a09e667; set h1 0xbb67ae85; set h2 0x3c6ef372; set h3 0xa54ff53a
set h4 0x510e527f; set h5 0x9b05688c; set h6 0x1f83d9ab; set h7 0x5be0cd19
set n [string length $data]
for {set off 0} {$off < $n} {incr off 64} {
binary scan [string range $data $off [expr {$off + 63}]] Iu16 w
for {set t 16} {$t < 64} {incr t} {
set x [lindex $w [expr {$t - 15}]]
set s0 [expr {((($x >> 7) | ($x << 25)) ^ (($x >> 18) | ($x << 14)) ^ ($x >> 3)) & 0xffffffff}]
set x [lindex $w [expr {$t - 2}]]
set s1 [expr {((($x >> 17) | ($x << 15)) ^ (($x >> 19) | ($x << 13)) ^ ($x >> 10)) & 0xffffffff}]
lappend w [expr {([lindex $w [expr {$t - 16}]] + $s0 + [lindex $w [expr {$t - 7}]] + $s1) & 0xffffffff}]
}
set a $h0; set b $h1; set c $h2; set d $h3
set e $h4; set f $h5; set g $h6; set h $h7
for {set t 0} {$t < 64} {incr t} {
set S1 [expr {((($e >> 6) | ($e << 26)) ^ (($e >> 11) | ($e << 21)) ^ (($e >> 25) | ($e << 7))) & 0xffffffff}]
set ch [expr {(($e & $f) ^ (~$e & $g)) & 0xffffffff}]
set T1 [expr {($h + $S1 + $ch + [lindex $K $t] + [lindex $w $t]) & 0xffffffff}]
set S0 [expr {((($a >> 2) | ($a << 30)) ^ (($a >> 13) | ($a << 19)) ^ (($a >> 22) | ($a << 10))) & 0xffffffff}]
set maj [expr {($a & $b) ^ ($a & $c) ^ ($b & $c)}]
set T2 [expr {($S0 + $maj) & 0xffffffff}]
set h $g; set g $f; set f $e; set e [expr {($d + $T1) & 0xffffffff}]
set d $c; set c $b; set b $a; set a [expr {($T1 + $T2) & 0xffffffff}]
}
set h0 [expr {($h0 + $a) & 0xffffffff}]; set h1 [expr {($h1 + $b) & 0xffffffff}]
set h2 [expr {($h2 + $c) & 0xffffffff}]; set h3 [expr {($h3 + $d) & 0xffffffff}]
set h4 [expr {($h4 + $e) & 0xffffffff}]; set h5 [expr {($h5 + $f) & 0xffffffff}]
set h6 [expr {($h6 + $g) & 0xffffffff}]; set h7 [expr {($h7 + $h) & 0xffffffff}]
}
return [format %08x%08x%08x%08x%08x%08x%08x%08x $h0 $h1 $h2 $h3 $h4 $h5 $h6 $h7]
}
proc file_hex {path} {
return [hex [readbin $path]]
}
}
# ---------------------------------------------------------------- PNG decode
# Return {w h ihdr-only} without decoding pixel data.
proc png_info {data} {
if {[string range $data 0 7] ne "\x89PNG\r\n\x1a\n"} {error "not a PNG"}
binary scan [string range $data 8 15] Iua4 len type
if {$type ne "IHDR"} {error "PNG missing IHDR"}
binary scan [string range $data 16 28] IuIucucucucucu w h depth ctype comp filt inter
return [list w $w h $h depth $depth colortype $ctype interlace $inter]
}
# Decode an 8-bit RGBA non-interlaced PNG (what rsvg-convert emits) into rows of
# RGBA byte values: a list of $h rows, each a flat list of $w*4 ints, top-down.
proc png_decode_rgba_rows {data} {
set info [png_info $data]
if {[dict get $info depth] != 8 || [dict get $info colortype] != 6 || [dict get $info interlace] != 0} {
error "unsupported PNG variant (need 8-bit RGBA non-interlaced): $info"
}
set w [dict get $info w]
set h [dict get $info h]
set idat ""
set pos 8
set n [string length $data]
while {$pos < $n} {
binary scan [string range $data $pos [expr {$pos + 7}]] Iua4 len type
if {$type eq "IDAT"} {
append idat [string range $data [expr {$pos + 8}] [expr {$pos + 8 + $len - 1}]]
} elseif {$type eq "IEND"} {
break
}
incr pos [expr {12 + $len}]
}
set raw [zlib decompress $idat]
set rowbytes [expr {$w * 4}]
if {[string length $raw] != ($rowbytes + 1) * $h} {
error "PNG pixel payload length mismatch"
}
set rows {}
set prev [lrepeat $rowbytes 0]
set pos 0
for {set y 0} {$y < $h} {incr y} {
binary scan [string range $raw $pos $pos] cu f
binary scan [string range $raw [expr {$pos + 1}] [expr {$pos + $rowbytes}]] cu* cur
incr pos [expr {$rowbytes + 1}]
switch -- $f {
0 {
set recon $cur
}
1 {
set recon [lrange $cur 0 3]
for {set i 4} {$i < $rowbytes} {incr i} {
lappend recon [expr {([lindex $cur $i] + [lindex $recon [expr {$i - 4}]]) & 0xff}]
}
}
2 {
set recon {}
for {set i 0} {$i < $rowbytes} {incr i} {
lappend recon [expr {([lindex $cur $i] + [lindex $prev $i]) & 0xff}]
}
}
3 {
set recon {}
for {set i 0} {$i < 4} {incr i} {
lappend recon [expr {([lindex $cur $i] + [lindex $prev $i] / 2) & 0xff}]
}
for {set i 4} {$i < $rowbytes} {incr i} {
lappend recon [expr {([lindex $cur $i] + ([lindex $recon [expr {$i - 4}]] + [lindex $prev $i]) / 2) & 0xff}]
}
}
4 {
set recon {}
for {set i 0} {$i < $rowbytes} {incr i} {
set x [lindex $cur $i]
if {$i >= 4} {
set a [lindex $recon [expr {$i - 4}]]
set c [lindex $prev [expr {$i - 4}]]
} else {
set a 0
set c 0
}
set b [lindex $prev $i]
set p [expr {$a + $b - $c}]
set pa [expr {abs($p - $a)}]
set pb [expr {abs($p - $b)}]
set pc [expr {abs($p - $c)}]
if {$pa <= $pb && $pa <= $pc} {
set pred $a
} elseif {$pb <= $pc} {
set pred $b
} else {
set pred $c
}
lappend recon [expr {($x + $pred) & 0xff}]
}
}
default {
error "PNG row $y: unknown filter type $f"
}
}
set prev $recon
lappend rows $recon
}
return $rows
}
# ---------------------------------------------------------------- ICO assembly
# DIB icon entry: BITMAPINFOHEADER (doubled height), bottom-up BGRA XOR data,
# then the 1bpp AND mask (bit set where fully transparent, rows padded to 4 bytes).
proc dib_entry {s rows} {
set hdr [binary format iiissiiiiii 40 $s [expr {$s * 2}] 1 32 0 [expr {$s * $s * 4}] 0 0 0 0]
set xor {}
set mask {}
set maskrowbytes [expr {(($s + 31) / 32) * 4}]
for {set y [expr {$s - 1}]} {$y >= 0} {incr y -1} {
set row [lindex $rows $y]
set maskrow [lrepeat $maskrowbytes 0]
set x 0
foreach {r g b a} $row {
lappend xor $b $g $r $a
if {$a == 0} {
set bi [expr {$x / 8}]
lset maskrow $bi [expr {[lindex $maskrow $bi] | (0x80 >> ($x % 8))}]
}
incr x
}
lappend mask {*}$maskrow
}
return $hdr[binary format c* $xor][binary format c* $mask]
}
# Build the .ico image (as a binary string) from a list of {size payload} pairs.
proc ico_assemble {entries} {
set dir [binary format sss 0 1 [llength $entries]]
set offset [expr {6 + 16 * [llength $entries]}]
set body ""
foreach entry $entries {
lassign $entry s payload
set dim [expr {$s >= 256 ? 0 : $s}]
set len [string length $payload]
append dir [binary format ccccssii $dim $dim 0 0 1 32 $len $offset]
append body $payload
incr offset $len
}
return $dir$body
}
proc rasterize {svg size png} {
if {[catch {exec -ignorestderr rsvg-convert -w $size -h $size -o $png $svg} err]} {
if {[string match "*couldn't execute*" $err]} {
error "rsvg-convert not found on PATH - install librsvg (any platform) to rasterize"
}
error "rsvg-convert failed at ${size}px for $svg: $err"
}
set data [readbin $png]
set info [png_info $data]
if {[dict get $info w] != $size || [dict get $info h] != $size} {
error "rasterizer returned [dict get $info w]x[dict get $info h] for ${size}px"
}
return $data
}
# Build the full .ico binary for svg at the given sizes.
proc ico_build {svg sizes allpng workdir} {
set entries {}
foreach s $sizes {
set png [file join $workdir "$s.png"]
set data [rasterize $svg $s $png]
if {$allpng || $s == 256} {
lappend entries [list $s $data]
} else {
lappend entries [list $s [dib_entry $s [png_decode_rgba_rows $data]]]
}
}
return [ico_assemble $entries]
}
# ---------------------------------------------------------------- sidecars
proc toml_escape {s} {
return [string map {\\ \\\\ \" \\\"} $s]
}
# Sidecar content for artifact (bytes given) derived from source via options.
proc sidecar_content {artifactpath artifactbytes sourcepath role options} {
set srcrel [relpath [file dirname $artifactpath] $sourcepath]
set lines {}
lappend lines "# assetorigin provenance sidecar (schema 1) for the asset named by this file"
lappend lines "# with the .assetorigin.toml suffix stripped. Optional and advisory."
lappend lines "schema = 1"
lappend lines "role = \"[toml_escape $role]\""
lappend lines "hash = \"sha256:[sha256::hex $artifactbytes]\""
lappend lines "source = \"[toml_escape $srcrel]\""
lappend lines "source_hash = \"sha256:[sha256::file_hex $sourcepath]\""
lappend lines "generator = \"make-ico.tcl\""
lappend lines "options = \"[toml_escape $options]\""
return "[join $lines \n]\n"
}
# Write content to path only when bytes differ; return unchanged|updated|created.
proc write_if_changed {path content} {
if {[file exists $path]} {
if {[readbin $path] eq $content} {
return unchanged
}
writebin $path $content
return updated
}
writebin $path $content
return created
}
# Compare content against path without writing; return match|DIFFER|MISSING.
proc check_against {path content} {
if {![file exists $path]} {return MISSING}
if {[readbin $path] eq $content} {return match}
return DIFFER
}
# ---------------------------------------------------------------- subcommands
proc opts_parse {argl spec} {
# spec: dict option -> default; boolean options have default 0 and take no value
set out $spec
for {set i 0} {$i < [llength $argl]} {incr i} {
set o [lindex $argl $i]
if {![dict exists $spec $o]} {error "unknown option $o (expected: [dict keys $spec])"}
if {[dict get $spec $o] eq "0"} {
dict set out $o 1
} else {
incr i
if {$i >= [llength $argl]} {error "option $o needs a value"}
dict set out $o [lindex $argl $i]
}
}
return $out
}
proc workdir_make {requested} {
if {$requested ne ""} {
file mkdir $requested
return [list $requested 0]
}
set base ""
foreach v {TEMP TMP TMPDIR} {
if {[info exists ::env($v)] && $::env($v) ne ""} {
set base $::env($v)
break
}
}
if {$base eq ""} {set base /tmp}
set dir [file join $base "make-ico-[pid]"]
file mkdir $dir
return [list $dir 1]
}
proc cmd_ico {argl} {
set o [opts_parse $argl {-svg {} -ico {} -work {} -sizes {16 24 32 48 64 128 256} -allpng 0 -role {}}]
if {[dict get $o -svg] eq "" || [dict get $o -ico] eq ""} {error "ico: -svg and -ico are required"}
set role [dict get $o -role]
if {$role eq ""} {set role [expr {[dict get $o -allpng] ? "web" : "pe-resource"}]}
lassign [workdir_make [dict get $o -work]] work cleanup
try {
set bytes [ico_build [dict get $o -svg] [dict get $o -sizes] [dict get $o -allpng] $work]
set state [write_if_changed [dict get $o -ico] $bytes]
set options "ico -sizes {[dict get $o -sizes]}"
if {[dict get $o -allpng]} {append options " -allpng"}
set sc [sidecar_content [dict get $o -ico] $bytes [dict get $o -svg] $role $options]
set scstate [write_if_changed "[dict get $o -ico].assetorigin.toml" $sc]
puts "wrote [dict get $o -ico] ([string length $bytes] bytes, [llength [dict get $o -sizes]] entries: [join [dict get $o -sizes] ,]) \[$state\]; sidecar \[$scstate\]"
} finally {
if {$cleanup} {file delete -force $work}
}
}
proc cmd_raster {argl} {
set o [opts_parse $argl {-svg {} -png {} -size {} -role web}]
if {[dict get $o -svg] eq "" || [dict get $o -png] eq "" || [dict get $o -size] eq ""} {
error "raster: -svg, -png and -size are required"
}
set size [dict get $o -size]
set bytes [rasterize [dict get $o -svg] $size [dict get $o -png]]
set sc [sidecar_content [dict get $o -png] $bytes [dict get $o -svg] [dict get $o -role] "raster -size $size"]
set scstate [write_if_changed "[dict get $o -png].assetorigin.toml" $sc]
puts "wrote [dict get $o -png] ([string length $bytes] bytes, ${size}px) sidecar \[$scstate\]"
}
proc cmd_all {argl} {
global MANIFEST
set o [opts_parse $argl {-check 0 -work {}}]
set check [dict get $o -check]
set here [file dirname [file normalize [info script]]]
lassign [workdir_make [dict get $o -work]] work cleanup
set drift 0
try {
foreach row $MANIFEST {
lassign $row kind src artifact sizes allpng role
set svg [file join $here $src]
set out [file join $here $artifact]
if {$kind eq "ico"} {
set subwork [file join $work [string map {/ _} $artifact]]
file mkdir $subwork
set bytes [ico_build $svg $sizes $allpng $subwork]
set options "ico -sizes {$sizes}"
if {$allpng} {append options " -allpng"}
} else {
set bytes [rasterize $svg $sizes [file join $work [file tail $artifact]]]
set options "raster -size $sizes"
}
set sc [sidecar_content $out $bytes $svg $role $options]
if {$check} {
set astate [check_against $out $bytes]
set sstate [check_against "$out.assetorigin.toml" $sc]
if {$astate ne "match"} {set drift 1}
if {$sstate ne "match"} {set drift 1}
puts [format "%-33s %-7s ([string length $bytes] bytes) sidecar: %s" $artifact $astate $sstate]
} else {
set astate [write_if_changed $out $bytes]
set sstate [write_if_changed "$out.assetorigin.toml" $sc]
puts [format "%-33s %-9s ([string length $bytes] bytes) sidecar: %s" $artifact $astate $sstate]
}
}
} finally {
if {$cleanup} {file delete -force $work}
}
if {$check && $drift} {
puts "DRIFT: one or more artifacts or sidecars do not match a fresh regeneration"
exit 1
}
}
# ---------------------------------------------------------------- dispatch
set cmd [lindex $argv 0]
set rest [lrange $argv 1 end]
switch -- $cmd {
ico {cmd_ico $rest}
raster {cmd_raster $rest}
all {cmd_all $rest}
default {
puts stderr "usage: tclsh make-ico.tcl all ?-check?"
puts stderr " tclsh make-ico.tcl ico -svg <in.svg> -ico <out.ico> ?-sizes {16 24 32 48 64 128 256}? ?-allpng? ?-role <role>? ?-work <dir>?"
puts stderr " tclsh make-ico.tcl raster -svg <in.svg> -png <out.png> -size <n> ?-role <role>?"
exit 2
}
}

9
src/assets/logo/project-default/icon.ico.assetorigin.toml

@ -0,0 +1,9 @@
# assetorigin provenance sidecar (schema 1) for the asset named by this file
# with the .assetorigin.toml suffix stripped. Optional and advisory.
schema = 1
role = "pe-resource"
hash = "sha256:e652feddf19468ad458d48584ce8af165d1594bbedb408f5a53fe68697872a33"
source = "icon.svg"
source_hash = "sha256:31d1e3326d479618fb3d190c409981b6a785af4d8f25845a4bcce6e27410ee30"
generator = "make-ico.tcl"
options = "ico -sizes {16 24 32 48 64 128 256}"

9
src/assets/logo/punk-mark.ico.assetorigin.toml

@ -0,0 +1,9 @@
# assetorigin provenance sidecar (schema 1) for the asset named by this file
# with the .assetorigin.toml suffix stripped. Optional and advisory.
schema = 1
role = "pe-resource"
hash = "sha256:da875dd08a7665162ead7bbb575e2cb8b82a12d6654579c1e389d97198c19a26"
source = "punk-mark.svg"
source_hash = "sha256:60396baad2f42747211fb43bd452179f73e9165105fd3361376a47414e65e22a"
generator = "make-ico.tcl"
options = "ico -sizes {16 24 32 48 64 128 256}"

9
src/assets/logo/web/apple-touch-icon.png.assetorigin.toml

@ -0,0 +1,9 @@
# assetorigin provenance sidecar (schema 1) for the asset named by this file
# with the .assetorigin.toml suffix stripped. Optional and advisory.
schema = 1
role = "web"
hash = "sha256:02350f65f4342785a39738c456e516125b153a17392a75a4d0e4443c6f8ff328"
source = "icon-opaque.svg"
source_hash = "sha256:604f07ecba198bfd299cb978587ac460929892b0b548c56a26cce5204e3e1a1e"
generator = "make-ico.tcl"
options = "raster -size 180"

9
src/assets/logo/web/favicon.ico.assetorigin.toml

@ -0,0 +1,9 @@
# assetorigin provenance sidecar (schema 1) for the asset named by this file
# with the .assetorigin.toml suffix stripped. Optional and advisory.
schema = 1
role = "web"
hash = "sha256:44ca400451f6e7ecb731dd808b88de3cadda528fe79a7bc18ceb4c7d6e19a599"
source = "icon.svg"
source_hash = "sha256:1834f46d39bb4c4af70bde92ca2b9c6e6cdfceb64879e366b85d6bd705b4d091"
generator = "make-ico.tcl"
options = "ico -sizes {16 32 48} -allpng"

9
src/assets/logo/web/icon-192.png.assetorigin.toml

@ -0,0 +1,9 @@
# assetorigin provenance sidecar (schema 1) for the asset named by this file
# with the .assetorigin.toml suffix stripped. Optional and advisory.
schema = 1
role = "web"
hash = "sha256:03e92d71538697a890bb52d9fff71316bd0c9125074897c76300690b90e8f63e"
source = "icon-opaque.svg"
source_hash = "sha256:604f07ecba198bfd299cb978587ac460929892b0b548c56a26cce5204e3e1a1e"
generator = "make-ico.tcl"
options = "raster -size 192"

9
src/assets/logo/web/icon-512.png.assetorigin.toml

@ -0,0 +1,9 @@
# assetorigin provenance sidecar (schema 1) for the asset named by this file
# with the .assetorigin.toml suffix stripped. Optional and advisory.
schema = 1
role = "web"
hash = "sha256:4c2c3d9474a4e5de2b110dc927cb1f32fcb0c58d610864af42147fcf9bde3b1f"
source = "icon-opaque.svg"
source_hash = "sha256:604f07ecba198bfd299cb978587ac460929892b0b548c56a26cce5204e3e1a1e"
generator = "make-ico.tcl"
options = "raster -size 512"

9
src/runtime/punkshell.ico.assetorigin.toml

@ -0,0 +1,9 @@
# assetorigin provenance sidecar (schema 1) for the asset named by this file
# with the .assetorigin.toml suffix stripped. Optional and advisory.
# punkshell.ico is a byte-identical derived copy of the generated logo artifact
# (hash equals source_hash); it is consumed by builds, never edited here.
schema = 1
role = "pe-resource"
hash = "sha256:da875dd08a7665162ead7bbb575e2cb8b82a12d6654579c1e389d97198c19a26"
source = "../assets/logo/punk-mark.ico"
source_hash = "sha256:da875dd08a7665162ead7bbb575e2cb8b82a12d6654579c1e389d97198c19a26"
Loading…
Cancel
Save