diff --git a/internal/book/epub_render.go b/internal/book/epub_render.go index ecac726..62cbf6e 100644 --- a/internal/book/epub_render.go +++ b/internal/book/epub_render.go @@ -123,7 +123,7 @@ func (p epubTextPage) open(ctx context.Context) (io.ReadCloser, error) { } } var data bytes.Buffer - if err := png.Encode(contextWriter{ctx, &data}, canvas); err != nil { + if err := (&png.Encoder{CompressionLevel: png.BestSpeed}).Encode(contextWriter{ctx, &data}, canvas); err != nil { return nil, err } return io.NopCloser(bytes.NewReader(data.Bytes())), nil diff --git a/internal/render/transform.go b/internal/render/transform.go index 821d89c..39bf91c 100644 --- a/internal/render/transform.go +++ b/internal/render/transform.go @@ -70,34 +70,37 @@ func Highlight(src image.Image, rects []image.Rectangle) *image.RGBA { return dst } +// rotate turns src by q quarter turns clockwise. Pixels move through an +// RGBA copy; At and Set would box a color per pixel. func rotate(src image.Image, q int) image.Image { if q == 0 { return src } b := src.Bounds() w, h := b.Dx(), b.Dy() - var dst *image.RGBA - switch q { - case 1: // 90 cw - dst = image.NewRGBA(image.Rect(0, 0, h, w)) - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - dst.Set(h-1-y, x, src.At(b.Min.X+x, b.Min.Y+y)) - } - } - case 2: - dst = image.NewRGBA(image.Rect(0, 0, w, h)) - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - dst.Set(w-1-x, h-1-y, src.At(b.Min.X+x, b.Min.Y+y)) - } - } - default: // 270 cw - dst = image.NewRGBA(image.Rect(0, 0, h, w)) - for y := 0; y < h; y++ { - for x := 0; x < w; x++ { - dst.Set(y, w-1-x, src.At(b.Min.X+x, b.Min.Y+y)) + rgba, ok := src.(*image.RGBA) + if !ok { + rgba = image.NewRGBA(b) + draw.Draw(rgba, b, src, b.Min, draw.Src) + } + dw, dh := h, w + if q == 2 { + dw, dh = w, h + } + dst := image.NewRGBA(image.Rect(0, 0, dw, dh)) + for y := 0; y < h; y++ { + row := rgba.Pix[rgba.PixOffset(b.Min.X, b.Min.Y+y):] + for x := 0; x < w; x++ { + var i int + switch q { + case 1: // 90 cw + i = dst.PixOffset(h-1-y, x) + case 2: + i = dst.PixOffset(w-1-x, h-1-y) + default: // 270 cw + i = dst.PixOffset(y, w-1-x) } + *(*[4]byte)(dst.Pix[i:]) = [4]byte(row[4*x:]) } } return dst diff --git a/internal/render/transform_test.go b/internal/render/transform_test.go index fc32197..b053872 100644 --- a/internal/render/transform_test.go +++ b/internal/render/transform_test.go @@ -34,6 +34,47 @@ func TestInvertPreservesAlphaBoundsAndSource(t *testing.T) { } } +func TestRotateQuarterTurnsClockwise(t *testing.T) { + src := image.NewNRGBA(image.Rect(1, 1, 3, 4)) // 2 wide, 3 tall, offset bounds + for y := range 3 { + for x := range 2 { + src.SetNRGBA(1+x, 1+y, color.NRGBA{uint8(x), uint8(y), 0, 255}) + } + } + at := func(img image.Image, x, y int) color.RGBA { return color.RGBAModel.Convert(img.At(x, y)).(color.RGBA) } + cases := []struct { + q, w, h int + pos func(x, y int) (int, int) // where source pixel (x, y) lands + }{ + {1, 3, 2, func(x, y int) (int, int) { return 2 - y, x }}, + {2, 2, 3, func(x, y int) (int, int) { return 1 - x, 2 - y }}, + {3, 3, 2, func(x, y int) (int, int) { return y, 1 - x }}, + } + for _, c := range cases { + got := rotate(src, c.q) + if got.Bounds() != image.Rect(0, 0, c.w, c.h) { + t.Fatalf("q=%d bounds = %v", c.q, got.Bounds()) + } + for y := range 3 { + for x := range 2 { + dx, dy := c.pos(x, y) + if want := (color.RGBA{uint8(x), uint8(y), 0, 255}); at(got, dx, dy) != want { + t.Fatalf("q=%d pixel (%d,%d) = %v, want %v", c.q, dx, dy, at(got, dx, dy), want) + } + } + } + } + // RGBA input skips the conversion copy, including a sub-image with offset bounds. + rgba := rotate(src, 2).(*image.RGBA) + sub := rgba.SubImage(image.Rect(1, 1, 2, 3)) + if got := rotate(sub, 1); at(got, 1, 0) != at(sub, 1, 1) || at(got, 0, 0) != at(sub, 1, 2) { + t.Fatalf("sub-image rotation: %v %v", at(got, 1, 0), at(got, 0, 0)) + } + if back := rotate(rotate(rgba, 1), 1); at(back, 0, 0) != at(src, 1, 1) || at(back, 1, 2) != at(src, 2, 3) { + t.Fatal("four quarter turns did not restore the image") + } +} + func TestHighlightTintsRectanglesOnly(t *testing.T) { src := image.NewRGBA(image.Rect(0, 0, 4, 4)) for y := 0; y < 4; y++ { diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c72a0fd..1e650f1 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -188,8 +188,8 @@ func New(r render.Renderer, srv *server.Server, marks *bookmarks.Store, position } } } - if m.panes[1].book != nil { - m.split = true + if len(paths) > 1 { + m.split = true // a second book that failed to open still shows its error } else { m.webtoon = resumeWebtoon } @@ -1366,28 +1366,36 @@ func (m Model) updateOCR(msg ocrMsg) (tea.Model, tea.Cmd) { m.find.running = false return m, nil } - if msg.err == nil { - key := p.book.Path + "\x00" + strconv.Itoa(msg.page) - m.ocrText[key] = msg.text - if strings.Contains(strings.ToLower(msg.text), m.find.term) { - m.find.hits = append(m.find.hits, msg.page) - } - } else { - m.find.skipped++ - } - m.find.scanned = msg.page + 1 - if m.find.scanned >= m.find.total { - m.find.running = false - m.status = fmt.Sprintf("search done: %d hit(s) for %q · n/p to jump", len(m.find.hits), m.find.term) - if m.find.skipped > 0 { - m.status += fmt.Sprintf(" · %d page(s) not searched", m.find.skipped) + for batch := 1; ; batch++ { // pages handled in this update + if msg.err == nil { + key := p.book.Path + "\x00" + strconv.Itoa(msg.page) + m.ocrText[key] = msg.text + if strings.Contains(strings.ToLower(msg.text), m.find.term) { + m.find.hits = append(m.find.hits, msg.page) + } + } else { + m.find.skipped++ + } + m.find.scanned = msg.page + 1 + if m.find.scanned >= m.find.total { + m.find.running = false + m.status = fmt.Sprintf("search done: %d hit(s) for %q · n/p to jump", len(m.find.hits), m.find.term) + if m.find.skipped > 0 { + m.status += fmt.Sprintf(" · %d page(s) not searched", m.find.skipped) + } + if len(m.find.hits) > 0 && m.active == m.find.pane { + return m.gotoHit(1) + } + return m, nil } - if len(m.find.hits) > 0 && m.active == m.find.pane { - return m.gotoHit(1) + // Book text needs no OCR: read it here in batches instead of one message + // per page, and yield between batches so input and progress stay live. + next := msg.page + 1 + if batch == 64 || !p.book.IsTextPage(next) { + return m, m.ocrPage(msg.gen, next) } - return m, nil + msg = ocrMsg{gen: msg.gen, page: next, book: p.book, text: strings.Join(p.book.PageText(next), "\n")} } - return m, m.ocrPage(msg.gen, msg.page+1) } func (m Model) gotoHit(dir int) (tea.Model, tea.Cmd) { diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index 6e4e9a6..b7670ab 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "testing" @@ -416,6 +417,49 @@ func TestReplacedBookRejectsRenderAndOCR(t *testing.T) { } } +func TestTextSearchScansPagesInBatches(t *testing.T) { + // Each heading starts a page: 70 text pages, the image, then the same 70 again. + var doc strings.Builder + for i := range 70 { + doc.WriteString("
needle
") + } + } + m := New(render.NewHalfBlock(), new(server.Server), &bookmarks.Store{}, &progress.Store{}, nil, false, []string{testEPUBPath(t, doc.String())}) + if m.panes[0].book == nil { + t.Fatalf("open EPUB: %v", m.panes[0].err) + } + defer m.panes[0].book.Close() + if n := m.panes[0].book.Len(); n != 141 { + t.Fatalf("pages = %d, want 141", n) + } + updated, cmd := m.startSearch("needle") + m = updated.(Model) + step := func(msg tea.Msg, scanned int, hits ...int) tea.Cmd { + t.Helper() + updated, cmd := m.Update(msg) + m = updated.(Model) + if m.find.scanned != scanned || !slices.Equal(m.find.hits, hits) { + t.Fatalf("scanned %d hits %v, want %d %v", m.find.scanned, m.find.hits, scanned, hits) + } + return cmd + } + // Page 0 arrives by message; the same update scans through page 63, then yields. + cmd = step(cmd(), 64, 63) + // Pages 64-69 scan in one update; the image page gets its own OCR command. + cmd = step(cmd(), 70, 63, 64) + if cmd == nil || !m.find.running { + t.Fatal("search stopped at the image page") + } + // The failed image page counts as skipped; the text after it scans in batches again. + cmd = step(ocrMsg{gen: m.find.gen, page: 70, book: m.panes[0].book, err: errors.New("no OCR")}, 134, 63, 64) + step(cmd(), 141, 63, 64, 134, 135) + if m.find.running || m.find.skipped != 1 || m.panes[0].page != 63 { + t.Fatalf("search end: running=%v skipped=%d page=%d", m.find.running, m.find.skipped, m.panes[0].page) + } +} + func TestPaneRemapRejectsOldRender(t *testing.T) { m := testModel() m.split = true @@ -545,8 +589,14 @@ func TestOpenSplitRendersOriginalPaneAndCancelsSearch(t *testing.T) { } } -func testEPUBPath(t *testing.T) string { +// testEPUBPath writes an EPUB whose spine holds a text document, an image +// and the text document again. A body replaces the default text content. +func testEPUBPath(t *testing.T, body ...string) string { t.Helper() + xhtml := `` + strings.Repeat("scroll ", 30) + `
` + if len(body) > 0 { + xhtml = strings.Join(body, "") + } path := filepath.Join(t.TempDir(), "text.epub") f, err := os.Create(path) if err != nil { @@ -556,7 +606,7 @@ func testEPUBPath(t *testing.T) string { for name, text := range map[string]string{ "META-INF/container.xml": `` + strings.Repeat("scroll ", 30) + `
`, + "text.xhtml": `` + xhtml + ``, } { w, err := z.Create(name) if err != nil {