From 9d7694470647f22202d1fb6614fe65a597552390 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 27 Aug 2026 11:47:15 +0200 Subject: [PATCH] Give a page a time budget, and take the fixes from underneath ops v0.5.0 -> v0.7.0, render v0.7.0 -> v0.10.0, reader v0.4.1 -> v0.5.0, pdffont -> v0.3.0 indirect. opentype comes along to v0.10.0 because render asks for it. What the workbench gains without asking: - A page stops drawing its own compressed bytes. A /CCITTFaxDecode image arrived still compressed and was painted through a stencil, so a scan came out as noise -- and noise looks like content. The first page of fr-cerfa/cerfa_11012.pdf was 86.9% covered in ink and is now 5.4%: a black rectangle with a band of static across the top, in place of a legible EU certificate of origin. - Annotations are drawn. A filled-in form used to appear empty, because what a person typed lives in the widget beside the content, not in it. - Soft masks, and a transparency group drawn as one thing. - A composite font's identifiers reach its glyphs through the charset, so a CIDFontType2 subset carrying no cmap no longer hides every glyph. - A clip is kept as the box it covers rather than as a value for every pixel of the page. - A form's field list and its structure tree survive a rotate, so the boxes keep their meaning and a screen reader keeps its reading order. - An inline image's dictionary is read in a settled order, so a page carrying both spellings of a key -- /W beside /Width -- no longer draws differently from one run to the next. What it had to ask for is the time budget. render will stop drawing a page when its time is up and hand back as far as it got, but only if the caller says how long -- and a browser tab is exactly the caller that cannot wait: of 59 432 corpus pages, 1 131 were still being drawn after twenty seconds and one took two hundred and seventy-three. So renderPage sets five seconds and shows the part that was drawn, instead of treating ErrTimedOut as a page that cannot be drawn at all. Half a figure is worth more to somebody scrolling than a sentence saying there was one. Showing half a page without saying so would be the one thing worse than showing nothing, so the status line says which it is. That is the only place renderPage writes the note, and it overwrites what was there, because what is on the screen now matters more than what happened before. --- go.mod | 14 ++++++------ go.sum | 24 +++++++++------------ scene.go | 28 ++++++++++++++++++++++-- scene_test.go | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index 817a54a..549cbd0 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module github.com/go-pdfkit/app go 1.26.4 require ( - github.com/go-pdfkit/ops v0.5.0 - github.com/go-pdfkit/reader v0.4.1 - github.com/go-pdfkit/render v0.7.0 + github.com/go-gfx/gfx v0.10.0 + github.com/go-pdfkit/forms v0.2.1 + github.com/go-pdfkit/ops v0.7.0 + github.com/go-pdfkit/reader v0.5.0 + github.com/go-pdfkit/render v0.10.0 github.com/go-widgets/painter v0.11.0 github.com/go-widgets/toolkit v0.250.0 github.com/go-widgets/webcanvas v0.1.0 @@ -16,14 +18,12 @@ require ( github.com/coder/websocket v1.8.15 // indirect github.com/go-crdt/collab v0.25.0 // indirect github.com/go-crdt/crdt v0.31.0 // indirect - github.com/go-gfx/gfx v0.10.0 // indirect github.com/go-iconoir/iconoir v0.2.0 // indirect github.com/go-images/images v0.0.0-20260811115337-bc5d586f8e38 // indirect github.com/go-opentype/fonts v0.9.0 // indirect - github.com/go-opentype/opentype v0.9.0 // indirect + github.com/go-opentype/opentype v0.10.0 // indirect github.com/go-opentype/shape v0.5.0 // indirect - github.com/go-pdfkit/forms v0.2.1 // indirect - github.com/go-pdfkit/pdffont v0.2.0 // indirect + github.com/go-pdfkit/pdffont v0.3.0 // indirect github.com/go-richdoc/richdoc v0.2.0 // indirect github.com/go-typeset/bidi v0.3.0 // indirect github.com/go-widgets/mvvm v0.5.0 // indirect diff --git a/go.sum b/go.sum index b35a32b..7b0affe 100644 --- a/go.sum +++ b/go.sum @@ -20,24 +20,20 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-opentype/fonts v0.9.0 h1:slB6OB3riLyUPrOxqXe0s6/AzdenF1TDvCN8N87hhQk= github.com/go-opentype/fonts v0.9.0/go.mod h1:C6yQL2apHItfEZ5hztpsHF0S5mlX/hklLlq/Z5fRG/g= -github.com/go-opentype/opentype v0.9.0 h1:GFgcJ3nwTDp4NJr5O+Paw7lhZx5Jv/R+noZwvhYDlkM= -github.com/go-opentype/opentype v0.9.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= +github.com/go-opentype/opentype v0.10.0 h1:cZVMZ3RVkcijXmxlmpqyVkjlNc33aSB2ugKVWYvoLUg= +github.com/go-opentype/opentype v0.10.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= github.com/go-opentype/shape v0.5.0 h1:jHNaOMHNBdDj5EixOevlrrsi92svMxvVMKN7GYaPfxo= github.com/go-opentype/shape v0.5.0/go.mod h1:3ImRYNIj6zpwWQ/DV3BWhgMFfmzDITH6XpZw2auQHTo= github.com/go-pdfkit/forms v0.2.1 h1:INa2GwAadxEhcXvBe7zJYgapYwaGn9WIaLxUJU/lm6A= github.com/go-pdfkit/forms v0.2.1/go.mod h1:LORxkdP4FVULFk0/PA0CdPZc1dZF9zTSaHrtfM7IYaw= -github.com/go-pdfkit/ops v0.4.0 h1:2WeYwDhN2OyoYtjYCdgm7gWGVNmiu+8OwOKEhyb3Cp0= -github.com/go-pdfkit/ops v0.4.0/go.mod h1:X89phxHICCYl6+zn3L6YXH3mRLBlsLdPYuDCryGLXzI= -github.com/go-pdfkit/ops v0.5.0 h1:b1LF7hF9QsqtF2AC0G7HjwulNCXJ0AOE7zaUSzswXrg= -github.com/go-pdfkit/ops v0.5.0/go.mod h1:Is3FBR2NcCUEzNt4eowdsVa+P0SBtGtcxK8lDlOeyrk= -github.com/go-pdfkit/pdffont v0.2.0 h1:yAp/oR5Z2kkqs4r0GWMalZMC7rc7XSCZXgwIypbpMWM= -github.com/go-pdfkit/pdffont v0.2.0/go.mod h1:y4vo5DgT95e57C3XxIWfA/xss+x6RwZwyj6KWdJc86s= -github.com/go-pdfkit/reader v0.4.0 h1:qPbNZSO+Xl+4NBvQoV1PYt7HlqHaEAQ1tUc6/IL8JAU= -github.com/go-pdfkit/reader v0.4.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= -github.com/go-pdfkit/reader v0.4.1 h1:pRxFqRjsn7H/VsGfWb9nYWyFuDgTU2Pjmoq/f5mgVq4= -github.com/go-pdfkit/reader v0.4.1/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= -github.com/go-pdfkit/render v0.7.0 h1:wTEvYxcJkYFYCXJMzrGcMUo0gVgp4A+VqAsTS/hyFq8= -github.com/go-pdfkit/render v0.7.0/go.mod h1:RCleIv5QnEDbh9kILa7J8pNUNDUyhsx5p8hv06lpcLk= +github.com/go-pdfkit/ops v0.7.0 h1:W8sK//0nfs/OACvjzvw/GK5ywtd90eDoYcdv74GxW8M= +github.com/go-pdfkit/ops v0.7.0/go.mod h1:Is3FBR2NcCUEzNt4eowdsVa+P0SBtGtcxK8lDlOeyrk= +github.com/go-pdfkit/pdffont v0.3.0 h1:G5DKcAmsZJ0e17QhSrcUaL7PKEXKjUx4P/iGMl7bAbI= +github.com/go-pdfkit/pdffont v0.3.0/go.mod h1:bfmNLna1l1CljNX/Utg55YzFylovfmI7sJnvgA3bzKI= +github.com/go-pdfkit/reader v0.5.0 h1:DaJ5C6eKXPRG9Cdu+olA/0KLmsQgyHEC1coteQQuBxU= +github.com/go-pdfkit/reader v0.5.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= +github.com/go-pdfkit/render v0.10.0 h1:2NXlxhb7bDWFfgWpMe1bfZXbefhEkLSeH99xWI8B1uk= +github.com/go-pdfkit/render v0.10.0/go.mod h1:ZNuGzwPk1m3kmJIg9X0bepSGrAmmCb5nTMbDEbHuFFA= github.com/go-richdoc/richdoc v0.2.0 h1:z9cLox9MoInZL6fIlweMzgDT/VqgnB2ZucSIEaRFglY= github.com/go-richdoc/richdoc v0.2.0/go.mod h1:aCX8ulqg5CoKqSWgWeEWul1Oj0d2VxQeaQNI0+jsm7c= github.com/go-typeset/bidi v0.3.0 h1:4fjGjejvjE2LzLNzY4si8PkVO321NcsKIiANhWT3jF4= diff --git a/scene.go b/scene.go index bb31c55..8f8552f 100644 --- a/scene.go +++ b/scene.go @@ -6,7 +6,9 @@ package main import ( + "errors" "fmt" + "time" "github.com/go-pdfkit/ops" "github.com/go-pdfkit/reader" @@ -346,15 +348,37 @@ func (s *state) renderPage() { if s.at < 1 { s.at = 1 } - img, err := drawPage(src, s.at, render.Options{Scale: s.fitScale(src)}) - if err != nil { + img, err := drawPage(src, s.at, render.Options{ + Scale: s.fitScale(src), + MaxDuration: pageBudget, + }) + // A page that ran out of time comes back as far as it got, which is worth + // showing: somebody scrolling would rather see most of a figure than a + // sentence saying there was one. Anything else, and there is no picture. + partial := errors.Is(err, render.ErrTimedOut) && img != nil + if err != nil && !partial { s.view = toolkit.NewFrame(toolkit.NewLabel("this page cannot be drawn: " + err.Error())) return } + if partial { + // Showing half a page without saying so would be the one thing worse + // than showing nothing, so the status line says which it is. This is + // the only place renderPage writes the note, and it overwrites + // whatever was there, because what is on the screen right now matters + // more than what happened before it. + s.note = fmt.Sprintf("this page was still being drawn after %s; this is as far as it got", pageBudget) + } s.page = toolkit.NewImageFit(img.Pix, img.W, img.H) s.view = toolkit.NewFrame(s.page) } +// pageBudget is how long one page may be drawn for before what has been drawn +// so far is shown instead. A handful of pages take minutes — of the 59 432 +// pages the renderer was measured against, 1 131 were still going after twenty +// seconds and one took two hundred and seventy-three — and a window that stops +// answering for that long reads as broken rather than as busy. +const pageBudget = 5 * time.Second + // fitScale is how much to magnify the page so that it fills the view without // spilling out of it. func (s *state) fitScale(src *reader.Document) float64 { diff --git a/scene_test.go b/scene_test.go index 5fa86d2..f6da0cc 100644 --- a/scene_test.go +++ b/scene_test.go @@ -4,9 +4,12 @@ import ( "errors" "strings" "testing" + "time" + "github.com/go-gfx/gfx/raster" "github.com/go-pdfkit/ops" "github.com/go-pdfkit/reader" + "github.com/go-pdfkit/render" "github.com/go-widgets/toolkit" ) @@ -588,3 +591,59 @@ func TestAControlIsWideEnoughForItsName(t *testing.T) { t.Errorf("the strip is %d wide and the surface is %d", total, surfaceW-2*margin) } } + +func TestAPageThatRanOutOfTimeIsShownAsFarAsItGot(t *testing.T) { + // The renderer is given a budget, and a page that overruns it comes back + // half drawn together with render.ErrTimedOut. Half a page is worth more + // to somebody scrolling than a sentence saying there was one, so the + // workbench shows it rather than the error. + s, _ := opened(t, 1) + was := drawPage + t.Cleanup(func() { drawPage = was }) + var budget time.Duration + drawPage = func(_ *reader.Document, _ int, opt render.Options) (*raster.Image, error) { + budget = opt.MaxDuration + return raster.New(4, 4), render.ErrTimedOut + } + + s.refresh() + if budget <= 0 { + t.Error("the renderer was given no budget at all") + } + if s.page == nil { + t.Fatal("the part of the page that was drawn was thrown away") + } + if !strings.Contains(s.note, "as far as it got") { + t.Errorf("half a page was shown without saying so; the status line said %q", s.note) + } + if got := s.statusLine(); !strings.Contains(strings.Join(got, "|"), "as far as it got") { + t.Errorf("the status line does not carry it: %q", got) + } + buf := buffer() + s.draw(buf) + if inked(buf, s.theme.Background) == 0 { + t.Error("nothing reached the screen") + } +} + +func TestAPageThatRanOutOfTimeWithNothingDrawnSaysSo(t *testing.T) { + // Coming back timed out and empty is not something the renderer does, + // but if it did there is no picture to show, so the view has to fall + // back on saying why rather than dereferencing nothing. + s, _ := opened(t, 1) + was := drawPage + t.Cleanup(func() { drawPage = was }) + drawPage = func(*reader.Document, int, render.Options) (*raster.Image, error) { + return nil, render.ErrTimedOut + } + + s.refresh() + if s.page != nil { + t.Error("a page was drawn from nothing") + } + buf := buffer() + s.draw(buf) + if inked(buf, s.theme.Background) == 0 { + t.Error("the reason was not drawn") + } +}