diff --git a/CHANGELOG.md b/CHANGELOG.md index eadfc3b..4abeaa0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,45 @@ because it turns other people's test suites red. ## [Unreleased] +### Breaking + +- **A generated GIF now moves, so its bytes are different.** A GIF is the one + picture format here that can hold more than one frame, and a still one told + you nothing about how the system under test treats an animation - whether it + keeps it, flattens it to the first frame, or re-encodes it. Every GIF now + carries a marker that travels across the picture in three frames, and the + manifest says `animated` and `frame_count` for each file. + + Two things change with it. The smallest GIF this tool will write goes from + 41 B to 114 B, because the number a format announces as its minimum has to be + a number a plain run accepts, and a plain run animates. And the bytes of every + GIF change, so a suite pinning their hashes will go red. + + **The way back is `--set frames=1`**, or `frames: 1` on a target in a recipe. + That takes the plain encoder and writes the same bytes this tool wrote before, + to the byte - there is a pinned hash proving it. + ### Added +- **WEBP, the twenty second format.** Lossless, one frame, no alpha. + `tfg generate --format webp --size 300kb` writes a picture worth 300 kB rather + than a thumbnail followed by filler, because the encoder measures out three + bytes a pixel and the size is therefore arithmetic - the same shape as BMP and + TIFF. `width` and `height` can be set, and naming one lets the other be worked + out from the size. The smallest WEBP this produces is 148 B. + + **Every size from that minimum upwards is reachable, with no gaps.** No other + format here manages that. A WebP is made of RIFF chunks and a chunk always + costs an even number of bytes, so the padding is in two parts: a private chunk + for the bulk, and up to seven bytes after it for the rest. + + There is no lossy variant and no `quality`. Lossy WebP is VP8, which is a + different codec rather than a setting, and `tfg formats webp` says what this + build writes rather than implying more. + +- **`frames` on GIF**, from 1 to 60, default 3. How many frames the animation + has. Set it to 1 for a still picture. + - **TIFF, the twenty first format.** Uncompressed, RGB, one page, little-endian. `tfg generate --format tiff --size 300kb` writes a picture worth 300 kB rather than a thumbnail followed by filler, because TIFF stores its pixels diff --git a/README.md b/README.md index 5819016..e292550 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ **Testing Files Generator** is a tool for QA engineers and developers who need real files to test against - an upload form, a parser, anything that takes a file and -has an opinion about it. You pick one of its 21 formats and the size you want, +has an opinion about it. You pick one of its 22 formats and the size you want, and you get **exactly that**: ask for a 10 MB PDF and you get a PDF that a reader will open, at 10 MB to the byte. Every run also leaves a manifest saying **what your system should do with each file**, which is the part other generators leave @@ -23,7 +23,7 @@ needs it finds out it exists. - **Hit an exact size, to the byte** - ask for 10485761 bytes and get exactly that, never a silently rounded file. -- **Write 21 real formats** - a generated PNG opens in an image viewer, a DOCX +- **Write 22 real formats** - a generated PNG opens in an image viewer, a DOCX opens in Word, a ZIP extracts. Not padded zeros with an extension. - **Say what should happen to each file** - the manifest carries an expected outcome, so your test reads the assertion instead of you writing it out. @@ -76,18 +76,18 @@ reference is below it. ## 📁 Formats it generates -Twenty one, and every one is a **real file of that format** - it opens in the +Twenty two, and every one is a **real file of that format** - it opens in the software that owns it, at the exact size you asked for: | group | formats | |---|---| | 📄 **Documents** | `pdf`, `docx` (Word), `xlsx` (Excel), `pptx` (PowerPoint) | -| 🖼️ **Images** | `png`, `jpg`, `bmp`, `gif`, `ico`, `svg`, `tiff` | +| 🖼️ **Images** | `png`, `jpg`, `bmp`, `gif`, `ico`, `svg`, `tiff`, `webp` | | 📝 **Text and markup** | `txt`, `md`, `csv`, `json`, `xml`, `html`, `log` | | 🗜️ **Archives** | `zip`, `targz` (`.tar.gz`) | | 🔊 **Audio** | `wav` | -Coming next: `7z`, `webp`, `mp3`, `mp4`. +Coming next: `7z`, `mp3`, `mp4`. Most of them take settings of their own - image dimensions, JPEG quality, PDF page count, rows and columns in a spreadsheet, what goes inside an archive. See @@ -437,7 +437,7 @@ ignored quietly: `extends`, `with`, `policy`, `engine`, `defaults.fill`, ## 📁 Formats in detail -The twenty one formats are listed near the top of this file. Each is produced at an +The twenty two formats are listed near the top of this file. Each is produced at an exact size and checked against independent readers before it ships - a PNG is opened and its pixels compared, a DOCX is read back by three separate libraries, an archive is extracted. @@ -455,7 +455,8 @@ recipe. `tfg formats ` prints the allowed range or list for each: | format | settings | |---|---| | `pdf` | `pages`, `page_size` | -| `png`, `bmp`, `gif`, `tiff` | `width`, `height` | +| `png`, `bmp`, `tiff`, `webp` | `width`, `height` | +| `gif` | `width`, `height`, `frames` | | `jpg` | `width`, `height`, `quality` | | `ico` | `width`, `height`, `embed` | | `wav` | `sample_rate`, `bit_depth`, `channels`, `content` | @@ -659,17 +660,17 @@ a valid one of its format. ### Which formats are coming next? -`7z`, `webp`, `mp3` and `mp4`. +`7z`, `mp3` and `mp4`. ## 🚧 Where this is Honest scope, because a tool that oversells itself wastes your afternoon. -**Working end to end:** twenty one formats, recipes, presets, the desktop window, +**Working end to end:** twenty two formats, recipes, presets, the desktop window, `generate`, `validate`, `verify`, `cleanup`, boundary sets, archive contents, size ranges, per format settings, manifests and every exit code above. -**Not there yet:** four more formats. The preset catalogue has one entry so far. +**Not there yet:** three more formats. The preset catalogue has one entry so far. The recipe keys listed under [Not built yet](#not-built-yet). The Linux downloads are unsigned. diff --git a/internal/format/all/all.go b/internal/format/all/all.go index 029b60e..6eb05a0 100644 --- a/internal/format/all/all.go +++ b/internal/format/all/all.go @@ -26,6 +26,7 @@ import ( _ "github.com/donislawdev/TestingFilesGenerator/internal/format/tiff" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/txt" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/wav" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/webp" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/xlsx" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/xmlfile" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/zip" diff --git a/internal/format/gif/animation.go b/internal/format/gif/animation.go new file mode 100644 index 0000000..a1bccea --- /dev/null +++ b/internal/format/gif/animation.go @@ -0,0 +1,98 @@ +// Animation, which is the whole reason this format is in the set twice over. +// +// A GIF is the only picture format here that can carry more than one frame, so +// a still one cannot tell a tester whether the system under test keeps an +// animation, flattens it to the first frame, or re-encodes it. What moves is a +// single bright square, redrawn on its own each frame and disposed with +// "restore to previous" so it leaves no trail. +// +// Split out of gif.go on 2026-08-29 because that file reached 478 lines and +// this project counts how many files crowd the size ceiling. The counter is +// meant to fall. +package gif + +import ( + "fmt" + "image" + stdgif "image/gif" + "io" + "strconv" +) + +// frameCount reads the frames setting, which is the one thing about a GIF that +// no other image format here has to answer. +func frameCount(props map[string]string) (int, error) { + raw, ok := props["frames"] + if !ok || raw == "" { + return defaultFrames, nil + } + n, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("gif: frames must be a whole number, got %q", raw) + } + if n < minFrames || n > maxFrames { + return 0, fmt.Errorf("gif: frames must be between %d and %d, got %d", minFrames, maxFrames, n) + } + return n, nil +} + +// marker is where the travelling square sits on frame i, and how big it is. +// +// It rides at three quarters of the height rather than the middle, because the +// label is burned into a band across the top and the two would collide on a +// short picture. +func marker(width, height, frames, i int) (x, y, side int) { + side = width / markerDivisor + if side > maxMarkerSide { + side = maxMarkerSide + } + if side < 1 { + side = 1 + } + if side > height { + side = height + } + x = (width - side) * i / frames + y = (height - side) * 3 / 4 + return x, y, side +} + +// markerFrame is one step of the animation: the square alone, at its own +// offset, and nothing else. Keeping it to the square is what stops the frames +// costing as much as the picture, in bytes and in memory both. +func markerFrame(m memo, i int) *image.Paletted { + x, y, side := marker(m.width, m.height, m.frames, i) + img := image.NewPaletted(image.Rect(x, y, x+side, y+side), buildPalette(paletteSize(m.width, m.height))) + for j := range img.Pix { + img.Pix[j] = labelInk + } + return img +} + +func encode(w io.Writer, m memo) error { + if m.frames <= 1 { + // The plain encoder, byte for byte what this package wrote before it + // could animate. Reached by asking for frames: 1. + return stdgif.Encode(w, picture(m), &stdgif.Options{NumColors: 256}) + } + + base := picture(m) + g := &stdgif.GIF{ + LoopCount: 0, + // Without this the encoder writes no global colour table and gives + // every frame its own. Measured: a 12 px square then cost 233 B + // instead of 41 B, because 192 B of it was a second copy of the + // palette. + Config: image.Config{ColorModel: base.Palette, Width: m.width, Height: m.height}, + Image: make([]*image.Paletted, 0, m.frames), + } + g.Image = append(g.Image, base) + g.Delay = append(g.Delay, frameDelay) + g.Disposal = append(g.Disposal, stdgif.DisposalNone) + for i := 1; i < m.frames; i++ { + g.Image = append(g.Image, markerFrame(m, i)) + g.Delay = append(g.Delay, frameDelay) + g.Disposal = append(g.Disposal, stdgif.DisposalPrevious) + } + return stdgif.EncodeAll(w, g) +} diff --git a/internal/format/gif/gif.go b/internal/format/gif/gif.go index d05cadd..0703482 100644 --- a/internal/format/gif/gif.go +++ b/internal/format/gif/gif.go @@ -10,14 +10,30 @@ // byte costs five, because a sub block pays for its own length. Sizes one, // two and four bytes above the bare picture are therefore unreachable, and // the generator says so instead of rounding. +// +// The picture moves, and that is the point of the format rather than a +// decoration. A GIF is the one image format here that can carry more than one +// frame, so a still one tells a tester nothing about whether the system under +// test keeps an animation, flattens it to the first frame, or re-encodes it. +// Every file therefore carries a marker that travels across the picture, and +// frames says how many steps it takes. +// +// Only the marker is redrawn. Frames after the first are small patches placed +// where the marker lands, disposed with "restore to previous" so the one +// underneath comes back and no trail is left. Measured on 2026-08-29 against +// four independent decoders - Pillow and ffmpeg compose the frames and show a +// single marker in each, the Windows Imaging Component and Chromium both count +// the frames - and against the alternative of redrawing a full width band, +// which cost 18626 B a file at 640x480 where this costs 329 B. +// +// frames: 1 asks for a single picture and takes the plain encoder, which is +// what this package wrote before animation existed. It is the way back to +// those bytes for anybody who pinned them. package gif import ( "context" "fmt" - "image" - "image/color" - stdgif "image/gif" "io" "strconv" @@ -54,6 +70,27 @@ const ( minDimension = 1 maxDimension = 20000 + // A still GIF cannot answer the question a tester asks of one, so three + // is the default: enough for the marker to be somewhere different every + // time, and cheap. Measured at 640x480, three frames cost 329 B over the + // same picture written as a single frame. + defaultFrames = 3 + minFrames = 1 + // Sixty frames run for just over seven seconds at the delay below. The + // ceiling is here because every frame after the first is held in memory + // until the file is written, not because the format minds. + maxFrames = 60 + + // frameDelay is in hundredths of a second, which is the unit the format + // uses. Twelve is slow enough to see and fast enough that a whole cycle + // fits in a glance. + frameDelay = 12 + + // markerSide is a fraction of the width, floored so it never vanishes and + // capped so a huge picture does not put megabytes into every frame. + markerDivisor = 8 + maxMarkerSide = 128 + // The picture is held in memory while it is encoded, one byte per pixel // plus the encoder's own working set. The same budget as PNG, which has // the same shape of cost. @@ -96,6 +133,12 @@ func init() { Min: minDimension, Max: maxDimension, Unit: "pixels", Detail: "How tall the picture is. Left out, a size is chosen that fits the bytes you asked for.", }, + { + Name: "frames", Kind: format.PropertyInt, + Min: minFrames, Max: maxFrames, + Default: strconv.Itoa(defaultFrames), + Detail: "How many frames the animation has. Set it to 1 for a still picture.", + }, }, JointLimits: []format.JointLimit{{ Of: "width", By: "height", Max: maxPixels, @@ -111,6 +154,7 @@ type generator struct{} type memo struct { width, height int + frames int seed uint64 label string // body is the encoded picture up to but not including the trailer. @@ -160,8 +204,8 @@ func (generator) Plan(r format.Request) (format.Plan, error) { "width": w, "height": h, "palette": paletteSize(w, h), - "animated": false, - "frame_count": 1, + "animated": m.frames > 1, + "frame_count": m.frames, }, } @@ -351,6 +395,11 @@ func chooseSize(r format.Request, label string) (memo, error) { _, wSet := r.Properties["width"] _, hSet := r.Properties["height"] + frames, err := frameCount(r.Properties) + if err != nil { + return memo{}, err + } + if wSet || hSet { w, err := dimension(r.Properties, "width", 640) if err != nil { @@ -363,7 +412,7 @@ func chooseSize(r format.Request, label string) (memo, error) { if err := checkJointLimits(w, h); err != nil { return memo{}, err } - m := memo{width: w, height: h, seed: r.Seed, label: label} + m := memo{width: w, height: h, frames: frames, seed: r.Seed, label: label} body, err := encodedBodySize(m) if err != nil { return memo{}, err @@ -374,7 +423,7 @@ func chooseSize(r format.Request, label string) (memo, error) { var smallest memo for _, rung := range sizeLadder { - m := memo{width: rung[0], height: rung[1], seed: r.Seed, label: label} + m := memo{width: rung[0], height: rung[1], frames: frames, seed: r.Seed, label: label} body, err := encodedBodySize(m) if err != nil { return memo{}, err @@ -405,110 +454,6 @@ func dimension(props map[string]string, key string, fallback int) (int, error) { return n, nil } -// paletteSize is how many entries the colour table gets. -// -// It follows the picture rather than being fixed at 256, and the reason is the -// smallest file this format can produce. A GIF writes its colour table in -// full, three bytes an entry, so a fixed 256 entry table puts 768 bytes into a -// one pixel picture and pushes the minimum from about fifty bytes to eight -// hundred. A generator for testing has to be able to make small files. -// -// The table is a power of two because the format says so. Two slots are always -// reserved for the label, and the gradient takes what is left. -func paletteSize(width, height int) int { - // The gradient walks x+y, so a picture cannot show more distinct shades - // than the length of that diagonal. - distinct := width + height - 1 - if distinct > maxGradient { - distinct = maxGradient - } - need := reservedSlots + distinct - // Four is the floor: two label slots plus at least two shades. - size := 4 - for size < need { - size *= 2 - } - if size > 256 { - size = 256 - } - return size -} - -// palettes are built once, when the package loads, and handed out from there. -// -// A color.Palette is a slice of interfaces, so every entry put into one boxes -// a colour onto the heap - 256 objects for a full table, every time a picture -// was built. Measured: 300 allocations to write one file against a ceiling of -// 128. Building them up front moves that cost out of the write entirely, and -// there are only seven possible tables because the size is a power of two. -var palettes = func() map[int]color.Palette { - out := map[int]color.Palette{} - for size := 4; size <= 256; size *= 2 { - out[size] = makePalette(size) - } - return out -}() - -// palette holds the two label colours in fixed slots so the rasteriser lands -// on them exactly rather than on whatever happens to be nearest, and fills the -// rest with a spread the gradient indexes straight into. No quantiser runs, -// which is what keeps the encoding cheap and the bytes the same everywhere. -func buildPalette(size int) color.Palette { - if p, ok := palettes[size]; ok { - return p - } - return makePalette(size) -} - -func makePalette(size int) color.Palette { - p := make(color.Palette, size) - p[labelBackground] = color.RGBA{R: 16, G: 16, B: 16, A: 255} - p[labelInk] = color.RGBA{R: 240, G: 240, B: 240, A: 255} - shades := size - reservedSlots - for i := reservedSlots; i < size; i++ { - // A smooth ramp across whatever room the table has, so the picture - // reads as a gradient the way the other image formats do. An earlier - // version multiplied the index by odd numbers and wrapped, which - // spread the colours nicely and looked like interference on screen - - // and cost size as well, because neighbouring pixels that share - // nothing are what LZW is worst at. - t := 0 - if shades > 1 { - t = (i - reservedSlots) * 255 / (shades - 1) - } - blue := 2 * t - if t > 127 { - blue = 2 * (255 - t) - } - p[i] = color.RGBA{R: uint8(t), G: uint8(255 - t), B: uint8(blue), A: 255} - } - return p -} - -func picture(m memo) *image.Paletted { - size := paletteSize(m.width, m.height) - shades := size - reservedSlots - if shades < 1 { - shades = 1 - } - img := image.NewPaletted(image.Rect(0, 0, m.width, m.height), buildPalette(size)) - off := int(m.seed % 256) - for y := 0; y < m.height; y++ { - row := img.Pix[y*img.Stride : y*img.Stride+m.width] - for x := 0; x < m.width; x++ { - row[x] = byte(reservedSlots + (x+y+off)%shades) - } - } - if m.label != "" && imagelabel.Fits(m.width, len(m.label)) { - imagelabel.Draw(img, m.label) - } - return img -} - -func encode(w io.Writer, m memo) error { - return stdgif.Encode(w, picture(m), &stdgif.Options{NumColors: 256}) -} - func encodedBodySize(m memo) (int64, error) { holder := &tailHolder{w: io.Discard, keep: trailerSize} if err := encode(holder, m); err != nil { @@ -519,8 +464,13 @@ func encodedBodySize(m memo) (int64, error) { // minimumBytes is the smallest file this generator can produce: a one pixel // picture with no label and no comment. +// +// It counts the default number of frames rather than one, because the number +// a format announces has to be a number a plain run will accept, and a plain +// run animates. Asking for frames: 1 reaches something smaller, and that is +// the setting saying so. func minimumBytes() int64 { - body, err := encodedBodySize(memo{width: 1, height: 1}) + body, err := encodedBodySize(memo{width: 1, height: 1, frames: defaultFrames}) if err != nil { return 1 << 62 } diff --git a/internal/format/gif/picture.go b/internal/format/gif/picture.go new file mode 100644 index 0000000..0648966 --- /dev/null +++ b/internal/format/gif/picture.go @@ -0,0 +1,113 @@ +// The pixels and the colour table. +// +// Split out of gif.go on 2026-08-29, when animation pushed that file over the +// line count this project crowds against. The three files are three jobs: the +// container and the size arithmetic, this one, and the frames. +package gif + +import ( + "image" + "image/color" + + "github.com/donislawdev/TestingFilesGenerator/internal/format/imagelabel" +) + +// paletteSize is how many entries the colour table gets. +// +// It follows the picture rather than being fixed at 256, and the reason is the +// smallest file this format can produce. A GIF writes its colour table in +// full, three bytes an entry, so a fixed 256 entry table puts 768 bytes into a +// one pixel picture and pushes the minimum from about fifty bytes to eight +// hundred. A generator for testing has to be able to make small files. +// +// The table is a power of two because the format says so. Two slots are always +// reserved for the label, and the gradient takes what is left. +func paletteSize(width, height int) int { + // The gradient walks x+y, so a picture cannot show more distinct shades + // than the length of that diagonal. + distinct := width + height - 1 + if distinct > maxGradient { + distinct = maxGradient + } + need := reservedSlots + distinct + // Four is the floor: two label slots plus at least two shades. + size := 4 + for size < need { + size *= 2 + } + if size > 256 { + size = 256 + } + return size +} + +// palettes are built once, when the package loads, and handed out from there. +// +// A color.Palette is a slice of interfaces, so every entry put into one boxes +// a colour onto the heap - 256 objects for a full table, every time a picture +// was built. Measured: 300 allocations to write one file against a ceiling of +// 128. Building them up front moves that cost out of the write entirely, and +// there are only seven possible tables because the size is a power of two. +var palettes = func() map[int]color.Palette { + out := map[int]color.Palette{} + for size := 4; size <= 256; size *= 2 { + out[size] = makePalette(size) + } + return out +}() + +// palette holds the two label colours in fixed slots so the rasteriser lands +// on them exactly rather than on whatever happens to be nearest, and fills the +// rest with a spread the gradient indexes straight into. No quantiser runs, +// which is what keeps the encoding cheap and the bytes the same everywhere. +func buildPalette(size int) color.Palette { + if p, ok := palettes[size]; ok { + return p + } + return makePalette(size) +} + +func makePalette(size int) color.Palette { + p := make(color.Palette, size) + p[labelBackground] = color.RGBA{R: 16, G: 16, B: 16, A: 255} + p[labelInk] = color.RGBA{R: 240, G: 240, B: 240, A: 255} + shades := size - reservedSlots + for i := reservedSlots; i < size; i++ { + // A smooth ramp across whatever room the table has, so the picture + // reads as a gradient the way the other image formats do. An earlier + // version multiplied the index by odd numbers and wrapped, which + // spread the colours nicely and looked like interference on screen - + // and cost size as well, because neighbouring pixels that share + // nothing are what LZW is worst at. + t := 0 + if shades > 1 { + t = (i - reservedSlots) * 255 / (shades - 1) + } + blue := 2 * t + if t > 127 { + blue = 2 * (255 - t) + } + p[i] = color.RGBA{R: uint8(t), G: uint8(255 - t), B: uint8(blue), A: 255} + } + return p +} + +func picture(m memo) *image.Paletted { + size := paletteSize(m.width, m.height) + shades := size - reservedSlots + if shades < 1 { + shades = 1 + } + img := image.NewPaletted(image.Rect(0, 0, m.width, m.height), buildPalette(size)) + off := int(m.seed % 256) + for y := 0; y < m.height; y++ { + row := img.Pix[y*img.Stride : y*img.Stride+m.width] + for x := 0; x < m.width; x++ { + row[x] = byte(reservedSlots + (x+y+off)%shades) + } + } + if m.label != "" && imagelabel.Fits(m.width, len(m.label)) { + imagelabel.Draw(img, m.label) + } + return img +} diff --git a/internal/format/webp/picture.go b/internal/format/webp/picture.go new file mode 100644 index 0000000..3752cf8 --- /dev/null +++ b/internal/format/webp/picture.go @@ -0,0 +1,102 @@ +// The pixels: what the picture shows and how a row of it reaches the coder. +// +// Split out of webp.go on 2026-08-29, when the format crossed the line count +// this project crowds against. The three files are three jobs - the container +// and the size arithmetic, this one, and the bitstream in vp8l.go. +package webp + +import ( + "context" + "image" + "image/color" + "io" + + "github.com/donislawdev/TestingFilesGenerator/internal/format/imagelabel" +) + +// writePicture codes the picture straight into w and reports how many bytes it +// handed over. +// +// Nothing but one row and the label band is held: the bit writer drains into w +// as it fills, so a request for gigabytes costs the same memory as a small one. +func writePicture(ctx context.Context, w io.Writer, m memo) (int64, error) { + off := int(m.seed % 256) + band := labelBand(m, off) + bandH := 0 + if band != nil { + bandH = band.Bounds().Dy() + } + + row := make([]byte, m.width*samplesPerPixel) + bw := newBitWriter(w) + var stopped error + writeStream(bw, m.width, m.height, func(y int) []byte { + if y%64 == 0 && stopped == nil { + stopped = interrupted(ctx) + } + if y < bandH { + copyBandRow(row, band, y, m.width) + } else { + fillRow(row, y, m.width, off) + } + return row + }) + written, err := bw.flush() + if stopped != nil { + return written, stopped + } + return written, err +} + +// interrupted reports a cancelled run, and exists so the row callback stays two +// levels deep rather than three. This project counts how many functions nest +// that far and the count is meant to fall. +func interrupted(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } +} + +func fillRow(row []byte, y, width, off int) { + for x := 0; x < width; x++ { + row[x*samplesPerPixel] = uint8((x + off) % 256) + row[x*samplesPerPixel+1] = uint8((y + off) % 256) + row[x*samplesPerPixel+2] = uint8((x + y + off) % 256) + } +} + +func labelBand(m memo, off int) *image.RGBA { + if m.label == "" || !imagelabel.Fits(m.width, len(m.label)) { + return nil + } + h := maxBandHeight + if m.height < h { + h = m.height + } + img := image.NewRGBA(image.Rect(0, 0, m.width, h)) + for y := 0; y < h; y++ { + for x := 0; x < m.width; x++ { + img.SetRGBA(x, y, color.RGBA{ + R: uint8((x + off) % 256), + G: uint8((y + off) % 256), + B: uint8((x + y + off) % 256), + A: 255, + }) + } + } + imagelabel.Draw(img, m.label) + return img +} + +func copyBandRow(row []byte, band *image.RGBA, y, width int) { + base := band.PixOffset(0, y) + for x := 0; x < width; x++ { + p := base + x*4 + row[x*samplesPerPixel] = band.Pix[p] + row[x*samplesPerPixel+1] = band.Pix[p+1] + row[x*samplesPerPixel+2] = band.Pix[p+2] + } +} diff --git a/internal/format/webp/vp8l.go b/internal/format/webp/vp8l.go new file mode 100644 index 0000000..c424be2 --- /dev/null +++ b/internal/format/webp/vp8l.go @@ -0,0 +1,203 @@ +// The VP8L bitstream, which is the lossless half of WebP. +// +// x/image/webp decodes and does not encode, so a WebP means writing this +// ourselves. The arrangement here is deliberately the dullest legal one the +// format allows, and the reason is the promise this tool makes rather than any +// property of WebP: a file has to come out at an exact size, so the size has to +// be arithmetic that can be inverted. +// +// - no transforms, no colour cache, no meta Huffman, no backward references +// - green, red and blue each get a complete code in which all 256 literals +// are eight bits long, so the canonical code for a symbol IS that symbol +// and a pixel always costs 24 bits +// - alpha gets a one symbol code, which costs no bits per pixel at all +// +// A real encoder would compress. This one measures out three bytes a pixel on +// purpose, which is what lets a picture GROW to fill the bytes that were asked +// for instead of being a thumbnail followed by filler - the same shape as BMP +// and TIFF. +package webp + +import "io" + +// The order the format reads the nineteen code length symbols in. +var codeLengthOrder = [19]int{17, 18, 0, 1, 2, 3, 4, 5, 16, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + +const ( + // literals is how many symbols a colour channel can take. + literals = 256 + // greenAlphabet carries the literals plus the length prefixes that a + // backward reference would use. We never emit one, but the code still has + // to declare a length for every symbol. + greenAlphabet = literals + 24 + + // codeLengthsUsed reaches index 13 of the order above, which is where the + // symbol meaning "eight bits" sits. Anything shorter cannot say it. + codeLengthsUsed = 14 + + // headerBits is every bit before the first pixel. Written out as a sum so + // that changing any part of the stream above forces this to be changed + // with it, rather than leaving a number nobody can trace. + headerBits = 8 + 14 + 14 + 1 + 3 + // signature, width, height, alpha hint, version + 3 + // no transform, no colour cache, no meta Huffman + (1 + 4 + codeLengthsUsed*3 + 1 + greenAlphabet) + // green + (1 + 4 + codeLengthsUsed*3 + 1 + literals) + // red + (1 + 4 + codeLengthsUsed*3 + 1 + literals) + // blue + (1 + 1 + 1 + 8) + // alpha, one symbol, eight bit + (1 + 1 + 1 + 1) // distance, one symbol, one bit + + // bitsPerPixel is three channels of eight bits. Alpha costs nothing. + bitsPerPixel = 24 +) + +// streamBytes is how long the bitstream is for a picture of this many pixels. +// +// The pixel part is a whole number of bytes, so the header is the only part +// that has to be rounded, and it is rounded once. +func streamBytes(pixels int64) int64 { + return (headerBits+7)/8 + pixels*bitsPerPixel/8 +} + +// bitWriter writes least significant bit first, which is the order VP8L reads. +// +// It writes THROUGH to an io.Writer rather than collecting the file, because a +// generator here may be asked for gigabytes and the regression surface says +// none of them holds the whole file in memory. The first version of this +// package did collect it, with a comment explaining that a Huffman stream is +// not a sequence of whole bytes - which is true and is not a reason. The +// partial byte is one byte of state, not a file. +type bitWriter struct { + to io.Writer + buf []byte + acc uint64 + bits uint + err error + // written counts the bytes handed to the writer, so the caller can check + // the arithmetic that sized the chunk header it already sent. + written int64 +} + +func newBitWriter(to io.Writer) *bitWriter { + return &bitWriter{to: to, buf: make([]byte, 0, 32*1024)} +} + +func (w *bitWriter) write(value uint32, n uint) { + w.acc |= uint64(value&(1<= 8 { + w.buf = append(w.buf, byte(w.acc)) + w.acc >>= 8 + w.bits -= 8 + } + if len(w.buf) >= 32*1024 { + w.drain() + } +} + +func (w *bitWriter) drain() { + if w.err != nil || len(w.buf) == 0 { + return + } + n, err := w.to.Write(w.buf) + w.written += int64(n) + w.buf = w.buf[:0] + w.err = err +} + +// flush empties the buffer and the partial byte, and reports the first error +// any write hit. +func (w *bitWriter) flush() (int64, error) { + if w.bits > 0 { + w.buf = append(w.buf, byte(w.acc)) + w.acc, w.bits = 0, 0 + } + w.drain() + return w.written, w.err +} + +// reverseByte returns the eight bits of v in the opposite order. +// +// Huffman codes travel most significant bit first through a stream that is +// otherwise least significant bit first, the same convention DEFLATE uses. +// With every literal eight bits long the canonical code for symbol s is s, so +// emitting one is emitting s backwards. +func reverseByte(v uint8) uint32 { + var out uint32 + for i := uint(0); i < 8; i++ { + out = out<<1 | uint32(v>>i)&1 + } + return out +} + +// writeFlatCode declares a code in which every literal is eight bits long and +// every symbol above the literals is unused. +// +// The code length alphabet needs two symbols to be a prefix code at all, so it +// carries 0 and 8 with one bit each. Symbol 0 sorts first and gets code 0, +// symbol 8 gets code 1. +func writeFlatCode(w *bitWriter, alphabet int) { + w.write(0, 1) // not a simple code + w.write(codeLengthsUsed-4, 4) + for i := 0; i < codeLengthsUsed; i++ { + length := uint32(0) + if codeLengthOrder[i] == 0 || codeLengthOrder[i] == 8 { + length = 1 + } + w.write(length, 3) + } + w.write(0, 1) // a length is read for every symbol in the alphabet + for s := 0; s < alphabet; s++ { + if s < literals { + w.write(1, 1) // symbol 8 + } else { + w.write(0, 1) // symbol 0, unused + } + } +} + +// writeSingleSymbolCode declares a code carrying one symbol, which then costs +// nothing to emit. +func writeSingleSymbolCode(w *bitWriter, symbol uint32, eightBit bool) { + w.write(1, 1) // simple + w.write(0, 1) // one symbol + if eightBit { + w.write(1, 1) + w.write(symbol, 8) + } else { + w.write(0, 1) + w.write(symbol, 1) + } +} + +// writeStream emits the whole bitstream. row is called once per row and hands +// back width*3 bytes in red, green, blue order. +func writeStream(w *bitWriter, width, height int, row func(y int) []byte) { + w.write(0x2F, 8) + w.write(uint32(width-1), 14) + w.write(uint32(height-1), 14) + w.write(0, 1) // no alpha in use + w.write(0, 3) // version + + w.write(0, 1) // no transform + w.write(0, 1) // no colour cache + w.write(0, 1) // no meta Huffman + + writeFlatCode(w, greenAlphabet) + writeFlatCode(w, literals) + writeFlatCode(w, literals) + writeSingleSymbolCode(w, 255, true) // alpha, opaque everywhere + writeSingleSymbolCode(w, 0, false) // distance, never used + + for y := 0; y < height; y++ { + px := row(y) + for x := 0; x < width; x++ { + // Green first. That is the order the format reads a pixel in, and + // getting it wrong produces a picture that decodes cleanly with + // its channels swapped - which is why the guard compares pixels + // rather than only opening the file. + w.write(reverseByte(px[x*3+1]), 8) + w.write(reverseByte(px[x*3]), 8) + w.write(reverseByte(px[x*3+2]), 8) + } + } +} diff --git a/internal/format/webp/webp.go b/internal/format/webp/webp.go new file mode 100644 index 0000000..9b951fc --- /dev/null +++ b/internal/format/webp/webp.go @@ -0,0 +1,469 @@ +// Package webp generates lossless WebP images. +// +// The third format whose size is arithmetic rather than whatever an encoder +// decides, after BMP and TIFF, and it is built that way on purpose: a request +// for 10 MB is answered with a picture worth 10 MB instead of a thumbnail +// followed by filler nobody can see. The bitstream lives in vp8l.go and says +// there why it compresses nothing. +// +// Written by hand rather than taken from a library, and the reason is +// measured rather than assumed: x/image/webp at v0.43.0 holds decode.go and +// doc.go and nothing else, so the ecosystem offers no encoder to take. Pure Go +// encoders exist outside it, and taking one would have put somebody else's +// release inside the byte stability contract D11 - their next version would +// move the hashes in our users' test suites. See docs/STACK.md section 4.2. +// +// Lossless only. There is no quality setting and no lossy variant, because +// lossy WebP is VP8, which needs a transform, a quantiser and an arithmetic +// coder - out of proportion to what this format has to do here. That is a +// scope decision written down rather than a fidelity level quietly lowered, +// and tfg formats says so. +// +// The padding channel is two stages, and the second exists for a reason worth +// stating: every RIFF chunk block costs an EVEN number of bytes - eight of +// header, the payload, and a pad byte when the payload is odd. A file built +// only out of chunks can therefore only ever have an even length, and half of +// every size anybody could ask for would be unreachable. So the bulk goes in a +// private chunk, and one to seven bytes after the end of the RIFF payload +// carry whatever is left. Measured on six readers (docs/MVP-FORMATS.md section +// 2.13): there is no dead zone at all, which is better than PNG or GIF manage. +package webp + +import ( + "context" + "encoding/binary" + "fmt" + "io" + "math/rand/v2" + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/format/imagelabel" +) + +const ( + generatorVersion = "1" + + // riffHeader is "RIFF", the size, and "WEBP". + riffHeader = 12 + // chunkHeader is a four character name and a four byte size. + chunkHeader = 4 + + // paddingName is the private chunk the filler travels in. Unknown chunks + // are what the container sets aside to be ignored, so this is the channel + // the format itself describes. + paddingName = "TFGp" + + // tailMax is how many bytes may sit after the RIFF payload. Seven is + // everything a chunk cannot reach on its own, and no more - the bulk + // belongs inside the container. + tailMax = 7 + + samplesPerPixel = 3 + + minDimension = 1 + maxDimension = 16383 // the format stores each side less one in fourteen bits + + // A RIFF size is a four byte unsigned field. + maxFileBytes = 1<<32 - 1 + + // The tallest label band the rasteriser produces, so the rows carrying the + // label are built once and the rest of the picture streams past. + maxBandHeight = 24 +) + +func init() { + format.Register(format.Descriptor{ + ID: "webp", + Extension: ".webp", + Fidelity: format.FidelityFull, + Determinism: format.DeterminismByte, + + MinBytes: bareBytes(minDimension, minDimension), + + Padding: format.PaddingChannel{ + // Measured against six independent readers - Pillow, ffprobe, + // exiftool, x/image, the Windows Imaging Component and Chromium - + // on files this generator writes, with the pixels compared rather + // than only decoded. + // + // A chunk placed BEFORE the image chunk was tried and refused by + // two of them, so it is not this. A negative control matters here + // more than usual: a truncated WebP is accepted by ffprobe and by + // the Windows Imaging Component, so those two cannot say no about + // this format and the evidence rests on the four that can. + Name: "a private chunk, with the odd byte after the payload", + Where: format.PlacementEnd, + Capacity: maxFileBytes, + }, + Label: format.LabelVisible, + Oracle: "pillow", + Properties: []format.Property{ + { + Name: "width", Kind: format.PropertyInt, + Min: minDimension, Max: maxDimension, Unit: "pixels", + Detail: "How wide the picture is. Left out, the picture is sized to fill the bytes you asked for.", + }, + { + Name: "height", Kind: format.PropertyInt, + Min: minDimension, Max: maxDimension, Unit: "pixels", + Detail: "How tall the picture is. Left out, the picture is sized to fill the bytes you asked for.", + }, + }, + GeneratorVersion: generatorVersion, + Generator: generator{}, + }) +} + +type generator struct{} + +type memo struct { + width, height int + seed uint64 + label string + // chunk is how many bytes of filler the private chunk carries, and tail + // how many sit after the RIFF payload. A chunk of -1 means none at all, + // which is not the same as one carrying nothing. + chunk int64 + tail int64 +} + +// bareBytes is the file with no padding of any kind. +func bareBytes(width, height int) int64 { + stream := streamBytes(int64(width) * int64(height)) + return riffHeader + chunkHeader + 4 + stream + stream%2 +} + +func (generator) Plan(r format.Request) (format.Plan, error) { + label := "" + if r.Label { + label = core.Label("webp", r.Bytes, r.Seed) + } + + w, h, err := chooseSize(r) + if err != nil { + return format.Plan{}, err + } + + bare := bareBytes(w, h) + if r.Bytes < bare { + return format.Plan{}, &format.BelowMinimumError{ + Format: "WEBP", + Requested: r.Bytes, + Minimum: bare, + Reason: fmt.Sprintf( + "a %dx%d picture is %d B of pixels at three bytes each, and the container and the coding tables take another %d B", + w, h, int64(w)*int64(h)*samplesPerPixel, bare-int64(w)*int64(h)*samplesPerPixel), + Hint: fmt.Sprintf("Ask for %d B or more, or set a smaller width and height", bare), + } + } + if r.Bytes > maxFileBytes { + return format.Plan{}, &format.AboveMaximumError{ + Format: "WEBP", + Requested: r.Bytes, + Maximum: maxFileBytes, + Reason: "a WebP declares its length in a four byte field, so the format cannot describe a file this large", + Hint: "Ask for 4 GiB or less, or pick a format with no length field of its own such as gif.", + } + } + + m := memo{width: w, height: h, seed: r.Seed, label: label} + settlePadding(&m, r.Bytes-bare) + + p := format.Plan{ + Bytes: r.Bytes, + Exact: true, + Determinism: format.DeterminismByte, + Properties: map[string]any{ + "width": w, + "height": h, + "compression": "lossless", + "bit_depth": 24, + "animated": false, + "frame_count": 1, + }, + } + + labelled := r.Label && imagelabel.Fits(w, len(label)) + if r.Label && !labelled { + p.Notes = append(p.Notes, format.Note{ + Code: "label_omitted", + Detail: fmt.Sprintf( + "The picture is %d px wide and the label needs more room, so this file carries no visible label. Its name and the manifest still identify it.", + w), + }) + } + p.Properties[format.PropertyLabelEmbedded] = labelled + p.Memo = m + return p, nil +} + +// settlePadding decides how the bytes above the bare picture are spent. +// +// Under eight there is no room for a chunk at all, so they go after the RIFF +// payload. From eight upwards the chunk carries an even payload and the tail +// carries nothing or one byte, which is what makes every size reachable - the +// chunk alone could only ever step in twos. +func settlePadding(m *memo, delta int64) { + m.chunk = -1 + switch { + case delta <= 0: + m.tail = 0 + case delta <= tailMax: + m.tail = delta + default: + m.tail = (delta - chunkHeader - 4) % 2 + m.chunk = delta - chunkHeader - 4 - m.tail + } +} + +// chooseSize settles the picture size. +// +// Named dimensions are used as given. Left out, the picture is grown to fill +// the request, because a pixel always costs three bytes here and the size is +// therefore arithmetic. What is left over goes into the padding. +func chooseSize(r format.Request) (int, int, error) { + _, wSet := r.Properties["width"] + _, hSet := r.Properties["height"] + + if wSet || hSet { + w, err := dimension(r.Properties, "width", 0) + if err != nil { + return 0, 0, err + } + h, err := dimension(r.Properties, "height", 0) + if err != nil { + return 0, 0, err + } + switch { + case wSet && hSet: + return w, h, nil + case wSet: + return w, fillHeight(r.Bytes, w), nil + default: + return fillWidth(r.Bytes, h), h, nil + } + } + + avail := (r.Bytes - bareBytes(minDimension, minDimension)) / samplesPerPixel + if avail < 1 { + return minDimension, minDimension, nil + } + // A square puts the most pixels into the request and is what a person + // expects when they said nothing. + side := int(isqrt(uint64(avail + 1))) + if side < minDimension { + side = minDimension + } + if side > maxDimension { + side = maxDimension + } + for side > minDimension && bareBytes(side, side) > r.Bytes { + side-- + } + return side, side, nil +} + +func fillHeight(bytes int64, width int) int { + h := int64(minDimension) + if width >= minDimension { + room := bytes - bareBytes(width, minDimension) + if room > 0 { + h += room / (int64(width) * samplesPerPixel) + } + } + if h > maxDimension { + h = maxDimension + } + for h > minDimension && bareBytes(width, int(h)) > bytes { + h-- + } + return int(h) +} + +func fillWidth(bytes int64, height int) int { + w := int64(minDimension) + if height >= minDimension { + room := bytes - bareBytes(minDimension, height) + if room > 0 { + w += room / (int64(height) * samplesPerPixel) + } + } + if w > maxDimension { + w = maxDimension + } + for w > minDimension && bareBytes(int(w), height) > bytes { + w-- + } + return int(w) +} + +// isqrt is the whole number square root, worked out rather than taken from +// floating point so the answer is the same on every machine. +func isqrt(n uint64) uint64 { + if n == 0 { + return 0 + } + x := n + y := (x + 1) / 2 + for y < x { + x = y + y = (x + n/x) / 2 + } + return x +} + +func dimension(props map[string]string, key string, fallback int) (int, error) { + raw, ok := props[key] + if !ok || raw == "" { + return fallback, nil + } + n, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("webp: %s must be a whole number of pixels, got %q", key, raw) + } + if n < minDimension || n > maxDimension { + return 0, fmt.Errorf("webp: %s must be between %d and %d pixels, got %d", key, minDimension, maxDimension, n) + } + return n, nil +} + +func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { + m, ok := p.Memo.(memo) + if !ok { + return fmt.Errorf("webp: the plan was not produced by this generator") + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // The length of the bitstream is arithmetic, so the headers can go out + // before a single pixel is coded and nothing has to be held back. + stream := streamBytes(int64(m.width) * int64(m.height)) + body := chunkHeader + 4 + stream + stream%2 + if m.chunk >= 0 { + body += chunkHeader + 4 + m.chunk + } + + if err := writeContainer(w, body, stream); err != nil { + return err + } + + written, err := writePicture(ctx, w, m) + if err != nil { + return err + } + // The chunk header above already promised this number. A mismatch would + // leave a file every reader mistrusts, so it is an error rather than a + // silent short write. + if written != stream { + return fmt.Errorf("webp: the bitstream came to %d B where the header promised %d B", written, stream) + } + if stream%2 == 1 { + if err := writeAll(w, []byte{0}); err != nil { + return err + } + } + + if m.chunk >= 0 { + if err := writeFiller(ctx, w, paddingName, m.seed, m.chunk); err != nil { + return err + } + } + if m.tail > 0 { + return writeAll(w, filler(m.seed+1, m.tail)) + } + return nil +} + +// writeContainer emits everything before the first coded pixel: the RIFF +// header, the form name, and the header of the chunk the bitstream goes in. +// +// It is a function of its own rather than five more error checks inside Write, +// because this project counts how many functions carry a lot of decisions and +// Write was the sixth to reach the band. +func writeContainer(w io.Writer, body, stream int64) error { + for _, part := range []struct { + text string + num uint32 + }{ + {text: "RIFF"}, + {num: uint32(4 + body)}, + {text: "WEBP"}, + {text: "VP8L"}, + {num: uint32(stream)}, + } { + var err error + if part.text != "" { + err = writeAll(w, []byte(part.text)) + } else { + err = writeUint32(w, part.num) + } + if err != nil { + return err + } + } + return nil +} + +// writeFiller emits the padding chunk without holding its payload in memory. +func writeFiller(ctx context.Context, w io.Writer, name string, seed uint64, size int64) error { + if err := writeAll(w, []byte(name)); err != nil { + return err + } + if err := writeUint32(w, uint32(size)); err != nil { + return err + } + rng := core.NewRand(seed) + buf := make([]byte, 32*1024) + for left := size; left > 0; { + n := int64(len(buf)) + if left < n { + n = left + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + fillBytes(buf[:n], rng) + if err := writeAll(w, buf[:n]); err != nil { + return err + } + left -= n + } + if size%2 == 1 { + return writeAll(w, []byte{0}) + } + return nil +} + +func filler(seed uint64, size int64) []byte { + out := make([]byte, size) + fillBytes(out, core.NewRand(seed)) + return out +} + +func fillBytes(b []byte, rng *rand.Rand) { + for i := 0; i < len(b); i += 8 { + v := rng.Uint64() + for j := 0; j < 8 && i+j < len(b); j++ { + b[i+j] = byte(v >> (8 * uint(j))) + } + } +} + +func writeUint32(w io.Writer, v uint32) error { + var b [4]byte + binary.LittleEndian.PutUint32(b[:], v) + return writeAll(w, b[:]) +} + +func writeAll(w io.Writer, b []byte) error { + _, err := w.Write(b) + return err +} diff --git a/internal/guard/animation_test.go b/internal/guard/animation_test.go new file mode 100644 index 0000000..15b605d --- /dev/null +++ b/internal/guard/animation_test.go @@ -0,0 +1,230 @@ +package guard + +import ( + "encoding/binary" + "fmt" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/engine" + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// An animated format that writes one frame answers none of the question a +// tester asks of it. +// +// The complaint this guards against was reported from use rather than found +// here: a GIF went into an upload form, came back out as a still picture, and +// nothing about that told anybody whether the site keeps animations, flattens +// them to the first frame, or re-encodes them. A file that cannot tell those +// three apart is not a test file for this format. +// +// The pinned hashes in generatorbytes_test.go already stop the bytes moving. +// They say nothing about what the bytes ARE - a generator that quietly went +// back to writing one frame would drift, get a new hash written down, and pass +// for ever after. This reads the file back and asks whether the animation is +// in it. +// +// Read rather than asked: the structure below is parsed out of the bytes the +// engine wrote, not taken from the plan. Asking the generator to confirm its +// own intention is the shape of guard this project has been caught by before. + +// gifStructure is what a reader finds when it walks a GIF data stream. +type gifStructure struct { + globalTable int + frames int + localTables int + comments int + controls int + loopBlocks int + // lefts is the horizontal offset of each image descriptor, in order. A + // marker that travels puts a different number in each. + lefts []int +} + +// walkGIF parses the block structure. It is deliberately strict: anything it +// does not recognise is an error rather than a block to skip, because a guard +// that shrugs at an unknown byte cannot tell a malformed file from a valid one. +func walkGIF(b []byte) (gifStructure, error) { + var s gifStructure + if len(b) < 13 { + return s, fmt.Errorf("the file is %d B and the screen descriptor alone needs 13 B", len(b)) + } + if string(b[:3]) != "GIF" { + return s, fmt.Errorf("the file does not start with a GIF signature") + } + packed := b[10] + pos := 13 + if packed&0x80 != 0 { + s.globalTable = 1 << ((packed & 0x07) + 1) + pos += 3 * s.globalTable + } + + blocks := func(p int) (int, error) { + for { + if p >= len(b) { + return 0, fmt.Errorf("a sub block chain runs past the end of the file") + } + n := int(b[p]) + p++ + if n == 0 { + return p, nil + } + p += n + } + } + + for { + if pos >= len(b) { + return s, fmt.Errorf("the file ends with no trailer") + } + marker := b[pos] + pos++ + switch marker { + case 0x3B: + if pos != len(b) { + return s, fmt.Errorf("the trailer is at %d and %d B follow it", pos-1, len(b)-pos) + } + return s, nil + case 0x21: + if pos >= len(b) { + return s, fmt.Errorf("an extension has no label") + } + switch b[pos] { + case 0xFE: + s.comments++ + case 0xF9: + s.controls++ + case 0xFF: + s.loopBlocks++ + } + pos++ + next, err := blocks(pos) + if err != nil { + return s, err + } + pos = next + case 0x2C: + if pos+9 > len(b) { + return s, fmt.Errorf("an image descriptor runs past the end of the file") + } + s.frames++ + s.lefts = append(s.lefts, int(binary.LittleEndian.Uint16(b[pos:pos+2]))) + local := b[pos+8] + pos += 9 + if local&0x80 != 0 { + s.localTables++ + pos += 3 * (1 << ((local & 0x07) + 1)) + } + if pos >= len(b) { + return s, fmt.Errorf("an image has no code size") + } + pos++ + next, err := blocks(pos) + if err != nil { + return s, err + } + pos = next + default: + return s, fmt.Errorf("the byte %#02x at offset %d is not a block marker", marker, pos-1) + } + } +} + +func TestAGeneratedGifReallyCarriesAnAnimation(t *testing.T) { + target := engine.Target{ + ID: "g", Format: "gif", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"width": "64", "height": "64"}, + } + b := generateOne(t, target) + + s, err := walkGIF(b) + if err != nil { + t.Fatalf("reading back the generated GIF: %v", err) + } + + d, err := format.Get("gif") + if err != nil { + t.Fatal(err) + } + want := 0 + for _, p := range d.Properties { + if p.Name == "frames" { + if _, e := fmt.Sscanf(p.Default, "%d", &want); e != nil { + t.Fatalf("the frames setting declares %q as its default and that is not a number", p.Default) + } + } + } + if want < 2 { + t.Fatalf("the frames setting defaults to %d, so a plain run writes a still picture and "+ + "a tester learns nothing about how the system under test treats an animation", want) + } + + if s.frames != want { + t.Errorf("a plain GIF holds %d frame(s) and the format declares %d as its default", + s.frames, want) + } + if s.controls != want { + t.Errorf("%d frame(s) carry %d graphic control block(s) - without one a frame has no delay, "+ + "and a reader is free to show the whole animation in an instant", s.frames, s.controls) + } + if s.loopBlocks != 1 { + t.Errorf("the file carries %d looping block(s), so the animation plays once and a tester "+ + "who blinks sees a still picture", s.loopBlocks) + } + + // The marker has to be somewhere different in every frame. Equal offsets + // would still decode, still animate on paper, and show nothing moving. + seen := map[int]bool{} + for i, left := range s.lefts { + if seen[left] { + t.Errorf("frame %d starts at x=%d and so does an earlier one, so nothing appears to move", i, left) + } + seen[left] = true + } + + // One table for the whole file. Measured on 2026-08-29: without it every + // frame carries its own copy of the palette, and a 12 px square cost 233 B + // instead of 41 B. + if s.globalTable == 0 { + t.Error("the file has no global colour table, so every frame carries its own copy of the palette") + } + if s.localTables != 0 { + t.Errorf("%d frame(s) carry their own colour table on top of the global one", s.localTables) + } + + // The padding channel still has to be there, because the size is exact. + if s.comments != 1 { + t.Errorf("the file carries %d comment block(s) and the padding channel is the comment", s.comments) + } + if len(b) != 65536 { + t.Errorf("the file is %d B where 65536 B was asked for", len(b)) + } +} + +func TestAStillGifIsStillAvailableAndSaysSo(t *testing.T) { + target := engine.Target{ + ID: "g", Format: "gif", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"width": "64", "height": "64", "frames": "1"}, + } + b := generateOne(t, target) + + s, err := walkGIF(b) + if err != nil { + t.Fatalf("reading back the generated GIF: %v", err) + } + if s.frames != 1 { + t.Errorf("frames: 1 produced %d frame(s)", s.frames) + } + // A single frame goes through the plain encoder, which writes neither a + // control block nor a looping block. Those two absences are what make the + // bytes the same as the ones this package wrote before it could animate, + // and generatorbytes_test.go pins that with the hash the animated case + // used to carry. + if s.controls != 0 || s.loopBlocks != 0 { + t.Errorf("a still GIF carries %d control block(s) and %d looping block(s), so it is not "+ + "the file the plain encoder writes", s.controls, s.loopBlocks) + } + if len(b) != 65536 { + t.Errorf("the file is %d B where 65536 B was asked for", len(b)) + } +} diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index 627d8b6..ed0231a 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -85,8 +85,45 @@ func goldenCases() map[string]engine.Target { // rather than in the code, and this is what closes it. "tiff_64kib_width_only": {ID: "g", Format: "tiff", Sizes: engine.Uniform(1, 65536), Label: true, Properties: map[string]string{"width": "128"}}, + // WebP, pinned from three sides. Its size is arithmetic rather than + // whatever a compressor decides, so a drift here can be a wrong SIZE and + // not only different bytes - the same shape as BMP and TIFF. + "webp_64kib": {ID: "g", Format: "webp", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"width": "64", "height": "48"}}, + + // One side named, so the other is worked out from the bytes that are + // left. TIFF found by measurement that a case naming both sides and a + // case naming neither both miss that arithmetic entirely. + "webp_64kib_width_only": {ID: "g", Format: "webp", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"width": "100"}}, + + // An odd size, which is the case the padding was rebuilt for. Every RIFF + // chunk block costs an even number of bytes, so without the tail after + // the payload this request could not be answered at all. + "webp_odd_size": {ID: "g", Format: "webp", Sizes: engine.Uniform(1, 65537), Label: true, + Properties: map[string]string{"width": "64", "height": "48"}}, + "gif_64kib": {ID: "g", Format: "gif", Sizes: engine.Uniform(1, 65536), Label: true, Properties: map[string]string{"width": "64", "height": "64"}}, + + // The still GIF, which is a different encoder rather than the same one + // with a smaller number. frames: 1 takes the plain path and writes no + // control block, no loop block and no second image descriptor, so the + // case above never reaches any of that arithmetic and a change to it + // would move nobody's bytes that anything here measures. + // + // It is also the way back to the bytes this package wrote before it + // could animate, which is a promise the package comment makes. Pinning + // it is what stops that sentence from being a sentence. + "gif_64kib_one_frame": {ID: "g", Format: "gif", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"width": "64", "height": "64", "frames": "1"}}, + + // Enough frames that the marker lands somewhere different from either + // end, so the arithmetic placing it is measured rather than assumed. + // With three frames a wrong divisor still puts the square inside the + // picture and nothing notices. + "gif_64kib_eight_frames": {ID: "g", Format: "gif", Sizes: engine.Uniform(1, 65536), Label: true, + Properties: map[string]string{"width": "64", "height": "64", "frames": "8"}}, "ico_32kib": {ID: "g", Format: "ico", Sizes: engine.Uniform(1, 32768), Label: true, Properties: map[string]string{"width": "32", "height": "32"}}, diff --git a/internal/guard/layers_test.go b/internal/guard/layers_test.go index 1398aeb..1fe2f1b 100644 --- a/internal/guard/layers_test.go +++ b/internal/guard/layers_test.go @@ -57,6 +57,7 @@ var layer = map[string]int{ "internal/format/zip": 1, "internal/format/targz": 1, "internal/format/tiff": 1, + "internal/format/webp": 1, "internal/format/wav": 1, "internal/recipe": 2, @@ -104,6 +105,7 @@ var sameLayerAllowed = map[string][]string{ "internal/format/zip", "internal/format/targz", "internal/format/tiff", + "internal/format/webp", "internal/format/wav", }, "internal/format/imagelabel": {"internal/format"}, @@ -128,6 +130,7 @@ var sameLayerAllowed = map[string][]string{ "internal/format/zip": {"internal/format", "internal/format/imagelabel"}, "internal/format/targz": {"internal/format"}, "internal/format/tiff": {"internal/format", "internal/format/imagelabel"}, + "internal/format/webp": {"internal/format", "internal/format/imagelabel"}, "internal/format/wav": {"internal/format", "internal/format/imagelabel"}, "internal/preset": {"internal/recipe"}, diff --git a/internal/guard/oracle_test.go b/internal/guard/oracle_test.go index f090c21..b0f88ea 100644 --- a/internal/guard/oracle_test.go +++ b/internal/guard/oracle_test.go @@ -143,7 +143,7 @@ func TestEveryFormatSurvivesItsReferenceTool(t *testing.T) { var structurallyChecked = map[string]bool{ "png": true, "wav": true, "pdf": true, "zip": true, "targz": true, "log": true, "csv": true, "json": true, "xml": true, "svg": true, "html": true, - "bmp": true, "gif": true, "ico": true, "jpg": true, "tiff": true, + "bmp": true, "gif": true, "ico": true, "jpg": true, "tiff": true, "webp": true, "docx": true, "xlsx": true, "pptx": true, // Nothing to check against beyond "these are the bytes we meant", so they // have one layer and it is honest to say so out loud. diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index 895f334..5557e1d 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -84,6 +84,7 @@ var reachableFromTheWindow = []string{ "format:svg", "format:targz", "format:tiff", + "format:webp", "format:txt", "format:wav", "format:xml", @@ -101,6 +102,7 @@ var reachableFromTheWindow = []string{ "property:xlsx.columns", "property:xlsx.rows", "property:bmp.width", + "property:gif.frames", "property:gif.height", "property:gif.width", "property:ico.embed", @@ -118,6 +120,8 @@ var reachableFromTheWindow = []string{ "property:targz.entry_size", "property:tiff.height", "property:tiff.width", + "property:webp.height", + "property:webp.width", "property:wav.bit_depth", "property:wav.channels", "property:wav.content", diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index ffaa4ee..87f6211 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -28,9 +28,19 @@ "measured_on": "2026-08-19" }, "gif_64kib": { + "bytes": 65536, + "sha256": "ff48902dceca395b00db2ede471825f4b224a2ee82063c330022efd60c4158b2", + "measured_on": "2026-08-29" + }, + "gif_64kib_eight_frames": { + "bytes": 65536, + "sha256": "73f49717016a4f1ed53131645974973250c9e1a830704af3a6faf9415cd60c90", + "measured_on": "2026-08-29" + }, + "gif_64kib_one_frame": { "bytes": 65536, "sha256": "659e48806e3abe402148f8246921769d66f3ea9d2f73e94888a3fb7abcf76977", - "measured_on": "2026-08-19" + "measured_on": "2026-08-29" }, "html_8kib": { "bytes": 8192, @@ -115,14 +125,14 @@ "sha256": "172e2e47dda124de9cdbc122b55d25c2fc20de4c78ab963bbf6c7645da247bbd", "measured_on": "2026-08-29" }, - "tiff_64kib_width_only": { + "tiff_64kib": { "bytes": 65536, - "sha256": "c5c38af41809c7e98333fc2a6bdcca36a955f70ab4413dbfde9b9ec4dd3d0b8f", + "sha256": "4d174b53a1603f63b1cd49307f793540190b2d03e26f60b91ca767a93d610340", "measured_on": "2026-08-29" }, - "tiff_64kib": { + "tiff_64kib_width_only": { "bytes": 65536, - "sha256": "4d174b53a1603f63b1cd49307f793540190b2d03e26f60b91ca767a93d610340", + "sha256": "c5c38af41809c7e98333fc2a6bdcca36a955f70ab4413dbfde9b9ec4dd3d0b8f", "measured_on": "2026-08-29" }, "txt_4kib": { @@ -137,6 +147,21 @@ "bytes": 32768, "sha256": "7f1de29ead6fd51cad8675133054f77f760c9f867cc26351260e5340e5201b3a" }, + "webp_64kib": { + "bytes": 65536, + "sha256": "de9034bd6315f0a51fd8fe1b99adfa5719597f7c3d7cdaba1d3b8624754bf89e", + "measured_on": "2026-08-29" + }, + "webp_64kib_width_only": { + "bytes": 65536, + "sha256": "0b56974137d6bd3c5923b2d2dff713efdc93e34c49a59c283ebdceb410541951", + "measured_on": "2026-08-29" + }, + "webp_odd_size": { + "bytes": 65537, + "sha256": "cab2df93e3a618f27b927f7fc3fc99e30787c640c2cb23a14effd5745f19dfff", + "measured_on": "2026-08-29" + }, "xlsx_32kib": { "bytes": 32768, "sha256": "11c19f918b1a996634117117c63e337513db7cadaa1872880482b59abfc7e1da", diff --git a/internal/guard/testdata/screens/generate-menu-hovered.png b/internal/guard/testdata/screens/generate-menu-hovered.png index 7918543..4777881 100644 Binary files a/internal/guard/testdata/screens/generate-menu-hovered.png and b/internal/guard/testdata/screens/generate-menu-hovered.png differ diff --git a/internal/guard/testdata/screens/generate-menu-hovered.xml b/internal/guard/testdata/screens/generate-menu-hovered.xml index d49aa7a..3314006 100644 --- a/internal/guard/testdata/screens/generate-menu-hovered.xml +++ b/internal/guard/testdata/screens/generate-menu-hovered.xml @@ -428,7 +428,7 @@ - + @@ -525,8 +525,8 @@ - - + + diff --git a/internal/guard/testdata/screens/generate-menu-keyed.png b/internal/guard/testdata/screens/generate-menu-keyed.png index 7ef3921..1747351 100644 Binary files a/internal/guard/testdata/screens/generate-menu-keyed.png and b/internal/guard/testdata/screens/generate-menu-keyed.png differ diff --git a/internal/guard/testdata/screens/generate-menu-keyed.xml b/internal/guard/testdata/screens/generate-menu-keyed.xml index a33bd5d..75fa16f 100644 --- a/internal/guard/testdata/screens/generate-menu-keyed.xml +++ b/internal/guard/testdata/screens/generate-menu-keyed.xml @@ -428,7 +428,7 @@ - + @@ -525,8 +525,8 @@ - - + + diff --git a/internal/guard/testdata/screens/generate-menu.png b/internal/guard/testdata/screens/generate-menu.png index 27ed2a3..31f4c41 100644 Binary files a/internal/guard/testdata/screens/generate-menu.png and b/internal/guard/testdata/screens/generate-menu.png differ diff --git a/internal/guard/testdata/screens/generate-menu.xml b/internal/guard/testdata/screens/generate-menu.xml index 108dfff..322493f 100644 --- a/internal/guard/testdata/screens/generate-menu.xml +++ b/internal/guard/testdata/screens/generate-menu.xml @@ -428,7 +428,7 @@ - + @@ -525,8 +525,8 @@ - - + + diff --git a/internal/guard/testdata/screens/preset-menu-setting.png b/internal/guard/testdata/screens/preset-menu-setting.png index 8e0fe3f..e247c13 100644 Binary files a/internal/guard/testdata/screens/preset-menu-setting.png and b/internal/guard/testdata/screens/preset-menu-setting.png differ diff --git a/internal/guard/testdata/screens/preset-menu-setting.xml b/internal/guard/testdata/screens/preset-menu-setting.xml index 33d6c61..527b84b 100644 --- a/internal/guard/testdata/screens/preset-menu-setting.xml +++ b/internal/guard/testdata/screens/preset-menu-setting.xml @@ -416,7 +416,7 @@ - + @@ -516,8 +516,8 @@ - - + + diff --git a/internal/guard/textformats_test.go b/internal/guard/textformats_test.go index c667a85..e9f652c 100644 --- a/internal/guard/textformats_test.go +++ b/internal/guard/textformats_test.go @@ -446,7 +446,7 @@ func TestAnSVGDrawingCarriesRealShapes(t *testing.T) { // already written down cannot, by construction, find what is missing from them. var textFormats = []string{"txt", "md", "log", "csv", "json", "xml", "html", "svg"} -var binaryFormats = []string{"bmp", "docx", "gif", "ico", "jpg", "pdf", "png", "pptx", "targz", "tiff", "wav", "xlsx", "zip"} +var binaryFormats = []string{"bmp", "docx", "gif", "ico", "jpg", "pdf", "png", "pptx", "targz", "tiff", "wav", "webp", "xlsx", "zip"} // Every registered format is on exactly one of the two lists above. // diff --git a/internal/gui/parts/filekind.go b/internal/gui/parts/filekind.go index 2c7dd4a..cd2382e 100644 --- a/internal/gui/parts/filekind.go +++ b/internal/gui/parts/filekind.go @@ -93,6 +93,7 @@ var fileKinds = map[string]fileKind{ "png": kindPicture, "svg": kindPicture, "tiff": kindPicture, + "webp": kindPicture, "docx": kindDocument, "pdf": kindDocument, "pptx": kindDocument, diff --git a/internal/oracle/oracle.go b/internal/oracle/oracle.go index aee677b..fffbb79 100644 --- a/internal/oracle/oracle.go +++ b/internal/oracle/oracle.go @@ -351,7 +351,7 @@ func Strict(formatID, path string) Result { func StrictKnows(formatID string) bool { switch formatID { case "png", "wav", "pdf", "zip", "targz", "log", "csv", "json", "xml", "svg", "html", - "bmp", "gif", "ico", "jpg", "tiff", "docx", "xlsx", "pptx": + "bmp", "gif", "ico", "jpg", "tiff", "webp", "docx", "xlsx", "pptx": return true } return false diff --git a/internal/oracle/scripts.go b/internal/oracle/scripts.go index 1945486..cbf9194 100644 --- a/internal/oracle/scripts.go +++ b/internal/oracle/scripts.go @@ -238,14 +238,24 @@ finally: // pillowScript opens the image and forces every pixel to be decoded, so a // truncated or malformed image fails rather than passing on its header alone. +// +// Every frame, not only the first. GIF started writing animations on +// 2026-08-29 and until then this decoded frame one and stopped, which means a +// second frame could have been malformed and the oracle would have said OK. +// The loop costs nothing on a still picture, where there is one frame to walk. const pillowScript = ` import sys try: - from PIL import Image + from PIL import Image, ImageSequence except ImportError: print("SKIP no pillow"); sys.exit(0) im = Image.open(sys.argv[1]) -im.load() -im.convert("RGBA").tobytes() -print("OK", im.format, im.width, im.height) +frames = 0 +for frame in ImageSequence.Iterator(im): + frame.load() + frame.convert("RGBA").tobytes() + frames += 1 +if frames < 1: + print("FAIL the reader found no frame at all"); sys.exit(1) +print("OK", im.format, im.width, im.height, "frames", frames) ` diff --git a/internal/oracle/strict.py b/internal/oracle/strict.py index 4b49db0..c230c17 100644 --- a/internal/oracle/strict.py +++ b/internal/oracle/strict.py @@ -1218,11 +1218,56 @@ def check_pptx(data): "ppt/theme/theme1.xml"], "PowerPoint presentation", "pptx") +def check_webp(data): + if data[:4] != b"RIFF" or data[8:12] != b"WEBP": + fail("the signature is not a WebP signature") + if len(data) < 20: + fail(f"the file is {len(data)} B and the container alone needs 20 B") + + declared = struct.unpack(" len(data): + fail(f"the header says the payload runs to {payload_end} and the file is {len(data)} B") + trailing = len(data) - payload_end + if trailing > 7: + fail(f"{trailing} B sit after the RIFF payload, and only the odd byte belongs there - " + "the bulk of the padding belongs in a chunk") + + pos, image, private, seen = 12, 0, 0, [] + while pos + 8 <= payload_end: + tag = data[pos:pos + 4] + size = struct.unpack(" payload_end: + fail(f"the chunk {tag.decode('latin1')!r} says {size} B and only " + f"{payload_end - pos - 8} B are left") + seen.append(tag.decode("latin1")) + if tag in (b"VP8L", b"VP8 ", b"VP8X"): + image += 1 + if tag == b"VP8L" and data[pos + 8] != 0x2F: + fail("the lossless stream does not start with its signature byte") + else: + private += 1 + pos = end + (size & 1) + + if pos != payload_end: + fail(f"the chunks end at {pos} and the payload ends at {payload_end}") + if image != 1: + fail(f"the file carries {image} image chunks and a still WebP has one") + + ok(f"riff {declared} B, chunks {seen}, {private} private, {trailing} B after the payload") + + CHECKS = {"png": check_png, "wav": check_wav, "pdf": check_pdf, "zip": check_zip, "log": check_log, "csv": check_csv, "json": check_json, "xml": check_xml, "svg": check_svg, "html": check_html, "targz": check_targz, "bmp": check_bmp, "gif": check_gif, "ico": check_ico, "jpg": check_jpg, - "tiff": check_tiff, + "tiff": check_tiff, "webp": check_webp, "docx": check_docx, "xlsx": check_xlsx, "pptx": check_pptx} if __name__ == "__main__": diff --git a/web/public/faq/index.html b/web/public/faq/index.html index 32211bd..dc8aa5e 100644 --- a/web/public/faq/index.html +++ b/web/public/faq/index.html @@ -134,7 +134,7 @@

Frequently asked questions

Which formats are coming next?

-

7z, tiff, webp, mp3 and mp4. 21 formats work end to end today.

+

7z, tiff, webp, mp3 and mp4. 22 formats work end to end today.

@@ -205,7 +205,7 @@

Frequently asked questions

{ "@type": "Question", "name": "Which formats are coming next?", - "acceptedAnswer": { "@type": "Answer", "text": "7z, tiff, webp, mp3 and mp4. 21 formats work end to end today." } + "acceptedAnswer": { "@type": "Answer", "text": "7z, tiff, webp, mp3 and mp4. 22 formats work end to end today." } }, { "@type": "Question", diff --git a/web/public/formats/index.html b/web/public/formats/index.html index 41449da..9030561 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -3,7 +3,7 @@ -21 Supported File Formats - PDF, DOCX, PNG, ZIP and More +22 Supported File Formats - PDF, DOCX, PNG, ZIP and More @@ -12,7 +12,7 @@ - + @@ -21,7 +21,7 @@ - + @@ -74,7 +74,7 @@
-

21 file formats, every one generated at an exact size

+

22 file formats, every one generated at an exact size

Each of these is a real file of that format. It opens in the software that owns it, and it is exactly the number of bytes you asked for. None of them is padded zeros with an extension @@ -117,7 +117,7 @@

21 file formats, every one generated at an exact size

gif .gif - 41 + 114 full pillow @@ -219,6 +219,13 @@

21 file formats, every one generated at an exact size

full ffprobe + + webp + .webp + 148 + full + pillow + xlsx .xlsx @@ -319,6 +326,11 @@

Settings each format accepts

height 1 - 20000 pixels + + + frames + 1 - 60 + ico width @@ -419,6 +431,16 @@

Settings each format accepts

content noise, silence, sweep, tone + + webp + width + 1 - 16383 pixels + + + + height + 1 - 16383 pixels + xlsx rows diff --git a/web/public/index.html b/web/public/index.html index f7e2926..60de359 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -3,7 +3,7 @@ -Test File Generator for QA - Exact Size, 21 Real Formats +Test File Generator for QA - Exact Size, 22 Real Formats @@ -12,7 +12,7 @@ - + @@ -21,7 +21,7 @@ - + @@ -87,7 +87,7 @@

Generate real test files at any exact size

- PDF, PNG, DOCX, ZIP - 21 formats in all, and every one is a + PDF, PNG, DOCX, ZIP - 22 formats in all, and every one is a real file that opens in the software that owns it, at exactly the size you asked for. Each run also writes down what your application is supposed to do with each file. Command line and desktop window, free and open source, working entirely on your machine. @@ -109,7 +109,7 @@

Generate real test files at any exact size

  • - 21 + 22

    real formats, each one opening in the software that owns it

  • @@ -229,7 +229,7 @@

    Built for a suite that runs unattended

    Ask for 10485761 bytes and get exactly that. A size a format cannot reach is an error with a reason, never a file of the wrong size.

  • -

    21 real formats

    +

    22 real formats

    Not padded zeros with an extension. A generated PNG opens in an image viewer, a DOCX opens in Word, a ZIP extracts. Each one is checked against independent readers before it ships.

  • @@ -310,7 +310,7 @@

    Where this is today

    state of it.

    - Working end to end: 21 formats, recipes, presets, the desktop + Working end to end: 22 formats, recipes, presets, the desktop window, generate, validate, verify, cleanup, boundary sets, archive contents, size ranges, per format settings, manifests and every exit code.

    diff --git a/web/public/pl/faq/index.html b/web/public/pl/faq/index.html index e41f24e..e377f87 100644 --- a/web/public/pl/faq/index.html +++ b/web/public/pl/faq/index.html @@ -135,7 +135,7 @@

    Najczęstsze pytania

    Jakie formaty są następne w kolejce?

    -

    7z, tiff, webp, mp3 i mp4. Formatów działających dziś od początku do końca jest 21.

    +

    7z, tiff, webp, mp3 i mp4. Formatów działających dziś od początku do końca jest 22.

    @@ -206,7 +206,7 @@

    Najczęstsze pytania

    { "@type": "Question", "name": "Jakie formaty są następne w kolejce?", - "acceptedAnswer": { "@type": "Answer", "text": "7z, tiff, webp, mp3 i mp4. Formatów działających dziś od początku do końca jest 21." } + "acceptedAnswer": { "@type": "Answer", "text": "7z, tiff, webp, mp3 i mp4. Formatów działających dziś od początku do końca jest 22." } }, { "@type": "Question", diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index 370d63d..3d5b1ea 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -3,7 +3,7 @@ -21 formatów plików testowych - PDF, DOCX, PNG, ZIP +22 formatów plików testowych - PDF, DOCX, PNG, ZIP @@ -12,7 +12,7 @@ - + @@ -21,7 +21,7 @@ - + @@ -74,7 +74,7 @@
    -

    21 formatów plików, każdy generowany o dokładnym rozmiarze

    +

    22 formatów plików, każdy generowany o dokładnym rozmiarze

    Każdy z nich to prawdziwy plik tego formatu. Otwiera się w programie, do którego należy, i ma dokładnie tyle bajtów, ile zamówiłeś. Żaden nie jest zerami z doklejonym rozszerzeniem. @@ -116,7 +116,7 @@

    21 formatów plików, każdy generowany o dokładnym rozmiarze

    gif .gif - 41 + 114 full pillow @@ -218,6 +218,13 @@

    21 formatów plików, każdy generowany o dokładnym rozmiarze

    full ffprobe + + webp + .webp + 148 + full + pillow + xlsx .xlsx @@ -319,6 +326,11 @@

    Ustawienia, które przyjmuje każdy format

    height 1 - 20000 pikseli + + + frames + 1 - 60 + ico width @@ -419,6 +431,16 @@

    Ustawienia, które przyjmuje każdy format

    content noise, silence, sweep, tone + + webp + width + 1 - 16383 pikseli + + + + height + 1 - 16383 pikseli + xlsx rows diff --git a/web/public/pl/index.html b/web/public/pl/index.html index 8e904b9..b4a07e9 100644 --- a/web/public/pl/index.html +++ b/web/public/pl/index.html @@ -3,7 +3,7 @@ -Generator plików testowych o zadanym rozmiarze - 21 formatów +Generator plików testowych o zadanym rozmiarze - 22 formatów @@ -12,7 +12,7 @@ - + @@ -21,7 +21,7 @@ - + @@ -87,7 +87,7 @@

    Generuj pliki testowe o zadanym rozmiarze

    - PDF, PNG, DOCX, ZIP - razem 21 formatów, a każdy to + PDF, PNG, DOCX, ZIP - razem 22 formatów, a każdy to prawdziwy plik, który otwiera się w programie, do którego należy, i ma dokładnie taki rozmiar, o jaki poprosisz. Każdy przebieg zapisuje też, co Twoja aplikacja ma z każdym plikiem zrobić. Wiersz poleceń i okno, darmowe i otwarte, działające wyłącznie na Twojej maszynie. @@ -109,7 +109,7 @@

    Generuj pliki testowe o zadanym rozmiarze

    • - 21 + 22

      prawdziwych formatów, każdy otwiera się w programie, do którego należy

    • @@ -229,7 +229,7 @@

      Zbudowane pod zestaw, który chodzi bez nadzoru

      Poproś o 10485761 bajtów i tyle dostaniesz. Rozmiar nieosiągalny dla formatu to błąd z powodem, nigdy plik o innym rozmiarze.

    • -

      21 prawdziwych formatów

      +

      22 prawdziwych formatów

      Nie zera z doklejonym rozszerzeniem. Wygenerowany PNG otwiera się w przeglądarce obrazów, DOCX w Wordzie, a ZIP się rozpakowuje. Każdy format jest sprawdzany niezależnym czytnikiem, zanim trafi do wydania.

    • @@ -310,7 +310,7 @@

      Gdzie to dziś jest

      faktyczny.

      - Działa od początku do końca: 21 formatów, przepisy, presety, + Działa od początku do końca: 22 formatów, przepisy, presety, okno, generate, validate, verify, cleanup, zestawy graniczne, zawartość archiwów, zakresy rozmiarów, ustawienia formatów, manifesty i wszystkie kody wyjścia. diff --git a/web/public/social.html b/web/public/social.html index da40984..f06b7c8 100644 --- a/web/public/social.html +++ b/web/public/social.html @@ -210,12 +210,12 @@

      Real test files.
      At any exact size.

      - 21 formats that open in the software that owns them, plus a manifest + 22 formats that open in the software that owns them, plus a manifest saying how your system should react to each file.

      • exact to the byte
      • -
      • 21 real formats
      • +
      • 22 real formats
      • same bytes every run
      • GUI + CLI
      • built for CI