466 lines
19 KiB
Go
466 lines
19 KiB
Go
//go:build !windows
|
|
|
|
package main
|
|
|
|
// Gates for two findings about WHICH pages of a PDF reach the OCR engine:
|
|
// the layer-first order, and the stamp band.
|
|
//
|
|
// LAYER FIRST — pdf-ocr rasterized every page before it looked at the text layer, and the
|
|
// reason written in the source was that the extra rendered page doubled as the
|
|
// page-count probe, so the job needed two binaries rather than three. MEASURED
|
|
// on this box (poppler 26.01.0), against the same two-page document:
|
|
//
|
|
// pdftotext -layout -q -f 1 -l 31 -- doc.pdf - 6.2 ms exit 0, exactly 2 form feeds
|
|
// pdftoppm -png -gray -r 300 -f 1 -l 3 doc.pdf 978.5 ms 2 PNGs
|
|
//
|
|
// So the layer pass carries the page count for free with the same cap+1 trick,
|
|
// on a binary pdf-ocr already runs, and the justification did not survive the
|
|
// measurement. These gates pin the restructured order.
|
|
//
|
|
// THE STAMP BAND — a stamp, a fax header or a scanner footer over a scanned page puts a
|
|
// handful of REAL characters in the text layer of a page that is otherwise a
|
|
// picture. Under a 16-character band that page never reached the engine, and the
|
|
// user got the stamp instead of their document.
|
|
//
|
|
// Every test here drives the real poppler and the real tesseract.
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/zlib"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// ── fixtures, built here rather than pasted as base64 ──────────────────────
|
|
|
|
// densePDF is a text PDF of n pages, each carrying well over ocrPageHasText
|
|
// characters — an ordinary born-digital document, the case the layer-first order is about.
|
|
//
|
|
// Built rather than embedded so the per-page character count is a number this
|
|
// file states and can assert, instead of a property of an opaque blob.
|
|
func densePDF(t *testing.T, n int) ([]byte, int) {
|
|
t.Helper()
|
|
const line = "the quick brown fox jumps over the lazy dog 0123456789"
|
|
const lines = 24
|
|
objs := []string{
|
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
|
"", // filled below: the page tree
|
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
}
|
|
kids := []string{}
|
|
perPage := 0
|
|
for p := 1; p <= n; p++ {
|
|
var c bytes.Buffer
|
|
for i := 0; i < lines; i++ {
|
|
fmt.Fprintf(&c, "BT /F1 11 Tf 72 %d Td (p%d %s) Tj ET\n", 720-i*24, p, line)
|
|
}
|
|
objs = append(objs,
|
|
fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", c.Len(), c.String()))
|
|
contents := len(objs)
|
|
objs = append(objs, fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "+
|
|
"/Resources << /Font << /F1 3 0 R >> >> /Contents %d 0 R >>", contents))
|
|
kids = append(kids, fmt.Sprintf("%d 0 R", len(objs)))
|
|
perPage = lines * (len(line) + 4)
|
|
}
|
|
objs[1] = fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), n)
|
|
return assemblePDF(objs), perPage
|
|
}
|
|
|
|
// stampedScanPDF is ONE page that is a picture of words, with a short REAL text
|
|
// layer written over it — a copier footer, a fax TTI header, a Bates number.
|
|
//
|
|
// The picture is the image XObject out of scannedPDFB64, this package's existing
|
|
// image-only fixture, so the page really is a scan and not a drawing of one. The
|
|
// stamp is ordinary visible Helvetica, which is what a scanner app, a fax MFP or
|
|
// an e-signature service actually writes.
|
|
func stampedScanPDF(t *testing.T, stamp string) []byte {
|
|
t.Helper()
|
|
raw, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(scannedPDFB64), ""))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
img, w, h := grabImageXObject(t, raw)
|
|
content := fmt.Sprintf("q 612 0 0 792 0 0 cm /Im1 Do Q\nBT /F1 9 Tf 36 768 Td (%s) Tj ET\n",
|
|
strings.NewReplacer("\\", `\\`, "(", `\(`, ")", `\)`).Replace(stamp))
|
|
objs := []string{
|
|
"<< /Type /Catalog /Pages 2 0 R >>",
|
|
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources " +
|
|
"<< /XObject << /Im1 5 0 R >> /Font << /F1 6 0 R >> >> /Contents 4 0 R >>",
|
|
fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content),
|
|
fmt.Sprintf("<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace "+
|
|
"/DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length %d >>\nstream\n%s\nendstream",
|
|
w, h, len(img), img),
|
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
|
}
|
|
return assemblePDF(objs)
|
|
}
|
|
|
|
// grabImageXObject lifts the deflated image stream, and its pixel dimensions,
|
|
// out of a PDF that holds exactly one image.
|
|
func grabImageXObject(t *testing.T, pdf []byte) (string, int, int) {
|
|
t.Helper()
|
|
re := regexp.MustCompile(`(?s)/Length (\d+) 0 R\s*/Subtype /Image\s*/Width (\d+)\s*/Height (\d+).*?stream\r?\n`)
|
|
m := re.FindSubmatchIndex(pdf)
|
|
if m == nil {
|
|
t.Fatal("the scanned fixture no longer holds an image XObject in the expected shape")
|
|
}
|
|
var w, h int
|
|
fmt.Sscan(string(pdf[m[4]:m[5]]), &w)
|
|
fmt.Sscan(string(pdf[m[6]:m[7]]), &h)
|
|
start := m[1]
|
|
end := bytes.Index(pdf[start:], []byte("\nendstream"))
|
|
if end < 0 {
|
|
t.Fatal("the scanned fixture's image stream has no endstream")
|
|
}
|
|
data := pdf[start : start+end]
|
|
// Prove it really is the deflated raster and not something that merely parses.
|
|
if _, err := zlib.NewReader(bytes.NewReader(data)); err != nil {
|
|
t.Fatalf("the scanned fixture's image stream is not deflate: %v", err)
|
|
}
|
|
return string(data), w, h
|
|
}
|
|
|
|
// assemblePDF writes objects 1..n with a correct xref, the same way
|
|
// mixedLayerPDF does.
|
|
func assemblePDF(objs []string) []byte {
|
|
var pdf bytes.Buffer
|
|
pdf.WriteString("%PDF-1.4\n")
|
|
offs := make([]int, 0, len(objs))
|
|
for i, o := range objs {
|
|
offs = append(offs, pdf.Len())
|
|
fmt.Fprintf(&pdf, "%d 0 obj\n%s\nendobj\n", i+1, o)
|
|
}
|
|
xref := pdf.Len()
|
|
fmt.Fprintf(&pdf, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1)
|
|
for _, o := range offs {
|
|
fmt.Fprintf(&pdf, "%010d 00000 n \n", o)
|
|
}
|
|
fmt.Fprintf(&pdf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n",
|
|
len(objs)+1, xref)
|
|
return pdf.Bytes()
|
|
}
|
|
|
|
// ── observing which binaries a job actually ran ────────────────────────────
|
|
|
|
// spyBinary puts a shim for `name` at the FRONT of PATH that records every
|
|
// invocation and then execs the real binary.
|
|
//
|
|
// The property under test — "a text PDF renders no pages" — is invisible in
|
|
// pdf-ocr's return value and the scratch directory is removed before the job
|
|
// returns, so the only honest place to observe it is at the process boundary.
|
|
// This observes exactly that, against the real tool, with no seam added to
|
|
// production code for a test's benefit.
|
|
func spyBinary(t *testing.T, name string) (log func() []string) {
|
|
t.Helper()
|
|
real, err := exec.LookPath(name)
|
|
if err != nil {
|
|
t.Skipf("%s is not installed on this machine", name)
|
|
}
|
|
dir := t.TempDir()
|
|
logPath := filepath.Join(dir, name+".log")
|
|
script := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$*\" >> %q\nexec %q \"$@\"\n", logPath, real)
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
return func() []string {
|
|
b, err := os.ReadFile(logPath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return strings.FieldsFunc(strings.TrimSpace(string(b)), func(r rune) bool { return r == '\n' })
|
|
}
|
|
}
|
|
|
|
// hideBinary removes `name` from this test's PATH while leaving every other tool
|
|
// reachable, so a job that depends on it takes the path it takes when poppler
|
|
// will not answer.
|
|
func hideBinary(t *testing.T, keep []string, hide string) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for _, k := range keep {
|
|
p, err := exec.LookPath(k)
|
|
if err != nil {
|
|
t.Skipf("%s is not installed on this machine", k)
|
|
}
|
|
if err := os.Symlink(p, filepath.Join(dir, k)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := exec.LookPath(hide); err != nil {
|
|
t.Skipf("%s is not installed on this machine, so there is nothing to hide", hide)
|
|
}
|
|
t.Setenv("PATH", dir)
|
|
if _, err := exec.LookPath(hide); err == nil {
|
|
t.Fatalf("%s is still reachable after narrowing PATH — the test is not testing anything", hide)
|
|
}
|
|
}
|
|
|
|
// ── layer first ─────────────────────────────────────────────────────────────
|
|
|
|
// A TEXT PDF RENDERS NOTHING.
|
|
//
|
|
// pdf-ocr's contract is "this PDF's text, reading the pages that have none". For
|
|
// a born-digital document that is every page from the text layer and no engine
|
|
// work at all — and, with the layer read first, no RASTER work either. Rasterizing first taxed
|
|
// every text document about 160x on this box (6.2 ms of layer pass against
|
|
// 978.5 ms of render for two pages) for a page count the layer pass already
|
|
// carries.
|
|
func TestATextPDFRendersNoPages(t *testing.T) {
|
|
needPDFOCR(t)
|
|
dir := t.TempDir()
|
|
raw, perPage := densePDF(t, 2)
|
|
if perPage <= ocrPageHasText {
|
|
t.Fatalf("the fixture carries ~%d chars per page, which is not above the "+
|
|
"%d-character band — it cannot test the text-document path",
|
|
perPage, ocrPageHasText)
|
|
}
|
|
src := filepath.Join(dir, "text.pdf")
|
|
if err := os.WriteFile(src, raw, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
seen := spyBinary(t, "pdftoppm")
|
|
text, pages, total, deadline, err := runPDFOCRCapped(dir, src,
|
|
time.Now().Add(ocrExtractTimeout), 30)
|
|
if err != nil {
|
|
t.Fatalf("pdf-ocr failed on a text PDF: %v", err)
|
|
}
|
|
if deadline {
|
|
t.Fatal("a two-page text PDF ran out of time")
|
|
}
|
|
if ran := seen(); len(ran) != 0 {
|
|
t.Fatalf("a text PDF rasterized pages it never needed — pdftoppm ran %d time(s):\n %s\n"+
|
|
"every page of this document has a text layer, so the render is pure cost on "+
|
|
"the user's machine", len(ran), strings.Join(ran, "\n "))
|
|
}
|
|
if len(pages) != 2 || total != 2 {
|
|
t.Fatalf("read %d pages, total=%d, want 2 and 2 — the page count has to survive "+
|
|
"the move to a layer-first order", len(pages), total)
|
|
}
|
|
for _, p := range pages {
|
|
if p.How != "text" {
|
|
t.Fatalf("page %d of a text document was recorded as how=%q", p.Page, p.How)
|
|
}
|
|
}
|
|
if !strings.Contains(text, "p1 the quick brown fox") || !strings.Contains(text, "p2 the quick brown fox") {
|
|
t.Fatalf("both pages' text did not come back:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// THE PAGE COUNT STILL COMES FROM THE cap+1 TRICK, only now on the layer pass.
|
|
// A document with a page past the cap has to say so, and it must say so without
|
|
// rendering that page — which is the whole saving.
|
|
func TestMorePagesIsAnsweredByTheLayerPass(t *testing.T) {
|
|
needPDFOCR(t)
|
|
dir := t.TempDir()
|
|
raw, _ := densePDF(t, 3)
|
|
src := filepath.Join(dir, "text.pdf")
|
|
if err := os.WriteFile(src, raw, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seen := spyBinary(t, "pdftoppm")
|
|
_, pages, total, _, err := runPDFOCRCapped(dir, src, time.Now().Add(ocrExtractTimeout), 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(pages) != 2 {
|
|
t.Fatalf("read %d pages with the cap at 2", len(pages))
|
|
}
|
|
if total >= 0 {
|
|
t.Fatal("a 3-page document under a cap of 2 was reported complete — the user " +
|
|
"would have no way to know the rest of their document exists")
|
|
}
|
|
if ran := seen(); len(ran) != 0 {
|
|
t.Fatalf("answering \"is there a page past the cap\" rendered %d page(s); the "+
|
|
"layer pass carries the count for free:\n %s", len(ran), strings.Join(ran, "\n "))
|
|
}
|
|
}
|
|
|
|
// POPPLER REFUSING THE LAYER IS NOT A REFUSAL OF THE DOCUMENT.
|
|
//
|
|
// The layer pass carries both the text and the page count, so when it returns
|
|
// neither there is no count to work from and the job falls back to the render
|
|
// probe that used to be unconditional. Losing that fallback would turn "I could
|
|
// not read the text layer" into "I could not read your document", which is the
|
|
// sentence this whole lane exists to avoid.
|
|
//
|
|
// Driven by taking pdftotext off this test's PATH: that produces exactly the
|
|
// condition — a layer pass that answers with nothing and no page count — against
|
|
// the real rasterizer and the real engine, without needing a PDF that poppler's
|
|
// two halves happen to disagree about.
|
|
func TestAScanIsStillReadWhenTheLayerPassAnswersNothing(t *testing.T) {
|
|
needPDFOCR(t)
|
|
dir := t.TempDir()
|
|
src := filepath.Join(dir, "scan.pdf")
|
|
raw, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(scannedPDFB64), ""))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(src, raw, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hideBinary(t, []string{"pdftoppm", "tesseract"}, "pdftotext")
|
|
|
|
text, pages, total, deadline, err := runPDFOCRCapped(dir, src,
|
|
time.Now().Add(ocrExtractTimeout), 30)
|
|
if err != nil {
|
|
t.Fatalf("a document whose text layer could not be read was refused outright: %v", err)
|
|
}
|
|
if deadline {
|
|
t.Fatal("a two-page scan ran out of time")
|
|
}
|
|
if len(pages) != 2 || total != 2 {
|
|
t.Fatalf("read %d pages, total=%d, want 2 and 2 — with no layer pass the page "+
|
|
"count has to come from the render probe", len(pages), total)
|
|
}
|
|
for _, p := range pages {
|
|
if p.How != "ocr" {
|
|
t.Fatalf("page %d was recorded as how=%q with no text layer available at all",
|
|
p.Page, p.How)
|
|
}
|
|
}
|
|
if !strings.Contains(text, ocrFixtureLine1) {
|
|
t.Fatalf("the document came back without its words:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// ── the stamp band ──────────────────────────────────────────────────────────
|
|
|
|
// ocrStampFixture is the exact case that was reported: twenty-one characters
|
|
// of scanner footer over a page that is otherwise a picture of words.
|
|
const ocrStampFixture = "Scanned by CamScanner"
|
|
|
|
// A STAMP OVER A SCAN MUST NOT STAND IN FOR THE PAGE.
|
|
//
|
|
// The band was 16 characters, so this page's 21-character footer read as "this
|
|
// page has a text layer" and the body of the document never reached the engine.
|
|
// The user got the name of the app that scanned it and nothing they wrote.
|
|
//
|
|
// MEASURED on this box, this fixture: layer 21 chars, OCR of the same page 88
|
|
// chars — a ratio of 4.2, against the 2.0 the comparison requires. On a
|
|
// full-page scan the ratio is far wider still (26x to 142x across five real
|
|
// stamp formats over a 2,530-character page).
|
|
func TestAStampedScanReturnsThePageNotTheStamp(t *testing.T) {
|
|
needPDFOCR(t)
|
|
if len(ocrStampFixture) != 21 {
|
|
t.Fatalf("the stamp is %d chars; the reported case is 21", len(ocrStampFixture))
|
|
}
|
|
dir := t.TempDir()
|
|
src := filepath.Join(dir, "stamped.pdf")
|
|
if err := os.WriteFile(src, stampedScanPDF(t, ocrStampFixture), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The layer measured through the real binary first, at arm's length from
|
|
// this package's own helpers, so a failure below says which half moved
|
|
// rather than blaming the code.
|
|
out, lerr := runCmd(30*time.Second, "pdftotext", "-layout", "-q", "-f", "1", "-l", "1", "--", src, "-")
|
|
if got := strings.TrimSpace(strings.ReplaceAll(out, "\f", "")); lerr != nil || got != ocrStampFixture {
|
|
t.Skipf("this poppler does not read the fixture's stamp as %q (got %q, err=%v) — "+
|
|
"the fixture, not the code, is what changed", ocrStampFixture, got, lerr)
|
|
}
|
|
|
|
text, pages, _, deadline, err := runPDFOCRCapped(dir, src, time.Now().Add(ocrExtractTimeout), 1)
|
|
if err != nil {
|
|
t.Fatalf("pdf-ocr failed on the stamped scan: %v", err)
|
|
}
|
|
if deadline {
|
|
t.Fatal("one page ran out of time")
|
|
}
|
|
if len(pages) != 1 {
|
|
t.Fatalf("read %d pages, want 1", len(pages))
|
|
}
|
|
if !strings.Contains(text, ocrFixtureLine1) {
|
|
t.Fatalf("a scanned page carrying a %d-character stamp came back as the stamp "+
|
|
"and nothing else.\n the page says %q\n what was indexed: %q\n"+
|
|
"the user believes this document is in their corpus; its body is not",
|
|
len(ocrStampFixture), ocrFixtureLine1, strings.TrimSpace(text))
|
|
}
|
|
if pages[0].How != "ocr" {
|
|
t.Fatalf("the page was recorded as how=%q after the engine's reading was kept",
|
|
pages[0].How)
|
|
}
|
|
}
|
|
|
|
// THE OTHER SIDE OF THE SAME WIDENING, and the one that makes it safe to widen.
|
|
//
|
|
// Widening the band admits genuinely sparse TEXT pages — a title page, a section
|
|
// divider — to the comparison, and on those two pages the layer and its OCR come
|
|
// out close in length, so "keep whichever is longer" is a coin flip. MEASURED on
|
|
// six real sparse pages from real documents (48 to 364 characters of layer), the
|
|
// OCR/layer length ratio ran 0.10 to 1.08; on the same measurement over five
|
|
// real stamp formats on a full-page scan it ran 26.7 to 141.7. Nothing sits
|
|
// between, so the engine's reading is kept only when it is a MULTIPLE longer.
|
|
//
|
|
// Without the multiple, one of those six real pages flipped: a 110-character
|
|
// layer lost to a 119-character OCR that kept 11 of the layer's 16 words and
|
|
// invented 8 the page does not contain.
|
|
func TestASparseTextPageKeepsItsOwnWordsAgainstACloseOCR(t *testing.T) {
|
|
needPDFOCR(t)
|
|
dir := t.TempDir()
|
|
if len(sparseLayerText) >= ocrPageHasText {
|
|
t.Fatalf("the fixture's layer is %d chars, which is not inside the %d-character "+
|
|
"band — it cannot test what the band admits", len(sparseLayerText), ocrPageHasText)
|
|
}
|
|
src := filepath.Join(dir, "sparse.pdf")
|
|
if err := os.WriteFile(src, stampedScanPDF(t, sparseLayerText), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// THE FIXTURE HAS TO SIT IN THE WINDOW THE MARGIN GOVERNS, and whether it
|
|
// does is a property of this machine's poppler and tesseract rather than of
|
|
// this file. Both sides are measured here, through the real binaries, so a
|
|
// fixture that drifted out of the window skips loudly instead of passing for
|
|
// the wrong reason — which is what the first version of this test did.
|
|
out, lerr := runCmd(30*time.Second, "pdftotext", "-layout", "-q", "-f", "1", "-l", "1", "--", src, "-")
|
|
layer := strings.TrimSpace(strings.ReplaceAll(out, "\f", ""))
|
|
if lerr != nil || layer != sparseLayerText {
|
|
t.Skipf("this poppler reads the fixture's layer as %q, not %q (err=%v)", layer, sparseLayerText, lerr)
|
|
}
|
|
page, rerr := renderOnePage(dir, src, 1, 60*time.Second)
|
|
if rerr != nil {
|
|
t.Skipf("this machine could not render the fixture: %v", rerr)
|
|
}
|
|
eng, _, oerr := runExtract(60*time.Second, "tesseract", ocrArgv(page)...)
|
|
ocr := strings.TrimSpace(eng)
|
|
if oerr != nil {
|
|
t.Skipf("this machine's engine could not read the fixture: %v", oerr)
|
|
}
|
|
if !(len(ocr) > len(layer) && len(ocr) < ocrBeatsLayer*len(layer)) {
|
|
t.Skipf("the fixture is outside the window this test is about: layer %d chars, "+
|
|
"OCR %d chars, and the margin only decides between %d and %d",
|
|
len(layer), len(ocr), len(layer)+1, ocrBeatsLayer*len(layer)-1)
|
|
}
|
|
|
|
text, pages, _, _, err := runPDFOCRCapped(dir, src, time.Now().Add(ocrExtractTimeout), 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pages[0].How != "text" {
|
|
t.Fatalf("an OCR reading of %d characters replaced a REAL %d-character text layer "+
|
|
"on a length lead of %d.\n the document states %q\n what was kept: %q\n"+
|
|
"the engine's reading is kept only when it is %dx longer; below that the "+
|
|
"two readings are close enough that length says nothing",
|
|
len(ocr), len(layer), len(ocr)-len(layer), sparseLayerText,
|
|
strings.TrimSpace(text), ocrBeatsLayer)
|
|
}
|
|
if !strings.Contains(text, sparseLayerText) {
|
|
t.Fatalf("the page was recorded as how=text and its text layer is not in the "+
|
|
"result:\n%s", text)
|
|
}
|
|
}
|
|
|
|
// Inside the band, and short enough that the engine's reading of this fixture's
|
|
// picture is LONGER than it but not a multiple longer — the window where the
|
|
// length proxy stops being informative and the margin is what decides.
|
|
const sparseLayerText = "Chapter 3 Methods and materials, with a note on the sampling frame and the cohorts"
|