From fb9b25ff2b7984dcd8d92d22118a8d96fece6e45 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 30 Aug 2026 16:40:08 +0200 Subject: [PATCH] Hand a page over as a JPEG, GIF, TIFF or BMP as well as a PNG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Read group could draw a page and hand it over one way. gfx writes five formats, and which five is its own answer rather than a guess: the list here names every container codec can READ, with the suffix a person expects, and codec.CanEncode decides which of them reach the chooser. A reference encoder arriving in gfx — WEBP is the obvious one — puts that format in front of a person with no line changing here. The chooser is a CycleButton on the same row as the verb it governs, the idiom the Pages group already uses for "Turn them", so no row moved and the tests that press the Read panel by row position kept their coordinates. It governs the zip below it as well: both verbs draw a page and write it, and two choosers disagreeing about the same picture would be a question nobody asked. drawnPNG is drawnPicture, encodePNG is encodeImage taking a format, and pageAsPNG is pageAsPicture, because the old names would be lies. What was measured, not just added: - Every offered format is pressed through the chooser on the panel and the bytes that come back are SNIFFED, not trusted: page001.jpg has to sniff as JPEG. Sizes, ink and the wrap back round to PNG are checked with it. - The suffix table is checked against a list written out in the test rather than against itself. That was not academic: swapping JPEG's suffix for ".png" first left every check in the file green, because the name and the bytes were both read from the same row. It fails now. - Three more mutations were run and all three fail: writing PNG whatever is chosen, naming the zip entries .png regardless, and leaving the chooser's subscription unwired. - Alpha is gfx's business and is not redone here. What is checked is what arrives: every format comes back with no pixel below full opacity and with more than half the page light, so the paper is white rather than the black a dropped alpha channel would leave. - A page that ran out of time is still handed over and the status line still says so, in whichever format was chosen. - Over 40 real arXiv documents, the first page of each written five ways: every file was named for what it holds, and the mean came to 209 kB (PNG), 129 kB (JPEG), 105 kB (GIF), 4.9 MB (BMP) and 6.6 MB (TIFF). The last two are uncompressed, and the ratio between them is exactly 4 bytes a pixel against 3 — the alpha channel TIFF carries. That is gfx's to change if it ever is, but it is written down where somebody choosing a format for a 200-page document will see it. 100% of statements, go vet and gofmt clean, the js/wasm build links, and the browser check drives it in Chrome and comes back PASS. Also: the README's Read bullet had said nothing about handing a page over at all, having been left behind by two earlier changes. It now describes what the group does. Co-Authored-By: Claude Opus 5 --- README.md | 7 +- panel.go | 4 +- read.go | 135 ++++++++++++++++++++++++++----- verbs_test.go | 216 +++++++++++++++++++++++++++++++++++++++++++------- 4 files changed, 312 insertions(+), 50 deletions(-) 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}, } {