From f529f44ea7a91d5f2bf21c4a6ad5003dff38c688 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Tue, 28 Jul 2026 23:16:25 +1000 Subject: [PATCH] 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 .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 --- src/assets/logo/AGENTS.md | 78 ++- .../punk-mark-solid.ico.assetorigin.toml | 9 + src/assets/logo/make-ico.ps1 | 111 ---- src/assets/logo/make-ico.tcl | 475 ++++++++++++++++++ .../project-default/icon.ico.assetorigin.toml | 9 + .../logo/punk-mark.ico.assetorigin.toml | 9 + .../web/apple-touch-icon.png.assetorigin.toml | 9 + .../logo/web/favicon.ico.assetorigin.toml | 9 + .../logo/web/icon-192.png.assetorigin.toml | 9 + .../logo/web/icon-512.png.assetorigin.toml | 9 + src/runtime/punkshell.ico.assetorigin.toml | 9 + 11 files changed, 613 insertions(+), 123 deletions(-) create mode 100644 src/assets/logo/alternative/punk-mark-solid.ico.assetorigin.toml delete mode 100644 src/assets/logo/make-ico.ps1 create mode 100644 src/assets/logo/make-ico.tcl create mode 100644 src/assets/logo/project-default/icon.ico.assetorigin.toml create mode 100644 src/assets/logo/punk-mark.ico.assetorigin.toml create mode 100644 src/assets/logo/web/apple-touch-icon.png.assetorigin.toml create mode 100644 src/assets/logo/web/favicon.ico.assetorigin.toml create mode 100644 src/assets/logo/web/icon-192.png.assetorigin.toml create mode 100644 src/assets/logo/web/icon-512.png.assetorigin.toml create mode 100644 src/runtime/punkshell.ico.assetorigin.toml diff --git a/src/assets/logo/AGENTS.md b/src/assets/logo/AGENTS.md index 080b893d..36cb7591 100644 --- a/src/assets/logo/AGENTS.md +++ b/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 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 - 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 @@ -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 "fix" it by making it transparent. +## Provenance records (assetorigin sidecars) + +Canonical format documentation for `*.assetorigin.toml` sidecars (G-135). + +- `.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 = ""` - 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 -- Regenerate after any SVG edit: - `pwsh src/assets/logo/make-ico.ps1 -Svg -Ico -Work ` - It rasterizes with `rsvg-convert` (librsvg, true alpha) and assembles the - ICONDIR by hand. ImageMagick is not used for assembly — its ICO writer picks - its own entry formats. -- `-Sizes 16,32,48` selects which entries to emit; `-AllPng` makes every entry +- Regenerate after any SVG edit: `tclsh src/assets/logo/make-ico.tcl all` + rebuilds every recorded artifact (three PE icos, the web favicon, the web + rasters) from the manifest inside the script, rewriting files only when + bytes change and refreshing each artifact's assetorigin sidecar. `all + -check` builds everything but writes nothing and exits 1 on any difference - + 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 -ico + ?-sizes {16 32 48}? ?-allpng?` and + `tclsh make-ico.tcl raster -svg -png -size `. + `-sizes` selects which entries to emit; `-allpng` makes every entry 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 - that cannot read PNG entries. -- Regenerate the web rasters from `web/icon-opaque.svg`: - `rsvg-convert -w -h -o web/icon-opaque.svg` at 180 - (apple-touch-icon), 192 and 512 (webmanifest). + defaults. A `-sizes` list without `256` yields an all-DIB file for consumers + that cannot read PNG entries. Every subcommand (re)writes the produced + artifact's sidecar - regeneration cannot leave a stale record. +- The web rasters (`apple-touch-icon.png`, `icon-192.png`, `icon-512.png`) are + 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 loses the alpha channel the icon depends on. - 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 +- 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 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`. diff --git a/src/assets/logo/alternative/punk-mark-solid.ico.assetorigin.toml b/src/assets/logo/alternative/punk-mark-solid.ico.assetorigin.toml new file mode 100644 index 00000000..750e606d --- /dev/null +++ b/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}" diff --git a/src/assets/logo/make-ico.ps1 b/src/assets/logo/make-ico.ps1 deleted file mode 100644 index a9f82ea9..00000000 --- a/src/assets/logo/make-ico.ps1 +++ /dev/null @@ -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 ',')) diff --git a/src/assets/logo/make-ico.tcl b/src/assets/logo/make-ico.tcl new file mode 100644 index 00000000..76f37a80 --- /dev/null +++ b/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 .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 -ico ?-sizes {16 24 32 48 64 128 256}? ?-allpng? ?-role ? ?-work ? +# tclsh make-ico.tcl raster -svg -png -size ?-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 -ico ?-sizes {16 24 32 48 64 128 256}? ?-allpng? ?-role ? ?-work ?" + puts stderr " tclsh make-ico.tcl raster -svg -png -size ?-role ?" + exit 2 + } +} diff --git a/src/assets/logo/project-default/icon.ico.assetorigin.toml b/src/assets/logo/project-default/icon.ico.assetorigin.toml new file mode 100644 index 00000000..6e938f4b --- /dev/null +++ b/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}" diff --git a/src/assets/logo/punk-mark.ico.assetorigin.toml b/src/assets/logo/punk-mark.ico.assetorigin.toml new file mode 100644 index 00000000..d5cdacb1 --- /dev/null +++ b/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}" diff --git a/src/assets/logo/web/apple-touch-icon.png.assetorigin.toml b/src/assets/logo/web/apple-touch-icon.png.assetorigin.toml new file mode 100644 index 00000000..1a6ffad7 --- /dev/null +++ b/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" diff --git a/src/assets/logo/web/favicon.ico.assetorigin.toml b/src/assets/logo/web/favicon.ico.assetorigin.toml new file mode 100644 index 00000000..4a066a18 --- /dev/null +++ b/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" diff --git a/src/assets/logo/web/icon-192.png.assetorigin.toml b/src/assets/logo/web/icon-192.png.assetorigin.toml new file mode 100644 index 00000000..787f6786 --- /dev/null +++ b/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" diff --git a/src/assets/logo/web/icon-512.png.assetorigin.toml b/src/assets/logo/web/icon-512.png.assetorigin.toml new file mode 100644 index 00000000..13f346d9 --- /dev/null +++ b/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" diff --git a/src/runtime/punkshell.ico.assetorigin.toml b/src/runtime/punkshell.ico.assetorigin.toml new file mode 100644 index 00000000..47893807 --- /dev/null +++ b/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"