diff --git a/README.md b/README.md index dd14102..b59b8d5 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,12 @@ a panel **beside** the page rather than instead of it: 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. + says what it holds. And the page itself the other way round — drawn at twice + the size it is shown at and handed over as a **PNG, JPEG, GIF, TIFF or BMP**, + one page or every page zipped, with the words on their own as a text file. + The formats offered are the ones `gfx` can write, asked of it rather than + listed here; a page that ran out of time is still handed over, and the status + line says how far it got. 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 diff --git a/panel.go b/panel.go index 70b7f0b..eda9b57 100644 --- a/panel.go +++ b/panel.go @@ -90,8 +90,10 @@ type tools struct { 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. + // of the picture of it, or empty for the picture, and which of the + // writable picture formats a page is handed over in. reading string + picture int } // newTools builds the panel's state with the defaults each control starts at. diff --git a/read.go b/read.go index 1fbc60d..e4c1eb1 100644 --- a/read.go +++ b/read.go @@ -39,8 +39,15 @@ func (s *state) readGroup() *column { func() { s.read(readingImages) }), bareH) box.add(button("Show the page again", toolkit.ButtonDefault, func() { s.read("") }), bareH) - box.add(button("Hand over this page as a PNG", toolkit.ButtonDefault, - s.pageAsPNG), bareH) + // The chooser sits on the same row as the verb it governs, so that what + // the file will be is beside the press that makes it rather than three + // rows above. It governs the zip below it as well: both of them draw the + // page and write it, and two choosers saying different things about the + // same picture would be a question nobody asked. + formats := toolkit.NewCycleButton(formatNames()...) + formats.Index().Subscribe(func(i int) { s.tools.picture = i }) + box.add(buttons(formats, button("Hand over this page", toolkit.ButtonDefault, + s.pageAsPicture)), bareH) box.add(button("Hand over every page, zipped", toolkit.ButtonDefault, s.everyPageZipped), bareH) box.add(button("Hand over what this page says", toolkit.ButtonDefault, @@ -122,19 +129,20 @@ func (s *state) imagesView(src *reader.Document) toolkit.Widget { return col.scrollerOf(s.pageW() - 2*margin) } -// pageAsPNG draws the page and hands it over in a format anything opens. +// pageAsPicture draws the page and hands it over in a format anything opens. // // A picture already in the document comes out of "what this page carries" as // the bytes it is stored in, which is right: a JPEG handed over as a JPEG is // the file itself, losing nothing. But a page is not a picture in the document // — it is a drawing of everything on it — and a scanned page's own pictures // are stored as JPEG 2000 and JBIG2, which almost nothing opens. This draws -// the page and writes a PNG. +// the page and writes it out in whichever of the formats the chooser beside it +// names. // // It is drawn at twice the size it is shown at, because what this is for is // taking away rather than looking at, and a page fitted to a window is smaller // than the page. -func (s *state) pageAsPNG() { +func (s *state) pageAsPicture() { if s.doc == nil { s.fail("open a document first") return @@ -144,21 +152,28 @@ func (s *state) pageAsPNG() { s.fail(msg) return } - png, partial, err := s.drawnPNG(src, s.at) + pic := s.chosenFormat() + data, partial, err := s.drawnPicture(src, s.at, pic) if err != nil { s.fail(err.Error()) return } - s.handOver(fmt.Sprintf("page%03d.png", s.at), png) + s.handOver(fmt.Sprintf("page%03d%s", s.at, pic.suffix), data) if partial { s.note += fmt.Sprintf("; this page was still being drawn after %s, so that is as far as it got", pageBudget) s.refresh() } } -// drawnPNG draws one page and writes it, saying whether what came back is all -// of it. -func (s *state) drawnPNG(src *reader.Document, at int) (data []byte, partial bool, err error) { +// drawnPicture draws one page and writes it in one format, saying whether what +// came back is all of it. +// +// Alpha is the library's business rather than this one's: PNG and TIFF carry +// it, and codec.Encode composites onto white for JPEG, GIF and BMP, which do +// not. Doing it again here would be doing it twice. What is this one's +// business is that the name matches — a page written as a BMP and handed over +// as a .png is a file nothing can open. +func (s *state) drawnPicture(src *reader.Document, at int, pic pictureFormat) (data []byte, partial bool, err error) { img, err := drawPage(src, at, render.Options{ Scale: 2 * s.fitScale(src), MaxDuration: pageBudget, @@ -171,8 +186,8 @@ func (s *state) drawnPNG(src *reader.Document, at int) (data []byte, partial boo return nil, false, fmt.Errorf("this page cannot be drawn: %w", err) } var buf bytes.Buffer - if err := encodePNG(&buf, img); err != nil { - return nil, false, fmt.Errorf("this page cannot be written as a PNG: %w", err) + if err := encodeImage(&buf, img, pic.format); err != nil { + return nil, false, fmt.Errorf("this page cannot be written as a %s: %w", pic.format, err) } return buf.Bytes(), partial, nil } @@ -181,8 +196,11 @@ func (s *state) drawnPNG(src *reader.Document, at int) (data []byte, partial boo // // A page at a time is no use for a document of two hundred, and a browser that // is handed two hundred downloads at once asks about each of them. A zip of -// PNGs is also what a comic reader opens under the name CBZ, which is the same -// file with another suffix. +// PNGs or JPEGs is also what a comic reader opens under the name CBZ, which is +// the same file with another suffix. +// +// The pages go in in whichever format the chooser on the row above names, and +// the entries carry that format's suffix. func (s *state) everyPageZipped() { if s.doc == nil { s.fail("open a document first") @@ -195,9 +213,10 @@ func (s *state) everyPageZipped() { } var buf bytes.Buffer zw := zip.NewWriter(&buf) + pic := s.chosenFormat() short := 0 for at := 1; at <= src.PageCount(); at++ { - png, partial, err := s.drawnPNG(src, at) + data, partial, err := s.drawnPicture(src, at, pic) if err != nil { s.fail(err.Error()) return @@ -209,8 +228,8 @@ func (s *state) everyPageZipped() { // used or a writer already closed, neither of which can happen with // one entry per page number, and a bytes.Buffer never fails to take // bytes. Close only flushes. - w, _ := zw.Create(fmt.Sprintf("page%03d.png", at)) - w.Write(png) + w, _ := zw.Create(fmt.Sprintf("page%03d%s", at, pic.suffix)) + w.Write(data) } zw.Close() s.handOver(strings.TrimSuffix(s.name, ".pdf")+"-pages.zip", buf.Bytes()) @@ -240,11 +259,87 @@ func (s *state) textAsFile() { s.handOver(fmt.Sprintf("page%03d.txt", s.at), []byte(text)) } -// encodePNG is a variable so a test can watch what happens when writing the +// encodeImage is a variable so a test can watch what happens when writing the // picture fails, which is the branch that decides whether a person is handed a // truncated file or told. -var encodePNG = func(w io.Writer, img *raster.Image) error { - return codec.Encode(w, img, codec.PNG) +var encodeImage = func(w io.Writer, img *raster.Image, f codec.Format) error { + return codec.Encode(w, img, f) +} + +// pictureFormat is one way of handing a drawn page over: the format itself, +// and the suffix a person expects the file to carry. The two are kept together +// because the one mistake worth designing out is a file whose name says one +// thing and whose bytes say another. +type pictureFormat struct { + format codec.Format + suffix string +} + +// everyKnownFormat is every container gfx can read, with its usual suffix. +// Only some of them can be written, and which is the library's contract rather +// than a guess made here: codec.CanEncode below decides what reaches the +// chooser. Written this way round, a reference encoder arriving in gfx — WEBP +// is the obvious one — puts that format in front of a person without a line +// changing here. +var everyKnownFormat = []pictureFormat{ + {codec.PNG, ".png"}, + {codec.JPEG, ".jpg"}, + {codec.GIF, ".gif"}, + {codec.WEBP, ".webp"}, + {codec.TIFF, ".tif"}, + {codec.BMP, ".bmp"}, + {codec.ICO, ".ico"}, + {codec.ICNS, ".icns"}, + {codec.PNM, ".pnm"}, + {codec.QOI, ".qoi"}, + {codec.JP2, ".jp2"}, + {codec.JBIG2, ".jbig2"}, +} + +// pictureFormats is what the chooser offers, in that order — PNG first, +// because it is the one anything opens and the one this handed over when there +// was no choice to make. +// +// The order is also worth reading as a warning about size. Over forty real +// documents, the first page of each drawn at twice the size it is shown at and +// written five ways, the mean file came to 209 kB as a PNG, 129 kB as a JPEG +// and 105 kB as a GIF — and 4.9 MB as a BMP and 6.6 MB as a TIFF, which are +// uncompressed. That is not this program's to fix (the encoders are gfx's, and +// the ratio between the two large ones is exactly four bytes a pixel against +// three, which is the alpha channel TIFF carries and BMP does not), but it is +// worth knowing before choosing one for a document of two hundred pages. +var pictureFormats = writableFormats() + +// writableFormats keeps the formats that can actually be written. +func writableFormats() []pictureFormat { + out := make([]pictureFormat, 0, len(everyKnownFormat)) + for _, p := range everyKnownFormat { + if codec.CanEncode(p.format) { + out = append(out, p) + } + } + return out +} + +// formatNames is what the chooser says, which is the format's own name: a +// person looking for a JPEG is looking for the word, not for ".jpg". +func formatNames() []string { + names := make([]string, len(pictureFormats)) + for i, p := range pictureFormats { + names[i] = p.format.String() + } + return names +} + +// chosenFormat is the format the two hand-over verbs write. A choice that is +// not one of them falls back on the first rather than reaching past the end of +// the list: the chooser cannot make that happen, and a panic if something else +// ever did would be a poor way of finding out. +func (s *state) chosenFormat() pictureFormat { + if s.tools.picture < 0 || s.tools.picture >= len(pictureFormats) { + return pictureFormats[0] + } + return pictureFormats[s.tools.picture] } // holds says what the bytes of a picture are. diff --git a/verbs_test.go b/verbs_test.go index 6a88c82..ced77a0 100644 --- a/verbs_test.go +++ b/verbs_test.go @@ -27,7 +27,10 @@ var ( 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} + // The Read group: three readings, then the chooser and the verb it + // governs sharing a row, then the zip, the words, and the note at the + // bottom. + readRows = []int{bareH, bareH, bareH, bareH, bareH, bareH, bareH} ) // content is the first page of a document as it would be saved. @@ -521,41 +524,158 @@ func lockedPDF(t *testing.T, password string) []byte { return out } -func TestAPageIsHandedOverAsAPNG(t *testing.T) { +func TestAPageIsHandedOverInEveryFormatTheChooserOffers(t *testing.T) { // A picture already in the document is handed over as the bytes it is // stored in, which loses nothing. A page is not a picture in the document // — it is a drawing of everything on it — and a scanned page's own // pictures are stored as JPEG 2000 and JBIG2, which almost nothing opens. + // + // The chooser is pressed rather than the field set, and what came back is + // SNIFFED rather than trusted: a name is what a person sees, and a name + // that does not match the bytes under it is a file nothing opens. s, h := opened(t, 1) - s.pageAsPNG() - if h.as != "page001.png" { - t.Fatalf("it was handed over as %q", h.as) - } - if codec.Sniff(h.saved) != codec.PNG { - t.Fatalf("what was handed over sniffs as %s", codec.Sniff(h.saved)) - } - img, err := codec.Decode(h.saved) - if err != nil { - t.Fatalf("what was handed over cannot be read back: %v", err) + openGroup(t, s, groupRead) + cx, cy := rowAt(t, s, readRows, 3, 0) // the chooser + bx, by := rowAt(t, s, readRows, 3, 1) // the verb beside it + if len(pictureFormats) < 5 { + t.Fatalf("only %d formats are offered", len(pictureFormats)) + } + for i, pic := range pictureFormats { + if s.tools.picture != i { + t.Fatalf("the chooser is on %d and the list on %d", s.tools.picture, i) + } + h.as, h.saved = "", nil + press(s, bx, by) + if h.as != "page001"+pic.suffix { + t.Fatalf("%s was handed over as %q", pic.format, h.as) + } + if got := codec.Sniff(h.saved); got != pic.format { + t.Fatalf("what was handed over as %q sniffs as %s", h.as, got) + } + img, err := codec.Decode(h.saved) + if err != nil { + t.Fatalf("%s cannot be read back: %v", pic.format, err) + } + // Twice the size it is shown at, because this is for taking away + // rather than for looking at. + if img.W < 2*surfaceW/3 { + t.Errorf("%s came out %dx%d, which is no bigger than the view", + pic.format, img.W, img.H) + } + // And it is a page, not a blank rectangle. + if ink := darkPixels(img); ink == 0 { + t.Errorf("%s came out blank", pic.format) + } + press(s, cx, cy) // on to the next format, wrapping to the first } - // Twice the size it is shown at, because this is for taking away rather - // than for looking at. - if img.W < 2*surfaceW/3 { - t.Errorf("the page came out %dx%d, which is no bigger than the view", img.W, img.H) + if s.tools.picture != 0 { + t.Errorf("the chooser did not wrap round to the first format, it is on %d", + s.tools.picture) } - // And it is a page, not a blank rectangle. - ink := 0 +} + +// darkPixels counts how much ink is on a picture of a page. +func darkPixels(img *raster.Image) int { + n := 0 for i := 0; i < img.W*img.H; i++ { if (uint32(img.Pix[i*4])*299+uint32(img.Pix[i*4+1])*587+uint32(img.Pix[i*4+2])*114)/1000 < 128 { - ink++ + n++ + } + } + return n +} + +func TestTheFormatsOfferedAreTheOnesThatCanBeWritten(t *testing.T) { + // The list is filtered through the library's own answer rather than + // written out here, so what is offered cannot drift from what can be + // written. Both directions are checked: nothing unwritable is offered, + // and nothing writable is left out. + // + // The suffixes are checked against a list written out here rather than + // against the one under test, because a table that is its own oracle + // proves nothing: swapping JPEG's suffix for ".png" left every other + // check in this file green, since the name and the bytes were both taken + // from the same row. + suffixes := map[codec.Format]string{ + codec.PNG: ".png", + codec.JPEG: ".jpg", + codec.GIF: ".gif", + codec.TIFF: ".tif", + codec.BMP: ".bmp", + } + offered := map[codec.Format]bool{} + for _, p := range pictureFormats { + if !codec.CanEncode(p.format) { + t.Errorf("%s is offered and cannot be written", p.format) + } + want, known := suffixes[p.format] + if !known { + t.Errorf("%s is offered and this test does not know what it is called", p.format) + } else if p.suffix != want { + t.Errorf("%s is handed over as %q, want %q", p.format, p.suffix, want) } + offered[p.format] = true } - if ink == 0 { - t.Error("the page came out blank") + for _, p := range everyKnownFormat { + if codec.CanEncode(p.format) && !offered[p.format] { + t.Errorf("%s can be written and is not offered", p.format) + } + } + if names := formatNames(); len(names) != len(pictureFormats) { + t.Fatalf("%d names for %d formats", len(names), len(pictureFormats)) + } + // A choice that is not one of them falls back on the first rather than + // reaching past the end of the list. + s, _ := opened(t, 1) + for _, bad := range []int{-1, len(pictureFormats)} { + s.tools.picture = bad + if got := s.chosenFormat(); got != pictureFormats[0] { + t.Errorf("choice %d gave %s", bad, got.format) + } } } -func TestAPageThatCannotBeHandedOverAsAPNG(t *testing.T) { +func TestAPageWithNoAlphaComesOutOnWhiteRatherThanBlack(t *testing.T) { + // JPEG, GIF and BMP carry no alpha, and gfx composites onto white for + // them rather than letting the encoder drop the channel — which would put + // what was UNDER the transparency on the page, and that is black. This + // does not redo that; it measures that what arrives is opaque and light, + // whichever way round the page was drawn. + s, h := opened(t, 1) + for _, pic := range pictureFormats { + s.tools.picture = 0 + for i, p := range pictureFormats { + if p.format == pic.format { + s.tools.picture = i + } + } + s.pageAsPicture() + img, err := codec.Decode(h.saved) + if err != nil { + t.Fatalf("%s cannot be read back: %v", pic.format, err) + } + clear, light := 0, 0 + for i := 0; i < img.W*img.H; i++ { + if img.Pix[i*4+3] != 255 { + clear++ + } + if img.Pix[i*4] > 200 && img.Pix[i*4+1] > 200 && img.Pix[i*4+2] > 200 { + light++ + } + } + if clear != 0 { + t.Errorf("%s came back with %d pixels that are not opaque", pic.format, clear) + } + // Most of a page is paper. Were the alpha dropped rather than + // composited, the paper would be black. + if light < img.W*img.H/2 { + t.Errorf("%s came back with %d of %d pixels light; the paper is not white", + pic.format, light, img.W*img.H) + } + } +} + +func TestAPageThatCannotBeHandedOverAsAPicture(t *testing.T) { for _, tc := range []struct { name string setup func(t *testing.T) *state @@ -582,16 +702,18 @@ func TestAPageThatCannotBeHandedOverAsAPNG(t *testing.T) { }, "cannot be drawn"}, {"a picture that cannot be written out", func(t *testing.T) *state { s, _ := opened(t, 1) - was := encodePNG - t.Cleanup(func() { encodePNG = was }) - encodePNG = func(io.Writer, *raster.Image) error { return errors.New("no") } + was := encodeImage + t.Cleanup(func() { encodeImage = was }) + encodeImage = func(io.Writer, *raster.Image, codec.Format) error { + return errors.New("no") + } return s }, "cannot be written as a PNG"}, } { t.Run(tc.name, func(t *testing.T) { s := tc.setup(t) h, _ := s.host.(*fakeHost) - s.pageAsPNG() + s.pageAsPicture() if h != nil && h.as != "" { t.Errorf("something was handed over anyway: %q", h.as) } @@ -612,7 +734,7 @@ func TestHalfAPageSaysSo(t *testing.T) { img, _ := was(d, i, o) return img, render.ErrTimedOut } - s.pageAsPNG() + s.pageAsPicture() if h.as != "page001.png" { t.Fatalf("as far as it got was not handed over: %q", h.as) } @@ -733,6 +855,44 @@ func TestEveryPageZipped(t *testing.T) { } } +func TestTheZipFollowsTheFormatChosenForOnePage(t *testing.T) { + // One chooser governs both hand-overs, so a zip asked for as JPEGs holds + // JPEGs under .jpg names, not PNGs under them. + jpeg := 0 + for i, p := range pictureFormats { + if p.format == codec.JPEG { + jpeg = i + } + } + s, h := opened(t, 2) + s.tools.picture = jpeg + s.everyPageZipped() + zr, err := zip.NewReader(bytes.NewReader(h.saved), int64(len(h.saved))) + if err != nil { + t.Fatalf("what was handed over is not a zip: %v", err) + } + if len(zr.File) != 2 { + t.Fatalf("%d entries for 2 pages", len(zr.File)) + } + for _, f := range zr.File { + if !strings.HasSuffix(f.Name, ".jpg") { + t.Errorf("the zip holds %q", f.Name) + } + rc, err := f.Open() + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.Fatal(err) + } + if got := codec.Sniff(data); got != codec.JPEG { + t.Errorf("%s is a %s", f.Name, got) + } + } +} + func TestWhatThePageSaysIsHandedOver(t *testing.T) { // The reading beside it shows the words on the screen and had no way of // taking them away. @@ -804,7 +964,7 @@ func TestADocumentThatCannotBeReopenedForPictures(t *testing.T) { name string run func(*state) }{ - {"page as a PNG", (*state).pageAsPNG}, + {"page as a picture", (*state).pageAsPicture}, {"every page zipped", (*state).everyPageZipped}, {"what the page says", (*state).textAsFile}, } {