diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ece34cc..41442729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,28 @@ because it turns other people's test suites red. removes the folder entry from `PATH` and leaves any file in the folder that the installer did not put there. A release candidate gets no installer. +- **Eleven new PDF settings.** `orientation` lays pages upright, wide, or + both in turn. `page_size=mixed` goes through a4, letter, legal, a3 and a5, + one a page. `rotate` asks the reader to turn every page by 90, 180 or 270 + degrees while the page itself stays upright, so a reader that ignores the + request shows something different from one that follows it. + `pdf_version` writes 1.4 instead of 1.7 at the start of the file. And the + document properties a reader shows: `title`, `author`, `subject`, + `keywords`, `creator`, `producer`, and the dates `created` and `modified`, + written as `2024-02-29` or `2024-02-29T13:45:00+02:00`, or `none` to leave + the date out. Text outside ASCII, such as Polish letters, reads back + unchanged. The manifest says what each file carries. A file made without + these settings is byte for byte the file this version made before. +- **The window groups the settings of a format** when there are enough of + them to need it. PDF shows its document properties under a heading of + their own, and `tfg formats pdf` prints the same heading in the same place. + +### Changed + +- **An empty text box in the window reads "not set"** instead of "worked out + from the size" when leaving it empty means going without - the password + of an archive, the author of a PDF. What that means for the file is in the + sentence beside the box. ### Fixed diff --git a/README.md b/README.md index 9228e86c..82141bfb 100644 --- a/README.md +++ b/README.md @@ -575,7 +575,7 @@ recipe. `tfg formats ` prints the allowed range or list for each: | format | settings | |---|---| -| `pdf` | `pages`, `page_size` | +| `pdf` | `pages`, `page_size`, `orientation`, `rotate`, `pdf_version`, and the document properties `title`, `author`, `subject`, `keywords`, `creator`, `producer`, `created`, `modified` | | `png`, `bmp`, `tiff`, `webp` | `width`, `height` | | `gif` | `width`, `height`, `frames` | | `avif`, `jpg`, `jxl` | `width`, `height`, `quality` | diff --git a/internal/cli/formats.go b/internal/cli/formats.go index 7c7dd048..09ca3f59 100644 --- a/internal/cli/formats.go +++ b/internal/cli/formats.go @@ -64,6 +64,7 @@ type propertyEntry struct { Choices []string `json:"choices,omitempty"` Default string `json:"default,omitempty"` Detail string `json:"detail,omitempty"` + Group string `json:"group,omitempty"` } // smallestAccepted is the number this command prints as the minimum. @@ -96,6 +97,7 @@ func entryFor(d format.Descriptor) formatEntry { props = append(props, propertyEntry{ Name: p.Name, Kind: string(p.Kind), Min: p.Min, Max: p.Max, Unit: p.Unit, Choices: p.Choices, Default: p.Default, Detail: p.Detail, + Group: p.Group, }) } return formatEntry{ @@ -128,7 +130,12 @@ func describeOne(d format.Descriptor, out io.Writer) { return } fmt.Fprint(out, "\nproperties, set with --set name=value:\n") - for _, p := range d.Properties { + for i, p := range d.Properties { + // A block of settings opens with its name, the way the window draws + // it. Register has already refused a block declared in two places. + if p.Group != "" && (i == 0 || d.Properties[i-1].Group != p.Group) { + fmt.Fprintf(out, "\n %s:\n", p.Group) + } fmt.Fprintf(out, " %-14s %s\n", p.Name, p.Allowed()) if p.Detail != "" { fmt.Fprintf(out, " %-14s %s\n", "", p.Detail) diff --git a/internal/format/format.go b/internal/format/format.go index 6c22d54b..d906d636 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -167,6 +167,21 @@ type Property struct { // and what a window shows beside the field. Detail string + // Group names the block of settings this one opens or belongs to, for a + // format that declares enough of them to need blocks. Empty for the + // settings that come first and belong to no block. + // + // It exists because PDF went from two settings to twelve in one step, and + // eight of them describe the document rather than its pages - a title, an + // author, two dates. One column of twelve names read as a list nobody + // sorted. The window draws the name as a heading above the first setting + // of the block and tfg formats prints it the same way. + // + // A block is declared as consecutive settings. Register refuses a group + // that comes back after another one, because both surfaces draw a heading + // where a block starts and a split block would get two. + Group string + // Secret marks a value that is a credential rather than a description of // the file, and there is exactly one of them today: the password an // archive is locked with. diff --git a/internal/format/pdf/document.go b/internal/format/pdf/document.go index 45f7dd89..67682bf9 100644 --- a/internal/format/pdf/document.go +++ b/internal/format/pdf/document.go @@ -15,15 +15,20 @@ import ( // the first part and therefore does not move. func document(m memo) (prefix, suffix []byte) { var body bytes.Buffer - body.WriteString("%PDF-1.7\n") + // The version is the one asked for and nothing else follows from it: the + // file uses nothing newer than 1.4, so every version offered here is true + // of it. 2.0 is not offered, because it requires things this file does not + // have yet - a file identifier and the widths of the font's characters. + body.WriteString("%PDF-" + m.opts.version + "\n") // A comment of high bytes tells any tool handling the file that it is // binary, which stops a transfer from mangling the line endings. body.Write([]byte{'%', 0xe2, 0xe3, 0xcf, 0xd3, '\n'}) var objects []string - kids := make([]string, 0, m.pages) - for i := 0; i < m.pages; i++ { + pages := m.opts.pages + kids := make([]string, 0, pages) + for i := 0; i < pages; i++ { // Objects: 1 catalog, 2 pages, 3 font, 4 info, then per page a page // object and a content stream. kids = append(kids, fmt.Sprintf("%d 0 R", 5+i*2)) @@ -31,15 +36,23 @@ func document(m memo) (prefix, suffix []byte) { objects = append(objects, "<>") objects = append(objects, fmt.Sprintf("<>", - strings.Join(kids, " "), m.pages)) + strings.Join(kids, " "), pages)) objects = append(objects, "<>") objects = append(objects, infoObject(m)) - for i := 0; i < m.pages; i++ { + // A page that asks to be turned says so after its paper, and a page that + // does not says nothing - an explicit /Rotate 0 would be bytes the file + // never had before it could be turned. + rotate := "" + if m.opts.rotate != 0 { + rotate = fmt.Sprintf("/Rotate %d", m.opts.rotate) + } + for i := 0; i < pages; i++ { content := pageContent(m, i) + paper := m.opts.geometry(i) objects = append(objects, fmt.Sprintf( - "<>>>>>", - m.pageSize.width, m.pageSize.height, 6+i*2)) + "<>>>>>", + paper.width, paper.height, rotate, 6+i*2)) objects = append(objects, fmt.Sprintf("<>\nstream\n%sendstream", len(content), content)) } diff --git a/internal/format/pdf/info.go b/internal/format/pdf/info.go index e8c939ab..026d7842 100644 --- a/internal/format/pdf/info.go +++ b/internal/format/pdf/info.go @@ -3,19 +3,238 @@ package pdf // The document information dictionary and the strings written into it. import ( + "encoding/hex" "fmt" + "regexp" + "strconv" "strings" + "time" + "unicode/utf16" + "unicode/utf8" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" ) +const ( + defaultTitle = "Testing Files Generator" + defaultProducer = "Testing Files Generator" + + // defaultCreated is fixed rather than read off the clock, because a + // timestamp from the clock would make two runs of the same recipe differ. + defaultCreated = "2020-01-01T00:00:00Z" + + // noDate is the value that leaves a date out of the document altogether. + noDate = "none" +) + +// docInfo is what the document says about itself. +type docInfo struct { + // title is what was asked for. Empty means the label, or the tool's name + // when there is no label - worked out in infoObject, because the label is + // not known until the plan is. + title string + author string + subject string + keywords string + creator string + producer string + created date + modified date + + // asked says whether any of it was set, which is what the refusal about + // the minimum needs to know: text somebody typed makes the smallest + // document bigger, and the refusal has to say so. + asked bool +} + +// date is one date as it was asked for and as the document writes it. An +// empty pdf means the document carries no such date. +type date struct { + asked string + pdf string +} + +// textKeys are the settings written as text, and dateKeys the ones written as +// dates. Together they are everything the document says about itself. +var ( + textKeys = []string{"title", "author", "subject", "keywords", "creator", "producer"} + dateKeys = []string{"created", "modified"} +) + +func readInfo(props map[string]string) (docInfo, error) { + for _, k := range textKeys { + if raw := props[k]; !utf8.ValidString(raw) { + return docInfo{}, &format.PropertyValueError{ + Format: "pdf", Key: k, Value: strings.ToValidUTF8(raw, "?"), + Reason: "it takes text, and this value is not valid UTF-8", + Remedy: "Write the value in UTF-8.", + } + } + } + info := docInfo{ + title: props["title"], author: props["author"], subject: props["subject"], + keywords: props["keywords"], creator: props["creator"], producer: props["producer"], + } + for _, k := range append(append([]string(nil), textKeys...), dateKeys...) { + info.asked = info.asked || props[k] != "" + } + if info.producer == "" { + info.producer = defaultProducer + } + var err error + if info.created, err = readDate(props, "created", defaultCreated); err != nil { + return docInfo{}, err + } + if info.modified, err = readDate(props, "modified", ""); err != nil { + return docInfo{}, err + } + return info, nil +} + +// dateShape is the three ways a date may be written: a day, a day and a time +// with no zone, and a day and a time in a zone. +// +// Strict on purpose. Go's own parser takes a fraction of a second after the +// seconds even when the layout has none, and a PDF date has no fractions - so +// accepting one would mean writing a date other than the one asked for. +var dateShape = regexp.MustCompile(`^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(Z|[+-]\d{2}:\d{2})?)?$`) + +// readDate reads one date, or says why it cannot be written. +// +// A time with no zone is written with no zone, never in the zone of the +// machine: two machines would otherwise write two files from one recipe. +func readDate(props map[string]string, key, fallback string) (date, error) { + raw := props[key] + if raw == "" { + raw = fallback + } + if raw == "" || raw == noDate { + return date{asked: raw}, nil + } + m := dateShape.FindStringSubmatch(raw) + if m == nil { + return date{}, &format.PropertyValueError{ + Format: "pdf", Key: key, Value: raw, + Reason: "it takes a date written as 2024-02-29, 2024-02-29T13:45:00 or 2024-02-29T13:45:00+02:00, or none", + Remedy: "Write the date in one of those three ways, with no fraction of a second.", + } + } + if !exists(m) { + return date{}, &format.PropertyValueError{ + Format: "pdf", Key: key, Value: raw, + Reason: "no calendar has that day, time or zone", + Remedy: "Write a date that exists, such as 2024-02-29T13:45:00+02:00.", + } + } + pdf := "D:" + m[1] + m[2] + m[3] + m[4] + m[5] + m[6] + switch zone := m[7]; { + case zone == "Z": + pdf += "Z" + case zone != "": + pdf += zone[:3] + "'" + zone[4:] + "'" + } + return date{asked: raw, pdf: pdf}, nil +} + +// exists says whether the parts of a date name a real moment: the 29th of +// February only in a leap year, no hour 24, no second 60, no zone past 23:59. +func exists(m []string) bool { + n := func(s string) int { + v, _ := strconv.Atoi(s) + return v + } + y, mo, d := n(m[1]), n(m[2]), n(m[3]) + h, mi, s := n(m[4]), n(m[5]), n(m[6]) + if h > 23 || mi > 59 || s > 59 { + return false + } + t := time.Date(y, time.Month(mo), d, h, mi, s, 0, time.UTC) + if t.Year() != y || int(t.Month()) != mo || t.Day() != d { + return false + } + if zone := m[7]; len(zone) == 6 { + return n(zone[1:3]) <= 23 && n(zone[4:6]) <= 59 + } + return true +} + +// infoObject is the document information dictionary. +// +// The keys that are always there come in the order they always came, and a +// key somebody asked for goes where the specification lists it. Nothing is +// written for a value nobody asked for, so a document with no settings is the +// document this format wrote before it had any. func infoObject(m memo) string { - title := m.label - if title == "" { - title = "Testing Files Generator" - } - // A fixed date, because a timestamp taken from the clock would make two - // runs of the same recipe differ. - return fmt.Sprintf("<>", - escapeString(title)) + i := m.opts.info + var b strings.Builder + b.WriteString("<<") + entry(&b, "Title", titleOf(m)) + entry(&b, "Author", i.author) + entry(&b, "Subject", i.subject) + entry(&b, "Keywords", i.keywords) + entry(&b, "Creator", i.creator) + entry(&b, "Producer", i.producer) + if i.created.pdf != "" { + fmt.Fprintf(&b, "/CreationDate(%s)", i.created.pdf) + } + if i.modified.pdf != "" { + fmt.Fprintf(&b, "/ModDate(%s)", i.modified.pdf) + } + b.WriteString(">>") + return b.String() +} + +// titleOf is the title the document carries: the one asked for, or the label, +// or the tool's name. +func titleOf(m memo) string { + switch { + case m.opts.info.title != "": + return m.opts.info.title + case m.label != "": + return m.label + } + return defaultTitle +} + +func entry(b *strings.Builder, key, value string) { + if value == "" { + return + } + b.WriteString("/" + key + pdfString(value)) +} + +// pdfString writes text the way every reader reads it back unchanged. +// +// Printable ASCII goes between brackets, as it always has. Anything else is +// written as UTF-16 with a byte order mark, in hex. A bracketed string is read +// in PDFDocEncoding, which has no Polish letters and turns a carriage return +// into a line feed - so "Zażółć" or a title with a line break in it would come +// back as something else. +func pdfString(s string) string { + if !printableASCII(s) { + return utf16Hex(s) + } + return "(" + escapeString(s) + ")" +} + +func printableASCII(s string) bool { + for i := 0; i < len(s); i++ { + if s[i] < 0x20 || s[i] > 0x7e { + return false + } + } + return true +} + +// utf16Hex is s as UTF-16 with a byte order mark, written in hex. +func utf16Hex(s string) string { + units := utf16.Encode([]rune(s)) + raw := make([]byte, 0, 2+2*len(units)) + raw = append(raw, 0xfe, 0xff) + for _, u := range units { + raw = append(raw, byte(u>>8), byte(u)) + } + return "<" + strings.ToUpper(hex.EncodeToString(raw)) + ">" } // escapeString protects the three characters that end or nest a PDF string. diff --git a/internal/format/pdf/minimum.go b/internal/format/pdf/minimum.go index e4e64ccc..8c3a82dc 100644 --- a/internal/format/pdf/minimum.go +++ b/internal/format/pdf/minimum.go @@ -2,28 +2,69 @@ package pdf // The smallest document this format produces, and how a refusal explains it. -// labelCost and cleanHint keep the message about the minimum honest. The -// figure in the registry is the smallest document with no label, so a user -// who left the label on and hits the limit needs to be told why the number -// they were shown is not the number they got. -func labelCost(label bool) string { - if label { - return " carrying the self describing label" +import ( + "fmt" + "strings" +) + +// documentWords names the document a refusal is about, the way a person would: +// "1 page A4 document", "3 page mixed size upright and landscape document". +// The words for the settings nobody changed are the words this refusal always +// used. +func documentWords(o options) string { + size := o.sizeName + if size == mixed { + size = "mixed size" + } + way := "" + switch o.orientation { + case orientLandscape: + way = " landscape" + case mixed: + way = " upright and landscape" } - return "" + return fmt.Sprintf("%d page %s%s document", o.pages, size, way) } -func cleanHint(label bool) string { +// carrying and cleanHint keep the message about the minimum honest. The +// figure in the registry is the smallest document with no label and nothing +// said about it, so a user who left the label on, or typed a title, and hits +// the limit needs to be told why the number they were shown is not the number +// they got. +func carrying(label bool, o options) string { + var what []string if label { + what = append(what, "the self describing label") + } + if o.info.asked { + what = append(what, "the document properties that were set") + } + if len(what) == 0 { + return "" + } + return " carrying " + strings.Join(what, " and ") +} + +func cleanHint(label bool, o options) string { + switch { + case label && o.info.asked: + return ", ask for fewer pages by setting pages to 1, shorten the document properties, or drop the label" + case label: return ", ask for fewer pages by setting pages to 1, or drop the label" + case o.info.asked: + return ", ask for fewer pages by setting pages to 1, or shorten the document properties" } return " or ask for fewer pages by setting pages to 1" } // minimumBytes is the smallest document this generator can produce: one A4 -// page with no label. Measured at start up rather than guessed. +// page with no label and every setting at its default. Measured at start up +// rather than guessed. func minimumBytes() int64 { - m := memo{pages: 1, pageSize: pageSizes["a4"]} - prefix, suffix := document(m) + opts, err := readOptions(nil) + if err != nil { + panic("pdf: the default settings are refused: " + err.Error()) + } + prefix, suffix := document(memo{opts: opts}) return int64(len(prefix) + len(suffix)) } diff --git a/internal/format/pdf/page.go b/internal/format/pdf/page.go index 6390eae7..d4bb97ec 100644 --- a/internal/format/pdf/page.go +++ b/internal/format/pdf/page.go @@ -9,12 +9,15 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/core" ) +// pageContent is what page index says. A page lying wide is shorter, so it +// holds fewer lines - worked out from its own paper, never from the first +// page's, because a mixed document has pages of five heights. func pageContent(m memo, index int) string { var b strings.Builder - top := m.pageSize.height - 72 + top := m.opts.geometry(index).height - 72 b.WriteString("BT\n/F1 18 Tf\n") - fmt.Fprintf(&b, "72 %d Td\n(%s) Tj\n", top, escapeString(fmt.Sprintf("Page %d of %d", index+1, m.pages))) + fmt.Fprintf(&b, "72 %d Td\n(%s) Tj\n", top, escapeString(fmt.Sprintf("Page %d of %d", index+1, m.opts.pages))) b.WriteString("ET\n") b.WriteString("BT\n/F1 11 Tf\n") diff --git a/internal/format/pdf/pdf.go b/internal/format/pdf/pdf.go index 14d55ed8..f2affa84 100644 --- a/internal/format/pdf/pdf.go +++ b/internal/format/pdf/pdf.go @@ -8,7 +8,6 @@ import ( "context" "fmt" "io" - "strconv" "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/format" @@ -30,27 +29,9 @@ func init() { Where: format.PlacementEnd, Capacity: 0, }, - Label: format.LabelVisible, - Oracle: "pdftotext", - Properties: []format.Property{ - { - Name: "pages", Kind: format.PropertyInt, - Min: 1, Max: maxPages, - Default: strconv.Itoa(defaultPages), - Detail: "How many pages the document has.", - }, - { - Name: "page_size", Kind: format.PropertyChoice, - // Written out rather than read from the map, so one build - // cannot offer a different set from the next. The ORDER is no - // longer decided here: registration sorts every closed set, so - // the menu in the window, "tfg formats pdf" and the wording of - // a refusal all list them the same way round. - Choices: []string{"a4", "a3", "a5", "letter", "legal"}, - Default: "a4", - Detail: "The paper size every page uses.", - }, - }, + Label: format.LabelVisible, + Oracle: "pdftotext", + Properties: properties, GeneratorVersion: generatorVersion, Generator: generator{}, }) @@ -59,10 +40,9 @@ func init() { type generator struct{} type memo struct { - pages int - pageSize pageSize - seed uint64 - label string + opts options + seed uint64 + label string // prefix is everything up to and including the trailer. Small - a few // kilobytes per page - so holding it costs nothing next to the padding. prefix []byte @@ -74,11 +54,7 @@ type memo struct { } func (generator) Plan(r format.Request) (format.Plan, error) { - pages, err := pageCount(r.Properties) - if err != nil { - return format.Plan{}, err - } - size, err := paperSize(r.Properties) + opts, err := readOptions(r.Properties) if err != nil { return format.Plan{}, err } @@ -88,7 +64,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) { label = core.Label("pdf", r.Bytes, r.Seed) } - m := memo{pages: pages, pageSize: size, seed: r.Seed, label: label} + m := memo{opts: opts, seed: r.Seed, label: label} m.prefix, m.suffix = document(m) // One number answers both questions: how much padding this file needs, and @@ -102,15 +78,7 @@ func (generator) Plan(r format.Request) (format.Plan, error) { Bytes: r.Bytes, Exact: true, Determinism: format.DeterminismByte, - Properties: map[string]any{ - "pages": pages, - "page_size": size.name, - "pdf_version": "1.7", - "fonts_embedded": false, - format.PropertyLabelEmbedded: r.Label, - "compressed": false, - "content_streams": pages, - }, + Properties: described(m, r.Label), } switch { @@ -121,9 +89,9 @@ func (generator) Plan(r format.Request) (format.Plan, error) { Format: "PDF", Requested: r.Bytes, Minimum: floor, - Reason: fmt.Sprintf("a %d page %s document%s already needs that much before any padding", - pages, size.name, labelCost(r.Label)), - Hint: fmt.Sprintf("Ask for %d B or more%s.", floor, cleanHint(r.Label)), + Reason: fmt.Sprintf("a %s%s already needs that much before any padding", + documentWords(opts), carrying(r.Label, opts)), + Hint: fmt.Sprintf("Ask for %d B or more%s.", floor, cleanHint(r.Label, opts)), } case r.Bytes < bare+minComment: // A comment is a per cent sign and a newline at the very least, so @@ -145,6 +113,51 @@ func (generator) Plan(r format.Request) (format.Plan, error) { return p, nil } +// described is what the manifest says about the file: what is in it, so a +// test can assert on it without opening the PDF. +// +// A value the document carries is always here, and a value it does not carry +// is not - an author nobody asked for is absent from both. The alternative, +// only what differs from the defaults, would make every consumer know the +// defaults of this build to read the manifest of it. +func described(m memo, labelled bool) map[string]any { + o := m.opts + props := map[string]any{ + "pages": o.pages, + "page_size": o.sizeName, + "orientation": o.orientation, + "rotate": o.rotate, + "pdf_version": o.version, + "fonts_embedded": false, + format.PropertyLabelEmbedded: labelled, + "compressed": false, + "content_streams": o.pages, + "title": titleOf(m), + "producer": o.info.producer, + } + if o.sizeName == mixed { + cycle := make([]string, 0, len(o.sizes)) + for _, s := range o.sizes { + cycle = append(cycle, s.name) + } + props["page_size_cycle"] = cycle + } + for key, value := range map[string]string{ + "author": o.info.author, "subject": o.info.subject, + "keywords": o.info.keywords, "creator": o.info.creator, + } { + if value != "" { + props[key] = value + } + } + for key, d := range map[string]date{"created": o.info.created, "modified": o.info.modified} { + if d.pdf != "" { + props[key] = d.asked + } + } + return props +} + func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { m, ok := p.Memo.(memo) if !ok { diff --git a/internal/format/pdf/properties.go b/internal/format/pdf/properties.go new file mode 100644 index 00000000..4966775e --- /dev/null +++ b/internal/format/pdf/properties.go @@ -0,0 +1,104 @@ +package pdf + +// The settings this format declares - what the window draws, what tfg formats +// prints and what the registry checks a value against before the generator +// ever sees it. + +import ( + "strconv" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" +) + +// documentProperties is the block of settings describing the document rather +// than its pages. The window draws the name above them and tfg formats prints +// it, see format.Property.Group. +const documentProperties = "Document properties" + +// anyText is what a setting written as free text accepts, said once for the +// six of them. +const anyText = "any text" + +// dateText is what a date accepts. The three ways a date may be written are +// the ones dateShape reads, and none leaves the date out. +const dateText = "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none" + +var properties = []format.Property{ + { + Name: "pages", Kind: format.PropertyInt, + Min: 1, Max: maxPages, + Default: strconv.Itoa(defaultPages), + Detail: "How many pages the document has.", + }, + { + Name: "page_size", Kind: format.PropertyChoice, + // Written out rather than read from the map, so one build cannot + // offer a different set from the next. The ORDER is not decided here: + // registration sorts every closed set, so the menu in the window, + // "tfg formats pdf" and the wording of a refusal all list them the + // same way round. + Choices: []string{"a4", "a3", "a5", "letter", "legal", mixed}, + Default: "a4", + Detail: "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.", + }, + { + Name: "orientation", Kind: format.PropertyChoice, + Choices: []string{orientPortrait, orientLandscape, mixed}, + Default: orientPortrait, + Detail: "Whether pages stand upright or lie wide. Mixed alternates the two, starting upright, " + + "and needs at least two pages.", + }, + { + Name: "rotate", Kind: format.PropertyChoice, + Choices: []string{"0", "90", "180", "270"}, + Default: "0", + Detail: "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.", + }, + { + Name: "pdf_version", Kind: format.PropertyChoice, + Choices: []string{"1.4", defaultVersion}, + Default: defaultVersion, + Detail: "The version written at the start of the file. Nothing else changes, " + + "because the document uses nothing newer than 1.4.", + }, + { + Name: "title", Kind: format.PropertyText, Shape: anyText, Group: documentProperties, + Detail: "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.", + }, + { + Name: "author", Kind: format.PropertyText, Shape: anyText, Group: documentProperties, + Detail: "The author in the document properties. Left empty, the document names none.", + }, + { + Name: "subject", Kind: format.PropertyText, Shape: anyText, Group: documentProperties, + Detail: "The subject in the document properties. Left empty, the document has none.", + }, + { + Name: "keywords", Kind: format.PropertyText, Shape: anyText, Group: documentProperties, + Detail: "The keywords in the document properties, as one line of text. Left empty, the document has none.", + }, + { + Name: "creator", Kind: format.PropertyText, Shape: anyText, Group: documentProperties, + Detail: "The program the document says it was written in, such as Microsoft Word. " + + "Left empty, the document names none.", + }, + { + Name: "producer", Kind: format.PropertyText, Shape: anyText, Group: documentProperties, + Default: defaultProducer, + Detail: "The program the document says turned it into a PDF.", + }, + { + Name: "created", Kind: format.PropertyText, Shape: dateText, Group: documentProperties, + Default: defaultCreated, + Detail: "When the document says it was created. A time written without a zone is kept without one, " + + "and none leaves the date out.", + }, + { + Name: "modified", Kind: format.PropertyText, Shape: dateText, Group: documentProperties, + Detail: "When the document says it was last changed, written the same way as created. " + + "Left empty, the document has no such date.", + }, +} diff --git a/internal/format/pdf/settings.go b/internal/format/pdf/settings.go index b013f197..a38baf86 100644 --- a/internal/format/pdf/settings.go +++ b/internal/format/pdf/settings.go @@ -1,16 +1,26 @@ package pdf -// Reading the settings a request carries: how many pages and which paper. +// Reading the settings a request carries: how many pages, which paper, which +// way up, and what the document says about itself. import ( "fmt" "strconv" "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" ) const ( defaultPages = 1 maxPages = 5000 + + defaultVersion = "1.7" + + orientPortrait = "portrait" + orientLandscape = "landscape" + // mixed is the value page_size and orientation share for "not all alike". + mixed = "mixed" ) type pageSize struct { @@ -26,6 +36,73 @@ var pageSizes = map[string]pageSize{ "legal": {"Legal", 612, 1008}, } +// mixedSizes is the order page_size=mixed walks through, one size a page. +// +// Taken from the page number rather than drawn from the seed, and that is the +// lesson of lineWidth: a draw would make the smallest document one seed can +// produce differ from the next. A4 and Letter come first because a document +// mixing the two is the one people actually meet - pages scanned on each side +// of the Atlantic and put together. +var mixedSizes = []string{"a4", "letter", "legal", "a3", "a5"} + +// options is everything a request asks of the document, read once. +type options struct { + pages int + // sizes is one size, or the cycle page_size=mixed walks through. + sizes []pageSize + sizeName string + orientation string + rotate int + version string + info docInfo +} + +// geometry is the paper page i is drawn on, the right way up. +// +// Landscape swaps the two sides of the page itself, so every reader shows it +// wide. That is a different thing from rotate, which leaves the page upright +// and asks the reader to turn it - see the declaration. +func (o options) geometry(i int) pageSize { + s := o.sizes[i%len(o.sizes)] + if o.orientation == orientLandscape || (o.orientation == mixed && i%2 == 1) { + s.width, s.height = s.height, s.width + } + return s +} + +// readOptions reads the settings, or says which one cannot be used. +// +// The registry has already refused a key it does not know and a value outside +// a declared set, so what is refused here is what only this format can judge: +// a date that does not exist, text that is not text, and "mixed" in a document +// with one page. +func readOptions(props map[string]string) (options, error) { + pages, err := pageCount(props) + if err != nil { + return options{}, err + } + o := options{pages: pages, version: defaultVersion, orientation: orientPortrait} + if o.sizes, o.sizeName, err = paperSizes(props, pages); err != nil { + return options{}, err + } + if raw := props["orientation"]; raw != "" { + if raw == mixed && pages < 2 { + return options{}, needsTwoPages("orientation", "choose portrait or landscape") + } + o.orientation = raw + } + if o.rotate, err = rotation(props); err != nil { + return options{}, err + } + if raw := props["pdf_version"]; raw != "" { + o.version = raw + } + if o.info, err = readInfo(props); err != nil { + return options{}, err + } + return o, nil +} + func pageCount(props map[string]string) (int, error) { raw, ok := props["pages"] if !ok || raw == "" { @@ -41,10 +118,22 @@ func pageCount(props map[string]string) (int, error) { return n, nil } -func paperSize(props map[string]string) (pageSize, error) { +// paperSizes is the paper every page uses, or the cycle a mixed document +// walks through, and the name the manifest gives it. +func paperSizes(props map[string]string, pages int) ([]pageSize, string, error) { raw, ok := props["page_size"] if !ok || raw == "" { - return pageSizes["a4"], nil + return []pageSize{pageSizes["a4"]}, pageSizes["a4"].name, nil + } + if raw == mixed { + if pages < 2 { + return nil, "", needsTwoPages("page_size", "choose one size such as a4") + } + cycle := make([]pageSize, 0, len(mixedSizes)) + for _, k := range mixedSizes { + cycle = append(cycle, pageSizes[k]) + } + return cycle, mixed, nil } s, ok := pageSizes[strings.ToLower(raw)] if !ok { @@ -52,9 +141,38 @@ func paperSize(props map[string]string) (pageSize, error) { for k := range pageSizes { names = append(names, k) } - return pageSize{}, fmt.Errorf("pdf: page_size %q is not one of: %s", raw, strings.Join(sorted(names), ", ")) + return nil, "", fmt.Errorf("pdf: page_size %q is not one of: %s", raw, strings.Join(sorted(names), ", ")) + } + return []pageSize{s}, s.name, nil +} + +// needsTwoPages refuses "mixed" in a document of one page. +// +// Refused rather than written, because the file would be a single page of one +// size and one way up, with a manifest saying it is mixed. Somebody testing how +// a reader copes with pages that differ would get a pass from a document where +// nothing differs. +func needsTwoPages(key, other string) *format.PropertyValueError { + return &format.PropertyValueError{ + Format: "pdf", + Key: key, + Value: mixed, + Reason: "mixed pages need at least two pages and this document has one", + Remedy: fmt.Sprintf("Set pages to 2 or more, or %s.", other), + } +} + +// rotation is the turn every page asks the reader for, in degrees. +func rotation(props map[string]string) (int, error) { + raw := props["rotate"] + if raw == "" { + return 0, nil + } + switch raw { + case "0", "90", "180", "270": + return strconv.Atoi(raw) } - return s, nil + return 0, fmt.Errorf("pdf: rotate %q is not one of: 0, 90, 180, 270", raw) } func sorted(in []string) []string { diff --git a/internal/format/registry.go b/internal/format/registry.go index 57b81b34..f8048773 100644 --- a/internal/format/registry.go +++ b/internal/format/registry.go @@ -36,9 +36,26 @@ func Register(d Descriptor) { for i := range d.Properties { SortChoices(d.Properties[i].Choices) } + if g, split := splitGroup(d.Properties); split { + panic(fmt.Sprintf("format: %q declares the group %q in two places", d.ID, g)) + } registry[d.ID] = d } +// splitGroup finds a group whose settings are not declared together. +func splitGroup(props []Property) (string, bool) { + closed := map[string]bool{} + for i, p := range props { + if i > 0 && props[i-1].Group != p.Group { + closed[props[i-1].Group] = true + } + if closed[p.Group] { + return p.Group, true + } + } + return "", false +} + // SmallestWithLabel is d.SmallestAccepted(Request{Label: true}), worked out // once per format per process and remembered. // diff --git a/internal/guard/choiceseed_test.go b/internal/guard/choiceseed_test.go new file mode 100644 index 00000000..5ebadc79 --- /dev/null +++ b/internal/guard/choiceseed_test.go @@ -0,0 +1,124 @@ +package guard + +import ( + "errors" + "sort" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" +) + +// The minimum stands whatever the seed is under every value a closed set +// offers, not only under the defaults. +// +// TestTheMinimumThisToolPrintsStandsWhateverTheSeedIs sweeps 256 seeds, and it +// sweeps them at the default settings only - a Request with no properties. So +// a value that made the length of a file depend on what the seed drew would +// sail past it: the default never reaches that value. Found while planning the +// PDF settings of 2026-09-29, where compressing the page text would have done +// exactly that, and the guard that exists to catch it could not have seen it. +// +// Every choice and every switch, each non-default value on its own. A value +// that is refused on its own - "mixed" in a document of one page - is given +// the setting it needs from companions, and a companion that stops being +// needed is itself a failure, so the list cannot rot into a list of reasons to +// look away. +func TestTheMinimumStandsWhateverTheSeedIsUnderEveryDeclaredChoice(t *testing.T) { + const seeds = 256 + checked := 0 + usedCompanion := map[string]bool{} + + for _, d := range format.All() { + for _, p := range d.Properties { + for _, v := range nonDefaultValues(p) { + key := d.ID + "." + p.Name + "=" + v + props := map[string]string{p.Name: v} + for k, cv := range choiceCompanions[key] { + props[k] = cv + usedCompanion[key] = true + } + r := format.Request{Label: true, Properties: props} + floor := d.SmallestAccepted(r) + for seed := uint64(0); seed < seeds; seed++ { + r.Bytes, r.Seed = floor, seed + _, err := d.Generator.Plan(r) + if err == nil { + continue + } + var below *format.BelowMinimumError + if errors.As(err, &below) { + t.Errorf("%s: the minimum is %d B and seed %d refuses it: %v\n"+ + "A floor that moves with the seed means somebody reads one number and gets "+ + "a refusal on the run after the one that worked.", key, floor, seed, err) + } else { + t.Errorf("%s is refused for a reason that is not its size, so the seeds were not "+ + "swept at all - give it what it needs in choiceCompanions: %v", key, err) + } + break + } + checked++ + } + } + } + if checked == 0 { + t.Fatal("no format declares a closed set, so this guard checked nothing") + } + + // A companion nobody needs any more is a value this guard sets for no + // reason, and it may be hiding the very refusal it was added for. + keys := make([]string, 0, len(choiceCompanions)) + for k := range choiceCompanions { + keys = append(keys, k) + } + sort.Strings(keys) + for _, key := range keys { + if !usedCompanion[key] { + t.Errorf("choiceCompanions names %s and no declared value matches it any more", key) + } + } +} + +// nonDefaultValues is every value of a closed set or a switch but the default. +func nonDefaultValues(p format.Property) []string { + var values []string + switch p.Kind { + case format.PropertyChoice: + values = p.Choices + case format.PropertyBool: + values = []string{"true", "false"} + case format.PropertyInt, format.PropertySize, format.PropertyText: + // Open ranges and free text have no list to walk. A kind added + // later reddens the linter here rather than being skipped unseen. + } + out := make([]string, 0, len(values)) + for _, v := range values { + if v != p.Default { + out = append(out, v) + } + } + return out +} + +// choiceCompanions is what a value needs beside it before a file can be made +// at all, keyed by format.setting=value. +var choiceCompanions = map[string]map[string]string{ + // Mixed pages need more than one page. + "pdf.page_size=mixed": {"pages": "2"}, + "pdf.orientation=mixed": {"pages": "2"}, + // A severity is carried only by the shapes that have one. + "log.level_mix=debug": {"entry_format": "plain"}, + "log.level_mix=errors": {"entry_format": "plain"}, + "log.level_mix=quiet": {"entry_format": "plain"}, + // XML in UTF-16 opens with a byte order mark. + "xml.encoding=utf-16be": {"bom": "true"}, + "xml.encoding=utf-16le": {"bom": "true"}, + // A directory entry needs a directory. + "zip.directory_entries=true": {"depth": "1"}, + "targz.directory_entries=true": {"depth": "1"}, + // Locking needs something to lock with. + "zip.encryption=aes-128": {"password": "guard"}, + "zip.encryption=aes-192": {"password": "guard"}, + "zip.encryption=aes-256": {"password": "guard"}, + "zip.encryption=zipcrypto": {"password": "guard"}, +} diff --git a/internal/guard/generatorbytes_test.go b/internal/guard/generatorbytes_test.go index 2c001e53..dc3e1a6e 100644 --- a/internal/guard/generatorbytes_test.go +++ b/internal/guard/generatorbytes_test.go @@ -48,6 +48,22 @@ func goldenCases() map[string]engine.Target { "png_64kib": {ID: "g", Format: "png", Sizes: engine.Uniform(1, 65536), Label: true, Properties: map[string]string{"width": "64", "height": "64"}}, "pdf_16kib": {ID: "g", Format: "pdf", Sizes: engine.Uniform(1, 16384), Label: true}, + // The PDF settings of 2026-09-29. The first case above pins all of + // them at their defaults, which is the promise that they cost the + // default document nothing. These pin what they write when set: text + // in both of the ways a string is written, a date in a zone and a + // date with no time, and pages of five sizes lying both ways, turned + // and under the older header. + "pdf_document_properties": {ID: "g", Format: "pdf", Sizes: engine.Uniform(1, 16384), Label: true, + Properties: map[string]string{ + "title": "Zażółć (gęślą) jaźń", "author": "QA", "subject": "Invoices", "keywords": "a, b", + "creator": "Microsoft Word", "producer": "Producer 1.0", + "created": "2024-02-29T13:45:00+02:00", "modified": "1999-12-31", + }}, + "pdf_mixed_pages": {ID: "g", Format: "pdf", Sizes: engine.Uniform(1, 32768), Label: true, + Properties: map[string]string{ + "pages": "5", "page_size": "mixed", "orientation": "mixed", "rotate": "270", "pdf_version": "1.4", + }}, // The three Office packages, each at a size well above its floor. What // they pin is a whole OPC container: the parts, their order, the // compression of each one and the padding part that settles the size. diff --git a/internal/guard/parity_test.go b/internal/guard/parity_test.go index 8be810f0..8483e3fd 100644 --- a/internal/guard/parity_test.go +++ b/internal/guard/parity_test.go @@ -185,6 +185,20 @@ var reachableFromTheWindow = []string{ "property:xml.encoding", "property:pdf.page_size", "property:pdf.pages", + // The settings of 2026-09-29, drawn by the same code - the eight that + // describe the document under a heading of their own, from the Group + // the declaration carries. + "property:pdf.orientation", + "property:pdf.rotate", + "property:pdf.pdf_version", + "property:pdf.title", + "property:pdf.author", + "property:pdf.subject", + "property:pdf.keywords", + "property:pdf.creator", + "property:pdf.producer", + "property:pdf.created", + "property:pdf.modified", "property:png.height", "property:png.width", // SVG names its dimensions the same way the nine picture formats do, on diff --git a/internal/guard/pdfsettings_test.go b/internal/guard/pdfsettings_test.go new file mode 100644 index 00000000..3805c737 --- /dev/null +++ b/internal/guard/pdfsettings_test.go @@ -0,0 +1,204 @@ +package guard + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" + "github.com/donislawdev/TestingFilesGenerator/internal/oracle" +) + +// Every PDF setting is read back by a reader that is not ours. +// +// The reference tool guard opens one PDF at its default settings, and the +// reader it asks - pdftotext - sees the words on the page and nothing else. A +// title that never reached the file, a page that was meant to lie wide and +// stands upright, a date written in the wrong zone: every one of those leaves +// the text, the size and the determinism exactly as they were. Twelve settings +// arrived on 2026-09-29 and none of them was visible to any guard until this. +// +// pdfinfo answers for all of them: the eight fields of the information +// dictionary, the size and the turn of every page, the version in the header. +// It is poppler, where pdftotext here is Xpdf, so it is a second engine as well +// as a second question. +// +// The reader has to be able to say no, or its yes means nothing. So the file +// with no settings is asked about the fields nobody set, and a field it +// reports there is a failure - the same reader that reads an author back has +// to report none when there is none. +// +// Named for the reference tool so the CI job that installs poppler runs it. +// Without pdfinfo it skips, and says so. +func TestEveryPdfSettingSurvivesItsReferenceTool(t *testing.T) { + if _, err := exec.LookPath("pdfinfo"); err != nil { + t.Skip("pdfinfo is not installed, so no PDF setting was read back - install poppler to run this") + } + d, err := format.Get("pdf") + if err != nil { + t.Fatalf("pdf is not registered: %v", err) + } + + cases := pdfWitnessCases() + + // Every setting the format declares is set by at least one case. A setting + // added tomorrow with no case here would otherwise be one this guard never + // asks about, and it would stay green. + covered := map[string]bool{} + for _, c := range cases { + for k := range c.set { + covered[k] = true + } + } + for _, p := range d.Properties { + if !covered[p.Name] { + t.Errorf("pdf declares %s and no case here sets it, so nothing reads it back", p.Name) + } + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + path := writePdf(t, d, c.set) + info := readPdfinfo(t, path) + for key, want := range c.want { + got, ok := info[key] + switch { + case want == "" && ok: + t.Errorf("pdfinfo reports %s %q in a file where nobody set it", key, got) + case want != "" && !ok: + t.Errorf("pdfinfo reports no %s, and the file was asked for %q", key, want) + case want != "" && !strings.HasPrefix(got, want): + t.Errorf("pdfinfo reads %s as %q, and the file was asked for %q", key, got, want) + } + } + if res := oracle.Strict("pdf", path); res.Available && res.Err != nil { + t.Errorf("the structural check refuses it: %v", res.Err) + } + }) + } +} + +type pdfWitnessCase struct { + name string + set map[string]string + // want is what pdfinfo has to print for a key, compared as a prefix + // because poppler shortens a zone of +02:00 to +02. An empty value is a + // key it must NOT print. + want map[string]string +} + +func pdfWitnessCases() []pdfWitnessCase { + return []pdfWitnessCase{ + { + name: "nothing set", + set: map[string]string{}, + want: map[string]string{ + "Producer": "Testing Files Generator", "CreationDate": "2020-01-01T00:00:00", + "Author": "", "Subject": "", "Keywords": "", "Creator": "", "ModDate": "", + "PDF version": "1.7", "Pages": "1", + "Page 1 size": "595 x 842", "Page 1 rot": "0", + }, + }, + { + name: "every field of the document", + set: map[string]string{ + "title": "Zażółć (gęślą) jaźń", "author": "Jan Kowalski", "subject": "Invoices", + "keywords": "a, b", "creator": "Microsoft Word", "producer": "Producer 1.0", + "created": "2024-02-29T13:45:00+02:00", "modified": "1999-12-31T23:59:59Z", + }, + want: map[string]string{ + "Title": "Zażółć (gęślą) jaźń", "Author": "Jan Kowalski", "Subject": "Invoices", + "Keywords": "a, b", "Creator": "Microsoft Word", "Producer": "Producer 1.0", + "CreationDate": "2024-02-29T13:45:00+02", "ModDate": "1999-12-31T23:59:59", + }, + }, + { + name: "no creation date", + set: map[string]string{"created": "none", "modified": "2024-02-29"}, + want: map[string]string{"CreationDate": "", "ModDate": "2024-02-29T00:00:00"}, + }, + { + // Six pages walk the cycle once and start it again, with every + // second page lying wide. + name: "mixed sizes both ways up, turned, under the older header", + set: map[string]string{ + "pages": "6", "page_size": "mixed", "orientation": "mixed", + "rotate": "90", "pdf_version": "1.4", + }, + want: pagesWant("1.4", "90", + "595 x 842", "792 x 612", "612 x 1008", "1191 x 842", "420 x 595", "842 x 595"), + }, + { + name: "one size lying wide", + set: map[string]string{"pages": "2", "page_size": "a5", "orientation": "landscape", "rotate": "270"}, + want: pagesWant("1.7", "270", "595 x 420", "595 x 420"), + }, + } +} + +// pagesWant is the size and turn of each page, and the header version. +func pagesWant(version, rot string, sizes ...string) map[string]string { + want := map[string]string{"PDF version": version, "Pages": fmt.Sprint(len(sizes))} + for i, s := range sizes { + want[fmt.Sprintf("Page %4d size", i+1)] = s + want[fmt.Sprintf("Page %4d rot", i+1)] = rot + } + return want +} + +func writePdf(t *testing.T, d format.Descriptor, props map[string]string) string { + t.Helper() + plan, err := d.Generator.Plan(format.Request{Bytes: 64 * 1024, Seed: 7741, Label: true, Properties: props}) + if err != nil { + t.Fatalf("planning: %v", err) + } + path := filepath.Join(t.TempDir(), "sample.pdf") + f, err := os.Create(path) + if err != nil { + t.Fatalf("creating %s: %v", path, err) + } + err = d.Generator.Write(context.Background(), f, plan) + if closeErr := f.Close(); err == nil { + err = closeErr + } + if err != nil { + t.Fatalf("writing: %v", err) + } + return path +} + +// pdfinfoLine is one "Key: value" line. The key may hold spaces - "Page 1 +// size" - so it is everything up to the first colon followed by spaces. +var pdfinfoLine = regexp.MustCompile(`^([^:]+):\s+(.*)$`) + +func readPdfinfo(t *testing.T, path string) map[string]string { + t.Helper() + cmd := exec.Command("pdfinfo", "-enc", "UTF-8", "-isodates", "-f", "1", "-l", "9999", path) + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + t.Fatalf("pdfinfo refused the file: %v %s", err, stderr.String()) + } + if strings.TrimSpace(stderr.String()) != "" { + t.Errorf("pdfinfo complained: %s", stderr.String()) + } + info := map[string]string{} + for _, line := range strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n") { + if m := pdfinfoLine.FindStringSubmatch(line); m != nil { + info[m[1]] = strings.TrimSpace(m[2]) + } + } + // The reader answered in the shape this guard reads, or every comparison + // above is against an empty map and a missing key reads as "not set". + if info["Pages"] == "" || info["PDF version"] == "" { + t.Fatalf("pdfinfo printed nothing this guard can read - the comparisons would all be against nothing:\n%s", out) + } + return info +} diff --git a/internal/guard/properties_test.go b/internal/guard/properties_test.go index 1dda9205..7c7a9b69 100644 --- a/internal/guard/properties_test.go +++ b/internal/guard/properties_test.go @@ -35,6 +35,12 @@ func TestABadPropertyValueIsTheCallersMistakeNotOurs(t *testing.T) { {"png", "width=-5", "below the declared minimum"}, {"png", "width=99999", "above the declared maximum"}, {"pdf", "page_size=a7", "not one of the declared choices"}, + // Free text the format reads itself, which the declaration cannot + // refuse because a date is not a closed set. The generator refuses, + // and it has to land on the same code as the registry would. + {"pdf", "created=2023-02-29", "a day no calendar has"}, + {"pdf", "modified=2024-02-29T13:45:00.5Z", "a fraction of a second, which a PDF date cannot hold"}, + {"pdf", "orientation=mixed", "mixed pages in a document of one page"}, // This one used to pass the first check and fail deeper, in different // words, because bit depth was declared as a range of 8 to 32 when it // is really a set of four values. diff --git a/internal/guard/screenpixels_test.go b/internal/guard/screenpixels_test.go index 9fa37318..635214b3 100644 --- a/internal/guard/screenpixels_test.go +++ b/internal/guard/screenpixels_test.go @@ -337,6 +337,15 @@ func screenScenes() []screenScene { chooseFormat(t, s.tab, "png") s.canvas.Focus(chooserFor(t, s.tab)) }}, + // The longest list of settings any format declares, and the first one + // with a block of its own: PDF went from two settings to thirteen on + // 2026-09-29, eight of them under "Document properties". A heading + // inside a fold had never been drawn among fields, so this is the + // picture of what that looks like rather than a reading of the code. + {name: "generate-pdf-settings", tab: text.TabOneTarget(), set: func(t *testing.T, s scene) { + chooseFormat(t, s.tab, "pdf") + openFold(t, s.tab, "", text.SettingsFor("pdf")) + }}, // The switch with the keyboard in it. The disc behind the square is the // toolkit's own mark and it is what the owner called ugly on 2026-08-18 // - it is still here, and this is the state it is still here in. What diff --git a/internal/guard/settingsgroup_test.go b/internal/guard/settingsgroup_test.go new file mode 100644 index 00000000..bb866d1a --- /dev/null +++ b/internal/guard/settingsgroup_test.go @@ -0,0 +1,105 @@ +package guard + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" +) + +// A block of settings is named where it starts, the same way on both outputs +// that print a declaration. +// +// PDF was the first format with enough settings to need blocks - thirteen on +// 2026-09-29, eight of them about the document rather than its pages. The +// window draws the name of a block above its first setting (the picture is +// generate-pdf-settings), and tfg formats has to put it in the same place, or +// the two surfaces describe one format in two shapes. The machine readable +// list carries it too, because a window built on that list could not draw a +// heading it is never told about. +func TestASettingsBlockIsNamedWhereItStartsOnBothOutputs(t *testing.T) { + d, err := format.Get("pdf") + if err != nil { + t.Fatalf("pdf is not registered: %v", err) + } + starts := map[string]string{} + for i, p := range d.Properties { + if p.Group != "" && (i == 0 || d.Properties[i-1].Group != p.Group) { + starts[p.Group] = p.Name + } + } + if len(starts) == 0 { + t.Fatal("pdf declares no block of settings, so this guard checks nothing - point it at a format that does") + } + + code, stdout, errOut := run(t, "formats", "pdf") + if code != cli.ExitOK { + t.Fatalf("exit %d: %s", code, errOut) + } + for group, first := range starts { + heading := "\n " + group + ":\n" + if n := strings.Count(stdout, heading); n != 1 { + t.Errorf("tfg formats pdf names the block %q %d times, and it starts once:\n%s", group, n, stdout) + } + if !strings.Contains(stdout, heading+" "+first+" ") { + t.Errorf("the block %q is not named directly above %s, its first setting:\n%s", group, first, stdout) + } + } + + code, stdout, errOut = run(t, "formats", "pdf", "--json") + if code != cli.ExitOK { + t.Fatalf("exit %d: %s", code, errOut) + } + var list []struct { + Properties []struct{ Name, Group string } `json:"properties"` + } + if err := json.Unmarshal([]byte(stdout), &list); err != nil || len(list) != 1 { + t.Fatalf("the machine readable list is not one format of JSON: %v\n%s", err, stdout) + } + printed := map[string]string{} + for _, p := range list[0].Properties { + printed[p.Name] = p.Group + } + for _, p := range d.Properties { + if got := printed[p.Name]; got != p.Group { + t.Errorf("the JSON puts %s in the block %q and the declaration in %q", p.Name, got, p.Group) + } + } +} + +// A block declared in two places is refused when the format registers. +// +// Both surfaces draw a heading where a block starts, so a block that stops and +// starts again would be named twice with other settings between the two - the +// reader would take them for two blocks. Refused at start up rather than drawn, +// because it is a mistake in a declaration and every build would carry it. +func TestABlockOfSettingsDeclaredInTwoPlacesIsRefused(t *testing.T) { + txt, err := format.Get("txt") + if err != nil { + t.Fatalf("txt is not registered: %v", err) + } + for i, props := range [][]format.Property{ + {{Name: "a", Group: "X"}, {Name: "b", Group: "Y"}, {Name: "c", Group: "X"}}, + // Settings with no block come first, so one after a block is the + // same mistake. + {{Name: "a"}, {Name: "b", Group: "X"}, {Name: "c"}}, + } { + // A name of its own each time: if the first were wrongly accepted, + // the second would panic for being registered twice and pass for + // the wrong reason. + id := "guard-split-block-" + string(rune('a'+i)) + func() { + defer func() { + r := recover() + if r == nil || !strings.Contains(fmt.Sprint(r), "two places") { + t.Errorf("a declaration with a block in two places was not refused for it (%v): %+v", r, props) + } + }() + format.Register(format.Descriptor{ID: id, Generator: txt.Generator, Properties: props}) + }() + } +} diff --git a/internal/guard/testdata/generator-golden.json b/internal/guard/testdata/generator-golden.json index ef8ecfb8..6d39c75e 100644 --- a/internal/guard/testdata/generator-golden.json +++ b/internal/guard/testdata/generator-golden.json @@ -138,6 +138,16 @@ "bytes": 16384, "sha256": "f5a4b84a7729ca4f314d51641c4318ed238401bff023599a94cc1e065b42679d" }, + "pdf_document_properties": { + "bytes": 16384, + "sha256": "5cd0b0e415dba836b9c6d46d1c79a93e080b63622179944d2ce43a040a80bf3e", + "measured_on": "2026-09-29" + }, + "pdf_mixed_pages": { + "bytes": 32768, + "sha256": "b108f98a71e7ece73248cc6abbc78eece506b626a410ce35bddb2f94701c753c", + "measured_on": "2026-09-29" + }, "png_64kib": { "bytes": 65536, "sha256": "65c6153643fe861e3e32149cefb638f5209a9c783cf702e655585d4e405ff657", diff --git a/internal/guard/testdata/screens/catalogue.png b/internal/guard/testdata/screens/catalogue.png index 9ad4df7c..5d65aeae 100644 Binary files a/internal/guard/testdata/screens/catalogue.png and b/internal/guard/testdata/screens/catalogue.png differ diff --git a/internal/guard/testdata/screens/catalogue.xml b/internal/guard/testdata/screens/catalogue.xml index 12416e84..532e3b89 100644 --- a/internal/guard/testdata/screens/catalogue.xml +++ b/internal/guard/testdata/screens/catalogue.xml @@ -2531,7 +2531,7 @@ - worked out from the size + not set diff --git a/internal/guard/testdata/screens/generate-pdf-settings.png b/internal/guard/testdata/screens/generate-pdf-settings.png new file mode 100644 index 00000000..fafbac7e Binary files /dev/null and b/internal/guard/testdata/screens/generate-pdf-settings.png differ diff --git a/internal/guard/testdata/screens/generate-pdf-settings.xml b/internal/guard/testdata/screens/generate-pdf-settings.xml new file mode 100644 index 00000000..3bc07653 --- /dev/null +++ b/internal/guard/testdata/screens/generate-pdf-settings.xml @@ -0,0 +1,883 @@ + + + + + + + + + + + Single batch + + + + + Presets + + + + + Several batches + + + + + About + + + + + + + + + + + + + + + + Single batch + + + + + + + Files of one format and one size, with a manifest that says how the system under test should react to them. + + + + + + + + + + + + + + + + + + + + + + File configuration + + + + + + + + + + + + + + Format + + + + + + + + + + + + + pdf + + + + + + + + + + + + + + + Size + + * + + + + + + + + + + + + + + + + 10mb + + + + + + + + + + + 10 485 760 B + + + + + + + + + How many files + + * + + + + + + + + + + + 1 + + + + + + + + + + + + + Damage + + + + + + + + + + + + + none + + + + + + + + + + + + + + Batch name + + * + + + + + + + + + + + + + + + + files + + + + + + + + + + + + + File names + + + + + + + + + + + + + + + files_0001 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Settings for pdf + + + + + + + + + + + + + + Pages + + + + + + + + + + + + + + + default: 1 + + + + + + + + + + + + + + + + Page size + + + + + + + + + + + + + a4 + + + + + + + + + + + + + + Orientation + + + + + + + + + + + + + portrait + + + + + + + + + + + + + + Rotate + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + Pdf version + + + + + + + + + + + + + 1.7 + + + + + + + + + + + + + + Document properties + + + + + + + Title + + + + + + + + + + + + + + + not set + + + + + + + + + + + + + + + + Author + + + + + + + + + + + + + + + not set + + + + + + + + + + + + + + + + Subject + + + + + + + + + + + + + + + not set + + + + + + + + + + + + + + + + Keywords + + + + + + + + + + + + + + + not set + + + + + + + + + + + + + + + + Creator + + + + + + + + + + + + + + + not set + + + + + + + + + + + + + + + + Producer + + + + + + + + + + + + + + + default: Testing Files Generator + + + + + + + + + + + + + + + + Created + + + + + + + + + + + + + + + default: 2020-01-01T00:00:00Z + + + + + + + + + + + + + + + + Modified + + + + + + + + + + + + + + + not set + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Output + + + + + + + + + + + + + + + Output directory + + * + + + + + + + + + + + + + + + + /tfg/out + + + + + + + + Choose... + + + + + + + + + + + Seed + + * + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + + + Label in each file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Preview + + + + + Generate + + + + + + + + + 1 file · 10.0 MB (10 485 760 B) · pdf · will go to /tfg/out + + + + + + + + + + + + + + + Donate + + + + + + + + + + + + + + + + diff --git a/internal/gui/parts/property.go b/internal/gui/parts/property.go index 60fd1e2f..aee98e78 100644 --- a/internal/gui/parts/property.go +++ b/internal/gui/parts/property.go @@ -163,13 +163,23 @@ func textField(p format.Property) PropertyField { } } -// leftAlone is what happens if this field is not touched. A declaration with no -// default means the format works the value out from the size it was asked for. +// leftAlone is what happens if this field is not touched. A number or a size +// with no default is worked out from the size the file was asked for. +// +// Free text with no default is not, and until 2026-09-29 its box said it was: +// the password of an archive read "worked out from the size". Nothing is +// worked out there - an empty password is no password - and the same would +// have been said of the author of a PDF. What leaving it empty gives is the +// format's to say, in the sentence beside the box. func leftAlone(p format.Property) string { - if p.Default == "" { + switch { + case p.Default != "": + return text.PlaceholderLeftEmpty(p.Default) + case p.Kind == format.PropertyText: + return text.PlaceholderNotSet() + default: return text.PlaceholderWorkedOut() } - return text.PlaceholderLeftEmpty(p.Default) } // PropertyFields draws every field one format declares, in the order it @@ -221,7 +231,13 @@ func DeclaredFields(declared []format.Property, into *Fields, tips *Tips) ([]Pro fields := make([]PropertyField, 0, len(declared)) objects := make([]fyne.CanvasObject, 0, len(declared)) - for _, p := range declared { + for i, p := range declared { + // A block of settings opens with its name, drawn as the name of a block + // 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)) + } f := FromProperty(p) fields = append(fields, f) // A setting the format itself calls a size gets its count of bytes. diff --git a/internal/gui/text/locale/en.json b/internal/gui/text/locale/en.json index d392e35b..35f95300 100644 --- a/internal/gui/text/locale/en.json +++ b/internal/gui/text/locale/en.json @@ -393,6 +393,10 @@ "description": "Stands in an empty box, in a quieter colour than a value. Carries one value, {{.Value}}, which has to stay spelled exactly that way.", "other": "default: {{.Value}}" }, + "PlaceholderNotSet": { + "description": "Stands in an empty box, in a quieter colour than a value.", + "other": "not set" + }, "PlaceholderNotStated": { "description": "Stands in an empty box, in a quieter colour than a value.", "other": "not stated" diff --git a/internal/gui/text/screens.go b/internal/gui/text/screens.go index 2a258095..1c2084c5 100644 --- a/internal/gui/text/screens.go +++ b/internal/gui/text/screens.go @@ -252,6 +252,11 @@ func PresetCatchesHeading() string { return say("PresetCatchesHeading", "Typical // comes from the size that was asked for. func PlaceholderWorkedOut() string { return say("PlaceholderWorkedOut", "worked out from the size") } +// PlaceholderNotSet stands in a free text setting that has no default. Left +// empty, the format goes without it - no password, no author - and what that +// means for the file is said in the sentence beside the box. +func PlaceholderNotSet() string { return say("PlaceholderNotSet", "not set") } + // PlaceholderFilter stands in the box at the top of an open list of formats, // where typing narrows the list to the formats whose identifier holds what was // typed, or whose name or kind has a word starting with it. diff --git a/web/content/en/site.json b/web/content/en/site.json index 8239b663..fff7c95d 100644 --- a/web/content/en/site.json +++ b/web/content/en/site.json @@ -241,7 +241,9 @@ "format ids separated by commas, or all": "format ids separated by commas, or all", "extensions separated by commas": "extensions separated by commas", "the id of a format, as tfg formats lists them": "the id of a format, as tfg formats lists them", - "the password, in plain text": "the password, in plain text" + "the password, in plain text": "the password, in plain text", + "any text": "any text", + "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none" }, "faq": [ { diff --git a/web/content/pl/site.json b/web/content/pl/site.json index 375eecee..5b2eb147 100644 --- a/web/content/pl/site.json +++ b/web/content/pl/site.json @@ -241,7 +241,9 @@ "format ids separated by commas, or all": "identyfikatory formatów rozdzielone przecinkami albo all", "extensions separated by commas": "rozszerzenia rozdzielone przecinkami", "the id of a format, as tfg formats lists them": "identyfikator formatu, tak jak wypisuje go tfg formats", - "the password, in plain text": "hasło, zwykłym tekstem" + "the password, in plain text": "hasło, zwykłym tekstem", + "any text": "dowolny tekst", + "a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none": "data, np. 2024-02-29 albo 2024-02-29T13:45:00+02:00, albo none" }, "faq": [ { diff --git a/web/public/formats/index.html b/web/public/formats/index.html index 3826fdeb..5f467709 100644 --- a/web/public/formats/index.html +++ b/web/public/formats/index.html @@ -540,7 +540,62 @@

Settings each format accepts

page_size - a3, a4, a5, legal, letter + a3, a4, a5, legal, letter, mixed + + + + orientation + landscape, mixed, portrait + + + + rotate + 0, 90, 180, 270 + + + + pdf_version + 1.4, 1.7 + + + + title + any text + + + + author + any text + + + + subject + any text + + + + keywords + any text + + + + creator + any text + + + + producer + any text + + + + created + a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none + + + + modified + a date such as 2024-02-29 or 2024-02-29T13:45:00+02:00, or none png diff --git a/web/public/pl/formaty/index.html b/web/public/pl/formaty/index.html index ccc577a5..d2599df3 100644 --- a/web/public/pl/formaty/index.html +++ b/web/public/pl/formaty/index.html @@ -540,7 +540,62 @@

Ustawienia, które przyjmuje każdy format

page_size - a3, a4, a5, legal, letter + a3, a4, a5, legal, letter, mixed + + + + orientation + landscape, mixed, portrait + + + + rotate + 0, 90, 180, 270 + + + + pdf_version + 1.4, 1.7 + + + + title + dowolny tekst + + + + author + dowolny tekst + + + + subject + dowolny tekst + + + + keywords + dowolny tekst + + + + creator + dowolny tekst + + + + producer + dowolny tekst + + + + created + data, np. 2024-02-29 albo 2024-02-29T13:45:00+02:00, albo none + + + + modified + data, np. 2024-02-29 albo 2024-02-29T13:45:00+02:00, albo none png