Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,30 @@ loaded, since nothing is fetched after start-up. A file is read through
the browser's own picker and handed back as a download; the bytes never
touch a server, and there is no server to touch.

## What is on the strip

**Open** · **Save** · **<** · **>** · **Rotate** · **Delete** ·
**Two up** · **Watermark** · **Sanitize**. The arrow keys turn pages too.
## What is on the strip, and what is beside the page

**Open** · **Save** · **<** · **>** · **Rotate** · **Delete**, and then
one control per group of verbs: **Pages** · **Sheet** · **Marks** · **File**,
with **Fill in** at the end for a document that carries a form. The arrow keys
turn pages too.

A strip cannot hold the rest. The verbs the library offers mostly have to be
told something first — which pages, how many to a sheet, what to write — and
there is nowhere on a row of buttons to say it. So a group opens a panel
**beside** the page rather than instead of it:

- **Pages** — which pages to keep or drop, turn them a quarter, a half or
three quarters, reverse the order, move the one on the screen, crop to a
box, put a blank page in, split into files of *n* pages.
- **Sheet** — *n* pages to a sheet, fold into a booklet, add another file
after this one, lay another file over it.
- **Marks** — write a watermark across every page.
- **File** — strip what runs rather than shows.

Beside, because every one of those changes the document and the document is
drawn from what would be saved: typing a crop box and watching the page come
back cropped is the whole point of the control, and a panel over the page
would hide the one thing worth looking at.

Every change is applied to the document, written out, and read back before
it is drawn — so what is on the screen is what would come out of Save,
Expand All @@ -53,7 +73,11 @@ Then in a real browser, over the DevTools protocol with nothing eyeballed:
Chrome loads the shell, the wasm starts, the file picker is intercepted and
handed a PDF, and the canvas pixels are read back to prove the page arrived
— which is how it was found that a file arriving from the browser needs a
frame of its own, having no event to be drawn on.
frame of its own, having no event to be drawn on. The same check then presses
along the strip until a panel opens beside the page, presses down that panel
until one of its verbs changes what is drawn, saves, and parses what came back
to find the mark in it: a panel that is painted and gets no events looks
exactly like one that works.

## Building

Expand Down
4 changes: 2 additions & 2 deletions app.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ func (a workbench) Release(x, y int) bool { return a.s.handleRelease(x, y) }
// one: nothing here has a second meaning.
func (a workbench) Context(x, y int) bool { return a.s.handleClick(x, y) }

// Char forwards a printable character; the workbench has nothing to type into.
func (a workbench) Char(string) bool { return false }
// Char forwards a printable character to whatever box is being typed into.
func (a workbench) Char(text string) bool { return a.s.handleChar(text) }

// KeyDown forwards a named key, which is how the pages are turned.
func (a workbench) KeyDown(key string) bool { return a.s.handleKeyDown(key) }
Expand Down
206 changes: 201 additions & 5 deletions browsercheck/check.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package main

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"

"github.com/go-pdfkit/reader"
)

// check is the protocol: boot, look, open a file the way a person does, look
Expand Down Expand Up @@ -137,7 +140,186 @@ func check(ctx context.Context, c *conn, page, sample, shot, dl string) error {
return fmt.Errorf("nothing that looks like a PDF was downloaded: %w", err)
}
fmt.Printf("the tab handed back a %d byte PDF\n", len(out))
return os.WriteFile(shot+".pdf", out, 0o644)
if err := os.WriteFile(shot+".pdf", out, 0o644); err != nil {
return err
}

// The strip is no longer the whole workbench: most of the verbs now live
// in a panel that a group opens beside the page. That panel has to be
// driven here too, because a panel that is drawn and gets no events looks
// exactly like one that works — which is what it was until the toolkit
// underneath learned to hand a press to what is inside a scroll view.
marked, err := drivePanel(ctx, c, sid)
if err != nil {
say(c)
return err
}
fmt.Println("a verb pressed in the panel changed the page:", marked)

// And what the tab hands back now carries the mark, which is the whole
// claim: what is on the screen is what comes out of Save.
drain(c)
if err := click(ctx, c, sid, savedAt, 8+15); err != nil {
return err
}
again, err := waitForPDF(ctx, dl, out)
if err != nil {
return err
}
fmt.Printf("the tab handed back a %d byte PDF after the panel was used\n", len(again))
doc, err := reader.Open(again)
if err != nil {
return fmt.Errorf("what the tab saved after the panel was used does not open: %w", err)
}
content, err := doc.PageContent(1)
if err != nil {
return fmt.Errorf("the first page of it cannot be read: %w", err)
}
if !bytes.Contains(content, []byte("(DRAFT) Tj")) {
return fmt.Errorf("the mark the panel wrote is not in the file the tab handed back")
}
fmt.Println("the mark written from the panel is in the saved file")
return nil
}

// Where the panel and the page are on the canvas, and how far apart the
// presses that look for them are. The panel is three hundred of the canvas's
// thousand pixels wide, at the right hand end.
const (
pageBand = 0.66
panelMid = 840
panelTop = 55
panelFoot = 690
stripY = 8 + 15
// The leftmost control worth pressing from this end: everything to the
// left of it is Open, Save, the arrows and the two that change the
// document without being asked anything, and none of those opens a panel.
stripStop = 306
)

// drivePanel opens a group of verbs from the strip and presses the verbs in it
// until one of them changes the page, which is what says the panel is wired to
// the document rather than merely painted next to it.
//
// It works from the right hand end of the strip, so that the sweep never
// presses the controls that drop a page.
func drivePanel(ctx context.Context, c *conn, sid string) (string, error) {
quiet, err := lookIn(ctx, c, sid, pageBand, 1)
if err != nil {
return "", err
}
// A control is wider than the step, so the same group opens several times
// running. Its panel is told apart by what it looks like, and one that has
// already been pressed all the way down is not pressed again.
tried := map[int]bool{}
for x := 996; x > stripStop; x -= 8 {
if err := click(ctx, c, sid, x, stripY); err != nil {
return "", err
}
opened, err := changedIn(ctx, c, sid, pageBand, 1, quiet.Hash)
if err != nil {
return "", err
}
if !opened {
continue
}
panel, err := lookIn(ctx, c, sid, pageBand, 1)
if err != nil {
return "", err
}
if tried[panel.Hash] {
if err := click(ctx, c, sid, x, stripY); err != nil {
return "", err
}
if _, err := changedIn(ctx, c, sid, pageBand, 1, quiet.Hash); err != nil {
return "", err
}
continue
}
tried[panel.Hash] = true
fmt.Printf("a panel opened from x=%d\n", x)
hit, err := pressDownPanel(ctx, c, sid)
if err != nil {
return "", err
}
if hit != "" {
return hit, nil
}
// Nothing in this group changes what is drawn. Put it away and carry
// on along the strip.
if err := click(ctx, c, sid, x, stripY); err != nil {
return "", err
}
if _, err := changedIn(ctx, c, sid, pageBand, 1, quiet.Hash); err != nil {
return "", err
}
}
return "", fmt.Errorf("no group on the strip opened a panel with a verb in it")
}

// pressDownPanel presses down the open panel until the page beside it changes,
// and says where that press was.
func pressDownPanel(ctx context.Context, c *conn, sid string) (string, error) {
page, err := lookIn(ctx, c, sid, 0, pageBand)
if err != nil {
return "", err
}
for y := panelTop; y < panelFoot; y += 16 {
if err := click(ctx, c, sid, panelMid, y); err != nil {
return "", err
}
changed, err := changedIn(ctx, c, sid, 0, pageBand, page.Hash)
if err != nil {
return "", err
}
if changed {
return fmt.Sprintf("pressed at y=%d", y), nil
}
}
return "", nil
}

// changedIn waits a moment for the canvas to catch up and reports whether the
// band changed.
//
// The wait is the point: the canvas is repainted on an animation frame rather
// than on the press itself, so a look taken straight after a click is a look
// at what was on the screen before it.
func changedIn(ctx context.Context, c *conn, sid string, from, to float64, was int) (bool, error) {
deadline := time.Now().Add(250 * time.Millisecond)
for {
v, err := lookIn(ctx, c, sid, from, to)
if err != nil {
return false, err
}
if v.Hash != was {
return true, nil
}
if time.Now().After(deadline) {
return false, nil
}
}
}

// waitForPDF waits for a PDF to land in the download directory that is not the
// one already seen.
func waitForPDF(ctx context.Context, dl string, notThis []byte) ([]byte, error) {
var out []byte
err := until(ctx, 30*time.Second, func() (bool, error) {
files, _ := os.ReadDir(dl)
for _, f := range files {
b, err := os.ReadFile(filepath.Join(dl, f.Name()))
if err == nil && len(b) > 4 && string(b[:4]) == "%PDF" && !bytes.Equal(b, notThis) {
out = b
return true, nil
}
}
return false, nil
})
if err != nil {
return nil, fmt.Errorf("no second PDF was handed back: %w", err)
}
return out, nil
}

// say prints whatever the tab said for itself, which is where a program that
Expand All @@ -163,11 +345,25 @@ type canvas struct {
Hash int `json:"hash"`
}

// look reads the pixels out of the canvas in the tab.
// look reads the pixels out of the whole canvas in the tab.
func look(ctx context.Context, c *conn, sid string) (canvas, error) {
const js = `(() => {
return lookIn(ctx, c, sid, 0, 1)
}

// lookIn reads the pixels out of a band of the canvas, given as fractions of
// its width. It is how the two halves of the workbench are told apart with
// nothing eyeballed: opening a group of verbs lights up the right hand band,
// and pressing one of them changes the left hand one, where the page is.
func lookIn(ctx context.Context, c *conn, sid string, from, to float64) (canvas, error) {
// The band runs from under the strip to above the status line. Both of
// those change for reasons that are not what is being asked about — a
// control lights up under the pointer, a message is written at the bottom
// — and counting them would answer a different question.
js := fmt.Sprintf(`(() => {
const c = document.getElementById('screen');
const d = c.getContext('2d').getImageData(0, 0, c.width, c.height).data;
const x0 = Math.floor(c.width * %g), x1 = Math.ceil(c.width * %g);
const y0 = Math.floor(c.height * 0.07), y1 = Math.floor(c.height * 0.95);
const d = c.getContext('2d').getImageData(x0, y0, x1 - x0, y1 - y0).data;
const r0 = d[0], g0 = d[1], b0 = d[2];
let n = 0, dark = 0, hash = 0;
for (let i = 0; i < d.length; i += 4) {
Expand All @@ -176,7 +372,7 @@ func look(ctx context.Context, c *conn, sid string) (canvas, error) {
hash = (hash * 31 + d[i] + d[i+1] * 3 + d[i+2] * 7) | 0;
}
return JSON.stringify({n, dark, hash});
})()`
})()`, from, to)
var out canvas
s, err := eval(ctx, c, sid, js)
if err != nil {
Expand Down
5 changes: 4 additions & 1 deletion browsercheck/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ func run() error {
}
fmt.Println("devtools at", wsURL)

ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
// Long enough for the panel to be driven as well as the strip: the sweep
// that finds a group and then a verb inside it is a few hundred presses,
// each of which waits for the canvas to catch up.
ctx, cancel := context.WithTimeout(context.Background(), 360*time.Second)
defer cancel()
c, err := dial(ctx, wsURL)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions form.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ func (s *state) showForm() {
return
}
s.showingForm = !s.showingForm
// The form takes the whole view: a document with a hundred fields in it
// has no room to spare for a panel of verbs beside them, and every one of
// those verbs would destroy the form anyway.
s.tools.open = ""
s.settle()
s.refresh()
}

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/go-pdfkit/reader v0.6.0
github.com/go-pdfkit/render v0.11.0
github.com/go-widgets/painter v0.11.0
github.com/go-widgets/toolkit v0.250.0
github.com/go-widgets/toolkit v0.272.0
github.com/go-widgets/webcanvas v0.1.0
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ github.com/go-widgets/mvvm v0.5.0 h1:o5hh6HAxbApONcbZxmyV9q12pCAPGRd6L24aS3gaCbA
github.com/go-widgets/mvvm v0.5.0/go.mod h1:Phdrd434RLxXW1D6dL1PPQH1tABwYLIN2X7jQVC4TbY=
github.com/go-widgets/painter v0.11.0 h1:xsj4zTz8B43rOZnWrx7ZsaUBMgJyvgaE/Pq2FfcG2Sw=
github.com/go-widgets/painter v0.11.0/go.mod h1:IPRLqdUJuJX8sfuHeYLZCzjoLvA0ApbOlyIAVmguJDQ=
github.com/go-widgets/toolkit v0.250.0 h1:bBesSXvk59WhY3AgbZ8DgfTUbkoe09cYueBNyq1QQQY=
github.com/go-widgets/toolkit v0.250.0/go.mod h1:eBfiAf9RI6uqmW6NoyFIBjNJgVmpjgVtVuJYS/8N5kI=
github.com/go-widgets/toolkit v0.272.0 h1:TksXi4e3L8cuk37IfcdNgRzWFYqel+j0G7nZSLGJBTo=
github.com/go-widgets/toolkit v0.272.0/go.mod h1:eBfiAf9RI6uqmW6NoyFIBjNJgVmpjgVtVuJYS/8N5kI=
github.com/go-widgets/webcanvas v0.1.0 h1:fSGllghHlFZSC7CBb1oetj/PKR/S3ds/piDrpr0Kmg0=
github.com/go-widgets/webcanvas v0.1.0/go.mod h1:UAoPu9dO6ZzcFFwQbVeW4Jm4X67XFKVJab9A6qWwsMM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
Expand Down
Loading
Loading