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 { 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/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..60b7739 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,10 @@ 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. +// 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 { @@ -80,24 +84,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 +142,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") + } +} diff --git a/internal/render/webtoon.go b/internal/render/webtoon.go index d62d599..925a7d6 100644 --- a/internal/render/webtoon.go +++ b/internal/render/webtoon.go @@ -14,18 +14,26 @@ 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))) + 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 @@ -41,7 +49,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..60c6288 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,44 @@ 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{}) + // 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 { 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/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..eda131f 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 { @@ -1599,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) } @@ -1725,8 +1731,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 +1749,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 +1763,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) - } -}