From 91039e2788659fc49bd93153dd42b62387f1abda Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 29 Sep 2026 23:19:02 +0200 Subject: [PATCH 1/2] window: the settings of every format and the presets in Polish The registries stay English, because tfg formats --json and tfg preset show --json go to other people's scripts. The window now looks their sentences up under keys built from identifiers and falls back to the registry's English: labels, what a setting takes and does, the heading of a block, the limit on two settings, a preset's question, what it finds and its notes, and the decimal mark of a size. The words live in locale/registry/.json. Each entry records a hash of the English it translates, so a sentence changed in a registry turns a guard red instead of leaving an old translation on screen. The English copy is written out of the registries for a translator and is never loaded. Choice values, format names and the reasons a run is refused stay English. The English window is unchanged: every stored screen matches without being redrawn, and the window's wording of what a setting takes is held to the command line's for every setting declared. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +- internal/core/humanise.go | 10 +- internal/format/format.go | 8 +- internal/guard/catalogue_test.go | 14 + internal/guard/embeddedassets_test.go | 4 + internal/guard/registrywords_test.go | 474 +++++++++ internal/guard/windowlanguage_test.go | 19 +- internal/gui/catalogue/fields.go | 5 +- internal/gui/parts/property.go | 83 +- internal/gui/text/catalogue.go | 50 +- internal/gui/text/locale/en.json | 44 + internal/gui/text/locale/pl.json | 11 + internal/gui/text/locale/registry/en.json | 1082 +++++++++++++++++++++ internal/gui/text/locale/registry/pl.json | 218 +++++ internal/gui/text/registry.go | 129 +++ internal/gui/text/registrywords.go | 99 ++ internal/gui/text/screens.go | 57 +- internal/gui/window/generate.go | 2 +- internal/gui/window/preset.go | 23 +- internal/gui/window/recipe.go | 2 +- internal/gui/window/recipebase.go | 6 +- internal/gui/window/recipefolds.go | 5 +- internal/gui/window/runline.go | 3 +- internal/gui/window/runreport.go | 4 +- internal/gui/window/runsummary.go | 4 +- internal/preset/expansion.go | 28 +- 26 files changed, 2341 insertions(+), 50 deletions(-) create mode 100644 internal/guard/registrywords_test.go create mode 100644 internal/gui/text/locale/registry/en.json create mode 100644 internal/gui/text/locale/registry/pl.json create mode 100644 internal/gui/text/registry.go create mode 100644 internal/gui/text/registrywords.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f2e199a..1dcf6197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,8 +21,11 @@ because it turns other people's test suites red. otherwise - so on a system set to Polish it opens in Polish. Preferences lets you choose another language. The choice takes effect the next time the window opens, and Restart now does that at once. It is off while files - are being made, and anything typed on the other tabs is cleared. The - command line, recipes and manifests do not change language. The same tab + are being made, and anything typed on the other tabs is cleared. In Polish + the settings of every format, the presets and the sizes of a run are Polish + as well, so a size reads 10,0 MB. The values you pick from a list + (portrait, a4), the names of the formats and the reason a run is refused + stay in English, and so do the command line, recipes and manifests. The same tab says what the window keeps between runs (the output directory, the window size and the language), shows the folder they are kept in, and Forget clears them. `tfg-gui --pseudo-language` opens the window with every diff --git a/internal/core/humanise.go b/internal/core/humanise.go index 3c7af240..1f954a20 100644 --- a/internal/core/humanise.go +++ b/internal/core/humanise.go @@ -19,7 +19,13 @@ import ( // HumanBytes counts in 1024s, the same as every size this tool accepts and the // same as what Explorer and ls show. See docs/RECIPE.md section 9. -func HumanBytes(n int64) string { +func HumanBytes(n int64) string { return HumanBytesIn(n, ".") } + +// HumanBytesIn is HumanBytes with the mark a language writes between the whole +// part and the tenths - 10.0 MB in English, 10,0 MB in Polish. The command line +// is English and never asks for another (D9). The window asks its catalogue, +// so one piece of arithmetic serves both and only the mark differs. +func HumanBytesIn(n int64, decimalMark string) string { const unit = 1024 if n < unit { // Through ExactBytes rather than its own %d, so the two never spell one @@ -32,7 +38,7 @@ func HumanBytes(n int64) string { div *= unit exp++ } - return fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]) + return strings.Replace(fmt.Sprintf("%.1f %cB", float64(n)/float64(div), "KMGT"[exp]), ".", decimalMark, 1) } // ExactBytes writes a count out in full, grouped in threes, with its unit. diff --git a/internal/format/format.go b/internal/format/format.go index d906d636..42edcd95 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -289,9 +289,15 @@ func (j JointLimit) readably(got int64) (asked, allowed string) { // Describe is the rule as one sentence, for the format list and for a window. func (j JointLimit) Describe() string { return fmt.Sprintf("%s times %s cannot pass %d %s, because %s", - j.Of, j.By, j.Max/j.per(), j.Unit, j.Why) + j.Of, j.By, j.Most(), j.Unit, j.Why) } +// Most is the ceiling counted in Unit - 40 for forty million pixels in +// megapixels - which is the number a sentence about this limit says. Here +// rather than worked out by whoever words the sentence, so the window saying it +// in another language divides the same way. +func (j JointLimit) Most() int64 { return j.Max / j.per() } + func (j JointLimit) per() int64 { if j.Per == 0 { return 1 diff --git a/internal/guard/catalogue_test.go b/internal/guard/catalogue_test.go index 732ece2c..01db378b 100644 --- a/internal/guard/catalogue_test.go +++ b/internal/guard/catalogue_test.go @@ -11,6 +11,8 @@ import ( "strconv" "strings" "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) const localeDir = "../gui/text/locale" @@ -180,11 +182,23 @@ func TestTheEnglishCatalogueSaysWhatTheCodeSays(t *testing.T) { // where PRODUCT.md declares its scope. This is that guard, and it keeps only the // half that means anything here: the dashes and the semicolons. Refusing // characters outside ASCII in a translation would be refusing the point of one. +// +// The words of the registries in every language are read as well, since the +// day they arrived in a folder of their own - the same gap as above, one folder +// down (docs/JEZYKI-REJESTRY-2026-09-29.md). func TestEveryTranslationObeysThePunctuationRule(t *testing.T) { files, err := filepath.Glob(filepath.Join(localeDir, "*.json")) if err != nil { t.Fatal(err) } + registry, err := filepath.Glob(filepath.Join(localeDir, text.RegistryFolder, "*.json")) + if err != nil { + t.Fatal(err) + } + if len(registry) == 0 { + t.Fatal("no file of registry words was found, so half of this guard asserts about nothing") + } + files = append(files, registry...) if len(files) == 0 { t.Fatal("no translation file was found, so this guard is asserting about nothing") } diff --git a/internal/guard/embeddedassets_test.go b/internal/guard/embeddedassets_test.go index fc2a8b6d..9a156e4a 100644 --- a/internal/guard/embeddedassets_test.go +++ b/internal/guard/embeddedassets_test.go @@ -142,6 +142,10 @@ var ownWork = map[string]bool{ // The same words in Polish, translated for this project on 2026-09-29 - // the first language the Preferences screen offers beside English. "github.com/donislawdev/TestingFilesGenerator/internal/gui/text locale/pl.json": true, + // The registries' own sentences, written out of them for a translator, and + // the same sentences in Polish, translated for this project on 2026-09-29. + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text locale/registry/en.json": true, + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text locale/registry/pl.json": true, } // accountFor requires exactly one registry entry to claim a file. None means diff --git a/internal/guard/registrywords_test.go b/internal/guard/registrywords_test.go new file mode 100644 index 00000000..e1e3f1a1 --- /dev/null +++ b/internal/guard/registrywords_test.go @@ -0,0 +1,474 @@ +package guard + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" + "testing/fstest" + + "fyne.io/fyne/v2" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/parts" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" + "github.com/donislawdev/TestingFilesGenerator/internal/gui/window" + "github.com/donislawdev/TestingFilesGenerator/internal/preset" +) + +// The words of the registries in the window's language - +// docs/JEZYKI-REJESTRY-2026-09-29.md, and text/registry.go for the mechanism. +// The registries stay English and the window looks their sentences up, so +// everything these guards hold is what a lookup can get wrong without a word: +// a sentence nobody translated, a translation of a sentence the registry no +// longer says, and two ways of describing one setting. + +// registryDir is where every language keeps the words of the registries. +var registryDir = filepath.Join(localeDir, text.RegistryFolder) + +// writeRegistryCatalogue writes registry/en.json out of the registries, the +// way TFG_WRITE_SCREEN_REFERENCE writes the stored screens. +const writeRegistryCatalogue = "TFG_WRITE_REGISTRY_CATALOGUE" + +// englishHash is what an entry in another language records of the English it +// translated, in the field go-i18n reserves for it and never shows. +func englishHash(english string) string { + sum := sha256.Sum256([]byte(english)) + return "sha256-" + hex.EncodeToString(sum[:6]) +} + +// registryWords is text.RegistryWords by key, held to being a list worth +// asserting about. +func registryWords(t *testing.T) map[string]text.RegistryWord { + t.Helper() + words := text.RegistryWords() + // Every format, preset and damage together say far more than this. Fewer + // means the registries were not filled in this process, and every guard + // below would then be comparing nothing with nothing. + if len(words) < 150 { + t.Fatalf("the registries say %d sentence(s) the window shows, so they were not registered here", len(words)) + } + out := make(map[string]text.RegistryWord, len(words)) + for _, w := range words { + out[w.Key] = w + } + return out +} + +// registryReflection is registry/en.json as the registries write it: every +// sentence, the hash another language records of it, and where it stands. +func registryReflection(t *testing.T) []byte { + t.Helper() + entries := map[string]map[string]string{} + for key, w := range registryWords(t) { + entries[key] = map[string]string{"description": w.Where, "hash": englishHash(w.English), "other": w.English} + } + var b bytes.Buffer + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(entries); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +// TestTheRegistryCatalogueSaysWhatTheRegistriesSay keeps the English copy a +// translator works from equal to the registries, the way en.json is kept equal +// to the code. It is also where the hashes every other language is held to come +// from, so a stale copy would hold them to last month's English. +func TestTheRegistryCatalogueSaysWhatTheRegistriesSay(t *testing.T) { + want := registryReflection(t) + path := filepath.Join(registryDir, "en.json") + if os.Getenv(writeRegistryCatalogue) == "1" { + if err := os.MkdirAll(registryDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, want, 0o644); err != nil { + t.Fatal(err) + } + t.Logf("wrote %s, %d B", path, len(want)) + return + } + have, err := os.ReadFile(path) + if err != nil { + t.Fatalf("there is no %s: %v\nRun: %s=1 go test ./internal/guard/ -run ^TestTheRegistryCatalogueSaysWhatTheRegistriesSay$", + path, err, writeRegistryCatalogue) + } + if !bytes.Equal(bytes.ReplaceAll(have, []byte("\r\n"), []byte("\n")), want) { + t.Errorf("%s is not what the registries say - a sentence, a setting or a preset changed.\n"+ + "Run: %s=1 go test ./internal/guard/ -run ^TestTheRegistryCatalogueSaysWhatTheRegistriesSay$\n"+ + "and then bring every other language in %s up to it.", path, writeRegistryCatalogue, registryDir) + } +} + +// registryFiles is every language's file of registry words, by tag. +func registryFiles(t *testing.T) map[string]map[string]map[string]string { + t.Helper() + return catalogueFilesIn(t, registryDir) +} + +// TestEveryLanguageSaysEverythingTheRegistriesSay is the registries' half of +// TestEveryLanguageSaysEverythingTheWindowSays, with the one thing that half +// does not need: a translation that has gone stale. +// +// A key made of identifiers cannot tell that the English under it changed. A +// Polish sentence describing a default the registry no longer has is worse +// than an English one, because it looks translated - so every entry records the +// hash of the English it translated, and a different hash is a failure that +// shows the English as it is now. +func TestEveryLanguageSaysEverythingTheRegistriesSay(t *testing.T) { + words := registryWords(t) + files := registryFiles(t) + for tag := range catalogueFiles(t) { + if _, has := files[tag]; !has { + t.Errorf("the window speaks %s and %s has no %s.json, so its settings and presets are English there", + tag, registryDir, tag) + } + } + if len(files) < 2 { + t.Fatal("no language but English has registry words, so this guard asserts about nothing") + } + for tag, entries := range files { + if tag == text.English { + continue + } + languageSaysTheRegistries(t, tag, entries, words) + } +} + +// languageSaysTheRegistries holds one language to every registry sentence. +func languageSaysTheRegistries(t *testing.T, tag string, entries map[string]map[string]string, words map[string]text.RegistryWord) { + t.Helper() + keys := make([]string, 0, len(words)) + for key := range words { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + w := words[key] + entry, has := entries[key] + switch { + case !has || entry["other"] == "": + t.Errorf("%s has no %s, so the window says it in English: %q", tag, key, w.English) + case entry["hash"] != englishHash(w.English): + t.Errorf("%s translates %s from an English the registry no longer says. It says now:\n %q\n"+ + "Translate it again and record hash %s.", tag, key, w.English, englishHash(w.English)) + case strings.Contains(entry["other"], "{{"): + t.Errorf("%s puts a value into %s, which is handed none: %q", tag, key, entry["other"]) + } + } + for key := range entries { + if _, known := words[key]; !known { + t.Errorf("%s carries %s, which no registry says any more", tag, key) + } + } +} + +// TestOneEnglishSentenceIsOneSentenceInEveryLanguage holds the translations of +// one English sentence to agreeing. A width is declared by four formats with +// one sentence, the format of a set by every preset that reads it, and a window +// describing the same setting in two ways from one screen to the next is the +// kind of difference nobody reports and everybody notices. +func TestOneEnglishSentenceIsOneSentenceInEveryLanguage(t *testing.T) { + words := registryWords(t) + for tag, entries := range registryFiles(t) { + if tag == text.English { + continue + } + said := map[string]string{} + first := map[string]string{} + keys := make([]string, 0, len(words)) + for key := range words { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + english, translated := words[key].English, entries[key]["other"] + if was, seen := said[english]; seen && was != translated { + t.Errorf("%s says %q two ways: %q under %s and %q under %s", + tag, english, was, first[english], translated, key) + } + if _, seen := said[english]; !seen { + said[english], first[english] = translated, key + } + } + } +} + +// TestTheWindowDescribesASettingAsTheCommandLineDoes holds the window's own +// wording of what a setting takes to the one tfg formats prints. +// +// The window cannot print Property.Allowed: it is one English sentence. So it +// puts the same sentence together out of its catalogue - and a second way of +// saying one thing is the start of two surfaces describing one setting +// differently (D1). In English the two have to agree to the letter, for every +// setting, every limit on two settings, and the sizes of a run. +func TestTheWindowDescribesASettingAsTheCommandLineDoes(t *testing.T) { + if got := text.AllowedWholeNumber(); got != "whole number" { + t.Fatalf("this process speaks another language (%q), so nothing here is English to compare", got) + } + checked := 0 + each := func(owner text.Owner, declared []format.Property) { + for _, p := range declared { + if got, want := parts.Allowed(p), p.Allowed(); got != want { + t.Errorf("%s %s: the window says %q and tfg formats says %q", owner, p.Name, got, want) + } + checked++ + } + } + for _, d := range format.All() { + each(text.FormatOwner(d.ID), d.Properties) + for _, j := range d.JointLimits { + if got, want := parts.JointLimit(text.FormatOwner(d.ID), j), j.Describe(); got != want { + t.Errorf("%s: the window says %q and tfg formats says %q", d.ID, got, want) + } + } + } + for _, p := range preset.All() { + each(text.PresetOwner(p.ID), append(append([]format.Property{}, p.Parameters...), p.Globals()...)) + } + for _, d := range damage.All() { + each(text.DamageOwner(d.ID), d.Parameters) + } + for _, n := range []int64{0, 1023, 1024, 1536, 10 << 20, 2516582400, 1 << 40} { + if got, want := text.HumanBytes(n), core.HumanBytes(n); got != want { + t.Errorf("%d bytes: the window says %q and the command line %q", n, got, want) + } + } + if checked < 80 { + t.Fatalf("only %d setting(s) were compared, so the registries were not filled here", checked) + } + t.Logf("%d settings described the same way on both surfaces", checked) +} + +// TestThePresetsSayInTheWindowWhatTheySayOnTheSite holds the two Polish copies +// of a preset's question and of what it finds to one wording. The site had +// them first (web/content//site.json), and the window keeps its own copy +// rather than reading the site's, so the site stays a renderer of its files and +// the window a program of its own - which is what makes this guard necessary. +func TestThePresetsSayInTheWindowWhatTheySayOnTheSite(t *testing.T) { + compared := 0 + for tag, entries := range registryFiles(t) { + if tag == text.English { + continue + } + raw, err := os.ReadFile(filepath.Join(repoRoot(t), "web", "content", tag, "site.json")) + if err != nil { + continue + } + var site struct { + Presets map[string]struct { + Question string `json:"question"` + Catches []string `json:"catches"` + } `json:"presets"` + } + if err := json.Unmarshal(raw, &site); err != nil { + t.Fatalf("the %s site file is not readable: %v", tag, err) + } + for _, p := range preset.All() { + onSite := site.Presets[p.ID] + said := []string{entries[text.QuestionKey(p.ID)]["other"]} + want := append([]string{onSite.Question}, onSite.Catches...) + for i := range p.Catches { + said = append(said, entries[text.CatchKey(p.ID, i+1)]["other"]) + } + if strings.Join(said, "\n") != strings.Join(want, "\n") { + t.Errorf("%s %s: the window says\n %s\nand the site says\n %s", + tag, p.ID, strings.Join(said, "\n "), strings.Join(want, "\n ")) + } + compared++ + } + } + if compared == 0 { + t.Fatal("no language has both a site and registry words, so this guard compared nothing") + } +} + +// registryPseudoChild puts the next guard in a process of its own, for the +// reason translation_test.go gives. +const registryPseudoChild = "TFG_REGISTRY_PSEUDO_CHILD" + +// TestNoSentenceOfTheRegistriesReachesTheWindowUndisguised is what proves the +// window asks for the registries' sentences rather than drawing them. +// +// Every other guard here holds the files. None of them would notice a screen +// that put a format's Detail on the screen directly - the file would be +// complete and the Polish window would show English. So this opens the window +// in the pseudo language, which disguises every sentence that is looked up, +// chooses every format and every preset in turn, and fails on any registry +// sentence that arrives as it is written. +func TestNoSentenceOfTheRegistriesReachesTheWindowUndisguised(t *testing.T) { + if os.Getenv(registryPseudoChild) == "1" { + registryInPseudo(t) + return + } + run := exec.Command(os.Args[0], + "-test.run=^TestNoSentenceOfTheRegistriesReachesTheWindowUndisguised$", "-test.v") + run.Env = append(os.Environ(), registryPseudoChild+"=1") + out, err := run.CombinedOutput() + if err != nil { + t.Fatalf("a sentence of the registries reached the window undisguised:\n%s", out) + } + if !strings.Contains(string(out), "PASS") { + t.Fatalf("the child said nothing about passing, which means it never ran:\n%s", out) + } +} + +func registryInPseudo(t *testing.T) { + t.Helper() + // Sentences rather than every word: a label of one short word may be + // made of letters the pseudo language leaves alone. + var sentences []string + for _, w := range text.RegistryWords() { + if strings.Count(w.English, " ") >= 3 { + sentences = append(sentences, w.English) + } + } + if err := text.LoadBuiltIn(text.Pseudo); err != nil { + t.Fatalf("the pseudo language would not load: %v", err) + } + host := newFakeHost(t) + window.Open(host) + undisguised := func(where, said string) { + for _, s := range sentences { + if strings.Contains(said, s) { + t.Errorf("%s shows %q as the registry wrote it", where, s) + } + } + } + + generate := selectTab(t, host.content, text.TabOneTarget()) + for _, id := range format.IDs() { + chooseFormat(t, generate, id) + undisguised("the single batch screen with "+id, saidOn(generate)) + } + // Proof the walk reached the sentences at all: a setting's sentence waits + // behind the button beside its name, and the disguised one is there. + pdf, _ := format.Get("pdf") + chooseFormat(t, generate, "pdf") + want := text.SettingDetail(text.FormatOwner("pdf"), pdf.Properties[0].Name, pdf.Properties[0].Detail) + if !strings.Contains(saidOn(generate), want) { + t.Fatalf("the single batch screen with pdf does not say %q, so this walk reads nothing it should", want) + } + + presets := selectTab(t, host.content, text.TabPresets()) + for _, id := range preset.IDs() { + choose(t, presets, text.FieldPreset(), id) + undisguised("the presets screen with "+id, saidOn(presets)) + } +} + +// saidOn is everything a screen says: the words drawn on it and what waits +// behind every button that explains a field, which is where a setting's +// sentence goes (parts.alsoSaying). +func saidOn(o fyne.CanvasObject) string { + var b strings.Builder + b.WriteString(textIn(o)) + walk(o, func(obj fyne.CanvasObject) { + if d, is := obj.(*parts.DetailButton); is { + b.WriteString(d.Explanation()) + b.WriteString("\n") + } + }) + return b.String() +} + +// registryPolishChild and registryCopyChild put the next two guards in +// processes of their own - each loads a catalogue, which changes the words for +// everything that runs after it. +const ( + registryPolishChild = "TFG_REGISTRY_POLISH_CHILD" + registryCopyChild = "TFG_REGISTRY_COPY_CHILD" +) + +// runChild runs one guard again in a process of its own and fails with what it +// said, the way the translation guards do. +func runChild(t *testing.T, name, env string) { + t.Helper() + run := exec.Command(os.Args[0], "-test.run=^"+name+"$", "-test.v") + run.Env = append(os.Environ(), env+"=1") + out, err := run.CombinedOutput() + if err != nil { + t.Fatalf("%s failed in its own process:\n%s", name, out) + } + if !strings.Contains(string(out), "PASS") { + t.Fatalf("the child said nothing about passing, which means it never ran:\n%s", out) + } +} + +// TestThePolishWindowSaysTheRegistriesInPolish is the whole path through the +// files this build carries: the registry folder compiled in with the rest of +// the catalogue, read into the language chosen, and asked where the window +// asks. Every other guard here reads the files from the disk, which would pass +// on a build that embedded none of them. +func TestThePolishWindowSaysTheRegistriesInPolish(t *testing.T) { + if os.Getenv(registryPolishChild) != "1" { + runChild(t, "TestThePolishWindowSaysTheRegistriesInPolish", registryPolishChild) + return + } + polish := registryFiles(t)["pl"] + if err := text.LoadBuiltIn("pl"); err != nil || text.Speaking() != "pl" { + t.Fatalf("the window would not speak Polish (%v, speaking %s)", err, text.Speaking()) + } + for _, d := range format.All() { + for _, p := range d.Properties { + key := text.DetailKey(text.FormatOwner(d.ID), p.Name) + if want := polish[key]["other"]; want != "" && text.SettingDetail(text.FormatOwner(d.ID), p.Name, p.Detail) != want { + t.Errorf("%s: the Polish window says %q", key, text.SettingDetail(text.FormatOwner(d.ID), p.Name, p.Detail)) + } + } + } + if got := text.HumanBytes(10 << 20); got != "10,0 MB" { + t.Errorf("ten megabytes read %q in the Polish window", got) + } + // What a run says about a limit nobody gave comes back with the name of + // the value it is about, which is the half of its key the preset knows. + expanded, err := preset.Expand("size-boundaries", nil) + if err != nil { + t.Fatal(err) + } + about := 0 + for _, n := range expanded.Spoken() { + if n.About == "" { + continue + } + about++ + if got, want := text.PresetNote(expanded.Preset.ID, n.About, n.Said), polish[text.NoteKey(expanded.Preset.ID, n.About)]["other"]; got != want { + t.Errorf("the note about %s reads %q in the Polish window, and %q was translated", n.About, got, want) + } + } + if about == 0 { + t.Error("size-boundaries run with no limit said nothing about the limit it made up, so no note could be found in Polish") + } +} + +// TestTheEnglishCopyOfTheRegistriesNeverAnswersForThem holds the English on +// screen to the registries themselves. registry/en.json is written for a +// translator, and a copy one commit behind the registries must not be able to +// put last month's sentence in an English window. +func TestTheEnglishCopyOfTheRegistriesNeverAnswersForThem(t *testing.T) { + if os.Getenv(registryCopyChild) != "1" { + runChild(t, "TestTheEnglishCopyOfTheRegistriesNeverAnswersForThem", registryCopyChild) + return + } + made := fstest.MapFS{ + "locale/en.json": &fstest.MapFile{Data: []byte(`{"LanguageName": {"other": "English"}}`)}, + "locale/" + text.RegistryFolder + "/en.json": &fstest.MapFile{ + Data: []byte(`{"Detail.format/pdf.pages": {"other": "a sentence the registry no longer says"}}`)}, + } + if err := text.Load(made, "locale"); err != nil { + t.Fatalf("the catalogue would not load: %v", err) + } + if got := text.SettingDetail(text.FormatOwner("pdf"), "pages", "today's sentence"); got != "today's sentence" { + t.Errorf("the English window said %q where the registry says today's sentence", got) + } +} diff --git a/internal/guard/windowlanguage_test.go b/internal/guard/windowlanguage_test.go index 9f77519f..35af6e6f 100644 --- a/internal/guard/windowlanguage_test.go +++ b/internal/guard/windowlanguage_test.go @@ -59,9 +59,16 @@ func TestTheWindowSpeaksTheLanguageItWasToldTo(t *testing.T) { // catalogueFiles is every catalogue compiled into the window, by tag. func catalogueFiles(t *testing.T) map[string]map[string]map[string]string { t.Helper() - paths, err := filepath.Glob(filepath.Join(localeDir, "*.json")) + return catalogueFilesIn(t, localeDir) +} + +// catalogueFilesIn is every catalogue file of one folder, by tag - the +// window's own, or the words of the registries beside them. +func catalogueFilesIn(t *testing.T, dir string) map[string]map[string]map[string]string { + t.Helper() + paths, err := filepath.Glob(filepath.Join(dir, "*.json")) if err != nil || len(paths) == 0 { - t.Fatalf("no catalogue in %s: %v", localeDir, err) + t.Fatalf("no catalogue in %s: %v", dir, err) } out := map[string]map[string]map[string]string{} for _, path := range paths { @@ -179,7 +186,13 @@ func TestEveryLetterEveryLanguageSaysIsInTheTypeface(t *testing.T) { "Inter-Regular": charactersIn(t, "Inter-Regular", font.Regular), "Inter-Bold": charactersIn(t, "Inter-Bold", font.Bold), } - for tag, entries := range catalogueFiles(t) { + languages := catalogueFiles(t) + // The words of the registries, under the same tag with a mark saying where + // they came from - they reach the same screens in the same face. + for tag, entries := range registryFiles(t) { + languages[tag+" "+text.RegistryFolder] = entries + } + for tag, entries := range languages { missing := map[string][]string{} for id, entry := range entries { for form, sentence := range entry { diff --git a/internal/gui/catalogue/fields.go b/internal/gui/catalogue/fields.go index c4c17bf4..577a0d82 100644 --- a/internal/gui/catalogue/fields.go +++ b/internal/gui/catalogue/fields.go @@ -116,7 +116,10 @@ func propertyField() Entry { states = append(states, State{d.caption, func() fyne.CanvasObject { s := form() tips := parts.NewTips() - _, objects := parts.DeclaredFields([]format.Property{d.p}, s, tips) + // Made up for the catalogue, so no language has words for them + // and each says its English. The owner is a plain name rather + // than text.FormatOwner, which this package has no edge to. + _, objects := parts.DeclaredFields("format/catalogue", []format.Property{d.p}, s, tips) return tips.Over(parts.FieldColumn(objects...)) }}) } diff --git a/internal/gui/parts/property.go b/internal/gui/parts/property.go index aee98e78..1a26b35e 100644 --- a/internal/gui/parts/property.go +++ b/internal/gui/parts/property.go @@ -185,9 +185,10 @@ func leftAlone(p format.Property) string { // PropertyFields draws every field one format declares, in the order it // declared them, each with the sentence saying what it takes. // -// The sentence comes from Property.Allowed, which is the one "tfg formats" -// prints. Two surfaces describing one format in two ways is D1 breaking in the -// place nobody thinks to compare, so there is one sentence and both read it. +// The sentence is Property.Allowed, which is the one "tfg formats" prints, put +// together in the window's language by Allowed. Two surfaces describing one +// format in two ways is D1 breaking in the place nobody thinks to compare, so a +// guard holds the English of the two to one sentence. // It registers each one with the screen, so a setting a format declares can be // told it was the one refused. Until 2026-08-12 these were the only fields on // either screen that could not: they were built with the plain Field function, @@ -195,7 +196,8 @@ func leftAlone(p format.Property) string { // appeared at the foot of the form with nothing marked. Thirteen formats // declare fourteen of these between them. func PropertyFields(d format.Descriptor, into *Fields, tips *Tips) ([]PropertyField, []fyne.CanvasObject) { - fields, objects := DeclaredFields(d.Properties, into, tips) + owner := text.FormatOwner(d.ID) + fields, objects := DeclaredFields(owner, d.Properties, into, tips) // A rule binding two settings belongs beside them and nowhere else. Drawn // from Min and Max alone, two number boxes would offer twenty thousand by @@ -206,11 +208,17 @@ func PropertyFields(d format.Descriptor, into *Fields, tips *Tips) ([]PropertyFi // preset declares parameters and no joint limits, so asking it for some // would be a field nothing fills. for _, j := range d.JointLimits { - objects = append(objects, Note(j.Describe())) + objects = append(objects, Note(JointLimit(owner, j))) } return fields, objects } +// JointLimit is JointLimit.Describe in the window's language. A guard asks the +// English of the two to agree to the letter, for every limit declared. +func JointLimit(owner text.Owner, j format.JointLimit) string { + return text.AllowedJointly(j.Of, j.By, j.Most(), text.Unit(j.Unit), text.JointReason(owner, j.Of, j.By, j.Why)) +} + // DeclaredFields draws every field a list of declared settings describes, in // the order it was declared, each with the sentence saying what it takes. // @@ -227,7 +235,10 @@ func PropertyFields(d format.Descriptor, into *Fields, tips *Tips) ([]PropertyFi // and that was a way of using the width a name standing OVER its box left // empty beside it - with the name beside the box there is no such width, a // row is as tall as one box, and a form reads down one column of names. -func DeclaredFields(declared []format.Property, into *Fields, tips *Tips) ([]PropertyField, []fyne.CanvasObject) { +// +// The owner is whose declaration the list is, which is half of the key every +// sentence of it is found under in another language - see text.Owner. +func DeclaredFields(owner text.Owner, declared []format.Property, into *Fields, tips *Tips) ([]PropertyField, []fyne.CanvasObject) { fields := make([]PropertyField, 0, len(declared)) objects := make([]fyne.CanvasObject, 0, len(declared)) @@ -236,7 +247,7 @@ func DeclaredFields(declared []format.Property, into *Fields, tips *Tips) ([]Pro // inside a section - the same heading as the files inside an archive. // tfg formats prints the same name in the same place. if p.Group != "" && (i == 0 || declared[i-1].Group != p.Group) { - objects = append(objects, Subheading(p.Group)) + objects = append(objects, Subheading(text.SettingGroup(owner, p.Group))) } f := FromProperty(p) fields = append(fields, f) @@ -255,7 +266,7 @@ func DeclaredFields(declared []format.Property, into *Fields, tips *Tips) ([]Pro // The button is what makes that safe rather than a loss: this is a tool // whose window and whose recipe file are two ways into one engine, so // somebody who finds a setting here has to be able to write it down. - objects = append(objects, into.Add(p.Name, text.SettingLabel(p.Name), PropertyDetail(p), + objects = append(objects, into.Add(p.Name, text.SettingLabel(p.Name), PropertyDetail(owner, p), tips.Say(text.SettingKey(p.Name)), ShapedFor(p, f.Control))) } return fields, objects @@ -321,13 +332,15 @@ func narrowOnAScreen(p format.Property) bool { // Exported because two screens draw these fields now, and the sentence has to be // composed one way. A screen assembling it itself would be D1 breaking in the // place nobody compares: two surfaces describing one format in two wordings. -func PropertyDetail(p format.Property) string { +// +// The owner is whose declaration p is - see DeclaredFields. +func PropertyDetail(owner text.Owner, p format.Property) string { detail := allowedOnAScreen(p) - if p.Detail != "" { + if said := text.SettingDetail(owner, p.Name, p.Detail); said != "" { if detail != "" { detail += ". " } - detail += p.Detail + detail += said } return detail } @@ -351,5 +364,51 @@ func allowedOnAScreen(p format.Property) string { if p.Kind == format.PropertyChoice { return "" } - return p.Allowed() + return Allowed(p) +} + +// Allowed is Property.Allowed in the window's language, put together from the +// same parts in the same order. A guard asks the English of the two to agree to +// the letter for every setting declared, so this cannot become a second opinion +// about what a setting takes (D1). +func Allowed(p format.Property) string { + what := allowedKind(p) + switch { + case p.Default != "" && what == "": + return text.AllowedDefault(p.Default) + case p.Default != "": + return text.AllowedWithDefault(what, p.Default) + } + return what +} + +// allowedKind is the part of Allowed that the kind of the setting decides. +func allowedKind(p format.Property) string { + switch p.Kind { + case format.PropertyInt: + return allowedNumber(p) + case format.PropertyChoice: + return text.AllowedOneOf(p.Choices) + case format.PropertyBool: + return text.AllowedTrueOrFalse() + case format.PropertySize: + return text.AllowedSize() + } + // A text setting describes itself with its shape or not at all. + return text.Shape(p.Shape) +} + +// allowedNumber is a whole number, with its range and what it counts when the +// declaration says. +func allowedNumber(p format.Property) string { + ranged := p.Min != 0 || p.Max != 0 + switch { + case p.Unit == "" && ranged: + return text.AllowedRange(p.Min, p.Max) + case p.Unit == "": + return text.AllowedWholeNumber() + case ranged: + return text.AllowedRangeOf(text.Unit(p.Unit), p.Min, p.Max) + } + return text.AllowedWholeNumberOf(text.Unit(p.Unit)) } diff --git a/internal/gui/text/catalogue.go b/internal/gui/text/catalogue.go index 651ba8e2..23970942 100644 --- a/internal/gui/text/catalogue.go +++ b/internal/gui/text/catalogue.go @@ -3,6 +3,7 @@ package text import ( "embed" "encoding/json" + "errors" "io/fs" "path" "strings" @@ -173,7 +174,13 @@ func fill(layout string, data map[string]any) string { // The English is handed over on every call rather than kept in a file of its // own, so there is one place a sentence is written and no way for a catalogue // to disagree with the code about what it says in English. -func say(id, english string) string { +func say(id, english string) string { return localise(id, english) } + +// localise is the one question both kinds of text ask: the entry under this +// key in the language the window speaks, or the English handed in when there +// is none. say asks it for the window's own sentences and lookup for the +// registries' - see registry.go. +func localise(id, english string) string { if pseudo { return pseudoOf(english) } @@ -199,21 +206,25 @@ func say(id, english string) string { // Languages are tried in the order given and English is always last, so a // catalogue that carries half a language leaves English sentences rather than // empty ones. +// +// The words of the registries are read from the folder beside the window's own, +// into the same language - see registry.go for why they are a file of their +// own. A catalogue without that folder is a window that has not translated +// them yet, and says their English. func Load(fsys fs.FS, dir string, prefer ...string) error { bundle := i18n.NewBundle(language.English) bundle.RegisterUnmarshalFunc("json", json.Unmarshal) - entries, err := fs.ReadDir(fsys, dir) - if err != nil { + if err := loadFiles(bundle, fsys, dir, ""); err != nil { return err } - for _, entry := range entries { - if entry.IsDir() || path.Ext(entry.Name()) != ".json" { - continue - } - if _, err := bundle.LoadMessageFileFS(fsys, path.Join(dir, entry.Name())); err != nil { - return err - } + // Not the English copy of the registries' words: it is written for a + // translator, and the English on screen comes from the registries + // themselves - a copy one commit behind them must not be able to answer + // for them. + err := loadFiles(bundle, fsys, path.Join(dir, RegistryFolder), English+".json") + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return err } localiser = i18n.NewLocalizer(bundle, append(prefer, English)...) @@ -227,6 +238,25 @@ func Load(fsys fs.FS, dir string, prefer ...string) error { return nil } +// loadFiles puts every catalogue file of one folder into the bundle, but the +// one named in except. Folders inside it are left to the caller, so the +// registry folder is not read as a language of the window. +func loadFiles(bundle *i18n.Bundle, fsys fs.FS, dir, except string) error { + entries, err := fs.ReadDir(fsys, dir) + if err != nil { + return err + } + for _, entry := range entries { + if entry.IsDir() || path.Ext(entry.Name()) != ".json" || entry.Name() == except { + continue + } + if _, err := bundle.LoadMessageFileFS(fsys, path.Join(dir, entry.Name())); err != nil { + return err + } + } + return nil +} + // carries says whether a catalogue was loaded for a language, so that Speaking // names the language the window answers in rather than the one it was asked // for - a file missing from the build is the difference. diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index 7dac2269..d934fc56 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -3,6 +3,46 @@ "description": "Shown on the About screen.", "other": "Generate test files, and know how the system under test should react to them." }, + "AllowedDefault": { + "description": "The start of the sentence under a setting a format, a preset or a damage declares, saying what it takes. The values in it - true, 2mb, the numbers, the default, the names a recipe writes - stay as they are. Carries one value, {{.Default}}, which has to stay spelled exactly that way.", + "other": "default {{.Default}}" + }, + "AllowedJointly": { + "description": "A note under two settings of a format whose product has a ceiling. Of and By are the names a recipe writes and stay as they are. Carries these values, each of which has to stay spelled exactly that way: {{.Of}}, {{.By}}, {{.Max}}, {{.Unit}}, {{.Why}}.", + "other": "{{.Of}} times {{.By}} cannot pass {{.Max}} {{.Unit}}, because {{.Why}}" + }, + "AllowedOneOf": { + "description": "The start of the sentence under a setting a format, a preset or a damage declares, saying what it takes. The values in it - true, 2mb, the numbers, the default, the names a recipe writes - stay as they are. Carries one value, {{.Values}}, which has to stay spelled exactly that way.", + "other": "one of: {{.Values}}" + }, + "AllowedRange": { + "description": "The start of the sentence under a setting a format, a preset or a damage declares, saying what it takes. The values in it - true, 2mb, the numbers, the default, the names a recipe writes - stay as they are. Carries these values, each of which has to stay spelled exactly that way: {{.Min}}, {{.Max}}.", + "other": "whole number from {{.Min}} to {{.Max}}" + }, + "AllowedRangeOf": { + "description": "The start of the sentence under a setting a format, a preset or a damage declares, saying what it takes. The values in it - true, 2mb, the numbers, the default, the names a recipe writes - stay as they are. Carries these values, each of which has to stay spelled exactly that way: {{.Unit}}, {{.Min}}, {{.Max}}.", + "other": "whole number of {{.Unit}} from {{.Min}} to {{.Max}}" + }, + "AllowedSize": { + "description": "The start of the sentence under a setting a format, a preset or a damage declares, saying what it takes. The values in it - true, 2mb, the numbers, the default, the names a recipe writes - stay as they are.", + "other": "a size such as 2mb, or a plain byte count" + }, + "AllowedTrueOrFalse": { + "description": "The start of the sentence under a setting a format, a preset or a damage declares, saying what it takes. The values in it - true, 2mb, the numbers, the default, the names a recipe writes - stay as they are.", + "other": "true or false" + }, + "AllowedWholeNumber": { + "description": "The start of the sentence under a setting a format, a preset or a damage declares, saying what it takes. The values in it - true, 2mb, the numbers, the default, the names a recipe writes - stay as they are.", + "other": "whole number" + }, + "AllowedWholeNumberOf": { + "description": "The start of the sentence under a setting a format, a preset or a damage declares, saying what it takes. The values in it - true, 2mb, the numbers, the default, the names a recipe writes - stay as they are. Carries one value, {{.Unit}}, which has to stay spelled exactly that way.", + "other": "whole number of {{.Unit}}" + }, + "AllowedWithDefault": { + "description": "The start of the sentence under a setting a format, a preset or a damage declares, saying what it takes. The values in it - true, 2mb, the numbers, the default, the names a recipe writes - stay as they are. Carries these values, each of which has to stay spelled exactly that way: {{.What}}, {{.Default}}.", + "other": "{{.What}}, default {{.Default}}" + }, "BatchHeading": { "description": "Shown in the window. Carries one value, {{.Number}}, which has to stay spelled exactly that way.", "other": "Batch {{.Number}}" @@ -83,6 +123,10 @@ "description": "Shown in the window. Carries one value, {{.Damage}}, which has to stay spelled exactly that way.", "other": "Settings for {{.Damage}}" }, + "DecimalMark": { + "description": "What this language writes between the whole part of a number and its tenths, as in 10.0 MB. One character.", + "other": "." + }, "DetailBasePreset": { "description": "The longer explanation behind the button beside a field name.", "other": "The settings under it are the preset's own. One left empty takes its default, and the manifest records which ones did." diff --git a/internal/gui/text/locale/pl.json b/internal/gui/text/locale/pl.json index b837855b..cf829e59 100644 --- a/internal/gui/text/locale/pl.json +++ b/internal/gui/text/locale/pl.json @@ -1,5 +1,15 @@ { "AboutTagline": { "other": "Generuj pliki testowe i wiedz, jak system pod testem powinien na nie zareagować." }, + "AllowedDefault": { "other": "domyślnie {{.Default}}" }, + "AllowedJointly": { "other": "{{.Of}} razy {{.By}} nie może przekroczyć {{.Max}} {{.Unit}}, bo {{.Why}}" }, + "AllowedOneOf": { "other": "jedna z wartości: {{.Values}}" }, + "AllowedRange": { "other": "liczba całkowita od {{.Min}} do {{.Max}}" }, + "AllowedRangeOf": { "other": "liczba {{.Unit}} od {{.Min}} do {{.Max}}" }, + "AllowedSize": { "other": "rozmiar, na przykład 2mb, albo sama liczba bajtów" }, + "AllowedTrueOrFalse": { "other": "true albo false" }, + "AllowedWholeNumber": { "other": "liczba całkowita" }, + "AllowedWholeNumberOf": { "other": "liczba {{.Unit}}" }, + "AllowedWithDefault": { "other": "{{.What}}, domyślnie {{.Default}}" }, "BatchHeading": { "other": "Partia {{.Number}}" }, "ButtonAddBatch": { "other": "Dodaj partię" }, "ButtonAddContents": { "other": "Dodaj pliki do środka" }, @@ -20,6 +30,7 @@ "ContentsHeading": { "other": "Pliki w każdym archiwum" }, "DamageNone": { "other": "brak" }, "DamageSettingsFor": { "other": "Ustawienia dla {{.Damage}}" }, + "DecimalMark": { "other": "," }, "DetailBasePreset": { "other": "Ustawienia pod nim należą do presetu. Puste bierze swoją wartość domyślną, a manifest zapisuje, które tak zrobiły." }, "DetailBoundary": { "other": "Podaj limit, który deklaruje twój system, na przykład 10mb. Jednostki liczą się po 1024, a przebieg wypisuje liczbę, której użył." }, "DetailBuildOnPreset": { "other": "Bez zaznaczenia partie poniżej są całym przepisem. Z zaznaczeniem najpierw idą pliki wybranego presetu, a partie są dodawane po nich." }, diff --git a/internal/gui/text/locale/registry/en.json b/internal/gui/text/locale/registry/en.json new file mode 100644 index 00000000..d5c899bf --- /dev/null +++ b/internal/gui/text/locale/registry/en.json @@ -0,0 +1,1082 @@ +{ + "Catch.empty-and-minimal.1": { + "description": "One line of what the empty-and-minimal preset typically finds, on the preset screen.", + "hash": "sha256-c9501051d6fa", + "other": "a valid file turned away for being too small, where the check counts bytes instead of reading them" + }, + "Catch.empty-and-minimal.2": { + "description": "One line of what the empty-and-minimal preset typically finds, on the preset screen.", + "hash": "sha256-ab3561380909", + "other": "an empty file that brings the reader down rather than being reported" + }, + "Catch.empty-and-minimal.3": { + "description": "One line of what the empty-and-minimal preset typically finds, on the preset screen.", + "hash": "sha256-0a385c1ce8f5", + "other": "a picture one pixel wide that divides by zero on the way to a thumbnail" + }, + "Catch.empty-and-minimal.4": { + "description": "One line of what the empty-and-minimal preset typically finds, on the preset screen.", + "hash": "sha256-b62092374dc7", + "other": "storage that reads nought bytes as a failed upload and keeps retrying" + }, + "Catch.filename-handling.1": { + "description": "One line of what the filename-handling preset typically finds, on the preset screen.", + "hash": "sha256-cd689fee5310", + "other": "a name that looks like a different one on screen, in a log or in a list" + }, + "Catch.filename-handling.2": { + "description": "One line of what the filename-handling preset typically finds, on the preset screen.", + "hash": "sha256-ba8e95039ea8", + "other": "a name cut, trimmed or rewritten between upload and storage" + }, + "Catch.filename-handling.3": { + "description": "One line of what the filename-handling preset typically finds, on the preset screen.", + "hash": "sha256-c7a8efdaa07d", + "other": "a length limit counted in characters where the storage counts bytes" + }, + "Catch.size-boundaries.1": { + "description": "One line of what the size-boundaries preset typically finds, on the preset screen.", + "hash": "sha256-1c7f6a00f596", + "other": "off by one errors at the limit" + }, + "Catch.size-boundaries.2": { + "description": "One line of what the size-boundaries preset typically finds, on the preset screen.", + "hash": "sha256-763425765f30", + "other": "MB confused with MiB, which is 4.8 per cent and enough to let a file through that should not pass" + }, + "Catch.size-boundaries.3": { + "description": "One line of what the size-boundaries preset typically finds, on the preset screen.", + "hash": "sha256-787363b16a1e", + "other": "a limit enforced in the browser and not on the server" + }, + "Catch.tabular-import.1": { + "description": "One line of what the tabular-import preset typically finds, on the preset screen.", + "hash": "sha256-39b5b6de0202", + "other": "a semicolon file read as one column, because the delimiter was assumed rather than looked for" + }, + "Catch.tabular-import.2": { + "description": "One line of what the tabular-import preset typically finds, on the preset screen.", + "hash": "sha256-2a752bcedf30", + "other": "a CRLF file split into rows with an empty row after each one" + }, + "Catch.tabular-import.3": { + "description": "One line of what the tabular-import preset typically finds, on the preset screen.", + "hash": "sha256-d413fe6b64bb", + "other": "a headerless table whose first row of data is eaten as column names" + }, + "Catch.tabular-import.4": { + "description": "One line of what the tabular-import preset typically finds, on the preset screen.", + "hash": "sha256-aa0e2941d4e7", + "other": "an import that keeps the columns it can show and drops the rest without a word" + }, + "Catch.tabular-import.5": { + "description": "One line of what the tabular-import preset typically finds, on the preset screen.", + "hash": "sha256-3f1943397e64", + "other": "a reader that takes JSON records one line at a time and stops at the first indented document" + }, + "Catch.text-encoding.1": { + "description": "One line of what the text-encoding preset typically finds, on the preset screen.", + "hash": "sha256-0392010f72c4", + "other": "a reader that assumes UTF-8 and shows a UTF-16 file as one character in three, or as rows of boxes" + }, + "Catch.text-encoding.2": { + "description": "One line of what the text-encoding preset typically finds, on the preset screen.", + "hash": "sha256-5fea89140de5", + "other": "a byte order mark read as content, so the first field of an import starts with three stray characters" + }, + "Catch.text-encoding.3": { + "description": "One line of what the text-encoding preset typically finds, on the preset screen.", + "hash": "sha256-8c3775227fee", + "other": "an importer that guesses the encoding from the opening bytes and guesses differently for a longer file" + }, + "Catch.text-encoding.4": { + "description": "One line of what the text-encoding preset typically finds, on the preset screen.", + "hash": "sha256-1f46ea750358", + "other": "a CRLF file split into rows with an empty row after each one, or a carriage return kept inside the last field" + }, + "Catch.upload-validation.1": { + "description": "One line of what the upload-validation preset typically finds, on the preset screen.", + "hash": "sha256-787363b16a1e", + "other": "a limit enforced in the browser and not on the server" + }, + "Catch.upload-validation.2": { + "description": "One line of what the upload-validation preset typically finds, on the preset screen.", + "hash": "sha256-d206391c78ce", + "other": "an SVG or an HTML file taken for a picture or for plain text, which is a way to get a script past a form" + }, + "Catch.upload-validation.3": { + "description": "One line of what the upload-validation preset typically finds, on the preset screen.", + "hash": "sha256-c3108c812fdc", + "other": "a file checked by its extension and never opened, so a PDF named .jpg goes through" + }, + "Catch.upload-validation.4": { + "description": "One line of what the upload-validation preset typically finds, on the preset screen.", + "hash": "sha256-5f1862f0a060", + "other": "a form that reads the whole body into memory before it looks at how big it is" + }, + "Catch.upload-validation.5": { + "description": "One line of what the upload-validation preset typically finds, on the preset screen.", + "hash": "sha256-3d1670bd44fd", + "other": "an upload named PHOTO.JPG turned away where photo.jpg is taken, or the other way round" + }, + "Catch.upload-validation.6": { + "description": "One line of what the upload-validation preset typically finds, on the preset screen.", + "hash": "sha256-b918ae8fc864", + "other": "a name with spaces, brackets or characters outside ASCII written to disk unchanged" + }, + "Detail.damage/zero-head.bytes": { + "description": "The sentence under the bytes setting of the zero-head damage, after what it takes.", + "hash": "sha256-8700126f6712", + "other": "How many bytes at the start are zeroed. Below four, some formats come out with damage no reader complains about." + }, + "Detail.format/avif.height": { + "description": "The sentence under the height setting of the avif format, after what it takes.", + "hash": "sha256-4b336c279584", + "other": "How tall the picture is. Left out, a size is chosen that fits the bytes you asked for." + }, + "Detail.format/avif.quality": { + "description": "The sentence under the quality setting of the avif format, after what it takes.", + "hash": "sha256-f6e1c3e1af83", + "other": "How much detail the picture keeps. Higher looks better and takes more of the file, leaving less room for padding." + }, + "Detail.format/avif.width": { + "description": "The sentence under the width setting of the avif format, after what it takes.", + "hash": "sha256-3085ac44f0e0", + "other": "How wide the picture is. Left out, a size is chosen that fits the bytes you asked for." + }, + "Detail.format/bmp.height": { + "description": "The sentence under the height setting of the bmp format, after what it takes.", + "hash": "sha256-c5f8bc5c8f49", + "other": "How tall the picture is. Left out, the picture is sized to fill the bytes you asked for." + }, + "Detail.format/bmp.width": { + "description": "The sentence under the width setting of the bmp format, after what it takes.", + "hash": "sha256-0c629fee898a", + "other": "How wide the picture is. Left out, the picture is sized to fill the bytes you asked for." + }, + "Detail.format/csv.columns": { + "description": "The sentence under the columns setting of the csv format, after what it takes.", + "hash": "sha256-a8c37c7f2d97", + "other": "How many columns each row has. Above 16384 a spreadsheet may show only the first 16384 and drop the rest without a word." + }, + "Detail.format/csv.delimiter": { + "description": "The sentence under the delimiter setting of the csv format, after what it takes.", + "hash": "sha256-1cc7b45d9a95", + "other": "What separates the fields. Choose semicolon for the shape a European spreadsheet exports." + }, + "Detail.format/csv.header": { + "description": "The sentence under the header setting of the csv format, after what it takes.", + "hash": "sha256-29dcadb7c881", + "other": "Whether the first row names the columns. Turn it off for a table dumped straight out of a database." + }, + "Detail.format/csv.line_ending": { + "description": "The sentence under the line_ending setting of the csv format, after what it takes.", + "hash": "sha256-d802b082ce8b", + "other": "How each row ends. Choose crlf for the shape RFC 4180 asks for and Excel writes." + }, + "Detail.format/csv.quote_style": { + "description": "The sentence under the quote_style setting of the csv format, after what it takes.", + "hash": "sha256-a2427ed0240b", + "other": "Which fields carry quotes. With none the description stops carrying separators as well, because an unquoted field cannot hold one." + }, + "Detail.format/docx.paragraphs": { + "description": "The sentence under the paragraphs setting of the docx format, after what it takes.", + "hash": "sha256-5d0141e9b1da", + "other": "How many paragraphs of text the document holds. The rest of the size you asked for is carried by a part of the package that is not shown." + }, + "Detail.format/gif.frames": { + "description": "The sentence under the frames setting of the gif format, after what it takes.", + "hash": "sha256-d2291fedd06c", + "other": "How many frames the animation has. Set it to 1 for a still picture." + }, + "Detail.format/gif.height": { + "description": "The sentence under the height setting of the gif format, after what it takes.", + "hash": "sha256-4b336c279584", + "other": "How tall the picture is. Left out, a size is chosen that fits the bytes you asked for." + }, + "Detail.format/gif.width": { + "description": "The sentence under the width setting of the gif format, after what it takes.", + "hash": "sha256-3085ac44f0e0", + "other": "How wide the picture is. Left out, a size is chosen that fits the bytes you asked for." + }, + "Detail.format/html.structure": { + "description": "The sentence under the structure setting of the html format, after what it takes.", + "hash": "sha256-40b00d44ba38", + "other": "Whether the file is a whole page or only the blocks that would sit inside one. A fragment has no doctype, no html element and no body, which is what a content field or the body of an email really holds. It is far smaller, so the smallest fragment sits well below the smallest page." + }, + "Detail.format/ico.embed": { + "description": "The sentence under the embed setting of the ico format, after what it takes.", + "hash": "sha256-1578534c06bf", + "other": "What sits inside the icon. A bitmap is read by every version of Windows. A PNG makes the file much smaller and needs Windows Vista or newer." + }, + "Detail.format/ico.height": { + "description": "The sentence under the height setting of the ico format, after what it takes.", + "hash": "sha256-e2712d04875d", + "other": "How tall the icon is. Left out, the largest standard icon size that fits is used." + }, + "Detail.format/ico.width": { + "description": "The sentence under the width setting of the ico format, after what it takes.", + "hash": "sha256-1e8b4b7bc765", + "other": "How wide the icon is. Left out, the largest standard icon size that fits is used." + }, + "Detail.format/jpg.height": { + "description": "The sentence under the height setting of the jpg format, after what it takes.", + "hash": "sha256-4b336c279584", + "other": "How tall the picture is. Left out, a size is chosen that fits the bytes you asked for." + }, + "Detail.format/jpg.quality": { + "description": "The sentence under the quality setting of the jpg format, after what it takes.", + "hash": "sha256-55a9a664be71", + "other": "How much detail survives compression, from 1 to 100. JPEG throws detail away by design, so a low number gives a visibly softer picture. It changes how large the picture encodes to, not the size of the file you asked for." + }, + "Detail.format/jpg.width": { + "description": "The sentence under the width setting of the jpg format, after what it takes.", + "hash": "sha256-3085ac44f0e0", + "other": "How wide the picture is. Left out, a size is chosen that fits the bytes you asked for." + }, + "Detail.format/json.formatting": { + "description": "The sentence under the formatting setting of the json format, after what it takes.", + "hash": "sha256-fc49d1b8da7d", + "other": "How the document is laid out. Every reader accepts all three, so this changes what meets everything around the parser rather than the parser itself - minified puts the whole file on one line and ends without a newline, and indented makes the same records several times larger." + }, + "Detail.format/jxl.height": { + "description": "The sentence under the height setting of the jxl format, after what it takes.", + "hash": "sha256-4b336c279584", + "other": "How tall the picture is. Left out, a size is chosen that fits the bytes you asked for." + }, + "Detail.format/jxl.quality": { + "description": "The sentence under the quality setting of the jxl format, after what it takes.", + "hash": "sha256-f6e1c3e1af83", + "other": "How much detail the picture keeps. Higher looks better and takes more of the file, leaving less room for padding." + }, + "Detail.format/jxl.width": { + "description": "The sentence under the width setting of the jxl format, after what it takes.", + "hash": "sha256-3085ac44f0e0", + "other": "How wide the picture is. Left out, a size is chosen that fits the bytes you asked for." + }, + "Detail.format/log.entry_format": { + "description": "The sentence under the entry_format setting of the log format, after what it takes.", + "hash": "sha256-96bf1c74dcf1", + "other": "Which kind of log to write. Web server shapes carry a request and a status, the others carry a level and a message." + }, + "Detail.format/log.ip_version": { + "description": "The sentence under the ip_version setting of the log format, after what it takes.", + "hash": "sha256-870d5b20c405", + "other": "Which kind of client address appears. Choose v6 to find out whether a reader handles it." + }, + "Detail.format/log.level_mix": { + "description": "The sentence under the level_mix setting of the log format, after what it takes.", + "hash": "sha256-45f87458cd88", + "other": "Which severities appear. Only the plain and json-lines shapes carry one, and asking for it beside another shape is refused." + }, + "Detail.format/log.line_ending": { + "description": "The sentence under the line_ending setting of the log format, after what it takes.", + "hash": "sha256-8595ad80ed76", + "other": "How each line ends. Choose crlf for a log written by a Windows service." + }, + "Detail.format/log.methods": { + "description": "The sentence under the methods setting of the log format, after what it takes.", + "hash": "sha256-c621eebad3fc", + "other": "Which request methods appear. Read is GET and HEAD, mixed adds POST, PUT, PATCH and DELETE." + }, + "Detail.format/log.rate": { + "description": "The sentence under the rate setting of the log format, after what it takes.", + "hash": "sha256-f8f4b55f9769", + "other": "How fast the entries arrive. Only means anything while timestamps advance." + }, + "Detail.format/log.status_mix": { + "description": "The sentence under the status_mix setting of the log format, after what it takes.", + "hash": "sha256-6555eacaebb2", + "other": "Which response codes appear. Realistic is mostly success with a tail of errors." + }, + "Detail.format/log.timestamps": { + "description": "The sentence under the timestamps setting of the log format, after what it takes.", + "hash": "sha256-b7c69e2424bc", + "other": "Whether each entry happens later than the one before it. Fixed puts every entry at the same instant, which is what this format did before it could advance." + }, + "Detail.format/md.bom": { + "description": "The sentence under the bom setting of the md format, after what it takes.", + "hash": "sha256-a2d6a88583d6", + "other": "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." + }, + "Detail.format/md.encoding": { + "description": "The sentence under the encoding setting of the md format, after what it takes.", + "hash": "sha256-9b6f8a3338ec", + "other": "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." + }, + "Detail.format/pdf.author": { + "description": "The sentence under the author setting of the pdf format, after what it takes.", + "hash": "sha256-33b1ee363d86", + "other": "The author in the document properties. Left empty, the document names none." + }, + "Detail.format/pdf.created": { + "description": "The sentence under the created setting of the pdf format, after what it takes.", + "hash": "sha256-76abcd78cc63", + "other": "When the document says it was created. A time written without a zone is kept without one, and none leaves the date out." + }, + "Detail.format/pdf.creator": { + "description": "The sentence under the creator setting of the pdf format, after what it takes.", + "hash": "sha256-57ed4066a447", + "other": "The program the document says it was written in, such as Microsoft Word. Left empty, the document names none." + }, + "Detail.format/pdf.keywords": { + "description": "The sentence under the keywords setting of the pdf format, after what it takes.", + "hash": "sha256-fa35deba8ee4", + "other": "The keywords in the document properties, as one line of text. Left empty, the document has none." + }, + "Detail.format/pdf.modified": { + "description": "The sentence under the modified setting of the pdf format, after what it takes.", + "hash": "sha256-67960ecb9bad", + "other": "When the document says it was last changed, written the same way as created. Left empty, the document has no such date." + }, + "Detail.format/pdf.orientation": { + "description": "The sentence under the orientation setting of the pdf format, after what it takes.", + "hash": "sha256-e3b372e39fb9", + "other": "Whether pages stand upright or lie wide. Mixed alternates the two, starting upright, and needs at least two pages." + }, + "Detail.format/pdf.page_size": { + "description": "The sentence under the page_size setting of the pdf format, after what it takes.", + "hash": "sha256-5bac95d9b758", + "other": "The paper size every page uses. Mixed goes through a4, letter, legal, a3 and a5 in turn, one a page, and needs at least two pages." + }, + "Detail.format/pdf.pages": { + "description": "The sentence under the pages setting of the pdf format, after what it takes.", + "hash": "sha256-298488d25c0b", + "other": "How many pages the document has." + }, + "Detail.format/pdf.pdf_version": { + "description": "The sentence under the pdf_version setting of the pdf format, after what it takes.", + "hash": "sha256-be65b8f13fa3", + "other": "The version written at the start of the file. Nothing else changes, because the document uses nothing newer than 1.4." + }, + "Detail.format/pdf.producer": { + "description": "The sentence under the producer setting of the pdf format, after what it takes.", + "hash": "sha256-d38b44bea162", + "other": "The program the document says turned it into a PDF." + }, + "Detail.format/pdf.rotate": { + "description": "The sentence under the rotate setting of the pdf format, after what it takes.", + "hash": "sha256-954a63b2b503", + "other": "Asks the reader to turn every page clockwise by this many degrees when it shows it. The page itself stays upright, so a reader that ignores the request shows it unturned." + }, + "Detail.format/pdf.subject": { + "description": "The sentence under the subject setting of the pdf format, after what it takes.", + "hash": "sha256-3c66b2d9cc36", + "other": "The subject in the document properties. Left empty, the document has none." + }, + "Detail.format/pdf.title": { + "description": "The sentence under the title setting of the pdf format, after what it takes.", + "hash": "sha256-28f3672d6413", + "other": "The title a reader shows in its title bar and in the document properties. Left empty, the title is the self describing label, or the name of this tool without one." + }, + "Detail.format/png.height": { + "description": "The sentence under the height setting of the png format, after what it takes.", + "hash": "sha256-4b336c279584", + "other": "How tall the picture is. Left out, a size is chosen that fits the bytes you asked for." + }, + "Detail.format/png.width": { + "description": "The sentence under the width setting of the png format, after what it takes.", + "hash": "sha256-3085ac44f0e0", + "other": "How wide the picture is. Left out, a size is chosen that fits the bytes you asked for." + }, + "Detail.format/pptx.slides": { + "description": "The sentence under the slides setting of the pptx format, after what it takes.", + "hash": "sha256-b62241b390bf", + "other": "How many slides the presentation holds. The rest of the size you asked for is carried by a part of the package that is not shown." + }, + "Detail.format/svg.height": { + "description": "The sentence under the height setting of the svg format, after what it takes.", + "hash": "sha256-093ec883d633", + "other": "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." + }, + "Detail.format/svg.width": { + "description": "The sentence under the width setting of the svg format, after what it takes.", + "hash": "sha256-5dc327958f8d", + "other": "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." + }, + "Detail.format/targz.compression": { + "description": "The sentence under the compression setting of the targz format, after what it takes.", + "hash": "sha256-82bb36e51fe2", + "other": "How hard the files inside are squeezed. The archive still comes out the size you asked for - what changes is how much of it is real content and how much is padding." + }, + "Detail.format/targz.depth": { + "description": "The sentence under the depth setting of the targz format, after what it takes.", + "hash": "sha256-485a554d4ae5", + "other": "How many directories deep the files inside sit. 0 puts them all at the top." + }, + "Detail.format/targz.directory_entries": { + "description": "The sentence under the directory_entries setting of the targz format, after what it takes.", + "hash": "sha256-4602285f60ac", + "other": "Whether the archive also lists the directories themselves. Most file browsers show the same folders either way, so the difference is in the entry list rather than on screen. It matters for readers that only create a directory the archive names." + }, + "Detail.format/targz.entries": { + "description": "The sentence under the entries setting of the targz format, after what it takes.", + "hash": "sha256-e8a3f919b300", + "other": "How many files the archive holds. Use contains instead when the files are not all alike." + }, + "Detail.format/targz.entry_format": { + "description": "The sentence under the entry_format setting of the targz format, after what it takes.", + "hash": "sha256-f4dab5f30a79", + "other": "The format of the files inside. Run tfg formats to see what this build supports." + }, + "Detail.format/targz.entry_mode": { + "description": "The sentence under the entry_mode setting of the targz format, after what it takes.", + "hash": "sha256-6afb3c26590f", + "other": "The permissions recorded for each file inside. It is what the archive says, not what the file gets - that depends on who unpacks it and how." + }, + "Detail.format/targz.entry_owner": { + "description": "The sentence under the entry_owner setting of the targz format, after what it takes.", + "hash": "sha256-e4fa8df2c217", + "other": "Who each file inside belongs to. Leave it unset and the archive names nobody, which is what most archives written by a build carry." + }, + "Detail.format/targz.entry_size": { + "description": "The sentence under the entry_size setting of the targz format, after what it takes.", + "hash": "sha256-c3687f6606e8", + "other": "How big each file inside is." + }, + "Detail.format/tiff.height": { + "description": "The sentence under the height setting of the tiff format, after what it takes.", + "hash": "sha256-c5f8bc5c8f49", + "other": "How tall the picture is. Left out, the picture is sized to fill the bytes you asked for." + }, + "Detail.format/tiff.width": { + "description": "The sentence under the width setting of the tiff format, after what it takes.", + "hash": "sha256-0c629fee898a", + "other": "How wide the picture is. Left out, the picture is sized to fill the bytes you asked for." + }, + "Detail.format/txt.bom": { + "description": "The sentence under the bom setting of the txt format, after what it takes.", + "hash": "sha256-a2d6a88583d6", + "other": "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." + }, + "Detail.format/txt.encoding": { + "description": "The sentence under the encoding setting of the txt format, after what it takes.", + "hash": "sha256-9b6f8a3338ec", + "other": "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." + }, + "Detail.format/wav.bit_depth": { + "description": "The sentence under the bit_depth setting of the wav format, after what it takes.", + "hash": "sha256-211bd38551ad", + "other": "How many bits each sample uses." + }, + "Detail.format/wav.channels": { + "description": "The sentence under the channels setting of the wav format, after what it takes.", + "hash": "sha256-f94ddfa60e04", + "other": "How many channels the signal has, so 1 for mono and 2 for stereo." + }, + "Detail.format/wav.content": { + "description": "The sentence under the content setting of the wav format, after what it takes.", + "hash": "sha256-c2baa480c847", + "other": "What the signal sounds like." + }, + "Detail.format/wav.sample_rate": { + "description": "The sentence under the sample_rate setting of the wav format, after what it takes.", + "hash": "sha256-71e4eb77e129", + "other": "How many samples a second the signal carries." + }, + "Detail.format/webp.height": { + "description": "The sentence under the height setting of the webp format, after what it takes.", + "hash": "sha256-c5f8bc5c8f49", + "other": "How tall the picture is. Left out, the picture is sized to fill the bytes you asked for." + }, + "Detail.format/webp.width": { + "description": "The sentence under the width setting of the webp format, after what it takes.", + "hash": "sha256-0c629fee898a", + "other": "How wide the picture is. Left out, the picture is sized to fill the bytes you asked for." + }, + "Detail.format/xlsx.columns": { + "description": "The sentence under the columns setting of the xlsx format, after what it takes.", + "hash": "sha256-a8c37c7f2d97", + "other": "How many columns each row has. Above 16384 a spreadsheet may show only the first 16384 and drop the rest without a word." + }, + "Detail.format/xlsx.rows": { + "description": "The sentence under the rows setting of the xlsx format, after what it takes.", + "hash": "sha256-5ea8a1ff74e7", + "other": "How many rows of cells the sheet holds. The rest of the size you asked for is carried by a part of the package that is not shown." + }, + "Detail.format/xml.bom": { + "description": "The sentence under the bom setting of the xml format, after what it takes.", + "hash": "sha256-a2d6a88583d6", + "other": "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." + }, + "Detail.format/xml.encoding": { + "description": "The sentence under the encoding setting of the xml format, after what it takes.", + "hash": "sha256-9b6f8a3338ec", + "other": "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." + }, + "Detail.format/zip.compression": { + "description": "The sentence under the compression setting of the zip format, after what it takes.", + "hash": "sha256-82bb36e51fe2", + "other": "How hard the files inside are squeezed. The archive still comes out the size you asked for - what changes is how much of it is real content and how much is padding." + }, + "Detail.format/zip.depth": { + "description": "The sentence under the depth setting of the zip format, after what it takes.", + "hash": "sha256-485a554d4ae5", + "other": "How many directories deep the files inside sit. 0 puts them all at the top." + }, + "Detail.format/zip.directory_entries": { + "description": "The sentence under the directory_entries setting of the zip format, after what it takes.", + "hash": "sha256-4602285f60ac", + "other": "Whether the archive also lists the directories themselves. Most file browsers show the same folders either way, so the difference is in the entry list rather than on screen. It matters for readers that only create a directory the archive names." + }, + "Detail.format/zip.encryption": { + "description": "The sentence under the encryption setting of the zip format, after what it takes.", + "hash": "sha256-2cdbcc794818", + "other": "How the archive is locked. AES is the WinZip scheme, and some readers cannot open it at all. ZipCrypto is the old one every reader opens and nothing modern trusts, and some of them hand back the encrypted bytes without saying so. A locked archive from this tool is a fixture rather than protection - the key is worked out from the run seed, so the same recipe gives the same archive on any machine, and the password is written into the manifest beside it." + }, + "Detail.format/zip.entries": { + "description": "The sentence under the entries setting of the zip format, after what it takes.", + "hash": "sha256-e8a3f919b300", + "other": "How many files the archive holds. Use contains instead when the files are not all alike." + }, + "Detail.format/zip.entry_format": { + "description": "The sentence under the entry_format setting of the zip format, after what it takes.", + "hash": "sha256-f4dab5f30a79", + "other": "The format of the files inside. Run tfg formats to see what this build supports." + }, + "Detail.format/zip.entry_size": { + "description": "The sentence under the entry_size setting of the zip format, after what it takes.", + "hash": "sha256-c3687f6606e8", + "other": "How big each file inside is." + }, + "Detail.format/zip.password": { + "description": "The sentence under the password setting of the zip format, after what it takes.", + "hash": "sha256-c3c77f362ce2", + "other": "The password the archive is locked with. It is written into the manifest as you typed it, because a test that cannot open the file cannot check anything." + }, + "Detail.preset/empty-and-minimal.formats": { + "description": "The sentence under the formats setting of the empty-and-minimal preset, after what it takes.", + "hash": "sha256-945bbcfdb3cf", + "other": "Which formats the set is built from. Leave it at all for every format this build has, or name the ones your system accepts." + }, + "Detail.preset/filename-handling.format": { + "description": "The sentence under the format setting of the filename-handling preset, after what it takes.", + "hash": "sha256-30738a4e7806", + "other": "What kind of file the whole set is made of." + }, + "Detail.preset/size-boundaries.format": { + "description": "The sentence under the format setting of the size-boundaries preset, after what it takes.", + "hash": "sha256-30738a4e7806", + "other": "What kind of file the whole set is made of." + }, + "Detail.preset/size-boundaries.limit": { + "description": "The sentence under the limit setting of the size-boundaries preset, after what it takes.", + "hash": "sha256-b43eca6d1365", + "other": "The size limit your system declares. Everything else is measured from it." + }, + "Detail.preset/size-boundaries.spread": { + "description": "The sentence under the spread setting of the size-boundaries preset, after what it takes.", + "hash": "sha256-149b17db988d", + "other": "How far either side of the limit to reach, as a list of sizes." + }, + "Detail.preset/tabular-import.columns": { + "description": "The sentence under the columns setting of the tabular-import preset, after what it takes.", + "hash": "sha256-de43c523ae48", + "other": "How many columns each row of the spreadsheet has. Rows times columns has a ceiling, and asking past it is refused before anything is written." + }, + "Detail.preset/tabular-import.rows": { + "description": "The sentence under the rows setting of the tabular-import preset, after what it takes.", + "hash": "sha256-46b383ddf143", + "other": "How many rows the spreadsheet holds. It is written at exactly the size that many rows package to, so the budget above moves with this." + }, + "Detail.preset/text-encoding.sample": { + "description": "The sentence under the sample setting of the text-encoding preset, after what it takes.", + "hash": "sha256-dcd4f143702e", + "other": "How big each file of the set is. UTF-16 stores two bytes for every character, so an odd number is refused." + }, + "Detail.preset/upload-validation.allow": { + "description": "The sentence under the allow setting of the upload-validation preset, after what it takes.", + "hash": "sha256-d8a6c0a7bd9f", + "other": "Which types your form is supposed to accept. Each one becomes a real file of that type, and they are the positive control of the whole set." + }, + "Detail.preset/upload-validation.bulk": { + "description": "The sentence under the bulk setting of the upload-validation preset, after what it takes.", + "hash": "sha256-4dd1c4efad8d", + "other": "How many files the mass upload holds. Nought leaves that group out of the set altogether." + }, + "Detail.preset/upload-validation.deny": { + "description": "The sentence under the deny setting of the upload-validation preset, after what it takes.", + "hash": "sha256-f9e5c633d218", + "other": "Which extensions your form is supposed to turn away. An extension this build has no format for still gets a file under that name, holding plain text." + }, + "Detail.preset/upload-validation.far-over": { + "description": "The sentence under the far-over setting of the upload-validation preset, after what it takes.", + "hash": "sha256-6a0093c6d7a0", + "other": "How far past the limit the one big file goes. Turn it off where writing several times the limit is not worth the disk." + }, + "Detail.preset/upload-validation.limit": { + "description": "The sentence under the limit setting of the upload-validation preset, after what it takes.", + "hash": "sha256-be929d76067d", + "other": "The size limit your upload form declares. This set takes one step either side of it - for a file at every distance, run the size-boundaries preset." + }, + "Group.format/pdf.Document properties": { + "description": "The heading over a block of settings of the pdf format.", + "hash": "sha256-7115a778ab47", + "other": "Document properties" + }, + "Joint.format/avif.width.height": { + "description": "Why width times height of the avif format have a ceiling, at the end of the note under the two, after the word because.", + "hash": "sha256-269a6c476761", + "other": "the encoder holds the whole picture in memory while it works" + }, + "Joint.format/gif.width.height": { + "description": "Why width times height of the gif format have a ceiling, at the end of the note under the two, after the word because.", + "hash": "sha256-0bed7aa664d8", + "other": "the picture is held in memory while it is encoded" + }, + "Joint.format/jpg.width.height": { + "description": "Why width times height of the jpg format have a ceiling, at the end of the note under the two, after the word because.", + "hash": "sha256-0bed7aa664d8", + "other": "the picture is held in memory while it is encoded" + }, + "Joint.format/jxl.width.height": { + "description": "Why width times height of the jxl format have a ceiling, at the end of the note under the two, after the word because.", + "hash": "sha256-269a6c476761", + "other": "the encoder holds the whole picture in memory while it works" + }, + "Joint.format/png.width.height": { + "description": "Why width times height of the png format have a ceiling, at the end of the note under the two, after the word because.", + "hash": "sha256-0bed7aa664d8", + "other": "the picture is held in memory while it is encoded" + }, + "Joint.format/xlsx.rows.columns": { + "description": "Why rows times columns of the xlsx format have a ceiling, at the end of the note under the two, after the word because.", + "hash": "sha256-e1ef283f3c5d", + "other": "the sheet is built in memory before it is packaged" + }, + "Label.allow": { + "description": "The name beside the box of a setting a recipe writes as allow.", + "hash": "sha256-e213c161d5ce", + "other": "Allow" + }, + "Label.author": { + "description": "The name beside the box of a setting a recipe writes as author.", + "hash": "sha256-d95082a2ee57", + "other": "Author" + }, + "Label.bit_depth": { + "description": "The name beside the box of a setting a recipe writes as bit_depth.", + "hash": "sha256-cc62f2e89a82", + "other": "Bit depth" + }, + "Label.bom": { + "description": "The name beside the box of a setting a recipe writes as bom.", + "hash": "sha256-e39338101951", + "other": "Bom" + }, + "Label.bulk": { + "description": "The name beside the box of a setting a recipe writes as bulk.", + "hash": "sha256-3140100df131", + "other": "Bulk" + }, + "Label.bytes": { + "description": "The name beside the box of a setting a recipe writes as bytes.", + "hash": "sha256-4d8000301fcc", + "other": "Bytes" + }, + "Label.channels": { + "description": "The name beside the box of a setting a recipe writes as channels.", + "hash": "sha256-4c8906cf76f5", + "other": "Channels" + }, + "Label.columns": { + "description": "The name beside the box of a setting a recipe writes as columns.", + "hash": "sha256-53aade77cd69", + "other": "Columns" + }, + "Label.compression": { + "description": "The name beside the box of a setting a recipe writes as compression.", + "hash": "sha256-278b1b2a9c84", + "other": "Compression" + }, + "Label.content": { + "description": "The name beside the box of a setting a recipe writes as content.", + "hash": "sha256-47bd29075f8b", + "other": "Content" + }, + "Label.created": { + "description": "The name beside the box of a setting a recipe writes as created.", + "hash": "sha256-d70b9e24bca2", + "other": "Created" + }, + "Label.creator": { + "description": "The name beside the box of a setting a recipe writes as creator.", + "hash": "sha256-88447b83090c", + "other": "Creator" + }, + "Label.delimiter": { + "description": "The name beside the box of a setting a recipe writes as delimiter.", + "hash": "sha256-4b4eb3b88e85", + "other": "Delimiter" + }, + "Label.deny": { + "description": "The name beside the box of a setting a recipe writes as deny.", + "hash": "sha256-05a2d7332eb9", + "other": "Deny" + }, + "Label.depth": { + "description": "The name beside the box of a setting a recipe writes as depth.", + "hash": "sha256-f1dbc33978a9", + "other": "Depth" + }, + "Label.directory_entries": { + "description": "The name beside the box of a setting a recipe writes as directory_entries.", + "hash": "sha256-7f31f347692c", + "other": "Directory entries" + }, + "Label.embed": { + "description": "The name beside the box of a setting a recipe writes as embed.", + "hash": "sha256-7512561d2f68", + "other": "Embed" + }, + "Label.encoding": { + "description": "The name beside the box of a setting a recipe writes as encoding.", + "hash": "sha256-4d25a47625ce", + "other": "Encoding" + }, + "Label.encryption": { + "description": "The name beside the box of a setting a recipe writes as encryption.", + "hash": "sha256-82f4e719a19c", + "other": "Encryption" + }, + "Label.entries": { + "description": "The name beside the box of a setting a recipe writes as entries.", + "hash": "sha256-7cb76b4af12a", + "other": "Entries" + }, + "Label.entry_format": { + "description": "The name beside the box of a setting a recipe writes as entry_format.", + "hash": "sha256-63d61a75badf", + "other": "Entry format" + }, + "Label.entry_mode": { + "description": "The name beside the box of a setting a recipe writes as entry_mode.", + "hash": "sha256-4a17440263ca", + "other": "Entry mode" + }, + "Label.entry_owner": { + "description": "The name beside the box of a setting a recipe writes as entry_owner.", + "hash": "sha256-d7bf94c5f409", + "other": "Entry owner" + }, + "Label.entry_size": { + "description": "The name beside the box of a setting a recipe writes as entry_size.", + "hash": "sha256-1190635edb6f", + "other": "Entry size" + }, + "Label.far-over": { + "description": "The name beside the box of a setting a recipe writes as far-over.", + "hash": "sha256-874367c30d4e", + "other": "Far-over" + }, + "Label.format": { + "description": "The name beside the box of a setting a recipe writes as format.", + "hash": "sha256-2f343666aaa8", + "other": "Format" + }, + "Label.formats": { + "description": "The name beside the box of a setting a recipe writes as formats.", + "hash": "sha256-9f01769a4278", + "other": "Formats" + }, + "Label.formatting": { + "description": "The name beside the box of a setting a recipe writes as formatting.", + "hash": "sha256-29d4198e41d8", + "other": "Formatting" + }, + "Label.frames": { + "description": "The name beside the box of a setting a recipe writes as frames.", + "hash": "sha256-2c447e8d0598", + "other": "Frames" + }, + "Label.header": { + "description": "The name beside the box of a setting a recipe writes as header.", + "hash": "sha256-ba5caa4285a8", + "other": "Header" + }, + "Label.height": { + "description": "The name beside the box of a setting a recipe writes as height.", + "hash": "sha256-bfe2eca40179", + "other": "Height" + }, + "Label.ip_version": { + "description": "The name beside the box of a setting a recipe writes as ip_version.", + "hash": "sha256-29016036fada", + "other": "Ip version" + }, + "Label.keywords": { + "description": "The name beside the box of a setting a recipe writes as keywords.", + "hash": "sha256-8bfdb93cafaa", + "other": "Keywords" + }, + "Label.level_mix": { + "description": "The name beside the box of a setting a recipe writes as level_mix.", + "hash": "sha256-cc4f6ea7c7fc", + "other": "Level mix" + }, + "Label.limit": { + "description": "The name beside the box of a setting a recipe writes as limit.", + "hash": "sha256-674b0ed54bf7", + "other": "Limit" + }, + "Label.line_ending": { + "description": "The name beside the box of a setting a recipe writes as line_ending.", + "hash": "sha256-c4f7d0b83542", + "other": "Line ending" + }, + "Label.methods": { + "description": "The name beside the box of a setting a recipe writes as methods.", + "hash": "sha256-8696622f3441", + "other": "Methods" + }, + "Label.modified": { + "description": "The name beside the box of a setting a recipe writes as modified.", + "hash": "sha256-e8ce5dcaf408", + "other": "Modified" + }, + "Label.orientation": { + "description": "The name beside the box of a setting a recipe writes as orientation.", + "hash": "sha256-e3d11242200f", + "other": "Orientation" + }, + "Label.page_size": { + "description": "The name beside the box of a setting a recipe writes as page_size.", + "hash": "sha256-bd69e66e00ef", + "other": "Page size" + }, + "Label.pages": { + "description": "The name beside the box of a setting a recipe writes as pages.", + "hash": "sha256-9046da16aea9", + "other": "Pages" + }, + "Label.paragraphs": { + "description": "The name beside the box of a setting a recipe writes as paragraphs.", + "hash": "sha256-d2ef75e86bb7", + "other": "Paragraphs" + }, + "Label.password": { + "description": "The name beside the box of a setting a recipe writes as password.", + "hash": "sha256-e7cf3ef4f17c", + "other": "Password" + }, + "Label.pdf_version": { + "description": "The name beside the box of a setting a recipe writes as pdf_version.", + "hash": "sha256-3ce7c43387ea", + "other": "Pdf version" + }, + "Label.producer": { + "description": "The name beside the box of a setting a recipe writes as producer.", + "hash": "sha256-98ee4021c6f1", + "other": "Producer" + }, + "Label.quality": { + "description": "The name beside the box of a setting a recipe writes as quality.", + "hash": "sha256-1b2c08a8733d", + "other": "Quality" + }, + "Label.quote_style": { + "description": "The name beside the box of a setting a recipe writes as quote_style.", + "hash": "sha256-8c8c9f41e450", + "other": "Quote style" + }, + "Label.rate": { + "description": "The name beside the box of a setting a recipe writes as rate.", + "hash": "sha256-45b63ffd01af", + "other": "Rate" + }, + "Label.rotate": { + "description": "The name beside the box of a setting a recipe writes as rotate.", + "hash": "sha256-c3613b1704f5", + "other": "Rotate" + }, + "Label.rows": { + "description": "The name beside the box of a setting a recipe writes as rows.", + "hash": "sha256-101f2ff3de22", + "other": "Rows" + }, + "Label.sample": { + "description": "The name beside the box of a setting a recipe writes as sample.", + "hash": "sha256-95a2f1edb08a", + "other": "Sample" + }, + "Label.sample_rate": { + "description": "The name beside the box of a setting a recipe writes as sample_rate.", + "hash": "sha256-218e0d43275e", + "other": "Sample rate" + }, + "Label.slides": { + "description": "The name beside the box of a setting a recipe writes as slides.", + "hash": "sha256-a0ffe3818444", + "other": "Slides" + }, + "Label.spread": { + "description": "The name beside the box of a setting a recipe writes as spread.", + "hash": "sha256-b11fb1f593a8", + "other": "Spread" + }, + "Label.status_mix": { + "description": "The name beside the box of a setting a recipe writes as status_mix.", + "hash": "sha256-e5e3006b5ecd", + "other": "Status mix" + }, + "Label.structure": { + "description": "The name beside the box of a setting a recipe writes as structure.", + "hash": "sha256-7203c2b3dad3", + "other": "Structure" + }, + "Label.subject": { + "description": "The name beside the box of a setting a recipe writes as subject.", + "hash": "sha256-68971283841a", + "other": "Subject" + }, + "Label.timestamps": { + "description": "The name beside the box of a setting a recipe writes as timestamps.", + "hash": "sha256-755f099dbf49", + "other": "Timestamps" + }, + "Label.title": { + "description": "The name beside the box of a setting a recipe writes as title.", + "hash": "sha256-7e8cd2056da7", + "other": "Title" + }, + "Label.width": { + "description": "The name beside the box of a setting a recipe writes as width.", + "hash": "sha256-3019947b6daf", + "other": "Width" + }, + "Note.size-boundaries.limit": { + "description": "What a run of the size-boundaries preset says when nobody gave its limit.", + "hash": "sha256-d65173d5db01", + "other": "no limit was given, so this set is built around 10mb - that is our placeholder and not your system's limit. Pass the limit your system declares, or the files say nothing about it." + }, + "Note.upload-validation.limit": { + "description": "What a run of the upload-validation preset says when nobody gave its limit.", + "hash": "sha256-c30ac7e13539", + "other": "no limit was given, so this set is built around 10mb - that is our placeholder and not your form's limit. Pass the limit your form declares, or the files say nothing about it." + }, + "Question.empty-and-minimal": { + "description": "The question the empty-and-minimal preset answers, at the top of the preset screen.", + "hash": "sha256-9a69d7227e12", + "other": "Does a file that is valid and as small as the format allows get through?" + }, + "Question.filename-handling": { + "description": "The question the filename-handling preset answers, at the top of the preset screen.", + "hash": "sha256-e558318318a2", + "other": "Will my system store, show and give back a file name it did not expect?" + }, + "Question.size-boundaries": { + "description": "The question the size-boundaries preset answers, at the top of the preset screen.", + "hash": "sha256-e26666b8f7a6", + "other": "Is a size limit enforced exactly where it is declared?" + }, + "Question.tabular-import": { + "description": "The question the tabular-import preset answers, at the top of the preset screen.", + "hash": "sha256-f2132eb34797", + "other": "Does my table import survive what real tools export?" + }, + "Question.text-encoding": { + "description": "The question the text-encoding preset answers, at the top of the preset screen.", + "hash": "sha256-766ce93a4d3c", + "other": "Does my reader know which encoding a file is in, or is it guessing?" + }, + "Question.upload-validation": { + "description": "The question the upload-validation preset answers, at the top of the preset screen.", + "hash": "sha256-950469ca4b3a", + "other": "Does my upload form take what it should and turn the rest away?" + }, + "Shape.a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-a40bd1decdc9", + "other": "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none" + }, + "Shape.any text": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-06c83efcd22e", + "other": "any text" + }, + "Shape.extensions separated by commas": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-69f0be0e37e3", + "other": "extensions separated by commas" + }, + "Shape.format ids separated by commas": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-cc1ca5dd954c", + "other": "format ids separated by commas" + }, + "Shape.format ids separated by commas, or all": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-48e97c935e5d", + "other": "format ids separated by commas, or all" + }, + "Shape.sizes separated by commas": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-f7e5e8c54e6f", + "other": "sizes separated by commas" + }, + "Shape.the id of a format, as tfg formats lists them": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-b6784882fdeb", + "other": "the id of a format, as tfg formats lists them" + }, + "Shape.the password, in plain text": { + "description": "What the text in a box has to look like, in the sentence under it.", + "hash": "sha256-eb266034b6a8", + "other": "the password, in plain text" + }, + "Unit.bytes": { + "description": "What a number counts, after it, as in: from 1 to 20000 bytes. Written the way the language puts a noun after a number.", + "hash": "sha256-277089d91c0b", + "other": "bytes" + }, + "Unit.columns": { + "description": "What a number counts, after it, as in: from 1 to 20000 columns. Written the way the language puts a noun after a number.", + "hash": "sha256-0e624a1a135e", + "other": "columns" + }, + "Unit.entries per second": { + "description": "What a number counts, after it, as in: from 1 to 20000 entries per second. Written the way the language puts a noun after a number.", + "hash": "sha256-90ad5144c654", + "other": "entries per second" + }, + "Unit.files": { + "description": "What a number counts, after it, as in: from 1 to 20000 files. Written the way the language puts a noun after a number.", + "hash": "sha256-3d7db37d08f9", + "other": "files" + }, + "Unit.hertz": { + "description": "What a number counts, after it, as in: from 1 to 20000 hertz. Written the way the language puts a noun after a number.", + "hash": "sha256-e333c47e4b5c", + "other": "hertz" + }, + "Unit.megapixels": { + "description": "What a number counts, after it, as in: from 1 to 20000 megapixels. Written the way the language puts a noun after a number.", + "hash": "sha256-2bb37d7da74d", + "other": "megapixels" + }, + "Unit.million cells": { + "description": "What a number counts, after it, as in: from 1 to 20000 million cells. Written the way the language puts a noun after a number.", + "hash": "sha256-95a765354101", + "other": "million cells" + }, + "Unit.paragraphs": { + "description": "What a number counts, after it, as in: from 1 to 20000 paragraphs. Written the way the language puts a noun after a number.", + "hash": "sha256-ec575de14f57", + "other": "paragraphs" + }, + "Unit.pixels": { + "description": "What a number counts, after it, as in: from 1 to 20000 pixels. Written the way the language puts a noun after a number.", + "hash": "sha256-6ec9c2b0eb14", + "other": "pixels" + }, + "Unit.rows": { + "description": "What a number counts, after it, as in: from 1 to 20000 rows. Written the way the language puts a noun after a number.", + "hash": "sha256-bc51e9e65d79", + "other": "rows" + }, + "Unit.slides": { + "description": "What a number counts, after it, as in: from 1 to 20000 slides. Written the way the language puts a noun after a number.", + "hash": "sha256-fd2c8f1dc03c", + "other": "slides" + } +} diff --git a/internal/gui/text/locale/registry/pl.json b/internal/gui/text/locale/registry/pl.json new file mode 100644 index 00000000..2bcf41f4 --- /dev/null +++ b/internal/gui/text/locale/registry/pl.json @@ -0,0 +1,218 @@ +{ + "Catch.empty-and-minimal.1": { "hash": "sha256-c9501051d6fa", "other": "poprawny plik odrzucony jako za mały, bo kontrola liczy bajty, zamiast go przeczytać" }, + "Catch.empty-and-minimal.2": { "hash": "sha256-ab3561380909", "other": "pusty plik, który wywraca czytnik, zamiast zostać zgłoszony" }, + "Catch.empty-and-minimal.3": { "hash": "sha256-0a385c1ce8f5", "other": "obrazek szeroki na jeden piksel, który w drodze do miniatury dzieli przez zero" }, + "Catch.empty-and-minimal.4": { "hash": "sha256-b62092374dc7", "other": "magazyn, który zero bajtów uznaje za nieudane przesłanie i ponawia je bez końca" }, + "Catch.filename-handling.1": { "hash": "sha256-cd689fee5310", "other": "nazwa, która na ekranie, w logu albo na liście wygląda jak inna" }, + "Catch.filename-handling.2": { "hash": "sha256-ba8e95039ea8", "other": "nazwa ucięta, przycięta albo przepisana między przesłaniem a zapisem" }, + "Catch.filename-handling.3": { "hash": "sha256-c7a8efdaa07d", "other": "limit długości liczony w znakach tam, gdzie magazyn liczy bajty" }, + "Catch.size-boundaries.1": { "hash": "sha256-1c7f6a00f596", "other": "błędy o jeden na samym limicie" }, + "Catch.size-boundaries.2": { "hash": "sha256-763425765f30", "other": "pomylone MB i MiB - to 4,8 procent, wystarczy, żeby przepuścić plik, który nie powinien przejść" }, + "Catch.size-boundaries.3": { "hash": "sha256-787363b16a1e", "other": "limit sprawdzany w przeglądarce, a nie na serwerze" }, + "Catch.tabular-import.1": { "hash": "sha256-39b5b6de0202", "other": "plik ze średnikami wczytany jako jedna kolumna, bo separator założono, zamiast go poszukać" }, + "Catch.tabular-import.2": { "hash": "sha256-2a752bcedf30", "other": "plik z CRLF podzielony na wiersze z pustym wierszem po każdym" }, + "Catch.tabular-import.3": { "hash": "sha256-d413fe6b64bb", "other": "tabela bez nagłówka, której pierwszy wiersz danych zostaje zjedzony jako nazwy kolumn" }, + "Catch.tabular-import.4": { "hash": "sha256-aa0e2941d4e7", "other": "import, który zostawia kolumny, jakie umie pokazać, a resztę bez słowa porzuca" }, + "Catch.tabular-import.5": { "hash": "sha256-3f1943397e64", "other": "czytnik, który bierze rekordy JSON po jednej linii i zatrzymuje się na pierwszym dokumencie z wcięciami" }, + "Catch.text-encoding.1": { "hash": "sha256-0392010f72c4", "other": "czytnik, który zakłada UTF-8 i pokazuje plik UTF-16 jako jeden znak na trzy albo jako rzędy prostokątów" }, + "Catch.text-encoding.2": { "hash": "sha256-5fea89140de5", "other": "znacznik kolejności bajtów wczytany jako treść, więc pierwsze pole importu zaczyna się od trzech obcych znaków" }, + "Catch.text-encoding.3": { "hash": "sha256-8c3775227fee", "other": "import, który zgaduje kodowanie z pierwszych bajtów i dla dłuższego pliku zgaduje inaczej" }, + "Catch.text-encoding.4": { "hash": "sha256-1f46ea750358", "other": "plik z CRLF podzielony na wiersze z pustym wierszem po każdym albo znak powrotu karetki zostawiony w ostatnim polu" }, + "Catch.upload-validation.1": { "hash": "sha256-787363b16a1e", "other": "limit sprawdzany w przeglądarce, a nie na serwerze" }, + "Catch.upload-validation.2": { "hash": "sha256-d206391c78ce", "other": "plik SVG albo HTML przyjęty jako obrazek albo zwykły tekst, co jest sposobem na przemycenie skryptu przez formularz" }, + "Catch.upload-validation.3": { "hash": "sha256-c3108c812fdc", "other": "plik sprawdzany po rozszerzeniu i nigdy nieotwierany, więc PDF nazwany .jpg przechodzi" }, + "Catch.upload-validation.4": { "hash": "sha256-5f1862f0a060", "other": "formularz, który wczytuje całe żądanie do pamięci, zanim sprawdzi, jak jest duże" }, + "Catch.upload-validation.5": { "hash": "sha256-3d1670bd44fd", "other": "plik PHOTO.JPG odrzucony tam, gdzie photo.jpg przechodzi, albo odwrotnie" }, + "Catch.upload-validation.6": { "hash": "sha256-b918ae8fc864", "other": "nazwa ze spacjami, nawiasami albo znakami spoza ASCII zapisana na dysk bez zmian" }, + "Detail.damage/zero-head.bytes": { "hash": "sha256-8700126f6712", "other": "Ile bajtów na początku zostaje wyzerowanych. Poniżej czterech niektóre formaty wychodzą z uszkodzeniem, na które żaden czytnik nie narzeka." }, + "Detail.format/avif.height": { "hash": "sha256-4b336c279584", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/avif.quality": { "hash": "sha256-f6e1c3e1af83", "other": "Ile szczegółów zachowuje obraz. Wyższa wartość wygląda lepiej i zajmuje więcej pliku, zostawiając mniej miejsca na dopełnienie." }, + "Detail.format/avif.width": { "hash": "sha256-3085ac44f0e0", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/bmp.height": { "hash": "sha256-c5f8bc5c8f49", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, obraz jest dobierany tak, żeby wypełnił zamówione bajty." }, + "Detail.format/bmp.width": { "hash": "sha256-0c629fee898a", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, obraz jest dobierany tak, żeby wypełnił zamówione bajty." }, + "Detail.format/csv.columns": { "hash": "sha256-a8c37c7f2d97", "other": "Ile kolumn ma każdy wiersz. Powyżej 16384 arkusz kalkulacyjny może pokazać tylko pierwsze 16384 i bez słowa pominąć resztę." }, + "Detail.format/csv.delimiter": { "hash": "sha256-1cc7b45d9a95", "other": "Co rozdziela pola. Wybierz semicolon, żeby dostać kształt, jaki eksportuje europejski arkusz kalkulacyjny." }, + "Detail.format/csv.header": { "hash": "sha256-29dcadb7c881", "other": "Czy pierwszy wiersz nazywa kolumny. Wyłącz, żeby dostać tabelę zrzuconą prosto z bazy danych." }, + "Detail.format/csv.line_ending": { "hash": "sha256-d802b082ce8b", "other": "Jak kończy się każdy wiersz. Wybierz crlf, żeby dostać kształt, którego wymaga RFC 4180 i który zapisuje Excel." }, + "Detail.format/csv.quote_style": { "hash": "sha256-a2427ed0240b", "other": "Które pola mają cudzysłowy. Przy none opis przestaje też zawierać separatory, bo pole bez cudzysłowu nie może go pomieścić." }, + "Detail.format/docx.paragraphs": { "hash": "sha256-5d0141e9b1da", "other": "Ile akapitów tekstu zawiera dokument. Resztę zamówionego rozmiaru niesie niewidoczna część paczki." }, + "Detail.format/gif.frames": { "hash": "sha256-d2291fedd06c", "other": "Ile klatek ma animacja. Ustaw 1, żeby dostać nieruchomy obraz." }, + "Detail.format/gif.height": { "hash": "sha256-4b336c279584", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/gif.width": { "hash": "sha256-3085ac44f0e0", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/html.structure": { "hash": "sha256-40b00d44ba38", "other": "Czy plik jest całą stroną, czy tylko blokami, które stałyby w jej środku. Fragment nie ma doctype, elementu html ani body, czyli tyle, ile naprawdę trzyma pole treści albo treść e-maila. Jest dużo mniejszy, więc najmniejszy fragment jest wyraźnie mniejszy od najmniejszej strony." }, + "Detail.format/ico.embed": { "hash": "sha256-1578534c06bf", "other": "Co jest w środku ikony. Bitmapę czyta każda wersja Windows. PNG daje dużo mniejszy plik i wymaga Windows Vista albo nowszego." }, + "Detail.format/ico.height": { "hash": "sha256-e2712d04875d", "other": "Jak wysoka jest ikona. Gdy pole zostaje puste, używany jest największy standardowy rozmiar ikony, który się mieści." }, + "Detail.format/ico.width": { "hash": "sha256-1e8b4b7bc765", "other": "Jak szeroka jest ikona. Gdy pole zostaje puste, używany jest największy standardowy rozmiar ikony, który się mieści." }, + "Detail.format/jpg.height": { "hash": "sha256-4b336c279584", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/jpg.quality": { "hash": "sha256-55a9a664be71", "other": "Ile szczegółów przetrwa kompresję, od 1 do 100. JPEG z założenia odrzuca szczegóły, więc niska liczba daje widocznie bardziej miękki obraz. Zmienia to, do jakiego rozmiaru obraz się koduje, a nie rozmiar zamówionego pliku." }, + "Detail.format/jpg.width": { "hash": "sha256-3085ac44f0e0", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/json.formatting": { "hash": "sha256-fc49d1b8da7d", "other": "Jak ułożony jest dokument. Każdy czytnik przyjmuje wszystkie trzy, więc to zmienia to, co spotyka otoczenie parsera, a nie sam parser - minified stawia cały plik w jednej linii i kończy go bez znaku nowej linii, a indented robi z tych samych rekordów plik kilka razy większy." }, + "Detail.format/jxl.height": { "hash": "sha256-4b336c279584", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/jxl.quality": { "hash": "sha256-f6e1c3e1af83", "other": "Ile szczegółów zachowuje obraz. Wyższa wartość wygląda lepiej i zajmuje więcej pliku, zostawiając mniej miejsca na dopełnienie." }, + "Detail.format/jxl.width": { "hash": "sha256-3085ac44f0e0", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/log.entry_format": { "hash": "sha256-96bf1c74dcf1", "other": "Jaki rodzaj logu zapisać. Kształty serwera WWW niosą żądanie i status, pozostałe niosą poziom i komunikat." }, + "Detail.format/log.ip_version": { "hash": "sha256-870d5b20c405", "other": "Jaki rodzaj adresu klienta się pojawia. Wybierz v6, żeby sprawdzić, czy czytnik go obsługuje." }, + "Detail.format/log.level_mix": { "hash": "sha256-45f87458cd88", "other": "Które poziomy ważności się pojawiają. Niosą je tylko kształty plain i json-lines, a prośba o nie przy innym kształcie jest odrzucana." }, + "Detail.format/log.line_ending": { "hash": "sha256-8595ad80ed76", "other": "Jak kończy się każda linia. Wybierz crlf, żeby dostać log zapisany przez usługę Windows." }, + "Detail.format/log.methods": { "hash": "sha256-c621eebad3fc", "other": "Które metody żądań się pojawiają. Read to GET i HEAD, mixed dodaje POST, PUT, PATCH i DELETE." }, + "Detail.format/log.rate": { "hash": "sha256-f8f4b55f9769", "other": "Jak szybko przychodzą wpisy. Ma znaczenie tylko wtedy, gdy znaczniki czasu idą naprzód." }, + "Detail.format/log.status_mix": { "hash": "sha256-6555eacaebb2", "other": "Które kody odpowiedzi się pojawiają. Realistic to głównie sukcesy z ogonem błędów." }, + "Detail.format/log.timestamps": { "hash": "sha256-b7c69e2424bc", "other": "Czy każdy wpis dzieje się później niż poprzedni. Fixed stawia każdy wpis w tej samej chwili, tak jak ten format robił, zanim umiał iść naprzód." }, + "Detail.format/md.bom": { "hash": "sha256-a2d6a88583d6", "other": "Czy plik zaczyna się od znacznika kolejności bajtów (BOM). Czytnik, który musi zgadywać kodowanie, go potrzebuje, a czytnik, który się go nie spodziewa, pokazuje go jako obce znaki na początku pliku." }, + "Detail.format/md.encoding": { "hash": "sha256-9b6f8a3338ec", "other": "W jakim kodowaniu zapisane są znaki. UTF-16 zapisuje dwa bajty na znak, więc plik w nim ma zawsze parzystą liczbę bajtów, a nieparzysty rozmiar jest odrzucany." }, + "Detail.format/pdf.author": { "hash": "sha256-33b1ee363d86", "other": "Autor we właściwościach dokumentu. Gdy pole zostaje puste, dokument nie podaje żadnego." }, + "Detail.format/pdf.created": { "hash": "sha256-76abcd78cc63", "other": "Kiedy według dokumentu powstał. Czas zapisany bez strefy zostaje bez niej, a none pomija datę." }, + "Detail.format/pdf.creator": { "hash": "sha256-57ed4066a447", "other": "Program, w którym według dokumentu go napisano, na przykład Microsoft Word. Gdy pole zostaje puste, dokument nie podaje żadnego." }, + "Detail.format/pdf.keywords": { "hash": "sha256-fa35deba8ee4", "other": "Słowa kluczowe we właściwościach dokumentu, jako jedna linia tekstu. Gdy pole zostaje puste, dokument nie ma żadnych." }, + "Detail.format/pdf.modified": { "hash": "sha256-67960ecb9bad", "other": "Kiedy według dokumentu zmieniono go ostatni raz, zapisane tak samo jak created. Gdy pole zostaje puste, dokument nie ma takiej daty." }, + "Detail.format/pdf.orientation": { "hash": "sha256-e3b372e39fb9", "other": "Czy strony stoją pionowo, czy leżą poziomo. Mixed zmienia je na przemian, zaczynając od pionowej, i wymaga co najmniej dwóch stron." }, + "Detail.format/pdf.page_size": { "hash": "sha256-5bac95d9b758", "other": "Rozmiar papieru każdej strony. Mixed przechodzi po kolei przez a4, letter, legal, a3 i a5, jeden na stronę, i wymaga co najmniej dwóch stron." }, + "Detail.format/pdf.pages": { "hash": "sha256-298488d25c0b", "other": "Ile stron ma dokument." }, + "Detail.format/pdf.pdf_version": { "hash": "sha256-be65b8f13fa3", "other": "Wersja zapisana na początku pliku. Nic innego się nie zmienia, bo dokument nie używa niczego nowszego niż 1.4." }, + "Detail.format/pdf.producer": { "hash": "sha256-d38b44bea162", "other": "Program, który według dokumentu zamienił go na PDF." }, + "Detail.format/pdf.rotate": { "hash": "sha256-954a63b2b503", "other": "Prosi czytnik, żeby przy wyświetlaniu obrócił każdą stronę o tyle stopni zgodnie z ruchem wskazówek zegara. Sama strona zostaje pionowa, więc czytnik, który ignoruje prośbę, pokazuje ją nieobróconą." }, + "Detail.format/pdf.subject": { "hash": "sha256-3c66b2d9cc36", "other": "Temat we właściwościach dokumentu. Gdy pole zostaje puste, dokument nie ma żadnego." }, + "Detail.format/pdf.title": { "hash": "sha256-28f3672d6413", "other": "Tytuł, który czytnik pokazuje na pasku tytułu i we właściwościach dokumentu. Gdy pole zostaje puste, tytułem jest samoopisująca etykieta, a bez niej nazwa tego narzędzia." }, + "Detail.format/png.height": { "hash": "sha256-4b336c279584", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/png.width": { "hash": "sha256-3085ac44f0e0", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, wybierany jest rozmiar, który mieści się w zamówionych bajtach." }, + "Detail.format/pptx.slides": { "hash": "sha256-b62241b390bf", "other": "Ile slajdów zawiera prezentacja. Resztę zamówionego rozmiaru niesie niewidoczna część paczki." }, + "Detail.format/svg.height": { "hash": "sha256-093ec883d633", "other": "Jaką wysokość deklaruje rysunek. Etykieta biegnie wzdłuż dolnej krawędzi, więc rysunek niższy od tego pasa nie ma widocznej etykiety." }, + "Detail.format/svg.width": { "hash": "sha256-5dc327958f8d", "other": "Jaką szerokość deklaruje rysunek. Nic nie jest tu rysowane w pikselach, więc duża liczba kosztuje kilka bajtów w pliku i bardzo dużo pamięci w tym, co go otworzy." }, + "Detail.format/targz.compression": { "hash": "sha256-82bb36e51fe2", "other": "Jak mocno ściskane są pliki w środku. Archiwum i tak ma zamówiony rozmiar - zmienia się to, ile z niego jest prawdziwą treścią, a ile dopełnieniem." }, + "Detail.format/targz.depth": { "hash": "sha256-485a554d4ae5", "other": "Na ilu poziomach katalogów leżą pliki w środku. 0 stawia je wszystkie na górze." }, + "Detail.format/targz.directory_entries": { "hash": "sha256-4602285f60ac", "other": "Czy archiwum wymienia też same katalogi. Większość przeglądarek plików pokazuje te same foldery w obu przypadkach, więc różnica jest w liście wpisów, a nie na ekranie. Ma znaczenie dla czytników, które tworzą tylko katalog nazwany w archiwum." }, + "Detail.format/targz.entries": { "hash": "sha256-e8a3f919b300", "other": "Ile plików zawiera archiwum. Gdy pliki nie są jednakowe, użyj zamiast tego contains." }, + "Detail.format/targz.entry_format": { "hash": "sha256-f4dab5f30a79", "other": "Format plików w środku. Uruchom tfg formats, żeby zobaczyć, co obsługuje ta wersja." }, + "Detail.format/targz.entry_mode": { "hash": "sha256-6afb3c26590f", "other": "Uprawnienia zapisane dla każdego pliku w środku. To mówi archiwum, a nie to, co dostaje plik - to zależy od tego, kto i jak je rozpakuje." }, + "Detail.format/targz.entry_owner": { "hash": "sha256-e4fa8df2c217", "other": "Do kogo należy każdy plik w środku. Zostaw unset, a archiwum nie wskaże nikogo, tak jak większość archiwów zapisanych przez build." }, + "Detail.format/targz.entry_size": { "hash": "sha256-c3687f6606e8", "other": "Jak duży jest każdy plik w środku." }, + "Detail.format/tiff.height": { "hash": "sha256-c5f8bc5c8f49", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, obraz jest dobierany tak, żeby wypełnił zamówione bajty." }, + "Detail.format/tiff.width": { "hash": "sha256-0c629fee898a", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, obraz jest dobierany tak, żeby wypełnił zamówione bajty." }, + "Detail.format/txt.bom": { "hash": "sha256-a2d6a88583d6", "other": "Czy plik zaczyna się od znacznika kolejności bajtów (BOM). Czytnik, który musi zgadywać kodowanie, go potrzebuje, a czytnik, który się go nie spodziewa, pokazuje go jako obce znaki na początku pliku." }, + "Detail.format/txt.encoding": { "hash": "sha256-9b6f8a3338ec", "other": "W jakim kodowaniu zapisane są znaki. UTF-16 zapisuje dwa bajty na znak, więc plik w nim ma zawsze parzystą liczbę bajtów, a nieparzysty rozmiar jest odrzucany." }, + "Detail.format/wav.bit_depth": { "hash": "sha256-211bd38551ad", "other": "Ile bitów zajmuje każda próbka." }, + "Detail.format/wav.channels": { "hash": "sha256-f94ddfa60e04", "other": "Ile kanałów ma sygnał, czyli 1 dla mono i 2 dla stereo." }, + "Detail.format/wav.content": { "hash": "sha256-c2baa480c847", "other": "Jak brzmi sygnał." }, + "Detail.format/wav.sample_rate": { "hash": "sha256-71e4eb77e129", "other": "Ile próbek na sekundę niesie sygnał." }, + "Detail.format/webp.height": { "hash": "sha256-c5f8bc5c8f49", "other": "Jak wysoki jest obraz. Gdy pole zostaje puste, obraz jest dobierany tak, żeby wypełnił zamówione bajty." }, + "Detail.format/webp.width": { "hash": "sha256-0c629fee898a", "other": "Jak szeroki jest obraz. Gdy pole zostaje puste, obraz jest dobierany tak, żeby wypełnił zamówione bajty." }, + "Detail.format/xlsx.columns": { "hash": "sha256-a8c37c7f2d97", "other": "Ile kolumn ma każdy wiersz. Powyżej 16384 arkusz kalkulacyjny może pokazać tylko pierwsze 16384 i bez słowa pominąć resztę." }, + "Detail.format/xlsx.rows": { "hash": "sha256-5ea8a1ff74e7", "other": "Ile wierszy komórek zawiera arkusz. Resztę zamówionego rozmiaru niesie niewidoczna część paczki." }, + "Detail.format/xml.bom": { "hash": "sha256-a2d6a88583d6", "other": "Czy plik zaczyna się od znacznika kolejności bajtów (BOM). Czytnik, który musi zgadywać kodowanie, go potrzebuje, a czytnik, który się go nie spodziewa, pokazuje go jako obce znaki na początku pliku." }, + "Detail.format/xml.encoding": { "hash": "sha256-9b6f8a3338ec", "other": "W jakim kodowaniu zapisane są znaki. UTF-16 zapisuje dwa bajty na znak, więc plik w nim ma zawsze parzystą liczbę bajtów, a nieparzysty rozmiar jest odrzucany." }, + "Detail.format/zip.compression": { "hash": "sha256-82bb36e51fe2", "other": "Jak mocno ściskane są pliki w środku. Archiwum i tak ma zamówiony rozmiar - zmienia się to, ile z niego jest prawdziwą treścią, a ile dopełnieniem." }, + "Detail.format/zip.depth": { "hash": "sha256-485a554d4ae5", "other": "Na ilu poziomach katalogów leżą pliki w środku. 0 stawia je wszystkie na górze." }, + "Detail.format/zip.directory_entries": { "hash": "sha256-4602285f60ac", "other": "Czy archiwum wymienia też same katalogi. Większość przeglądarek plików pokazuje te same foldery w obu przypadkach, więc różnica jest w liście wpisów, a nie na ekranie. Ma znaczenie dla czytników, które tworzą tylko katalog nazwany w archiwum." }, + "Detail.format/zip.encryption": { "hash": "sha256-2cdbcc794818", "other": "Jak zamknięte jest archiwum. AES to schemat WinZip i niektóre czytniki w ogóle go nie otworzą. ZipCrypto to stary schemat, który otwiera każdy czytnik i któremu nic współczesnego nie ufa, a niektóre czytniki oddają zaszyfrowane bajty bez słowa. Zamknięte archiwum z tego narzędzia to dane testowe, a nie ochrona - klucz jest wyprowadzany z seeda przebiegu, więc ten sam przepis daje to samo archiwum na każdej maszynie, a hasło jest zapisane w manifeście obok niego." }, + "Detail.format/zip.entries": { "hash": "sha256-e8a3f919b300", "other": "Ile plików zawiera archiwum. Gdy pliki nie są jednakowe, użyj zamiast tego contains." }, + "Detail.format/zip.entry_format": { "hash": "sha256-f4dab5f30a79", "other": "Format plików w środku. Uruchom tfg formats, żeby zobaczyć, co obsługuje ta wersja." }, + "Detail.format/zip.entry_size": { "hash": "sha256-c3687f6606e8", "other": "Jak duży jest każdy plik w środku." }, + "Detail.format/zip.password": { "hash": "sha256-c3c77f362ce2", "other": "Hasło, którym zamknięto archiwum. Trafia do manifestu tak, jak je wpisano, bo test, który nie otworzy pliku, niczego nie sprawdzi." }, + "Detail.preset/empty-and-minimal.formats": { "hash": "sha256-945bbcfdb3cf", "other": "Z jakich formatów powstaje zestaw. Zostaw all, żeby dostać każdy format tej wersji, albo wymień te, które przyjmuje twój system." }, + "Detail.preset/filename-handling.format": { "hash": "sha256-30738a4e7806", "other": "Z jakiego rodzaju plików składa się cały zestaw." }, + "Detail.preset/size-boundaries.format": { "hash": "sha256-30738a4e7806", "other": "Z jakiego rodzaju plików składa się cały zestaw." }, + "Detail.preset/size-boundaries.limit": { "hash": "sha256-b43eca6d1365", "other": "Limit rozmiaru, który deklaruje twój system. Wszystko inne jest od niego odmierzane." }, + "Detail.preset/size-boundaries.spread": { "hash": "sha256-149b17db988d", "other": "Jak daleko po obu stronach limitu sięgnąć, jako lista rozmiarów." }, + "Detail.preset/tabular-import.columns": { "hash": "sha256-de43c523ae48", "other": "Ile kolumn ma każdy wiersz arkusza. Wiersze razy kolumny mają sufit, a prośba ponad niego jest odrzucana, zanim cokolwiek zostanie zapisane." }, + "Detail.preset/tabular-import.rows": { "hash": "sha256-46b383ddf143", "other": "Ile wierszy zawiera arkusz. Jest zapisywany dokładnie w rozmiarze, do jakiego pakuje się tyle wierszy, więc budżet powyżej zmienia się razem z tą wartością." }, + "Detail.preset/text-encoding.sample": { "hash": "sha256-dcd4f143702e", "other": "Jak duży jest każdy plik zestawu. UTF-16 zapisuje dwa bajty na każdy znak, więc nieparzysta liczba jest odrzucana." }, + "Detail.preset/upload-validation.allow": { "hash": "sha256-d8a6c0a7bd9f", "other": "Jakie typy twój formularz ma przyjmować. Każdy staje się prawdziwym plikiem tego typu i to one są kontrolą pozytywną całego zestawu." }, + "Detail.preset/upload-validation.bulk": { "hash": "sha256-4dd1c4efad8d", "other": "Ile plików zawiera przesyłanie masowe. Zero pomija tę grupę w zestawie." }, + "Detail.preset/upload-validation.deny": { "hash": "sha256-f9e5c633d218", "other": "Jakie rozszerzenia twój formularz ma odrzucać. Rozszerzenie, dla którego ta wersja nie ma formatu, i tak dostaje plik o tej nazwie, ze zwykłym tekstem w środku." }, + "Detail.preset/upload-validation.far-over": { "hash": "sha256-6a0093c6d7a0", "other": "Jak daleko ponad limit sięga jeden duży plik. Wyłącz tam, gdzie zapis kilkukrotności limitu nie jest wart miejsca na dysku." }, + "Detail.preset/upload-validation.limit": { "hash": "sha256-be929d76067d", "other": "Limit rozmiaru, który deklaruje twój formularz przesyłania. Ten zestaw robi jeden krok po obu stronach - żeby dostać plik w każdej odległości, uruchom preset size-boundaries." }, + "Group.format/pdf.Document properties": { "hash": "sha256-7115a778ab47", "other": "Właściwości dokumentu" }, + "Joint.format/avif.width.height": { "hash": "sha256-269a6c476761", "other": "koder trzyma cały obraz w pamięci podczas pracy" }, + "Joint.format/gif.width.height": { "hash": "sha256-0bed7aa664d8", "other": "obraz jest trzymany w pamięci podczas kodowania" }, + "Joint.format/jpg.width.height": { "hash": "sha256-0bed7aa664d8", "other": "obraz jest trzymany w pamięci podczas kodowania" }, + "Joint.format/jxl.width.height": { "hash": "sha256-269a6c476761", "other": "koder trzyma cały obraz w pamięci podczas pracy" }, + "Joint.format/png.width.height": { "hash": "sha256-0bed7aa664d8", "other": "obraz jest trzymany w pamięci podczas kodowania" }, + "Joint.format/xlsx.rows.columns": { "hash": "sha256-e1ef283f3c5d", "other": "arkusz powstaje w pamięci, zanim zostanie spakowany" }, + "Label.allow": { "hash": "sha256-e213c161d5ce", "other": "Dozwolone" }, + "Label.author": { "hash": "sha256-d95082a2ee57", "other": "Autor" }, + "Label.bit_depth": { "hash": "sha256-cc62f2e89a82", "other": "Głębia bitowa" }, + "Label.bom": { "hash": "sha256-e39338101951", "other": "BOM" }, + "Label.bulk": { "hash": "sha256-3140100df131", "other": "Przesyłanie masowe" }, + "Label.bytes": { "hash": "sha256-4d8000301fcc", "other": "Bajty" }, + "Label.channels": { "hash": "sha256-4c8906cf76f5", "other": "Kanały" }, + "Label.columns": { "hash": "sha256-53aade77cd69", "other": "Kolumny" }, + "Label.compression": { "hash": "sha256-278b1b2a9c84", "other": "Kompresja" }, + "Label.content": { "hash": "sha256-47bd29075f8b", "other": "Zawartość" }, + "Label.created": { "hash": "sha256-d70b9e24bca2", "other": "Utworzono" }, + "Label.creator": { "hash": "sha256-88447b83090c", "other": "Program tworzący" }, + "Label.delimiter": { "hash": "sha256-4b4eb3b88e85", "other": "Separator" }, + "Label.deny": { "hash": "sha256-05a2d7332eb9", "other": "Zabronione" }, + "Label.depth": { "hash": "sha256-f1dbc33978a9", "other": "Głębokość" }, + "Label.directory_entries": { "hash": "sha256-7f31f347692c", "other": "Wpisy katalogów" }, + "Label.embed": { "hash": "sha256-7512561d2f68", "other": "Obraz w środku" }, + "Label.encoding": { "hash": "sha256-4d25a47625ce", "other": "Kodowanie" }, + "Label.encryption": { "hash": "sha256-82f4e719a19c", "other": "Szyfrowanie" }, + "Label.entries": { "hash": "sha256-7cb76b4af12a", "other": "Liczba plików" }, + "Label.entry_format": { "hash": "sha256-63d61a75badf", "other": "Format plików w środku" }, + "Label.entry_mode": { "hash": "sha256-4a17440263ca", "other": "Uprawnienia plików" }, + "Label.entry_owner": { "hash": "sha256-d7bf94c5f409", "other": "Właściciel plików" }, + "Label.entry_size": { "hash": "sha256-1190635edb6f", "other": "Rozmiar plików w środku" }, + "Label.far-over": { "hash": "sha256-874367c30d4e", "other": "Daleko ponad limit" }, + "Label.format": { "hash": "sha256-2f343666aaa8", "other": "Format" }, + "Label.formats": { "hash": "sha256-9f01769a4278", "other": "Formaty" }, + "Label.formatting": { "hash": "sha256-29d4198e41d8", "other": "Układ" }, + "Label.frames": { "hash": "sha256-2c447e8d0598", "other": "Klatki" }, + "Label.header": { "hash": "sha256-ba5caa4285a8", "other": "Nagłówek" }, + "Label.height": { "hash": "sha256-bfe2eca40179", "other": "Wysokość" }, + "Label.ip_version": { "hash": "sha256-29016036fada", "other": "Wersja IP" }, + "Label.keywords": { "hash": "sha256-8bfdb93cafaa", "other": "Słowa kluczowe" }, + "Label.level_mix": { "hash": "sha256-cc4f6ea7c7fc", "other": "Poziomy ważności" }, + "Label.limit": { "hash": "sha256-674b0ed54bf7", "other": "Limit" }, + "Label.line_ending": { "hash": "sha256-c4f7d0b83542", "other": "Koniec linii" }, + "Label.methods": { "hash": "sha256-8696622f3441", "other": "Metody" }, + "Label.modified": { "hash": "sha256-e8ce5dcaf408", "other": "Zmodyfikowano" }, + "Label.orientation": { "hash": "sha256-e3d11242200f", "other": "Orientacja" }, + "Label.page_size": { "hash": "sha256-bd69e66e00ef", "other": "Rozmiar strony" }, + "Label.pages": { "hash": "sha256-9046da16aea9", "other": "Strony" }, + "Label.paragraphs": { "hash": "sha256-d2ef75e86bb7", "other": "Akapity" }, + "Label.password": { "hash": "sha256-e7cf3ef4f17c", "other": "Hasło" }, + "Label.pdf_version": { "hash": "sha256-3ce7c43387ea", "other": "Wersja PDF" }, + "Label.producer": { "hash": "sha256-98ee4021c6f1", "other": "Producent PDF" }, + "Label.quality": { "hash": "sha256-1b2c08a8733d", "other": "Jakość" }, + "Label.quote_style": { "hash": "sha256-8c8c9f41e450", "other": "Cudzysłowy" }, + "Label.rate": { "hash": "sha256-45b63ffd01af", "other": "Tempo" }, + "Label.rotate": { "hash": "sha256-c3613b1704f5", "other": "Obrót" }, + "Label.rows": { "hash": "sha256-101f2ff3de22", "other": "Wiersze" }, + "Label.sample": { "hash": "sha256-95a2f1edb08a", "other": "Rozmiar próbki" }, + "Label.sample_rate": { "hash": "sha256-218e0d43275e", "other": "Częstotliwość próbkowania" }, + "Label.slides": { "hash": "sha256-a0ffe3818444", "other": "Slajdy" }, + "Label.spread": { "hash": "sha256-b11fb1f593a8", "other": "Rozrzut" }, + "Label.status_mix": { "hash": "sha256-e5e3006b5ecd", "other": "Kody odpowiedzi" }, + "Label.structure": { "hash": "sha256-7203c2b3dad3", "other": "Struktura" }, + "Label.subject": { "hash": "sha256-68971283841a", "other": "Temat" }, + "Label.timestamps": { "hash": "sha256-755f099dbf49", "other": "Znaczniki czasu" }, + "Label.title": { "hash": "sha256-7e8cd2056da7", "other": "Tytuł" }, + "Label.width": { "hash": "sha256-3019947b6daf", "other": "Szerokość" }, + "Note.size-boundaries.limit": { "hash": "sha256-d65173d5db01", "other": "nie podano limitu, więc ten zestaw jest zbudowany wokół 10mb - to nasza wartość zastępcza, a nie limit twojego systemu. Podaj limit, który deklaruje twój system, inaczej pliki nic o nim nie mówią." }, + "Note.upload-validation.limit": { "hash": "sha256-c30ac7e13539", "other": "nie podano limitu, więc ten zestaw jest zbudowany wokół 10mb - to nasza wartość zastępcza, a nie limit twojego formularza. Podaj limit, który deklaruje twój formularz, inaczej pliki nic o nim nie mówią." }, + "Question.empty-and-minimal": { "hash": "sha256-9a69d7227e12", "other": "Czy plik poprawny i najmniejszy, na jaki format pozwala, przechodzi?" }, + "Question.filename-handling": { "hash": "sha256-e558318318a2", "other": "Czy mój system zapisze, pokaże i odda nazwę pliku, której się nie spodziewał?" }, + "Question.size-boundaries": { "hash": "sha256-e26666b8f7a6", "other": "Czy limit rozmiaru działa dokładnie tam, gdzie jest zadeklarowany?" }, + "Question.tabular-import": { "hash": "sha256-f2132eb34797", "other": "Czy import tabeli poradzi sobie z tym, co eksportują prawdziwe narzędzia?" }, + "Question.text-encoding": { "hash": "sha256-766ce93a4d3c", "other": "Czy mój czytnik wie, w jakim kodowaniu jest plik, czy zgaduje?" }, + "Question.upload-validation": { "hash": "sha256-950469ca4b3a", "other": "Czy mój formularz przesyłania plików przyjmuje to, co powinien, i odrzuca resztę?" }, + "Shape.a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": { "hash": "sha256-a40bd1decdc9", "other": "data, np. 2024-02-29 albo 2024-02-29T13:45:00+02:00, albo none" }, + "Shape.any text": { "hash": "sha256-06c83efcd22e", "other": "dowolny tekst" }, + "Shape.extensions separated by commas": { "hash": "sha256-69f0be0e37e3", "other": "rozszerzenia rozdzielone przecinkami" }, + "Shape.format ids separated by commas": { "hash": "sha256-cc1ca5dd954c", "other": "identyfikatory formatów rozdzielone przecinkami" }, + "Shape.format ids separated by commas, or all": { "hash": "sha256-48e97c935e5d", "other": "identyfikatory formatów rozdzielone przecinkami albo all" }, + "Shape.sizes separated by commas": { "hash": "sha256-f7e5e8c54e6f", "other": "rozmiary rozdzielone przecinkami" }, + "Shape.the id of a format, as tfg formats lists them": { "hash": "sha256-b6784882fdeb", "other": "identyfikator formatu, tak jak wypisuje go tfg formats" }, + "Shape.the password, in plain text": { "hash": "sha256-eb266034b6a8", "other": "hasło, zwykłym tekstem" }, + "Unit.bytes": { "hash": "sha256-277089d91c0b", "other": "bajtów" }, + "Unit.columns": { "hash": "sha256-0e624a1a135e", "other": "kolumn" }, + "Unit.entries per second": { "hash": "sha256-90ad5144c654", "other": "wpisów na sekundę" }, + "Unit.files": { "hash": "sha256-3d7db37d08f9", "other": "plików" }, + "Unit.hertz": { "hash": "sha256-e333c47e4b5c", "other": "herców" }, + "Unit.megapixels": { "hash": "sha256-2bb37d7da74d", "other": "megapikseli" }, + "Unit.million cells": { "hash": "sha256-95a765354101", "other": "milionów komórek" }, + "Unit.paragraphs": { "hash": "sha256-ec575de14f57", "other": "akapitów" }, + "Unit.pixels": { "hash": "sha256-6ec9c2b0eb14", "other": "pikseli" }, + "Unit.rows": { "hash": "sha256-bc51e9e65d79", "other": "wierszy" }, + "Unit.slides": { "hash": "sha256-fd2c8f1dc03c", "other": "slajdów" } +} diff --git a/internal/gui/text/registry.go b/internal/gui/text/registry.go new file mode 100644 index 00000000..0212d04b --- /dev/null +++ b/internal/gui/text/registry.go @@ -0,0 +1,129 @@ +package text + +import ( + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +// The words the engine's registries hold, in the language the window speaks - +// docs/JEZYKI-REJESTRY-2026-09-29.md. +// +// A format, a preset and a damage declare their settings with a sentence for a +// person, and the command line prints those sentences as they are: tfg formats +// --json and tfg preset show --json go to other people's scripts, so they stay +// English (D9). The window looks each one up under a key made of identifiers, +// and answers with the registry's English when its language has no entry - a +// sentence missing from a catalogue is an English sentence on screen, never an +// empty one, and a guard keeps every language this build carries complete. +// +// A folder of their own rather than entries in the window's catalogue, for two +// reasons read off the guards: en.json is written out of this package's code +// by tools/gen-locale.py and held to it, and every entry of another language +// has to be one en.json has. A registry sentence is neither - its English is +// in the registry, and registry/en.json is written out of the registries by the +// guard that holds it to them. +// +// Every entry in another language carries the hash of the English it +// translates, in the field go-i18n reserves for that and never shows. A key +// alone cannot tell that the English under it changed, and a Polish sentence +// describing last month's default is worse than an English one, because it +// looks translated. + +// RegistryFolder is where the words of the registries are kept, inside the +// folder of the window's catalogue. +const RegistryFolder = "registry" + +// Owner is whose declaration a setting is, which is half of the key its words +// are found under. Two formats both declare a width, and each says what it is +// for in a sentence of its own. +type Owner string + +// FormatOwner, PresetOwner and DamageOwner are the three kinds of declaration. +// +// The global settings a preset reads - the format of the whole set - are the +// preset's here, because every screen draws them in one list with its +// parameters. The same English under two presets is one Polish sentence twice, +// and a guard holds the two to agreeing. +func FormatOwner(id string) Owner { return Owner("format/" + id) } +func PresetOwner(id string) Owner { return Owner("preset/" + id) } +func DamageOwner(id string) Owner { return Owner("damage/" + id) } + +// The keys, built here and nowhere else - exported for the guard that writes +// registry/en.json out of the registries, so the window and the guard cannot +// come to ask for two different things. +func LabelKey(name string) string { return "Label." + name } +func DetailKey(o Owner, name string) string { return "Detail." + string(o) + "." + name } +func GroupKey(o Owner, group string) string { return "Group." + string(o) + "." + group } +func UnitKey(unit string) string { return "Unit." + unit } +func ShapeKey(shape string) string { return "Shape." + shape } +func JointKey(o Owner, of, by string) string { return "Joint." + string(o) + "." + of + "." + by } +func QuestionKey(preset string) string { return "Question." + preset } +func CatchKey(preset string, n int) string { return "Catch." + preset + "." + strconv.Itoa(n) } +func NoteKey(preset, about string) string { return "Note." + preset + "." + about } + +// lookup is say for a sentence whose key is made of identifiers and whose +// English comes from a registry rather than from this package. Nothing to say +// stays nothing: a setting declared without a sentence has no entry to find. +func lookup(key, english string) string { + if english == "" { + return "" + } + return localise(key, english) +} + +// SettingDetail is the sentence a declaration gives one of its settings. +func SettingDetail(o Owner, name, english string) string { + return lookup(DetailKey(o, name), english) +} + +// SettingGroup is the name of a block of settings - see format.Property.Group. +func SettingGroup(o Owner, group string) string { return lookup(GroupKey(o, group), group) } + +// Unit is what a number counts, as a declaration names it: "pixels", "rows". +func Unit(unit string) string { return lookup(UnitKey(unit), unit) } + +// Shape is what free text has to look like, as a declaration words it. +func Shape(shape string) string { return lookup(ShapeKey(shape), shape) } + +// JointReason is why two settings multiplied together have a ceiling. +func JointReason(o Owner, of, by, english string) string { + return lookup(JointKey(o, of, by), english) +} + +// PresetQuestion is the question a preset answers. +func PresetQuestion(preset, english string) string { return lookup(QuestionKey(preset), english) } + +// PresetCatches is what a preset typically finds, one line each, counted from +// one in the order the preset lists them. +func PresetCatches(preset string, english []string) []string { + out := make([]string, len(english)) + for i, line := range english { + out[i] = lookup(CatchKey(preset, i+1), line) + } + return out +} + +// PresetNote is what a run says about a value of the preset nobody gave. +// +// A note about no value is one a preset put together from the values it was +// given, so there is no fixed sentence to have translated. It is said as it +// came, and the pseudo language leaves it undisguised on purpose: it IS English +// in every window, and that is what the pseudo language is for showing. +func PresetNote(preset, about, english string) string { + if about == "" { + return english + } + return lookup(NoteKey(preset, about), english) +} + +// HumanBytes is core.HumanBytes with the decimal mark of the window's language. +// +// The pseudo language keeps the mark the number was written with: it disguises +// words, and a number is a value. +func HumanBytes(n int64) string { + if pseudo { + return core.HumanBytes(n) + } + return core.HumanBytesIn(n, DecimalMark()) +} diff --git a/internal/gui/text/registrywords.go b/internal/gui/text/registrywords.go new file mode 100644 index 00000000..5f8231f9 --- /dev/null +++ b/internal/gui/text/registrywords.go @@ -0,0 +1,99 @@ +package text + +import ( + "sort" + + "github.com/donislawdev/TestingFilesGenerator/internal/damage" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + "github.com/donislawdev/TestingFilesGenerator/internal/preset" +) + +// RegistryWord is one sentence of the registries the window shows, under the +// key it is looked up by, with where it stands - which a translator reading +// the bare sentence could not tell. +type RegistryWord struct { + Key string + English string + Where string +} + +// RegistryWords is every sentence of the registries the window shows, sorted +// by key - the list registry/en.json is written from and every other language +// is held to. +// +// Walked from the registries rather than written down, so a format added +// tomorrow brings its sentences with it, and a language without them turns a +// guard red instead of an English sentence arriving in a Polish window. Asked +// by the guards and never by the window, which looks each sentence up where it +// draws it - under the same keys, built by the same functions. +func RegistryWords() []RegistryWord { + w := registryWords{} + for _, d := range format.All() { + w.format(d) + } + for _, p := range preset.All() { + w.preset(p) + } + for _, d := range damage.All() { + w.settings(DamageOwner(d.ID), "the "+d.ID+" damage", d.Parameters) + } + return w.sorted() +} + +// registryWords collects by key. A key reached twice - a width declared by +// four formats has one label - holds the sentence it was first reached with. +type registryWords map[string]RegistryWord + +func (w registryWords) add(key, english, where string) { + if _, have := w[key]; english == "" || have { + return + } + w[key] = RegistryWord{Key: key, English: english, Where: where} +} + +func (w registryWords) format(d format.Descriptor) { + owner := FormatOwner(d.ID) + w.settings(owner, "the "+d.ID+" format", d.Properties) + for _, j := range d.JointLimits { + w.add(JointKey(owner, j.Of, j.By), j.Why, "Why "+j.Of+" times "+j.By+" of the "+d.ID+ + " format have a ceiling, at the end of the note under the two, after the word because.") + w.unit(j.Unit) + } +} + +func (w registryWords) preset(p preset.Preset) { + whose := "the " + p.ID + " preset" + declared := append(append([]format.Property{}, p.Parameters...), p.Globals()...) + w.settings(PresetOwner(p.ID), whose, declared) + w.add(QuestionKey(p.ID), p.Question, "The question "+whose+" answers, at the top of the preset screen.") + for i, line := range p.Catches { + w.add(CatchKey(p.ID, i+1), line, "One line of what "+whose+" typically finds, on the preset screen.") + } + for name, said := range p.SaidWhenDefaulted { + w.add(NoteKey(p.ID, name), said, "What a run of "+whose+" says when nobody gave its "+name+".") + } +} + +func (w registryWords) settings(owner Owner, whose string, declared []format.Property) { + for _, p := range declared { + w.add(LabelKey(p.Name), EnglishLabel(p.Name), "The name beside the box of a setting a recipe writes as "+p.Name+".") + w.add(DetailKey(owner, p.Name), p.Detail, "The sentence under the "+p.Name+" setting of "+whose+", after what it takes.") + w.add(GroupKey(owner, p.Group), p.Group, "The heading over a block of settings of "+whose+".") + w.add(ShapeKey(p.Shape), p.Shape, "What the text in a box has to look like, in the sentence under it.") + w.unit(p.Unit) + } +} + +func (w registryWords) unit(unit string) { + w.add(UnitKey(unit), unit, "What a number counts, after it, as in: from 1 to 20000 "+unit+ + ". Written the way the language puts a noun after a number.") +} + +func (w registryWords) sorted() []RegistryWord { + out := make([]RegistryWord, 0, len(w)) + for _, word := range w { + out = append(out, word) + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 3a3b0199..46822e65 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -381,7 +381,16 @@ func SettingsFor(formatID string) string { // It changes only what is drawn. The key is what a refusal is matched against // and what goes into a recipe, and neither of those goes through here - see // SettingKey for where the key stays visible. -func SettingLabel(key string) string { +// +// Looked up by the key since 2026-09-29, because another language cannot be +// spelled out of an English key. The English is still spelled out of it - the +// registry declares no label - so an English window reads as it did. +func SettingLabel(key string) string { return lookup(LabelKey(key), EnglishLabel(key)) } + +// EnglishLabel is the English SettingLabel falls back to, spelled out of the +// key - bit_depth reads "Bit depth". Exported for the guard that writes the +// words of the registries out for a translator. +func EnglishLabel(key string) string { if key == "" { return key } @@ -389,6 +398,52 @@ func SettingLabel(key string) string { return strings.ToUpper(spaced[:1]) + spaced[1:] } +// What a setting takes, in the window's language - format.Property.Allowed put +// together from these rather than read whole, because that sentence is English +// and the command line prints it (D9). The values in it - true, 2mb, the +// default, the numbers - are what a recipe writes, and stay as they are. +// +// A guard puts every declared setting through both and asks for the same +// English to the letter, so the window and tfg formats cannot come to describe +// one setting in two ways (D1). +func AllowedWholeNumber() string { return say("AllowedWholeNumber", "whole number") } +func AllowedWholeNumberOf(unit string) string { + return sayf("AllowedWholeNumberOf", "whole number of {{.Unit}}", map[string]any{"Unit": unit}) +} +func AllowedRange(least, most int64) string { + return sayf("AllowedRange", "whole number from {{.Min}} to {{.Max}}", + map[string]any{"Min": least, "Max": most}) +} +func AllowedRangeOf(unit string, least, most int64) string { + return sayf("AllowedRangeOf", "whole number of {{.Unit}} from {{.Min}} to {{.Max}}", + map[string]any{"Unit": unit, "Min": least, "Max": most}) +} +func AllowedOneOf(values []string) string { + return sayf("AllowedOneOf", "one of: {{.Values}}", map[string]any{"Values": strings.Join(values, ", ")}) +} +func AllowedTrueOrFalse() string { return say("AllowedTrueOrFalse", "true or false") } +func AllowedSize() string { + return say("AllowedSize", "a size such as 2mb, or a plain byte count") +} +func AllowedDefault(value string) string { + return sayf("AllowedDefault", "default {{.Default}}", map[string]any{"Default": value}) +} +func AllowedWithDefault(what, value string) string { + return sayf("AllowedWithDefault", "{{.What}}, default {{.Default}}", + map[string]any{"What": what, "Default": value}) +} + +// AllowedJointly is format.JointLimit.Describe in the window's language. Of and +// By are the keys a recipe writes, as they are in English. +func AllowedJointly(of, by string, most int64, unit, why string) string { + return sayf("AllowedJointly", "{{.Of}} times {{.By}} cannot pass {{.Max}} {{.Unit}}, because {{.Why}}", + map[string]any{"Of": of, "By": by, "Max": most, "Unit": unit, "Why": why}) +} + +// DecimalMark is what the window's language writes between the whole part of +// a number and its tenths - see HumanBytes. +func DecimalMark() string { return say("DecimalMark", ".") } + // SettingKey says what to write in a recipe for the field being looked at. // // The other half of SettingLabel, and the reason the change is not a loss. diff --git a/internal/gui/window/generate.go b/internal/gui/window/generate.go index 61b78c89..080f5865 100644 --- a/internal/gui/window/generate.go +++ b/internal/gui/window/generate.go @@ -532,7 +532,7 @@ func (g *Generate) rebuildDamageFields() { return } - fields, objects := parts.DeclaredFields(d.Parameters, g.fields, g.tips) + fields, objects := parts.DeclaredFields(text.DamageOwner(d.ID), d.Parameters, g.fields, g.tips) g.damage.props = fields if len(objects) == 0 { g.damage.box.Refresh() diff --git a/internal/gui/window/preset.go b/internal/gui/window/preset.go index 8b1e4b6b..8e352067 100644 --- a/internal/gui/window/preset.go +++ b/internal/gui/window/preset.go @@ -181,7 +181,7 @@ func (p *Preset) onPresetChosen(id string) { // The question first, because it is the thing somebody chooses by. A list // of ids says nothing about which one answers what they came to ask. - p.about.Add(parts.Prose(chosen.Question)) + p.about.Add(parts.Prose(text.PresetQuestion(chosen.ID, chosen.Question))) // One line each rather than one sentence. Run together they are unreadable, // because the entries have commas inside them - "MB confused with MiB, @@ -196,7 +196,7 @@ func (p *Preset) onPresetChosen(id string) { // which left more room between the items than around the whole list. if len(chosen.Catches) > 0 { p.about.Add(parts.Subheading(text.PresetCatchesHeading())) - p.about.Add(parts.Bullets(chosen.Catches)) + p.about.Add(parts.Bullets(text.PresetCatches(chosen.ID, chosen.Catches))) } p.about.Refresh() @@ -224,7 +224,7 @@ func (p *Preset) onPresetChosen(id string) { // registers declares a single narrow parameter and there is no pair to // make. What it buys is the next thing, arriving on three screens instead // of two. - fields, objects := parts.DeclaredFields(settings, p.fields, p.tips) + fields, objects := parts.DeclaredFields(text.PresetOwner(chosen.ID), settings, p.fields, p.tips) p.params = fields for _, o := range objects { p.paramBox.Add(o) @@ -374,12 +374,27 @@ func (l *lastExpansion) of(h Host, id string, given preset.Args) (*preset.Expans got, err := preset.Expand(id, given) var notes []string if err == nil { - notes = got.Notes() + notes = notesIn(got) } *l = lastExpansion{held: true, id: id, given: maps.Clone(given), got: got, notes: notes, err: err} return got, notes, err } +// notesIn is what a run says about a set, in the window's language wherever +// the sentence is a fixed one - see preset.Note. Both screens that expand a +// preset ask this, so the two cannot say one set's notes in two languages. +func notesIn(e *preset.Expansion) []string { + if e == nil { + return nil + } + spoken := e.Spoken() + out := make([]string, 0, len(spoken)) + for _, n := range spoken { + out = append(out, text.PresetNote(e.Preset.ID, n.About, n.Said)) + } + return out +} + // engineTarget turns one recipe target into one engine target. // // The command line has the same conversion and the two cannot be shared - they diff --git a/internal/gui/window/recipe.go b/internal/gui/window/recipe.go index 5714d7ba..2e298732 100644 --- a/internal/gui/window/recipe.go +++ b/internal/gui/window/recipe.go @@ -711,7 +711,7 @@ func (r *Recipe) settle() ([]engine.Target, engine.Options, error) { } // What the run has to say out loud about a preset's value nobody gave. - r.notes = read.Notes() + r.notes = notesIn(read.Expansion) targets := make([]engine.Target, 0, len(rec.Targets)) for _, t := range rec.Targets { diff --git a/internal/gui/window/recipebase.go b/internal/gui/window/recipebase.go index 729bd260..8fa15748 100644 --- a/internal/gui/window/recipebase.go +++ b/internal/gui/window/recipebase.go @@ -41,6 +41,9 @@ type base struct { // typed into it. declared []format.Property params []parts.PropertyField + // owner is whose declaration they are, for the sentences under them in + // another language - replaced with them. + owner text.Owner } // newBase builds the controls, without placing them. The switch starts off, @@ -96,6 +99,7 @@ func (b *base) choose(id string) error { settings = append(settings, chosen.Parameters...) settings = append(settings, chosen.Globals()...) b.declared = settings + b.owner = text.PresetOwner(chosen.ID) b.params = make([]parts.PropertyField, 0, len(settings)) for _, p := range settings { b.params = append(b.params, parts.FromProperty(p)) @@ -138,7 +142,7 @@ func (b *base) parameterRows(fields *parts.Fields, tips *parts.Tips) []fyne.Canv fields.InBytes(recipe.KeyWith + "." + f.Name) } rows = append(rows, fields.Add(recipe.KeyWith+"."+f.Name, text.SettingLabel(f.Name), - parts.PropertyDetail(d), tips.Say(text.SettingKey(f.Name)), parts.ShapedFor(d, f.Control))) + parts.PropertyDetail(b.owner, d), tips.Say(text.SettingKey(f.Name)), parts.ShapedFor(d, f.Control))) } return rows } diff --git a/internal/gui/window/recipefolds.go b/internal/gui/window/recipefolds.go index f3ef8dde..eda89d54 100644 --- a/internal/gui/window/recipefolds.go +++ b/internal/gui/window/recipefolds.go @@ -50,20 +50,21 @@ func (r *Recipe) declaredSettings(b *batch, at func(string) string) fyne.CanvasO } var out []fyne.CanvasObject + owner := text.FormatOwner(b.formatPick.Selected) for i, f := range b.props { // Shaped here as well as on the single batch screen. The two draw the // same declarations through different code, and only one of them was // given the width on the first try - which is how a difference between // two surfaces starts. out = append(out, r.fields.Add(at(recipe.KeyProperties+"."+f.Name), text.SettingLabel(f.Name), - parts.PropertyDetail(b.declared[i]), r.tips.Say(text.SettingKey(f.Name)), + parts.PropertyDetail(owner, b.declared[i]), r.tips.Say(text.SettingKey(f.Name)), parts.ShapedFor(b.declared[i], f.Control))) } // A rule binding two settings belongs beside them. Two number boxes drawn // from a range alone would offer a pair the run then refuses. if d, err := format.Get(b.formatPick.Selected); err == nil { for _, j := range d.JointLimits { - out = append(out, parts.Note(j.Describe())) + out = append(out, parts.Note(parts.JointLimit(owner, j))) } } diff --git a/internal/gui/window/runline.go b/internal/gui/window/runline.go index b5f41cf4..a23cb39f 100644 --- a/internal/gui/window/runline.go +++ b/internal/gui/window/runline.go @@ -3,7 +3,6 @@ package window import ( "strings" - "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/gui/text" ) @@ -55,7 +54,7 @@ func (l *runLine) fallback(dir string) string { func (l *runLine) where(dir string) string { dir = strings.TrimSpace(dir) if dir != "" && l.freeKnown && dir == l.freeIn { - return text.DirectoryWithFreeSpace(dir, core.HumanBytes(l.free)) + return text.DirectoryWithFreeSpace(dir, text.HumanBytes(l.free)) } return dir } diff --git a/internal/gui/window/runreport.go b/internal/gui/window/runreport.go index a3eb83e7..bc76cc07 100644 --- a/internal/gui/window/runreport.go +++ b/internal/gui/window/runreport.go @@ -46,14 +46,14 @@ func manifestReachNote(res *engine.Result) []string { return nil } return []string{text.ManifestTooLargeToRead( - core.HumanBytes(size), core.HumanBytes(manifest.MaxBytes))} + text.HumanBytes(size), text.HumanBytes(manifest.MaxBytes))} } // progressText is the line under the bar. Bytes rather than files, because one // large file is a run where the file count says nothing for minutes. func progressText(p engine.Progress, elapsed time.Duration) string { line := text.Progress(p.FilesDone, p.FilesTotal, - core.HumanBytes(p.BytesDone), core.HumanBytes(p.BytesTotal), + text.HumanBytes(p.BytesDone), text.HumanBytes(p.BytesTotal), core.Percent(p.BytesDone, p.BytesTotal)) // The estimate stays quiet until it has enough to go on. A number that diff --git a/internal/gui/window/runsummary.go b/internal/gui/window/runsummary.go index 8788edb0..6ee72b2f 100644 --- a/internal/gui/window/runsummary.go +++ b/internal/gui/window/runsummary.go @@ -140,12 +140,12 @@ func (s summary) totalText() string { return "" case drawn: if s.least != s.most { - return text.SizeBetween(core.HumanBytes(s.least), core.HumanBytes(s.most)) + return text.SizeBetween(text.HumanBytes(s.least), text.HumanBytes(s.most)) } case stated: // Every size is on the form, so the exact total below is the answer. } - return text.SizeAndBytes(core.HumanBytes(s.least), core.ExactBytes(s.least)) + return text.SizeAndBytes(text.HumanBytes(s.least), core.ExactBytes(s.least)) } // exactly is the summary of a plan: the sizes are drawn by now, so the two diff --git a/internal/preset/expansion.go b/internal/preset/expansion.go index 7be59795..cd2417bf 100644 --- a/internal/preset/expansion.go +++ b/internal/preset/expansion.go @@ -55,16 +55,38 @@ func Expand(id string, given Args) (*Expansion, error) { // A set built around a limit we invented carries expectations that read exactly // like a set built around the real one, so the run says which number it made up. func (e *Expansion) Notes() []string { - var out []string + spoken := e.Spoken() + out := make([]string, 0, len(spoken)) + for _, n := range spoken { + out = append(out, n.Said) + } + return out +} + +// Note is one thing a run says about a set, and the parameter it is about when +// it is about a value nobody gave - which is what lets a window find the +// sentence in its own language. About is empty for what a preset says about +// the set as a whole: those sentences are put together from the values, so +// there is nothing fixed to translate them from. +type Note struct { + About string + Said string +} + +// Spoken is Notes with what each one is about, in the same order. +func (e *Expansion) Spoken() []Note { + var out []Note for _, name := range e.Defaulted { if said := e.Preset.SaidWhenDefaulted[name]; said != "" { - out = append(out, said) + out = append(out, Note{About: name, Said: said}) } } // What those values then laid out, after what we invented, because a // sentence about the set reads as the consequence of the numbers above it. if e.Preset.Says != nil { - out = append(out, e.Preset.Says(e.Settled)...) + for _, said := range e.Preset.Says(e.Settled) { + out = append(out, Note{Said: said}) + } } return out } From 3af3ba4eb1467dcc24b093e044f445973332879b Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 29 Sep 2026 23:32:19 +0200 Subject: [PATCH 2/2] window: what a text setting takes is the default case, as on the command line The linter's exhaustive check refused the switch that says what a setting takes, because a text setting was handled after it rather than in it. Property.Allowed answers a text setting in its default case, and the window's wording of the same sentence now has the same shape. The guard holding a preset's Polish words to the site's skips a language only when the site has no file for it. Any other failure to read that file is a failure, where it used to pass without comparing anything. The comment on PropertyFields had two paragraphs run together, so "It registers each one" read as if the guard did. The changelog entry for the language is wrapped to the width of the lines around it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++++------ internal/guard/registrywords_test.go | 9 ++++++++- internal/gui/parts/property.go | 12 ++++++++---- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dcf6197..fa71bf9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,12 +25,12 @@ because it turns other people's test suites red. the settings of every format, the presets and the sizes of a run are Polish as well, so a size reads 10,0 MB. The values you pick from a list (portrait, a4), the names of the formats and the reason a run is refused - stay in English, and so do the command line, recipes and manifests. The same tab - says what the window keeps between runs (the output directory, the window - size and the language), shows the folder they are kept in, and Forget - clears them. `tfg-gui --pseudo-language` opens the window with every - sentence longer and accented, to see how a translation will fit before - one exists. It is never saved as a choice. + stay in English, and so do the command line, recipes and manifests. The + same tab says what the window keeps between runs (the output directory, + the window size and the language), shows the folder they are kept in, and + Forget clears them. `tfg-gui --pseudo-language` opens the window with + every sentence longer and accented, to see how a translation will fit + before one exists. It is never saved as a choice. - **A Windows installer, `tfg-setup__windows_amd64.msi`, beside the zip archives.** It installs the window and the command line for every account on the machine, in `Program Files\Testing Files Generator`, puts diff --git a/internal/guard/registrywords_test.go b/internal/guard/registrywords_test.go index e1e3f1a1..6c7385f2 100644 --- a/internal/guard/registrywords_test.go +++ b/internal/guard/registrywords_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "os" "os/exec" "path/filepath" @@ -261,10 +262,16 @@ func TestThePresetsSayInTheWindowWhatTheySayOnTheSite(t *testing.T) { if tag == text.English { continue } + // A window language the site does not have is nothing to compare. Any + // other failure to read is a comparison lost without a word, which + // the count below only notices when EVERY language was lost. raw, err := os.ReadFile(filepath.Join(repoRoot(t), "web", "content", tag, "site.json")) - if err != nil { + if errors.Is(err, os.ErrNotExist) { continue } + if err != nil { + t.Fatalf("the %s site file could not be read: %v", tag, err) + } var site struct { Presets map[string]struct { Question string `json:"question"` diff --git a/internal/gui/parts/property.go b/internal/gui/parts/property.go index 1a26b35e..18be1f3b 100644 --- a/internal/gui/parts/property.go +++ b/internal/gui/parts/property.go @@ -189,8 +189,9 @@ func leftAlone(p format.Property) string { // together in the window's language by Allowed. Two surfaces describing one // format in two ways is D1 breaking in the place nobody thinks to compare, so a // guard holds the English of the two to one sentence. -// It registers each one with the screen, so a setting a format declares can be -// told it was the one refused. Until 2026-08-12 these were the only fields on +// +// Each field is registered with the screen, so a setting a format declares can +// be told it was the one refused. Until 2026-08-12 these were the only fields on // either screen that could not: they were built with the plain Field function, // which had nowhere to put a refusal, so "width must be between 1 and 20000" // appeared at the foot of the form with nothing marked. Thirteen formats @@ -393,9 +394,12 @@ func allowedKind(p format.Property) string { return text.AllowedTrueOrFalse() case format.PropertySize: return text.AllowedSize() + default: + // A text setting describes itself with its shape or not at all - the + // same default as Property.Allowed, so a kind added tomorrow reads the + // same on both surfaces until somebody gives it words. + return text.Shape(p.Shape) } - // A text setting describes itself with its shape or not at all. - return text.Shape(p.Shape) } // allowedNumber is a whole number, with its range and what it counts when the