diff --git a/CHANGELOG.md b/CHANGELOG.md index 5844e36..0651326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,26 @@ because it turns other people's test suites red. ### Added +- **SVG drawings can be any size you ask for.** Two new settings on `svg`: + `width` and `height`, both a whole number of pixels from 1 to 20000. They + default to the 800 by 600 these drawings have always been, so a recipe that + says nothing gets the same bytes it got before. + + ``` + tfg generate --format svg --size 20kb --set width=1920 --set height=1080 + ``` + + There is no joint limit on the two, unlike the picture formats, because + nothing is drawn into pixels here - the file only says how big it is. That + makes a small file that claims to be enormous, which is the point: a 3 kB + drawing declaring 20000 by 20000 asks whether whatever opens it has a limit + on picture size and not only on file size. Pillow, for one, refuses to open + the result. + + A drawing shorter than 57 pixels has no room for the label along its bottom + edge. It is still produced and still named, and the run says which files + those were. + - **Text and Markdown files can be written in UTF-16, with or without a byte order mark.** Two new settings on `txt` and `md`: `encoding`, which takes `utf-8`, `utf-16le` or `utf-16be`, and `bom`, which is `true` or `false`. @@ -191,6 +211,20 @@ because it turns other people's test suites red. ### Changed +- **Byte counts are grouped in threes.** A total used to print as + `2516582400 B`. It now prints as `2 516 582 400 B`, in every message that + names a number of bytes - `tfg formats`, the summary a run prints, what a + preset says its budget is, and what `tfg validate` reports. + + Grouped with a space rather than a comma, because a comma is a thousands mark + in some countries and a decimal point in others, and this tool is read in + both. + + Machine output is untouched. `--json` and the manifest carry numbers rather + than sentences, so nothing that parses those sees any of this. If you have a + script reading a byte count out of the human output, it needs to take the + spaces out. + - **Notes are reported once per thing they say, not once per file.** A run of 25 000 one-byte text files used to print 25 001 `note:` lines, every one of them the same sentence about the label not fitting. It now prints one, with diff --git a/README.md b/README.md index 7d064c7..e2090e6 100644 --- a/README.md +++ b/README.md @@ -478,7 +478,8 @@ recipe. `tfg formats ` prints the allowed range or list for each: | `log` | `entry_format`, `timestamps`, `rate`, `methods`, `status_mix`, `level_mix`, `ip_version`, `line_ending` | | `txt`, `md` | `encoding`, `bom` | | `json` | `formatting` | -| `xml`, `html`, `svg` | none | +| `svg` | `width`, `height` | +| `xml`, `html` | none | ``` tfg generate --format jpg --size 500kb --set width=1920 --set height=1080 --set quality=85 diff --git a/internal/cli/formats.go b/internal/cli/formats.go index 6b9d830..466cb9c 100644 --- a/internal/cli/formats.go +++ b/internal/cli/formats.go @@ -7,6 +7,7 @@ import ( "fmt" "io" + "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/format" ) @@ -109,8 +110,8 @@ func entryFor(d format.Descriptor) formatEntry { // list and ignore the argument, ending with 0 - so there was no way to ask what // a format accepts, and the silence looked like an answer. func describeOne(d format.Descriptor, out io.Writer) { - fmt.Fprintf(out, "%s - %s fidelity, %s deterministic, minimum %d B\n", - d.ID, d.Fidelity, d.Determinism, smallestAccepted(d)) + fmt.Fprintf(out, "%s - %s fidelity, %s deterministic, minimum %s\n", + d.ID, d.Fidelity, d.Determinism, core.ExactBytes(smallestAccepted(d))) fmt.Fprintf(out, " extension %s\n", d.Extension) fmt.Fprintf(out, " padding %s\n", d.Padding.Name) fmt.Fprintf(out, " label %s\n", d.Label) diff --git a/internal/cli/generate.go b/internal/cli/generate.go index 4e516b4..45abb57 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -351,10 +351,16 @@ func sizesFromFlags(g *generateOpts, errOut io.Writer) (sizes []int64, low, high if errors.Is(err, core.ErrBoundaryTooSmall) { // A number somebody typed, so this is USAGE rather than a problem // with a document. The end above it keeps the code it had. + // Ungrouped, unlike every other count this program prints, and the + // exception is deliberate: this echoes back the number somebody + // typed after --boundary. A message that quotes your input and + // respells it on the way is a message you have to translate before + // you can compare it with what you wrote. fmt.Fprintf(errOut, "tfg: --boundary %d B is too small - %s\n", limit, err) return nil, 0, 0, 0, ExitUsage } if err != nil { + // Ungrouped for the same reason as the line above it. fmt.Fprintf(errOut, "tfg: --boundary %d B is too large - %s\n", limit, err) return nil, 0, 0, 0, ExitRecipe } @@ -387,9 +393,9 @@ func produce(ctx context.Context, targets []engine.Target, opt engine.Options, g // Echo the exact byte count. The exact number is the point of this tool, // and it is what any other tool will show when the user goes to check the // file. - fmt.Fprintf(errOut, "%s in %s, %d B total\n", + fmt.Fprintf(errOut, "%s in %s, %s total\n", core.Count(len(planned), "file", "files"), core.Count(len(targets), "target", "targets"), - engine.TotalBytes(planned)) + core.ExactBytes(engine.TotalBytes(planned))) echoBoundaries(targets, planned, errOut) echoManifestReach(planned, errOut) @@ -530,10 +536,10 @@ func echoBoundaries(targets []engine.Target, planned []engine.PlannedFile, errOu if t.BoundaryLimit <= 0 { continue } - fmt.Fprintf(errOut, "boundary %q around %d B:\n", t.ID, t.BoundaryLimit) + fmt.Fprintf(errOut, "boundary %q around %s:\n", t.ID, core.ExactBytes(t.BoundaryLimit)) for _, f := range planned { if f.Target == t { - fmt.Fprintf(errOut, " %-26s %d B\n", f.Name, f.Plan.Bytes) + fmt.Fprintf(errOut, " %-26s %s\n", f.Name, core.ExactBytes(f.Plan.Bytes)) } } diff --git a/internal/cli/preset.go b/internal/cli/preset.go index c98b8ab..09de047 100644 --- a/internal/cli/preset.go +++ b/internal/cli/preset.go @@ -537,9 +537,9 @@ func describePreset(e *preset.Expansion, b budget, out io.Writer) { fmt.Fprintf(out, " --%-12s the global flag, this preset gives it a default\n", name) } - fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %d B total, format %s\n", + fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, format %s\n", core.Count(b.Targets, "target", "targets"), core.Count(b.Files, "file", "files"), - b.Bytes, strings.Join(b.Formats, ", ")) + core.ExactBytes(b.Bytes), strings.Join(b.Formats, ", ")) for _, note := range e.Notes() { fmt.Fprintf(out, "\nnote: %s\n", note) } diff --git a/internal/cli/recipecmd.go b/internal/cli/recipecmd.go index aa8ef7d..d3a11ff 100644 --- a/internal/cli/recipecmd.go +++ b/internal/cli/recipecmd.go @@ -94,8 +94,9 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int { }, ExitOK) } - fmt.Fprintf(out, "%s is valid: %s, %s, %d B total\n%s\n", - path, core.Count(len(rec.Targets), "target", "targets"), core.Count(len(planned), "file", "files"), engine.TotalBytes(planned), hash) + fmt.Fprintf(out, "%s is valid: %s, %s, %s total\n%s\n", + path, core.Count(len(rec.Targets), "target", "targets"), core.Count(len(planned), "file", "files"), + core.ExactBytes(engine.TotalBytes(planned)), hash) return ExitOK } diff --git a/internal/core/humanise.go b/internal/core/humanise.go index 37ac11c..f185770 100644 --- a/internal/core/humanise.go +++ b/internal/core/humanise.go @@ -3,6 +3,8 @@ package core import ( "fmt" "math" + "strconv" + "strings" "time" ) @@ -20,7 +22,10 @@ import ( func HumanBytes(n int64) string { const unit = 1024 if n < unit { - return fmt.Sprintf("%d B", n) + // Through ExactBytes rather than its own %d, so the two never spell one + // number two ways. Below 1024 there is nothing to group, which is + // exactly why this is easy to get wrong and leave wrong. + return ExactBytes(n) } div, exp := int64(unit), 0 for n/div >= unit && exp < 3 { @@ -30,6 +35,55 @@ func HumanBytes(n int64) string { return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]) } +// ExactBytes writes a count out in full, grouped in threes, with its unit. +// +// The exact number is the point of this tool and it can never be replaced by a +// rounded one - but eleven digits in a row is a number nobody reads, and both +// surfaces printed it that way. "2516582400 B" was measured on the window's run +// panel and on four lines of the command line, and the owner's report of it was +// that the bytes are welcome and unreadable, which are both true at once. +// +// Grouped with a space rather than a comma, and that is the one choice here +// worth writing down. A comma is the thousands mark in English and the decimal +// mark for most of Europe, so "2,516" is either two and a half thousand or two +// and a half depending on who is reading - and the people who read this run it +// in every country. A space means the same thing everywhere. +// +// Machine output is untouched on purpose. Nothing in a manifest or under --json +// goes through here, because a number there is a number and not a sentence. +func ExactBytes(n int64) string { + return groupedInThrees(strconv.FormatInt(n, 10)) + " B" +} + +// groupedInThrees puts a space every three digits, counting from the right. +// +// Written out rather than reached for in a library because the one in the +// standard library is about money: golang.org/x/text/message formats to a +// LOCALE, and a locale is exactly what this must not have - the window and the +// command line have to say the same thing on a Polish desktop and an American +// one, and docs/UX.md has the surfaces agreeing as a rule rather than a hope. +func groupedInThrees(digits string) string { + sign := "" + if strings.HasPrefix(digits, "-") { + sign, digits = "-", digits[1:] + } + if len(digits) <= 3 { + return sign + digits + } + lead := len(digits) % 3 + if lead == 0 { + lead = 3 + } + var out strings.Builder + out.Grow(len(digits) + (len(digits)-1)/3) + out.WriteString(digits[:lead]) + for i := lead; i < len(digits); i += 3 { + out.WriteByte(' ') + out.WriteString(digits[i : i+3]) + } + return sign + out.String() +} + // Percent divides before multiplying where it has to, so a very large run does // not wrap on the way to a number between nought and a hundred. // diff --git a/internal/format/svgfile/svg.go b/internal/format/svgfile/svg.go index 4fbb64f..b277d44 100644 --- a/internal/format/svgfile/svg.go +++ b/internal/format/svgfile/svg.go @@ -37,47 +37,149 @@ import ( const ( generatorVersion = "1" - width = 800 - height = 600 + defaultWidth = 800 + defaultHeight = 600 - // textBand is the strip along the bottom edge that shapes stay out of, so + // The range the two dimensions accept. + // + // The upper end is the same number the nine picture formats use, and that + // is deliberate rather than lazy: somebody who has learnt "width up to + // 20000" for PNG does not learn a second number here. What is NOT carried + // over is their joint ceiling of 40 megapixels, because the reason for it + // does not exist here - a picture format holds the raster in memory while + // it encodes, and this one writes text. + // + // Measured 2026-09-08, a document of this shape rendered headless by + // Inkscape 1.4.4 and read back with Pillow: + // + // 10000x8000 80 Mpx 5.3 s 339 kB + // 20000x20000 400 Mpx 25.6 s 1.6 MB + // 32000x32000 1024 Mpx 43.6 s 4.1 MB + // 65536x65536 4295 Mpx 165.3 s 17 MB + // + // So the renderer does not set this ceiling - it never broke. The line + // worth crossing belongs to a reader instead: Pillow refuses an image over + // PIL.Image.MAX_IMAGE_PIXELS, measured at 89478485 px, as a decompression + // bomb. 20000 per axis reaches 400 Mpx, four and a half times over that + // line, so a set can hold files on both sides of it. That is how the CSV + // column ceiling was chosen too - above the point where a real reader + // starts to say no, not as high as the arithmetic allows. + minDimension = 1 + maxDimension = 20000 + + // TextBand is the strip along the bottom edge that shapes stay out of, so // the two lines of text below it are read against plain background. // // Without it about one shape in ten landed on the label - measured on a // small and a large file, 11.0% and 10.3% - and the label is the one thing // in the file that says what the file is. A drawing is still a drawing with // a margin. A label with a circle through it is not a label. - textBand = 56 - drawHeight = height - textBand + TextBand = 56 + + // Width and Height name the two settings. Exported so that a guard presses + // the key this format actually declares rather than a string spelled twice, + // which is the same reason jsonfile exports the name of its layout setting. + Width = "width" + Height = "height" declaration = `` + "\n" - rootOpen = `` + "\n" rootClose = "\n" - // The closing record is a text element, so the drawing ends with something - // that can be stretched to any length without changing what it is. - // - // It gets its own baseline. Sharing one with the identity label meant the - // closing text, written last, painted straight over it - the label was - // there in the bytes and unreadable on screen. Nothing caught that: the - // size was exact, the file parsed, and a renderer still drew a full page of - // shapes. - textOpen = `` - textY = "566" textClose = "\n" - // labelOpen carries the identity label along the bottom edge. It is the - // same width as textOpen, so the smallest file this format can produce does - // not move. - labelOpen = `` - labelY = "584" - tailLast = textClose + rootClose - - // fixedWidth is every literal byte of the closing record. - fixedWidth = len(textOpen) + len(tailLast) ) +// rootOpen is the opening tag for a drawing of these dimensions. +// +// It used to be a constant with 800 and 600 written into it, which is why the +// minimum below was a constant too. Both now depend on the dimensions asked +// for: "width=\"20000\"" is three bytes longer than "width=\"800\"", and the +// baseline of a text element near the bottom edge of a tall drawing is a +// longer number as well. +func rootOpen(w, h int) string { + return fmt.Sprintf( + ``+"\n", + w, h, w, h) +} + +// textOpen opens the closing record: a text element, so the drawing ends with +// something that can be stretched to any length without changing what it is. +// +// It gets its own baseline. Sharing one with the identity label meant the +// closing text, written last, painted straight over it - the label was there +// in the bytes and unreadable on screen. Nothing caught that: the size was +// exact, the file parsed, and a renderer still drew a full page of shapes. +func textOpen(h int) string { + return fmt.Sprintf(``, h-34) +} + +// labelOpen carries the identity label along the bottom edge, on the other +// baseline of the two. +func labelOpen(h int) string { + return fmt.Sprintf(``, h-16) +} + +// labelFits says whether the band along the bottom has room to show the label. +// +// Below this the file is still produced and still named - it simply carries no +// visible label, and says so. That is the same answer the picture formats give +// for a drawing too small to write on, and reusing their sentence is the point: +// a second wording for one situation is a second thing to keep true. +func labelFits(h int) bool { return h > TextBand } + +// drawHeightFor is the strip shapes are drawn in. +// +// At least one row, always. A drawing shorter than the text band has no room +// for the band, and the band is a courtesy to the label rather than a +// structural part of the document. +func drawHeightFor(h int) int { + if d := h - TextBand; d > 0 { + return d + } + return 1 +} + +// span keeps an argument to IntN positive. +// +// The shape code below assumes a margin of up to eighty units, which was safe +// for as long as the canvas was a constant 800 by 600. It is not safe now: +// measured 2026-09-08 by running it, IntN(0) and IntN(-30) both panic with +// "invalid argument to IntN", so "--set width=50" would have been a panic on a +// value that looks entirely legal. A generator panic costs one file rather +// than the process - there is a guard for that - but a panic is not an answer +// to a legal setting. +// +// At 800 by 600 every argument is already positive, so this changes no byte of +// any file this tool has produced. +func span(n int) int { + if n < 1 { + return 1 + } + return n +} + +// fit keeps a shape's lower edge out of the text band on any canvas. +// +// Arithmetic, not caution: at the default dimensions this can never bind. The +// widest rect starts at 719 and reaches 798 of 800, the tallest at 463 and +// reaches 542 of 544, and the largest circle and ellipse both reach 542 as +// well. Two units of slack on every axis, so the clamp is inert at 800 by 600 +// and the stored hashes prove it. +// +// room is always at least one, so this never returns a shape of no size. Each +// caller subtracts a coordinate drawn from span(drawHeight-margin) from +// drawHeight: above the margin that leaves the margin itself, and at or below +// it span returns one, the coordinate is nought and the room is the whole +// drawing. There is no third case. +// +// That claim was a guarded branch here until it was measured. A panic put in +// its place did not fire once across the whole canvas sweep, so the branch was +// a defence nothing could turn red - the eighth of its kind removed from this +// codebase. It is written down instead, which is what a claim nothing can +// contradict is worth. +func fit(extent, room int) int { return min(extent, room) } + func init() { format.Register(format.Descriptor{ ID: "svg", @@ -89,7 +191,16 @@ func init() { // rectangle. That is a shape request rather than a byte count, and it // arrives with the shape count property. The minimum here is the // declaration, the root and one whole record. - MinBytes: minimumBytes(), + // + // It is the minimum at the DEFAULT dimensions, and only that. Larger + // numbers in the root element and in a text baseline make a longer + // document, so the floor moves with the settings - the same shape as + // the JSON layouts, where the registry states the default layout's + // minimum and each of the others answers for its own. Nobody has to + // keep a second number in step: Plan refuses with the figure for the + // dimensions actually asked for, and Descriptor.SmallestAccepted asks + // Plan rather than reading anything declared here. + MinBytes: minimumBytes(defaultWidth, defaultHeight), Padding: format.PaddingChannel{ Name: "the text of the closing label", @@ -101,10 +212,25 @@ func init() { // rather than a comment. Label: format.LabelVisible, Oracle: "inkscape", - // Dimensions, shape counts, gradients, fonts, embedded rasters and SMIL - // come later. Declaring none now makes a recipe asking for them fail - // loudly. - Properties: nil, + // Shape counts, gradients, fonts, embedded rasters and SMIL come + // later. Declaring none of them makes a recipe asking for one fail + // loudly rather than quietly. + Properties: []format.Property{ + { + Name: Width, Kind: format.PropertyInt, + Min: minDimension, Max: maxDimension, Unit: "pixels", + Default: strconv.Itoa(defaultWidth), + Detail: "How wide the drawing says it is. Nothing is drawn into pixels here, " + + "so a large number costs a few bytes in the file and a great deal of memory in whatever opens it.", + }, + { + Name: Height, Kind: format.PropertyInt, + Min: minDimension, Max: maxDimension, Unit: "pixels", + Default: strconv.Itoa(defaultHeight), + Detail: "How tall the drawing says it is. The label sits along the bottom edge, " + + "so a drawing shorter than that strip carries no visible label.", + }, + }, GeneratorVersion: generatorVersion, Generator: generator{}, }) @@ -115,10 +241,48 @@ type generator struct{} type memo struct { labelLine string // includes the trailing newline, empty when absent seed uint64 + width int + height int +} + +// dimension reads one of the two size settings. +// +// The registry has already refused anything outside the declared range by the +// time a run reaches here, so the bounds below are a backstop for a caller +// that reaches the generator directly - the same belt the page count of PDF +// wears, and for the same reason. +func dimension(props map[string]string, name string, fallback int) (int, error) { + raw, ok := props[name] + if !ok || raw == "" { + return fallback, nil + } + n, err := strconv.Atoi(raw) + if err != nil { + return 0, &format.PropertyValueError{ + Format: "svg", Key: name, Value: raw, + Reason: "it has to be a whole number of pixels", + } + } + if n < minDimension || n > maxDimension { + return 0, &format.PropertyValueError{ + Format: "svg", Key: name, Value: raw, + Reason: fmt.Sprintf("it has to be between %d and %d", minDimension, maxDimension), + } + } + return n, nil } func (generator) Plan(r format.Request) (format.Plan, error) { - min := minimumBytes() + w, err := dimension(r.Properties, Width, defaultWidth) + if err != nil { + return format.Plan{}, err + } + h, err := dimension(r.Properties, Height, defaultHeight) + if err != nil { + return format.Plan{}, err + } + + min := minimumBytes(w, h) if r.Bytes < min { return format.Plan{}, &format.BelowMinimumError{ Format: "SVG", @@ -136,18 +300,28 @@ func (generator) Plan(r format.Request) (format.Plan, error) { Properties: map[string]any{ "encoding": "utf-8", "line_ending": "lf", - "width": width, - "height": height, - "view_box": "0 0 800 600", + Width: w, + Height: h, + "view_box": fmt.Sprintf("0 0 %d %d", w, h), }, } - m := memo{seed: r.Seed} + m := memo{seed: r.Seed, width: w, height: h} if r.Label { - line := labelOpen + core.Label("svg", r.Bytes, r.Seed) + textClose - if int64(len(line))+minimumBytes() <= r.Bytes { + switch line := labelOpen(h) + core.Label("svg", r.Bytes, r.Seed) + textClose; { + case !labelFits(h): + // Room on the page rather than room in the byte count, so it is a + // separate sentence from the one below. Both leave the file named + // by its own name and by the manifest. + p.Notes = append(p.Notes, format.Note{ + Code: "label_omitted", + Detail: fmt.Sprintf( + "The drawing is %d px tall and the label needs the %d px strip along the bottom, so this file carries no visible label. Its name and the manifest still identify it.", + h, TextBand), + }) + case int64(len(line))+min <= r.Bytes: m.labelLine = line - } else { + default: p.Notes = append(p.Notes, format.Note{ Code: "label_omitted", Detail: fmt.Sprintf( @@ -168,24 +342,37 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { return fmt.Errorf("svg: the plan was not produced by this generator") } - head := declaration + rootOpen + m.labelLine + head := declaration + rootOpen(m.width, m.height) + m.labelLine if err := core.WriteAll(w, []byte(head)); err != nil { return err } rng := core.NewRand(m.seed) - return core.FillRecords(ctx, w, rng, p.Bytes-int64(len(head)), shapes{}) + return core.FillRecords(ctx, w, rng, p.Bytes-int64(len(head)), shapesFor(m.width, m.height)) } // shapes builds the drawing. A natural record is one shape, and the closing // record is a text label stretched to land the byte count. -type shapes struct{} +// +// It carries the canvas rather than reading package constants, because the +// canvas is a setting now. Nothing else changed about what it draws. +type shapes struct { + width int + drawHeight int + // open is the closing record's opening tag, whose baseline depends on how + // tall the drawing is. + open string +} + +func shapesFor(w, h int) shapes { + return shapes{width: w, drawHeight: drawHeightFor(h), open: textOpen(h)} +} // Shortest is the smallest closing record: the label element with no text at // all, plus the bytes that close the root. -func (shapes) Shortest() int64 { return int64(fixedWidth) } +func (s shapes) Shortest() int64 { return int64(len(s.open) + len(tailLast)) } -func (shapes) Append(dst []byte, rng *rand.Rand) []byte { +func (s shapes) Append(dst []byte, rng *rand.Rand) []byte { // A line is drawn with a stroke and a closed shape is filled. Each branch // says which it wants, because a shape painted the wrong way is invisible // and the size never notices. @@ -193,39 +380,43 @@ func (shapes) Append(dst []byte, rng *rand.Rand) []byte { switch rng.IntN(4) { case 0: + x := rng.IntN(span(s.width - 80)) dst = append(dst, ` edge { + edge = v + } + shapes++ + } + num := func(b []byte) int { + n, _ := strconv.Atoi(string(b)) + return n + } + for _, m := range rectRe.FindAllSubmatch(doc, -1) { + note(num(m[1]) + num(m[2])) + } + for _, m := range circleRe.FindAllSubmatch(doc, -1) { + note(num(m[1]) + num(m[2])) + } + for _, m := range ellipseRe.FindAllSubmatch(doc, -1) { + note(num(m[1]) + num(m[2])) + } + for _, m := range lineRe.FindAllSubmatch(doc, -1) { + y1, y2 := num(m[1]), num(m[2]) + if y2 > y1 { + y1 = y2 + } + note(y1) + } + return edge, shapes +} + +// TestNoSvgShapeReachesIntoTheStripTheLabelSitsIn presses the clamp. +// +// The strip is asked of the format rather than written down here, so the two +// cannot drift: a wider strip would move this bound with it. +func TestNoSvgShapeReachesIntoTheStripTheLabelSitsIn(t *testing.T) { + for _, c := range svgCanvases { + w, h := c[0], c[1] + t.Run(fmt.Sprintf("%dx%d", w, h), func(t *testing.T) { + doc := drawSVG(t, 8192, svgCanvas(w, h)) + edge, shapes := lowestSVGEdge(doc) + if shapes == 0 { + t.Fatalf("%dx%d: read no shapes at all - this case proves nothing, "+ + "and two empty lists are equal", w, h) + } + room := h - svgfile.TextBand + if room < 1 { + room = 1 + } + if edge > room { + t.Errorf("%dx%d: a shape reaches %d and the drawing is %d deep before the "+ + "%d px strip the label sits in - a label with a circle through it is not a label", + w, h, edge, room, svgfile.TextBand) + } + }) + } +} + +// TestSayingTheSvgCanvasOutLoudChangesNoByte is the promise a new setting makes +// to every recipe written before it existed. +// +// Silence and stating the default are two different journeys through the +// parser, the registry and the generator, and they are allowed to disagree by +// accident. This is the one place that says they must not. +func TestSayingTheSvgCanvasOutLoudChangesNoByte(t *testing.T) { + d, err := format.Get("svg") + if err != nil { + t.Fatal(err) + } + + // The default is read from the declaration rather than written here. A + // number copied into a test is a number that stops being the default + // without anything saying so. + spoken := map[string]string{} + for _, p := range d.Properties { + if p.Name != svgfile.Width && p.Name != svgfile.Height { + continue + } + if p.Default == "" { + t.Fatalf("%s declares no default, so silence has nothing to be equal to", p.Name) + } + spoken[p.Name] = p.Default + } + if len(spoken) != 2 { + t.Fatalf("expected width and height to be declared, found %d of them", len(spoken)) + } + + for _, size := range []int64{194, 1024, 20480} { + silent := drawSVG(t, size, nil) + aloud := drawSVG(t, size, spoken) + if !bytes.Equal(silent, aloud) { + t.Errorf("%d B: saying %v out loud produced different bytes from leaving it out", + size, spoken) + } + } +} + +// TestASvgTooShortForTheLabelSaysSo is the untouchable rule about silence, +// applied to the one thing a drawing can lose by being small. +func TestASvgTooShortForTheLabelSaysSo(t *testing.T) { + d, err := format.Get("svg") + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + height int + labels bool + }{{1, false}, {40, false}, {svgfile.TextBand, false}, {svgfile.TextBand + 1, true}, {600, true}} { + p, err := d.Generator.Plan(format.Request{ + Bytes: 8192, Seed: 7741, Label: true, Properties: svgCanvas(400, c.height), + }) + if err != nil { + t.Fatalf("planning at height %d: %v", c.height, err) + } + got, _ := p.Properties[format.PropertyLabelEmbedded].(bool) + if got != c.labels { + t.Errorf("height %d: label embedded is %v, expected %v", c.height, got, c.labels) + } + said := false + for _, n := range p.Notes { + if n.Code == "label_omitted" { + said = true + } + } + if !c.labels && !said { + t.Errorf("height %d: no visible label and the run says nothing about it", c.height) + } + if c.labels && said { + t.Errorf("height %d: the label is there and the run apologises for it anyway", c.height) + } + } +} diff --git a/web/public/formats/index.html b/web/public/formats/index.html index 6e30225..57badba 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -510,6 +510,16 @@

Settings each format accepts

slides 1 - 500 slides + + svg + width + 1 - 20000 pixels + + + + height + 1 - 20000 pixels + targz entries diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index 2ed90a6..3d0a921 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -510,6 +510,16 @@

Ustawienia, które przyjmuje każdy format

slides 1 - 500 slajdów + + svg + width + 1 - 20000 pikseli + + + + height + 1 - 20000 pikseli + targz entries