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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ because it turns other people's test suites red.
same new code and were checked against their recorded hashes and across every
format at five sizes and two seeds.

### Added

- **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`.
Both default to what these formats have always produced, so a recipe that
says nothing gets the same bytes it got before.

```
tfg generate --format txt --size 4kb --set encoding=utf-16le --set bom=true
```

**In UTF-16 an odd number of bytes is refused.** Every character takes two
bytes, so only an even size can be a whole file, and asking for 4001 B gets
an error naming 4000 B and 4002 B rather than a file that is one byte out.
Three readers were measured on a UTF-16 file cut to an odd length: Python,
Node and .NET all reject it when asked strictly, and all three repair it in
silence otherwise, which is why this is an error and not a rounded size.

The self describing label costs twice as much in UTF-16, so it needs a file
of at least 66 B rather than 33 B to fit. Below that the file is still
produced and the manifest says the label was left out.

Single byte encodings such as Windows-1252 are deliberately not offered. The
generated text is English, so a file written in one would be byte for byte
the same file as UTF-8 - a setting that changes nothing.

### Security

- **On Windows, the desktop window loads the library it uses for dark menus from
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,8 @@ recipe. `tfg formats <id>` prints the allowed range or list for each:
| `pptx` | `slides` |
| `csv` | `delimiter`, `line_ending`, `header`, `quote_style`, `columns` |
| `log` | `entry_format`, `timestamps`, `rate`, `methods`, `status_mix`, `level_mix`, `ip_version`, `line_ending` |
| `json`, `xml`, `html`, `md`, `txt`, `svg` | none |
| `txt`, `md` | `encoding`, `bom` |
| `json`, `xml`, `html`, `svg` | none |

```
tfg generate --format jpg --size 500kb --set width=1920 --set height=1080 --set quality=85
Expand Down
49 changes: 38 additions & 11 deletions internal/format/md/md.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (

"github.com/donislawdev/TestingFilesGenerator/internal/core"
"github.com/donislawdev/TestingFilesGenerator/internal/format"
"github.com/donislawdev/TestingFilesGenerator/internal/format/textenc"
)

// The padding channel is the content, measured 2026-08-01 like the rest of the
Expand Down Expand Up @@ -55,12 +56,15 @@ func init() {
Where: format.PlacementEnd,
Capacity: 0,
},
Label: format.LabelVisible,
Label: format.LabelVisible,
// No reference tool, for the reason TXT gives: a reader is handed a
// path and would have to guess the encoding. The structural check is
// the layer that can be told.
Oracle: format.OracleNone,
// Heading depth, table size and which elements appear come later.
// Declaring none now is what makes a recipe asking for them fail
// loudly rather than quietly producing something else.
Properties: nil,
// Declaring only what is here is what makes a recipe asking for them
// fail loudly rather than quietly producing something else.
Properties: textenc.Properties(),
GeneratorVersion: generatorVersion,
Generator: generator{},
})
Expand All @@ -71,6 +75,11 @@ type generator struct{}
type memo struct {
labelLine string // includes the trailing blank line, empty when absent
seed uint64
codec textenc.Codec
// source is the ASCII content this file holds, counted in characters
// rather than in file bytes - see the same field on TXT. It is what lets
// the block loop below stay the loop it was.
source int64
}

func (generator) Plan(r format.Request) (format.Plan, error) {
Expand All @@ -84,30 +93,41 @@ func (generator) Plan(r format.Request) (format.Plan, error) {
}
}

codec, err := textenc.Parse("md", r.Properties)
if err != nil {
return format.Plan{}, err
}
if err := codec.Check("MD", r.Bytes); err != nil {
return format.Plan{}, err
}

p := format.Plan{
Bytes: r.Bytes,
Exact: true,
Determinism: format.DeterminismByte,
Properties: map[string]any{
"encoding": "utf-8",
"line_ending": "lf",
"flavour": "commonmark",
textenc.Setting: codec.Name(),
textenc.SettingBOM: codec.HasBOM(),
"line_ending": "lf",
"flavour": "commonmark",
},
}

m := memo{seed: r.Seed}
m := memo{seed: r.Seed, codec: codec, source: codec.Source(r.Bytes)}
if r.Label {
// A plain line followed by a blank one is a paragraph, which renders
// visibly and cannot break the document wherever it sits.
line := core.Label("md", r.Bytes, r.Seed) + "\n\n"
if int64(len(line)) <= r.Bytes {
if int64(len(line)) <= m.source {
m.labelLine = line
} else {
// What the label COSTS here, not how long it reads - the two
// differ by a factor of two in UTF-16.
p.Notes = append(p.Notes, format.Note{
Code: "label_omitted",
Detail: fmt.Sprintf(
"The label needs %d B and the file is %d B, so this file carries no label. Its name and the manifest still identify it.",
len(line), r.Bytes),
codec.Cost(int64(len(line))), r.Bytes),
})
}
}
Expand All @@ -123,7 +143,14 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error {
return fmt.Errorf("md: the plan was not produced by this generator")
}

remaining := p.Bytes
// The mark is bytes rather than text, so it goes out as itself, and
// everything after it goes through the encoder.
if err := writeAll(w, m.codec.Preamble()); err != nil {
return err
}
w = m.codec.Writer(w)

remaining := m.source
if m.labelLine != "" {
if err := writeAll(w, []byte(m.labelLine)); err != nil {
return err
Expand Down
Loading
Loading