//go:build !windows package main // Gates for OCR (contextocr.go) — the literal words on a picture, read // on the user's own machine. // // EVERY ONE OF THESE DRIVES REAL TESSERACT through a real ctx_extract management // frame. A unit test of the argv builder would have passed on the machine this // was written on and still shipped a lane that cannot OCR, because the failure // mode here is not a wrong flag — it is an engine that exits 0 and reads // nothing. So the fixtures are real: a real image-only PDF, produced by // rasterizing a text PDF through ghostscript's pdfimage8 device (verified: its // text layer is EXACTLY ZERO characters, which is the property that makes it a // scanned document and not a text one), and a real 1-bit PNG of known words. // // Both are small enough to live in the source and regenerable from the recipe // above — nobody inherits a fixture they cannot rebuild. import ( "bytes" "compress/zlib" "encoding/base64" "encoding/json" "fmt" "image/png" "os" "path/filepath" "strings" "testing" "time" ) // The words on both fixtures. The FIRST line only — the second exists so a test // can tell "read the page" from "read the top of the page". const ocrFixtureLine1 = "OCR FIXTURE ALPHA zulu yankee" const ocrFixtureLine2 = "second line xray whiskey victor" // A two-page PDF containing nothing but a grayscale raster of those words: // // ps2pdf fx.ps fx.pdf // gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfimage8 -r150 -sOutputFile=scanned.pdf fx.pdf // // Measured: `pdftotext scanned.pdf -` returns 0 characters of text (two form // feeds and nothing else), while the pages are covered in words. That is the // exact document this whole slice exists for. const scannedPDFB64 = "JVBERi0xLjMKJcfsj6IKJSVJbnZvY2F0aW9uOiBncyAtcSAtZE5PUEFVU0UgLWRCQVRDSCAtc0RFVklDRT1wZGZpbWFnZTggLXIx" + "NTAgLXNPdXRwdXRGaWxlPT8gPwo0IDAgb2JqCjw8Ci9MZW5ndGggNSAwIFIKL1N1YnR5cGUgL0ltYWdlCi9XaWR0aCA4NzUKL0hl" + "aWdodCA0NTgKL0NvbG9yU3BhY2UgL0RldmljZUdyYXkKL0JpdHNQZXJDb21wb25lbnQgOAovRmlsdGVyIC9GbGF0ZURlY29kZQo+" + "PgpzdHJlYW0KeJzt2Nt6YrmuBlDe/6Wzv726ksxp65cNRQUIY1x0gw+y5EOS7o8PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAODj8j+39wNrl6NFf5qWZ4eRl++O45hp" + "Upp+7JrX2skwBl3s00ZjHb5OMrV0sW9z32jPvuwTWt212L9/VePAw5QUPi5041P7Hr2f/3l82bpXdJVkakkL3u6+0Z572YdUurC6" + "bdde1dWvxdO444w6erPQrU/t8tH1buxU1XpF0cu0+wVvd9dgz73sYyrtra7b1Vf1qltXxZu/NAvNKxZBQoa72U9FVM2bRZ+WTmn3" + "C97ursGeetkHVdpaXbfrr6qntr11Ke1+wdvdNdgzL3vfbbuP8Sw3vxdfyoZzR1y/mF+H2rmh+xleeSDTe1lEGdpPc3cKSQve7kEX" + "8IeXvfeu3Um6i+UvmrFhLiiVGCs/dxym1zN2bmj/mPr8O4eLf9NTK5beKeSel+ZBF/Anl73cf9fuY3Vzrr2qqcRY+dDxNT8E2r+h" + "t+XfSWcYo4S1ryzknpfmrhewC1adah9ou3kx6LhpT/jUqrZ0I+b7cs+n9vV9O07f0md43YH8Gbxd852e2vpKj5drmDBtR7VcuUY1" + "pvjP3CnI2JO/rP4DOBT+2VMMmnbjWVQZHdOMmzCNnOeu1ik7Tjv1VE/tfLwhYp9su7FxdJfkZVREmrajWq5coxpz2rnhnP77PHek" + "z2OgUFcq+6Mas5j5MHVG46HFjOfuxd5sdGzt8XZLn+FVB/I5NmW8XdtuIXHBee3zlg0TpoKr5co1hsrGydNB/fd57pk+XoYVLnvN" + "uezmDj+JOqF4aPXAf/bUthLuW/oM8zrd0uOkGKVbu7gscXSTZTl/GD8VXCVXL3EaNC4wp53qmWa26cfmpuxpTD3vgdpCYn8xsP4+" + "jexOYR66lXDf0mdYZZWq/e4ch8V53cBy6WJ0XWLM7yef2jzkWMRc7LxV5ZjYPBfb/Om1tWc/qS0k9o8DR5sjUwZNmFufWshwN/tz" + "1HFYnDe0F3V3q+cFc3qrV7Q1KESd/n5M4ecvX5/KHenrLYs/71Z1ADt79qNCQmlr6nHxsixGpgzWcfZb+gz381/e0vzU6vh1X/3U" + "Nu5NEfvct1XEbtWnwXHh72+fn8oSw/Aig1Buc4nmgh4mJPTVvEi4vytbI+Mu/bvfan1WbQHlknFeGX73qTULxpXqLakD5UGh7JBE" + "zvP7259PY4SQwkZml7RSiv14q+1bJLy4LYuRIYM2UBqeWvoMt9Mvgw5RNrdnt5BmwbRQF2nqyYPm0HnEuapUQdjfUHGzEaHgrWvx" + "YHVC34XsHfFoe6Gy48o4fUufYV6nX/g8LUbp9mZZSLNgWqiLNPXkQaHuYURVVqqg3IH4oyg0N3v1Pk8tfl8tVHXk/a3j9C19hjnf" + "ft3q29ZT2y+k+xbWSbGn7VgMqgsvax4KS2mETZj35/Lbn1r9l3Duz9Pj8O2OboPLOH1Ln+GivClmffwxShd9VUi3YJ1bjD1tx2JQ" + "EX0cEDIrQhZPbVXjRumLisuWB6uPcNqj0JsO+ZpbFzYtBdrZ0+nwY4Yx3XrZcPzXF70upF2wzC3HngpeDKoTrbIrwxdpfI0/Lxpq" + "3Ch9bFvs5lPIdZQHMjQtT3nZXp74Yb/XcfqWPsOwSMoyHH+M0kVfFNIuWKXWxJ4KXgwqEy2yC+GLNL5yrDZuY2uWQ5Z78ATiQeXL" + "OB3X/Z9anrFIdxpzbW9wqS2idNEXl6NdsMisiz2luhhUrVD8UkvhizSqT+UedM1N1es9eAbzUVXXKPwFlianZeL6RfD6cm1sapV/" + "GF6vkZKcLaJ00ee+atvrBYvMmthz3MWgXHw1eApfpDF+mrPptybl1MzYCPLTxrPa/F58ObSkZeL6e8HrOF2Cd31qqSVG6aLvhNyI" + "1WxSfAvVj8km1cuXMpcpfJFyWjYEWmd2KcI8/VM77+S8rR+nlqF7rjFVGCsvtvW2pzYUUEWcIlxqG9kfR8YYsegm5M6CVe2n3Kcc" + "qrhxUJ3qoTsstfXUFp/D8Fh3HNIV9DDzcZ1y7LrnIlOFsfJmW6stzLta599nWNY2p7lIJMaIRZd9c8Rl5YunNte0KL5L9djdhi8y" + "LpY9B6p/TY3NXd0v8dRWe950z6NThbHyQ0eIvv476uYMqxMr0lwcZYwRiy77pojrJH7mqc2bmKaeh4WN6HII8VNGzZi2oMdZpJ27" + "5+H93sS167lFsDpOzr/PsDixHL5cMkZpiy77przigrnw6gp/TO3F5OGVrJaeZl791Mq3FuqKu9eMSTMfbquyuONty7E9rtwG2/+t" + "eW2Gl1ode5X4HCMWXfZ1O5GixdyPDYf2c4wwKORa/6kz/9CJBdWFzLl3zUOIPKbb+wdr6trqf7inT/CHbe3G5pYVo+642Tc8lvS6" + "4bU93X32U5Vf6fku9vNlBHfwfPfaU+M3esJ7/YQpwd/p/j/g4zxhSvB3nvKleWr8Pk/50jw1fp/nvNTPmRUAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAv9vlf9rmMOT15EKWJf7cHvya3WbgqfU9uwPu5tfsNgNPre/ZHXA365V+zXm8GU+t79kd" + "cDee2m/lqfU9uwPuxlP7rTae2q/xF0U90348Uy7s89T+9dS7e6ZcXkjatq55f3ju/Gq79ak105pZvcPUf3KdLqeq90bu5fMvL/9e" + "Losb8G9SeyGXb3VzGr4R5fN77PuvbeOpHT53QWPax84U/uN4/Q8FVRPmr1NT/eVcc45XbOg6mTh6ne28xU3wYnM2bsDHe7uc1e1p" + "+F6U8mBOJ1Ncg+8h0+cmaEp76J4+f96IqfcyzyjTW6Q8/C47p5/nDkPS57rudbZ5xKkn7sQy4Wpj39ZlVLen4YsoxauIU/7mqaX8" + "Ur1T9FPMWGhZ3CrlcbFc97xSuX5M5e+yXfyUOAdPi4Wi/s2f4i8mbNF1zfn6zB0pUnUS84Q+aD7wsd4h+HQjwgrFYjnlMHOVfVPI" + "FKQ/xauy3d/tsTGcSb2x76vYrbp5Gh5mDpegOJ4U6fqn1gWNh/vZPqw5XJJ25SK7If6UZmiuNqvZ0CG9Mfnrs40juvWr/Q0Jzxv7" + "vtqjDs3TaZR723+p2q99avMK67v13T50DznMX8LKTc7HkH32YWpuLpNPCSyzvTJI3OvuBrz3M/t/3f7OYy5z+9ycHsgq0rVPrRiU" + "2ougY++lCFp8iVkUOR8XSfO6/c/NZfJpPzaz3Upl+qlTlVGdm5cWfuZUOze3f31bbvTcU7X/9VNLaYeaL9NtrS/EVhbz6NMqffah" + "kin6n8ZF7uVS6x0+jehPqhne7dh7K69d3Lmwa+XRTx+nQOuLHo4snWtz4HXRTQ3T4Km2dsLXv+edK+5uqGRa4XIpO3Ld+9n2f8nE" + "fakzyOW+s/L0LpWPuO1jc3nWp47FZama10Fj2rHmpoYpXL1aiv7n35//vPaphUcVCst1b2Y7H8kVE6tBYwrF/PfTnNDUMx7xKUTd" + "UF2VYspG5HXQ7af2UXTVDXOcWMKUz/jP25/aMHWqLNe9n+3uSc0jqkFjCsX8d1QcUTi7sO37Gx0PcCPyOuhdn1qMcyqhCv7Zc9iz" + "9KCGMJf5y3n5LqU63+1sPbWfMZ5ROLu07dsbHQ8wRA7HGYLe8w/I04hT32kziuDD6zp9KWrNPcXfi1+fixh13ZvZTiO+Yiw2sd49" + "T61zOqO0v13zeEWqGXGBsGB1aXLQfC1SqXXOU6hT35+eZq3vAZ/fbnxqH8OxfP82Pqe/ymWd7SXXn2a2GYznVq78xr4PNZ1McUuK" + "h3OJGz1emmHldr3D572gyzrT5ajTG0Y2S53DT6Nz7Dr/Q7BQalf3Mtt6RH9SqwymcwtLv5OwRenswi3Z3uj+AH/oqV0Od2t8QGWh" + "ZXnLy3tOqryt47pfPWlDp6hlgnUud3xqm1s0F/jO0n0L+xgO+FIM33pqp0h//9S2ftZ/rjWsme9KfmpT7HHE9GUOVy5UrViWWr21" + "arWtbC9TY7dQsebGDXhj4ZDm7Z0OODRXu16tNke681Nbxjz3VzGrgtp8qwHT6Lxuvf/tsaTPxU+GlG05oq5/3pb1hvVrv5HL7NQ+" + "/MapB8co66dWzRmzq+c2g6a054I/FjeiSy7nOy8yrXPNU5vWj2GburezXTy1aSe+lmwSnjf2faUdCvuW2tM+b7yKYlIz/sqgqd7p" + "8/qpzZ3LXa1H5w367gmrX+rRfd3LbKsRG8Gr1IoUVou/j3aHiqML7emoq1HzlOH6lePHY10GLU94jhj+k6LLLkYvB9RbVW3QWOJU" + "SVXqsu5ltqunFoKXuc0prBZ/J+mQwtEtTrQKMoyZZowd5fjD5y5ozO/YWXzLdcb//JnDlwPq2advVc/lKE+tfjZ2NS/T3Y89NjWj" + "msXfS7qb6YxC+/pMyxlXpbod9e/DNmF+5vqkY+nHh/b7JFMm2DTA33mtC/Va2cK31/rZ/VrZwsFr3d3Xyha+vdavidfKFj5d+/8q" + "Huu1soWD17q7r5UtHLzW3X2tbOHgte7ua2ULAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAX/4Pl+sREgplbmRzdHJlYW0KZW5kb2JqCjUgMCBvYmoKMzY3OQplbmRvYmoKNiAwIG9iago8PAovRmlsdGVyL0ZsYXRl" + "RGVjb2RlL0xlbmd0aCA4IDAgUgo+PgpzdHJlYW0KeJwzMTLQMwADBRA0MrTUszCBc5NzufQ9cw0VXPIBma8IHgplbmRzdHJlYW0K" + "ZW5kb2JqCjggMCBvYmoKMzQKZW5kb2JqCjcgMCBvYmoKPDwKL0NvbnRlbnRzIDYgMCBSCi9UeXBlIC9QYWdlCi9QYXJlbnQgMiAw" + "IFIKL01lZGlhQm94IFswIDAgNDIwLjAwMDAwMCAyMTkuODQwMDAwXQovUmVzb3VyY2VzIDw8Ci9YT2JqZWN0IDw8Ci9JbTEgNCAw" + "IFIKPj4KPj4KPj4KZW5kb2JqCjkgMCBvYmoKPDwKL0xlbmd0aCAxMCAwIFIKL1N1YnR5cGUgL0ltYWdlCi9XaWR0aCA4NzUKL0hl" + "aWdodCA0NTgKL0NvbG9yU3BhY2UgL0RldmljZUdyYXkKL0JpdHNQZXJDb21wb25lbnQgOAovRmlsdGVyIC9GbGF0ZURlY29kZQo+" + "PgpzdHJlYW0KeJzt2NtaY8euBlC//0uzv73SDZ5T+lVl4wYbxrhI7DqopDoAydsbAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPB2+Z/7+4G1y7VFf5qWZ4eRl4+O6zFl" + "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" + "KcHnPOf/x3vClOBznvKleWr8PE/50jw1fp7nvNTPmRUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP9vlf8bm" + "MOT15EKWJX7dHvyY3ebEU5t7dgc8zI/ZbU48tblnd8DDrFf6Mefxy3hqc8/ugIfx1H4qT23u2R3wMJ7aT7Xx1H6MTxT1TPvxTLmw" + "z1P711Mf7plyeSFp26bm/eG5873t3qc2TBtmza6m/pPrdDlUvTdyL59/efn3clncgH+T2gu5fOib0/CNKH+/x77/2jae2tXnKWhM" + "+7ozhX+7vv5XBXUT6tfS1H851pzjNRu6TiaOXmdbt3gI3mzOxg14+90uR317Gr4XpT2Yw8k01+BjSPk8BE1pn7rL5783ovRe6ow2" + "vUXKp99lx/Tz3NOQ9Lmve51tHnHoiTuxTLjb2F/rcta3p+GLKM2riFM+89RSfqneEv0QMxbaFrdK+bxYrruu1K4fU/lctoufEsfg" + "abFQ1L/5U/zFhC26rTlfn9qRInUnUSfMQfOBn+s9BS83IqzQLJZTDjNX2Q+FlCDzKd6U7f5unxvDmfQb+3s1u9U3l+Fh5ukSNMeT" + "It3+1Kag8XD/tp/WPF2SceUmu1P8kmZo7jZr2NBTeufkb882jpjW7/Y3JFw39vcajzo0l9No93b+0rXf+tTqCuu79dF+6j7lUL+E" + "lYecr0PO2YepublNPiWwzPbGIHGvpxvwu5/Z/5v2t4651PbanB7IKtKtT60ZlNqboOfeSxO0+RKzaHK+XiTNm/Y/N7fJp/3YzHYr" + "lfJTpyujOzcvLfzM6Xautr9/W2507enaP/3UUtqh5ku5rf2F2Mqijj6sMmcfKinR/zQucm+XWu/wYcR8UsPwacd+t/baxZ0Lu9Ye" + "fflYAq0vejiydK7DgfdFDzWUwaW2ccL7v+vONXc3VFJWuFzajlz3frbzXzJxX/oMcrm/WXt6l85b3PZzc3vWh47FZema10Fj2rHm" + "oYYSrl8tRf/z77//vPWphUcVCst1b2Zbj+SGid2gcwrN/N9nOKHScz7iQ4i+obsqzZSNyOug20/trenqG2qcWELJ5/zP+5/aaWqp" + "LNe9n+3uSdUR3aBzCs3836g5onB2Ydv3Nzoe4EbkddCHPrUY51BCF/xvz9WepQd1CnOpX47LTyn1+W5n66l9jfMZhbNL27690fEA" + "Q+RwnCHoI/+APIw49B02owl+el2HL02tuaf5e/H9cxOjr3sz2zLiPcZiE/vd89QmhzNK+zs1n69INyMuEBbsLk0Omq9FKrXPuYQ6" + "9P3pGdb6GPD3251P7e10LB+/jY/pr3JZZ3vJ9aeZYwbnc2tX/sU+DjWdTHNLmodziRt9vjSnlcf1rj7vBV3WmS5Hn95p5LDUMXwZ" + "nWP3+V8FC6VOdS+z7UfMJ7XKoJxbWPo3CVuUzi7cku2Nng/wi57a5epunR9QW2hb3vLyHpNqb+t53feetKElaptgn8sDn9rmFtUC" + "f7N038I+hgO+NMO3ntoh0uef2tbP+r9rndbMdyU/tRL7PKJ8qeHahboV21K7t9attpXtpTROCzVrbtyAXywcUt3ecsChudv1brUa" + "6cFPbRnz2N/F7Aoa8+0GlNF53X7/x2NJn5ufDCnbdkRff92W9YbNa/8il+rQfvqN0w+OUdZPrZtzzq6fOwwqadeC3xY3Ykou51sX" + "Kevc8tTK+jHsUPd2tounVnbifckh4bqxv1faobBvqT3t88araCYN428Mmuotn9dPrXYud7UfnTfooyesfulHz3Uvs+1GbATvUmtS" + "WC3+e4w71BxdaE9H3Y2qU07Xrx1/PtZl0PaEa8TwnxRTdjF6O6Dfqm6DziWWSrpSl3Uvs109tRC8za2msFr8N0mHFI5ucaJdkNOY" + "MuPc0Y6/+jwFjflddzbfcp3xP39q+HZAP/vwreu5XMtTu5+NU83LdPdjn5uGUcPiv0u6m+mMQvv6TNsZN6W6HfXzYYcwX3N90rHM" + "40P7Y5JpExwa4HNe60K9Vrbw4bV+dr9WtnDlte7ua2ULH17r18RrZQt/3fr/Kr7Xa2ULV17r7r5WtnDlte7ua2ULV17r7r5WtgAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPDu/wBvapGDCmVuZHN0cmVhbQplbmRv" + "YmoKMTAgMCBvYmoKMzcyNgplbmRvYmoKMTEgMCBvYmoKPDwKL0ZpbHRlci9GbGF0ZURlY29kZS9MZW5ndGggMTMgMCBSCj4+CnN0" + "cmVhbQp4nDMxMtAzAAMFEDQytNSzMIFzk3O59D1zDRVc8gGZrwgeCmVuZHN0cmVhbQplbmRvYmoKMTMgMCBvYmoKMzQKZW5kb2Jq" + "CjEyIDAgb2JqCjw8Ci9Db250ZW50cyAxMSAwIFIKL1R5cGUgL1BhZ2UKL1BhcmVudCAyIDAgUgovTWVkaWFCb3ggWzAgMCA0MjAu" + "MDAwMDAwIDIxOS44NDAwMDBdCi9SZXNvdXJjZXMgPDwKL1hPYmplY3QgPDwKL0ltMSA5IDAgUgo+Pgo+Pgo+PgplbmRvYmoKMSAw" + "IG9iago8PAovUGFnZXMgMiAwIFIKL1R5cGUgL0NhdGFsb2cKL0luZm8gMyAwIFIKPj4KZW5kb2JqCjIgMCBvYmoKPDwKL0NvdW50" + "IDIKL0tpZHMgWzcgMCBSIDEyIDAgUiBdCi9UeXBlIC9QYWdlcwo+PgplbmRvYmoKMyAwIG9iago8PAovUHJvZHVjZXIoR1BMIEdo" + "b3N0c2NyaXB0IDEwLjA2LjApCi9DcmVhdGlvbkRhdGUoRDoyMDI2MDkxMjIzMzAzM1owMCcwMCcpCj4+CmVuZG9iagp4cmVmCjAg" + "MTQKMDAwMDAwMDAwMCA2NTUzNiBmIAowMDAwMDA4Mzk3IDAwMDAwIG4gCjAwMDAwMDg0NTggMDAwMDAgbiAKMDAwMDAwODUyMyAw" + "MDAwMCBuIAowMDAwMDAwMDk1IDAwMDAwIG4gCjAwMDAwMDM5MzEgMDAwMDAgbiAKMDAwMDAwMzk1MSAwMDAwMCBuIAowMDAwMDA0" + "MDc1IDAwMDAwIG4gCjAwMDAwMDQwNTcgMDAwMDAgbiAKMDAwMDAwNDIxOSAwMDAwMCBuIAowMDAwMDA4MTAzIDAwMDAwIG4gCjAw" + "MDAwMDgxMjQgMDAwMDAgbiAKMDAwMDAwODI1MSAwMDAwMCBuIAowMDAwMDA4MjMyIDAwMDAwIG4gCnRyYWlsZXIKPDwKL1NpemUg" + "MTQKL1Jvb3QgMSAwIFIKL0lEIFs8MjRBNDAxOThFRTI3MERFRUVDQ0JDNzZGQzkyODJFMDM+PDI0QTQwMTk4RUUyNzBERUVFQ0NC" + "Qzc2RkM5MjgyRTAzPl0KPj4Kc3RhcnR4cmVmCjg2MTgKJSVFT0YK" const ocrPNGB64 = "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" // ── helpers ──────────────────────────────────────────────────────────────── // needOCR skips when this machine cannot OCR, and says WHICH of the two reasons // it is — because the difference is the thing under test and a skip line that // blurs it would hide a regression in the very code that draws it. func needOCR(t *testing.T) { t.Helper() ok, _, why := probePrepToolDetail(prepTool{ Name: "ocr", Bin: "tesseract", Args: []string{"--version"}, Prove: ocrProve}) if !ok { if why != "" { t.Skip("this machine cannot OCR: " + why) } t.Skip("tesseract is not installed on this machine") } } func needPDFOCR(t *testing.T) { t.Helper() needOCR(t) if _, err := runCmd(5*time.Second, "pdftoppm", "-v"); err != nil { t.Skip("pdftoppm (poppler) is not installed on this machine") } } func extractResp(t *testing.T, out string) ctxExtractResp { t.Helper() var r ctxExtractResp if err := json.Unmarshal([]byte(out), &r); err != nil { t.Fatalf("reply is not a ctx_extract result: %v\n%s", err, out) } return r } // ── the measurement that decides the probe ───────────────────────────────── // THE WHOLE REASON contextocr.go DOES NOT PROBE WITH --version. // // tesseract ships its engine and its language data as separate packages on every // distro that packages it (Ubuntu: tesseract-ocr and tesseract-ocr-eng; verified // with dpkg -L, the base package contains no .traineddata at all). This asserts // the consequence, against the real binary, by pointing TESSDATA_PREFIX at an // empty directory: // // --version exits 0 <- a probe that believed this advertises OCR on a // machine that cannot read one word // a real OCR exits 1 <- the only authority for the capability // // If a future tesseract ever makes --version fail without language data, this // test goes red and contextocr.go's header becomes wrong — which is the point. // It is a measurement, pinned, not a comment anyone has to trust. func TestVersionExitCodeIsNotEvidenceOfLanguageData(t *testing.T) { needOCR(t) empty := t.TempDir() t.Setenv("TESSDATA_PREFIX", empty) if _, err := runCmd(10*time.Second, "tesseract", "--version"); err != nil { t.Fatalf("tesseract --version failed with no language data (%v) — if this is "+ "now the behaviour, contextocr.go's header and its real-OCR probe are "+ "describing a version of tesseract that no longer exists", err) } // And --list-langs, the other obvious cheap probe, also exits 0. if _, err := runCmd(10*time.Second, "tesseract", "--list-langs"); err != nil { t.Fatalf("tesseract --list-langs failed with no language data (%v) — same "+ "note as above", err) } // The real thing does not. img := filepath.Join(t.TempDir(), "probe.png") if err := os.WriteFile(img, ocrProbePNG, 0o600); err != nil { t.Fatal(err) } if _, err := runCmd(20*time.Second, "tesseract", img, "-", "--psm", ocrProbePSM); err == nil { t.Fatal("tesseract OCR'd an image with NO language data available — the " + "empty TESSDATA_PREFIX did not take effect, so this test is not " + "measuring what it claims to") } } // A box with the engine and no language data must be BLOCKED-with-a-reason, not // silently absent. Those two are the same empty tool list to the host, they are // fixed by different commands, and telling a user "no machine of yours can OCR" // when tesseract is sitting on their disk sends them to install it twice. func TestNoLanguageDataReportsWhyRatherThanNothing(t *testing.T) { needOCR(t) t.Setenv("TESSDATA_PREFIX", t.TempDir()) ok, _, why := probePrepToolDetail(prepTool{ Name: "ocr", Bin: "tesseract", Args: []string{"--version"}, Prove: ocrProve}) if ok { t.Fatal("a machine with no language data was advertised as able to OCR") } if why == "" { t.Fatal("a machine with tesseract installed and no language data reported " + "NOTHING — indistinguishable from a machine with no tesseract, which " + "is the distinction this lane is required to make") } if !strings.Contains(strings.ToLower(why), "language") { t.Fatalf("the reason does not name the missing piece: %q", why) } // And it must reach the host: the scan report is where the distinction // travels, and a reason that never leaves the box is not a distinction. resetPrepCache() _, scan := scanPrepTools() found := false for _, b := range scan.Blocked { if b.Name == "ocr" && strings.Contains(strings.ToLower(b.Reason), "language") { found = true } } if !found { t.Fatalf("the blocked reason never made it into the scan report: %+v", scan.Blocked) } resetPrepCache() } // osd is orientation/script detection. It ships INSIDE the base engine package // and it cannot recognise a word. Counting it as a language would rebuild the // exact bug above: a box with the engine and no language pack would report one // language and still OCR nothing. func TestOSDIsNotCountedAsALanguage(t *testing.T) { needOCR(t) for _, l := range ocrLangs() { if l == ocrLangOSD { t.Fatal("osd is being reported as a recognition language — a machine " + "with only osd.traineddata would look language-equipped and read nothing") } } if ocrLang() == ocrLangOSD { t.Fatal("the box would try to OCR with osd") } } // ── end to end: a real image ─────────────────────────────────────────────── // THE ACCEPTANCE TEST. A real PNG of known words, base64 into a real // ctx_extract frame, through the real caps gate, out to real tesseract, and the // words come back. func TestOCRReadsARealImageThroughTheFrame(t *testing.T) { setupBox(t) needOCR(t) resetPrepCache() out, errOut, code := driveExtract(t, tAnchor, []string{extractReq(t, "ocr", ocrPNGB64)}) if code != 0 { t.Fatalf("ctx_extract ocr failed: %d %s", code, errOut) } r := extractResp(t, out) if !strings.Contains(r.Text, ocrFixtureLine1) { t.Fatalf("OCR did not return the words on the image.\nwant a line containing %q\ngot:\n%s", ocrFixtureLine1, r.Text) } if !strings.Contains(r.Text, ocrFixtureLine2) { t.Fatalf("OCR read only the top of the image — the second line is missing:\n%s", r.Text) } if r.Tool != "ocr" { t.Fatalf("the reply names the wrong job: %q", r.Tool) } if r.Lang == "" { t.Fatal("the reply does not say which language the engine read in — a wrong " + "transcription then has no visible first thing to check") } } // ── end to end: a real SCANNED PDF ───────────────────────────────────────── // THE NAMED CASE, and the outcome named as the worst one. // // First: prove the premise rather than assert it. pdf-text on this document // succeeds and returns NOTHING — a user whose scanned contract went down that // path would be told, by a job that exited 0, that their document has no words // on it. func TestPDFTextReturnsNothingForAScannedDocument(t *testing.T) { setupBox(t) if _, err := runCmd(5*time.Second, "pdftotext", "-v"); err != nil { t.Skip("pdftotext is not installed on this machine") } resetPrepCache() out, errOut, code := driveExtract(t, tAnchor, []string{extractReq(t, "pdf-text", scannedPDFB64)}) if code != 0 { t.Fatalf("pdf-text refused the scanned PDF (%d %s) — this fixture is supposed "+ "to be a VALID pdf that simply has no text layer", code, errOut) } r := extractResp(t, out) if strings.TrimSpace(strings.ReplaceAll(r.Text, "\f", "")) != "" { t.Fatalf("the scanned fixture has a text layer after all (%q) — it is not a "+ "scanned document and every test below it is measuring the wrong thing", r.Text) } } // And then the fix: the same bytes, asked for by the job that OCRs, come back // as the words that are visibly on the page. func TestPDFOCRReadsAScannedDocument(t *testing.T) { setupBox(t) needPDFOCR(t) resetPrepCache() out, errOut, code := driveExtract(t, tAnchor, []string{extractReq(t, "pdf-ocr", scannedPDFB64)}) if code != 0 { t.Fatalf("ctx_extract pdf-ocr failed: %d %s", code, errOut) } r := extractResp(t, out) if !strings.Contains(r.Text, ocrFixtureLine1) { t.Fatalf("a scanned PDF came back without the words on it.\nwant %q\ngot:\n%s", ocrFixtureLine1, r.Text) } // BOTH pages, so the loop is not stopping after the first. if strings.Count(r.Text, ocrFixtureLine2) < 2 { t.Fatalf("only one page of a two-page scan was read:\n%s", r.Text) } if r.Pages != 2 { t.Fatalf("pages=%d, want 2 — the reply must say how much of the document it read", r.Pages) } if r.PagesOCR != 2 { t.Fatalf("pages_ocr=%d, want 2 — both pages of this document have no text "+ "layer, so both must be recorded as engine-read", r.PagesOCR) } if r.PagesMore { t.Fatal("a two-page document was reported as having more pages than the cap") } if r.Deadline { t.Fatal("a two-page document ran out of time — the budget arithmetic is wrong") } } // A PDF THAT ALREADY HAS TEXT comes back as its own text, not as a // transcription of a picture of it. pdf-ocr's contract is "the text layer where // there is one, the engine where there is not". // // NOTE WHAT THIS FIXTURE IS AND IS NOT. Its single page carries 44 characters, // which is INSIDE ocrPageHasText's band, so this page does reach the engine for // a second opinion — the widened band admits it deliberately. What the // test pins is the outcome: the engine's reading is not a multiple longer than // the page's own 44 characters, so the document's text is what is kept and // pages_ocr stays 0. The case where a text page is never rendered at all is a // DIFFERENT one and is pinned by TestATextPDFRendersNoPages, against a document // with an ordinary page's worth of text on it. func TestPDFOCRUsesTheTextLayerWhenThereIsOne(t *testing.T) { setupBox(t) needPDFOCR(t) resetPrepCache() out, errOut, code := driveExtract(t, tAnchor, []string{extractReq(t, "pdf-ocr", probePDFB64)}) if code != 0 { t.Fatalf("ctx_extract pdf-ocr failed on a text PDF: %d %s", code, errOut) } r := extractResp(t, out) if !strings.Contains(r.Text, probePDFText) { t.Fatalf("the text layer was not returned:\n%s", r.Text) } if r.PagesOCR != 0 { t.Fatalf("pages_ocr=%d on a document whose every page has a text layer — the "+ "engine ran when it had nothing to add", r.PagesOCR) } if r.Pages != 1 { t.Fatalf("pages=%d, want 1", r.Pages) } } // A BLANK PAGE IS NOT A MISSING CAPABILITY. The third of the three answers: the // engine ran, it read every page, and the pages are empty. That has to come back // as a successful job that read N pages, not as a refusal and not as silence — // otherwise it is indistinguishable from a box with no OCR. func TestABlankScanReportsPagesReadNotAFailure(t *testing.T) { setupBox(t) needPDFOCR(t) resetPrepCache() blank := makeBlankScannedPDF(t) out, errOut, code := driveExtract(t, tAnchor, []string{extractReq(t, "pdf-ocr", blank)}) if code != 0 { t.Fatalf("a blank scanned page was reported as a FAILED job (%d %s) — it is a "+ "successful reading of a page with nothing on it", code, errOut) } r := extractResp(t, out) if strings.TrimSpace(r.Text) != "" { t.Fatalf("the engine invented text on a blank page: %q", r.Text) } if r.Pages < 1 || r.PagesOCR < 1 { t.Fatalf("a blank scan reported pages=%d pages_ocr=%d — with both at zero this "+ "is the same reply a box with no engine gives, which is the distinction "+ "this lane exists to make", r.Pages, r.PagesOCR) } } // makeBlankScannedPDF is a one-page PDF with a single white image on it and no // text layer — built here rather than embedded, because it is 3 lines of PDF and // a fixture nobody can regenerate is worse than one that costs nothing. func makeBlankScannedPDF(t *testing.T) string { t.Helper() // A 2x2 white 8-bit grayscale image, uncompressed, drawn across the page. pdf := "%PDF-1.4\n" + "1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n" + "2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n" + "3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] " + "/Resources << /XObject << /Im0 5 0 R >> >> /Contents 4 0 R >>\nendobj\n" + "4 0 obj\n<< /Length 42 >>\nstream\nq 200 0 0 200 0 0 cm /Im0 Do Q\nendstream\nendobj\n" + "5 0 obj\n<< /Type /XObject /Subtype /Image /Width 2 /Height 2 " + "/ColorSpace /DeviceGray /BitsPerComponent 8 /Length 4 >>\nstream\n" + "\xff\xff\xff\xff\nendstream\nendobj\n" + "trailer\n<< /Size 6 /Root 1 0 R >>\n%%EOF\n" return base64.StdEncoding.EncodeToString([]byte(pdf)) } // ── the cap, and the promise that it is never silent ─────────────────────── // A document longer than one job reads must SAY SO. Silently returning 30 pages // of a 44-page contract is the same failure as returning an empty scan: the user // has no way to know the rest exists. // // Driven at cap=1 against the real two-page scan rather than by building a // 31-page one: the cap is the only variable, every other decision in the run is // the one production takes, and the alternative costs 31 real OCR passes to // assert one boolean. func TestPageCapIsReportedNeverSilent(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) } text, pages, total, deadline, err := runPDFOCRCapped(dir, src, time.Now().Add(ocrExtractTimeout), 1) if err != nil { t.Fatalf("pdf-ocr failed: %v", err) } if deadline { t.Fatal("one page ran out of 150 s") } if len(pages) != 1 { t.Fatalf("read %d pages with the cap at 1", len(pages)) } if total >= 0 { t.Fatal("a document with a page past the cap was reported as complete — the " + "user would have no way to know the rest of their document exists") } if !strings.Contains(text, ocrFixtureLine1) { t.Fatalf("the one page it did read came back without its words:\n%s", text) } // And the page past the cap must not leak into the text: it was rendered to // answer "is there more" and OCRing it would return content from a page this // job is not claiming to have done. if strings.Count(text, ocrFixtureLine2) != 1 { t.Fatalf("page 2 was read despite the cap of 1:\n%s", text) } // A document SHORTER than the cap is reported complete, from the same run. _, _, total2, _, err := runPDFOCRCapped(dir, src, time.Now().Add(ocrExtractTimeout), 5) if err != nil { t.Fatal(err) } if total2 != 2 { t.Fatalf("a 2-page document under a cap of 5 reported total=%d, want 2", total2) } // Nothing of the user's document is left behind. left, _ := os.ReadDir(dir) for _, e := range left { if strings.HasPrefix(e.Name(), "ocrpages-") { t.Fatalf("the rasterized pages of the user's document were left on disk: %s", e.Name()) } } } // THE MECHANISM, asserted cheaply against both binaries that carry it. // // pdf-ocr asks for ONE PAGE MORE than it will read and uses the existence of // that page as the answer to "is there more". That request now rides the TEXT // LAYER pass, which costs 6 ms instead of the ~490 ms a rendered page costs, so // the properties the count depends on are pdftotext's: -l bounds it, asking past // the end is not an error, and every real page produces exactly one form feed — // for a scanned document too, whose pages are form feeds and nothing else. // // The render probe keeps its own half of this, because it is still what answers // the count when the layer pass will not (renderPageProbe). Both are driven here // against the real binaries and the real fixture. func TestPageCapMechanismAsksForOneMorePage(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) } // THE LAYER PASS, which is where the count comes from now. Asked for 1+1 // pages of a 2-page document it must answer 2 — and asked for far more than // the document has it must still answer 2 and still exit 0, which is what // makes one request serve both "how many" and "is there more". for _, ask := range []int{2, 31} { got, ok := pdfTextByPage(src, ask, time.Now().Add(60*time.Second)) if !ok { t.Fatalf("the layer pass refused a valid scanned document when asked for %d pages", ask) } if len(got) != 2 { t.Fatalf("pdftotext -f 1 -l %d reported %d pages of a 2-page scan, want 2 — "+ "the page count rides the form feeds of this pass", ask, len(got)) } } // A scanned page really does produce a form feed and no characters, which is // the property that lets the count come from a document with no text at all. if layer, _ := pdfTextByPage(src, 31, time.Now().Add(60*time.Second)); strings.TrimSpace(layer[0]) != "" { t.Fatalf("the scanned fixture's first page has text (%q) — the count and the "+ "per-page decision would be reading the same thing", layer[0]) } // THE RENDER PROBE, the fallback half: same cap+1 bound, and the page past // the cap is removed before anything can read it. pages, more, err := renderPageProbe(dir, src, 1, time.Now().Add(60*time.Second)) if err != nil { t.Fatalf("the render probe refused the fixture: %v", err) } if !more || len(pages) != 1 { t.Fatalf("the render probe returned %d page(s), more=%v, want 1 and true", len(pages), more) } left, _ := filepath.Glob(filepath.Join(dir, "probe-*.png")) if len(left) != 1 { t.Fatalf("%d rendered pages are on disk after a cap of 1 — the page past the "+ "cap exists only to answer \"is there more\" and OCRing it would return "+ "text from a page this job is not claiming to have done", len(left)) } // And past the end it produces nothing, which is how a document SHORTER than // the cap is correctly reported as complete. if _, moreThan5, err := renderPageProbe(dir, src, 5, time.Now().Add(60*time.Second)); err != nil || moreThan5 { t.Fatalf("a 2-page document under a cap of 5 reported more=%v (err=%v) — the "+ "more-pages signal would fire on every document", moreThan5, err) } } // ── budgets ──────────────────────────────────────────────────────────────── // The ceiling is DERIVED from the page cap and the measured per-page cost, not // chosen beside it. If someone raises the cap without touching the budget the // arithmetic still holds; if someone replaces the formula with a literal, this // is what notices. func TestOCRTimeoutIsDerivedFromThePageCap(t *testing.T) { want := ocrSetupBudget + ocrPageCap*ocrPerPageBudget if ocrExtractTimeout != want { t.Fatalf("ocrExtractTimeout is %s but the page cap and per-page budget say %s — "+ "a job can now be cut off mid-document by a number nobody re-derived", ocrExtractTimeout, want) } // And every OCR job must get more time than the default, or the whole point // of a per-tool ceiling is lost. if ctxExtractTimeoutFor("pdf-ocr") <= ctxExtractTimeout { t.Fatal("pdf-ocr gets no more time than pdf-text — every scanned document " + "over a few pages would be cut off") } if ctxExtractTimeoutFor("ocr") <= 0 { t.Fatal("ocr has no ceiling") } if ctxExtractTimeoutFor("pdf-text") != ctxExtractTimeout { t.Fatal("the default ceiling moved — the host mirrors it and would now give up first") } } // The advertised OCR jobs must carry the language list, because "which pack is // my machine using" is the first question a wrong transcription raises and the // answer must not require running anything. func TestOCRToolsAdvertiseTheirLanguages(t *testing.T) { setupBox(t) needOCR(t) resetPrepCache() tools, _, ok := prepCapable(tAnchor) if !ok { t.Skip("no prep tools on this machine") } for _, name := range []string{"ocr", "pdf-ocr"} { for _, tool := range tools { if tool.Name != name { continue } if len(tool.Langs) == 0 { t.Fatalf("%s is advertised with no language list", name) } for _, l := range tool.Langs { if l == ocrLangOSD { t.Fatalf("%s advertises osd as a language", name) } } } } resetPrepCache() } // ── the budget, which is why there is no table on the host ───────────────── // EVERY ADVERTISED JOB MUST DECLARE WHAT IT COSTS, because the host's dispatch // wait is built from that declaration and nothing else. A job advertised with no // ceiling would arrive at the host as 0 and fall to the host's guard value, // which is the text-layer number — and a job worth advertising is rarely a job // that finishes in twenty seconds. // // This is the gate that makes the design safe for tools that do not exist yet: // the speech-to-text and video-frame lanes being built alongside this one cannot // ship a job whose cost the host has to guess. func TestEveryAdvertisedToolDeclaresItsCeiling(t *testing.T) { setupBox(t) resetPrepCache() defer resetPrepCache() tools, _, _ := prepCapable(tAnchor) if len(tools) == 0 { t.Skip("no prep tools installed on this machine") } for _, tool := range tools { if tool.CeilingS <= 0 { t.Fatalf("%q is advertised with no ceiling — the host builds its dispatch "+ "wait from this number, so a job without one is a job the host has to "+ "guess the cost of", tool.Name) } // DECLARED == ENFORCED. Two literals would be the mirror again, one file // closer. The advertisement must be read from the same function the job // is actually run under. if want := ctxExtractTimeoutFor(tool.Name).Seconds(); tool.CeilingS != want { t.Fatalf("%q advertises %.0fs but is RUN under %.0fs — the declaration and "+ "the enforcement have to be one value", tool.Name, tool.CeilingS, want) } } } // THE GRANT IS CLAMPED DOWN, NEVER UP — the same rule ctxPullBudget follows, for // the same reason: the host's wait is the grant plus a round trip, so a box that // took a grant LARGER than its own ceiling would be working past the moment the // host gave up, and a box that let the host raise its ceiling could be parked by // a host for as long as that host liked. func TestExtractBudgetClampsDownNeverUp(t *testing.T) { own := ctxExtractTimeoutFor("pdf-ocr") if got := ctxExtractBudget("pdf-ocr", own.Seconds()*10); got != own { t.Fatalf("a host granting 10x got %s, want this box's own %s — a grant must "+ "never RAISE what this machine will spend", got, own) } if got := ctxExtractBudget("pdf-ocr", 5); got != 5*time.Second { t.Fatalf("a grant below the box's ceiling was ignored: got %s, want 5s", got) } // A HOST THAT PREDATES THE FIELD still works. It can only be asking for the // text-layer read, and refusing would break every bastion whose host has not // been updated. if got := ctxExtractBudget("pdf-text", 0); got != ctxExtractTimeout { t.Fatalf("a job from a host with no grant got %s, want the box's own %s", got, ctxExtractTimeout) } if got := ctxExtractBudget("pdf-text", -17); got != ctxExtractTimeout { t.Fatalf("a negative grant must fall back to the box's own ceiling, got %s", got) } } // AND THE GRANTED NUMBER REACHES THE RUNNER, proved end to end through a real // frame rather than by arithmetic that nothing calls. // // The proof is the box's own timeout sentence: it names the duration the job was // actually run under, so granting an absurdly small budget and reading that // number back is a direct observation of the wire field arriving, being clamped, // and bounding the work. Deterministic at 1 ms — no machine rasterizes and reads // a page in a millisecond — where a "grant less than it needs" test against a // small fixture would be a race (this fixture's whole pipeline measures 0.31 s). func TestGrantedBudgetBoundsARealJob(t *testing.T) { setupBox(t) if _, err := runCmd(5*time.Second, "pdftotext", "-v"); err != nil { t.Skip("pdftotext is not installed on this machine") } resetPrepCache() req, err := json.Marshal(ctxExtractReq{ Tool: "pdf-text", Mime: "application/pdf", B64: strings.Join(strings.Fields(probePDFB64), ""), ExtractBudget: 0.001}) if err != nil { t.Fatal(err) } start := time.Now() out, errOut, code := driveExtract(t, tAnchor, []string{string(req)}) elapsed := time.Since(start) if code == 0 { t.Fatalf("a job granted 1 ms returned a complete result in %s — the grant "+ "never reached the runner:\n%s", elapsed, out) } if !strings.Contains(errOut, "1ms") { t.Fatalf("the job was bounded by something OTHER than the granted 1 ms — the "+ "box reports the duration it actually ran under, and it is not the grant:\n%s", errOut) } // And it is a DEADLINE, not a rejected document: the two were being reported // as one, and the difference is whether the user goes looking at their file. if !strings.Contains(errOut, "still working") { t.Fatalf("running out of the granted time was reported as a bad document:\n%s", errOut) } if elapsed > 20*time.Second { t.Fatalf("a job granted 1 ms took %s to come back", elapsed) } } // The other direction, on the same path: a NORMAL grant — the one the host // actually sends, this box's own declared ceiling — runs the job to completion. // Without this the test above passes on a build where the grant is always tiny. func TestDeclaredCeilingAsAGrantRunsTheJob(t *testing.T) { setupBox(t) needPDFOCR(t) resetPrepCache() // Exactly what the host does: read the box's declared ceiling out of the // advertisement and hand that number back in the job. tools, _, _ := prepCapable(tAnchor) var declared float64 for _, tool := range tools { if tool.Name == "pdf-ocr" { declared = tool.CeilingS } } if declared <= 0 { t.Fatal("pdf-ocr is advertised with no ceiling for the host to grant") } req, err := json.Marshal(ctxExtractReq{ Tool: "pdf-ocr", Mime: "application/pdf", B64: strings.Join(strings.Fields(scannedPDFB64), ""), ExtractBudget: declared}) if err != nil { t.Fatal(err) } out, errOut, code := driveExtract(t, tAnchor, []string{string(req)}) if code != 0 { t.Fatalf("the job failed under the grant the host would actually send "+ "(%.0fs): %d %s", declared, code, errOut) } r := extractResp(t, out) if strings.Count(r.Text, ocrFixtureLine2) < 2 || r.Deadline { t.Fatalf("the document came back incomplete under a full grant "+ "(deadline=%v):\n%s", r.Deadline, r.Text) } } // ── the comparison the comment promised ──────────────────────────────────── // mixedLayerPDF is a one-page PDF carrying BOTH a short real text layer and a // picture of a different word, which is the exact shape the defect lives in. // // Built here rather than embedded because every byte of it is derived: the // picture IS ocrProbePNG, the same image the capability probe reads, decoded to // grayscale samples and flated into an image XObject; the text layer is thirteen // characters drawn in PDF text render mode 3, which is the invisible-text mode a // scanner's own OCR layer uses. // // The numbers are what make it a test rather than an illustration: // // text layer "REF 4471-XQ99" 13 chars <- under ocrPageHasText (16), so // the page goes to the engine // OCR "PRIVATAE" 8 chars <- non-empty, and FEWER // // Measured through the real binaries before it was written down: pdftotext // returns exactly those 13 characters and tesseract reads exactly that word. // Under the old code the 8 were kept and the 13 thrown away, with the page // recorded as pages_ocr=1 as though a choice had been made. func mixedLayerPDF(t *testing.T) string { t.Helper() img, err := png.Decode(bytes.NewReader(ocrProbePNG)) if err != nil { t.Fatalf("the embedded probe image does not decode: %v", err) } b := img.Bounds() w, h := b.Dx(), b.Dy() gray := make([]byte, 0, w*h) for y := b.Min.Y; y < b.Max.Y; y++ { for x := b.Min.X; x < b.Max.X; x++ { r, g, bl, _ := img.At(x, y).RGBA() gray = append(gray, byte((r*299+g*587+bl*114)/1000>>8)) } } var zb bytes.Buffer zw := zlib.NewWriter(&zb) if _, err := zw.Write(gray); err != nil { t.Fatal(err) } zw.Close() content := fmt.Sprintf("q 360 0 0 88 20 60 cm /Im0 Do Q\nBT /F1 10 Tf 3 Tr 20 20 Td (%s) Tj ET\n", mixedLayerText) 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 400 200] /Resources " + "<< /XObject << /Im0 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, zb.Len(), zb.String()), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", } 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 base64.StdEncoding.EncodeToString(pdf.Bytes()) } // The thirteen characters of the text layer, and the word the picture says. const mixedLayerText = "REF 4471-XQ99" // THE ASSERTION: the engine's output is kept only when it found MORE than // the text layer did. That sentence was in this lane's source as a promise, and // the code kept the engine's output whenever it was non-empty, comparing // nothing. A page with a short real reference code and a picture the engine // reads badly would lose the code — real characters of the user's document, // thrown away for a worse guess, with the record saying a page was OCR'd. func TestOCRIsKeptOnlyWhenItBeatsTheTextLayer(t *testing.T) { needPDFOCR(t) if len(mixedLayerText) != 13 { t.Fatalf("the fixture's text layer is %d chars; the test needs it under "+ "ocrPageHasText (%d) and longer than the word the picture says", len(mixedLayerText), ocrPageHasText) } dir := t.TempDir() src := filepath.Join(dir, "mixed.pdf") raw, err := base64.StdEncoding.DecodeString(mixedLayerPDF(t)) if err != nil { t.Fatal(err) } if err := os.WriteFile(src, raw, 0o600); err != nil { t.Fatal(err) } // The two sides, measured through the real binaries rather than assumed, so // a failure below says which half moved. layer, layerRead := pdfTextByPage(src, 1, time.Now().Add(30*time.Second)) if !layerRead || len(layer) == 0 || strings.TrimSpace(layer[0]) != mixedLayerText { t.Skipf("this poppler does not read the fixture's invisible text layer as %q "+ "(got %q, read=%v) — the fixture, not the code, is what changed", mixedLayerText, layer, layerRead) } text, pages, _, deadline, err := runPDFOCRCapped(dir, src, time.Now().Add(ocrExtractTimeout), 1) if err != nil { t.Fatalf("pdf-ocr failed on the mixed page: %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, mixedLayerText) { t.Fatalf("the engine's shorter reading REPLACED the page's real text layer.\n"+ " the document states %q (%d chars)\n"+ " what was kept instead: %q\n"+ "pdf-ocr's contract is that OCR is kept only when it found MORE than the "+ "text layer did; the user's own characters were discarded for the engine's "+ "guess", mixedLayerText, len(mixedLayerText), strings.TrimSpace(text)) } if pages[0].How != "text" { t.Fatalf("the page was recorded as how=%q after the text layer won — the "+ "record has to say which of the two was actually kept", pages[0].How) } } // THE OTHER DIRECTION, from the same mechanism: where the engine DOES find more // than the text layer, its reading is what is kept. A comparison that always // preferred the layer would silently turn pdf-ocr back into pdf-text. func TestOCRStillWinsWhenItFindsMoreThanTheLayer(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) } text, pages, _, _, err := runPDFOCRCapped(dir, src, time.Now().Add(ocrExtractTimeout), 2) if err != nil { t.Fatal(err) } if !strings.Contains(text, ocrFixtureLine1) { t.Fatalf("a scan with no text layer came back without its words:\n%s", text) } for _, p := range pages { if p.How != "ocr" { t.Fatalf("page %d of a document with NO text layer was recorded as how=%q", p.Page, p.How) } } } // ── the rotated scan ─────────────────────────────────────────────────────── // A real image-only PDF of a six-line letter page, SCANNED UPSIDE DOWN. Its text // layer is exactly zero characters, so every page of it goes to the engine. // // Regenerate with the two published commands and nothing else — the -dAutoRotate // Pages=/None matters, because without it ps2pdf quietly turns the page the right // way up again and the fixture stops testing anything: // // ps2pdf -dAutoRotatePages=/None rot.ps rot.pdf // gs -q -dNOPAUSE -dBATCH -sDEVICE=pdfimage8 -r100 -sOutputFile=rotscan.pdf rot.pdf // // where rot.ps draws the six lines below after `306 396 translate 180 rotate // -306 -396 translate`. // // MEASURED at 300 dpi: the default page-segmentation mode reads it as // "'y yBnoswuy) ind sem yeu] JusuNDOP Bul", and --psm 1 reads it exactly. const rotatedScanPDFB64 = "" + "JVBERi0xLjMKJcfsj6IKJSVJbnZvY2F0aW9uOiBncyAtcSAtZE5PUEFVU0UgLWRCQVRDSCAtc0RFVklDRT1wZGZpbWFnZTggLXIx" + "MDAgLXNPdXRwdXRGaWxlPT8gPwo0IDAgb2JqCjw8Ci9MZW5ndGggNSAwIFIKL1N1YnR5cGUgL0ltYWdlCi9XaWR0aCA4MjYKL0hl" + "aWdodCAxMTY5Ci9Db2xvclNwYWNlIC9EZXZpY2VHcmF5Ci9CaXRzUGVyQ29tcG9uZW50IDgKL0ZpbHRlciAvRmxhdGVEZWNvZGUK" + "Pj4Kc3RyZWFtCnic7dzRdqO4EgVQ/v+nde8kMVSVShg77kymvfdDGoOQRC8dg2zDGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "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" + "Dh9vv2V+EpPTzXPiA2zn5Gyp2rTh9na/jWbPKTnbVMd8tZZ7Hmo/OlGO8e7Td/PMq8YizpYk572loTbGnoflOWd/zz7ek5tzTrch" + "pOrSOacNV0rO1PPpgLZcMnX+pKPzmbHWKDnvrg6/2+i8mJzyDnx3njP2FyMX6JLTvb1PFe+NlpexrXDOmJJzr6PN/5HkMMZoxu1y" + "PN8itV/KrK5d2g37qy5zd/eckjNSl3Lt+VhyyXQdNp3DSvmT/x3JeXPlK5D9b/tNRh58W9h7ni9NG8LLWP80Oo9JSVkqi/NCPZi5" + "ZOr8qqO1wv77nHzIkvN+0oDP6+OAWJQa+xCrJW5XP2XDMdZWzYYtcXzPHdnmuppZTi7Zt7guPx3UlneUHFqXB8RyZJ4M2ZeU/5dJ" + "Dq1XJudaJv4TyZkP6r/Qa36Q5LTSQW2Hf7NPAAAAAAAAAAAAPOo1v/vy6zHezGt+Mel3l7wZZxx4huTAKE8dSHf1xxut6w7lVv/p" + "2QX5sQX5iQK12eM+71vxo1DqSH4mx0fBucD0cIJ5ldDyAvGhHeP27IyRR3/zzIAy2tPCKjnzUw66XMSHGoypspycrrW5P6v64Rvq" + "uBvjeLDO7RSQkhOfX5XCtu82j+U5OekpWGnY15tTT5NTepqXmo1Hc/A9x4CKz9EdOTl5zKdXzYCe0/jZyFzLdGLqPnJ7MDlHx/LC" + "/EBr+Ia7yQkFxnFSKDOUfd1RZ0nOtq1r2XfKyTnauJOc7Tw5oa9mObzK/eTUtXHNbbTfxmM389hPONs21ZJyMuqa3JGnzzl5lezw" + "EvfmOc2kvexSJidTgTk5pZawcvzZec7RRfie6ZPlsZ9IriZnLjmP4G2vPO2WPhvYQvmj2P3kzAVCPc1G5xy+LV7+f46sbdtGmBWE" + "d+7wb9wlBSavulU6Yi76WuJ8Jp508vcwc3LmAmNeaJfgG9Ig/fw3vbq48/G2vpXtd+uJWTtrY54Rja/LxG0q0Kxr64HXeXJc3R35" + "rzTnAf5taSxeH5nbYr+Xj+3uEwL4ZSQHAAAAAAAAAACAx/hlFzzB3SjwBLmBZ0gONDfTN4+tuD1a7HZTdNn7eLxGvll/v/v4KB7u" + "gomdGKGJ5jac2H6pILU0Rmmy3nqdb8suvZ1qLv2oh136OHgny+e81FVbKJyGUChbh3ZdyPePxV6k/syJi+VyBbVL15Nze79outFU" + "kjbHRiXnTXXJWTzcbNwGaxnoMTnHuhHG0kuTk8d3LZrDOj3Go/b8yeSkNprj4W1saWlOQszE+ql/OTnzVdfyPXqRnL6bsYI5MXNF" + "bXJOu7FKTHtckvOejiv8eK3/NdtIV2tl2233o0QZqWnALqcpdTnNO+bexsIXkjN3JHd80Y1wWTZPfySHfxxvq/ENtvmsYIt7XEhO" + "GvfdtdNpcqYaUudCR5bJGd0HGg8npytaqlufIfmbLS7I5nNO2mGbx1adKuWBdJwstkN4Vfuz/9lXp4+z1mewdKJLPS6fwaUd+pNL" + "7UefnPUZkr9Zm5wuCanYheTMzTTv4HsWlv0p1cQa7p9zYpVl86VzTn3TmPZwznljcQIT38jjO3Xa+LnXvvft73JeNGKJKTkjVXcl" + "OW0Ff+ZqbZGcfrPkvJ3mEmYcQ/jIyPn1yJ2rlW3P3JXkhHlXreVqcpqs1s2l6TZ+5UWuT3KYPXrdfi857Xfu6+T0Ob2enMWUa2ro" + "XnLSibPu2vaAN/dDyckXf1eSc22eczc5t7afSs6W/pmq5p29ODn5jfpj4fNV+9larW5L5RafrcVP7WI1cca2zZXm6mMloR9ldf4k" + "zmdrAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/OX+" + "ByFeI8IKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjM4OTUKZW5kb2JqCjYgMCBvYmoKPDwKL0ZpbHRlci9GbGF0ZURlY29kZS9M" + "ZW5ndGggOCAwIFIKPj4Kc3RyZWFtCnicM7U00TM3MgACBRC0MDHUM7OAc5NzufQ9cw0VXPIBnQ4INgplbmRzdHJlYW0KZW5kb2Jq" + "CjggMCBvYmoKMzYKZW5kb2JqCjcgMCBvYmoKPDwKL0NvbnRlbnRzIDYgMCBSCi9UeXBlIC9QYWdlCi9QYXJlbnQgMiAwIFIKL01l" + "ZGlhQm94IFswIDAgNTk0LjcyMDAwMCA4NDEuNjgwMDAwXQovUmVzb3VyY2VzIDw8Ci9YT2JqZWN0IDw8Ci9JbTEgNCAwIFIKPj4K" + "Pj4KPj4KZW5kb2JqCjEgMCBvYmoKPDwKL1BhZ2VzIDIgMCBSCi9UeXBlIC9DYXRhbG9nCi9JbmZvIDMgMCBSCj4+CmVuZG9iagoy" + "IDAgb2JqCjw8Ci9Db3VudCAxCi9LaWRzIFs3IDAgUiBdCi9UeXBlIC9QYWdlcwo+PgplbmRvYmoKMyAwIG9iago8PAovUHJvZHVj" + "ZXIoR1BMIEdob3N0c2NyaXB0IDEwLjA2LjApCi9DcmVhdGlvbkRhdGUoRDoyMDI2MDkxMzAzMzk1NFowMCcwMCcpCj4+CmVuZG9i" + "agp4cmVmCjAgOQowMDAwMDAwMDAwIDY1NTM2IGYgCjAwMDAwMDQ0MzggMDAwMDAgbiAKMDAwMDAwNDQ5OSAwMDAwMCBuIAowMDAw" + "MDA0NTU3IDAwMDAwIG4gCjAwMDAwMDAwOTUgMDAwMDAgbiAKMDAwMDAwNDE0OCAwMDAwMCBuIAowMDAwMDA0MTY4IDAwMDAwIG4g" + "CjAwMDAwMDQyOTQgMDAwMDAgbiAKMDAwMDAwNDI3NiAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDkKL1Jvb3QgMSAwIFIKL0lE" + "IFs8QzE5ODYxNUQ2QjE5NTY0NDI4NjUwMDUyMzFGNjMzQjY+PEMxOTg2MTVENkIxOTU2NDQyODY1MDA1MjMxRjYzM0I2Pl0KPj4K" + "c3RhcnR4cmVmCjQ2NTIKJSVFT0YK" // The first line of the upside-down page. const rotatedFixtureLine1 = "ROTATED FIXTURE ALPHA zulu yankee" // A 180-DEGREE PAGE IS STILL THE USER'S DOCUMENT. A duplex feeder puts pages // through the other way up routinely, and before the orientation pass this lane // wrote the engine's garbled reading of one straight into the corpus with // nothing in the reply or the rows saying anything was wrong. Garbage that // claims to be a document is worse than a document that says it could not be // read. func TestAnUpsideDownScanIsReadNotIngestedAsGarbage(t *testing.T) { needPDFOCR(t) dir := t.TempDir() src := filepath.Join(dir, "rot.pdf") raw, err := base64.StdEncoding.DecodeString(strings.Join(strings.Fields(rotatedScanPDFB64), "")) if err != nil { t.Fatal(err) } if err := os.WriteFile(src, raw, 0o600); err != nil { t.Fatal(err) } // It really is a scan: no text layer at all, so nothing but the engine can // read it and the comparison above cannot be what makes this pass. if layer, _ := pdfTextByPage(src, 1, time.Now().Add(30*time.Second)); len(layer) > 0 && strings.TrimSpace(layer[0]) != "" { t.Fatalf("the rotated fixture has a text layer (%q) — it is not a scan and "+ "this test would prove nothing", layer[0]) } text, pages, _, deadline, err := runPDFOCRCapped(dir, src, time.Now().Add(ocrExtractTimeout), 1) if err != nil { t.Fatalf("pdf-ocr failed on an upside-down 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, rotatedFixtureLine1) { t.Fatalf("an upside-down scan went into the corpus as garbage.\n"+ " want the page's real words: %q\n"+ " what the engine returned: %q\n"+ "tesseract corrects this itself when it is asked to detect orientation; "+ "without that the user's document is stored as nonsense and nothing says so", rotatedFixtureLine1, strings.TrimSpace(text)) } } // AND THE UPRIGHT PAGE MUST NOT MOVE. The orientation pass is only worth having // if it cannot damage the common case, so this pins the pages that read fine // today against the exact same bytes. func TestOrientationDetectionDoesNotChangeAnUprightPage(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) } text, _, _, _, err := runPDFOCRCapped(dir, src, time.Now().Add(ocrExtractTimeout), 2) if err != nil { t.Fatal(err) } for _, want := range []string{ocrFixtureLine1, ocrFixtureLine2} { if !strings.Contains(text, want) { t.Fatalf("an upright scan lost %q once orientation detection was asked "+ "for:\n%s", want, text) } } }