From 6473a80dc3b966b42b260e56952b26785495b88e Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:23:07 -0400 Subject: [PATCH 1/6] Keep kitty uploads out of the frame string Bubble Tea copies each frame into its buffer, copies it three more times per flush and parses every changed line twice. With a page upload of a few megabytes in the frame, a flush took about 12 ms and 12 MB. The frame now carries a short APC marker for each upload, and the terminal writer splices the upload in. The writer moves into the ui package, next to the frames it writes. --- internal/ui/output.go | 150 ++++++++++++++++++++++++++++++++ internal/ui/output_test.go | 92 ++++++++++++++++++++ internal/ui/performance_test.go | 6 +- internal/ui/ui.go | 21 ++--- internal/ui/ui_test.go | 4 +- main.go | 20 +---- main_test.go | 24 ----- 7 files changed, 259 insertions(+), 58 deletions(-) create mode 100644 internal/ui/output.go create mode 100644 internal/ui/output_test.go diff --git a/internal/ui/output.go b/internal/ui/output.go new file mode 100644 index 0000000..d241d8f --- /dev/null +++ b/internal/ui/output.go @@ -0,0 +1,150 @@ +package ui + +import ( + "bytes" + "io" + "os" + "strconv" + "sync" + + "github.com/charmbracelet/x/ansi" +) + +// uploadMarker opens the APC sequence that stands in for a page upload in a +// frame. Terminals ignore APC sequences they don't know, and Bubble Tea +// measures them as zero width and keeps them when it truncates a line. +const ( + uploadMarker = "\x1b_cbzr:" + markerEnd = "\x1b\\" +) + +// uploads hands page uploads to the terminal writer by marker. Bubble Tea +// copies each frame into its buffer, copies it three more times per flush and +// parses every changed line twice. With a kitty upload of a few megabytes in +// the frame, a flush cost about 12 ms and 12 MB; with markers, 56 µs. +type uploads struct { + mu sync.Mutex + views uint64 // frames built so far + next uint64 // id for the next new upload + entries []upload +} + +type upload struct { + data []byte + id uint64 + view uint64 // the last frame that showed it +} + +func newUploads() *uploads { return &uploads{} } + +// begin starts a frame. It keeps the uploads of the previous frame, which +// Bubble Tea may still be writing, and drops older ones; an upload that +// shows up again later gets a new marker, so its line goes out in full. +func (u *uploads) begin() { + if u == nil { + return + } + u.mu.Lock() + defer u.mu.Unlock() + kept := u.entries[:0] + for _, e := range u.entries { + if e.view == u.views { + kept = append(kept, e) + } + } + clear(u.entries[len(kept):]) + u.entries = kept + u.views++ +} + +// appendMarker appends the marker for data to frame. An upload keeps its +// marker from frame to frame, so Bubble Tea sees its line unchanged and does +// not send it again. A nil table appends the upload itself. +func (u *uploads) appendMarker(frame, data []byte) []byte { + if len(data) == 0 { + return frame + } + if u == nil { + return append(frame, data...) + } + u.mu.Lock() + defer u.mu.Unlock() + i := 0 + for i < len(u.entries) && (&u.entries[i].data[0] != &data[0] || len(u.entries[i].data) != len(data)) { + i++ + } + if i == len(u.entries) { + u.next++ + u.entries = append(u.entries, upload{data: data, id: u.next}) + } + u.entries[i].view = u.views + frame = append(frame, uploadMarker...) + frame = strconv.AppendUint(frame, u.entries[i].id, 10) + return append(frame, markerEnd...) +} + +func (u *uploads) lookup(id uint64) []byte { + u.mu.Lock() + defer u.mu.Unlock() + for _, e := range u.entries { + if e.id == id { + return e.data + } + } + return nil +} + +// write sends p to w with every marker replaced by its upload. A marker +// whose upload is gone writes nothing. +func (u *uploads) write(w io.Writer, p []byte) (int, error) { + if u == nil { + return w.Write(p) + } + n := 0 + for { + i := bytes.Index(p, []byte(uploadMarker)) + if i < 0 { + break + } + end := bytes.Index(p[i:], []byte(markerEnd)) + if end < 0 { + break + } + id, _ := strconv.ParseUint(string(p[i+len(uploadMarker):i+end]), 10, 64) + k, err := w.Write(p[:i]) + if err != nil { + return n + k, err + } + // Uploads never change after rendering, so the write needs no lock. + if _, err := w.Write(u.lookup(id)); err != nil { + return n + i, err + } + n += i + end + len(markerEnd) + p = p[i+end+len(markerEnd):] + } + k, err := w.Write(p) + return n + k, err +} + +// Output writes frames to the terminal. It splices page uploads in for their +// markers and wraps each frame in synchronized output, so the terminal never +// paints half a frame. Embedding the file keeps the descriptor Bubble Tea +// uses for resize detection. +type Output struct { + *os.File + uploads *uploads +} + +// Output returns the terminal writer for this model's frames. +func (m Model) Output(f *os.File) Output { return Output{f, m.uploads} } + +func (w Output) Write(p []byte) (int, error) { + if _, err := w.File.WriteString(ansi.SetModeSynchronizedOutput); err != nil { + return 0, err + } + n, err := w.uploads.write(w.File, p) + if _, endErr := w.File.WriteString(ansi.ResetModeSynchronizedOutput); err == nil { + err = endErr + } + return n, err +} diff --git a/internal/ui/output_test.go b/internal/ui/output_test.go new file mode 100644 index 0000000..9cead6d --- /dev/null +++ b/internal/ui/output_test.go @@ -0,0 +1,92 @@ +package ui + +import ( + "bytes" + "os" + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "cbzr/internal/book" + "cbzr/internal/render" +) + +// written returns the bytes Output sends for the model's frame. +func written(t *testing.T, m Model) string { + t.Helper() + var out bytes.Buffer + if _, err := m.uploads.write(&out, []byte(m.View())); err != nil { + t.Fatal(err) + } + return out.String() +} + +func TestUploadsTravelByMarker(t *testing.T) { + m := testModel() + p := m.panes[0] + p.book = &book.Book{Title: "Synthetic"} + p.res = render.Result{Cols: 1, Rows: 1, Lines: []string{"x"}, Transmit: []byte("\x1b_Gfirst\x1b\\")} + first := m.View() + if strings.Contains(first, "first") || strings.Count(first, uploadMarker) != 1 { + t.Fatalf("frame carries the upload instead of a marker: %q", first) + } + if m.View() != first { + t.Fatal("the marker changed between frames, so the upload would go out again") + } + // Bubble Tea truncates and measures each line; the marker must survive both. + if line := uploadMarker + "1" + markerEnd + "abc"; ansi.StringWidth(line) != 3 || !strings.HasPrefix(ansi.Truncate(line, 2, ""), uploadMarker+"1"+markerEnd) { + t.Fatal("the terminal library no longer treats the marker as a zero-width sequence") + } + var out bytes.Buffer + if n, err := m.uploads.write(&out, []byte(first)); err != nil || n != len(first) || strings.Count(out.String(), "\x1b_Gfirst\x1b\\") != 1 || strings.Contains(out.String(), uploadMarker) { + t.Fatalf("spliced frame: %d, %v, %q", n, err, out.String()) + } + + // A new page gets a new marker; the previous frame, which Bubble Tea may + // still be writing, keeps its upload for one more frame. + p.res.Transmit = []byte("\x1b_Gsecond\x1b\\") + second := m.View() + out.Reset() + m.uploads.write(&out, []byte(second)) //nolint:errcheck + if second == first || !strings.Contains(out.String(), "second") { + t.Fatal("a new upload kept the old marker") + } + out.Reset() + m.uploads.write(&out, []byte(first)) //nolint:errcheck + if !strings.Contains(out.String(), "first") { + t.Fatal("the previous frame lost its upload") + } + m.View() + out.Reset() + m.uploads.write(&out, []byte(first)) //nolint:errcheck + if strings.Contains(out.String(), "first") || strings.Contains(out.String(), uploadMarker) || len(m.uploads.entries) != 1 { + t.Fatalf("an old upload stayed or its marker leaked: %q, %d kept", out.String(), len(m.uploads.entries)) + } +} + +func TestOutputFramesAndSplicesUploads(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "output") + if err != nil { + t.Fatal(err) + } + defer f.Close() + m := testModel() + out := m.Output(f) + if out.Fd() != f.Fd() { + t.Fatal("output lost the terminal descriptor") + } + marked := m.uploads.appendMarker([]byte("a"), []byte("\x1b_Gimage\x1b\\")) + frame := append(marked, "b"+uploadMarker+"99"+markerEnd+"c"...) + if n, err := out.Write(frame); err != nil || n != len(frame) { + t.Fatalf("frame write: %d, %v", n, err) + } + got, err := os.ReadFile(f.Name()) + if err != nil || string(got) != "\x1b[?2026ha\x1b_Gimage\x1b\\bc\x1b[?2026l" { + t.Fatalf("unframed output: %q, %v", got, err) + } + f.Close() + if n, err := out.Write(frame); err == nil || n != 0 { + t.Fatalf("failed output write: %d, %v", n, err) + } +} diff --git a/internal/ui/performance_test.go b/internal/ui/performance_test.go index fc5ce30..02ee43d 100644 --- a/internal/ui/performance_test.go +++ b/internal/ui/performance_test.go @@ -34,7 +34,7 @@ func TestChromeChangesKeepImageTransmissionLineStable(t *testing.T) { } found := false for i, line := range before { - if strings.Contains(line, "\x1b_G") { + if strings.Contains(line, uploadMarker) { found = true if line != after[i] { t.Fatal("chrome update would replay the image upload") @@ -45,11 +45,11 @@ func TestChromeChangesKeepImageTransmissionLineStable(t *testing.T) { t.Fatal("frame lost its image transmission") } m.mode = modeHelp - if strings.Contains(m.View(), "\x1b_G") { + if strings.Contains(m.View(), uploadMarker) { t.Fatal("help unexpectedly uploaded an image") } m.mode = modeRead - if !strings.Contains(m.View(), "\x1b_G") { + if !strings.Contains(written(t, m), "\x1b_Gpayload") { t.Fatal("leaving help lost the image transmission") } }) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index c9c80a0..cee6a6f 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -131,6 +131,7 @@ type Model struct { pressX, pressY int hover string // destination of the link under the mouse clip []byte // pending OSC 52 clipboard write + uploads *uploads clipGen int actionGen int // discard deferred text actions after newer input } @@ -178,6 +179,7 @@ func New(r render.Renderer, srv *server.Server, marks *bookmarks.Store, position openBrowser: openBrowser, nativeAvailable: nativeAvailable, ocrText: map[string]string{}, + uploads: newUploads(), } resumeWebtoon := false for i, path := range paths { @@ -1725,8 +1727,13 @@ func (m Model) View() string { } // Keep image uploads on a body row, separate from changing titles and // clipboard writes. Retain them for repaints after menus and coalesced - // frames. They can take megabytes and View runs on every message, so the - // frame copies them once. + // frames. They travel as markers that Output replaces. + m.uploads.begin() + var uploads []byte + for i := 0; i < m.paneCount(); i++ { + uploads = m.uploads.appendMarker(uploads, m.panes[i].res.Transmit) + uploads = m.uploads.appendMarker(uploads, m.panes[i].res2.Transmit) + } row, clipRow := 0, 0 if !m.zen && len(lines) > 1 { row = 1 @@ -1738,10 +1745,7 @@ func (m Model) View() string { if !m.zen || m.mode == modeSearch { status = "\n" + m.statusView() } - size := len(m.clip) + len(lines) + len(status) - for i := 0; i < m.paneCount(); i++ { - size += len(m.panes[i].res.Transmit) + len(m.panes[i].res2.Transmit) - } + size := len(m.clip) + len(uploads) + len(lines) + len(status) for _, line := range lines { size += len(line) } @@ -1755,10 +1759,7 @@ func (m Model) View() string { frame.Write(m.clip) } if r == row { - for i := 0; i < m.paneCount(); i++ { - frame.Write(m.panes[i].res.Transmit) - frame.Write(m.panes[i].res2.Transmit) - } + frame.Write(uploads) } frame.WriteString(line) } diff --git a/internal/ui/ui_test.go b/internal/ui/ui_test.go index aa41319..3a37979 100644 --- a/internal/ui/ui_test.go +++ b/internal/ui/ui_test.go @@ -256,7 +256,7 @@ func testBookPath(t *testing.T, metadata ...string) string { func testModel() Model { return Model{panes: [2]*pane{newPane(), newPane()}, width: 80, height: 24, marks: &bookmarks.Store{}, progress: &progress.Store{}, srv: new(server.Server), - renderer: render.NewHalfBlock(), ocrText: make(map[string]string)} + renderer: render.NewHalfBlock(), ocrText: make(map[string]string), uploads: newUploads()} } func TestOverlayKeepsKittyTransmission(t *testing.T) { @@ -271,7 +271,7 @@ func TestOverlayKeepsKittyTransmission(t *testing.T) { m = updated.(Model) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEsc}) m = updated.(Model) - if !strings.Contains(m.View(), "payload") { + if !strings.Contains(written(t, m), "payload") { t.Fatal("overlay lost unpainted transmission") } } diff --git a/main.go b/main.go index c1d7286..938161c 100644 --- a/main.go +++ b/main.go @@ -14,7 +14,6 @@ import ( "runtime" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/x/ansi" "cbzr/internal/book" "cbzr/internal/bookmarks" @@ -107,7 +106,7 @@ func main() { for { srv.SetBooks(m.Books()) // All-motion reporting feeds the link hover in the status bar. - p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseAllMotion(), tea.WithFPS(120), tea.WithFilter(ui.FilterMouse), tea.WithOutput(terminalOutput{os.Stdout})) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseAllMotion(), tea.WithFPS(120), tea.WithFilter(ui.FilterMouse), tea.WithOutput(m.Output(os.Stdout))) final, err := p.Run() // Free terminal-side images after the program released the tty. for id := uint32(1); id <= 6; id++ { @@ -154,23 +153,6 @@ func main() { } } -// terminalOutput keeps the terminal from painting a partial frame. Embedding -// the file preserves the descriptor Bubble Tea uses for resize detection. -// The terminal holds the frame until the closing mode switch, so the frame -// goes out in three writes instead of a megabyte-sized copy. -type terminalOutput struct{ *os.File } - -func (w terminalOutput) Write(p []byte) (int, error) { - if _, err := w.File.WriteString(ansi.SetModeSynchronizedOutput); err != nil { - return 0, err - } - n, err := w.File.Write(p) - if _, endErr := w.File.WriteString(ansi.ResetModeSynchronizedOutput); err == nil { - err = endErr - } - return n, err -} - func checkBooks(paths []string, stdout, stderr io.Writer) int { if len(paths) == 0 { fmt.Fprintln(stderr, "cbzr: -check needs at least one book path") diff --git a/main_test.go b/main_test.go index fb6a1f2..d98b788 100644 --- a/main_test.go +++ b/main_test.go @@ -120,27 +120,3 @@ func TestCheckCLIHeadless(t *testing.T) { } } } - -func TestTerminalOutputFrames(t *testing.T) { - f, err := os.CreateTemp(t.TempDir(), "output") - if err != nil { - t.Fatal(err) - } - defer f.Close() - out := terminalOutput{f} - if out.Fd() != f.Fd() { - t.Fatal("output wrapper lost the terminal descriptor") - } - frame := []byte("\x1b_Ga=d,d=I,i=1\x1b\\frame") - if n, err := out.Write(frame); err != nil || n != len(frame) { - t.Fatalf("frame write: %d, %v", n, err) - } - got, err := os.ReadFile(f.Name()) - if err != nil || string(got) != "\x1b[?2026h"+string(frame)+"\x1b[?2026l" { - t.Fatalf("unframed output: %q, %v", got, err) - } - f.Close() - if n, err := out.Write(frame); err == nil || n != 0 { - t.Fatalf("failed output write: %d, %v", n, err) - } -} From 0a67931bb9662e8dd040e7aae2d25fa771c8361a Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:26:15 -0400 Subject: [PATCH 2/6] Compose half-block webtoon frames at cell resolution Half-block cells show one pixel across, but webtoon frames were built at the terminal's full pixel size and then shrunk. ComposeWebtoon takes the size of one viewport pixel and keeps the same layout at the smaller size, which cuts a frame from 25 ms to under 1 ms. --- internal/native/native_darwin.go | 2 +- internal/render/webtoon.go | 15 +++++++----- internal/render/webtoon_test.go | 40 +++++++++++++++++++++++++++----- internal/ui/ui.go | 6 ++++- 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/internal/native/native_darwin.go b/internal/native/native_darwin.go index 7287492..e80ea20 100644 --- a/internal/native/native_darwin.go +++ b/internal/native/native_darwin.go @@ -287,7 +287,7 @@ func (r *reader) webtoonFrame(width, height int) (*image.RGBA, error) { } img, page, offset, err := render.ComposeWebtoon( load, r.book.Len(), r.state.Page, r.state.Offset, scrollPixels, - width, height, 0, 1, 1, nil, + width, height, 0, 1, 1, 1, nil, ) if err == nil { if page == r.state.Page && math.Abs(offset-r.state.Offset) < 1e-9 { diff --git a/internal/render/webtoon.go b/internal/render/webtoon.go index d62d599..78cd21c 100644 --- a/internal/render/webtoon.go +++ b/internal/render/webtoon.go @@ -14,16 +14,19 @@ type PageLoader func(int) (image.Image, error) // ComposeWebtoon builds a viewport from consecutive pages. Wide pages shrink // to the viewport; narrow pages keep their native size. Offset is the // fractional vertical position in page. scrollRows may contain fractional -// terminal rows and may cross page boundaries. Pages for which invert reports -// true appear inverted; invert may be nil. Pass the frame to Recycle once it -// is drawn. -func ComposeWebtoon(load PageLoader, pageCount, page int, offset, scrollRows float64, cols, rows, rot int, cw, ch float64, invert func(int) bool) (*image.RGBA, int, float64, error) { +// terminal rows and may cross page boundaries. One viewport pixel covers unit +// terminal pixels each way, so a renderer that shows less detail gets a +// smaller frame with the same layout. Pages for which invert reports true +// appear inverted; invert may be nil. Pass the frame to Recycle once it is +// drawn. +func ComposeWebtoon(load PageLoader, pageCount, page int, offset, scrollRows float64, cols, rows, rot int, cw, ch, unit float64, invert func(int) bool) (*image.RGBA, int, float64, error) { if pageCount < 1 { return nil, 0, 0, fmt.Errorf("webtoon: no pages") } page = min(pageCount-1, max(0, page)) cols, rows = max(1, cols), max(1, rows) - cw, ch = max(1, cw), max(1, ch) + unit = max(1, unit) + cw, ch = max(1, cw)/unit, max(1, ch)/unit viewW := max(1, int(math.Round(float64(cols)*cw))) viewH := max(1, int(math.Round(float64(rows)*ch))) @@ -41,7 +44,7 @@ func ComposeWebtoon(load PageLoader, pageCount, page int, offset, scrollRows flo if b.Dx() < 1 || b.Dy() < 1 { return pageImage{}, fmt.Errorf("webtoon: page %d has invalid dimensions", i+1) } - scale := min(1, float64(viewW)/float64(b.Dx())) + scale := min(1/unit, float64(viewW)/float64(b.Dx())) width := max(1, int(math.Round(float64(b.Dx())*scale))) height := max(1, int(math.Round(float64(b.Dy())*scale))) return pageImage{img: img, width: width, height: height}, nil diff --git a/internal/render/webtoon_test.go b/internal/render/webtoon_test.go index 52e0dfa..c7e8f9a 100644 --- a/internal/render/webtoon_test.go +++ b/internal/render/webtoon_test.go @@ -15,7 +15,7 @@ func TestComposeWebtoonScrollsAcrossPages(t *testing.T) { } load := func(i int) (image.Image, error) { return pages[i], nil } - img, page, offset, err := ComposeWebtoon(load, len(pages), 0, 0, 150, 100, 100, 0, 1, 1, nil) + img, page, offset, err := ComposeWebtoon(load, len(pages), 0, 0, 150, 100, 100, 0, 1, 1, 1, nil) if err != nil { t.Fatal(err) } @@ -25,7 +25,7 @@ func TestComposeWebtoonScrollsAcrossPages(t *testing.T) { assertPixel(t, img, 50, 25, color.RGBA{R: 255, A: 255}) assertPixel(t, img, 50, 75, color.RGBA{B: 255, A: 255}) - img, page, offset, err = ComposeWebtoon(load, len(pages), 0, 0, 250, 100, 100, 0, 1, 1, nil) + img, page, offset, err = ComposeWebtoon(load, len(pages), 0, 0, 250, 100, 100, 0, 1, 1, 1, nil) if err != nil { t.Fatal(err) } @@ -42,7 +42,7 @@ func TestComposeWebtoonScrollsBackwardAcrossPage(t *testing.T) { } load := func(i int) (image.Image, error) { return pages[i], nil } - img, page, offset, err := ComposeWebtoon(load, len(pages), 1, 0.25, -100, 100, 100, 0, 1, 1, nil) + img, page, offset, err := ComposeWebtoon(load, len(pages), 1, 0.25, -100, 100, 100, 0, 1, 1, 1, nil) if err != nil { t.Fatal(err) } @@ -60,7 +60,7 @@ func TestComposeWebtoonKeepsStripEndAtViewportBottom(t *testing.T) { } load := func(i int) (image.Image, error) { return pages[i], nil } - img, page, offset, err := ComposeWebtoon(load, len(pages), 1, 1, 0, 100, 100, 0, 1, 1, nil) + img, page, offset, err := ComposeWebtoon(load, len(pages), 1, 1, 0, 100, 100, 0, 1, 1, 1, nil) if err != nil { t.Fatal(err) } @@ -76,7 +76,7 @@ func TestComposeWebtoonDoesNotUpscaleNarrowPages(t *testing.T) { pages := []image.Image{solidPage(40, 200, pageColor)} load := func(i int) (image.Image, error) { return pages[i], nil } - img, _, _, err := ComposeWebtoon(load, 1, 0, 0, 0, 100, 100, 0, 1, 1, nil) + img, _, _, err := ComposeWebtoon(load, 1, 0, 0, 0, 100, 100, 0, 1, 1, 1, nil) if err != nil { t.Fatal(err) } @@ -93,7 +93,7 @@ func TestComposeWebtoonInvertsShownPages(t *testing.T) { } load := func(i int) (image.Image, error) { return pages[i], nil } // Only the narrow middle page inverts; its blank margins stay transparent. - img, _, _, err := ComposeWebtoon(load, len(pages), 0, 0, 30, 100, 100, 0, 1, 1, func(i int) bool { return i == 1 }) + img, _, _, err := ComposeWebtoon(load, len(pages), 0, 0, 30, 100, 100, 0, 1, 1, 1, func(i int) bool { return i == 1 }) if err != nil { t.Fatal(err) } @@ -106,6 +106,34 @@ func TestComposeWebtoonInvertsShownPages(t *testing.T) { } } +func TestComposeWebtoonAtCellResolutionKeepsLayout(t *testing.T) { + red, blue := color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255} + pages := []image.Image{solidPage(40, 400, red), solidPage(400, 400, blue)} + load := func(i int) (image.Image, error) { return pages[i], nil } + // 25x25 cells of 8x16 pixels, scrolled 10 rows: the full frame is + // 200x400; one pixel per cell across gives 25x50. + full, fullPage, fullOffset, err := ComposeWebtoon(load, 2, 0, 0, 10, 25, 25, 0, 8, 16, 1, nil) + if err != nil { + t.Fatal(err) + } + small, page, offset, err := ComposeWebtoon(load, 2, 0, 0, 10, 25, 25, 0, 8, 16, 8, nil) + if err != nil { + t.Fatal(err) + } + if full.Bounds().Size() != image.Pt(200, 400) || small.Bounds().Size() != image.Pt(25, 50) { + t.Fatalf("frames %v and %v", full.Bounds(), small.Bounds()) + } + if page != fullPage || math.Abs(offset-fullOffset) > 1e-9 || math.Abs(offset-0.4) > 1e-9 { + t.Fatalf("position = page %d offset %.3f, full frame page %d offset %.3f", page, offset, fullPage, fullOffset) + } + // The narrow page keeps a fifth of the width; the wide one fills it. + assertPixel(t, small, 12, 10, red) + assertPixel(t, small, 5, 10, color.RGBA{}) + assertPixel(t, small, 12, 40, blue) + assertPixel(t, full, 100, 80, red) + assertPixel(t, full, 40, 80, color.RGBA{}) +} + func solidPage(width, height int, c color.Color) image.Image { img := image.NewRGBA(image.Rect(0, 0, width, height)) draw.Draw(img, img.Bounds(), &image.Uniform{C: c}, image.Point{}, draw.Src) diff --git a/internal/ui/ui.go b/internal/ui/ui.go index cee6a6f..eda131f 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1601,8 +1601,12 @@ func (m *Model) renderPane(i int) tea.Cmd { if inverted { invertPage = b.CanInvertPage } + unit := 1.0 + if r.Name() == "halfblock" { + unit = cw // a half-block cell shows one pixel across; compose no finer + } var frame *image.RGBA - frame, outPage, outOffset, err = render.ComposeWebtoon(load, b.Len(), pg, offset, scroll, cols, rows, rot, cw, ch, invertPage) + frame, outPage, outOffset, err = render.ComposeWebtoon(load, b.Len(), pg, offset, scroll, cols, rows, rot, cw, ch, unit, invertPage) if err == nil { img, owned = frame, append(owned, frame) } From d8fa9826745ae5eb8d89f798f0e6ead4a659ac65 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:28:13 -0400 Subject: [PATCH 3/6] Reuse recent page rotations Every render of a rotated page, from a pan step to a webtoon frame, copied the whole page again, twice for JPEG pages. The two latest rotations stay cached; a pooled frame drops its rotation when it is recycled. --- internal/render/render.go | 5 ++- internal/render/transform.go | 51 ++++++++++++++++++++++++++++--- internal/render/transform_test.go | 28 +++++++++++++++++ 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/internal/render/render.go b/internal/render/render.go index 4c72c40..fa099fb 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -42,7 +42,10 @@ func getRGBA(r image.Rectangle) *image.RGBA { // Recycle hands back a frame from ComposeWebtoon, Invert or Highlight once // nothing refers to it any more. -func Recycle(img *image.RGBA) { rgbaPool.Put(img) } +func Recycle(img *image.RGBA) { + forgetRotations(img) + rgbaPool.Put(img) +} // fitBox scales iw x ih pixels to fit maxCols x maxRows cells of cw x ch // pixels each, preserving aspect ratio. Returns the cell grid and pixel size. diff --git a/internal/render/transform.go b/internal/render/transform.go index dc52061..6328e2f 100644 --- a/internal/render/transform.go +++ b/internal/render/transform.go @@ -3,6 +3,7 @@ package render import ( "image" "image/draw" + "sync" ) type subImager interface { @@ -11,7 +12,8 @@ type subImager interface { // Transform applies a clockwise quarter-turn rotation and a zoom crop. // rot is quarter turns (0-3), zoom >= 1, cx/cy locate the visible center -// as fractions of the (rotated) image. +// as fractions of the (rotated) image. The result may share pixels with src +// or with other callers; treat it as read-only and never Recycle it. func Transform(img image.Image, rot int, zoom, cx, cy float64) image.Image { img = rotate(img, rot&3) if zoom <= 1.001 { @@ -80,24 +82,49 @@ func Highlight(src image.Image, rects []image.Rectangle) *image.RGBA { return dst } +// rotations keeps the latest quarter turns. Panning or zooming a rotated +// page and every rotated webtoon frame turn the same pages again; two +// entries cover a spread, two panes or the pages of a webtoon frame. +var rotations struct { + sync.Mutex + entries [2]rotation + next int +} + +type rotation struct { + src image.Image + q int + dst *image.RGBA +} + // rotate turns src by q quarter turns clockwise. Pixels move through an -// RGBA copy; At and Set would box a color per pixel. +// RGBA copy; At and Set would box a color per pixel. The result is shared +// with later calls for the same src. func rotate(src image.Image, q int) image.Image { if q == 0 { return src } + rotations.Lock() + for _, r := range rotations.entries { + if r.dst != nil && r.src == src && r.q == q { + rotations.Unlock() + return r.dst + } + } + rotations.Unlock() b := src.Bounds() w, h := b.Dx(), b.Dy() rgba, ok := src.(*image.RGBA) if !ok { - rgba = image.NewRGBA(b) + rgba = getRGBA(b) + defer Recycle(rgba) 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)) + dst := getRGBA(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++ { @@ -113,9 +140,25 @@ func rotate(src image.Image, q int) image.Image { *(*[4]byte)(dst.Pix[i:]) = [4]byte(row[4*x:]) } } + rotations.Lock() + rotations.entries[rotations.next] = rotation{src, q, dst} + rotations.next = (rotations.next + 1) % len(rotations.entries) + rotations.Unlock() return dst } +// forgetRotations drops the turns of img, or the turn that is img, before +// its pixels change. +func forgetRotations(img *image.RGBA) { + rotations.Lock() + defer rotations.Unlock() + for i, r := range rotations.entries { + if r.src == image.Image(img) || r.dst == img { + rotations.entries[i] = rotation{} + } + } +} + func clampf(v, lo, hi float64) float64 { if hi < lo { hi = lo diff --git a/internal/render/transform_test.go b/internal/render/transform_test.go index c48e49f..63df2a2 100644 --- a/internal/render/transform_test.go +++ b/internal/render/transform_test.go @@ -124,3 +124,31 @@ func TestHighlightTintsRectanglesOnly(t *testing.T) { t.Fatalf("rectangle edges: %v %v", got.RGBAAt(2, 0), got.RGBAAt(3, 3)) } } + +func TestRotateReusesRecentTurns(t *testing.T) { + src := image.NewRGBA(image.Rect(0, 0, 3, 2)) + first := rotate(src, 1) + if rotate(src, 1) != first || testing.AllocsPerRun(10, func() { rotate(src, 1) }) != 0 { + t.Fatal("turning the same page again copied it") + } + if rotate(src, 3) == first { + t.Fatal("another turn reused the wrong rotation") + } + // A pooled frame comes back with new pixels; its old turn must go. + frame := getRGBA(image.Rect(0, 0, 1, 1)) + frame.Pix[0] = 1 + if rotate(frame, 2).(*image.RGBA).Pix[0] != 1 { + t.Fatal("rotation lost the pixel") + } + Recycle(frame) + frame.Pix[0] = 9 + if rotate(frame, 2).(*image.RGBA).Pix[0] != 9 { + t.Fatal("a recycled frame kept its old rotation") + } + turned := rotate(src, 2).(*image.RGBA) + Recycle(turned) + turned.Pix[0] = 77 // the pool's next user draws over it + if rotate(src, 2).(*image.RGBA).Pix[0] != 0 { + t.Fatal("a recycled rotation stayed in the cache") + } +} From 9715dc4f98f96873647956b370578fd931bef56a Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:38:37 -0400 Subject: [PATCH 4/6] Cut the memory text books keep Laid-out pages kept every shaped glyph as an 80-byte shaping.Glyph, and painting and selection read six of its fields. Pages now keep those six in 24 bytes and take a glyph's ink bounds from its outline when they paint; rasters stay byte for byte the same. System fallback font collections load only their first face. The faces of Noto Sans CJK and the macOS and Windows collections are regional or metric variants of the same characters, and each parsed face copied the shared tables, about 200 MB for Noto Sans CJK. A 2,874-page FB2 now keeps 124 MB instead of 500 MB with Noto Sans CJK installed. --- internal/book/epub_font.go | 18 ++++++---- internal/book/epub_font_test.go | 11 +++--- internal/book/epub_layout.go | 47 ++++++++++++++----------- internal/book/epub_layout_test.go | 4 +-- internal/book/epub_render.go | 57 ++++++++++++++++++++++--------- internal/book/select.go | 8 ++--- 6 files changed, 92 insertions(+), 53 deletions(-) diff --git a/internal/book/epub_font.go b/internal/book/epub_font.go index db57ef5..9d6b3ae 100644 --- a/internal/book/epub_font.go +++ b/internal/book/epub_font.go @@ -29,7 +29,7 @@ var epubFallbackFonts = sync.OnceValues(findEPUBFallbackFonts) func findEPUBFallbackFonts() ([]*font.Font, error) { if path := os.Getenv("CBZR_EPUB_FALLBACK_FONT"); path != "" { - fonts, err := readEPUBFallbackFonts(path) + fonts, err := readEPUBFallbackFonts(path, maxEPUBFontFaces) if err != nil { return nil, fmt.Errorf("EPUB fallback font: %w", err) } @@ -74,16 +74,20 @@ func findEPUBFallbackFonts() ([]*font.Font, error) { "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", } } + // The faces of these collections are regional or metric variants of the + // same characters. Each parsed face copies the shared tables, which cost + // about 200 MB for all ten faces of Noto Sans CJK, so one face is enough. var fonts []*font.Font for _, path := range paths { - if found, err := readEPUBFallbackFonts(path); err == nil { + if found, err := readEPUBFallbackFonts(path, 1); err == nil { fonts = append(fonts, found...) } } return fonts, nil } -func readEPUBFallbackFonts(path string) ([]*font.Font, error) { +// readEPUBFallbackFonts parses up to faces faces of a font file. +func readEPUBFallbackFonts(path string, faces int) ([]*font.Font, error) { f, err := os.Open(path) if err != nil { return nil, err @@ -93,10 +97,11 @@ func readEPUBFallbackFonts(path string) ([]*font.Font, error) { if err != nil { return nil, err } - return parseEPUBFonts(data) + return parseEPUBFonts(data, faces) } -func parseEPUBFonts(data []byte) ([]*font.Font, error) { +// parseEPUBFonts parses up to faces faces of a TTF, OTF or collection. +func parseEPUBFonts(data []byte, faces int) ([]*font.Font, error) { if len(data) < 4 { return nil, fmt.Errorf("font must be TTF/OTF or TTC/OTC") } @@ -119,6 +124,7 @@ func parseEPUBFonts(data []byte) ([]*font.Font, error) { if len(loaders) < 1 || len(loaders) > maxEPUBFontFaces { return nil, fmt.Errorf("font collection must contain 1 to %d faces", maxEPUBFontFaces) } + loaders = loaders[:min(len(loaders), faces)] fonts := make([]*font.Font, 0, len(loaders)) for _, loader := range loaders { f, err := font.NewFont(loader) @@ -134,7 +140,7 @@ func parseEPUBFonts(data []byte) ([]*font.Font, error) { var epubBundledFonts = sync.OnceValues(func() ([]*font.Font, error) { var fonts []*font.Font for _, data := range [][]byte{goregular.TTF, gobold.TTF, goitalic.TTF, gobolditalic.TTF} { - parsed, err := parseEPUBFonts(data) + parsed, err := parseEPUBFonts(data, 1) if err != nil { return nil, err } diff --git a/internal/book/epub_font_test.go b/internal/book/epub_font_test.go index 48b54bd..dffbbaa 100644 --- a/internal/book/epub_font_test.go +++ b/internal/book/epub_font_test.go @@ -15,7 +15,7 @@ import ( ) func TestEPUBFallbackShapedRaster(t *testing.T) { - fonts, err := parseEPUBFonts(goregular.TTF) + fonts, err := parseEPUBFonts(goregular.TTF, 1) if err != nil { t.Fatal(err) } @@ -33,11 +33,6 @@ func TestEPUBFallbackShapedRaster(t *testing.T) { if len(runs) != 3 || runs[0].font != &primary || runs[1].font != fonts[0] || runs[2].font != &primary { t.Fatalf("wrong fallback runs: %#v", runs) } - for _, run := range runs { - if run.Face != nil { - t.Fatal("page retained mutable layout face") - } - } before, _, err := l.book.PageBytes(0) if err != nil { t.Fatal(err) @@ -121,6 +116,10 @@ func TestEPUBFontCollection(t *testing.T) { if err != nil || len(fonts) != count { t.Fatalf("font collection: %d fonts, %v", len(fonts), err) } + // System collections only need their first face. + if first, err := parseEPUBFonts(data, 1); err != nil || len(first) != 1 { + t.Fatalf("first face of a collection: %d fonts, %v", len(first), err) + } for _, f := range fonts { l := &epubLayout{book: &Book{}, regular: []*font.Face{font.NewFace(f)}, y: epubMargin} if err := l.text("A", false); err != nil { diff --git a/internal/book/epub_layout.go b/internal/book/epub_layout.go index 87a6652..4b1f102 100644 --- a/internal/book/epub_layout.go +++ b/internal/book/epub_layout.go @@ -248,13 +248,28 @@ func epubClusterFaces(inputs []shaping.Input, text []rune, faces []*font.Face) [ func freezeEPUBLine(line shaping.Line, text string) epubLine { line = slices.Clone(line) slices.SortFunc(line, func(a, b shaping.Output) int { return int(a.VisualIndex - b.VisualIndex) }) - result := epubLine{text: text} + result := epubLine{text: text, runs: make([]epubRun, 0, len(line))} var x fixed.Int26_6 for _, output := range line { - f := output.Face.Font - output.Face = nil - output.Glyphs = slices.Clone(output.Glyphs) - result.runs = append(result.runs, epubRun{Output: output, font: f, x: x}) + run := epubRun{ + font: output.Face.Font, x: x, advance: output.Advance, size: output.Size, direction: output.Direction, + ascent: max(output.LineBounds.Ascent.Ceil(), output.GlyphBounds.Ascent.Ceil()), + descent: max((-output.LineBounds.Descent).Ceil(), (-output.GlyphBounds.Descent).Ceil()), + glyphs: make([]epubGlyph, len(output.Glyphs)), + } + var gx fixed.Int26_6 + for i, g := range output.Glyphs { + left := gx + g.XOffset + g.XBearing + if i == 0 || left < run.inkLeft { + run.inkLeft = left + } + if i == 0 || left+g.Width > run.inkRight { + run.inkRight = left + g.Width + } + run.glyphs[i] = epubGlyph{g.GlyphID, g.Advance, g.XOffset, g.YOffset, int32(g.TextIndex()), int32(g.RunesCount())} + gx += g.Advance + } + result.runs = append(result.runs, run) x += output.Advance } return result @@ -262,8 +277,7 @@ func freezeEPUBLine(line shaping.Line, text string) epubLine { func epubLineBounds(line epubLine) (ascent, descent int) { for _, run := range line.runs { - ascent = max(ascent, run.LineBounds.Ascent.Ceil(), run.GlyphBounds.Ascent.Ceil()) - descent = max(descent, (-run.LineBounds.Descent).Ceil(), (-run.GlyphBounds.Descent).Ceil()) + ascent, descent = max(ascent, run.ascent), max(descent, run.descent) } return } @@ -271,20 +285,15 @@ func epubLineBounds(line epubLine) (ascent, descent int) { func epubLineWidth(line epubLine) fixed.Int26_6 { var width fixed.Int26_6 for _, run := range line.runs { - width = max(width, run.x+run.Advance) + width = max(width, run.x+run.advance) } return width } func epubLineFits(line epubLine) bool { for _, run := range line.runs { - x := run.x - for _, glyph := range run.Glyphs { - left := x + glyph.XOffset + glyph.XBearing - if left < -fixed.I(epubMargin) || left+glyph.Width > fixed.I(epubPageWidth-epubMargin) { - return false - } - x += glyph.Advance + if len(run.glyphs) > 0 && (run.x+run.inkLeft < -fixed.I(epubMargin) || run.x+run.inkRight > fixed.I(epubPageWidth-epubMargin)) { + return false } } return true @@ -336,12 +345,12 @@ func (l *epubLayout) paragraph(p epubParagraph, heading bool) error { left, right := fixed.I(epubPageWidth), fixed.Int26_6(0) for _, run := range line.runs { x := run.x - for _, glyph := range run.Glyphs { - if glyph.TextIndex() < ruby.end && glyph.TextIndex()+glyph.RunesCount() > ruby.start { + for _, glyph := range run.glyphs { + if int(glyph.index) < ruby.end && int(glyph.index+glyph.runes) > ruby.start { left = min(left, x) - right = max(right, x+glyph.Advance) + right = max(right, x+glyph.advance) } - x += glyph.Advance + x += glyph.advance } } if right <= left { diff --git a/internal/book/epub_layout_test.go b/internal/book/epub_layout_test.go index a7f7256..e7996b8 100644 --- a/internal/book/epub_layout_test.go +++ b/internal/book/epub_layout_test.go @@ -18,7 +18,7 @@ import ( func bundledEPUBLayout(t *testing.T) *epubLayout { t.Helper() - fonts, err := parseEPUBFonts(goregular.TTF) + fonts, err := parseEPUBFonts(goregular.TTF, 1) if err != nil { t.Fatal(err) } @@ -100,7 +100,7 @@ func TestEPUBRubyInlineAndPageBounds(t *testing.T) { t.Fatal("ruby outside annotation band") } for _, run := range annotation.runs { - if run.Size != fixed.I(16) { + if run.size != fixed.I(16) { t.Fatal("reading is not smaller") } } diff --git a/internal/book/epub_render.go b/internal/book/epub_render.go index 6892d9b..8ab0026 100644 --- a/internal/book/epub_render.go +++ b/internal/book/epub_render.go @@ -12,6 +12,7 @@ import ( "strings" "sync" + "github.com/go-text/typesetting/di" "github.com/go-text/typesetting/font" "github.com/go-text/typesetting/font/opentype" "github.com/go-text/typesetting/shaping" @@ -26,9 +27,20 @@ const ( ) type epubRun struct { - shaping.Output - font *font.Font - x fixed.Int26_6 + font *font.Font + x, advance, size fixed.Int26_6 + direction di.Direction + ascent, descent int // pixels above and below the baseline + inkLeft, inkRight fixed.Int26_6 // glyph ink extent from x + glyphs []epubGlyph +} + +// epubGlyph keeps what painting and selection read of a shaped glyph in 24 +// bytes. shaping.Glyph takes 80, and a long novel lays out millions. +type epubGlyph struct { + id font.GID + advance, xOffset, yOffset fixed.Int26_6 + index, runes int32 // rune range in the paragraph } type epubLine struct { @@ -73,24 +85,37 @@ func (p epubTextPage) render(ctx context.Context) ([]byte, error) { face = font.NewFace(run.font) faces[run.font] = face } - scale := float32(run.Size) / 64 / float32(face.Upem()) + scale := float32(run.size) / 64 / float32(face.Upem()) x := float32(epubMargin) + float32(run.x)/64 - for _, glyph := range run.Glyphs { + for _, glyph := range run.glyphs { if err := ctx.Err(); err != nil { return err } - if glyph.GlyphID != font.EmptyGlyph { - outline, ok := face.GlyphDataOutline(glyph.GlyphID) - ox := x + float32(glyph.XOffset)/64 - oy := float32(line.y) - float32(glyph.YOffset)/64 + if glyph.id != font.EmptyGlyph { + outline, ok := face.GlyphDataOutline(glyph.id) + if !ok || len(outline.Segments) == 0 { + x += float32(glyph.advance) / 64 + continue + } + ox := x + float32(glyph.xOffset)/64 + oy := float32(line.y) - float32(glyph.yOffset)/64 + // The outline's points enclose its curves, so they bound the ink. + minX, minY := float32(math.Inf(1)), float32(math.Inf(1)) + maxX, maxY := float32(math.Inf(-1)), float32(math.Inf(-1)) + for i := range outline.Segments { + for _, p := range outline.Segments[i].ArgsSlice() { + minX, maxX = min(minX, p.X), max(maxX, p.X) + minY, maxY = min(minY, p.Y), max(maxY, p.Y) + } + } bounds := image.Rect( - int(math.Floor(float64(ox+float32(glyph.XBearing)/64)))-1, - int(math.Floor(float64(oy-float32(glyph.YBearing)/64)))-1, - int(math.Ceil(float64(ox+float32(glyph.XBearing+glyph.Width)/64)))+1, - int(math.Ceil(float64(oy-float32(glyph.YBearing+glyph.Height)/64)))+1, + int(math.Floor(float64(ox+minX*scale)))-1, + int(math.Floor(float64(oy-maxY*scale)))-1, + int(math.Ceil(float64(ox+maxX*scale)))+1, + int(math.Ceil(float64(oy-minY*scale)))+1, ).Intersect(canvas.Bounds()) - if !ok || len(outline.Segments) == 0 || bounds.Empty() { - x += float32(glyph.Advance) / 64 + if bounds.Empty() { + x += float32(glyph.advance) / 64 continue } ox -= float32(bounds.Min.X) @@ -117,7 +142,7 @@ func (p epubTextPage) render(ctx context.Context) ([]byte, error) { raster.ClosePath() raster.Draw(canvas, bounds, image.Black, image.Point{}) } - x += float32(glyph.Advance) / 64 + x += float32(glyph.advance) / 64 } } for _, annotation := range line.annotations { diff --git a/internal/book/select.go b/internal/book/select.go index 3e66702..a4d42c9 100644 --- a/internal/book/select.go +++ b/internal/book/select.go @@ -190,10 +190,10 @@ func (p epubTextPage) textLines() []textLine { out := textLine{text: line.text, top: float64(top) / epubPageHeight, bottom: float64(bottom) / epubPageHeight, end: line.end} for _, run := range line.runs { x := fixed.I(epubMargin) + run.x - rtl := run.Direction.Progression() == di.TowardTopLeft - for _, glyph := range run.Glyphs { - out.boxes = append(out.boxes, textBox{epubFraction(x), epubFraction(x + glyph.Advance), glyph.TextIndex() - line.offset, glyph.RunesCount(), rtl}) - x += glyph.Advance + rtl := run.direction.Progression() == di.TowardTopLeft + for _, glyph := range run.glyphs { + out.boxes = append(out.boxes, textBox{epubFraction(x), epubFraction(x + glyph.advance), int(glyph.index) - line.offset, int(glyph.runes), rtl}) + x += glyph.advance } } for _, link := range line.links { From c23dbf9d35788840a8cc78a7621d9b0f2b2920e8 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:53:02 -0400 Subject: [PATCH 5/6] Keep half-block webtoon frames at full width With cells of 8.4x17 pixels, a frame height rounded up made the frame fit its cells by height and leave a column blank. --- internal/render/webtoon.go | 5 +++++ internal/render/webtoon_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/internal/render/webtoon.go b/internal/render/webtoon.go index 78cd21c..925a7d6 100644 --- a/internal/render/webtoon.go +++ b/internal/render/webtoon.go @@ -29,6 +29,11 @@ func ComposeWebtoon(load PageLoader, pageCount, page int, offset, scrollRows flo cw, ch = max(1, cw)/unit, max(1, ch)/unit viewW := max(1, int(math.Round(float64(cols)*cw))) viewH := max(1, int(math.Round(float64(rows)*ch))) + if unit > 1 { + // A coarse frame rounded up in height would fit its cells by height + // and lose a column; rounded down, the width decides. + viewH = max(1, int(float64(rows)*ch)) + } type pageImage struct { img image.Image diff --git a/internal/render/webtoon_test.go b/internal/render/webtoon_test.go index c7e8f9a..60c6288 100644 --- a/internal/render/webtoon_test.go +++ b/internal/render/webtoon_test.go @@ -132,6 +132,16 @@ func TestComposeWebtoonAtCellResolutionKeepsLayout(t *testing.T) { assertPixel(t, small, 12, 40, blue) assertPixel(t, full, 100, 80, red) assertPixel(t, full, 40, 80, color.RGBA{}) + // Cells of 8.4x17 pixels make a frame 50.6 pixels tall; rounded up, it + // would fit its cells by height and leave a column blank. + frame, _, _, err := ComposeWebtoon(load, 2, 0, 0, 0, 200, 25, 0, 8.4, 17, 8.4, nil) + if err != nil { + t.Fatal(err) + } + res, err := (&HalfBlock{cw: 8.4, ch: 17}).Render(frame, 0, 200, 25, false) + if err != nil || res.Cols != 200 || res.Rows != 25 { + t.Fatalf("half-block grid %dx%d, %v", res.Cols, res.Rows, err) + } } func solidPage(width, height int, c color.Color) image.Image { From 15bdbab20c03a89f4a8fb634494dcb116f9b52d2 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:53:03 -0400 Subject: [PATCH 6/6] Document that rotations are cached by image --- internal/render/transform.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/render/transform.go b/internal/render/transform.go index 6328e2f..60b7739 100644 --- a/internal/render/transform.go +++ b/internal/render/transform.go @@ -14,6 +14,8 @@ type subImager interface { // rot is quarter turns (0-3), zoom >= 1, cx/cy locate the visible center // as fractions of the (rotated) image. The result may share pixels with src // or with other callers; treat it as read-only and never Recycle it. +// Rotations are cached by image, so src must not change afterwards unless it +// goes through Recycle. func Transform(img image.Image, rot int, zoom, cx, cy float64) image.Image { img = rotate(img, rot&3) if zoom <= 1.001 {