Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,21 @@ because it turns other people's test suites red.

### Changed

- **A spreadsheet can now be built wider than a spreadsheet can open.** The
`columns` setting of `xlsx` used to stop at 64. It now reaches 32768, which is
the ceiling `csv` already had.

The number matters because of where a reader stops. Excel and LibreOffice
Calc both hold 16384 columns. Measured with Calc: a sheet of 16384 columns
opens whole, and a sheet of 16385 opens as 16384 - the last column is dropped
and nothing is said about it. At a ceiling of 64 there was no way to build a
file that asks a spreadsheet about its own limit, which is the kind of file
this tool exists to produce.

`rows` times `columns` still cannot pass 2 million cells, so a sheet 16385
columns wide holds up to 122 rows. Nothing about a sheet of 64 columns or
fewer changes, and the default is still one column.

- **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
Expand Down
2 changes: 1 addition & 1 deletion internal/format/md/md.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func init() {
// Heading depth, table size and which elements appear come later.
// Declaring only what is here is what makes a recipe asking for them
// fail loudly rather than quietly producing something else.
Properties: textenc.Properties(),
Properties: textenc.Axes(textenc.Setting, textenc.SettingBOM),
GeneratorVersion: generatorVersion,
Generator: generator{},
})
Expand Down
87 changes: 71 additions & 16 deletions internal/format/textenc/textenc.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
// Package textenc is the character encoding the text formats share.
//
// It lives here rather than inside one of them because TXT and MD ask the same
// question and have to answer it in the same words. A copy in each is how
// It lives here rather than inside one of them because TXT, MD and XML ask the
// same question and have to answer it in the same words. A copy in each is how
// thirteen packages ended up carrying four different versions of one filler
// loop, and the same rule applies to a setting a person reads.
//
// Sharing the declaration is not the same as being held to it, and until
// 2026-09-09 only the first was true here. A format that simply did not call
// this package could declare the same setting any way it liked, which is the
// gap Axes and TestEveryTextFormatDeclaresTheEncodingSettingsAsTheyAreDeclared
// Once close between them.
//
// What it holds is arithmetic as much as bytes. A file written in UTF-16 is a
// whole number of sixteen bit units, so its length is always even - which
// means half of all sizes stop being reachable, and the exact size promise
Expand All @@ -18,6 +24,7 @@ package textenc
import (
"fmt"
"io"
"sort"
"unicode/utf16"
"unicode/utf8"

Expand Down Expand Up @@ -253,19 +260,67 @@ func appendUnit(dst []byte, u uint16, big bool) []byte {
return append(dst, byte(u), byte(u>>8))
}

// Properties is the declaration both text formats hand to the registry, so
// the two cannot describe the same setting differently.
func Properties() []format.Property {
return []format.Property{
{
Name: Setting, Kind: format.PropertyChoice,
Choices: []string{UTF16BE, UTF16LE, UTF8}, Default: UTF8,
Detail: "Which encoding the characters are written in. UTF-16 stores two bytes per character, so a file in it always has an even number of bytes and an odd size is refused.",
},
{
Name: SettingBOM, Kind: format.PropertyBool,
Default: "false",
Detail: "Whether the file opens with a byte order mark. A reader that has to guess the encoding needs one, and a reader that does not expect it shows it as stray characters at the start of the file.",
},
// axes is the declaration of every setting a text format may take, by key.
//
// One copy, so two text formats cannot offer the same setting with a different
// set of values, a different default or a different sentence beside it. That
// is not tidiness waiting for a problem: measured on 2026-09-09 by giving md a
// declaration of its own that agreed on kind, unit and default and offered one
// encoding fewer, eleven guards stayed green while txt and xml wrote utf-16be
// and md refused it.
//
// The default is the one field here that is untouchable rule 3 rather than
// consistency. Every one of these formats has always written UTF-8 with no
// mark, so a format defaulting to anything else moves its own bytes.
var axes = map[string]format.Property{
Setting: {
Name: Setting, Kind: format.PropertyChoice,
Choices: []string{UTF16BE, UTF16LE, UTF8}, Default: UTF8,
Detail: "Which encoding the characters are written in. UTF-16 stores two bytes per character, so a file in it always has an even number of bytes and an odd size is refused.",
},
SettingBOM: {
Name: SettingBOM, Kind: format.PropertyBool,
Default: "false",
Detail: "Whether the file opens with a byte order mark. A reader that has to guess the encoding needs one, and a reader that does not expect it shows it as stray characters at the start of the file.",
},
}

// Names is every text encoding setting this build declares, in a stable order.
//
// It exists for the guard that compares what a text format offers against what
// this package says the setting is, so an axis added tomorrow is covered
// without a line changing in that guard.
func Names() []string {
out := make([]string, 0, len(axes))
for n := range axes {
out = append(out, n)
}
sort.Strings(out)
return out
}

// Axes is the declarations for the settings named, in the order given.
//
// A format lists what it takes rather than receiving all of it, and the three
// that exist today all take both. The narrowing is here because the fourth is
// already named and cannot: an HTML document has to be UTF-8 by its own
// specification, so the only encoding axis it could carry is the mark - and
// without this it would have to write that declaration out by hand, which is
// exactly the drift the guard above exists to refuse. The choice is by NAME
// rather than by value, because no text format wants a narrower set of
// encodings and building for one that might is guessing at a shape.
//
// An unknown name panics rather than being skipped, for the reason archive
// gives: this is called from init, the caller is a programmer, and a silently
// dropped axis is a setting that vanishes from both surfaces with nothing said.
func Axes(names ...string) []format.Property {
out := make([]format.Property, 0, len(names))
for _, n := range names {
p, ok := axes[n]
if !ok {
panic(fmt.Sprintf("textenc: %q is not a text encoding setting this build declares", n))
}
out = append(out, p)
}
return out
}
2 changes: 1 addition & 1 deletion internal/format/txt/txt.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func init() {
// Line endings and line length come later. Until they do, declaring
// only what is here is what makes a recipe asking for them fail loudly
// instead of quietly producing something else.
Properties: textenc.Properties(),
Properties: textenc.Axes(textenc.Setting, textenc.SettingBOM),
GeneratorVersion: generatorVersion,
Generator: generator{},
})
Expand Down
22 changes: 18 additions & 4 deletions internal/format/xlsx/xlsx.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,23 @@ const (
maxRows = 200_000

minColumns = 1
// Sixteen thousand three hundred and eighty four is the format's own limit.
// This one is the width a person would actually look at.
maxColumns = 64
// maxColumns is deliberately ABOVE the width of a spreadsheet, and that is
// the whole reason for the number. It is the same ceiling CSV carries, for
// the same question asked of the same reader.
//
// It said 64 until 2026-09-08, with the reason "the width a person would
// actually look at". That reason describes a document somebody reads, and
// this tool writes fixtures somebody tests with - a ceiling belongs to the
// reader under test, not to our own comfort. At 64 there was no way to
// build a sheet that asks Excel about its own limit at all.
//
// Measured 2026-09-08 with LibreOffice Calc 26.2.5.2 headless, on
// workbooks built outside this tool because this tool could not build
// them: 16384 columns come back whole, and 16385 come back as 16384 with
// the last column dropped, exit 0 and not one word on either stream. That
// silent loss is the thing a tester needs a fixture for, and standing on
// both sides of the line is what a boundary set is.
maxColumns = 32768

// The sheet is held in memory while the package is built, so the pair is
// bounded as well as each side.
Expand Down Expand Up @@ -75,7 +89,7 @@ func init() {
Name: "columns", Kind: format.PropertyInt,
Min: minColumns, Max: maxColumns, Unit: "columns",
Default: "1",
Detail: "How many columns each row has.",
Detail: "How many columns each row has. Above 16384 a spreadsheet may show only the first 16384 and drop the rest without a word.",
},
},
JointLimits: []format.JointLimit{{
Expand Down
2 changes: 1 addition & 1 deletion internal/format/xmlfile/xml.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ func init() {
// Depth, element counts, namespaces, CDATA and an internal DTD come
// later. Declaring only what is here is what makes a recipe asking for
// them fail loudly instead of quietly producing something else.
Properties: textenc.Properties(),
Properties: textenc.Axes(textenc.Setting, textenc.SettingBOM),
GeneratorVersion: generatorVersion,
Generator: generator{},
})
Expand Down
79 changes: 79 additions & 0 deletions internal/guard/columnceiling_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package guard

import (
"testing"

"github.com/donislawdev/TestingFilesGenerator/internal/format"
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"
)

// spreadsheetWidth is where a spreadsheet stops, measured rather than
// remembered: 2026-09-03 for CSV and again 2026-09-08 for XLSX, both with
// LibreOffice Calc 26.2.5.2 headless. A table of this many columns comes back
// whole. One column more comes back with this many, the last column dropped,
// exit 0 and not one word on either stream.
const spreadsheetWidth = 16384

// narrowerOnPurpose names a format whose columns cannot reach past a
// spreadsheet because the format itself stops first, with the reason.
//
// It is empty, and that is the point rather than an oversight. A format ends up
// here only when its OWN structure caps it - the way an icon stores each side
// in a single byte - and never because a smaller number felt tidier. Writing
// the reason down is the price of the exception, which is what stops this
// becoming the sort of list somebody adds to instead of thinking.
var narrowerOnPurpose = map[string]string{}

// A format with columns has to offer more of them than a spreadsheet accepts.
//
// The ceiling of a setting belongs to the reader under test, not to what this
// tool finds comfortable to write. Standing on both sides of a limit is what a
// boundary set is for, so a ceiling that stops at the limit offers the last
// table that survives and never the first that does not.
//
// This is not hypothetical tidiness, it is a defect this project shipped.
// XLSX declared 64 from the day it was written until 2026-09-08, with the
// reason "the width a person would actually look at" - which describes a
// document somebody reads rather than a fixture somebody tests with. CSV asked
// the same question of the same reader and answered 32768. Nothing compared
// them, and at 64 there was no way to build a sheet that asks Excel about its
// own limit at all.
//
// What it asks about is the OFFER, because that is what a ceiling is. That the
// file itself works was measured on 2026-09-08 rather than asserted here: a
// workbook of 16385 columns built by this tool converts through Calc at exit 0
// and comes back with 16384, which is the same silent loss a workbook from
// another writer produces. Building one costs twelve megabytes, which is not a
// price worth paying on every run of this suite.
//
// Asked of every registered format rather than of the two that have the
// setting today, so a third tabular format is covered on the day it arrives.
func TestAFormatWithColumnsReachesPastWhatASpreadsheetAccepts(t *testing.T) {
examined := 0

for _, d := range format.All() {
for _, p := range d.Properties {
if p.Name != "columns" || p.Kind != format.PropertyInt {
continue
}
if why, narrow := narrowerOnPurpose[d.ID]; narrow {
if p.Max > spreadsheetWidth {
t.Errorf("%s is excused as narrower on purpose (%s) and reaches %d anyway - take the excuse off",
d.ID, why, p.Max)
}
continue
}
examined++
if p.Max <= spreadsheetWidth {
t.Errorf("%s offers at most %d columns and a spreadsheet accepts %d, "+
"so no fixture built from it can ask the reader about its own limit - "+
"raise the ceiling, or name the structural reason in narrowerOnPurpose",
d.ID, p.Max, spreadsheetWidth)
}
}
}

if examined == 0 {
t.Fatal("no format offers a column count, so this proved nothing")
}
}
Loading
Loading