Files
volumen/internal/imagefile/imagefile.go
T

464 lines
13 KiB
Go
Raw Permalink Normal View History

2026-09-18 12:03:35 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Package imagefile identifies the image formats volumen accepts, from
// the bytes rather than from a name or a declared type.
//
// Only the three formats below are stored, and the media route serves
// nothing else: a browser is never handed a document from a directory
// that an archive or an upload can write to. An SVG is a document of
// sorts, so the media route serves it sandboxed and the signature test
// here demands the root element really be <svg>.
package imagefile
import (
"bytes"
"encoding/binary"
"path/filepath"
"strconv"
"strings"
)
// The canonical extensions and the Content-Type each is served with.
const (
ExtWebP = ".webp"
ExtAVIF = ".avif"
ExtSVG = ".svg"
MIMEWebP = "image/webp"
MIMEAVIF = "image/avif"
MIMESVG = "image/svg+xml"
)
var mimeTypes = map[string]string{
ExtWebP: MIMEWebP,
ExtAVIF: MIMEAVIF,
ExtSVG: MIMESVG,
}
// ContentType returns the Content-Type for an allowed image name, or ""
// when the name does not carry an allowed extension.
func ContentType(name string) string {
return mimeTypes[strings.ToLower(filepath.Ext(filepath.Base(name)))]
}
// Allowed reports whether name carries an allowed extension.
func Allowed(name string) bool { return ContentType(name) != "" }
// SignatureMatches reports whether data carries the signature of the
// format the extension names.
func SignatureMatches(data []byte, ext string) bool {
switch strings.ToLower(ext) {
case ExtWebP:
// RIFF....WEBP, twelve bytes of magic.
return len(data) >= 12 &&
bytes.Equal(data[:4], []byte("RIFF")) &&
bytes.Equal(data[8:12], []byte("WEBP"))
case ExtAVIF:
// ISO BMFF: bytes 4..7 are the box size, 8..11 are "ftyp",
// followed by the major brand.
if len(data) < 16 || !bytes.Equal(data[4:8], []byte("ftyp")) {
return false
}
brand := data[8:12]
return bytes.Equal(brand, []byte("avif")) || bytes.Equal(brand, []byte("avis"))
case ExtSVG:
// The root element must be <svg>: XML text that opens with
// another document (an XHTML page, an SVGZ masquerading as a
// plain .svg) is not an accepted image.
return svgRootTag(data) != nil
}
return false
}
// Detect returns the canonical extension for data, or "" when the bytes
// carry no accepted signature.
func Detect(data []byte) string {
if SignatureMatches(data, ExtWebP) {
return ExtWebP
}
if SignatureMatches(data, ExtAVIF) {
return ExtAVIF
}
if SignatureMatches(data, ExtSVG) {
return ExtSVG
}
return ""
}
// Dimensions reports the pixel size of a WebP, AVIF or SVG image, reading
// only the container headers or the root element, and ok=false when the
// bytes carry no size it can trust. A caller that holds a whole file can
// pass it whole; a 64 KiB prefix of a media file carries every header
// this reads.
func Dimensions(data []byte) (width, height int, ok bool) {
if w, h, ok := webpDimensions(data); ok {
return w, h, true
}
if w, h, ok := avifDimensions(data); ok {
return w, h, true
}
return svgDimensions(data)
}
// webpDimensions reads the size out of the three chunk shapes WebP
// uses: VP8 (lossy), VP8L (lossless) and VP8X (extended canvas).
func webpDimensions(data []byte) (int, int, bool) {
if len(data) < 20 || !bytes.Equal(data[:4], []byte("RIFF")) ||
!bytes.Equal(data[8:12], []byte("WEBP")) {
return 0, 0, false
}
switch string(data[12:16]) {
case "VP8 ":
// After the frame tag sit the three sync bytes 0x9d 0x01 0x2a,
// then the width and the height as 16-bit little-endian values
// whose top two bits carry a scale code.
if len(data) < 30 || data[23] != 0x9d || data[24] != 0x01 || data[25] != 0x2a {
return 0, 0, false
}
w := int(binary.LittleEndian.Uint16(data[26:28]) & 0x3fff)
h := int(binary.LittleEndian.Uint16(data[28:30]) & 0x3fff)
return w, h, w > 0 && h > 0
case "VP8L":
// The payload opens with 0x2f and packs width-1 into 14 bits
// followed by height-1 into 14 more, least significant first.
if len(data) < 25 || data[20] != 0x2f {
return 0, 0, false
}
bits := uint32(data[21]) | uint32(data[22])<<8 | uint32(data[23])<<16 | uint32(data[24])<<24
w := int(bits&0x3fff) + 1
h := int((bits>>14)&0x3fff) + 1
return w, h, true
case "VP8X":
// The canvas size sits as two 24-bit little-endian minus-one
// values after the flags and three reserved bytes.
if len(data) < 30 {
return 0, 0, false
}
w := int(uint32(data[24])|uint32(data[25])<<8|uint32(data[26])<<16) + 1
h := int(uint32(data[27])|uint32(data[28])<<8|uint32(data[29])<<16) + 1
return w, h, true
}
return 0, 0, false
}
// avifDimensions walks the ISO-BMFF boxes of an AVIF: the meta box
// names the primary item (pitm) and associates it with properties
// (ipma inside iprp); the ispe property it points at carries the image
// extent. Anything the walk cannot certify is reported as unknown
// rather than guessed.
func avifDimensions(data []byte) (int, int, bool) {
if len(data) < 16 || !bytes.Equal(data[4:8], []byte("ftyp")) {
return 0, 0, false
}
var meta []byte
for _, b := range readBoxes(data) {
if b.typ == "meta" {
meta = b.body
break
}
}
if meta == nil || len(meta) < 4 {
return 0, 0, false
}
// meta is a full box: four bytes of version and flags precede the
// children.
children := readBoxes(meta[4:])
var primary uint64
var properties []box
var associations []box
for _, b := range children {
switch b.typ {
case "pitm":
if len(b.body) < 1 {
return 0, 0, false
}
// The bounds name the whole item id: a box whose body stops
// short of it is refused rather than read past, because a
// truncated box at the end of the buffer has no bytes left
// to read and the slice would run out of range.
if b.body[0] == 0 {
if len(b.body) < 6 {
return 0, 0, false
}
primary = uint64(binary.BigEndian.Uint16(b.body[4:6]))
} else {
if len(b.body) < 8 {
return 0, 0, false
}
primary = uint64(binary.BigEndian.Uint32(b.body[4:8]))
}
case "iprp":
for _, inner := range readBoxes(b.body) {
switch inner.typ {
case "ipco":
properties = readBoxes(inner.body)
case "ipma":
associations = append(associations, inner)
}
}
}
}
if primary == 0 || properties == nil {
return 0, 0, false
}
for _, assoc := range associations {
for _, propertyIndex := range readAssociations(assoc) {
if propertyIndex.item != primary {
continue
}
// Property indices are one-based over ipco's children.
if propertyIndex.index == 0 || propertyIndex.index > len(properties) {
continue
}
boxed := properties[propertyIndex.index-1]
// ispe is a full box: version and flags, then the width and
// the height as big-endian 32-bit values.
if boxed.typ != "ispe" || len(boxed.body) < 12 {
continue
}
w := int(binary.BigEndian.Uint32(boxed.body[4:8]))
h := int(binary.BigEndian.Uint32(boxed.body[8:12]))
return w, h, w > 0 && h > 0
}
}
return 0, 0, false
}
// boxAssociation pairs an item id with the one-based property index one
// of its associations names.
type boxAssociation struct {
item uint64
index int
}
// readAssociations decodes an ipma box's entries into item/property
// pairs, honouring both the 16- and 32-bit item id sizes and the
// 7- and 15-bit index sizes the flags select.
func readAssociations(b box) []boxAssociation {
if len(b.body) < 12 {
return nil
}
flags := uint32(b.body[1])<<16 | uint32(b.body[2])<<8 | uint32(b.body[3])
wideItems := flags&0b10 != 0
wideIndexes := flags&0b01 != 0
idSize, indexSize := 2, 1
if wideItems {
idSize = 4
}
if wideIndexes {
indexSize = 2
}
count := int(binary.BigEndian.Uint32(b.body[4:8]))
out := make([]boxAssociation, 0, count)
pos := 8
for range count {
if pos+idSize+1 > len(b.body) {
return out
}
var item uint64
if wideItems {
item = uint64(binary.BigEndian.Uint32(b.body[pos : pos+4]))
} else {
item = uint64(binary.BigEndian.Uint16(b.body[pos : pos+2]))
}
pos += idSize
assocCount := int(b.body[pos])
pos++
for range assocCount {
if pos+indexSize > len(b.body) {
return out
}
var index int
if wideIndexes {
// The essential bit rides the top bit of a 15-bit index.
index = int(binary.BigEndian.Uint16(b.body[pos:pos+2]) & 0x7fff)
} else {
index = int(b.body[pos] & 0x7f)
}
pos += indexSize
out = append(out, boxAssociation{item: item, index: index})
}
}
return out
}
// box is one ISO-BMFF box: its type and the body after the header.
type box struct {
typ string
body []byte
}
// readBoxes splits a run of sibling boxes. A size of zero means "to the
// end of the input" and a size of one promotes to a 64-bit size; both
// are honoured, and a truncated or empty box stops the walk.
func readBoxes(data []byte) []box {
var out []box
pos := 0
for pos+8 <= len(data) {
size := uint64(binary.BigEndian.Uint32(data[pos : pos+4]))
typ := string(data[pos+4 : pos+8])
header := 8
if size == 1 {
if pos+16 > len(data) {
break
}
size = binary.BigEndian.Uint64(data[pos+8 : pos+16])
header = 16
}
if size < uint64(header) {
break
}
end := min(uint64(pos)+size, uint64(len(data)))
out = append(out, box{typ: typ, body: data[pos+header : end]})
if end <= uint64(pos)+8 {
break
}
pos = int(end)
}
return out
}
// svgRootTag returns the start tag of the root <svg> element, or nil when
// the bytes are not an SVG document. The XML prolog, comments and any
// leading declaration are stepped over on the way to it; anything that
// opens the document with another root element is refused, so an XHTML
// page never takes the .svg extension it is served under.
func svgRootTag(data []byte) []byte {
s := bytes.TrimPrefix(data, []byte("\ufeff"))
for {
s = bytes.TrimLeft(s, " \t\r\n")
switch {
case bytes.HasPrefix(s, []byte("<?xml")):
end := bytes.Index(s, []byte("?>"))
if end < 0 {
return nil
}
s = s[end+2:]
case bytes.HasPrefix(s, []byte("<!--")):
end := bytes.Index(s, []byte("-->"))
if end < 0 {
return nil
}
s = s[end+3:]
case bytes.HasPrefix(s, []byte("<!")):
end := bytes.IndexByte(s, '>')
if end < 0 {
return nil
}
s = s[end+1:]
case bytes.HasPrefix(s, []byte("<svg")):
if len(s) > 4 {
switch s[4] {
case ' ', '\t', '\n', '\r', '>', '/':
default:
return nil // <svgrect and friends are not a root
}
}
end := bytes.IndexByte(s, '>')
if end < 0 {
return nil
}
return s[:end+1]
default:
return nil
}
}
}
// svgDimensions reads the size off the root element: the width and height
// attributes when both are bare lengths, or the viewBox box otherwise. A
// percentage length carries no size the media library could show.
func svgDimensions(data []byte) (int, int, bool) {
tag := svgRootTag(data)
if tag == nil {
return 0, 0, false
}
if width, ok := svgLength(tag, "width"); ok {
if height, ok := svgLength(tag, "height"); ok {
return width, height, width > 0 && height > 0
}
}
if box, ok := svgAttr(tag, "viewBox"); ok {
parts := strings.FieldsFunc(box, func(r rune) bool {
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
if len(parts) == 4 {
w, errW := strconv.ParseFloat(parts[2], 64)
h, errH := strconv.ParseFloat(parts[3], 64)
if errW == nil && errH == nil && w >= 1 && h >= 1 {
return int(w), int(h), true
}
}
}
return 0, 0, false
}
// svgLength reads one of the root element's size attributes as a pixel
// length: a plain number or one written in px.
func svgLength(tag []byte, name string) (int, bool) {
value, ok := svgAttr(tag, name)
if !ok {
return 0, false
}
value = strings.TrimSuffix(strings.TrimSuffix(value, "px"), "PX")
if strings.TrimLeftFunc(value, func(r rune) bool {
return (r >= '0' && r <= '9') || r == '.'
}) != "" {
return 0, false // a unit the media library does not convert
}
n, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, false
}
return int(n), n >= 0
}
// svgAttr returns the quoted value of one attribute of a start tag. The
// scan is deliberately shallow: it reads the plain attributes the size
// of a figure is written with and gives up on anything else.
func svgAttr(tag []byte, name string) (string, bool) {
s := string(tag)
i := strings.IndexByte(s, ' ') // past "<svg"
if i < 0 {
return "", false
}
for i < len(s) {
for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r') {
i++
}
start := i
for i < len(s) && s[i] != '=' && !isSVGTagSpace(s[i]) && s[i] != '>' && s[i] != '/' {
i++
}
key := s[start:i]
if i >= len(s) || s[i] != '=' {
return "", false
}
i++
if i >= len(s) || (s[i] != '"' && s[i] != '\'') {
return "", false
}
quote := s[i]
i++
valueStart := i
for i < len(s) && s[i] != quote {
i++
}
if i >= len(s) {
return "", false
}
value := s[valueStart:i]
i++
if key == name {
return value, true
}
}
return "", false
}
func isSVGTagSpace(b byte) bool {
return b == ' ' || b == '\t' || b == '\n' || b == '\r'
}