├── .gitignore ├── Makefile ├── go.mod ├── README.md ├── main.go ├── exit_scene.go ├── gemini.go ├── LICENSE ├── go.sum ├── browser_scene.go └── layout.go /.gitignore: -------------------------------------------------------------------------------- 1 | /rmgem 2 | /rmgem.arm 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: rmgem rmgem.arm 2 | 3 | rmgem: $(wildcard *.go) 4 | go build -o rmgem 5 | 6 | rmgem.arm: $(wildcard *.go) 7 | GOOS=linux GOARCH=arm go build -o rmgem.arm 8 | 9 | .PHONY: clean 10 | clean: 11 | rm rmgem rmgem.arm 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/irth/rmgem 2 | 3 | go 1.15 4 | 5 | require ( 6 | git.sr.ht/~adnano/go-gemini v0.1.19 // indirect 7 | github.com/irth/go-simple v0.0.0-20210309191312-fedb235a168c 8 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 9 | ) 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RMGem 2 | 3 | A *very* simple and rudimentary Gemini client for the reMarkable. 4 | 5 | To build, install Golang >=1.16, and call `make`. 6 | 7 | `./rmgen` will be the binary for your current architecture 8 | `./rmgen.arm` will be the binary for the reMarkable 9 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | ui "github.com/irth/go-simple" 5 | ) 6 | 7 | type RMGem struct { 8 | simple *ui.App 9 | sceneStack *ui.SceneStack 10 | } 11 | 12 | func main() { 13 | app := &RMGem{} 14 | bs := NewBrowserScene(app, "gemini://gemini.circumlunar.space/docs/specification.gmi") 15 | bs.(*BrowserScene).fetch("gemini://gemini.circumlunar.space/docs/specification.gmi") 16 | app.sceneStack = &ui.SceneStack{bs} 17 | 18 | app.simple = ui.NewApp(app.sceneStack) 19 | app.simple.RunForever() 20 | } 21 | -------------------------------------------------------------------------------- /exit_scene.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/irth/go-simple" 8 | ui "github.com/irth/go-simple" 9 | ) 10 | 11 | type ExitScene struct{} 12 | 13 | type exitWidget struct{} 14 | 15 | func (e exitWidget) Update(out ui.Output) ([]ui.BoundEventHandler, error) { 16 | // Update is called after empty draws its stuff and exits, so in our case 17 | // immediately after cleaning the screen 18 | log.Println("Goodbye...") 19 | os.Exit(0) 20 | return nil, nil 21 | } 22 | 23 | func (e exitWidget) Render() (string, error) { 24 | // Display empty label to clear the screen 25 | return ui.Label(ui.Pos(ui.Abs(0), ui.Abs(0), ui.Abs(10), ui.Abs(10)), " ").Render() 26 | 27 | } 28 | 29 | func (e ExitScene) Render() (simple.Widget, error) { 30 | return exitWidget{}, nil 31 | } 32 | -------------------------------------------------------------------------------- /gemini.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "git.sr.ht/~adnano/go-gemini" 8 | ) 9 | 10 | func Fetch(url string) (gemini.Text, error) { 11 | // TODO: implement UI for TOFU instead of trusting everything 12 | client := gemini.Client{} 13 | 14 | resp, err := client.Get(context.TODO(), url) 15 | if err != nil { 16 | return nil, fmt.Errorf("while making the Gemini request: %w", err) 17 | } 18 | if resp.Status == gemini.StatusRedirect || resp.Status == gemini.StatusPermanentRedirect { 19 | // TODO: indicate in the UI that the redirect is happening, also avoid loops 20 | return Fetch(resp.Meta) 21 | } 22 | defer resp.Body.Close() 23 | 24 | parsed, err := gemini.ParseText(resp.Body) 25 | if err != nil { 26 | return nil, fmt.Errorf("while parsing the Gemini response") 27 | } 28 | 29 | return parsed, nil 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Wojciech ~irth Kwolek 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | git.sr.ht/~adnano/go-gemini v0.1.19 h1:afHsauRIFb12diB32KLpepvT5UKJDk80dQf2mp9/tnI= 2 | git.sr.ht/~adnano/go-gemini v0.1.19/go.mod h1:kmWT0aLnjkuzAMouxNT6Bqv756HYHSe56HE7yoF5P7Y= 3 | github.com/irth/go-simple v0.0.0-20210309172018-5cc989c2e6e5 h1:Ktn7OFtJH3vyzfjKPy/8tJ9I0dQhROx+eAGbq03iehI= 4 | github.com/irth/go-simple v0.0.0-20210309172018-5cc989c2e6e5/go.mod h1:Bj1lX3PL6nOSQwbb1AYV8PHNqwrQDHdgjL21y/FNrVI= 5 | github.com/irth/go-simple v0.0.0-20210309191312-fedb235a168c h1:64O07xYYfv6OIoy0vqhBoCAmJZmFLHh23XJjbbIR+Vs= 6 | github.com/irth/go-simple v0.0.0-20210309191312-fedb235a168c/go.mod h1:Bj1lX3PL6nOSQwbb1AYV8PHNqwrQDHdgjL21y/FNrVI= 7 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 8 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 9 | golang.org/x/net v0.0.0-20210119194325-5f4716e94777 h1:003p0dJM77cxMSyCPFphvZf/Y5/NXf5fzg6ufd1/Oew= 10 | golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 11 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 12 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 13 | golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= 14 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 15 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 16 | -------------------------------------------------------------------------------- /browser_scene.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | 8 | "git.sr.ht/~adnano/go-gemini" 9 | "github.com/irth/go-simple" 10 | ui "github.com/irth/go-simple" 11 | ) 12 | 13 | type BrowserScene struct { 14 | r *RMGem 15 | url string 16 | gemtext gemini.Text 17 | pages []Page 18 | layout LayoutEngine 19 | currentPage int 20 | pageHeight int 21 | } 22 | 23 | func NewBrowserScene(r *RMGem, url string) simple.Scene { 24 | b := &BrowserScene{ 25 | r: r, 26 | url: url, 27 | gemtext: nil, 28 | pages: nil, 29 | layout: NewLayoutEngine(r.simple.ScreenHeight() - 300), 30 | currentPage: 0, 31 | pageHeight: 20, 32 | } 33 | 34 | return b 35 | } 36 | 37 | func (b *BrowserScene) Render() (ui.Widget, error) { 38 | var prev simple.Widget 39 | var next simple.Widget 40 | if b.currentPage > 0 { 41 | prev = ui.Button( 42 | "prev", 43 | ui.Pos(ui.Abs(50), ui.Abs(b.r.simple.ScreenHeight()-90), ui.Abs(150), ui.Abs(100)), 44 | "[prev]", 45 | func(a *ui.App, btn *ui.ButtonWidget) error { b.previousPage(); return nil }, 46 | ) 47 | } 48 | if b.currentPage < len(b.pages)-1 { 49 | next = ui.Button( 50 | "next", 51 | ui.Pos(ui.Abs(b.r.simple.ScreenWidth()-50-120), ui.Abs(b.r.simple.ScreenHeight()-90), ui.Abs(150), ui.Abs(100)), 52 | "[next]", 53 | func(a *ui.App, btn *ui.ButtonWidget) error { b.nextPage(); return nil }, 54 | ) 55 | } 56 | 57 | return ui.WidgetList{ 58 | ui.Justify(ui.Left), 59 | b.hr(80), 60 | ui.FontSize(42), 61 | ui.Button( 62 | "exit", 63 | ui.Pos(ui.Abs(20), ui.Abs(20), ui.Abs(70), ui.Abs(70)), 64 | "[X]", 65 | func(a *ui.App, button *ui.ButtonWidget) error { 66 | // this scene will clear screen and call os.Exit(0) 67 | b.r.sceneStack.Replace(ExitScene{}) 68 | return nil 69 | }, 70 | ), 71 | ui.TextInput( 72 | "address", 73 | ui.Pos(ui.Abs(100), ui.Abs(30), ui.Abs(b.r.simple.ScreenWidth()-250-5), ui.Abs(55)), 74 | b.url, 75 | func(a *ui.App, t *ui.TextInputWidget, value string) error { 76 | b.url = value 77 | return nil 78 | }, 79 | ), 80 | ui.Button( 81 | "go", 82 | ui.Pos(ui.Abs(b.r.simple.ScreenWidth()-130), ui.Abs(25), ui.Abs(90), ui.Abs(70)), 83 | "[Go]", 84 | func(a *ui.App, button *ui.ButtonWidget) error { 85 | err := b.fetch(b.url) 86 | if err != nil { 87 | panic(err) 88 | } 89 | return nil 90 | }, 91 | ), 92 | b.getCurrentPage().AtPosition(150, func(url string) { 93 | err := b.fetch(url) 94 | if err != nil { 95 | log.Println(err) 96 | } 97 | }), 98 | b.hr(b.r.simple.ScreenHeight() - 140), 99 | ui.FontSize(40), 100 | prev, 101 | next, 102 | }, nil 103 | } 104 | 105 | func (b *BrowserScene) fetch(newUrl string) error { 106 | u := newUrl 107 | if b.url != "" { 108 | new, err := url.Parse(newUrl) 109 | if err != nil { 110 | return fmt.Errorf("while parsing new url: %w", err) 111 | } 112 | base, err := url.Parse(b.url) 113 | if err != nil { 114 | return fmt.Errorf("while parsing base url: %w", err) 115 | } 116 | u = base.ResolveReference(new).String() 117 | } 118 | 119 | b.url = u 120 | var err error 121 | b.currentPage = 0 122 | b.gemtext, err = Fetch(b.url) 123 | if err != nil { 124 | b.gemtext = nil 125 | b.pages = nil 126 | if err != nil { 127 | log.Println("Fetch error:", err.Error()) 128 | } 129 | return err 130 | } 131 | b.pages = b.layout.splitPages(b.gemtext) 132 | return nil 133 | } 134 | 135 | func (b *BrowserScene) nextPage() { 136 | if b.currentPage < len(b.pages)-1 { 137 | b.currentPage++ 138 | } 139 | } 140 | 141 | func (b *BrowserScene) previousPage() { 142 | if b.currentPage > 0 { 143 | b.currentPage-- 144 | } 145 | } 146 | 147 | func (b *BrowserScene) hr(y int) simple.Widget { 148 | return ui.WidgetList{ 149 | ui.FontSize(32), 150 | ui.Label( 151 | ui.Pos(ui.Abs(0), ui.Abs(y), ui.Abs(b.r.simple.ScreenWidth()), ui.Abs(55)), 152 | "______________________________________________________________________________________", 153 | ), 154 | } 155 | } 156 | 157 | func (b *BrowserScene) getCurrentPage() Page { 158 | if b.pages == nil { 159 | return Page{nil, b.layout} 160 | } 161 | return b.pages[b.currentPage] 162 | } 163 | -------------------------------------------------------------------------------- /layout.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "time" 8 | 9 | "git.sr.ht/~adnano/go-gemini" 10 | ui "github.com/irth/go-simple" 11 | "github.com/mitchellh/go-wordwrap" 12 | ) 13 | 14 | type Dimensions struct { 15 | LineHeight int 16 | FontSize int 17 | Padding Padding 18 | LineSpacing int 19 | } 20 | 21 | func (d Dimensions) Total(lines int) int { 22 | betweenLines := lines - 1 23 | if betweenLines < 0 { 24 | betweenLines = 0 25 | } 26 | return d.Padding.Top + lines*d.LineHeight + betweenLines*d.LineSpacing + d.Padding.Bottom 27 | } 28 | 29 | type Padding struct { 30 | Top int 31 | Bottom int 32 | } 33 | 34 | func (p Padding) Total() int { 35 | return p.Top + p.Bottom 36 | } 37 | 38 | type LayoutEngine struct { 39 | PageHeight int 40 | 41 | Text Dimensions 42 | Link Dimensions 43 | Heading1 Dimensions 44 | Heading2 Dimensions 45 | Heading3 Dimensions 46 | } 47 | 48 | func NewLayoutEngine(pageHeight int) LayoutEngine { 49 | return LayoutEngine{ 50 | PageHeight: pageHeight, 51 | Text: Dimensions{ 52 | LineHeight: 28, 53 | FontSize: 32, 54 | Padding: Padding{Top: 8, Bottom: 8}, 55 | LineSpacing: 8, 56 | }, 57 | 58 | Link: Dimensions{ 59 | LineHeight: 29 + 15, 60 | FontSize: 32, 61 | Padding: Padding{Top: 4, Bottom: 4}, 62 | }, 63 | 64 | Heading1: Dimensions{ 65 | LineHeight: 56, 66 | FontSize: 64, 67 | Padding: Padding{Top: 32, Bottom: 16}, 68 | }, 69 | Heading2: Dimensions{ 70 | LineHeight: 42, 71 | FontSize: 48, 72 | Padding: Padding{Top: 24, Bottom: 8}, 73 | }, 74 | Heading3: Dimensions{ 75 | LineHeight: 33, 76 | FontSize: 38, 77 | Padding: Padding{Top: 16, Bottom: 4}, 78 | }, 79 | } 80 | } 81 | 82 | func (l *LayoutEngine) getDimensions(line gemini.Line) Dimensions { 83 | go func() { 84 | for { 85 | time.Sleep(1 * time.Second) 86 | } 87 | }() 88 | switch line.(type) { 89 | case gemini.LineText, gemini.LineListItem: 90 | return l.Text 91 | 92 | case gemini.LineHeading1: 93 | // TODO: wordwrap headings and links 94 | return l.Heading1 95 | 96 | case gemini.LineHeading2: 97 | return l.Heading2 98 | 99 | case gemini.LineHeading3: 100 | return l.Heading3 101 | 102 | case gemini.LineLink: 103 | return l.Link 104 | 105 | default: 106 | // log.Printf("layout: getDimensions: unknown line type: %T", line) 107 | return Dimensions{} 108 | } 109 | 110 | } 111 | 112 | func (l LayoutEngine) wrapLines(text string) []string { 113 | // 71 - line wrap length, checked experimentally 114 | return strings.Split(wordwrap.WrapString(text, 71), "\n") 115 | } 116 | 117 | func (l LayoutEngine) estimateLines(line gemini.Line) int { 118 | switch line := line.(type) { 119 | case gemini.LineText, gemini.LineListItem: 120 | if line.String() == "" { 121 | return 0 122 | } 123 | wrapped := l.wrapLines(line.String()) 124 | noOfLines := len(wrapped) 125 | return noOfLines 126 | 127 | case gemini.LineHeading1, gemini.LineHeading2, gemini.LineHeading3, gemini.LineLink: 128 | // TODO: wordwrap headings and links 129 | return 1 130 | 131 | default: 132 | // log.Printf("layout: estimateLines: unknown line type: %T", line) 133 | return 0 134 | } 135 | } 136 | 137 | func (l LayoutEngine) getWidget(pos ui.Position, line gemini.Line, idx int, followUrl func(url string)) ui.Widget { 138 | switch line := line.(type) { 139 | case gemini.LineText, gemini.LineListItem: 140 | return l.textWidget(pos, line.String()) 141 | 142 | case gemini.LineHeading1, gemini.LineHeading2, gemini.LineHeading3: 143 | // TODO: wordwrap headings and links 144 | return l.headingWidget(pos, line.String()) 145 | 146 | case gemini.LineLink: 147 | return l.linkWidget(pos, line, idx, followUrl) 148 | 149 | default: 150 | return nil 151 | } 152 | 153 | } 154 | 155 | func (l LayoutEngine) textWidget(pos ui.Position, line string) ui.Widget { 156 | wrapped := l.wrapLines(line) 157 | 158 | wl := ui.WidgetList{} 159 | 160 | x := pos.X 161 | y := (pos.Y.(ui.Abs)) 162 | w := pos.Width 163 | h := pos.Height 164 | 165 | for _, textLine := range wrapped { 166 | wl = append(wl, 167 | ui.Paragraph( 168 | ui.Pos(x, y, w, h), 169 | textLine, 170 | ), 171 | ) 172 | y += ui.Abs(l.Text.LineHeight + l.Text.LineSpacing) 173 | } 174 | 175 | return wl 176 | } 177 | 178 | func (l LayoutEngine) linkWidget(pos ui.Position, line gemini.LineLink, idx int, followUrl func(url string)) ui.Widget { 179 | text := line.Name 180 | if text == "" { 181 | text = line.URL 182 | } 183 | return ui.Button( 184 | fmt.Sprintf("link_%d", idx), 185 | pos, 186 | fmt.Sprintf("=> %s", text), 187 | func(a *ui.App, b *ui.ButtonWidget) error { 188 | followUrl(line.URL) 189 | return nil 190 | }, 191 | ) 192 | } 193 | 194 | func (l LayoutEngine) headingWidget(pos ui.Position, text string) ui.Widget { 195 | return ui.Paragraph(pos, text) 196 | } 197 | 198 | func (l LayoutEngine) newPage(gemtext gemini.Text) Page { 199 | return Page{LayoutEngine: l, Gemtext: gemtext} 200 | } 201 | 202 | func (l LayoutEngine) trySplitting(line gemini.Line, remainingSpace int) (gemini.Line, gemini.Line, bool) { 203 | switch line := line.(type) { 204 | case gemini.LineText: 205 | wrapped := l.wrapLines(line.String()) 206 | lineCount := len(wrapped) 207 | dim := l.getDimensions(line) 208 | splitBoundary := (remainingSpace - dim.Padding.Total() + lineCount*dim.LineSpacing) / 209 | (lineCount * (dim.LineSpacing + dim.LineHeight)) 210 | if splitBoundary <= 0 { 211 | return nil, nil, false 212 | } 213 | 214 | if splitBoundary > lineCount { 215 | log.Println("splitBoundary > lineCount: this shouldn't ever happen") 216 | return nil, nil, false 217 | } 218 | 219 | firstText := wrapped[:splitBoundary] 220 | secondText := wrapped[splitBoundary:] 221 | 222 | return gemini.LineText(strings.Join(firstText, " ")), 223 | gemini.LineText(strings.Join(secondText, " ")), 224 | true 225 | 226 | default: 227 | return nil, nil, false 228 | } 229 | 230 | } 231 | 232 | func (l LayoutEngine) splitPages(gemtext gemini.Text) []Page { 233 | pages := []Page{} 234 | 235 | page := l.newPage(nil) 236 | 237 | height := 0 238 | for _, line := range gemtext { 239 | lineCount := l.estimateLines(line) 240 | lineHeight := l.getDimensions(line).Total(lineCount) 241 | if height+lineHeight > l.PageHeight { 242 | log.Println("exceeded max page length at height", height) 243 | remainingSpace := l.PageHeight - height 244 | first, second, ok := l.trySplitting(line, remainingSpace) 245 | 246 | if ok { 247 | log.Println("solved by splitting the line") 248 | page.Gemtext = append(page.Gemtext, first) 249 | pages = append(pages, page) 250 | page = l.newPage(gemini.Text{second}) 251 | height = l.getDimensions(second).Total(l.estimateLines(second)) 252 | fmt.Println(first.String(), "///", second.String()) 253 | continue 254 | } 255 | 256 | // if the element won't fit, start a new page 257 | pages = append(pages, page) 258 | page = l.newPage(nil) 259 | height = 0 260 | 261 | } 262 | if lineHeight > l.PageHeight { 263 | // if the element is too big to fit on a single page, 264 | // give it a dedicated page and hope for the best 265 | // TODO: split the element in two! 266 | pages = append(pages, l.newPage(gemini.Text{line})) 267 | continue 268 | } 269 | height += lineHeight 270 | page.Gemtext = append(page.Gemtext, line) 271 | } 272 | 273 | pages = append(pages, page) 274 | for n, ll := range pages { 275 | fmt.Println("") 276 | fmt.Println("page", n) 277 | fmt.Println("first:", ll.Gemtext[0].String()) 278 | fmt.Println("last:", ll.Gemtext[len(ll.Gemtext)-1].String()) 279 | fmt.Println("") 280 | } 281 | return pages 282 | } 283 | 284 | type Page struct { 285 | Gemtext gemini.Text 286 | LayoutEngine LayoutEngine 287 | } 288 | 289 | func (p Page) AtPosition(Y int, followUrl func(url string)) ui.Widget { 290 | x := 100 291 | y := Y 292 | 293 | wl := ui.WidgetList{} 294 | 295 | totalHeight := 0 296 | 297 | for idx, line := range p.Gemtext { 298 | lineCount := p.LayoutEngine.estimateLines(line) 299 | dim := p.LayoutEngine.getDimensions(line) 300 | pos := ui.Pos( 301 | ui.Abs(x), ui.Abs(y+dim.Padding.Top), 302 | ui.Abs(1304), ui.Abs(dim.LineHeight*lineCount)) 303 | y += dim.Total(lineCount) 304 | 305 | wl = append(wl, ui.WidgetList{ 306 | ui.FontSize(dim.FontSize), 307 | p.LayoutEngine.getWidget(pos, line, idx, followUrl), 308 | }) 309 | 310 | totalHeight += dim.Total(lineCount) 311 | } 312 | 313 | log.Println("drawing page with estimated height: ", totalHeight) 314 | return wl 315 | } 316 | --------------------------------------------------------------------------------