From 404977ef5d8cf5e676b4dca7a8dba420b0ff23c4 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 27 Aug 2026 21:50:22 +0200 Subject: [PATCH] The rest of the verbs: marks, protection, and reading a page back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more groups on the strip and two of the four filled out. What was left of pdfops after the first half is here, less the three that have no place in a workbench and are named below. Marks a watermark, page numbers in a shape somebody chose, a Bates number padded and started where they say, a stamp in one of nine places — over a range of its own, defaulting to every page, because a page number is nearly always wanted on all of them File sanitize, flatten the annotations in, drop the annotations, the bookmarks or what the file says about itself, pack it smaller, give it a title and an author Protect open a file that has a password, put a user and an owner password on this one and say what a reader may do with it, take the protection off, and ask what the file it came from was protected with Read what the page says, and what it carries PROTECTING A FILE WITHOUT BREAKING THE ONE PROMISE The workbench draws a document by writing it out and reading it back, so a document about to be written behind a password could not be drawn at all: the page would have been replaced by "this document cannot be read back". It reads its own output back with the password just given instead, so a protected document is still drawn. It is not the same bytes twice — encryption needs randomness, by design — but it is the same document, protected the same way. The other half is the password to open a file that already has one. That box is in the same panel, and the panel opens with no document: type the password, then press Open. Without it, decrypt was a verb that could only ever be offered for files it was not needed on. READING WITHOUT PRETENDING Nothing in Read changes the document, and what it reads it reads out of the document written and read back — so what it lists is what would come out of Save, which is the same rule the rest of the workbench keeps. The list of pictures says "what the page itself places", and that wording is measured rather than hedged. Against poppler's pdfimages over the 157 of 400 real form pages that carry any picture at all, the two agree on 99. Every one of the 58 differences is us listing FEWER, never more, and both kinds were run down: pdfimages lists a stencil or soft mask as a row of its own beside the picture it belongs to, and it lists pictures drawn by an annotation's appearance stream. Removing the annotations from cerfa_10011.pdf and asking pdfimages again gives exactly the two pictures we list. Text, against pdftotext on the same 400 first pages: both find words on 395, and the counts agree within 10% on 383 of those (97.0%). The five worst are 1999 US Department of Labor forms where we find ten words and pdftotext finds between 230 and 530 — a gap in the extractor rather than in this workbench, and worth a look there. LEFT OUT, ON PURPOSE fields the Fill in panel already is this, and better fill the same permissions folded into Protect, which shows what a file allows rather than printing it MEASURED Twenty verbs over the same 400 real forms from the eleven issuing bodies — 8 000 outputs, each written by ops, read back by our own reader, parsed by pdfinfo and drawn by pdftoppm with ink counted on the page. The encrypted ones were given to poppler with -upw, the way the workbench gives them to itself. 7 911 of 7 911 applicable outputs passed all four 89 not applicable: one-page documents where deleting page one would leave a document with none, which the panel refuses for that reason The browser check now demands that the file the tab hands back be a DIFFERENT file before it will call a press a verb. That change caught its own weakness on the first run: the sweep found the Read group, whose first control replaces the picture of the page with what the page says, and called it a verb because the canvas changed. It now sweeps past Read, Protect and File — none of which changes what is drawn — and stops on the watermark in Marks, whose mark is then found in the file that came out. Also: a picture handed over from the Read panel is no longer labelled a PDF on its way out of the tab. A browser told a JPEG is a PDF opens it in a viewer that cannot read it. Co-authored-by: Claude Opus 5 --- README.md | 42 ++-- browsercheck/check.go | 61 +++-- file.go | 227 +++++++++++++++++++ go.mod | 1 + go.sum | 2 + main.go | 26 ++- marks.go | 125 +++++++++++ panel.go | 115 +++++++--- panel_test.go | 39 ---- read.go | 143 ++++++++++++ scene.go | 38 ++-- scene_test.go | 4 +- verbs_test.go | 509 ++++++++++++++++++++++++++++++++++++++++++ 13 files changed, 1209 insertions(+), 123 deletions(-) create mode 100644 file.go create mode 100644 marks.go create mode 100644 read.go create mode 100644 verbs_test.go diff --git a/README.md b/README.md index 5f49b1f..dd14102 100644 --- a/README.md +++ b/README.md @@ -30,27 +30,43 @@ touch a server, and there is no server to touch. ## What is on the strip, and what is beside the page **Open** · **Save** · **<** · **>** · **Rotate** · **Delete**, and then -one control per group of verbs: **Pages** · **Sheet** · **Marks** · **File**, -with **Fill in** at the end for a document that carries a form. The arrow keys -turn pages too. +one control per group of verbs: **Pages** · **Sheet** · **Marks** · **File** · +**Protect** · **Read**, with **Fill in** at the end for a document that carries +a form. The arrow keys turn pages too. A strip cannot hold the rest. The verbs the library offers mostly have to be -told something first — which pages, how many to a sheet, what to write — and -there is nowhere on a row of buttons to say it. So a group opens a panel -**beside** the page rather than instead of it: +told something first — which pages, how many to a sheet, what to write, which +password — and there is nowhere on a row of buttons to say it. So a group opens +a panel **beside** the page rather than instead of it: - **Pages** — which pages to keep or drop, turn them a quarter, a half or three quarters, reverse the order, move the one on the screen, crop to a box, put a blank page in, split into files of *n* pages. - **Sheet** — *n* pages to a sheet, fold into a booklet, add another file after this one, lay another file over it. -- **Marks** — write a watermark across every page. -- **File** — strip what runs rather than shows. - -Beside, because every one of those changes the document and the document is -drawn from what would be saved: typing a crop box and watching the page come -back cropped is the whole point of the control, and a panel over the page -would hide the one thing worth looking at. +- **Marks** — a watermark, page numbers in a shape you choose, a Bates number + padded and started where you say, and a stamp in one of nine places. +- **File** — sanitize, flatten the annotations in, drop the annotations, the + bookmarks or what the file says about itself, pack it smaller, give it a + title and an author. +- **Protect** — open a file that has a password, put a user and an owner + password on this one and say what a reader may do with it, or take the + protection off. +- **Read** — what the page says, and what it carries: every picture on it, + listed with its size and its shape, each one handed over under a name that + says what it holds. + +A protected document is still drawn, because the workbench reads its own +output back with the password it was just given. It is not the same bytes +twice — encryption needs randomness, by design — but it is the same document, +protected the same way. + +Beside, because nearly every one of those changes the document and the document +is drawn from what would be saved: typing a crop box and watching the page come +back cropped is the whole point of the control, and a panel over the page would +hide the one thing worth looking at. **Read** is the exception that keeps the +rule — it changes nothing, and what it reads it reads out of the document +written and read back, so what it lists is what would come out of Save. Every change is applied to the document, written out, and read back before it is drawn — so what is on the screen is what would come out of Save, diff --git a/browsercheck/check.go b/browsercheck/check.go index a466006..77860d9 100644 --- a/browsercheck/check.go +++ b/browsercheck/check.go @@ -149,23 +149,29 @@ func check(ctx context.Context, c *conn, page, sample, shot, dl string) error { // driven here too, because a panel that is drawn and gets no events looks // exactly like one that works — which is what it was until the toolkit // underneath learned to hand a press to what is inside a scroll view. - marked, err := drivePanel(ctx, c, sid) + // What counts as a verb having run is that the file the tab hands back is + // a DIFFERENT file. Some of the panels change what is on the screen + // without changing the document at all — the Read group replaces the + // picture of the page with what the page says — and a check that stopped + // at the first press to redraw the canvas would call that a verb. + var again []byte + marked, err := drivePanel(ctx, c, sid, func() (bool, error) { + drain(c) + if err := click(ctx, c, sid, savedAt, stripY); err != nil { + return false, err + } + got, err := waitForPDF(ctx, dl, out, 6*time.Second) + if err != nil { + return false, nil // the same file back: nothing was changed + } + again = got + return true, nil + }) if err != nil { say(c) return err } - fmt.Println("a verb pressed in the panel changed the page:", marked) - - // And what the tab hands back now carries the mark, which is the whole - // claim: what is on the screen is what comes out of Save. - drain(c) - if err := click(ctx, c, sid, savedAt, 8+15); err != nil { - return err - } - again, err := waitForPDF(ctx, dl, out) - if err != nil { - return err - } + fmt.Println("a verb pressed in the panel changed the document:", marked) fmt.Printf("the tab handed back a %d byte PDF after the panel was used\n", len(again)) doc, err := reader.Open(again) if err != nil { @@ -203,7 +209,7 @@ const ( // // It works from the right hand end of the strip, so that the sweep never // presses the controls that drop a page. -func drivePanel(ctx context.Context, c *conn, sid string) (string, error) { +func drivePanel(ctx context.Context, c *conn, sid string, confirm func() (bool, error)) (string, error) { quiet, err := lookIn(ctx, c, sid, pageBand, 1) if err != nil { return "", err @@ -238,7 +244,7 @@ func drivePanel(ctx context.Context, c *conn, sid string) (string, error) { } tried[panel.Hash] = true fmt.Printf("a panel opened from x=%d\n", x) - hit, err := pressDownPanel(ctx, c, sid) + hit, err := pressDownPanel(ctx, c, sid, confirm) if err != nil { return "", err } @@ -257,9 +263,9 @@ func drivePanel(ctx context.Context, c *conn, sid string) (string, error) { return "", fmt.Errorf("no group on the strip opened a panel with a verb in it") } -// pressDownPanel presses down the open panel until the page beside it changes, -// and says where that press was. -func pressDownPanel(ctx context.Context, c *conn, sid string) (string, error) { +// pressDownPanel presses down the open panel until a press both changes the +// page beside it and changes the document, and says where that press was. +func pressDownPanel(ctx context.Context, c *conn, sid string, confirm func() (bool, error)) (string, error) { page, err := lookIn(ctx, c, sid, 0, pageBand) if err != nil { return "", err @@ -272,9 +278,22 @@ func pressDownPanel(ctx context.Context, c *conn, sid string) (string, error) { if err != nil { return "", err } - if changed { + if !changed { + continue + } + did, err := confirm() + if err != nil { + return "", err + } + if did { return fmt.Sprintf("pressed at y=%d", y), nil } + // It redrew and changed nothing that would be saved. Take the page as + // it now stands as the new baseline and carry on down the panel. + page, err = lookIn(ctx, c, sid, 0, pageBand) + if err != nil { + return "", err + } } return "", nil } @@ -303,9 +322,9 @@ func changedIn(ctx context.Context, c *conn, sid string, from, to float64, was i // waitForPDF waits for a PDF to land in the download directory that is not the // one already seen. -func waitForPDF(ctx context.Context, dl string, notThis []byte) ([]byte, error) { +func waitForPDF(ctx context.Context, dl string, notThis []byte, patience time.Duration) ([]byte, error) { var out []byte - err := until(ctx, 30*time.Second, func() (bool, error) { + err := until(ctx, patience, func() (bool, error) { files, _ := os.ReadDir(dl) for _, f := range files { b, err := os.ReadFile(filepath.Join(dl, f.Name())) diff --git a/file.go b/file.go new file mode 100644 index 0000000..25b8651 --- /dev/null +++ b/file.go @@ -0,0 +1,227 @@ +// The File and Protect groups: what happens to the file rather than to any +// page of it. +// +// Everything in File is a switch the writer is thrown before it writes, and +// none of them can be thrown back — the library takes each as a decision about +// the file that will be written, not as a setting to be toggled. So they are +// buttons that say what they did, rather than boxes to tick that would be +// stuck on once ticked. + +package main + +import ( + "fmt" + + "github.com/go-pdfkit/ops" + "github.com/go-pdfkit/reader" + "github.com/go-widgets/toolkit" +) + +// fileGroup is the panel. +func (s *state) fileGroup() *column { + box := newColumn() + box.add(button("Sanitize — drop what runs", toolkit.ButtonDefault, s.sanitize), bareH) + box.add(button("Flatten the annotations in", toolkit.ButtonDefault, s.flatten), bareH) + box.add(button("Drop every annotation", toolkit.ButtonDanger, s.dropAnnots), bareH) + box.add(button("Drop the bookmarks", toolkit.ButtonDanger, s.dropOutlines), bareH) + box.add(button("Drop what it says about itself", toolkit.ButtonDanger, s.clearInfo), bareH) + box.add(button("Pack it smaller", toolkit.ButtonDefault, s.compress), bareH) + + box.add(s.entryRow("Title", "what the document is called", "", + func(v string) { s.tools.title = v }), labelledH) + box.add(s.entryRow("Author", "who wrote it", "", + func(v string) { s.tools.author = v }), labelledH) + box.add(button("Say so in the file", toolkit.ButtonDefault, s.setInfo), bareH) + return box +} + +// flatten draws the annotations into the pages, so that what a form says is +// part of the page rather than a layer over it. +func (s *state) flatten() { + s.changeSaying("flattened what was drawn over the pages", func(d *ops.Doc) error { + d.Flatten() + return nil + }) +} + +// dropAnnots removes every annotation, links included. +func (s *state) dropAnnots() { + s.changeSaying("dropped every annotation", func(d *ops.Doc) error { + d.RemoveAnnotations() + return nil + }) +} + +// dropOutlines removes the bookmarks. +func (s *state) dropOutlines() { + s.changeSaying("dropped the bookmarks", func(d *ops.Doc) error { + d.DropOutlines() + return nil + }) +} + +// clearInfo drops what the file says about itself: who made it, with what, and +// when — which is the part of a document nobody means to send. +func (s *state) clearInfo() { + s.changeSaying("dropped what it said about itself", func(d *ops.Doc) error { + d.ClearInfo() + return nil + }) +} + +// compress packs the objects into compressed streams, which is what every +// writer since PDF 1.5 does. +func (s *state) compress() { + before := s.written() + if !s.changeSaying("packed", func(d *ops.Doc) error { + d.Compress() + return nil + }) { + return + } + after := s.written() + if before == 0 || after == 0 { + // The redraw is about to say which of the two steps went wrong, and it + // says it better than a pair of zeroes would. + return + } + // Saying how much smaller is the only way to know it did anything, and it + // costs a write that was going to happen anyway. + s.note = fmt.Sprintf("packed: %d bytes rather than %d", after, before) + s.refresh() +} + +// written is how large the file would be if it were saved now, or zero when +// there is nothing to write or it cannot be written at all — which the control +// that asked, or the redraw after it, says for itself. +func (s *state) written() int { + if s.doc == nil { + return 0 + } + out, msg := s.reopenBytes() + if msg != "" { + return 0 + } + return len(out) +} + +// setInfo puts a title and an author into the file. +func (s *state) setInfo() { + title, author := s.tools.title, s.tools.author + s.changeSaying("said who it is by", func(d *ops.Doc) error { + d.SetInfo("Title", title) + d.SetInfo("Author", author) + return nil + }) +} + +// allowed is what a reader of the protected file may do. +var allowed = []struct { + name string + bit reader.Permissions +}{ + {"print it", reader.PermPrint}, + {"change it", reader.PermModify}, + {"copy text out of it", reader.PermCopy}, + {"annotate it", reader.PermAnnotate}, + {"fill its forms in", reader.PermFillForms}, +} + +// protectGroup is the panel: a password to open a file that has one, and a +// password to put on the file that will be written. +func (s *state) protectGroup() *column { + box := newColumn() + box.add(s.secretRow("Password to open a file with", func(v string) { s.tools.openPw = v }), labelledH) + box.add(toolkit.NewLabel("Type it before pressing Open."), bareH) + + box.add(s.secretRow("User password", func(v string) { s.tools.userPw = v }), labelledH) + box.add(s.secretRow("Owner password", func(v string) { s.tools.ownerPw = v }), labelledH) + for _, a := range allowed { + name := a.name + box.add(tickRow("May "+name, s.tools.allow[name], + func(on bool) { s.tools.allow[name] = on }), bareH) + } + box.add(button("Protect it", toolkit.ButtonProminent, s.encrypt), bareH) + box.add(button("Take the protection off", toolkit.ButtonDanger, s.decrypt), bareH) + box.add(button("What is it protected with?", toolkit.ButtonDefault, s.protection), bareH) + return box +} + +// secretRow is a box that shows dots rather than what is typed into it. +func (s *state) secretRow(label string, to func(string)) toolkit.Widget { + e := toolkit.NewEntry("") + e.Mask = '•' + e.Text().Subscribe(to) + s.typing = append(s.typing, e) + return toolkit.NewFormField(label, e) +} + +// encrypt protects the file that will be written. +// +// What is drawn afterwards is still what would be saved: the workbench writes +// the document, and reads it back with the password just given. It is not the +// same bytes twice — encryption needs randomness, so every redraw produces a +// different file — but it is the same document, protected the same way. +func (s *state) encrypt() { + if s.tools.userPw == "" && s.tools.ownerPw == "" { + s.fail("protecting a file needs a user password or an owner password") + return + } + var perms reader.Permissions + for _, a := range allowed { + if s.tools.allow[a.name] { + perms |= a.bit + } + } + how := reader.Encryption{ + UserPassword: s.tools.userPw, + OwnerPassword: s.tools.ownerPw, + Permissions: perms, + } + was := s.reopenPw + s.reopenPw = s.tools.userPw + if !s.changeSaying("protected with AES-256", func(d *ops.Doc) error { + d.Encrypt(how) + return nil + }) { + s.reopenPw = was + } +} + +// decrypt writes the file without protection. +func (s *state) decrypt() { + was := s.reopenPw + s.reopenPw = "" + if !s.changeSaying("the protection will not be written", func(d *ops.Doc) error { + d.Decrypt() + return nil + }) { + s.reopenPw = was + } +} + +// protection says how the file this document was read from was protected. It +// says nothing about how it will be written, which is what the two buttons +// above it decide. +func (s *state) protection() { + if s.doc == nil { + s.fail("open a document first") + return + } + p, ok := s.doc.Protection() + if !ok { + s.fail("the file this came from was not protected") + return + } + s.note = fmt.Sprintf("%s, revision %d, opened as %s, allowing %s", + p.Method, p.Revision, openedAs(p.Owner), p.Permissions) + s.refresh() +} + +// openedAs says which password the file was opened with. +func openedAs(owner bool) string { + if owner { + return "the owner, so the permissions do not apply" + } + return "the user" +} diff --git a/go.mod b/go.mod index 3eda1ec..8da06ca 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.4 require ( github.com/go-gfx/gfx v0.10.0 + github.com/go-pdfkit/extract v0.2.0 github.com/go-pdfkit/forms v0.2.2 github.com/go-pdfkit/ops v0.7.0 github.com/go-pdfkit/reader v0.6.0 diff --git a/go.sum b/go.sum index a595e39..8264f4d 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,8 @@ github.com/go-opentype/opentype v0.10.0 h1:cZVMZ3RVkcijXmxlmpqyVkjlNc33aSB2ugKVW github.com/go-opentype/opentype v0.10.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= github.com/go-opentype/shape v0.5.0 h1:jHNaOMHNBdDj5EixOevlrrsi92svMxvVMKN7GYaPfxo= github.com/go-opentype/shape v0.5.0/go.mod h1:3ImRYNIj6zpwWQ/DV3BWhgMFfmzDITH6XpZw2auQHTo= +github.com/go-pdfkit/extract v0.2.0 h1:ibx2C2mJTJjGzP9bRnrRnmvdeZjv9aTas6tSkeEiTeM= +github.com/go-pdfkit/extract v0.2.0/go.mod h1:7KJGmgvxjk9qqbDdjpaAcEzd4G3vq3ciHuuufVdLiu4= github.com/go-pdfkit/forms v0.2.2 h1:tGnENs09LBUBppSrH0dGenV8KCQcbngsi+OatWID90Y= github.com/go-pdfkit/forms v0.2.2/go.mod h1:dQ4FzO6qF7RJh6oEa0dkgL0i8hKBmm/e7hj4XC56h5Q= github.com/go-pdfkit/ops v0.7.0 h1:W8sK//0nfs/OACvjzvw/GK5ywtd90eDoYcdv74GxW8M= diff --git a/main.go b/main.go index aa1ab3e..458c9fc 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ package main import ( + "strings" "syscall/js" "github.com/go-widgets/webcanvas" @@ -60,14 +61,14 @@ func read(file js.Value, done func([]byte)) { file.Call("arrayBuffer").Call("then", then) } -// Save hands a document to the person as a download. The blob is made from a -// copy of the bytes, since the Go side may write over its own buffer. +// Save hands a file to the person as a download. The blob is made from a copy +// of the bytes, since the Go side may write over its own buffer. func (browser) Save(name string, data []byte) { buf := js.Global().Get("Uint8Array").New(len(data)) js.CopyBytesToJS(buf, data) parts := js.Global().Get("Array").New(1) parts.SetIndex(0, buf) - blob := js.Global().Get("Blob").New(parts, map[string]any{"type": "application/pdf"}) + blob := js.Global().Get("Blob").New(parts, map[string]any{"type": mimeOf(name)}) url := js.Global().Get("URL").Call("createObjectURL", blob) link := js.Global().Get("document").Call("createElement", "a") link.Set("href", url) @@ -75,3 +76,22 @@ func (browser) Save(name string, data []byte) { link.Call("click") js.Global().Get("URL").Call("revokeObjectURL", url) } + +// mimeOf is what a file handed back is, taken from what it is called. +// +// Not everything the workbench hands over is a document: a picture pulled off +// a page is a picture, and a browser told it is a PDF opens it in a PDF viewer +// that cannot read it. +func mimeOf(name string) string { + switch { + case strings.HasSuffix(name, ".jpg"): + return "image/jpeg" + case strings.HasSuffix(name, ".jp2"): + return "image/jp2" + case strings.HasSuffix(name, ".jbig2"): + return "image/x-jbig2" + case strings.HasSuffix(name, ".samples"): + return "application/octet-stream" + } + return "application/pdf" +} diff --git a/marks.go b/marks.go new file mode 100644 index 0000000..6a9dcbe --- /dev/null +++ b/marks.go @@ -0,0 +1,125 @@ +// The Marks group: what gets written on top of what the pages already show. +// +// A watermark, a page number, a Bates number and a stamp are one verb in the +// library — text drawn on a page — offered here as the four things people +// actually ask for, because "stamp this page with {n} padded to six digits" is +// not what anybody says when they mean a Bates number. + +package main + +import ( + "fmt" + "strings" + + "github.com/go-pdfkit/ops" + "github.com/go-widgets/toolkit" +) + +// places is where a stamp can sit, in the words somebody would use, and in the +// order the library numbers them. +var places = []struct { + name string + where ops.Position +}{ + {"in the middle", ops.Center}, + {"top left", ops.TopLeft}, + {"top centre", ops.TopCenter}, + {"top right", ops.TopRight}, + {"bottom left", ops.BottomLeft}, + {"bottom centre", ops.BottomCenter}, + {"bottom right", ops.BottomRight}, + {"middle left", ops.MiddleLeft}, + {"middle right", ops.MiddleRight}, +} + +// placeNames is the list a person chooses from. +func placeNames() []string { + out := make([]string, 0, len(places)) + for _, p := range places { + out = append(out, p.name) + } + return out +} + +// marksGroup is the panel. +func (s *state) marksGroup() *column { + box := newColumn() + box.add(s.entryRow("Which pages", "1-3,7 — empty means all of them", "", + func(v string) { s.tools.markSpec = v }), labelledH) + + box.add(s.entryRow("Watermark", "what it says", s.tools.mark, + func(v string) { s.tools.mark = v }), labelledH) + box.add(button("Write it across them", toolkit.ButtonDefault, s.watermark), bareH) + + box.add(s.entryRow("Page numbers", "{page} and {pages} are filled in", s.tools.numbers, + func(v string) { s.tools.numbers = v }), labelledH) + box.add(button("Number them", toolkit.ButtonDefault, s.number), bareH) + + box.add(s.entryRow("Bates prefix", "what comes before the number", "", + func(v string) { s.tools.prefix = v }), labelledH) + box.add(buttons( + s.spinRow("Starting at", 1, s.tools.start, func(v int) { s.tools.start = v }), + s.spinRow("Padded to", 1, s.tools.digits, func(v int) { s.tools.digits = v }), + ), labelledH) + box.add(button("Stamp the numbers on", toolkit.ButtonDefault, s.bates), bareH) + + box.add(s.entryRow("Stamp", "what it says", s.tools.stamp, + func(v string) { s.tools.stamp = v }), labelledH) + box.add(buttons( + chooseRow("Where it goes", placeNames(), s.tools.at, func(i int) { s.tools.at = i }), + s.spinRow("Points", 4, s.tools.size, func(v int) { s.tools.size = v }), + ), labelledH) + box.add(button("Stamp them", toolkit.ButtonDefault, s.stamp), bareH) + return box +} + +// marked is the range the Marks group acts on: what was typed, or every page +// when nothing was. Every page rather than the one on the screen, because a +// watermark or a page number is nearly always wanted on all of them. +func (s *state) marked() string { + if strings.TrimSpace(s.tools.markSpec) == "" { + return "all" + } + return s.tools.markSpec +} + +// watermark writes across the pages, at an angle and faintly, which is what +// the library's own watermark does. +func (s *state) watermark() { + spec, text := s.marked(), s.tools.mark + s.changeSaying("wrote "+text+" across "+spec, + func(d *ops.Doc) error { return d.Watermark(spec, text) }) +} + +// number writes a page number at the foot of each page. +func (s *state) number() { + spec, format := s.marked(), s.tools.numbers + s.changeSaying("numbered "+spec, + func(d *ops.Doc) error { return d.PageNumbers(spec, format) }) +} + +// bates stamps a running number, which is how a set of documents is given one +// identifier per page that nothing else in it shares. +func (s *state) bates() { + spec := s.marked() + prefix, start, digits := s.tools.prefix, s.tools.start, s.tools.digits + s.changeSaying(fmt.Sprintf("stamped %s from %s%0*d", spec, prefix, digits, start), + func(d *ops.Doc) error { return d.Bates(spec, prefix, start, digits) }) +} + +// stamp draws a line of text where it was asked for. +func (s *state) stamp() { + spec := s.marked() + at := s.tools.at + if at < 0 || at >= len(places) { + at = 0 + } + mark := ops.Stamp{ + Text: s.tools.stamp, + Font: ops.Helvetica, + Size: float64(s.tools.size), + Position: places[at].where, + } + s.changeSaying(fmt.Sprintf("stamped %s %s", spec, places[at].name), + func(d *ops.Doc) error { return d.Stamp(spec, mark) }) +} diff --git a/panel.go b/panel.go index 6ad81ec..2e78fcf 100644 --- a/panel.go +++ b/panel.go @@ -35,14 +35,16 @@ const ( // it does to the file rather than by which library call it makes: somebody // looking for "two to a sheet" is thinking about the sheet, not about NUp. const ( - groupPages = "Pages" - groupSheet = "Sheet" - groupMarks = "Marks" - groupFile = "File" + groupPages = "Pages" + groupSheet = "Sheet" + groupMarks = "Marks" + groupFile = "File" + groupProtect = "Protect" + groupRead = "Read" ) // groupNames is the order they are offered in. -var groupNames = []string{groupPages, groupSheet, groupMarks, groupFile} +var groupNames = []string{groupPages, groupSheet, groupMarks, groupFile, groupProtect, groupRead} // tools is the tool panel: which group is open, the widgets of every group // that has been opened, and what those widgets currently say. @@ -67,21 +69,59 @@ type tools struct { // The Sheet group. up int // how many pages to a sheet - // The Marks group. - mark string // what a watermark says + // The Marks group, which acts on a range of its own: the Pages group's + // box is that group's, and one box shared between two panels would show + // the wrong thing in whichever of them was not last used. + markSpec string + mark string // what a watermark says + numbers string // the shape of a page number + prefix string // what comes before a Bates number + start int // the first Bates number + digits int // how many digits it is padded to + stamp string // what a stamp says + at int // where on the page it goes + size int // how large, in points + + // The File group. + title, author string + + // The Protect group. + openPw, userPw, ownerPw string + allow map[string]bool + + // The Read group: which reading of the document is on the screen instead + // of the picture of it, or empty for the picture. + reading string } // newTools builds the panel's state with the defaults each control starts at. func newTools() *tools { return &tools{ - built: map[string]toolkit.Widget{}, - turn: 90, - moveTo: 1, - before: 1, - every: 1, - up: 2, - mark: "DRAFT", + built: map[string]toolkit.Widget{}, + turn: 90, + moveTo: 1, + before: 1, + every: 1, + up: 2, + mark: "DRAFT", + numbers: "{page} / {pages}", + start: 1, + digits: 6, + stamp: "COPY", + size: 12, + allow: everythingAllowed(), + } +} + +// everythingAllowed is what a protected file lets a reader do until somebody +// says otherwise, which is everything: a password on a file is nearly always +// meant to keep it shut rather than to stop whoever opened it printing it. +func everythingAllowed() map[string]bool { + out := map[string]bool{} + for _, a := range allowed { + out[a.name] = true } + return out } // showGroup opens a group of verbs beside the page, or closes it when it is @@ -112,6 +152,10 @@ func (s *state) body() toolkit.Widget { rows = s.sheetGroup() case groupMarks: rows = s.marksGroup() + case groupProtect: + rows = s.protectGroup() + case groupRead: + rows = s.readGroup() default: rows = s.fileGroup() } @@ -158,10 +202,15 @@ func (c *column) add(w toolkit.Widget, h int) { // scroller is the stack in a view that can be scrolled when it is taller than // the panel, told how tall it is. -func (c *column) scroller() *toolkit.ScrollView { - c.box.SetBounds(toolkit.Rect{W: rowsW, H: c.h}) +func (c *column) scroller() *toolkit.ScrollView { return c.scrollerOf(rowsW) } + +// scrollerOf is the same at a width of the caller's choosing, which is what a +// reading of the page needs: it is put where the page was rather than in the +// panel, and the page is a good deal wider than the panel is. +func (c *column) scrollerOf(w int) *toolkit.ScrollView { + c.box.SetBounds(toolkit.Rect{W: w, H: c.h}) sv := toolkit.NewScrollView(c.box) - sv.SetContentSize(rowsW, c.h) + sv.SetContentSize(w, c.h) return sv } @@ -211,22 +260,6 @@ func (s *state) sheetGroup() *column { return box } -// marksGroup is what gets written on top of what the pages already show. -func (s *state) marksGroup() *column { - box := newColumn() - box.add(s.entryRow("Watermark", "what it says", s.tools.mark, - func(v string) { s.tools.mark = v }), labelledH) - box.add(button("Write it across every page", toolkit.ButtonDefault, s.watermark), bareH) - return box -} - -// fileGroup is what happens to the file rather than to any page of it. -func (s *state) fileGroup() *column { - box := newColumn() - box.add(button("Sanitize", toolkit.ButtonDefault, s.sanitize), bareH) - return box -} - // entryRow is a named box to type in, bound to where what is typed goes. func (s *state) entryRow(label, hint, initial string, to func(string)) toolkit.Widget { e := toolkit.NewEntry(initial) @@ -243,6 +276,22 @@ func (s *state) spinRow(label string, min, initial int, to func(int)) toolkit.Wi return toolkit.NewFormField(label, sp) } +// chooseRow is a named list to choose from, bound to where the choice goes. +// The list is drawn over whatever is under it by the popover host the view is +// wrapped in, which is the one thing a drop-down cannot do for itself. +func chooseRow(label string, options []string, chosen int, to func(int)) toolkit.Widget { + d := toolkit.NewDropDown(options, chosen) + d.Selected().Subscribe(to) + return toolkit.NewFormField(label, d) +} + +// tickRow is a box to tick, which names itself. +func tickRow(label string, on bool, to func(bool)) toolkit.Widget { + c := toolkit.NewCheckButton(label, on) + c.Checked().Subscribe(to) + return c +} + // pageCeiling is as high as any of these numbers is allowed to go. It is not a // page count: the document changes under the control, and a number that is too // large is refused by the operation itself, which is the one place that knows. diff --git a/panel_test.go b/panel_test.go index 1c99636..f8d5960 100644 --- a/panel_test.go +++ b/panel_test.go @@ -17,8 +17,6 @@ var ( pagesRows = []int{labelledH, bareH, bareH, bareH, labelledH, bareH, labelledH, bareH, labelledH, bareH, labelledH, bareH} sheetRows = []int{labelledH, bareH, bareH, bareH, bareH} - marksRows = []int{labelledH, bareH} - fileRows = []int{bareH} ) // openGroup shows a group and lays it out, which is what gives its controls @@ -309,43 +307,6 @@ func TestAddingAndOverlayingAnotherFile(t *testing.T) { } } -func TestTheMarksAndFilePanels(t *testing.T) { - s, _ := opened(t, 2) - openGroup(t, s, groupMarks) - x, y := rowAt(t, s, marksRows, 0, 1) - press(s, x, y) - for _, c := range []string{"O", "K"} { - s.handleChar(c) - } - if s.tools.mark != "DRAFTOK" { - t.Fatalf("the box holds %q", s.tools.mark) - } - x, y = rowAt(t, s, marksRows, 1, 1) - press(s, x, y) - out, err := s.doc.Bytes() - if err != nil { - t.Fatal(err) - } - back, err := reader.Open(out) - if err != nil { - t.Fatal(err) - } - content, err := back.PageContent(1) - if err != nil { - t.Fatal(err) - } - if !contains(content, "(DRAFTOK) Tj") { - t.Error("what was typed is not on the page") - } - - openGroup(t, s, groupFile) - x, y = rowAt(t, s, fileRows, 0, 1) - press(s, x, y) - if s.note == "" { - t.Error("sanitising said nothing for itself") - } -} - func TestOnlyTheBoxLastPressedTakesWhatIsTyped(t *testing.T) { // Two boxes in one panel: the range and the crop box. A press on the // second has to take the caret off the first, or every letter meant for diff --git a/read.go b/read.go new file mode 100644 index 0000000..a7ef631 --- /dev/null +++ b/read.go @@ -0,0 +1,143 @@ +// The Read group: what the page says and what it carries, as against what it +// looks like. +// +// Nothing here changes the document, so nothing here can break the promise the +// rest of the workbench makes. It keeps it all the same: the text and the +// pictures are read out of the document written and read back, not out of the +// one being assembled — so what is listed is what would come out of Save. + +package main + +import ( + "fmt" + "strings" + + "github.com/go-pdfkit/extract" + "github.com/go-pdfkit/reader" + "github.com/go-widgets/toolkit" +) + +// The two readings on offer, and the picture of the page they replace. +const ( + readingText = "text" + readingImages = "images" +) + +// readGroup is the panel. +func (s *state) readGroup() *column { + box := newColumn() + box.add(button("What this page says", toolkit.ButtonDefault, + func() { s.read(readingText) }), bareH) + box.add(button("What this page carries", toolkit.ButtonDefault, + func() { s.read(readingImages) }), bareH) + box.add(button("Show the page again", toolkit.ButtonDefault, + func() { s.read("") }), bareH) + box.add(toolkit.NewLabel("Neither of these changes the document."), bareH) + return box +} + +// read puts a reading of the page where the picture of it was, or the picture +// back when it is the reading already showing. +func (s *state) read(what string) { + if s.tools.reading == what { + what = "" + } + s.tools.reading = what + s.note = "" + s.refresh() +} + +// readingView is the page read rather than drawn. +func (s *state) readingView(src *reader.Document) toolkit.Widget { + if s.tools.reading == readingText { + return s.textView(src) + } + return s.imagesView(src) +} + +// lineH is how tall one line of a reading is. +const lineH = 16 + +// textView is everything the page says, in reading order. +func (s *state) textView(src *reader.Document) toolkit.Widget { + text, err := extract.Text(src, s.at) + if err != nil { + return toolkit.NewLabel("this page cannot be read: " + err.Error()) + } + lines := strings.Split(strings.TrimRight(text, "\n"), "\n") + if len(lines) == 1 && lines[0] == "" { + return toolkit.NewLabel("this page says nothing that can be read as text") + } + col := newColumn() + for _, line := range lines { + col.add(toolkit.NewLabel(line), lineH) + } + return col.scrollerOf(s.pageW() - 2*margin) +} + +// imagesView is every picture the page places, and a way of taking each one +// away. What is handed over is the picture as the document holds it: a JPEG +// comes out a JPEG, and what the library has unfiltered into plain samples +// comes out as those, because turning samples into pixels means reading a +// colour space, which is the renderer's work rather than this one's. +func (s *state) imagesView(src *reader.Document) toolkit.Widget { + images, err := extract.Images(src, s.at) + if err != nil { + return toolkit.NewLabel("the pictures on this page cannot be read: " + err.Error()) + } + if len(images) == 0 { + return toolkit.NewLabel("this page carries no pictures") + } + col := newColumn() + // What the PAGE places, which is not everything a viewer draws: a picture + // belonging to an annotation's appearance is drawn on top of the page + // rather than by it, and a mask is part of the picture it belongs to + // rather than a picture of its own. Measured against poppler's pdfimages + // over 157 pages of real forms that carry any, those two account for + // every page the two count differently. + col.add(toolkit.NewLabel("What the page itself places:"), lineH) + for i, im := range images { + name := fmt.Sprintf("page%03d-%02d%s", s.at, i+1, pictureSuffix(im)) + data := im.Data + row := toolkit.NewSettingRow(name, + button("Hand it over", toolkit.ButtonDefault, func() { s.handOver(name, data) })) + row.Subtitle = fmt.Sprintf("%d by %d, %s, %d bytes, drawn %.0f by %.0f points at %.0f, %.0f", + im.Width, im.Height, holds(im), len(im.Data), + im.DrawnWidth, im.DrawnHeight, im.X, im.Y) + col.add(row, 2*lineH+8) + } + return col.scrollerOf(s.pageW() - 2*margin) +} + +// holds says what the bytes of a picture are. +func holds(im extract.Image) string { + switch im.Filter { + case "DCTDecode": + return "a JPEG" + case "JPXDecode": + return "a JPEG 2000" + case "JBIG2Decode": + return "JBIG2" + } + return "plain samples" +} + +// pictureSuffix names a picture by what it holds. +func pictureSuffix(im extract.Image) string { + switch im.Filter { + case "DCTDecode": + return ".jpg" + case "JPXDecode": + return ".jp2" + case "JBIG2Decode": + return ".jbig2" + } + return ".samples" +} + +// handOver gives a picture to the person as a download of its own. +func (s *state) handOver(name string, data []byte) { + s.host.Save(name, data) + s.note = fmt.Sprintf("handed over %s, %d bytes", name, len(data)) + s.refresh() +} diff --git a/scene.go b/scene.go index c7915bf..231c2e3 100644 --- a/scene.go +++ b/scene.go @@ -68,6 +68,11 @@ type state struct { // raw is the file as it arrived. A form is filled in on the file itself // rather than on a document rebuilt around it, so the bytes are kept. raw []byte + // reopenPw is the password the document will need to be read back with, + // which is empty until somebody asks for it to be protected. Reading it + // back is how the workbench draws it, so protecting a file would + // otherwise put a message where the page was. + reopenPw string // form is what the document asks to be filled in, when it asks anything, // and showingForm says the panel is up instead of the page. form *filling @@ -146,7 +151,7 @@ const ( // open asks for a file and takes it as the document. func (s *state) open() { s.host.Open(func(name string, data []byte) { - d, err := ops.Open(data) + d, err := ops.OpenWithPassword(data, s.tools.openPw) if err != nil { s.fail("cannot open " + name + ": " + err.Error()) return @@ -154,6 +159,8 @@ func (s *state) open() { s.doc, s.name, s.at, s.raw = d, name, 1, data s.note = "" s.showingForm = false + s.reopenPw = "" + s.tools.reading = "" s.readForm(data) if s.form != nil { s.note = fmt.Sprintf("this document has a form: %d fields", @@ -219,13 +226,6 @@ func (s *state) deletePage() { s.change(func(d *ops.Doc) error { return d.Delete(pageSpec(at)) }) } -// watermark writes across every page. -func (s *state) watermark() { - text := s.tools.mark - s.changeSaying("wrote "+text+" across every page", - func(d *ops.Doc) error { return d.Watermark("all", text) }) -} - // sanitize strips whatever in the file runs rather than shows. func (s *state) sanitize() { s.changeSaying("stripped what runs rather than shows", func(d *ops.Doc) error { @@ -322,7 +322,7 @@ func (s *state) reopen() (*reader.Document, string) { if msg != "" { return nil, msg } - src, err := openBytes(out) + src, err := openBytes(out, s.reopenPw) if err != nil { return nil, "this document cannot be read back: " + err.Error() } @@ -335,7 +335,7 @@ func (s *state) reopen() (*reader.Document, string) { // exactly why it is worth being able to see what happens when one does. var ( docBytes = (*ops.Doc).Bytes - openBytes = reader.Open + openBytes = reader.OpenWithPassword drawPage = render.Page ) @@ -363,6 +363,12 @@ func (s *state) renderPage() { if s.at < 1 { s.at = 1 } + // A reading of the page is read out of the document written and read back, + // like the picture of it, so what is listed is what would come out of Save. + if s.tools.reading != "" { + s.show(s.readingView(src)) + return + } img, err := drawPage(src, s.at, render.Options{ Scale: s.fitScale(src), MaxDuration: pageBudget, @@ -395,7 +401,11 @@ func (s *state) renderPage() { // box and watching the page come back cropped is the whole of what the control // is for, and a panel that covered the page would hide it. func (s *state) show(w toolkit.Widget) { - s.view = s.arrange(w) + // Wrapped in the toolkit's own popover host, which is what draws a list a + // drop-down has opened on top of everything and offers it the next press + // before anything under it sees one. Without it a list opens onto nothing: + // the chevron works and no option can be chosen. + s.view = toolkit.NewPopoverHost(s.arrange(w)) // Laid out here rather than only when it is painted, because a press can // arrive before the next frame does: the view is built afresh by every // change, and a widget nobody has given bounds to is under no point at @@ -500,10 +510,12 @@ func (s *state) draw(buf []byte) { p := painter.NewPixelPainter(buf, s.w, s.h) s.toolbar.SetBounds(painter.Rect{X: margin, Y: margin, W: s.w - 2*margin, H: toolbarH}) s.toolbar.Draw(p, s.theme) - s.view.SetBounds(painter.Rect{X: margin, Y: viewTop, W: viewW, H: viewH}) - s.view.Draw(p, s.theme) s.status.SetBounds(painter.Rect{X: 0, Y: s.h - statusH, W: s.w, H: statusH}) s.status.Draw(p, s.theme) + // The view last, because a list opened near the foot of the panel is drawn + // over whatever is below it — and below it is the status line. + s.view.SetBounds(painter.Rect{X: margin, Y: viewTop, W: viewW, H: viewH}) + s.view.Draw(p, s.theme) } // pointer sends a press, a move or a release to the strip and to the view. diff --git a/scene_test.go b/scene_test.go index 7e5899e..5953d8d 100644 --- a/scene_test.go +++ b/scene_test.go @@ -502,7 +502,9 @@ func TestWhenTheDocumentJustWrittenCannotBeReadBack(t *testing.T) { s, _ := opened(t, 2) was := openBytes t.Cleanup(func() { openBytes = was }) - openBytes = func([]byte) (*reader.Document, error) { return nil, errors.New("not a PDF after all") } + openBytes = func([]byte, string) (*reader.Document, error) { + return nil, errors.New("not a PDF after all") + } s.refresh() if s.page != nil { diff --git a/verbs_test.go b/verbs_test.go new file mode 100644 index 0000000..a85e29d --- /dev/null +++ b/verbs_test.go @@ -0,0 +1,509 @@ +package main + +import ( + "bytes" + "errors" + "image" + "image/color" + "image/jpeg" + "strings" + "testing" + + "github.com/go-pdfkit/extract" + "github.com/go-pdfkit/ops" + "github.com/go-pdfkit/reader" +) + +// The heights of the rows of the groups this file drives. +var ( + marksRows = []int{labelledH, labelledH, bareH, labelledH, bareH, + labelledH, labelledH, bareH, labelledH, labelledH, bareH} + fileRows = []int{bareH, bareH, bareH, bareH, bareH, bareH, labelledH, labelledH, bareH} + protectRows = []int{labelledH, bareH, labelledH, labelledH, + bareH, bareH, bareH, bareH, bareH, bareH, bareH, bareH} + readRows = []int{bareH, bareH, bareH, bareH} +) + +// content is the first page of a document as it would be saved. +func content(t *testing.T, d *ops.Doc) []byte { + t.Helper() + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + c, err := back.PageContent(1) + if err != nil { + t.Fatal(err) + } + return c +} + +// typeInto puts the caret in the box on a row and types a word into it. +func typeInto(t *testing.T, s *state, rows []int, n int, word string) { + t.Helper() + x, y := rowAt(t, s, rows, n, 1) + press(s, x, y) + if !s.editing() { + t.Fatalf("pressing row %d did not put the caret in a box", n) + } + for _, c := range strings.Split(word, "") { + if !s.handleChar(c) { + t.Fatalf("row %d refused a character", n) + } + } +} + +func TestTheMarksPanelWritesWhatWasTypedWhereItWasAsked(t *testing.T) { + s, _ := opened(t, 3) + openGroup(t, s, groupMarks) + + // A range of its own, and a watermark in words somebody chose. + typeInto(t, s, marksRows, 0, "1") + typeInto(t, s, marksRows, 1, "!") + x, y := rowAt(t, s, marksRows, 2, 1) + press(s, x, y) + if !bytes.Contains(content(t, s.doc), []byte("(DRAFT!) Tj")) { + t.Error("the watermark is not on the first page") + } + if s.doc.PageCount() != 3 { + t.Errorf("watermarking changed the page count to %d", s.doc.PageCount()) + } + + // Page numbers, in a shape somebody chose. + typeInto(t, s, marksRows, 3, "!") + x, y = rowAt(t, s, marksRows, 4, 1) + press(s, x, y) + if !bytes.Contains(content(t, s.doc), []byte("(1 / 3!) Tj")) { + t.Error("the page number is not on the first page") + } + + // A Bates number, padded and started where the numbers say. + typeInto(t, s, marksRows, 5, "AB") + x, y = rowAt(t, s, marksRows, 6, 0) + press(s, x+51, y-8) // the + of the left of the two numbers on that row + px, py := rowAt(t, s, marksRows, 6, 1) + press(s, px+120, py-8) // and the + of the right one + if s.tools.digits != 7 { + t.Fatalf("the Bates number is padded to %d digits", s.tools.digits) + } + s.tools.digits = 6 + if s.tools.start != 2 { + t.Fatalf("the Bates number starts at %d", s.tools.start) + } + x, y = rowAt(t, s, marksRows, 7, 1) + press(s, x, y) + if !bytes.Contains(content(t, s.doc), []byte("(AB000002) Tj")) { + t.Errorf("the Bates number is not on the first page: %q", s.note) + } + + // A stamp, where the list says. + typeInto(t, s, marksRows, 8, "?") + x, y = rowAt(t, s, marksRows, 9, 1) + press(s, x+120, y-8) // the + of the point size, which shares the row + if s.tools.size != 13 { + t.Fatalf("the stamp is %d points", s.tools.size) + } + x, y = rowAt(t, s, marksRows, 9, 0) + press(s, x, y) // opens the list + press(s, x, y+40) + if s.tools.at == 0 { + t.Fatal("no place was chosen from the list") + } + x, y = rowAt(t, s, marksRows, 10, 1) + press(s, x, y) + if !bytes.Contains(content(t, s.doc), []byte("(COPY?) Tj")) { + t.Error("the stamp is not on the first page") + } + if !strings.Contains(s.note, places[s.tools.at].name) { + t.Errorf("the status line says %q", s.note) + } +} + +func TestAMarkOnEveryPageWhenNoRangeIsGiven(t *testing.T) { + s, _ := opened(t, 2) + if s.marked() != "all" { + t.Errorf("with nothing typed the range is %q", s.marked()) + } + s.tools.markSpec = " 2 " + if s.marked() != " 2 " { + t.Errorf("with a range typed it is %q", s.marked()) + } + // A place that is not one falls back on the middle rather than reaching + // past the end of the list. + s.tools.at = 99 + s.stamp() + if !strings.Contains(s.note, places[0].name) { + t.Errorf("the status line says %q", s.note) + } +} + +func TestTheFilePanel(t *testing.T) { + s, _ := opened(t, 3) + openGroup(t, s, groupFile) + for _, n := range []int{0, 1, 2, 3, 4} { + x, y := rowAt(t, s, fileRows, n, 1) + press(s, x, y) + if s.note == "" { + t.Errorf("the control on row %d said nothing for itself", n) + } + } + // Packing says how much smaller the file came out. + x, y := rowAt(t, s, fileRows, 5, 1) + press(s, x, y) + if !strings.Contains(s.note, "rather than") { + t.Errorf("packing said %q", s.note) + } + + // A title and an author, which are what a file says about itself. + typeInto(t, s, fileRows, 6, "T") + typeInto(t, s, fileRows, 7, "A") + x, y = rowAt(t, s, fileRows, 8, 1) + press(s, x, y) + out, err := s.doc.Bytes() + if err != nil { + t.Fatal(err) + } + back, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + info, ok := back.GetDict(back.Trailer(), "Info") + if !ok { + t.Fatal("the file says nothing about itself") + } + if got, _ := reader.ToString(mustResolve(back, info.Get("Title"))); string(got) != "T" { + t.Errorf("the title is %q", got) + } + if got, _ := reader.ToString(mustResolve(back, info.Get("Author"))); string(got) != "A" { + t.Errorf("the author is %q", got) + } +} + +func TestHowLargeAFileThatCannotBeWrittenIs(t *testing.T) { + // written is asked before and after packing; a document that cannot be + // written at all has no size, and the redraw says so for itself. + s, _ := opened(t, 2) + was := docBytes + t.Cleanup(func() { docBytes = was }) + docBytes = func(*ops.Doc) ([]byte, error) { return nil, errors.New("no") } + if n := s.written(); n != 0 { + t.Errorf("a document that cannot be written came to %d bytes", n) + } + s.compress() + if strings.Contains(s.note, "rather than") { + t.Errorf("packing claimed a size it could not measure: %q", s.note) + } + // What went wrong is on the screen where the page was, which says it + // better than a pair of zeroes on the status line would. + if s.note != "packed" { + t.Errorf("packing said %q", s.note) + } +} + +func TestProtectingAFileAndTakingItOffAgain(t *testing.T) { + s, h := opened(t, 2) + openGroup(t, s, groupProtect) + + // Nothing to protect it with is said rather than done. + x, y := rowAt(t, s, protectRows, 9, 1) + press(s, x, y) + if !strings.Contains(s.note, "needs a user password") { + t.Errorf("protecting with no password said %q", s.note) + } + + // A password, typed into a box that shows dots rather than letters. + typeInto(t, s, protectRows, 2, "shh") + typeInto(t, s, protectRows, 3, "owner") + if s.tools.userPw != "shh" || s.tools.ownerPw != "owner" { + t.Fatalf("the boxes hold %q and %q", s.tools.userPw, s.tools.ownerPw) + } + // One of the permissions taken away. + x, y = rowAt(t, s, protectRows, 6, 0) + press(s, x, y) + if s.tools.allow["copy text out of it"] { + t.Error("the tick did not come off") + } + + x, y = rowAt(t, s, protectRows, 9, 1) + press(s, x, y) + if !strings.Contains(s.note, "AES-256") { + t.Fatalf("protecting said %q", s.note) + } + // The page is still drawn, because the workbench reads its own output + // back with the password it was just given. + if s.page == nil { + t.Error("a protected document was not drawn") + } + s.save() + if _, err := reader.Open(h.saved); err == nil { + t.Error("what was saved opens with no password") + } + if _, err := reader.OpenWithPassword(h.saved, "owner"); err != nil { + t.Errorf("the owner password does not open what was saved: %v", err) + } + back, err := reader.OpenWithPassword(h.saved, "shh") + if err != nil { + t.Fatalf("what was saved does not open with the password: %v", err) + } + p, ok := back.Protection() + if !ok || p.Permissions.Allows(reader.PermCopy) { + t.Errorf("protected %v, allowing %s", ok, p.Permissions) + } + + // And taken off again. + x, y = rowAt(t, s, protectRows, 10, 1) + press(s, x, y) + s.save() + if _, err := reader.Open(h.saved); err != nil { + t.Errorf("what was saved after the protection came off does not open: %v", err) + } +} + +func TestWhatAFileIsProtectedWith(t *testing.T) { + // A document opened from a protected file says how it was protected; one + // opened from a file that was not says that instead. + plain, _ := opened(t, 2) + openGroup(t, plain, groupProtect) + x, y := rowAt(t, plain, protectRows, 11, 1) + press(plain, x, y) + if !strings.Contains(plain.note, "not protected") { + t.Errorf("it said %q", plain.note) + } + + locked := lockedPDF(t, "shh") + s := newState(surfaceW, surfaceH, &fakeHost{name: "locked.pdf", file: locked}) + openGroup(t, s, groupProtect) + // Without the password it will not open at all. + s.open() + if s.doc != nil || s.note == "" { + t.Errorf("a protected file opened with no password, saying %q", s.note) + } + // With it, it does. + typeInto(t, s, protectRows, 0, "shh") + s.open() + if s.doc == nil { + t.Fatalf("the password did not open it: %q", s.note) + } + x, y = rowAt(t, s, protectRows, 11, 1) + press(s, x, y) + if !strings.Contains(s.note, "AES") || !strings.Contains(s.note, "the user") { + t.Errorf("it said %q", s.note) + } + if openedAs(true) == openedAs(false) { + t.Error("the owner and the user are said the same way") + } + + // Protecting a document there is none of says so, and leaves the + // password that reads it back alone. + none := newState(surfaceW, surfaceH, &fakeHost{}) + none.tools.userPw = "x" + none.encrypt() + if none.reopenPw != "" || none.note == "" { + t.Errorf("reopen password %q, note %q", none.reopenPw, none.note) + } + none.decrypt() + if none.note == "" { + t.Error("taking the protection off nothing said nothing") + } +} + +func TestTheFileAndProtectPanelsWithNothingOpen(t *testing.T) { + empty := newState(surfaceW, surfaceH, &fakeHost{}) + for _, act := range []func(){ + empty.flatten, empty.dropAnnots, empty.dropOutlines, empty.clearInfo, + empty.compress, empty.setInfo, empty.protection, + } { + empty.note = "" + act() + if empty.note == "" { + t.Error("a control with no document said nothing") + } + } +} + +func TestReadingWhatAPageSaysAndWhatItCarries(t *testing.T) { + s := newState(surfaceW, surfaceH, &fakeHost{name: "words.pdf", file: wordyPDF(t)}) + s.open() + openGroup(t, s, groupRead) + + x, y := rowAt(t, s, readRows, 0, 1) + press(s, x, y) + if s.tools.reading != readingText { + t.Fatalf("the reading is %q", s.tools.reading) + } + if s.page != nil { + t.Error("the picture of the page was drawn as well as the reading of it") + } + // Pressing it again puts the page back. + press(s, x, y) + if s.tools.reading != "" { + t.Errorf("the reading is %q", s.tools.reading) + } + + // What it carries: one picture, handed over under a name that says what + // it is. + x, y = rowAt(t, s, readRows, 1, 1) + press(s, x, y) + if s.tools.reading != readingImages { + t.Fatalf("the reading is %q", s.tools.reading) + } + // The button beside the picture hands it over. Where it is is swept for + // rather than computed: the list is put where the page was, not in the + // panel, and what says the control is wired is that a press finds it. + before := s.tools.reading + pressed := false + for y := viewTop; y < viewTop+viewH && !pressed; y += 4 { + for x := s.pageW() - 80; x < s.pageW() && !pressed; x += 8 { + s.handleClick(x, y) + s.draw(buffer()) + pressed = strings.Contains(s.note, "handed over") + } + } + if !pressed { + t.Errorf("no press beside a picture handed it over; the status line says %q", s.note) + } + if !strings.Contains(s.note, ".jpg") { + t.Errorf("the picture was handed over as %q", s.note) + } + if s.tools.reading != before { + t.Error("handing a picture over changed which reading is on the screen") + } + + // And the page comes back. + x, y = rowAt(t, s, readRows, 2, 1) + press(s, x, y) + if s.tools.reading != "" || s.page == nil { + t.Errorf("the reading is %q and the page %v", s.tools.reading, s.page != nil) + } +} + +func TestAPageWithNothingToRead(t *testing.T) { + s, _ := opened(t, 2) // the sample carries a square and no text + s.tools.reading = readingText + s.refresh() + if s.page != nil { + t.Error("a picture of the page was drawn under the reading") + } + s.tools.reading = readingImages + s.refresh() + if s.page != nil { + t.Error("a picture of the page was drawn under the list") + } +} + +func TestHowAPictureIsNamedByWhatItHolds(t *testing.T) { + for filter, want := range map[reader.Name]string{ + "DCTDecode": ".jpg", + "JPXDecode": ".jp2", + "JBIG2Decode": ".jbig2", + "": ".samples", + } { + im := extract.Image{Filter: filter} + if got := pictureSuffix(im); got != want { + t.Errorf("a %q picture is called %s, want %s", filter, got, want) + } + if holds(im) == "" { + t.Errorf("a %q picture holds nothing that can be said", filter) + } + } +} + +func TestAPageThatCannotBeReadAtAll(t *testing.T) { + // A page whose content stream cannot be decoded can be neither read nor + // looked into, and both readings say so rather than showing nothing. + s := newState(surfaceW, surfaceH, &fakeHost{name: "odd.pdf", file: unreadablePDF(t)}) + s.open() + if s.doc == nil { + t.Fatal("the document did not open") + } + for _, what := range []string{readingText, readingImages} { + s.tools.reading = what + s.refresh() + if s.page != nil { + t.Errorf("a page was drawn for the %s reading", what) + } + } +} + +// wordyPDF is a document whose page says something and carries a picture. +func wordyPDF(t *testing.T) []byte { + t.Helper() + var jpg bytes.Buffer + im := image.NewRGBA(image.Rect(0, 0, 2, 2)) + im.Set(0, 0, color.RGBA{255, 0, 0, 255}) + if err := jpeg.Encode(&jpg, im, nil); err != nil { + t.Fatal(err) + } + w := reader.NewWriter("1.7") + pages := w.Reserve() + font := w.Add(reader.Dict{"Type": reader.Name("Font"), + "Subtype": reader.Name("Type1"), "BaseFont": reader.Name("Helvetica")}) + pic := w.Add(&reader.Stream{Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Image"), + "Width": reader.Integer(2), "Height": reader.Integer(2), + "ColorSpace": reader.Name("DeviceRGB"), "BitsPerComponent": reader.Integer(8), + "Filter": reader.Name("DCTDecode"), + }, Raw: jpg.Bytes()}) + body := w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte( + "BT /F1 12 Tf 20 300 Td (Hello there) Tj ET\nq 60 0 0 60 20 60 cm /P1 Do Q")}) + page := w.Add(reader.Dict{ + "Type": reader.Name("Page"), "Parent": pages, "Contents": body, + "Resources": reader.Dict{ + "Font": reader.Dict{"F1": font}, + "XObject": reader.Dict{"P1": pic}, + }, + }) + w.Put(pages, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{page}, "Count": reader.Integer(1), + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(300), reader.Integer(400)}}) + root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pages}) + out, err := w.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + return out +} + +// unreadablePDF is a document whose page carries a content stream that no +// filter can undo. +func unreadablePDF(t *testing.T) []byte { + t.Helper() + w := reader.NewWriter("1.7") + pages := w.Reserve() + page := w.Add(reader.Dict{ + "Type": reader.Name("Page"), "Parent": pages, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(100)}, + "Contents": w.Add(&reader.Stream{ + Dict: reader.Dict{"Filter": reader.Name("DCTDecode")}, Raw: []byte("not a jpeg")}), + }) + w.Put(pages, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{page}, "Count": reader.Integer(1)}) + root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pages}) + out, err := w.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + return out +} + +// lockedPDF is a document behind a password. +func lockedPDF(t *testing.T, password string) []byte { + t.Helper() + d, err := ops.Open(samplePDF(t, 2)) + if err != nil { + t.Fatal(err) + } + d.Encrypt(reader.Encryption{UserPassword: password, OwnerPassword: "owner", + Permissions: reader.AllPermissions}) + out, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + return out +}