diff --git a/read.go b/read.go index 31a8e22..1fbc60d 100644 --- a/read.go +++ b/read.go @@ -9,6 +9,7 @@ package main import ( + "archive/zip" "bytes" "errors" "fmt" @@ -40,6 +41,10 @@ func (s *state) readGroup() *column { func() { s.read("") }), bareH) box.add(button("Hand over this page as a PNG", toolkit.ButtonDefault, s.pageAsPNG), 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, + s.textAsFile), bareH) box.add(toolkit.NewLabel("None of these changes the document."), bareH) return box } @@ -139,30 +144,102 @@ func (s *state) pageAsPNG() { s.fail(msg) return } - img, err := drawPage(src, s.at, render.Options{ + png, partial, err := s.drawnPNG(src, s.at) + if err != nil { + s.fail(err.Error()) + return + } + s.handOver(fmt.Sprintf("page%03d.png", s.at), png) + 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) { + img, err := drawPage(src, at, render.Options{ Scale: 2 * s.fitScale(src), MaxDuration: pageBudget, }) // A page that ran out of time comes back as far as it got. Handing that // over silently would be handing over half a page as though it were the - // page, so this says which it is. - partial := errors.Is(err, render.ErrTimedOut) && img != nil + // page, so the caller is told which it is. + partial = errors.Is(err, render.ErrTimedOut) && img != nil if err != nil && !partial { - s.fail("this page cannot be drawn: " + err.Error()) - return + return nil, false, fmt.Errorf("this page cannot be drawn: %w", err) } var buf bytes.Buffer if err := encodePNG(&buf, img); err != nil { - s.fail("this page cannot be written as a PNG: " + err.Error()) + return nil, false, fmt.Errorf("this page cannot be written as a PNG: %w", err) + } + return buf.Bytes(), partial, nil +} + +// everyPageZipped hands over the whole document as pictures, in one file. +// +// 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. +func (s *state) everyPageZipped() { + if s.doc == nil { + s.fail("open a document first") return } - s.handOver(fmt.Sprintf("page%03d.png", s.at), buf.Bytes()) - if partial { - s.note += fmt.Sprintf("; this page was still being drawn after %s, so that is as far as it got", pageBudget) + src, msg := s.reopen() + if msg != "" { + s.fail(msg) + return + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + short := 0 + for at := 1; at <= src.PageCount(); at++ { + png, partial, err := s.drawnPNG(src, at) + if err != nil { + s.fail(err.Error()) + return + } + if partial { + short++ + } + // The zip is built in memory: Create only refuses a name already + // 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) + } + zw.Close() + s.handOver(strings.TrimSuffix(s.name, ".pdf")+"-pages.zip", buf.Bytes()) + if short > 0 { + s.note += fmt.Sprintf("; %d of them were still being drawn after %s", short, pageBudget) s.refresh() } } +// textAsFile hands over what the page says, which the reading beside it shows +// on the screen and had no way of taking away. +func (s *state) textAsFile() { + if s.doc == nil { + s.fail("open a document first") + return + } + src, msg := s.reopen() + if msg != "" { + s.fail(msg) + return + } + text, err := extract.Text(src, s.at) + if err != nil { + s.fail("this page cannot be read: " + err.Error()) + return + } + 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 // picture fails, which is the branch that decides whether a person is handed a // truncated file or told. diff --git a/verbs_test.go b/verbs_test.go index f7b3047..6a88c82 100644 --- a/verbs_test.go +++ b/verbs_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "archive/zip" "github.com/go-gfx/gfx/codec" "github.com/go-gfx/gfx/raster" "github.com/go-pdfkit/extract" @@ -699,3 +700,142 @@ func TestAPictureThatCannotBeWrittenOut(t *testing.T) { t.Errorf("it said %q", s.note) } } + +func TestEveryPageZipped(t *testing.T) { + // A page at a time is no use for a document of two hundred, and a browser + // handed two hundred downloads at once asks about each of them. + s, h := opened(t, 1) + s.everyPageZipped() + if !strings.HasSuffix(h.as, "-pages.zip") { + t.Fatalf("it was handed over as %q", h.as) + } + 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) != s.doc.PageCount() { + t.Fatalf("%d files for %d pages", len(zr.File), s.doc.PageCount()) + } + // Every one of them is a picture, not an empty entry with a name. + for _, f := range zr.File { + rc, err := f.Open() + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + t.Fatal(err) + } + if codec.Sniff(data) != codec.PNG { + t.Errorf("%s is not a PNG", f.Name) + } + } +} + +func TestWhatThePageSaysIsHandedOver(t *testing.T) { + // The reading beside it shows the words on the screen and had no way of + // taking them away. + h := &fakeHost{name: "words.pdf", file: wordyPDF(t)} + s := newState(surfaceW, surfaceH, h) + s.open() + if s.doc == nil { + t.Fatalf("the document did not open: %q", s.note) + } + s.textAsFile() + if h.as != "page001.txt" { + t.Fatalf("it was handed over as %q", h.as) + } + if len(h.saved) == 0 { + t.Error("a page with words on it handed over nothing") + } +} + +func TestNothingOpenToHandOver(t *testing.T) { + for _, verb := range []struct { + name string + run func(*state) + }{ + {"every page zipped", (*state).everyPageZipped}, + {"what the page says", (*state).textAsFile}, + } { + t.Run(verb.name, func(t *testing.T) { + s := newState(surfaceW, surfaceH, &fakeHost{}) + verb.run(s) + if !strings.Contains(s.note, "open a document first") { + t.Errorf("it said %q", s.note) + } + }) + } +} + +func TestAZipOfPagesThatWillNotDraw(t *testing.T) { + s, h := opened(t, 1) + was := drawPage + t.Cleanup(func() { drawPage = was }) + drawPage = func(*reader.Document, int, render.Options) (*raster.Image, error) { + return nil, errors.New("no") + } + s.everyPageZipped() + if h.as != "" { + t.Errorf("a zip was handed over anyway: %q", h.as) + } + if !strings.Contains(s.note, "cannot be drawn") { + t.Errorf("it said %q", s.note) + } +} + +func TestAZipOfPagesStillBeingDrawn(t *testing.T) { + s, _ := opened(t, 1) + was := drawPage + t.Cleanup(func() { drawPage = was }) + drawPage = func(d *reader.Document, i int, o render.Options) (*raster.Image, error) { + img, _ := was(d, i, o) + return img, render.ErrTimedOut + } + s.everyPageZipped() + if !strings.Contains(s.note, "still being drawn") { + t.Errorf("it said %q", s.note) + } +} + +func TestADocumentThatCannotBeReopenedForPictures(t *testing.T) { + for _, verb := range []struct { + name string + run func(*state) + }{ + {"page as a PNG", (*state).pageAsPNG}, + {"every page zipped", (*state).everyPageZipped}, + {"what the page says", (*state).textAsFile}, + } { + t.Run(verb.name, func(t *testing.T) { + s, _ := opened(t, 1) + was := docBytes + t.Cleanup(func() { docBytes = was }) + docBytes = func(*ops.Doc) ([]byte, error) { return nil, errors.New("the ink ran out") } + verb.run(s) + if !strings.Contains(s.note, "cannot be written") { + t.Errorf("it said %q", s.note) + } + }) + } +} + +func TestAPageWhoseWordsCannotBeRead(t *testing.T) { + // A page whose content stream will not decode has no words to hand over, + // and saying so beats handing over an empty file as though the page were + // blank. + s := newState(surfaceW, surfaceH, &fakeHost{name: "odd.pdf", file: unreadablePDF(t)}) + s.open() + if s.doc == nil { + t.Fatal("the document did not open") + } + h, _ := s.host.(*fakeHost) + s.textAsFile() + if h.as != "" { + t.Errorf("something was handed over anyway: %q", h.as) + } + if !strings.Contains(s.note, "cannot be read") { + t.Errorf("it said %q", s.note) + } +}