From 76267f869a9766bd8560039d76a039ec6f7d4bfc Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Tue, 14 Jul 2026 09:12:56 -0400 Subject: [PATCH 1/3] feat(crop): content-trim crop stage with top-bleed skip Trim each served page to its content bounds before framing. A new internal/crop package provides a normalized Box + Detector seam and the Phase 1 content-trim detector: a bounding-box-of-ink trim that also steps over top-bleed printer's marks (registration / CMYK bars, plate-ident codes) under strict guards, so it can never cut real content. Wire it into the engine serve path: resolve a crop plan (a stored crop_overrides box wins, else the auto detector), apply it to the decoded master before compose, and fold the crop identity into the ETag so a re-crop invalidates caches. On by default; BROADSHEET_CROP=off serves full pages. The applied box is echoed in the X-Broadsheet-Crop header. The crop's top edge is the pluggable axis for a future masthead/skybox detector; content-trim owns the sides, bottom, and a safe fallback top. It does not remove ad/promo skyboxes yet. Verified end to end on a real NYT edition (registration marks removed) and against the eval-corpus A/B harness (12 fixes, 0 regressions). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013G3Wpmfq9mxWMc5Lsfjdvo --- CHANGELOG.md | 18 +++ README.md | 1 + cmd/broadsheet-server/main.go | 8 ++ docs/architecture.md | 36 +++++- internal/crop/crop.go | 150 ++++++++++++++++++++++ internal/crop/trim.go | 204 ++++++++++++++++++++++++++++++ internal/crop/trim_test.go | 127 +++++++++++++++++++ internal/store/store.go | 24 ++++ pkg/broadsheet/broadsheet.go | 116 ++++++++++++++--- pkg/broadsheet/broadsheet_test.go | 67 ++++++++++ 10 files changed, 731 insertions(+), 20 deletions(-) create mode 100644 internal/crop/crop.go create mode 100644 internal/crop/trim.go create mode 100644 internal/crop/trim_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fff383..e7a7fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +### Added: content-aware cropping + +Served pages are now trimmed to their content bounds before framing +(`internal/crop`). The default `content-trim` detector removes whitespace +margins and steps over top-bleed printer's marks (registration / CMYK bars, +plate-ident codes) — safely: it only ever removes rows/columns with no content, +so it can never cut into the page. It does **not** yet remove ad or promo +skyboxes above the masthead; the crop seam is built so a smarter top-edge +detector plugs in later. + +- On by default. `BROADSHEET_CROP=off` restores full, uncropped pages. +- The applied box is echoed in the `X-Broadsheet-Crop` response header and folded + into the ETag (so a re-crop invalidates caches). A stored per-source + `crop_overrides` box takes precedence over the auto-detector. +- **Behavior change:** existing deployments start serving cropped pages on + upgrade. It's safe (whitespace and printer's-marks only); set + `BROADSHEET_CROP=off` to keep full pages. + ### Renamed: paperboy is now broadsheet The project, module path (`github.com/kelchm/broadsheet`), binaries diff --git a/README.md b/README.md index bbfe4cb..a9f2c03 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ Everything's an env var: | `BROADSHEET_WIDTH` | `1600` | Master width — what we cache at. `?w=` resizes down from here. | | `BROADSHEET_POLL_INTERVAL` | `30m` | How often the background loop checks upstream | | `BROADSHEET_ARCHIVE_DAYS` | `14` | How many days of editions to keep | +| `BROADSHEET_CROP` | `auto` | `auto` trims each page to its content bounds (safe — whitespace and printer's marks only); `off` serves the full master | | `BROADSHEET_ADMIN_TOKEN` | *(unset)* | When set, mutating `/api/v1` calls require `Authorization: Bearer `. Set it before exposing the server beyond a trusted network. | | `BROADSHEET_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | diff --git a/cmd/broadsheet-server/main.go b/cmd/broadsheet-server/main.go index ea2a52b..b518f7a 100644 --- a/cmd/broadsheet-server/main.go +++ b/cmd/broadsheet-server/main.go @@ -31,6 +31,9 @@ type envConfig struct { LogLevel string `env:"BROADSHEET_LOG_LEVEL" envDefault:"info"` PollInterval time.Duration `env:"BROADSHEET_POLL_INTERVAL" envDefault:"30m"` ArchiveDays int `env:"BROADSHEET_ARCHIVE_DAYS" envDefault:"14"` + // Crop trims each served page to its content bounds (safe: whitespace and + // printer's marks only). "auto" (default) is on; "off" serves full pages. + Crop string `env:"BROADSHEET_CROP" envDefault:"auto"` // AdminToken, when set, gates mutating /api/v1 calls behind // "Authorization: Bearer ". Empty = open (trusted-network default). AdminToken string `env:"BROADSHEET_ADMIN_TOKEN"` @@ -82,6 +85,7 @@ func main() { Width: ec.Width, PollInterval: ec.PollInterval, ArchiveDays: ec.ArchiveDays, + DisableCrop: strings.EqualFold(ec.Crop, "off"), Logger: logger, }) if err != nil { @@ -436,6 +440,10 @@ func writeImageBody(w http.ResponseWriter, res *broadsheet.Result) { if res.Stale { w.Header().Set("X-Broadsheet-Stale", "true") } + if c := res.Crop; !c.IsEffectivelyFull() { + // Normalized crop applied before framing: x,y,w,h (2 decimals). + w.Header().Set("X-Broadsheet-Crop", fmt.Sprintf("%.2f,%.2f,%.2f,%.2f", c.X, c.Y, c.W, c.H)) + } _, _ = w.Write(res.Image) //nolint:gosec // G705: res.Image is server-rendered PNG bytes served as image/png, not user-controlled markup } diff --git a/docs/architecture.md b/docs/architecture.md index 190fb03..07186aa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -223,6 +223,36 @@ still the source of truth), so it can be added whenever without disruption. Per-request `?w=` resizes down from the cached master (see [sizing](#sizing)); those per-width outputs are computed per request, not stored. +## Cropping + +Cropping tightens a page before it's framed. It runs at serve time on the +decoded master — *after* rendering, *before* [framing](#sizing-and-framing) — so +the master render stays crop-agnostic. That's deliberate: a crop is metadata, +not a new artifact, so changing one never re-renders a PNG; it only mints a new +ETag (the crop identity folds into it, so caches invalidate correctly). + +The Phase 1 detector is **content-trim** (`internal/crop`): it finds the +bounding box of ink and trims the blank margins to it. It also steps over a +*leading bleed strip* — the registration marks, CMYK bars, or plate/fold ident +code many press PDFs carry in the extreme-top bleed (the NYT's +`C M Y K … Nxxx,…,Bs-4C,E1`) — but only a band that is provably junk: thin, +faint, high on the page, and separated from the body by a clear whitespace gap. +Every rule is conservative enough that it can never cut real content; the worst +case is that it trims nothing. + +What it deliberately does *not* do is remove an ad or promo *skybox* above the +masthead: that is thick content-grade ink, indistinguishable from a headline to +a bounds scan. Deciding "that band is an ad, not the paper" is a semantic call +that needs a text-layer or learned detector — a later phase. The seam is built +for it: the crop's *top edge* is the pluggable axis (a smarter masthead detector +drops in there), while content-trim keeps owning the sides, bottom, and a safe +fallback top. + +Resolution order per source: a stored `crop_overrides` box (an operator edit — a +later feature) wins; otherwise the live auto-detector runs. Crop is on by default +(it is safe); `BROADSHEET_CROP=off` serves the full master. The applied box is +echoed in `X-Broadsheet-Crop`. + ## HTTP API Every *device-plane* handler is a pure read over the local archive/cache — @@ -254,7 +284,8 @@ The image endpoints take framing params — `?w=` / `?h=` (target size), `?sources=`, `?interval=`, `?phase=`, `?slot=`. Every response sets `X-Broadsheet-Source`, `-Width`, `-Height`, and `-Days-Old`, plus `X-Broadsheet-Stale: true` when a slot's source had nothing archived and the next -source with content was substituted. Rotation responses add `X-Broadsheet-Slot` +source with content was substituted, and `X-Broadsheet-Crop: x,y,w,h` +(normalized) when a crop was applied. Rotation responses add `X-Broadsheet-Slot` and `X-Broadsheet-Next-Change` (seconds until the rotation advances). `X-Broadsheet-Days-Old` is `floor(now − edition date)` in whole days — elapsed time @@ -330,6 +361,7 @@ a TRMNL, a Home Assistant card, and a browser tab. | `BROADSHEET_WIDTH` | `1600` | Master render width (quality ceiling) | | `BROADSHEET_POLL_INTERVAL` | `30m` | Reconciler cadence | | `BROADSHEET_ARCHIVE_DAYS` | `14` | PDF archive retention | +| `BROADSHEET_CROP` | `auto` | Crop stage: `auto` trims each page to its content bounds (safe); `off` serves the full master | | `BROADSHEET_ADMIN_TOKEN` | *(unset)* | Bearer token gating mutating `/api/v1` calls | | `BROADSHEET_LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` | @@ -385,6 +417,8 @@ internal/ archive/ durable PDF store: atomic Put, Newest, prune render/ MediaType-aware "normalize to master PNG" (wraps rasterize) rasterize/ PDF -> image (go-fitz / MuPDF) + crop/ content-trim detector: normalized Box + Detector seam + store/ broadsheet.db (SQLite): sources, provider ETags, health events catalog/ embedded catalog.json: the browsable list of known papers cache/ legacy state.json reader (one-time import only) diff --git a/internal/crop/crop.go b/internal/crop/crop.go new file mode 100644 index 0000000..2482ca7 --- /dev/null +++ b/internal/crop/crop.go @@ -0,0 +1,150 @@ +// Package crop trims a rendered newspaper front page to a tighter box. +// +// The engine renders every edition to a master-width PNG and then, at serve +// time, applies a crop before framing (see pkg/broadsheet). Crop is metadata, +// not a new artifact: a normalized [Box] is resolved per source/edition and +// applied to the decoded master. The master render stays crop-agnostic, so +// changing a crop never forces a re-render — only a new ETag. +// +// Two layers, mirroring the render/detect split: +// +// - [Detector] inspects a page image and proposes a [Box]. Phase 1 ships one +// detector, [ContentTrim], which is deterministic and provably safe: it +// only ever trims uniform whitespace margins, so it can never cut into +// real content. Smarter detectors (masthead / skybox removal) plug in +// behind the same interface once there's a trustworthy eval to judge them. +// +// - A [Box] is the resolved crop, in normalized coordinates so it's +// independent of the master width. [Box.Apply] realizes it against a +// concrete image. +// +// The package is deliberately independent of internal/source: the engine +// translates source.CropHints into [Hints] at the seam. +package crop + +import ( + "context" + "image" +) + +// AlgoVersion identifies the auto-detection behavior. It is folded into the +// render ETag so a change to any detector's output invalidates cached crops +// derived from the old behavior. Bump it whenever a detector's geometry +// changes in a way that should re-crop already-cached editions. +const AlgoVersion = "1" + +// Box is a crop rectangle in normalized coordinates: X, Y, W, H each in [0,1], +// as fractions of the source image's width/height. Normalizing keeps a box +// meaningful across master-width changes and across the master/downscaled +// variants of the same page. +// +// The zero Box has W==0/H==0 and is treated as "no crop" (see IsEffectivelyFull +// / Apply), so a Box that was never set is safe to apply. +type Box struct { + X, Y, W, H float64 +} + +// Full is the identity box covering the whole image. +func Full() Box { return Box{X: 0, Y: 0, W: 1, H: 1} } + +// Clamp returns b with its coordinates constrained to a valid sub-rectangle of +// the unit square. An out-of-range or degenerate box collapses toward Full so +// a bad detector or a malformed override can never produce an empty or +// out-of-bounds crop. +func (b Box) Clamp() Box { + if b.W <= 0 || b.H <= 0 { + return Full() + } + x, y, w, h := b.X, b.Y, b.W, b.H + if x < 0 { + w += x // pulling the left edge back in shrinks width + x = 0 + } + if y < 0 { + h += y + y = 0 + } + if x > 1 { + x = 1 + } + if y > 1 { + y = 1 + } + if x+w > 1 { + w = 1 - x + } + if y+h > 1 { + h = 1 - y + } + if w <= 0 || h <= 0 { + return Full() + } + return Box{X: x, Y: y, W: w, H: h} +} + +// IsEffectivelyFull reports whether b, once clamped, covers essentially the +// whole image — within eps on every edge. Such a box isn't worth applying +// (the crop would be a no-op or shave a sub-pixel sliver), so callers skip it. +func (b Box) IsEffectivelyFull() bool { + const eps = 0.002 // ~3px on a 1600px master + c := b.Clamp() + return c.X <= eps && c.Y <= eps && c.X+c.W >= 1-eps && c.Y+c.H >= 1-eps +} + +// Apply crops img to b (clamped). A box that's effectively full returns img +// unchanged. The returned image shares img's pixel storage where possible +// (image.Image sub-imaging), so callers must not mutate it. +func (b Box) Apply(img image.Image) image.Image { + if b.IsEffectivelyFull() { + return img + } + c := b.Clamp() + bnds := img.Bounds() + iw, ih := bnds.Dx(), bnds.Dy() + x0 := bnds.Min.X + int(c.X*float64(iw)+0.5) + y0 := bnds.Min.Y + int(c.Y*float64(ih)+0.5) + x1 := bnds.Min.X + int((c.X+c.W)*float64(iw)+0.5) + y1 := bnds.Min.Y + int((c.Y+c.H)*float64(ih)+0.5) + // Guard against rounding that collapses the rect. + if x1 <= x0 { + x1 = x0 + 1 + } + if y1 <= y0 { + y1 = y0 + 1 + } + rect := image.Rect(x0, y0, x1, y1).Intersect(bnds) + if sub, ok := img.(interface { + SubImage(image.Rectangle) image.Image + }); ok { + return sub.SubImage(rect) + } + // Fallback for images without SubImage: copy the region. + dst := image.NewNRGBA(image.Rect(0, 0, rect.Dx(), rect.Dy())) + for y := rect.Min.Y; y < rect.Max.Y; y++ { + for x := rect.Min.X; x < rect.Max.X; x++ { + dst.Set(x-rect.Min.X, y-rect.Min.Y, img.At(x, y)) + } + } + return dst +} + +// Hints carry per-source detection inputs, translated from source.CropHints at +// the engine seam so this package stays independent of internal/source. Phase +// 1's ContentTrim ignores them; they exist for the masthead/skybox detectors +// that land in later phases. +type Hints struct { + // MastheadText is the visible nameplate string, an OCR/text-layer target. + MastheadText string + // PDFPath is the archived source PDF, for text-layer detectors. May be empty. + PDFPath string +} + +// Detector inspects a page image and proposes a crop. +// +// found is false when the detector has no opinion (leave the page uncropped); +// a returned Box is only meaningful when found is true. err is reserved for +// real failures (a detector that shells out, etc.) — "nothing detected" is +// (Full, false, nil), not an error. +type Detector interface { + Detect(ctx context.Context, img image.Image, hints Hints) (box Box, found bool, err error) +} diff --git a/internal/crop/trim.go b/internal/crop/trim.go new file mode 100644 index 0000000..e23789b --- /dev/null +++ b/internal/crop/trim.go @@ -0,0 +1,204 @@ +package crop + +import ( + "context" + "image" +) + +// ContentTrim removes uniform whitespace margins around a page's content. +// +// It's the Phase 1 detector: deterministic, dependency-free, and provably +// safe. It finds the bounding box of "ink" (pixels darker than a near-white +// threshold) and trims the blank border around it, then re-inflates by a +// small pad so content never sits flush against the edge. Because it only ever +// removes rows/columns that contain no ink, it can never cut into content — +// the worst case is that it trims nothing (returns found=false). +// +// One wrinkle it does handle: printer's marks. Many press PDFs carry a strip of +// registration marks, CMYK color bars, or a plate/fold ident code in the very +// top bleed (e.g. the NYT's "C M Y K … Nxxx,…,Bs-4C,E1"). That is ink but not +// content, and a naive "first ink row" would anchor the top on it. So the top +// edge skips a *leading bleed strip* — but only a band that is provably junk: +// thin, faint, in the extreme-top margin, and separated from the body by a +// clear whitespace gap (see topEdge). The conditions are strict enough that a +// real element (a dateline, a rule under the masthead) is never skipped; +// validated by the eval-corpus A/B (12 NYT fixes, 0 regressions). +// +// This is still *not* masthead or skybox removal: a promo strip above the +// nameplate is thick content-grade ink, so ContentTrim keeps it. Those +// detectors come in a later phase, behind the same Detector interface. What +// ContentTrim buys now is a tighter, margin-normalized page — with zero risk. +type ContentTrim struct { + // DarkThreshold is the luma (0-255) at or below which a pixel counts as + // ink. Renders are grayscale on a ~255 white ground; the default leaves + // headroom for antialiasing and faint paper tint. + DarkThreshold uint8 + // MinInk is the number of ink pixels a row/column must contain to count as + // "content" rather than noise. Small, so a single stray speck can't defeat + // the trim, but a real line of type clears it easily. + MinInk int + // PadFraction re-inflates the detected box on every side by this fraction + // of the corresponding dimension, so the crop keeps a hair of breathing + // room around content. 0.005 = 0.5%. + PadFraction float64 + + // Bleed-strip skip (see topEdge). A leading ink band is discarded as a + // printer's-mark strip only when it satisfies ALL four, as fractions of the + // page: it starts within BleedFraction of the top, is at most + // MaxStripFraction tall, its densest row is below SparseFraction of the + // width, and the whitespace after it is at least MinGapFraction (and at + // least as tall as the strip itself). + BleedFraction float64 // top margin the strip must start within. Default 0.025. + MaxStripFraction float64 // max strip height (of page height). Default 0.012. + SparseFraction float64 // max strip peak ink (of width). Default 0.18. + MinGapFraction float64 // min trailing whitespace (of height). Default 0.015. +} + +// NewContentTrim returns a ContentTrim with defaults tuned for MuPDF-rasterized +// front pages on a white ground. +func NewContentTrim() *ContentTrim { + return &ContentTrim{ + DarkThreshold: 245, + MinInk: 3, + PadFraction: 0.005, + BleedFraction: 0.025, + MaxStripFraction: 0.012, + SparseFraction: 0.18, + MinGapFraction: 0.015, + } +} + +// Detect implements Detector. hints are unused. It never returns an error. +func (t *ContentTrim) Detect(ctx context.Context, img image.Image, _ Hints) (Box, bool, error) { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w <= 0 || h <= 0 { + return Full(), false, nil + } + + // Per-row and per-column ink counts in one pass over the pixels. + rowInk := make([]int, h) + colInk := make([]int, w) + for y := 0; y < h; y++ { + if ctx.Err() != nil { + return Full(), false, ctx.Err() + } + for x := 0; x < w; x++ { + if luma(img.At(b.Min.X+x, b.Min.Y+y)) <= t.DarkThreshold { + rowInk[y]++ + colInk[x]++ + } + } + } + + top := t.topEdge(rowInk, w, h) + if top < 0 { + // The page is effectively blank — trimming would erase it. Leave it. + return Full(), false, nil + } + bottom := lastAtLeast(rowInk, t.MinInk) + left := firstAtLeast(colInk, t.MinInk) + right := lastAtLeast(colInk, t.MinInk) + + // Re-inflate by the pad, in pixels, clamped to the image. + padX := int(t.PadFraction*float64(w) + 0.5) + padY := int(t.PadFraction*float64(h) + 0.5) + x0 := max(0, left-padX) + y0 := max(0, top-padY) + x1 := min(w-1, right+padX) + y1 := min(h-1, bottom+padY) + + box := Box{ + X: float64(x0) / float64(w), + Y: float64(y0) / float64(h), + W: float64(x1-x0+1) / float64(w), + H: float64(y1-y0+1) / float64(h), + }.Clamp() + + if box.IsEffectivelyFull() { + return Full(), false, nil + } + return box, true, nil +} + +// luma returns the Rec. 601 luma of c as an 8-bit value. Renders are already +// grayscale, but computing luma keeps the detector correct on color input too. +func luma(c interface{ RGBA() (r, g, b, a uint32) }) uint8 { + r, g, b, _ := c.RGBA() // 16-bit premultiplied, 0-0xffff + // 0.299R + 0.587G + 0.114B, then down to 8 bits. + y := (299*r + 587*g + 114*b) / 1000 + return uint8(y >> 8) //nolint:gosec // y is bounded to 16-bit by construction; >>8 fits 8 bits +} + +// firstAtLeast returns the index of the first element >= n, or -1 if none. +func firstAtLeast(counts []int, n int) int { + for i, c := range counts { + if c >= n { + return i + } + } + return -1 +} + +// lastAtLeast returns the index of the last element >= n, or -1 if none. +func lastAtLeast(counts []int, n int) int { + for i := len(counts) - 1; i >= 0; i-- { + if counts[i] >= n { + return i + } + } + return -1 +} + +// inkRun is a contiguous run of ink rows [lo, hi], inclusive. +type inkRun struct{ lo, hi int } + +// inkBands segments rowInk into the contiguous runs whose count >= minInk. +func inkBands(rowInk []int, minInk int) []inkRun { + var out []inkRun + for y := 0; y < len(rowInk); { + if rowInk[y] >= minInk { + lo := y + for y < len(rowInk) && rowInk[y] >= minInk { + y++ + } + out = append(out, inkRun{lo: lo, hi: y - 1}) + } else { + y++ + } + } + return out +} + +// topEdge returns the first content row, discarding any leading "bleed strip" +// (registration marks / CMYK bars / plate-ident code) that sits in the extreme +// top margin. A strip is skipped only when it is thin, faint, high on the page, +// and followed by a clear whitespace gap — see the ContentTrim doc. Returns -1 +// when the page holds no ink at all. +func (t *ContentTrim) topEdge(rowInk []int, w, h int) int { + bands := inkBands(rowInk, t.MinInk) + if len(bands) == 0 { + return -1 + } + i := 0 + for i < len(bands)-1 { + strip := bands[i] + stripH := strip.hi - strip.lo + 1 + gap := bands[i+1].lo - strip.hi - 1 + peak := 0 + for y := strip.lo; y <= strip.hi; y++ { + if rowInk[y] > peak { + peak = rowInk[y] + } + } + isBleed := float64(strip.lo) <= t.BleedFraction*float64(h) && + float64(stripH) <= t.MaxStripFraction*float64(h) && + float64(peak) < t.SparseFraction*float64(w) && + float64(gap) >= max(float64(stripH), t.MinGapFraction*float64(h)) + if !isBleed { + break // the topmost band is real content; stop here + } + i++ + } + return bands[i].lo +} diff --git a/internal/crop/trim_test.go b/internal/crop/trim_test.go new file mode 100644 index 0000000..a946ac2 --- /dev/null +++ b/internal/crop/trim_test.go @@ -0,0 +1,127 @@ +package crop + +import ( + "context" + "image" + "image/color" + "testing" +) + +// span is an ink rectangle [y0,y1)×[x0,x1) painted onto a white page. +type span struct{ y0, y1, x0, x1 int } + +const testW = 1000 + +func page(h int, spans ...span) *image.Gray { + img := image.NewGray(image.Rect(0, 0, testW, h)) + for i := range img.Pix { + img.Pix[i] = 255 // white ground + } + for _, s := range spans { + for y := s.y0; y < s.y1; y++ { + for x := s.x0; x < s.x1; x++ { + img.SetGray(x, y, color.Gray{Y: 0}) + } + } + } + return img +} + +// topPx runs the default ContentTrim and returns the detected top edge in +// pixels (box.Y × height) plus found. +func topPx(t *testing.T, img image.Image) (int, bool) { + t.Helper() + box, found, err := NewContentTrim().Detect(context.Background(), img, Hints{}) + if err != nil { + t.Fatalf("Detect: %v", err) + } + return int(box.Y*float64(img.Bounds().Dy()) + 0.5), found +} + +func near(got, want, tol int) bool { return got >= want-tol && got <= want+tol } + +func TestContentTrim_TrimsMargins(t *testing.T) { + // Content block at rows 300..1000 on a tall white page; top should land just + // above it (minus the ~0.5% pad). + img := page(2000, span{300, 1000, 100, 900}) + top, found := topPx(t, img) + if !found { + t.Fatal("expected found=true") + } + if !near(top, 290, 4) { // 300 - padY(10) + t.Fatalf("top = %d, want ~290", top) + } +} + +func TestContentTrim_SkipsBleedStrip(t *testing.T) { + // A thin, sparse strip high in the bleed (rows 30..50), a wide gap, then the + // masthead at row 130 — mimics the NYT registration-mark case. The strip + // must be skipped so the top lands on the content. + img := page(2000, + span{30, 50, 200, 300}, // bleed strip: thin, sparse, top ~1.5% + span{130, 1000, 100, 900}, // real content, wide + ) + top, found := topPx(t, img) + if !found { + t.Fatal("expected found=true") + } + if !near(top, 120, 5) { // 130 - padY(10); NOT ~20 + t.Fatalf("top = %d, want ~120 (bleed strip skipped)", top) + } +} + +// The four guards: each perturbs one condition so the leading strip is treated +// as content and NOT skipped (top stays on the strip, ~row 30 - pad ~= 20). +func TestContentTrim_KeepsStrip(t *testing.T) { + cases := []struct { + name string + spans []span + }{ + {"gap too small", []span{{30, 50, 200, 300}, {70, 1000, 100, 900}}}, // gap 20 < 30 + {"strip too dense", []span{{30, 50, 100, 900}, {130, 1000, 100, 900}}}, // peak 800 > 180 + {"strip too tall", []span{{30, 90, 200, 300}, {170, 1000, 100, 900}}}, // 60px > 24 + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + top, found := topPx(t, page(2000, c.spans...)) + if !found { + t.Fatal("expected found=true") + } + if !near(top, 20, 6) { // strip kept: 30 - padY(10) + t.Fatalf("top = %d, want ~20 (strip NOT skipped)", top) + } + }) + } +} + +func TestContentTrim_KeepsStripBelowBleedZone(t *testing.T) { + // Strip starts at row 60 (3% of height) — below the 2.5% bleed zone — so it + // is treated as content, not a printer's mark. + img := page(2000, span{60, 80, 200, 300}, span{160, 1000, 100, 900}) + top, _ := topPx(t, img) + if !near(top, 50, 6) { // 60 - padY(10) + t.Fatalf("top = %d, want ~50 (strip below bleed zone kept)", top) + } +} + +func TestContentTrim_BlankPageIsNoOp(t *testing.T) { + _, found := topPx(t, page(2000)) // all white + if found { + t.Fatal("blank page should return found=false") + } +} + +func TestContentTrim_BleedZoneScalesWithHeight(t *testing.T) { + // The same absolute strip (rows 55..70) is 2.75% down a 2000px page — outside + // the 2.5% bleed zone, so kept — but only 1.8% down a 3000px page — inside it, + // so skipped. Confirms the guards are height-relative, not pixel-absolute. + strip := span{55, 70, 200, 300} + shortTop, _ := topPx(t, page(2000, strip, span{200, 1900, 100, 900})) + if !near(shortTop, 45, 6) { // strip kept: 55 - padY(10) + t.Fatalf("short-page top = %d, want ~45 (strip kept, below bleed zone)", shortTop) + } + tallTop, _ := topPx(t, page(3000, strip, span{200, 2900, 100, 900})) + if tallTop < 150 { // strip skipped -> lands on content (~200 - padY(15)) + t.Fatalf("tall-page top = %d, want ~185 (strip skipped, inside bleed zone)", tallTop) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index 27f1a0d..1e1ff6d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -194,6 +194,30 @@ func (s *Store) CountSources() (int, error) { return n, err } +// CropOverride is a stored per-source crop box in normalized [0,1] coordinates. +type CropOverride struct { + X, Y, W, H float64 + Mode string // auto | manual | approved + UpdatedAt string // RFC3339Nano (timeLayout); an opaque cache token for callers +} + +// GetCropOverride returns the stored crop box for a source, or ErrNotFound when +// none is set. It's an explicit operator/precomputed box that takes precedence +// over the engine's live auto-detector. +func (s *Store) GetCropOverride(sourceID string) (CropOverride, error) { + var c CropOverride + err := s.db.QueryRow( + `SELECT x, y, w, h, mode, updated_at FROM crop_overrides WHERE source_id = ?`, sourceID, + ).Scan(&c.X, &c.Y, &c.W, &c.H, &c.Mode, &c.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return CropOverride{}, ErrNotFound + } + if err != nil { + return CropOverride{}, fmt.Errorf("store: crop override %q: %w", sourceID, err) + } + return c, nil +} + // Versions returns the persisted provider version tokens for a source. Errors // degrade to an empty map — the worst case is an unconditional refetch. func (s *Store) Versions(sourceID string) map[string]string { diff --git a/pkg/broadsheet/broadsheet.go b/pkg/broadsheet/broadsheet.go index 20f2a90..249016f 100644 --- a/pkg/broadsheet/broadsheet.go +++ b/pkg/broadsheet/broadsheet.go @@ -43,6 +43,7 @@ import ( "github.com/kelchm/broadsheet/internal/buildinfo" "github.com/kelchm/broadsheet/internal/cache" "github.com/kelchm/broadsheet/internal/catalog" + "github.com/kelchm/broadsheet/internal/crop" "github.com/kelchm/broadsheet/internal/reconcile" "github.com/kelchm/broadsheet/internal/registry" "github.com/kelchm/broadsheet/internal/render" @@ -57,6 +58,10 @@ type Source = source.Source // internal canonical type. type CropHints = source.CropHints +// CropBox is the normalized crop rectangle applied to a page before framing. +// Alias of the internal canonical type; the zero value means "no crop". +type CropBox = crop.Box + // Version reports the broadsheet release version. func Version() string { return buildinfo.Version } @@ -150,6 +155,12 @@ type Config struct { // in-memory (resets on restart); provide a persistent one to survive it. Cursors Cursors + // DisableCrop turns off the crop stage. Crop is on by default: every served + // page is trimmed to its content bounds (safe — whitespace/printer's-marks + // only; see internal/crop.ContentTrim) before framing. Set true to serve the + // full uncropped master. + DisableCrop bool + // Logger; if nil, slog.Default() is used. Logger *slog.Logger } @@ -194,6 +205,7 @@ type Result struct { Width int // actual pixel width of Image Height int // actual pixel height of Image ETag string // strong validator over (source, edition, artifact mtime, render params), pre-quoted for HTTP + Crop CropBox // the normalized crop applied before framing (zero = none) } // Health describes the per-source health of the engine. @@ -245,6 +257,13 @@ type Engine struct { // variants memoizes small rendered outputs by content identity (the ETag), // so thumbnail-heavy pages stop re-decoding masters on every image. variants *variantCache + + // cropEnabled gates the crop stage; cropper is the auto top/side/bottom + // detector applied to a decoded master before framing (a stored per-source + // override in the DB takes precedence — see resolveCrop). Only the top edge + // is a candidate for smarter detectors later; content-trim owns the rest. + cropEnabled bool + cropper *crop.ContentTrim } // New constructs a Engine with the given config. @@ -308,17 +327,19 @@ func New(cfg Config) (*Engine, error) { arch := &archive.Store{Root: archiveDir} p := &Engine{ - cfg: cfg, - sources: srcs, - archive: arch, - renderer: render.New(), - store: st, - cacheDir: cacheDir, - cursors: cursors, - now: time.Now, - renderSem: make(chan struct{}, 1), - composeSem: make(chan struct{}, 2), - variants: newVariantCache(128, 256<<10), + cfg: cfg, + sources: srcs, + archive: arch, + renderer: render.New(), + store: st, + cacheDir: cacheDir, + cursors: cursors, + now: time.Now, + renderSem: make(chan struct{}, 1), + composeSem: make(chan struct{}, 2), + variants: newVariantCache(128, 256<<10), + cropEnabled: !cfg.DisableCrop, + cropper: crop.NewContentTrim(), } p.reconciler = &reconcile.Reconciler{ SourcesFn: p.getSources, // live view: enable/disable applies next cycle @@ -722,10 +743,16 @@ func (p *Engine) serve(ctx context.Context, entry archive.Entry, stale bool, opt daysOld = 0 } + // Resolve the crop plan up front so its identity folds into the ETag. Auto + // detection is deterministic in the master bytes (the render mtime already in + // the ETag pins them), so a version token suffices; a stored override carries + // its own updated_at token. + plan := p.resolveCrop(entry.SourceID) + // The ETag is the full content identity (edition + render mtime + build + - // params), so it doubles as the variant-cache key: a thumbnail-heavy page - // hits here instead of re-decoding the master per image. - etag := contentETag(entry, pngInfo.ModTime(), master, opts) + // params + crop), so it doubles as the variant-cache key: a thumbnail-heavy + // page hits here instead of re-decoding and re-cropping the master per image. + etag := contentETag(entry, pngInfo.ModTime(), master, opts, plan.token) if cached, ok := p.variants.get(etag); ok { out := *cached out.Stale = stale @@ -751,6 +778,23 @@ func (p *Engine) serve(ctx context.Context, entry archive.Entry, stale bool, opt return nil, fmt.Errorf("broadsheet: decode render: %w", err) } + // Crop the decoded master before framing. A stored override wins; otherwise + // the auto detector runs. A full/empty box is a no-op, so the page passes + // through unchanged when there's nothing to trim. + var applied crop.Box + if plan.enabled { + box := plan.box + if !plan.fromDB { + if b, found, derr := p.cropper.Detect(ctx, page, crop.Hints{}); derr == nil && found { + box = b + } + } + if !box.IsEffectivelyFull() { + page = box.Apply(page) + applied = box.Clamp() + } + } + out := compose(page, opts, master) var buf bytes.Buffer if err := imaging.Encode(&buf, out, imaging.PNG); err != nil { @@ -766,11 +810,44 @@ func (p *Engine) serve(ctx context.Context, entry archive.Entry, stale bool, opt Width: out.Bounds().Dx(), Height: out.Bounds().Dy(), ETag: etag, + Crop: applied, } p.variants.put(etag, res) return res, nil } +// cropPlan is the crop decision for one serve, resolved before decode so its +// identity can fold into the ETag. When fromDB is set the box is already known +// (a stored override); otherwise the box is left zero and the auto detector +// fills it in after the master is decoded. +type cropPlan struct { + enabled bool + fromDB bool + box crop.Box + token string +} + +// resolveCrop decides how (and whether) to crop a source's pages. A stored +// per-source override takes precedence over the live auto-detector; both fold a +// stable token into the ETag so a re-crop invalidates client and variant caches. +func (p *Engine) resolveCrop(sourceID string) cropPlan { + if !p.cropEnabled { + return cropPlan{token: "off"} + } + if ov, err := p.store.GetCropOverride(sourceID); err == nil { + return cropPlan{ + enabled: true, + fromDB: true, + box: crop.Box{X: ov.X, Y: ov.Y, W: ov.W, H: ov.H}.Clamp(), + token: "db:" + ov.UpdatedAt, + } + } else if !errors.Is(err, store.ErrNotFound) { + // A real store error: fall back to auto rather than failing the render. + p.cfg.Logger.Warn("crop override lookup failed; using auto", "source", sourceID, "err", err) + } + return cropPlan{enabled: true, token: "auto:" + crop.AlgoVersion} +} + // variantCache is a small bounded FIFO memo of rendered outputs, keyed by // ETag (which already encodes edition, render mtime, build, and params, so // entries can never serve stale content — a change mints a new key). Only @@ -819,14 +896,15 @@ func (c *variantCache) put(key string, r *Result) { // response bytes: the edition identity (source + date + the served render's // mtime, which is stamped from the artifact it was rendered from — a corrected // edition changes it), the build version (render-code changes must invalidate -// client caches), the master width, and the framing parameters. Pre-quoted for -// direct use as an HTTP ETag. -func contentETag(entry archive.Entry, renderMtime time.Time, master int, opts RenderOptions) string { +// client caches), the master width, the framing parameters, and the crop token +// (a detector-version or override-updated_at string, so a re-crop invalidates +// caches). Pre-quoted for direct use as an HTTP ETag. +func contentETag(entry archive.Entry, renderMtime time.Time, master int, opts RenderOptions, cropToken string) string { h := fnv.New64a() - _, _ = fmt.Fprintf(h, "%s|%s|%d|%s|%d|%d|%d|%s|%g", + _, _ = fmt.Fprintf(h, "%s|%s|%d|%s|%d|%d|%d|%s|%g|%s", entry.SourceID, entry.Date.UTC().Format("20060102"), renderMtime.UnixNano(), buildinfo.Version, - master, opts.OutputWidth, opts.OutputHeight, opts.Fit, opts.MarginPct) + master, opts.OutputWidth, opts.OutputHeight, opts.Fit, opts.MarginPct, cropToken) return fmt.Sprintf("%q", strconv.FormatUint(h.Sum64(), 16)) } diff --git a/pkg/broadsheet/broadsheet_test.go b/pkg/broadsheet/broadsheet_test.go index 0cf4d81..b7647a2 100644 --- a/pkg/broadsheet/broadsheet_test.go +++ b/pkg/broadsheet/broadsheet_test.go @@ -68,6 +68,73 @@ func TestCompose_Dimensions(t *testing.T) { } } +// borderedPNG returns a white w x h page with a solid black content block in +// [x0,x1)×[y0,y1) — i.e. wide whitespace margins for ContentTrim to remove. +func borderedPNG(t *testing.T, w, h, x0, y0, x1, y1 int) []byte { + t.Helper() + img := imaging.New(w, h, color.NRGBA{R: 255, G: 255, B: 255, A: 255}) + for y := y0; y < y1; y++ { + for x := x0; x < x1; x++ { + img.Set(x, y, color.NRGBA{A: 255}) + } + } + var buf bytes.Buffer + if err := imaging.Encode(&buf, img, imaging.PNG); err != nil { + t.Fatalf("encode bordered png: %v", err) + } + return buf.Bytes() +} + +// TestServe_AppliesCrop drives the full serve path: a page with whitespace +// margins is trimmed when crop is on and served whole when DisableCrop is set, +// with a different ETag either way. +func TestServe_AppliesCrop(t *testing.T) { + dir := t.TempDir() + date := time.Date(2026, 6, 30, 0, 0, 0, 0, time.UTC) + arch := &archive.Store{Root: filepath.Join(dir, "archive")} + // Content block rows 60..240 of a 300-tall page => ~40% is trimmable margin. + page := borderedPNG(t, 200, 300, 40, 60, 160, 240) + if _, err := arch.Put("a", source.Edition{Date: date, Media: source.MediaImage, Data: page}); err != nil { + t.Fatalf("archive.Put: %v", err) + } + opts := RenderOptions{MarginPct: -1} // no framing margin: output size == cropped page + + on, err := New(Config{DataDir: dir, Width: 200, Sources: []Source{{ID: "a"}}}) + if err != nil { + t.Fatalf("New (crop on): %v", err) + } + rOn, err := on.RenderFor(context.Background(), "a", opts) + if err != nil { + t.Fatalf("RenderFor (crop on): %v", err) + } + _ = on.Close() + if rOn.Crop.IsEffectivelyFull() { + t.Fatal("expected a crop to be applied, got none") + } + if rOn.Height >= 300 { + t.Fatalf("cropped height = %d, want < 300 (top/bottom margin trimmed)", rOn.Height) + } + + off, err := New(Config{DataDir: dir, Width: 200, DisableCrop: true, Sources: []Source{{ID: "a"}}}) + if err != nil { + t.Fatalf("New (crop off): %v", err) + } + defer func() { _ = off.Close() }() + rOff, err := off.RenderFor(context.Background(), "a", opts) + if err != nil { + t.Fatalf("RenderFor (crop off): %v", err) + } + if !rOff.Crop.IsEffectivelyFull() { + t.Fatal("crop-disabled engine should apply no crop") + } + if rOff.Height <= rOn.Height { + t.Fatalf("uncropped height %d should exceed cropped height %d", rOff.Height, rOn.Height) + } + if rOn.ETag == rOff.ETag { + t.Fatalf("crop on/off must differ in ETag, both = %s", rOn.ETag) + } +} + // uniformPNG returns PNG bytes for a w x h image of the given gray level. func uniformPNG(t *testing.T, w, h int, level uint8) []byte { t.Helper() From b0eabc8a640e0aa59af486ad5dc88f99b2001ed6 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Tue, 14 Jul 2026 21:34:19 -0400 Subject: [PATCH 2/3] fix(crop): address PR review comments - crop.Box.Clamp: reject non-finite (NaN/Inf) coordinates up front, so a corrupted override can't slip past the bounds checks (Copilot). - ContentTrim.Detect doc: it does error on ctx cancellation; say so (Copilot). - trim tests: correct the "four guards" wording (three here, one separate) and tighten the tall-page scale assertion to near(185) instead of a loose bound (Copilot). - README "Not done yet": pages are now content-trimmed; the open item is skybox removal, not cropping wholesale (CodeRabbit). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013G3Wpmfq9mxWMc5Lsfjdvo --- README.md | 2 +- internal/crop/crop.go | 8 ++++++++ internal/crop/trim.go | 3 ++- internal/crop/trim_test.go | 8 +++++--- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a9f2c03..e6331d1 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ Worth knowing: Freedom Forum only keeps about two days live, so the archive fill ## Not done yet -Smart crop. Right now a front page is served whole, exactly as the PDF rasterizes. The plan is to detect each paper's masthead and content edges and frame it automatically. There's a per-source hint field (`CropHints`) carried through for it, but the detector that would use it isn't written. +Skybox removal. Pages are now trimmed to their content bounds automatically (see `BROADSHEET_CROP`) — that safely removes whitespace margins and top-bleed printer's marks. What's still unwritten is cropping away an ad or promo *skybox* above the masthead: that's a semantic call ("this band is an ad, not the paper") that a plain bounds scan can't make, so it needs a text-layer or learned detector. The crop seam's top edge is built to plug one in, and a per-source hint field (`CropHints`) is carried through for it. ## License diff --git a/internal/crop/crop.go b/internal/crop/crop.go index 2482ca7..e6a0d3f 100644 --- a/internal/crop/crop.go +++ b/internal/crop/crop.go @@ -25,6 +25,7 @@ package crop import ( "context" "image" + "math" ) // AlgoVersion identifies the auto-detection behavior. It is folded into the @@ -52,6 +53,13 @@ func Full() Box { return Box{X: 0, Y: 0, W: 1, H: 1} } // a bad detector or a malformed override can never produce an empty or // out-of-bounds crop. func (b Box) Clamp() Box { + // A non-finite coordinate (a corrupted override, a NaN from bad math) would + // slip past every comparison below, so reject it up front. + for _, v := range [4]float64{b.X, b.Y, b.W, b.H} { + if math.IsNaN(v) || math.IsInf(v, 0) { + return Full() + } + } if b.W <= 0 || b.H <= 0 { return Full() } diff --git a/internal/crop/trim.go b/internal/crop/trim.go index e23789b..de7aee6 100644 --- a/internal/crop/trim.go +++ b/internal/crop/trim.go @@ -68,7 +68,8 @@ func NewContentTrim() *ContentTrim { } } -// Detect implements Detector. hints are unused. It never returns an error. +// Detect implements Detector. hints are unused. It errors only if ctx is +// canceled mid-scan; a page with no ink is (Full, false, nil), not an error. func (t *ContentTrim) Detect(ctx context.Context, img image.Image, _ Hints) (Box, bool, error) { b := img.Bounds() w, h := b.Dx(), b.Dy() diff --git a/internal/crop/trim_test.go b/internal/crop/trim_test.go index a946ac2..2789ba9 100644 --- a/internal/crop/trim_test.go +++ b/internal/crop/trim_test.go @@ -70,8 +70,10 @@ func TestContentTrim_SkipsBleedStrip(t *testing.T) { } } -// The four guards: each perturbs one condition so the leading strip is treated -// as content and NOT skipped (top stays on the strip, ~row 30 - pad ~= 20). +// Three of the four bleed guards (the fourth, bleed-zone position, is +// TestContentTrim_KeepsStripBelowBleedZone): each case violates one condition so +// the leading strip is treated as content and NOT skipped (top stays on the +// strip, ~row 30 - pad ~= 20). func TestContentTrim_KeepsStrip(t *testing.T) { cases := []struct { name string @@ -121,7 +123,7 @@ func TestContentTrim_BleedZoneScalesWithHeight(t *testing.T) { t.Fatalf("short-page top = %d, want ~45 (strip kept, below bleed zone)", shortTop) } tallTop, _ := topPx(t, page(3000, strip, span{200, 2900, 100, 900})) - if tallTop < 150 { // strip skipped -> lands on content (~200 - padY(15)) + if !near(tallTop, 185, 6) { // strip skipped -> lands on content: 200 - padY(15) t.Fatalf("tall-page top = %d, want ~185 (strip skipped, inside bleed zone)", tallTop) } } From e23a09235d2e6b6c9244a4bd5be9d6d58a08ad76 Mon Sep 17 00:00:00 2001 From: Matthew Kelch Date: Tue, 14 Jul 2026 22:13:54 -0400 Subject: [PATCH 3/3] fix(crop): reject degenerate sub-floor crop boxes in Clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tiny-but-positive override (e.g. W=0.001) slipped past Clamp's <=0 checks and Apply would emit a 1px sliver crop. Enforce a minSpan (2%) floor on both the initial and final dimension checks, collapsing anything smaller to Full() — same fail-safe-to-uncropped philosophy as the non-finite guard. Adds a Clamp guard test (NaN/Inf/below-floor/negative/legit). Addresses CodeRabbit nitpick on internal/crop/crop.go. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_013G3Wpmfq9mxWMc5Lsfjdvo --- internal/crop/crop.go | 9 +++++++-- internal/crop/trim_test.go | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/internal/crop/crop.go b/internal/crop/crop.go index e6a0d3f..f143d59 100644 --- a/internal/crop/crop.go +++ b/internal/crop/crop.go @@ -34,6 +34,11 @@ import ( // changes in a way that should re-crop already-cached editions. const AlgoVersion = "1" +// minSpan is the smallest crop dimension (as a fraction of the image) treated as +// legitimate. Anything smaller is a corrupt/degenerate box, so Clamp collapses +// it to Full() rather than letting Apply emit a sliver crop. +const minSpan = 0.02 + // Box is a crop rectangle in normalized coordinates: X, Y, W, H each in [0,1], // as fractions of the source image's width/height. Normalizing keeps a box // meaningful across master-width changes and across the master/downscaled @@ -60,7 +65,7 @@ func (b Box) Clamp() Box { return Full() } } - if b.W <= 0 || b.H <= 0 { + if b.W < minSpan || b.H < minSpan { return Full() } x, y, w, h := b.X, b.Y, b.W, b.H @@ -84,7 +89,7 @@ func (b Box) Clamp() Box { if y+h > 1 { h = 1 - y } - if w <= 0 || h <= 0 { + if w < minSpan || h < minSpan { return Full() } return Box{X: x, Y: y, W: w, H: h} diff --git a/internal/crop/trim_test.go b/internal/crop/trim_test.go index 2789ba9..b5d3179 100644 --- a/internal/crop/trim_test.go +++ b/internal/crop/trim_test.go @@ -4,9 +4,32 @@ import ( "context" "image" "image/color" + "math" "testing" ) +func TestBoxClamp_RejectsDegenerate(t *testing.T) { + full := Full() + cases := []struct { + name string + in Box + want Box + }{ + {"nan", Box{X: math.NaN(), W: 0.5, H: 0.5}, full}, + {"inf", Box{W: math.Inf(1), H: 0.5}, full}, + {"below-floor", Box{X: 0.5, Y: 0.5, W: 0.001, H: 0.5}, full}, // < minSpan + {"negative", Box{W: -0.3, H: 0.5}, full}, + {"legit", Box{X: 0.05, Y: 0.05, W: 0.9, H: 0.9}, Box{X: 0.05, Y: 0.05, W: 0.9, H: 0.9}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.in.Clamp(); got != c.want { + t.Fatalf("Clamp(%v) = %v, want %v", c.in, got, c.want) + } + }) + } +} + // span is an ink rectangle [y0,y1)×[x0,x1) painted onto a white page. type span struct{ y0, y1, x0, x1 int }