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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ recipe. `tfg formats <id>` 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` |
Expand Down
9 changes: 8 additions & 1 deletion internal/cli/formats.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions internal/format/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 20 additions & 7 deletions internal/format/pdf/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,44 @@ 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))
}

objects = append(objects, "<</Type/Catalog/Pages 2 0 R>>")
objects = append(objects, fmt.Sprintf("<</Type/Pages/Kids[%s]/Count %d>>",
strings.Join(kids, " "), m.pages))
strings.Join(kids, " "), pages))
objects = append(objects, "<</Type/Font/Subtype/Type1/BaseFont/Helvetica/Encoding/WinAnsiEncoding>>")
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(
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 %d %d]/Contents %d 0 R/Resources<</Font<</F1 3 0 R>>>>>>",
m.pageSize.width, m.pageSize.height, 6+i*2))
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 %d %d]%s/Contents %d 0 R/Resources<</Font<</F1 3 0 R>>>>>>",
paper.width, paper.height, rotate, 6+i*2))
objects = append(objects, fmt.Sprintf("<</Length %d>>\nstream\n%sendstream", len(content), content))
}

Expand Down
235 changes: 227 additions & 8 deletions internal/format/pdf/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("<</Title(%s)/Producer(Testing Files Generator)/CreationDate(D:20200101000000Z)>>",
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.
Expand Down
Loading
Loading