diff --git a/internal/format/pdf/document.go b/internal/format/pdf/document.go new file mode 100644 index 00000000..45f7dd89 --- /dev/null +++ b/internal/format/pdf/document.go @@ -0,0 +1,62 @@ +package pdf + +// The document itself: its objects, the cross reference table and the trailer. + +import ( + "bytes" + "fmt" + "strings" +) + +// document builds everything up to the trailer, and the closing lines. +// +// The two are returned apart because the padding goes between them. The +// closing lines carry the offset of the cross reference table, which sits in +// 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") + // 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++ { + // 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, "<>") + objects = append(objects, fmt.Sprintf("<>", + strings.Join(kids, " "), m.pages)) + objects = append(objects, "<>") + objects = append(objects, infoObject(m)) + + for i := 0; i < m.pages; i++ { + content := pageContent(m, i) + objects = append(objects, fmt.Sprintf( + "<>>>>>", + m.pageSize.width, m.pageSize.height, 6+i*2)) + objects = append(objects, fmt.Sprintf("<>\nstream\n%sendstream", len(content), content)) + } + + offsets := make([]int, 0, len(objects)) + for i, obj := range objects { + offsets = append(offsets, body.Len()) + fmt.Fprintf(&body, "%d 0 obj\n%s\nendobj\n", i+1, obj) + } + + xrefPos := body.Len() + fmt.Fprintf(&body, "xref\n0 %d\n", len(objects)+1) + body.WriteString("0000000000 65535 f \n") + for _, off := range offsets { + fmt.Fprintf(&body, "%010d 00000 n \n", off) + } + fmt.Fprintf(&body, "trailer\n<>\n", len(objects)+1) + + suffix = []byte(fmt.Sprintf("startxref\n%d\n%%%%EOF\n", xrefPos)) + return body.Bytes(), suffix +} diff --git a/internal/format/pdf/info.go b/internal/format/pdf/info.go new file mode 100644 index 00000000..e8c939ab --- /dev/null +++ b/internal/format/pdf/info.go @@ -0,0 +1,27 @@ +package pdf + +// The document information dictionary and the strings written into it. + +import ( + "fmt" + "strings" +) + +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)) +} + +// escapeString protects the three characters that end or nest a PDF string. +// Without this a label containing a bracket would produce a file no reader +// can parse. +func escapeString(s string) string { + r := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`) + return r.Replace(s) +} diff --git a/internal/format/pdf/minimum.go b/internal/format/pdf/minimum.go new file mode 100644 index 00000000..e4e64ccc --- /dev/null +++ b/internal/format/pdf/minimum.go @@ -0,0 +1,29 @@ +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" + } + return "" +} + +func cleanHint(label bool) string { + if label { + return ", ask for fewer pages by setting pages to 1, or drop the label" + } + 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. +func minimumBytes() int64 { + m := memo{pages: 1, pageSize: pageSizes["a4"]} + prefix, suffix := document(m) + return int64(len(prefix) + len(suffix)) +} diff --git a/internal/format/pdf/padding.go b/internal/format/pdf/padding.go new file mode 100644 index 00000000..e4f3fde1 --- /dev/null +++ b/internal/format/pdf/padding.go @@ -0,0 +1,101 @@ +package pdf + +// The padding: a comment block that brings the document to the size asked for. + +import ( + "context" + "fmt" + "io" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +const ( + // padChunkSize is how much padding is built before each write, and how + // often cancellation is noticed. + padChunkSize = 32 * 1024 + + // A comment is at least a per cent sign and a newline, so one byte of + // padding is the single amount that cannot be produced. + minComment = 2 +) + +// The padding channel is a comment block placed after the trailer and before +// startxref. +// +// The obvious place, and the one this project's own notes assumed, is between +// startxref and %%EOF. Measured, that is wrong twice over. +// +// A reader finds the cross reference table by scanning backwards from the end +// of the file for the startxref keyword, and how far back it looks is up to +// the reader. Xpdf 4.06 reads a comment of 1004 bytes there and fails at +// 1005. The Windows renderer reads any size. So that placement produces files +// that open for one tester and not for another, which is worse than a +// placement that fails for everybody. +// +// After the trailer nothing downstream refers to a position: the objects and +// the cross reference table both sit earlier, and startxref still points at +// the same offset. Measured at 64 bytes, 4 KB, 100 KB, 1 MB, 2 MB and 10 MB +// against Xpdf, exiftool and the Windows renderer - all read the document and +// report one page. + +// writeComment emits the padding as a comment block without holding it in +// memory. Every line starts with a per cent sign, so a reader skips the lot. +func writeComment(ctx context.Context, w io.Writer, seed uint64, n int64) error { + if n <= 0 { + return nil + } + if n < minComment { + return fmt.Errorf("pdf: %d B of padding cannot be written as a comment", n) + } + + // Line length is fixed so the block stays readable in an editor. The last + // line takes whatever is left. + const lineLen = 64 + rng := core.NewRand(seed) + buf := make([]byte, 0, padChunkSize+lineLen) + + remaining := n + for remaining > 0 { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + buf = buf[:0] + for len(buf) < padChunkSize && remaining > int64(len(buf)) { + left := remaining - int64(len(buf)) + size := int64(lineLen) + switch { + case left <= lineLen: + // Everything left goes on one line. The caller guaranteed + // this is never exactly one byte. + size = left + case left-lineLen < minComment: + // A full line here would leave one byte behind, and one byte + // cannot be a comment. Shorten this line so the last one has + // room. Found by the property test, not by reasoning. + size = lineLen - minComment + } + if size < minComment { + return fmt.Errorf("pdf: %d B left over, which cannot form a comment line", size) + } + buf = append(buf, '%') + for i := int64(0); i < size-2; i++ { + buf = append(buf, padAlphabet[rng.IntN(len(padAlphabet))]) + } + buf = append(buf, '\n') + } + + if _, err := w.Write(buf); err != nil { + return err + } + remaining -= int64(len(buf)) + } + return nil +} + +// padAlphabet keeps the padding printable, so opening the file in an editor +// shows comment lines rather than binary noise. +var padAlphabet = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") diff --git a/internal/format/pdf/page.go b/internal/format/pdf/page.go new file mode 100644 index 00000000..6390eae7 --- /dev/null +++ b/internal/format/pdf/page.go @@ -0,0 +1,115 @@ +package pdf + +// What a page says: its heading, the body text and the footer label. + +import ( + "fmt" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" +) + +func pageContent(m memo, index int) string { + var b strings.Builder + top := m.pageSize.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))) + b.WriteString("ET\n") + + b.WriteString("BT\n/F1 11 Tf\n") + rng := core.NewRand(core.FileSeed(m.seed, index)) + y := top - 36 + for line := 0; line < 24 && y > 108; line++ { + fmt.Fprintf(&b, "1 0 0 1 72 %d Tm\n(%s) Tj\n", y, escapeString(bodyLine(rng))) + y -= 16 + } + b.WriteString("ET\n") + + // The label lives in the footer, where it does not sit on top of the + // content a person is looking at. + if m.label != "" { + b.WriteString("BT\n/F1 8 Tf\n") + fmt.Fprintf(&b, "72 48 Td\n(%s) Tj\n", escapeString(m.label)) + b.WriteString("ET\n") + } + return b.String() +} + +// lineWidth is how many characters every line of body text comes to. +// +// Fixed, and that is the whole of the point. The words are still drawn from the +// seed, so two seeds give different text - only the length is settled, the same +// way every record based format here settles the length of its closing record. +// +// Why it has to be fixed was measured rather than argued. A line used to be +// eight to thirteen words of four to nine characters, so the smallest document +// one seed could produce was not the smallest another could: +// +// the floor across 200 seeds 3090 B to 3499 B +// --size 3300 accepted for 6 seeds out of 10 +// +// An error that appears and disappears when the seed changes is the one thing a +// tool built on "the same seed gives the same run" cannot have, and the engine +// says exactly that about a size drawn from a range. Nobody had applied it to +// the minimum itself. +// +// The first repair kept the ragged lines and took the theoretical worst case as +// the floor. That was honest and consistent, and it cost about 1400 B of range +// that no request could reach any more. This is the answer that costs the user +// nothing, and it pays for it by shifting every byte this format produces - +// a breaking change, taken deliberately while nothing depends on those bytes. +// +// Eighty four characters is close to what the old lines averaged, so a page +// still looks like a page. Justified rather than ragged, which if anything +// reads more like a real document than the random lengths did. +const lineWidth = 84 + +// bodyLine is one line of body text: drawn from the seed, always lineWidth +// characters long, and never ending in the middle of a word. +// +// core.AppendFiller does almost this and is not used, which is worth saying +// because sharing a primitive is usually the right answer here. That one cuts +// at the byte and makes the difference up with spaces, which is exactly right +// where it is used - inside a field of a CSV row or a JSON value, where a +// clipped word is padding nobody reads. This text is the visible content of a +// page. A document whose every line ends in "refere" reads as broken rather +// than as filler, and the fidelity bar for this format is that it looks like a +// document in Adobe. +// +// So: whole words while the next one still fits, then spaces. The line is the +// same length either way, which is the property the floor depends on. +func bodyLine(rng interface{ IntN(int) int }) string { + var b strings.Builder + b.Grow(lineWidth) + + // Started at a word the seed chose and walked in order, so two seeds read + // differently without needing a draw per word. + at := rng.IntN(len(words)) + for { + w := words[at%len(words)] + need := len(w) + if b.Len() > 0 { + need++ // the space in front of it + } + if b.Len()+need > lineWidth { + break + } + if b.Len() > 0 { + b.WriteByte(' ') + } + b.WriteString(w) + at++ + } + // Justified to the fixed width. Every vocabulary here is ASCII, so a byte + // and a character are the same thing and the count is the width. + return b.String() + strings.Repeat(" ", lineWidth-b.Len()) +} + +var words = []string{ + "account", "amount", "balance", "branch", "client", "column", "contract", + "customer", "delivery", "document", "invoice", "item", "ledger", "note", + "order", "payment", "period", "product", "quantity", "receipt", "record", + "reference", "region", "report", "sample", "service", "shipment", + "statement", "summary", "supplier", "total", "transfer", "unit", "value", +} diff --git a/internal/format/pdf/pdf.go b/internal/format/pdf/pdf.go index 50c696ec..14d55ed8 100644 --- a/internal/format/pdf/pdf.go +++ b/internal/format/pdf/pdf.go @@ -5,50 +5,16 @@ package pdf import ( - "bytes" "context" "fmt" "io" "strconv" - "strings" "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/format" ) -const ( - generatorVersion = "1" - - // padChunkSize is how much padding is built before each write, and how - // often cancellation is noticed. - padChunkSize = 32 * 1024 - - // A comment is at least a per cent sign and a newline, so one byte of - // padding is the single amount that cannot be produced. - minComment = 2 - - defaultPages = 1 - maxPages = 5000 -) - -// The padding channel is a comment block placed after the trailer and before -// startxref. -// -// The obvious place, and the one this project's own notes assumed, is between -// startxref and %%EOF. Measured, that is wrong twice over. -// -// A reader finds the cross reference table by scanning backwards from the end -// of the file for the startxref keyword, and how far back it looks is up to -// the reader. Xpdf 4.06 reads a comment of 1004 bytes there and fails at -// 1005. The Windows renderer reads any size. So that placement produces files -// that open for one tester and not for another, which is worse than a -// placement that fails for everybody. -// -// After the trailer nothing downstream refers to a position: the objects and -// the cross reference table both sit earlier, and startxref still points at -// the same offset. Measured at 64 bytes, 4 KB, 100 KB, 1 MB, 2 MB and 10 MB -// against Xpdf, exiftool and the Windows renderer - all read the document and -// report one page. +const generatorVersion = "1" func init() { format.Register(format.Descriptor{ @@ -107,19 +73,6 @@ type memo struct { padLen int64 } -type pageSize struct { - name string - width, height int -} - -var pageSizes = map[string]pageSize{ - "a4": {"A4", 595, 842}, - "a3": {"A3", 842, 1191}, - "a5": {"A5", 420, 595}, - "letter": {"Letter", 612, 792}, - "legal": {"Legal", 612, 1008}, -} - func (generator) Plan(r format.Request) (format.Plan, error) { pages, err := pageCount(r.Properties) if err != nil { @@ -192,24 +145,6 @@ func (generator) Plan(r format.Request) (format.Plan, error) { return p, nil } -// 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" - } - return "" -} - -func cleanHint(label bool) string { - if label { - return ", ask for fewer pages by setting pages to 1, or drop the label" - } - return " or ask for fewer pages by setting pages to 1" -} - func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { m, ok := p.Memo.(memo) if !ok { @@ -231,292 +166,3 @@ func (generator) Write(ctx context.Context, w io.Writer, p format.Plan) error { _, err := w.Write(m.suffix) return err } - -// writeComment emits the padding as a comment block without holding it in -// memory. Every line starts with a per cent sign, so a reader skips the lot. -func writeComment(ctx context.Context, w io.Writer, seed uint64, n int64) error { - if n <= 0 { - return nil - } - if n < minComment { - return fmt.Errorf("pdf: %d B of padding cannot be written as a comment", n) - } - - // Line length is fixed so the block stays readable in an editor. The last - // line takes whatever is left. - const lineLen = 64 - rng := core.NewRand(seed) - buf := make([]byte, 0, padChunkSize+lineLen) - - remaining := n - for remaining > 0 { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - buf = buf[:0] - for len(buf) < padChunkSize && remaining > int64(len(buf)) { - left := remaining - int64(len(buf)) - size := int64(lineLen) - switch { - case left <= lineLen: - // Everything left goes on one line. The caller guaranteed - // this is never exactly one byte. - size = left - case left-lineLen < minComment: - // A full line here would leave one byte behind, and one byte - // cannot be a comment. Shorten this line so the last one has - // room. Found by the property test, not by reasoning. - size = lineLen - minComment - } - if size < minComment { - return fmt.Errorf("pdf: %d B left over, which cannot form a comment line", size) - } - buf = append(buf, '%') - for i := int64(0); i < size-2; i++ { - buf = append(buf, padAlphabet[rng.IntN(len(padAlphabet))]) - } - buf = append(buf, '\n') - } - - if _, err := w.Write(buf); err != nil { - return err - } - remaining -= int64(len(buf)) - } - return nil -} - -// padAlphabet keeps the padding printable, so opening the file in an editor -// shows comment lines rather than binary noise. -var padAlphabet = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - -// document builds everything up to the trailer, and the closing lines. -// -// The two are returned apart because the padding goes between them. The -// closing lines carry the offset of the cross reference table, which sits in -// 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") - // 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++ { - // 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, "<>") - objects = append(objects, fmt.Sprintf("<>", - strings.Join(kids, " "), m.pages)) - objects = append(objects, "<>") - objects = append(objects, infoObject(m)) - - for i := 0; i < m.pages; i++ { - content := pageContent(m, i) - objects = append(objects, fmt.Sprintf( - "<>>>>>", - m.pageSize.width, m.pageSize.height, 6+i*2)) - objects = append(objects, fmt.Sprintf("<>\nstream\n%sendstream", len(content), content)) - } - - offsets := make([]int, 0, len(objects)) - for i, obj := range objects { - offsets = append(offsets, body.Len()) - fmt.Fprintf(&body, "%d 0 obj\n%s\nendobj\n", i+1, obj) - } - - xrefPos := body.Len() - fmt.Fprintf(&body, "xref\n0 %d\n", len(objects)+1) - body.WriteString("0000000000 65535 f \n") - for _, off := range offsets { - fmt.Fprintf(&body, "%010d 00000 n \n", off) - } - fmt.Fprintf(&body, "trailer\n<>\n", len(objects)+1) - - suffix = []byte(fmt.Sprintf("startxref\n%d\n%%%%EOF\n", xrefPos)) - return body.Bytes(), suffix -} - -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)) -} - -func pageContent(m memo, index int) string { - var b strings.Builder - top := m.pageSize.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))) - b.WriteString("ET\n") - - b.WriteString("BT\n/F1 11 Tf\n") - rng := core.NewRand(core.FileSeed(m.seed, index)) - y := top - 36 - for line := 0; line < 24 && y > 108; line++ { - fmt.Fprintf(&b, "1 0 0 1 72 %d Tm\n(%s) Tj\n", y, escapeString(bodyLine(rng))) - y -= 16 - } - b.WriteString("ET\n") - - // The label lives in the footer, where it does not sit on top of the - // content a person is looking at. - if m.label != "" { - b.WriteString("BT\n/F1 8 Tf\n") - fmt.Fprintf(&b, "72 48 Td\n(%s) Tj\n", escapeString(m.label)) - b.WriteString("ET\n") - } - return b.String() -} - -// lineWidth is how many characters every line of body text comes to. -// -// Fixed, and that is the whole of the point. The words are still drawn from the -// seed, so two seeds give different text - only the length is settled, the same -// way every record based format here settles the length of its closing record. -// -// Why it has to be fixed was measured rather than argued. A line used to be -// eight to thirteen words of four to nine characters, so the smallest document -// one seed could produce was not the smallest another could: -// -// the floor across 200 seeds 3090 B to 3499 B -// --size 3300 accepted for 6 seeds out of 10 -// -// An error that appears and disappears when the seed changes is the one thing a -// tool built on "the same seed gives the same run" cannot have, and the engine -// says exactly that about a size drawn from a range. Nobody had applied it to -// the minimum itself. -// -// The first repair kept the ragged lines and took the theoretical worst case as -// the floor. That was honest and consistent, and it cost about 1400 B of range -// that no request could reach any more. This is the answer that costs the user -// nothing, and it pays for it by shifting every byte this format produces - -// a breaking change, taken deliberately while nothing depends on those bytes. -// -// Eighty four characters is close to what the old lines averaged, so a page -// still looks like a page. Justified rather than ragged, which if anything -// reads more like a real document than the random lengths did. -const lineWidth = 84 - -// bodyLine is one line of body text: drawn from the seed, always lineWidth -// characters long, and never ending in the middle of a word. -// -// core.AppendFiller does almost this and is not used, which is worth saying -// because sharing a primitive is usually the right answer here. That one cuts -// at the byte and makes the difference up with spaces, which is exactly right -// where it is used - inside a field of a CSV row or a JSON value, where a -// clipped word is padding nobody reads. This text is the visible content of a -// page. A document whose every line ends in "refere" reads as broken rather -// than as filler, and the fidelity bar for this format is that it looks like a -// document in Adobe. -// -// So: whole words while the next one still fits, then spaces. The line is the -// same length either way, which is the property the floor depends on. -func bodyLine(rng interface{ IntN(int) int }) string { - var b strings.Builder - b.Grow(lineWidth) - - // Started at a word the seed chose and walked in order, so two seeds read - // differently without needing a draw per word. - at := rng.IntN(len(words)) - for { - w := words[at%len(words)] - need := len(w) - if b.Len() > 0 { - need++ // the space in front of it - } - if b.Len()+need > lineWidth { - break - } - if b.Len() > 0 { - b.WriteByte(' ') - } - b.WriteString(w) - at++ - } - // Justified to the fixed width. Every vocabulary here is ASCII, so a byte - // and a character are the same thing and the count is the width. - return b.String() + strings.Repeat(" ", lineWidth-b.Len()) -} - -// escapeString protects the three characters that end or nest a PDF string. -// Without this a label containing a bracket would produce a file no reader -// can parse. -func escapeString(s string) string { - r := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`) - return r.Replace(s) -} - -func pageCount(props map[string]string) (int, error) { - raw, ok := props["pages"] - if !ok || raw == "" { - return defaultPages, nil - } - n, err := strconv.Atoi(raw) - if err != nil { - return 0, fmt.Errorf("pdf: pages must be a whole number, got %q", raw) - } - if n < 1 || n > maxPages { - return 0, fmt.Errorf("pdf: pages must be between 1 and %d, got %d", maxPages, n) - } - return n, nil -} - -func paperSize(props map[string]string) (pageSize, error) { - raw, ok := props["page_size"] - if !ok || raw == "" { - return pageSizes["a4"], nil - } - s, ok := pageSizes[strings.ToLower(raw)] - if !ok { - names := make([]string, 0, len(pageSizes)) - 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 s, nil -} - -func sorted(in []string) []string { - out := append([]string(nil), in...) - for i := range out { - for j := i + 1; j < len(out); j++ { - if out[j] < out[i] { - out[i], out[j] = out[j], out[i] - } - } - } - return out -} - -// minimumBytes is the smallest document this generator can produce: one A4 -// page with no label. Measured at start up rather than guessed. -func minimumBytes() int64 { - m := memo{pages: 1, pageSize: pageSizes["a4"]} - prefix, suffix := document(m) - return int64(len(prefix) + len(suffix)) -} - -var words = []string{ - "account", "amount", "balance", "branch", "client", "column", "contract", - "customer", "delivery", "document", "invoice", "item", "ledger", "note", - "order", "payment", "period", "product", "quantity", "receipt", "record", - "reference", "region", "report", "sample", "service", "shipment", - "statement", "summary", "supplier", "total", "transfer", "unit", "value", -} diff --git a/internal/format/pdf/settings.go b/internal/format/pdf/settings.go new file mode 100644 index 00000000..b013f197 --- /dev/null +++ b/internal/format/pdf/settings.go @@ -0,0 +1,70 @@ +package pdf + +// Reading the settings a request carries: how many pages and which paper. + +import ( + "fmt" + "strconv" + "strings" +) + +const ( + defaultPages = 1 + maxPages = 5000 +) + +type pageSize struct { + name string + width, height int +} + +var pageSizes = map[string]pageSize{ + "a4": {"A4", 595, 842}, + "a3": {"A3", 842, 1191}, + "a5": {"A5", 420, 595}, + "letter": {"Letter", 612, 792}, + "legal": {"Legal", 612, 1008}, +} + +func pageCount(props map[string]string) (int, error) { + raw, ok := props["pages"] + if !ok || raw == "" { + return defaultPages, nil + } + n, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("pdf: pages must be a whole number, got %q", raw) + } + if n < 1 || n > maxPages { + return 0, fmt.Errorf("pdf: pages must be between 1 and %d, got %d", maxPages, n) + } + return n, nil +} + +func paperSize(props map[string]string) (pageSize, error) { + raw, ok := props["page_size"] + if !ok || raw == "" { + return pageSizes["a4"], nil + } + s, ok := pageSizes[strings.ToLower(raw)] + if !ok { + names := make([]string, 0, len(pageSizes)) + 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 s, nil +} + +func sorted(in []string) []string { + out := append([]string(nil), in...) + for i := range out { + for j := i + 1; j < len(out); j++ { + if out[j] < out[i] { + out[i], out[j] = out[j], out[i] + } + } + } + return out +}