<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mykola Melnyk</title>
    <description>The latest articles on DEV Community by Mykola Melnyk (@mykola_melnyk_ml).</description>
    <link>https://dev.to/mykola_melnyk_ml</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4082372%2F697b1ae8-84e9-4705-884a-5142627d34c1.jpg</url>
      <title>DEV Community: Mykola Melnyk</title>
      <link>https://dev.to/mykola_melnyk_ml</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mykola_melnyk_ml"/>
    <language>en</language>
    <item>
      <title>Reading the results: text, boxes, entities and how they line up</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Sat, 05 Sep 2026 18:12:17 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/reading-the-results-text-boxes-entities-and-how-they-line-up-5cog</link>
      <guid>https://dev.to/mykola_melnyk_ml/reading-the-results-text-boxes-entities-and-how-they-line-up-5cog</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizxndecr0s3ep2kd2gbz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fizxndecr0s3ep2kd2gbz.png" alt="Reading the results: text, boxes, entities" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written 2026-09-05. Targets &lt;code&gt;@stabrise/scaledp@0.1.1&lt;/code&gt;. Assumes you have run a pipeline; assumes nothing about the schemas.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;code&gt;transform()&lt;/code&gt; returns plain objects. Four schemas cover everything — &lt;code&gt;ScaleDpImage&lt;/code&gt;, &lt;code&gt;Document&lt;/code&gt;, &lt;code&gt;DetectorOutput&lt;/code&gt;, &lt;code&gt;NerOutput&lt;/code&gt; — every one carries an &lt;code&gt;exception&lt;/code&gt; field, and every entity carries the boxes its characters fell inside. That last property is what lets you draw a redaction rectangle over a name the model found in a string.&lt;/p&gt;

&lt;h2&gt;
  
  
  A row is just an object
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;span class="c1"&gt;// [ 'path', 'page', 'image', 'text', 'ner', 'row_time', 'execution_time' ]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no result class, no accessor API, no &lt;code&gt;.get()&lt;/code&gt;. A stage reads a named field and writes another one, and what you get back is whatever the stages wrote. &lt;code&gt;path&lt;/code&gt; and &lt;code&gt;page&lt;/code&gt; come along from the reader; &lt;code&gt;row_time&lt;/code&gt; and &lt;code&gt;execution_time&lt;/code&gt; are added by the runner.&lt;/p&gt;

&lt;p&gt;That means destructuring works, &lt;code&gt;JSON.stringify&lt;/code&gt; works, and passing a row to another function does not require importing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four schemas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;ScaleDpImage&lt;/code&gt; — a rendered page
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ScaleDpImage&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;      &lt;span class="c1"&gt;// DPI it was rendered at; 0 when unknown&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;        &lt;span class="c1"&gt;// encoded bytes, PNG unless imageType says otherwise&lt;/span&gt;
  &lt;span class="nx"&gt;imageType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Encoded bytes, not raw pixels — so &lt;code&gt;row.image.data&lt;/code&gt; is a PNG you can hand to &lt;code&gt;createImageBitmap&lt;/code&gt; or a &lt;code&gt;Blob&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;Document&lt;/code&gt; — text plus the boxes it came from
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Document&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;            &lt;span class="c1"&gt;// 'ocr' | 'pdf' | an engine name&lt;/span&gt;
  &lt;span class="nx"&gt;bboxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;text&lt;/code&gt; is the whole page as one string. &lt;code&gt;bboxes&lt;/code&gt; is every word (or region — see &lt;code&gt;boxLevel&lt;/code&gt;) with its geometry &lt;em&gt;and&lt;/em&gt; its own text.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;DetectorOutput&lt;/code&gt; — boxes with no text
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;DetectorOutput&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;            &lt;span class="c1"&gt;// 'dbnet-onnx' | 'paddle' | 'yolo' | …&lt;/span&gt;
  &lt;span class="nx"&gt;bboxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference from &lt;code&gt;Document&lt;/code&gt; is exactly the absence of &lt;code&gt;text&lt;/code&gt;. A detector found regions; nobody has read them yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;NerOutput&lt;/code&gt; — entities
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Entity&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;entity_group&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;           &lt;span class="c1"&gt;// character offset into the document text&lt;/span&gt;
  &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;            &lt;span class="c1"&gt;// the boxes those characters fall inside&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;model&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;propagated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The &lt;code&gt;Box&lt;/code&gt; convention, which is not what you expect
&lt;/h2&gt;

&lt;p&gt;This one catches people, so it is worth stating precisely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Box&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;       &lt;span class="c1"&gt;// top-left of the AXIS-ALIGNED box of the same size,&lt;/span&gt;
  &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;       &lt;span class="c1"&gt;// centred on the ROTATED rect's centre&lt;/span&gt;
  &lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;   &lt;span class="c1"&gt;// always the LONGER side&lt;/span&gt;
  &lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="nx"&gt;angle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;   &lt;span class="c1"&gt;// degrees about that same centre, normalised to (-90, 270]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A &lt;code&gt;Box&lt;/code&gt; is not xyxy and it is not a polygon.&lt;/strong&gt; For an unrotated box, &lt;code&gt;x, y, width, height&lt;/code&gt; behave exactly as you would hope and &lt;code&gt;ctx.strokeRect(box.x, box.y, box.width, box.height)&lt;/code&gt; is correct. For a rotated one it is not — you need the centre and the angle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;strokeBox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CanvasRenderingContext2D&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;

  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;translate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;angle&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PI&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strokeRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;restore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two consequences of "&lt;code&gt;width&lt;/code&gt; is always the longer side" worth knowing: a genuinely tall, narrow region is reported as a wide box rotated 90°, and that is correct rather than a bug. And coordinates are in &lt;strong&gt;the rendered page's pixel space&lt;/strong&gt; — the same space &lt;code&gt;PdfToImage&lt;/code&gt; produced — so they line up with &lt;code&gt;row.image&lt;/code&gt; without conversion. That is a deliberate divergence from the Python library, which leaves text-layer boxes in PDF points.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F22jejszi7m08la3r6cu2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F22jejszi7m08la3r6cu2.png" alt="A Box stores the centre, the size and an angle, not two corners, and width is always the longer side" width="800" height="477"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Entities carry their boxes, and that is the useful part
&lt;/h2&gt;

&lt;p&gt;An NER model operates on a string. It returns character offsets. On its own that gives you a table, not a redaction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entity_group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;strokeBox&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// draw it on the page&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;person       Marta Feldmann        0.99
email        accounts@acme.de      0.97
iban         DE89 3704 0044 0532…  0.94
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting from &lt;code&gt;start&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt; to &lt;code&gt;boxes&lt;/code&gt; is not trivial and is worth knowing about, because it was a real bug. The text was reconstructed in reading order — boxes clustered by y, then sorted by x — while &lt;code&gt;bboxes&lt;/code&gt; keeps the detector's order. A forward-only cursor walking the boxes therefore found that boxes routinely sat &lt;em&gt;behind&lt;/em&gt; it, gave up, and left the entity with &lt;strong&gt;no boxes at all&lt;/strong&gt;: listed in the table, invisible on the page. For a redaction tool that is the worst possible failure, since nothing looks wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F74yfpmjusxskk5m52824.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F74yfpmjusxskk5m52824.png" alt="An entity carries character offsets into the page text, which map back to the boxes those characters fell inside" width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you need this machinery yourself, it is exported:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;buildCharToBoxMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boxesForRange&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/ner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://scaledp-ts.stabrise.com/demo" rel="noopener noreferrer"&gt;builder&lt;/a&gt; renders &lt;code&gt;bboxes&lt;/code&gt; directly, which is the fastest way to see what the schema actually contains:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwy8va46t4fxd7x0weab1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwy8va46t4fxd7x0weab1.png" alt="A table of recognised words with score, x, y, width, height and angle for each: ScaleDP 0.948, scalable 0.997, info@stabrise.com 1.000" width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One row per &lt;code&gt;Box&lt;/code&gt;. The text it carries, the score the model gave it, and the geometry — &lt;code&gt;x&lt;/code&gt;, &lt;code&gt;y&lt;/code&gt;, &lt;code&gt;w&lt;/code&gt;, &lt;code&gt;h&lt;/code&gt;, &lt;code&gt;angle&lt;/code&gt;. Note &lt;code&gt;info@stabrise.com&lt;/code&gt; at &lt;code&gt;1.000&lt;/code&gt; and the &lt;code&gt;ScaleDP&lt;/code&gt; wordmark at &lt;code&gt;0.948&lt;/code&gt;: confidence is per box, so you can threshold on it per box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Displaying it
&lt;/h2&gt;

&lt;p&gt;Four helpers, all returning a DOM element rather than a string, all mirroring ScaleDP's notebook helpers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;renderInto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;showImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;showText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;showNer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;visualizeNer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;showBoxes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/display&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;renderInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="nf"&gt;showImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;renderInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="nf"&gt;showText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;              &lt;span class="c1"&gt;// layout-preserving&lt;/span&gt;
&lt;span class="nf"&gt;renderInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#entities&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;showNer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ner&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                &lt;span class="c1"&gt;// a table&lt;/span&gt;
&lt;span class="nf"&gt;renderInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#inline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="nf"&gt;visualizeNer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ner&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;// highlighted in the text&lt;/span&gt;
&lt;span class="nf"&gt;renderInto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#boxes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="nf"&gt;showBoxes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;             &lt;span class="c1"&gt;// geometry, first 20&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things they do that are easy to miss:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;showText&lt;/code&gt; renders monospace, and that is load-bearing.&lt;/strong&gt; Layout-preserving text encodes the page's layout in spaces and blank lines. In a proportional font the columns do not line up and it looks like broken output rather than preserved layout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failures render as failures.&lt;/strong&gt; Every helper checks &lt;code&gt;exception&lt;/code&gt; first and returns a red error block. A stage that failed shows up as a visible error rather than an empty panel, which is the difference between "something went wrong" and "there was nothing on the page".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;visualizeNer&lt;/code&gt; drops overlapping entities rather than nesting them&lt;/strong&gt; — highest score wins — because two spans cannot occupy the same characters in a flat text run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Annotating the page is a stage, not a helper
&lt;/h2&gt;

&lt;p&gt;Drawing boxes onto the image is a pipeline stage, as in ScaleDP. The annotated page is just another image column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ImageDrawBoxes&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ImageDrawBoxes&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;inputCols&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;outputCol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;annotated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat: &lt;strong&gt;&lt;code&gt;image_with_boxes&lt;/code&gt; is an &lt;code&gt;Image&lt;/code&gt;, so it typechecks everywhere an image does&lt;/strong&gt; — including as the input to a detector. Wiring a detector after an annotation pass means it will faithfully detect the rectangles and label text you just drew. The stage catalogue marks &lt;code&gt;ImageDrawBoxes&lt;/code&gt; as terminal for this reason, rather than forbidding it, because chaining two annotation passes for two colours is a genuinely useful idiom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plain objects mean no runtime validation.&lt;/strong&gt; You get TypeScript types and nothing else. A typo in a column name produces &lt;code&gt;undefined&lt;/code&gt;, not an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;bboxes&lt;/code&gt; can be large.&lt;/strong&gt; A dense page at word level is hundreds of boxes per page; forty pages is tens of thousands of objects. Drop columns you have consumed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The display helpers are deliberately minimal.&lt;/strong&gt; They exist so you can see a result in three lines, not so you can ship a viewer. For anything real, read the schemas and render them yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @stabrise/scaledp pdfjs-dist onnxruntime-web ppu-paddle-ocr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://scaledp-ts.stabrise.com/demo?p=W3sidHlwZSI6IlBkZlRvSW1hZ2UiLCJvcHRpb25zIjp7InJlc29sdXRpb24iOjIwMH19LHsidHlwZSI6IlBhZGRsZVRleHRSZWNvZ25pemVyIiwib3B0aW9ucyI6eyJrZWVwRm9ybWF0dGluZyI6dHJ1ZX19LHsidHlwZSI6IkltYWdlRHJhd0JveGVzIiwib3B0aW9ucyI6eyJpbnB1dENvbHMiOlsiaW1hZ2UiLCJ0ZXh0Il0sIm91dHB1dENvbCI6ImFubm90YXRlZCIsImNvbG9yIjoiIzNmYzlmNSIsImxpbmVXaWR0aCI6Mn19XQ" rel="noopener noreferrer"&gt;Open this pipeline in the builder&lt;/a&gt;&lt;/strong&gt; and switch the result panel between &lt;strong&gt;Text&lt;/strong&gt; and &lt;strong&gt;Boxes&lt;/strong&gt;. Those two views are &lt;code&gt;row.text.text&lt;/code&gt; and &lt;code&gt;row.text.bboxes&lt;/code&gt; — the same two fields this post has been describing, rendered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/schemas" rel="noopener noreferrer"&gt;Schemas&lt;/a&gt; — every field of all five&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/api/display" rel="noopener noreferrer"&gt;Display helpers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/columns" rel="noopener noreferrer"&gt;Columns are the wiring&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/stages/transform/image-draw-boxes" rel="noopener noreferrer"&gt;&lt;code&gt;ImageDrawBoxes&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;Repo: StabRise/scaledp-ts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ocr</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The same pipeline, minus the cluster</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Sat, 05 Sep 2026 18:11:36 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/the-same-pipeline-minus-the-cluster-19k8</link>
      <guid>https://dev.to/mykola_melnyk_ml/the-same-pipeline-minus-the-cluster-19k8</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5551fy3rzmqsj5nax4ng.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5551fy3rzmqsj5nax4ng.png" alt="The same pipeline, minus the cluster" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written 2026-09-05. Compares &lt;code&gt;@stabrise/scaledp@0.1.1&lt;/code&gt; with &lt;a href="https://github.com/StabRise/ScaleDP" rel="noopener noreferrer"&gt;ScaleDP&lt;/a&gt; on Python/Spark. Assumes you can read both languages; assumes no Spark expertise.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; The browser library mirrors the Python one stage for stage — same names, same parameters, same schemas — so porting a pipeline is mechanical rather than a rewrite. What does not carry over is Spark, and the surprising part of the port was how little of Spark was actually load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same pipeline, twice
&lt;/h2&gt;

&lt;p&gt;Python, on Spark:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PipelineModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;PdfDataToImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolution&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;TesseractOcr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputCol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outputCol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepFormatting&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Ner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputCol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outputCol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TypeScript, in a tab:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PdfToImage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TesseractOcr&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;inputCol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outputCol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keepFormatting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GlinerNer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;inputCol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outputCol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parameter names match because they are the same parameters. &lt;code&gt;resolution&lt;/code&gt;, &lt;code&gt;inputCol&lt;/code&gt;, &lt;code&gt;outputCol&lt;/code&gt;, &lt;code&gt;keepFormatting&lt;/code&gt; — none of these were renamed to feel more idiomatic in TypeScript, and that was a deliberate choice. A pipeline should read the same in both, so that porting a notebook is a transcription job and not a design job.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4aelparfadfevn0odm0e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4aelparfadfevn0odm0e.png" alt="The same three stages with the same parameter names, once on Spark and once in a browser tab" width="800" height="250"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually different
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Options objects instead of positional column arguments.&lt;/strong&gt; The column names are still options with the same defaults.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rows, not a DataFrame.&lt;/strong&gt; &lt;code&gt;transform&lt;/code&gt; returns &lt;code&gt;Row[]&lt;/code&gt; — plain objects. A stage that produces several rows per input (a PDF becoming one row per page) does exactly what &lt;code&gt;posexplode&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything is async.&lt;/strong&gt; Model loading, decoding and inference are all promises. In Python they are blocking calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Different NER architecture.&lt;/strong&gt; Python's &lt;code&gt;Ner&lt;/code&gt; is BERT token classification with a fixed tag set. &lt;code&gt;GlinerNer&lt;/code&gt; is GLiNER — zero-shot, where the entity labels are the prompt. That is a genuine capability difference, not a naming one: adding a new entity type is an edit rather than a training run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that surprised us
&lt;/h2&gt;

&lt;p&gt;Going in, "port a Spark library to the browser" sounds like it means replacing a distributed execution engine. It did not, and the reason is worth writing down because it generalises.&lt;/p&gt;

&lt;p&gt;Reading ScaleDP's stages, two things stood out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every stage is a pure &lt;code&gt;Transformer&lt;/code&gt;.&lt;/strong&gt; There are no &lt;code&gt;Estimator&lt;/code&gt;s anywhere in the library, so there is no &lt;code&gt;fit()&lt;/code&gt;/&lt;code&gt;transform()&lt;/code&gt; duality to model. A stage is a function from a row to a row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The only DataFrame surface stages use is &lt;code&gt;withColumn&lt;/code&gt;, &lt;code&gt;drop&lt;/code&gt; and &lt;code&gt;select&lt;/code&gt;.&lt;/strong&gt; No joins, no groupBy, no window functions, no UDF registration beyond the obvious.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those two facts together mean the Spark coupling is one abstraction thick. Strip it and what remains is: an ordered array of stages, each reading a named field and writing a named field, over an array of plain objects.&lt;/p&gt;

&lt;p&gt;That is the entire runner, and it is 215 lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Stage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;P&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;BaseStageParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BaseStageParams&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;

  &lt;span class="cm"&gt;/** Transform one row's input value into this stage's output value. */&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StageContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="cm"&gt;/** Value written to `outputCol` when `apply` throws. */&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StageContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// column wiring, error capture, keepInputData and timing, once, for everyone&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generalisable lesson: &lt;strong&gt;audit the surface a framework's users actually touch before assuming you need the framework.&lt;/strong&gt; ScaleDP looked Spark-shaped from the outside. From the inside, Spark was providing partitioning and shuffles — neither of which a single-document browser pipeline needs — plus a &lt;code&gt;withColumn&lt;/code&gt; API that is three lines of object spread.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Spark was genuinely providing
&lt;/h2&gt;

&lt;p&gt;Not nothing. Being honest about this matters, because it is exactly the list of things the browser version cannot do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Horizontal scale.&lt;/strong&gt; Forty executors reading forty PDFs. One tab is one machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spill to disk.&lt;/strong&gt; A dataset larger than memory. The browser pipeline holds its rows in memory and that is that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fault tolerance across a cluster.&lt;/strong&gt; A lost executor is retried. A crashed tab is a crashed tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your workload needs any of those, the Python library is the answer and the pipeline you wrote for the browser will port back almost unchanged. That symmetry is the point of matching the names.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxifl7mu4wcrza10sfpl7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxifl7mu4wcrza10sfpl7.png" alt="Horizontal scale, spill to disk and fault tolerance are gone; the stage model and column wiring carry over" width="800" height="267"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What carries over unchanged
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The non-throwing error contract.&lt;/strong&gt; Failures land in the output schema's &lt;code&gt;exception&lt;/code&gt; field and the pipeline completes. &lt;code&gt;propagateError&lt;/code&gt; opts into throwing. Identical in both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;Box&lt;/code&gt; convention.&lt;/strong&gt; &lt;code&gt;x&lt;/code&gt;/&lt;code&gt;y&lt;/code&gt; is the top-left of the axis-aligned box of the same size centred on the rotated rect's centre, &lt;code&gt;angle&lt;/code&gt; is degrees about that centre, and &lt;code&gt;width&lt;/code&gt; is always the longer side. Getting this wrong shifts every downstream consumer silently, so it is reproduced exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layout-preserving text reconstruction&lt;/strong&gt; under &lt;code&gt;keepFormatting&lt;/code&gt;, including the per-line indent and blank-line rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parameter names and defaults&lt;/strong&gt;, wherever a Python equivalent exists.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last two are not asserted, they are tested. &lt;code&gt;test/fixtures/*.py&lt;/code&gt; run the real ScaleDP code and write JSON goldens; the parity suites diff against them. Verifying a port against its own expectations just encodes your own misunderstanding twice.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://scaledp-ts.stabrise.com/demo" rel="noopener noreferrer"&gt;builder&lt;/a&gt; will generate the TypeScript for any pipeline you assemble, which makes the correspondence easy to check against a notebook you already have:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5alc9qmyzcef1iia76au.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5alc9qmyzcef1iia76au.png" alt="Generated TypeScript showing PdfToImage, DbnetOnnxDetector, TesseractRecognizer and ImageDrawBoxes composed into a Pipeline" width="800" height="310"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PdfToImage&lt;/code&gt;, &lt;code&gt;DbnetOnnxDetector&lt;/code&gt;, &lt;code&gt;TesseractRecognizer&lt;/code&gt; — the same stage names, the same parameter names, the same order. The imports are the only genuinely new thing to learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  What has no browser equivalent
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Python stage&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CraftTextDetector&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;PyTorch only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;LayoutDetector&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Needs the PaddleOCR Python runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;EasyOcr&lt;/code&gt;, &lt;code&gt;SuryaOcr&lt;/code&gt;, &lt;code&gt;DocTROcr&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No browser builds — use &lt;code&gt;PaddleTextRecognizer&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;LLMOcr&lt;/code&gt;, &lt;code&gt;LLMNer&lt;/code&gt;, &lt;code&gt;LLMExtractor&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Any fetch-based OpenAI client works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;TextSplitter&lt;/code&gt;, &lt;code&gt;TextEmbeddings&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Not yet ported&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And one stage that goes the other way: &lt;code&gt;PaddleRecognizer&lt;/code&gt; has &lt;strong&gt;no Python equivalent&lt;/strong&gt;. It is PP-OCR's recognition model reading another detector's boxes, which the Python API cannot express.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Matching names locks you to someone else's API decisions.&lt;/strong&gt; Some of ScaleDP's parameter names are not what we would pick starting fresh. Portability was worth more than taste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matching behaviour means reproducing quirks.&lt;/strong&gt; The DBNet path feeds the model BGR channels normalised against RGB ImageNet statistics, because the reference does and the model was trained that way. "Fixing" it shifts every box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It also means deciding, bug by bug, when &lt;em&gt;not&lt;/em&gt; to match.&lt;/strong&gt; Five Python bugs are deliberately not reproduced. Each one is listed publicly, because a silent divergence is worse than either choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @stabrise/scaledp pdfjs-dist onnxruntime-web ppu-paddle-ocr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://scaledp-ts.stabrise.com/demo?p=W3sidHlwZSI6IlBkZlRvSW1hZ2UiLCJvcHRpb25zIjp7InJlc29sdXRpb24iOjIwMH19LHsidHlwZSI6IkRibmV0T25ueERldGVjdG9yIiwib3B0aW9ucyI6eyJvdXRwdXRDb2wiOiJkZXRlY3RlZCJ9fSx7InR5cGUiOiJUZXNzZXJhY3RSZWNvZ25pemVyIiwib3B0aW9ucyI6eyJpbnB1dENvbHMiOlsiaW1hZ2UiLCJkZXRlY3RlZCJdLCJrZWVwRm9ybWF0dGluZyI6dHJ1ZX19LHsidHlwZSI6IkltYWdlRHJhd0JveGVzIiwib3B0aW9ucyI6eyJpbnB1dENvbHMiOlsiaW1hZ2UiLCJ0ZXh0Il0sIm91dHB1dENvbCI6ImFubm90YXRlZCIsImNvbG9yIjoiIzNmYzlmNSIsImxpbmVXaWR0aCI6Mn19XQ" rel="noopener noreferrer"&gt;Open the ported pipeline in the builder&lt;/a&gt;&lt;/strong&gt; and compare the generated TypeScript against the Python you already have. If porting a notebook is not close to mechanical, that is a bug worth reporting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://scaledp-ts.stabrise.com/docs/porting-from-python" rel="noopener noreferrer"&gt;Porting from Python ScaleDP&lt;/a&gt; — the full stage map, divergences, and the bug list&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/pipelines-and-rows" rel="noopener noreferrer"&gt;Pipelines and rows&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/ScaleDP" rel="noopener noreferrer"&gt;Python: StabRise/ScaleDP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;TypeScript: StabRise/scaledp-ts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ocr</category>
      <category>machinelearning</category>
      <category>typescript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>onError must return the right shape, or the crash just moves</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Sat, 05 Sep 2026 18:10:56 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/onerror-must-return-the-right-shape-or-the-crash-just-moves-47n0</link>
      <guid>https://dev.to/mykola_melnyk_ml/onerror-must-return-the-right-shape-or-the-crash-just-moves-47n0</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9klb5xbwjakstgm9o3zj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9klb5xbwjakstgm9o3zj.png" alt="onError must return the right shape" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written 2026-09-05, about &lt;code&gt;@stabrise/scaledp@0.1.1&lt;/code&gt;. Follows on from &lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/error-contract" rel="noopener noreferrer"&gt;the non-throwing error contract&lt;/a&gt;; assumes TypeScript.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A pipeline where stages never throw needs each failed stage to still produce a &lt;em&gt;value&lt;/em&gt;. Return &lt;code&gt;null&lt;/code&gt; and you have not removed the crash — you have moved it three stages downstream, to a place with no idea what went wrong. So &lt;code&gt;onError&lt;/code&gt; returns a well-formed empty instance of the stage's output schema, every schema ships a &lt;code&gt;create*&lt;/code&gt; factory to make that trivial, and the payoff is that no downstream stage needs an error branch at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half of the contract nobody designs
&lt;/h2&gt;

&lt;p&gt;The interesting half of "stages never throw" is easy to state: catch the exception, record the message. Most people get there.&lt;/p&gt;

&lt;p&gt;The half that decides whether the design works is: &lt;strong&gt;what value goes in the output column?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is an obvious answer, and it is wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong answer
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Tempting. Do not do this.&lt;/span&gt;
&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;DetectorOutput&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads as honest — the stage produced nothing, so it returns nothing. What happens next:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Three stages later, in a recognizer that knows nothing about the detector&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;DetectorOutput&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bboxes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;    &lt;span class="c1"&gt;// TypeError: Cannot read properties of null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You have converted one caught, attributed, per-row failure into an uncaught &lt;code&gt;TypeError&lt;/code&gt; in a different stage. Worse, that &lt;code&gt;TypeError&lt;/code&gt; is caught by &lt;em&gt;that&lt;/em&gt; stage's error handling, so the message the user finally sees is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PaddleRecognizer: TypeError: Cannot read properties of null (reading 'bboxes')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detector's actual failure — a model that would not load, a 404 on the weights — is gone. The reported stage is not the failed stage. This is precisely the outcome the contract exists to prevent, reintroduced by its own error handler.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;undefined&lt;/code&gt; is the same bug with a different message. So is throwing from &lt;code&gt;onError&lt;/code&gt;, which takes down the runner's catch block.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fynpwgnwwe58ojmkcwamp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fynpwgnwwe58ojmkcwamp.png" alt="Returning null from onError turns one attributed failure into an unattributed TypeError three stages later" width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The right answer
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;DetectorOutput&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createDetectorOutput&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;createDetectorOutput&lt;/code&gt; is the factory that ships with the schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createDetectorOutput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Partial&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;DetectorOutput&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}):&lt;/span&gt; &lt;span class="nx"&gt;DetectorOutput&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;memory&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;detector&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;bboxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bboxes&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
    &lt;span class="na"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct fields, empty values, one non-empty string. Every schema has one — &lt;code&gt;createImage&lt;/code&gt;, &lt;code&gt;createDocument&lt;/code&gt;, &lt;code&gt;createDetectorOutput&lt;/code&gt;, &lt;code&gt;createNerOutput&lt;/code&gt; — and they exist so that "an empty but valid instance of this schema" is never something a stage author has to get right by hand.&lt;/p&gt;

&lt;p&gt;The base class makes the requirement unavoidable by typing it as abstract:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Value written to `outputCol` when `apply` throws. Subclasses return an
 * empty instance of their output schema carrying the message, so downstream
 * stages see a well-formed value rather than `undefined`.
 */&lt;/span&gt;
&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot write a stage without answering the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this buys downstream
&lt;/h2&gt;

&lt;p&gt;Here is the payoff, and it is bigger than it looks. Consider a recognizer consuming a detector's boxes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Document&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputCols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ScaleDpImage&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;inputCols&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;DetectorOutput&lt;/span&gt;

  &lt;span class="c1"&gt;// Check exception first -- a failed upstream stage returns a well-formed&lt;/span&gt;
  &lt;span class="c1"&gt;// but empty value, so testing the payload first buries the real cause.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bboxes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what is &lt;em&gt;not&lt;/em&gt; there. No null check. No &lt;code&gt;?.&lt;/code&gt;. No &lt;code&gt;if (!boxes) return&lt;/code&gt;. The &lt;code&gt;bboxes&lt;/code&gt; array is guaranteed to exist because the schema guarantees it, so the loop is over an empty array and does nothing.&lt;/p&gt;

&lt;p&gt;Multiply that across eighteen stages and every display helper. &lt;strong&gt;The invariant is worth more than any individual null check&lt;/strong&gt;, because null checks are things you forget and invariants are things you establish once.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwqlvbqt7rkdmgmn4frs0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwqlvbqt7rkdmgmn4frs0.png" alt="A well-formed empty value means the downstream stage needs no null check and no error branch" width="799" height="232"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The two &lt;code&gt;exception&lt;/code&gt; lines are doing something different from a null check: they are propagating &lt;em&gt;attribution&lt;/em&gt;. Without them the recognizer would happily return an empty document with an empty exception, and a genuinely failed pipeline would look like a blank page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Empty is not the same as failed
&lt;/h2&gt;

&lt;p&gt;A deliberate consequence, worth being explicit about because it is a design decision rather than an accident.&lt;/p&gt;

&lt;p&gt;After a failed detector and after a detector that found nothing, &lt;code&gt;boxes.bboxes.length === 0&lt;/code&gt;. Downstream code cannot distinguish them, and that is intentional — it is exactly why downstream code needs no error branch.&lt;/p&gt;

&lt;p&gt;The distinction lives in one place, and one place only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// failed&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bboxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// genuinely nothing on this page&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some stages lean on this deliberately. &lt;code&gt;TesseractScriptDetector&lt;/code&gt; returns an &lt;em&gt;empty script with an empty exception&lt;/em&gt; when it cannot identify a script, because a blank page is a legitimate answer rather than a failure. Encoding "I looked and there was nothing" as an error would make blank pages noisy for no benefit.&lt;/p&gt;

&lt;p&gt;Here is what that looks like when it actually happens. A &lt;code&gt;PaddleRecognizer&lt;/code&gt; pointed at a column no stage writes, run in the &lt;a href="https://scaledp-ts.stabrise.com/demo" rel="noopener noreferrer"&gt;builder&lt;/a&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj2y5va2ccriji62yxms9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj2y5va2ccriji62yxms9.png" alt="The text column, flagged with a red dot, holding the recognizer's error message and stack rather than a null" width="800" height="393"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The recognizer failed, and the result is still a &lt;code&gt;Document&lt;/code&gt;. It has a &lt;code&gt;text&lt;/code&gt; (empty), it has &lt;code&gt;bboxes&lt;/code&gt; (empty), and it has an &lt;code&gt;exception&lt;/code&gt; naming the stage and the cause. Nothing downstream of it has to know that anything went wrong — which is the entire argument for returning a shape instead of a &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The display helpers cooperate
&lt;/h2&gt;

&lt;p&gt;The invariant extends past the pipeline. Every display helper checks &lt;code&gt;exception&lt;/code&gt; first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;showText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;document_&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShowTextOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}):&lt;/span&gt; &lt;span class="nx"&gt;HTMLElement&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;document_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;errorBlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;document_&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a failed stage renders as a red error block, not an empty panel. That matters more than it sounds: the failure mode of a quiet error contract is that failures become &lt;em&gt;invisible&lt;/em&gt;, and an empty &lt;code&gt;&amp;lt;pre&amp;gt;&lt;/code&gt; looks exactly like a page with no text on it. Making the UI layer aware of the same field is what keeps a silent contract from producing a silent product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, generalised
&lt;/h2&gt;

&lt;p&gt;This is not really about OCR. Any pipeline that converts exceptions into values faces the same question, and the same answer applies:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a failure produces a value, that value must satisfy every invariant a success would have satisfied. Otherwise you have not handled the error; you have deferred it to code that cannot attribute it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Practically, that means three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Give every result type a factory that produces a valid empty instance.&lt;/strong&gt; If constructing "empty but valid" is fiddly, it will be got wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the failure marker inside the type, not beside it.&lt;/strong&gt; A &lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt; wrapper also works, and is arguably cleaner — but it does not survive &lt;code&gt;postMessage&lt;/code&gt; as nicely, and it forces every consumer to unwrap, which is exactly the per-consumer branch this design is avoiding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the error path type-checked, not documented.&lt;/strong&gt; An abstract &lt;code&gt;onError&lt;/code&gt; returning the schema type is enforced by the compiler. A comment saying "return an empty instance" is not.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Result&amp;lt;T, E&amp;gt;&lt;/code&gt; is the better-typed alternative and we did not choose it.&lt;/strong&gt; It makes the failure impossible to ignore, which is a genuine advantage over a string field you can forget to read. We took the schema-with-&lt;code&gt;exception&lt;/code&gt; route for Python parity — the field exists in ScaleDP and has the same meaning — and because it clones across a worker boundary for free. That is a defensible trade, not an obviously correct one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A well-formed empty value is easy to ignore.&lt;/strong&gt; It is the same criticism as the contract itself: an empty &lt;code&gt;Document&lt;/code&gt; flows into your index without complaint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Factories add a small amount of ceremony.&lt;/strong&gt; Every schema needs one, and a new field means updating it. In exchange, no stage ever hand-rolls an empty instance and gets a field wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;onError&lt;/code&gt; receives a string, not the original error.&lt;/strong&gt; You lose the ability to branch on the error type inside &lt;code&gt;onError&lt;/code&gt;. In practice the stage that threw already knows what it threw; the ones that do not are just passing an upstream message through.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @stabrise/scaledp pdfjs-dist onnxruntime-web ppu-paddle-ocr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://scaledp-ts.stabrise.com/demo?p=W3sidHlwZSI6IlBkZlRvSW1hZ2UiLCJvcHRpb25zIjp7InJlc29sdXRpb24iOjIwMH19LHsidHlwZSI6IlBhZGRsZVRleHRSZWNvZ25pemVyIiwib3B0aW9ucyI6eyJrZWVwRm9ybWF0dGluZyI6dHJ1ZX19LHsidHlwZSI6IkltYWdlRHJhd0JveGVzIiwib3B0aW9ucyI6eyJpbnB1dENvbHMiOlsiaW1hZ2UiLCJ0ZXh0Il0sIm91dHB1dENvbCI6ImFubm90YXRlZCIsImNvbG9yIjoiIzNmYzlmNSIsImxpbmVXaWR0aCI6Mn19XQ" rel="noopener noreferrer"&gt;Open a pipeline in the builder&lt;/a&gt;&lt;/strong&gt; and break a stage on purpose — point its &lt;code&gt;inputCols&lt;/code&gt; at a column no stage writes. What you get back is a well-formed empty &lt;code&gt;Document&lt;/code&gt; with a populated &lt;code&gt;exception&lt;/code&gt;, which is exactly what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/error-contract" rel="noopener noreferrer"&gt;The error contract&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/schemas" rel="noopener noreferrer"&gt;Schemas&lt;/a&gt; — every &lt;code&gt;create*&lt;/code&gt; factory&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/stages" rel="noopener noreferrer"&gt;The stage lifecycle&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://scaledp-ts.stabrise.com/docs/api/display" rel="noopener noreferrer"&gt;Display helpers&lt;/a&gt; — how failures render&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;Repo: StabRise/scaledp-ts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ocr</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Privacy is an architecture, not a checkbox</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Fri, 04 Sep 2026 08:17:53 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/privacy-is-an-architecture-not-a-checkbox-5h4g</link>
      <guid>https://dev.to/mykola_melnyk_ml/privacy-is-an-architecture-not-a-checkbox-5h4g</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpsv68r474ugn2tk9i0nk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpsv68r474ugn2tk9i0nk.png" alt="Privacy is an architecture, not a checkbox" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written 2026-09-04, about &lt;code&gt;@stabrise/scaledp@0.1.1&lt;/code&gt;. No OCR background assumed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; "We delete it after processing" is a promise — it depends on a vendor's behaviour, and you verify it by reading a document. "It never left the tab" is a property — it depends on the data flow, and you verify it by opening the network panel. If you are building anything that handles documents people care about, the difference is worth designing for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Promises versus properties
&lt;/h2&gt;

&lt;p&gt;Here is a claim from a typical OCR vendor's security page:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Documents are processed in memory and deleted immediately after the response is returned. We do not use customer data for training.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have no reason to doubt it. But look at what it takes to &lt;em&gt;believe&lt;/em&gt; it: a policy document, an audit report, a contract with penalties, and trust in an organisation's future behaviour — including after an acquisition, a breach, or a change in leadership. Those are all real mitigations, and they are all promises.&lt;/p&gt;

&lt;p&gt;Now the same claim for a client-side pipeline:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The document is never transmitted.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The verification is different in kind. You open DevTools, filter the network panel to XHR and fetch, drop a file in, and watch nothing happen. You can do that yourself, in thirty seconds, without asking anyone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A promise constrains behaviour. A property constrains what is possible.&lt;/strong&gt; Both are worth having; only one of them survives an organisation you cannot audit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcsik9oioto3akhv0pq3v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcsik9oioto3akhv0pq3v.png" alt="A deletion promise is verified by reading a policy; a no-transmission property is verified by watching the network panel" width="800" height="248"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What "never left the tab" actually means here
&lt;/h2&gt;

&lt;p&gt;Being precise, because overclaiming this is worse than not claiming it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What genuinely never leaves:&lt;/strong&gt; the document bytes, the rendered page images, the recognised text, the detected boxes, the extracted entities. All of it lives in JavaScript memory and &lt;code&gt;OffscreenCanvas&lt;/code&gt; buffers inside one tab, is passed between stages as plain objects, and is garbage-collected when you drop the reference.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PdfToImage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaddleTextRecognizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;keepFormatting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GlinerNer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;person&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iban&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;// Every byte of this stays in the tab.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What does leave, once:&lt;/strong&gt; the model weights, downloaded from a model host. That is an outbound request for a &lt;em&gt;public artefact&lt;/em&gt; — it says "this origin fetched PaddleOCR v6-small", not "this user uploaded a contract". If even that is too much signal, you self-host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;modelHost&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://models.internal.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indexeddb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After which there is no third-party request at all. Which brings us to the demonstration.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvehas0q138tflzvr2ktj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvehas0q138tflzvr2ktj.png" alt="Model weights come inbound once and are cached; there is no outbound path for the document" width="799" height="232"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://scaledp-ts.stabrise.com/demo" rel="noopener noreferrer"&gt;demo&lt;/a&gt; states its own runtime in a strip across the top, which is the same information your app should surface:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flichuu5lo2bod32psh6s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flichuu5lo2bod32psh6s.png" alt="A runtime strip reading: engine auto, running on wasm; threads multithreading unavailable, this page is not cross-origin isolated" width="795" height="33"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two honest things are visible there. The inference is running on WebAssembly &lt;strong&gt;in this tab&lt;/strong&gt;. And multithreading is unavailable, because the page is not cross-origin isolated — GitHub Pages cannot send the headers that would allow it. That is a real performance cost of the deployment, printed on screen rather than hidden.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to prove it to a reviewer
&lt;/h2&gt;

&lt;p&gt;This is the part that actually matters in a procurement conversation, and it takes about a minute.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Load the app once with a network connection, and let the models cache. They go to IndexedDB, scoped to your origin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn the network off.&lt;/strong&gt; Airplane mode, or DevTools' offline throttling.&lt;/li&gt;
&lt;li&gt;Drop a document in and run the pipeline.&lt;/li&gt;
&lt;li&gt;It works.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A system that produces correct output with no network cannot be exfiltrating anything, and that argument needs no policy document. It is the same reasoning a reviewer applies to an air-gapped machine, just cheaper.&lt;/p&gt;

&lt;p&gt;For the version that goes in a security questionnaire, the three bullets are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Document bytes are processed in-page by WebAssembly; there is no server-side component and no endpoint to send them to.&lt;/li&gt;
&lt;li&gt;Model weights are static files, served from &lt;code&gt;&amp;lt;your origin&amp;gt;&lt;/code&gt;; they are inputs to the computation, not a channel out of it.&lt;/li&gt;
&lt;li&gt;Verification: the pipeline runs to completion with networking disabled after first load.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this does not buy you
&lt;/h2&gt;

&lt;p&gt;Every honest privacy claim comes with a boundary, and here is this one's.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An untrusted device is still untrusted.&lt;/strong&gt; If the user's machine has malware, or a hostile browser extension with page access, running locally does not save you. It removes &lt;em&gt;your&lt;/em&gt; server from the threat model, not theirs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your own app is still in the path.&lt;/strong&gt; The library does not upload. Your application code can. Nothing stops a well-meaning analytics call from including a filename, or an error reporter from capturing a page image in a breadcrumb. The property holds for the pipeline; it holds for your product only if you keep it.&lt;/p&gt;

&lt;p&gt;That is worth an explicit rule in review: &lt;strong&gt;anything that touches a row is in scope.&lt;/strong&gt; Error reporting, session replay, analytics, and log shipping all deserve a second look in a codebase that makes this claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Persistence is a decision you now own.&lt;/strong&gt; Cached models live in IndexedDB on the user's disk. That is weights, not documents — but on a shared machine it is still a fact about what the user did, and &lt;code&gt;evict()&lt;/code&gt; exists for the flows where that matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compliance is not automatic.&lt;/strong&gt; Not sending data to a processor removes one category of obligation. It does not remove your obligations as a controller for anything you &lt;em&gt;do&lt;/em&gt; store server-side afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern this enables
&lt;/h2&gt;

&lt;p&gt;The strongest version of this is not "build a fully local product" — that is a big commitment. It is a much smaller change to an ordinary upload flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;drop file → OCR + NER locally → show the user what's in it
          → they redact → upload the redacted version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend does not change at all. The document that reaches your server has already had its PII blacked out, by code running on the user's machine, before anything was transmitted. You have improved your data-handling posture without a migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You inherit the operational failure.&lt;/strong&gt; No server means no server to blame, and no retry policy. A model that fails to load on someone's laptop is a UI state you have to design.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First load is expensive.&lt;/strong&gt; ~6 MB for OCR is nothing; ~580 MB for the default NER model is not. The privacy property costs bytes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Verify it yourself" only works if it stays true.&lt;/strong&gt; This is a property of a &lt;em&gt;build&lt;/em&gt;. It deserves a test, not just a paragraph — a check that no network request is made during a pipeline run is cheap to write and cheap to keep.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not scale.&lt;/strong&gt; One tab, one machine. At volume, a server with a proper DPA is both cheaper and, done well, perfectly respectable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @stabrise/scaledp pdfjs-dist onnxruntime-web ppu-paddle-ocr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://scaledp-ts.stabrise.com/demo?p=W3sidHlwZSI6IlBkZlRvSW1hZ2UiLCJvcHRpb25zIjp7InJlc29sdXRpb24iOjIwMH19LHsidHlwZSI6IlBhZGRsZVRleHRSZWNvZ25pemVyIiwib3B0aW9ucyI6eyJrZWVwRm9ybWF0dGluZyI6dHJ1ZX19LHsidHlwZSI6IkltYWdlRHJhd0JveGVzIiwib3B0aW9ucyI6eyJpbnB1dENvbHMiOlsiaW1hZ2UiLCJ0ZXh0Il0sIm91dHB1dENvbCI6ImFubm90YXRlZCIsImNvbG9yIjoiIzNmYzlmNSIsImxpbmVXaWR0aCI6Mn19XQ" rel="noopener noreferrer"&gt;Open the pipeline in the builder&lt;/a&gt;&lt;/strong&gt;, run it once so the weights are cached, then turn off your network and run it again. It works. That is the demonstration, and it takes about a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs" rel="noopener noreferrer"&gt;Introduction&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://scaledp-ts.stabrise.com/docs/guides/self-hosting-models" rel="noopener noreferrer"&gt;Self-hosting models&lt;/a&gt; — including the fully-offline configuration&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/models-and-cache" rel="noopener noreferrer"&gt;Models and caching&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/recipes/redact-pii" rel="noopener noreferrer"&gt;Redact PII from a document&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;Repo: StabRise/scaledp-ts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ocr</category>
      <category>privacy</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Running OCR entirely in the browser meant decoupling detection from recognition</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Fri, 04 Sep 2026 08:17:21 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/running-ocr-entirely-in-the-browser-meant-decoupling-detection-from-recognition-37ak</link>
      <guid>https://dev.to/mykola_melnyk_ml/running-ocr-entirely-in-the-browser-meant-decoupling-detection-from-recognition-37ak</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5nfbwr7no9p7j1l7it0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5nfbwr7no9p7j1l7it0.png" alt="Running OCR in the browser meant decoupling detection from recognition" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written 2026-09-04. Code examples target &lt;code&gt;@stabrise/scaledp@0.1.1&lt;/code&gt; and &lt;code&gt;onnxruntime-web@1.20.x&lt;/code&gt;. This post assumes basic familiarity with OCR (text detection vs. text recognition) and TypeScript; it does not assume you know onnxruntime-web.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;&lt;code&gt;@stabrise/scaledp&lt;/code&gt;&lt;/a&gt; is a document-AI pipeline — PDF rendering, OCR, NER — that runs entirely client-side on &lt;code&gt;onnxruntime-web&lt;/code&gt;, no server involved. The recognizer that ships with it, PaddleOCR, detects and reads text in one pass, which meant no other detector could ever feed it a box. We built &lt;code&gt;PaddleRecognizer&lt;/code&gt;, a second stage with the contract &lt;code&gt;[image, boxes] -&amp;gt; Document&lt;/code&gt;, so DBNet or a YOLO signature detector can hand PaddleOCR's recognition model boxes it never found itself. Along the way we hit a WebGPU kernel gap, a batching constraint in the underlying library, and a word-splitting bug that was quietly dropping characters.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: an OCR stage that couldn't be composed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@stabrise/scaledp&lt;/code&gt; is a TypeScript port of &lt;a href="https://github.com/StabRise/ScaleDP" rel="noopener noreferrer"&gt;ScaleDP&lt;/a&gt;, a Python/Spark document-processing library. The pipeline model is the same in both: a list of stages, each stage a pure function over a row, &lt;code&gt;[image, text, boxes, ...]&lt;/code&gt; in, &lt;code&gt;[image, text, boxes, ...]&lt;/code&gt; out. The point of the TypeScript version is that it never leaves the browser — no upload, which matters a lot once you're running OCR over things like IDs, contracts, or medical forms.&lt;/p&gt;

&lt;p&gt;For text recognition we use &lt;a href="https://www.npmjs.com/package/ppu-paddle-ocr" rel="noopener noreferrer"&gt;ppu-paddle-ocr&lt;/a&gt;, a WASM/ONNX port of PP-OCR. Our &lt;code&gt;PaddleTextRecognizer&lt;/code&gt; stage wraps it directly: hand it a page image, get back text and boxes. Convenient, but it hides a detail that matters once you have more than one detector — PP-OCR's &lt;code&gt;run()&lt;/code&gt; call does detection &lt;em&gt;and&lt;/em&gt; recognition together. There was no way to say "read this specific set of boxes"; the boxes always came from PP-OCR's own detector.&lt;/p&gt;

&lt;p&gt;That's a real limitation, because the library also ships &lt;code&gt;DbnetOnnxDetector&lt;/code&gt; (the same DBNet ONNX model ScaleDP uses server-side) and a YOLO-based signature/face detector. If your pipeline picks DBNet for detection — maybe because it's faster, maybe because you're comparing detectors, maybe because you need YOLO to find signature regions specifically — there was no path from those boxes into PP-OCR's recognizer. You'd fall back to Tesseract, which does support recognize-only via &lt;code&gt;TesseractRecognizer&lt;/code&gt;, even if PaddleOCR would read your script better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: split detection out of the recognizer
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;PaddleRecognizer&lt;/code&gt; is the missing half. Same contract as &lt;code&gt;TesseractRecognizer&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;imageColumn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;boxColumn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;Document&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;so any stage that writes a &lt;code&gt;DetectorOutput&lt;/code&gt; column — DBNet, YOLO, PaddleOCR's own detector — can feed it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vtczoyagfmr0ewq7r6t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vtczoyagfmr0ewq7r6t.png" alt="PaddleTextRecognizer's single detect-and-recognize call versus the two-stage PaddleRecognizer pipeline" width="800" height="280"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;configure&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PdfToImage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DbnetOnnxDetector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PaddleRecognizer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/ocr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indexeddb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;workerSrc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/pdf.worker.min.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PdfToImage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DbnetOnnxDetector&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;inputCol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outputCol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;boxes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaddleRecognizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;inputCols&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;boxes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v6-small&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detection and recognition are now two stages you can mix independently: swap &lt;code&gt;DbnetOnnxDetector&lt;/code&gt; for the YOLO signature detector to recognize &lt;em&gt;only&lt;/em&gt; signature regions, or run DBNet once and try several recognizer presets against the same boxes without re-detecting.&lt;/p&gt;

&lt;p&gt;Run in the &lt;a href="https://scaledp-ts.stabrise.com/demo" rel="noopener noreferrer"&gt;builder&lt;/a&gt;, that is DBNet's boxes with PaddleOCR's text in them:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb2i21t0551kw0ii81nfa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb2i21t0551kw0ii81nfa.png" alt="A scanned page with cyan word boxes found by DBNet and read by PaddleRecognizer, including a false positive around a face" width="800" height="1071"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Worth noticing the box around the man's face: DBNet found structure there and PaddleOCR dutifully tried to read it. Detection errors do not disappear because you changed which model reads them — they just become somebody else's boxes.&lt;/p&gt;

&lt;p&gt;Three things made this harder than "call the recognizer per box," and each is the kind of detail that only shows up once you've profiled or diffed real output.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Straighten before you crop
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;ppu&lt;/code&gt;'s own cropping is axis-aligned. Hand it a rotated box and it crops that box's &lt;em&gt;bounding rectangle&lt;/em&gt;, not the box itself — fine for horizontal text, wrong for anything at an angle (a rotated scan, a stamped/rotated field). &lt;code&gt;PaddleRecognizer&lt;/code&gt; straightens each box with a perspective warp before cropping, so a 15° rotated line is read upright instead of read with its neighbors bleeding into the crop.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Batch by canvas, not by call
&lt;/h3&gt;

&lt;p&gt;The natural implementation is "for each box, call &lt;code&gt;run()&lt;/code&gt;." That's also the slow one: &lt;code&gt;ppu&lt;/code&gt; only batches crops &lt;em&gt;within a single &lt;code&gt;run()&lt;/code&gt; call&lt;/em&gt;, and it cuts them from one canvas. Calling it per box costs one inference and one main-thread yield per line — on a page with 80 lines, that's 80 round trips.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PaddleRecognizer&lt;/code&gt; stacks the straightened crops onto a single sheet canvas first, then reads them together in one &lt;code&gt;run()&lt;/code&gt;. Results come back sorted into reading order rather than array order, so we match them back to boxes by the slot each crop occupied on the sheet, not by index.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0d9nvcrjombmjng3niwa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0d9nvcrjombmjng3niwa.png" alt="Calling run() per box costs one inference per line; stacking the crops onto one sheet costs one inference per page" width="800" height="280"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified shape of what happens internally&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;crops&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;straightenAndCrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;stackOntoSheet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;crops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;// one canvas, N slots&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;recognizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// one inference call&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;matchBySlotOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;crops&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// not by array position&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. WebGPU can't run this graph
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;onnxruntime-web&lt;/code&gt;'s WebGPU backend rewrites convolutions into an internal &lt;code&gt;com.ms.internal.nhwc&lt;/code&gt; op set. PP-OCR's recognition graph hits a kernel that isn't implemented for that rewrite, and session creation fails outright — not a slowdown, a hard error. &lt;code&gt;ppu-paddle-ocr&lt;/code&gt; already retries on WASM internally when this happens; we exposed the same behavior in our own &lt;code&gt;createSession()&lt;/code&gt; as an opt-in &lt;code&gt;fallbackToWasm&lt;/code&gt; flag. It's off by default: if you asked for WebGPU and it's genuinely misconfigured for some other reason, we want that to fail loudly, not silently degrade to WASM and leave you wondering why inference is slower than expected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/ocr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modelBuffer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;executionProviders&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webgpu&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;fallbackToWasm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// PP-OCR recognition specifically needs this&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A word-splitting bug we found while wiring this up
&lt;/h2&gt;

&lt;p&gt;Building &lt;code&gt;PaddleRecognizer&lt;/code&gt; meant looking hard at how word-level boxes get derived from a line the model read as a whole — and that surfaced a pre-existing bug in &lt;code&gt;PaddleTextRecognizer&lt;/code&gt; too. The old code cut each line at its ink gaps &lt;em&gt;first&lt;/em&gt; and recognized every word crop independently. PP-OCR's recognizer is a CTC model trained on full lines; feeding it a three-character crop stretched to a fixed input height is nothing like its training distribution, and it also throws away the sentence-level context the model's accuracy depends on.&lt;/p&gt;

&lt;p&gt;The fix reverses the order: read the line whole, then split the &lt;em&gt;words&lt;/em&gt; out of the &lt;em&gt;result&lt;/em&gt;, reconciling the model's own word boundaries against a vertical-projection ink-gap scan. Equal counts zip together directly. Where they disagree, the ink decides how many boxes exist and the text decides what's in them — more ink-spans than words merges adjacent spans smallest-gap-first, more words than spans joins the extras back onto the span they overlap. On a real page this measurably fixed dropped characters — &lt;code&gt;https:/stabrise.com/scaledp/&lt;/code&gt; (missing a slash) became &lt;code&gt;https://stabrise.com/scaledp/&lt;/code&gt; — and it's cheaper too: one inference per line instead of one per word.&lt;/p&gt;

&lt;p&gt;It also fixed something subtler: on a signature, the old per-word recognition emitted a scatter of single letters for one continuous stroke, and cutting the line up to match produced a row of boxes with identical width and height — the &lt;em&gt;character count&lt;/em&gt; rendered as geometry, not anything measured from the pixels. Reading whole-line-first, that signature went from 15 boxes (10 of them these fake uniform ones) down to accurate stroke-level geometry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this shape, not a bigger abstraction
&lt;/h2&gt;

&lt;p&gt;We didn't build a generic "pluggable detector/recognizer interface" with a registry and adapters. &lt;code&gt;PaddleRecognizer&lt;/code&gt; and &lt;code&gt;TesseractRecognizer&lt;/code&gt; just happen to share the same input contract — &lt;code&gt;[image, boxes] -&amp;gt; Document&lt;/code&gt; — because that's the natural shape of "read boxes someone else found," not because we designed an interface for it. Every stage in the pipeline is engine-specific and takes exactly the parameters that engine needs; composability comes from stages agreeing on column names and schemas, the same way Python ScaleDP's Spark stages do. Adding a third recognizer later means writing a third stage with that same two-column contract, not implementing an interface.&lt;/p&gt;

&lt;p&gt;That also matters for a rule this library holds hard: stages never throw by default. A batch job over forty PDF pages can't lose the other thirty-nine because page twelve's crop was degenerate. &lt;code&gt;PaddleRecognizer&lt;/code&gt; records a failure in the output &lt;code&gt;Document&lt;/code&gt;'s &lt;code&gt;exception&lt;/code&gt; field and returns a well-formed empty document for that row; you opt into throwing with &lt;code&gt;propagateError: true&lt;/code&gt; if you'd rather fail fast during development.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sheet-batching costs memory, not just time.&lt;/strong&gt; Stacking every crop on a page onto one canvas means the whole page's worth of crops lives in memory at once. For most pages that's trivial; for a page with hundreds of tiny regions (a densely annotated form, say) you may want to chunk boxes into multiple sheets — &lt;code&gt;recBatchSize&lt;/code&gt; exists for exactly this, and defaults conservatively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fallbackToWasm&lt;/code&gt; trades silent correctness for silent slowness.&lt;/strong&gt; We chose to make WebGPU failures loud by default specifically so a genuinely broken provider doesn't quietly become "OCR is fine but 3x slower than it should be" with no error to grep for. Turn the flag on once you've confirmed WebGPU works for your other stages and just isn't implemented for this one op set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Straightening every box costs a warp per crop.&lt;/strong&gt; For an unrotated page (the common case) this is wasted work; we don't currently skip it for axis-aligned boxes, because detecting "close enough to axis-aligned" reliably is its own source of edge cases. If your pipeline is always horizontal-only and profiling shows this mattering, that's a reasonable place to special-case.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @stabrise/scaledp onnxruntime-web ppu-paddle-ocr pdfjs-dist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;configure&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PdfToImage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;DbnetOnnxDetector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PaddleRecognizer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/ocr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indexeddb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;workerSrc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/pdf.worker.min.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PdfToImage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DbnetOnnxDetector&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaddleRecognizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v6-small&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The live demo (drop a PDF or image, pick a detector and recognizer, run it) is at &lt;a href="https://scaledp-ts.stabrise.com/demo" rel="noopener noreferrer"&gt;scaledp-ts.stabrise.com/demo&lt;/a&gt;; docs at &lt;a href="https://scaledp-ts.stabrise.com/docs" rel="noopener noreferrer"&gt;scaledp-ts.stabrise.com/docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;Repo: StabRise/scaledp-ts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/ScaleDP" rel="noopener noreferrer"&gt;Python sibling: StabRise/ScaleDP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.npmjs.com/package/ppu-paddle-ocr" rel="noopener noreferrer"&gt;&lt;code&gt;ppu-paddle-ocr&lt;/code&gt; on npm&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://onnxruntime.ai/docs/tutorials/web/" rel="noopener noreferrer"&gt;onnxruntime-web execution providers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/stages/recognise/paddle-recognizer" rel="noopener noreferrer"&gt;PaddleRecognizer docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ocr</category>
      <category>webdev</category>
      <category>machinelearning</category>
      <category>opensource</category>
    </item>
    <item>
      <title>One bad page must not lose the other forty</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Fri, 04 Sep 2026 08:16:37 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/one-bad-page-must-not-lose-the-other-forty-5am8</link>
      <guid>https://dev.to/mykola_melnyk_ml/one-bad-page-must-not-lose-the-other-forty-5am8</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vq2ytj2u8v3xh2uai9l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5vq2ytj2u8v3xh2uai9l.png" alt="One bad page must not lose the other forty" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written 2026-09-04, about &lt;code&gt;@stabrise/scaledp@0.1.1&lt;/code&gt;. Assumes TypeScript and async/await.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; In a document pipeline, an exception is almost never worth the batch it destroys. So stages here never throw by default: every output schema carries an &lt;code&gt;exception: string&lt;/code&gt;, a failed stage writes a well-formed empty value with the message in that field, and the pipeline completes. &lt;code&gt;propagateError: true&lt;/code&gt; opts back into throwing. One sentence of requirement generates the whole design — and it has a real cost, which is the second half of the post.&lt;/p&gt;

&lt;h2&gt;
  
  
  The requirement
&lt;/h2&gt;

&lt;p&gt;You have a 40-page PDF. Page 12 has a degenerate detection — a box of zero width, a crop that lands entirely outside the page, a region the recognizer chokes on. The natural implementation throws, the &lt;code&gt;await&lt;/code&gt; rejects, and you have zero pages of output from a run that successfully processed thirty-nine.&lt;/p&gt;

&lt;p&gt;That is the wrong outcome, and it is wrong in a way that gets worse with scale. The larger the batch, the higher the chance that &lt;em&gt;something&lt;/em&gt; in it is malformed, and the more expensive it is to have lost the rest.&lt;/p&gt;

&lt;p&gt;State it as a requirement and the design follows: &lt;strong&gt;one bad page must not lose the other forty.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft5e3j4mjxjfqisg81wc6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft5e3j4mjxjfqisg81wc6.png" alt="Throwing loses all forty pages; recording the failure in the exception field keeps thirty-nine" width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract
&lt;/h2&gt;

&lt;p&gt;Every output schema carries an &lt;code&gt;exception&lt;/code&gt; field:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Document&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;bboxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;     &lt;span class="c1"&gt;// &amp;lt;-- here&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same for &lt;code&gt;ScaleDpImage&lt;/code&gt;, &lt;code&gt;DetectorOutput&lt;/code&gt; and &lt;code&gt;NerOutput&lt;/code&gt;. It is not an optional field and it is not &lt;code&gt;Error | null&lt;/code&gt; — it is a string, empty when nothing went wrong, because it crosses &lt;code&gt;postMessage&lt;/code&gt; to a worker and has to survive structured cloning.&lt;/p&gt;

&lt;p&gt;The runner does the catching, once, for every stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expanded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;expanded&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;outputCol&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;propagateError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;
  &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;outputCol&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;formatException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means a stage author writes ordinary code that throws when something is wrong. They do not write try/catch. They write one extra method — &lt;code&gt;onError&lt;/code&gt; — that returns an empty instance of their output schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;// never rejects for a page failure&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`page &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is that actually happening, in the &lt;a href="https://scaledp-ts.stabrise.com/demo" rel="noopener noreferrer"&gt;live builder&lt;/a&gt;. The pipeline is &lt;code&gt;PdfToImage&lt;/code&gt; → &lt;code&gt;PaddleRecognizer&lt;/code&gt;, with the recognizer deliberately pointed at a column no stage writes:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk2jg0p2yyijo0v9ohifv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk2jg0p2yyijo0v9ohifv.png" alt="The page rendered and the run completed; the failure is recorded in the text column, which is marked with a red dot and carries the message and stack" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three things in that screenshot are the whole contract. &lt;strong&gt;The run completed&lt;/strong&gt; — there is a result to look at. &lt;strong&gt;The page still rendered&lt;/strong&gt;, because &lt;code&gt;PdfToImage&lt;/code&gt; succeeded and its failure-free output is untouched by what happened downstream. And &lt;strong&gt;the failure is in the column&lt;/strong&gt;, tagged on the tab with a red dot, carrying the message the stage wrote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PaddleRecognizer: OcrError: No boxes in column "nosuchcolumn".
This stage reads a detector's output; run a text detector before it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The stage name is prepended deliberately. With two recognizers in a pipeline, the &lt;em&gt;column&lt;/em&gt; tells you which output failed and the &lt;em&gt;message&lt;/em&gt; tells you which stage wrote it — and those are different questions once a pipeline has ten stages in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check &lt;code&gt;exception&lt;/code&gt; before you check the payload
&lt;/h2&gt;

&lt;p&gt;This is a one-line ordering rule that appears verbatim in seven files, and it is the difference between an error message that names the cause and one that buries it.&lt;/p&gt;

&lt;p&gt;A failed upstream stage does not return &lt;code&gt;undefined&lt;/code&gt; — it returns a &lt;em&gt;well-formed but empty&lt;/em&gt; value. So this looks reasonable and is wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WRONG: reports the symptom, hides the cause&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OcrError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no decoded image bytes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PdfToImage&lt;/code&gt; failed three stages ago because the pdf.js worker was not configured. What the user sees is "no decoded image bytes" from the recognizer, and they spend an afternoon debugging the wrong stage.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// RIGHT: the real cause propagates&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OcrError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no decoded image bytes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment explaining this is copy-pasted into every stage that consumes another stage's output rather than abstracted into a helper. Seven small correct things beat one clever thing that a future stage forgets to call.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkwzukmfbwz6qte1wlmlg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkwzukmfbwz6qte1wlmlg.png" alt="Checking the payload first blames the recognizer; checking exception first names the stage that actually failed" width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Opting into throwing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaddleTextRecognizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;propagateError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per stage, not global, and useful in two opposite situations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;While developing.&lt;/strong&gt; A mis-wired column name produces an empty result and no error, which is a genuinely annoying thing to debug. With &lt;code&gt;propagateError&lt;/code&gt; it is a stack trace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In a batch job where silence is worse than a crash.&lt;/strong&gt; If you are OCRing a legal archive and a page fails, an empty string that flows into your search index is worse than a failed job, because nobody will ever notice it.&lt;/p&gt;

&lt;p&gt;The error types all extend &lt;code&gt;ScaleDpError&lt;/code&gt; and carry the stage they came from, so &lt;code&gt;instanceof&lt;/code&gt; works when you have opted in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;OcrError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DetectionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ConfigError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;ConfigError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;showSetupHelp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;OcrError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;reportPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error&lt;/th&gt;
&lt;th&gt;Raised by&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ImageError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;decoding, encoding, cropping, empty data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;OcrError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;recognition, missing input columns on OCR stages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DetectionError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;detectors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;NerError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GLiNER&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ConfigError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a missing peer dependency, a bad &lt;code&gt;configure()&lt;/code&gt; value&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What is deliberately &lt;em&gt;not&lt;/em&gt; covered
&lt;/h2&gt;

&lt;p&gt;Two failures happen outside the contract, on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input normalisation.&lt;/strong&gt; &lt;code&gt;transform('https://example.com/doc.pdf')&lt;/code&gt; fetches, and a non-ok response throws before any stage runs. There is no row yet to write an exception onto.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Constructor validation.&lt;/strong&gt; An unknown OCR preset or an out-of-range threshold throws &lt;code&gt;RangeError&lt;/code&gt; when the stage is constructed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaddleTextRecognizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v6-smal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// RangeError: preset: unknown preset 'v6-smal'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is deliberate. &lt;strong&gt;A parameter mistake is a programming error, not a data error.&lt;/strong&gt; Reporting it at construction names the field; reporting it at row three names nothing useful, and reporting it as an empty column names nothing at all.&lt;/p&gt;

&lt;p&gt;The distinction generalises: the non-throwing contract is for &lt;em&gt;data&lt;/em&gt; failures, which are expected and per-row. Programming failures should still be loud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failures are timed
&lt;/h2&gt;

&lt;p&gt;A small detail with a real diagnostic payoff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Failures are timed too: a stage that spent nine seconds before&lt;/span&gt;
&lt;span class="c1"&gt;// throwing still cost nine seconds.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timing is recorded outside the try/catch. A stage that downloads a 580 MB model, times out and fails still shows 40 seconds in &lt;code&gt;row_time&lt;/code&gt;. If failures were untimed, the pipeline that took a minute would report ten milliseconds of work, and the profile would be a lie exactly when you most need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;This is the part that matters, because the contract is not free.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nothing forces you to check.&lt;/strong&gt; &lt;code&gt;row.text.text&lt;/code&gt; on a failed row is &lt;code&gt;''&lt;/code&gt;, and &lt;code&gt;''&lt;/code&gt; flows happily into a search index, a diff, or a summary. The contract converts a loud failure into a quiet one, and the discipline of checking &lt;code&gt;exception&lt;/code&gt; moves onto the caller. That is a real cost and I would not pretend otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An empty result and a failed result look identical downstream.&lt;/strong&gt; A detector that found nothing and a detector that crashed both hand on zero boxes. That is intentional — it is why downstream stages need no error branch — but it means "no entities on this page" is ambiguous unless you look at &lt;code&gt;exception&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You lose the stack at the throw site&lt;/strong&gt; unless you set &lt;code&gt;propagateError&lt;/code&gt;. The message includes the stack as a string, which is not the same as a debugger paused at the frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;onError&lt;/code&gt; is one more method per stage&lt;/strong&gt;, and a stage that returns the wrong shape from it converts one failure into a worse one — which is its own post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mitigation for the first two is not cleverness, it is a habit: treat &lt;code&gt;exception&lt;/code&gt; as part of the result you destructure, not as an edge case. The display helpers cooperate here — anything with a non-empty &lt;code&gt;exception&lt;/code&gt; renders as a red error block rather than an empty panel, so a failure is &lt;em&gt;visible&lt;/em&gt; rather than merely absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @stabrise/scaledp pdfjs-dist onnxruntime-web ppu-paddle-ocr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The screenshot above is reproducible in one click: &lt;strong&gt;&lt;a href="https://scaledp-ts.stabrise.com/demo?p=W3sidHlwZSI6IlBkZlRvSW1hZ2UiLCJvcHRpb25zIjp7InJlc29sdXRpb24iOjIwMH19LHsidHlwZSI6IlBhZGRsZVRleHRSZWNvZ25pemVyIiwib3B0aW9ucyI6eyJrZWVwRm9ybWF0dGluZyI6dHJ1ZX19LHsidHlwZSI6IkltYWdlRHJhd0JveGVzIiwib3B0aW9ucyI6eyJpbnB1dENvbHMiOlsiaW1hZ2UiLCJ0ZXh0Il0sIm91dHB1dENvbCI6ImFubm90YXRlZCIsImNvbG9yIjoiIzNmYzlmNSIsImxpbmVXaWR0aCI6Mn19XQ" rel="noopener noreferrer"&gt;open the mis-wired pipeline in the builder&lt;/a&gt;&lt;/strong&gt;, then point the recognizer's &lt;code&gt;inputCols&lt;/code&gt; at a column nothing writes and run it. The page still renders, the run still finishes, and the failure shows up where the contract says it will.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/error-contract" rel="noopener noreferrer"&gt;The error contract&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/stages" rel="noopener noreferrer"&gt;The stage lifecycle&lt;/a&gt; — where &lt;code&gt;onError&lt;/code&gt; sits&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/schemas" rel="noopener noreferrer"&gt;Schemas&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/timings" rel="noopener noreferrer"&gt;Timings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;Repo: StabRise/scaledp-ts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ocr</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A document pipeline in 30 lines, running in a browser tab</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Thu, 03 Sep 2026 13:21:20 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/a-document-pipeline-in-30-lines-running-in-a-browser-tab-112c</link>
      <guid>https://dev.to/mykola_melnyk_ml/a-document-pipeline-in-30-lines-running-in-a-browser-tab-112c</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpjvznq34qcw43gt990am.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpjvznq34qcw43gt990am.png" alt="A document pipeline in 30 lines" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written 2026-09-03. Targets &lt;code&gt;@stabrise/scaledp@0.1.1&lt;/code&gt;, &lt;code&gt;pdfjs-dist@6&lt;/code&gt;, &lt;code&gt;onnxruntime-web@1.29&lt;/code&gt;. Assumes TypeScript and npm; assumes nothing about OCR or ONNX.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Install one package plus the engines you actually use, call &lt;code&gt;configure()&lt;/code&gt; once, and build a &lt;code&gt;Pipeline&lt;/code&gt; out of stages. Every line of the thirty is explained below, including the three that people get wrong on the first try.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we are building
&lt;/h2&gt;

&lt;p&gt;Drop a PDF in, get back one row per page, each carrying the recognised text and a word box for every word on the page. No server, no upload, no API key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Node 20+ and a bundler that handles ESM (Vite, Next, Rspack — anything modern)&lt;/li&gt;
&lt;li&gt;A browser with WebAssembly, which is all of them&lt;/li&gt;
&lt;li&gt;Somewhere to serve static assets from your own origin&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @stabrise/scaledp
npm &lt;span class="nb"&gt;install &lt;/span&gt;pdfjs-dist onnxruntime-web ppu-paddle-ocr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;@stabrise/scaledp&lt;/code&gt; is a hard dependency. The engines are &lt;strong&gt;optional peer dependencies&lt;/strong&gt;, reached through a dynamic &lt;code&gt;import()&lt;/code&gt; inside the stage that needs them — so a project that only reads PDF text layers never pulls an ML runtime into its bundle. Install what your pipeline uses and nothing else.&lt;/p&gt;

&lt;p&gt;If you skip one you need, you get a message naming the package rather than a bare &lt;code&gt;Cannot find module&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: serve the assets
&lt;/h2&gt;

&lt;p&gt;This is the step that fails first for almost everyone. Two runtimes need files you have to host yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;onnxruntime-web&lt;/code&gt;'s &lt;code&gt;.wasm&lt;/code&gt; binaries are resolved automatically from the resolved package version, so you usually do not have to touch those — but if you serve them yourself, they must match the loader's build variant &lt;strong&gt;and&lt;/strong&gt; version exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: configure, once
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;configure&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indexeddb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;workerSrc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/pdf.worker.min.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cMapUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/cmaps/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;standardFontDataUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/standard_fonts/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things worth knowing here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is no default &lt;code&gt;workerSrc&lt;/code&gt;.&lt;/strong&gt; The library never guesses where your app puts things — asset URLs, model hosts and auth all come from &lt;code&gt;configure()&lt;/code&gt;. A hardcoded &lt;code&gt;/pdf.worker.min.mjs&lt;/code&gt; inside a library would break every app that serves from a sub-path, so there isn't one. If you forget this, pdf.js fails with &lt;code&gt;Setting up fake worker failed&lt;/code&gt;, and the library rewrites that into a message containing the two-line fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;cache: 'indexeddb'&lt;/code&gt; is what makes the second visit instant.&lt;/strong&gt; Weights are stored per origin and reused. It is opt-in because writing hundreds of megabytes to a user's disk should be a decision, not a default side effect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;onProgress&lt;/code&gt; fires during model downloads, not during inference.&lt;/strong&gt; You want it wired before the first run, because the first run is where the bytes are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: the pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pipeline&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PdfToImage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PaddleTextRecognizer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/ocr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PdfToImage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaddleTextRecognizer&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole thing. &lt;code&gt;transform&lt;/code&gt; accepts a &lt;code&gt;File&lt;/code&gt;, a &lt;code&gt;Blob&lt;/code&gt;, a &lt;code&gt;Uint8Array&lt;/code&gt;, an &lt;code&gt;ArrayBuffer&lt;/code&gt;, a URL string, or rows you built yourself.&lt;/p&gt;

&lt;p&gt;The composition model is worth a paragraph, because it is unusual and it is the reason the rest of the library is small. &lt;strong&gt;Stages are not connected to each other.&lt;/strong&gt; There is no &lt;code&gt;.pipe()&lt;/code&gt;, no interface, no adapter registry. &lt;code&gt;PdfToImage&lt;/code&gt; writes a field called &lt;code&gt;image&lt;/code&gt; on the row; &lt;code&gt;PaddleTextRecognizer&lt;/code&gt; reads a field called &lt;code&gt;image&lt;/code&gt;. They compose because they agree on a name, and the array order is the execution order.&lt;/p&gt;

&lt;p&gt;Which means re-wiring is a string change:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zce62hcka3anx59ggxe.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5zce62hcka3anx59ggxe.png" alt="PdfToImage writes an image column and PaddleTextRecognizer reads it — stages compose by column name" width="799" height="229"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PdfToImage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;outputCol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaddleTextRecognizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;inputCol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;page&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: read the results
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`page &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; failed:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bboxes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// x, y, width, height are in the rendered page's pixel space --&lt;/span&gt;
    &lt;span class="c1"&gt;// the same space PdfToImage produced, so they line up with the image.&lt;/span&gt;
    &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strokeRect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;bboxes&lt;/code&gt; loop is worth seeing rather than describing. Here is the same pipeline run in the &lt;a href="https://scaledp-ts.stabrise.com/demo" rel="noopener noreferrer"&gt;builder&lt;/a&gt;, with the result panel switched to &lt;strong&gt;Boxes&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbbor6h2zodobb1s6plot.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbbor6h2zodobb1s6plot.png" alt="A table of recognised words with score and geometry: ScaleDP 0.948, scalable 0.997, info@stabrise.com 1.000, each with x, y, width, height and angle" width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One row per entry in &lt;code&gt;row.text.bboxes&lt;/code&gt; — the word, the model's confidence in it, and the rectangle it occupies in the rendered page's pixel space. Those &lt;code&gt;x&lt;/code&gt;/&lt;code&gt;y&lt;/code&gt;/&lt;code&gt;w&lt;/code&gt;/&lt;code&gt;h&lt;/code&gt; numbers are what the &lt;code&gt;strokeRect&lt;/code&gt; call above draws.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That &lt;code&gt;exception&lt;/code&gt; check is not defensive habit, it is the contract.&lt;/strong&gt; Stages in this library never throw by default. A failure is recorded in the output schema's &lt;code&gt;exception&lt;/code&gt; field and the pipeline completes, so one bad page does not lose the other forty. If you would rather fail fast while developing, pass &lt;code&gt;propagateError: true&lt;/code&gt; to a stage and it throws instead.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F92yspl2j24784iq8cn35.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F92yspl2j24784iq8cn35.png" alt="PdfToImage deletes the content column by default, so the row after stage one no longer carries the file bytes" width="800" height="240"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: what the first run costs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;execution_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// { stages: { PdfToImage: 412, PaddleTextRecognizer: 1830 }, total: 2244 }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those milliseconds exclude the model download, which on the first run dominates everything else. PaddleOCR's default &lt;code&gt;v6-small&lt;/code&gt; preset is about 6 MB — small enough that most users will not notice. Adding NER changes that picture completely; the default GLiNER model is around 580 MB, and you should check the cache and warn before starting that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The complete 30 lines
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;configure&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PdfToImage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PaddleTextRecognizer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/ocr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indexeddb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;workerSrc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/pdf.worker.min.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PdfToImage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaddleTextRecognizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;keepFormatting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readDocument&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exception&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;words&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;words&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bboxes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;resolution: 300&lt;/code&gt; is a real memory decision.&lt;/strong&gt; A 300 DPI A4 page is roughly 35 MB of raw pixels, and a 40-page PDF becomes 40 rows that all exist at once. Drop to 200 for speed and memory, go to 400 only for small print, and use &lt;code&gt;pageLimit&lt;/code&gt; on long documents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;keepFormatting: true&lt;/code&gt; costs you a clean string.&lt;/strong&gt; It preserves layout by inserting spaces and newlines, which is what you want for reading and for NER context — and what you do not want if you are about to run an exact-match search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reusing one &lt;code&gt;Pipeline&lt;/code&gt; across files is fine and intended&lt;/strong&gt;, but call &lt;code&gt;dispose()&lt;/code&gt; when you are done to release ONNX sessions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;Add NER, run it in a worker so the UI never blocks, or skip OCR entirely on PDFs that already have a text layer — that last one is the highest-leverage change in most real pipelines, because most PDFs are not scans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @stabrise/scaledp pdfjs-dist onnxruntime-web ppu-paddle-ocr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;a href="https://scaledp-ts.stabrise.com/demo?p=W3sidHlwZSI6IlBkZlRvSW1hZ2UiLCJvcHRpb25zIjp7InJlc29sdXRpb24iOjMwMH19LHsidHlwZSI6IlBhZGRsZVRleHRSZWNvZ25pemVyIiwib3B0aW9ucyI6eyJrZWVwRm9ybWF0dGluZyI6dHJ1ZX19XQ" rel="noopener noreferrer"&gt;Open exactly this pipeline in the builder&lt;/a&gt;&lt;/strong&gt; — &lt;code&gt;PdfToImage({ resolution: 300 })&lt;/code&gt; then &lt;code&gt;PaddleTextRecognizer({ keepFormatting: true })&lt;/code&gt;, the same two stages as the thirty lines above. Drop your own PDF on it, or use one of the samples. Nothing you drop there is uploaded anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/quickstart" rel="noopener noreferrer"&gt;Quickstart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/columns" rel="noopener noreferrer"&gt;Columns are the wiring&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/error-contract" rel="noopener noreferrer"&gt;The error contract&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/installation" rel="noopener noreferrer"&gt;Installation, and the assets you must serve&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://scaledp-ts.stabrise.com/demo" rel="noopener noreferrer"&gt;Live builder&lt;/a&gt; — the same pipeline, on your own file&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ocr</category>
      <category>typescript</category>
      <category>tutorial</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Spark's coupling was thinner than it looked</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Thu, 03 Sep 2026 10:28:05 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/sparks-coupling-was-thinner-than-it-looked-4ani</link>
      <guid>https://dev.to/mykola_melnyk_ml/sparks-coupling-was-thinner-than-it-looked-4ani</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3618mala60bd90wvn2j1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3618mala60bd90wvn2j1.png" alt="Spark's coupling was thinner than it looked" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written 2026-09-03, about porting &lt;a href="https://github.com/StabRise/ScaleDP" rel="noopener noreferrer"&gt;ScaleDP&lt;/a&gt; (Python/Spark) to &lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;&lt;code&gt;@stabrise/scaledp@0.1.1&lt;/code&gt;&lt;/a&gt; (TypeScript/browser). Assumes you have seen a Spark DataFrame; assumes no deep Spark knowledge.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Porting a Spark-based document library to the browser sounded like it meant replacing a distributed execution engine. It did not. Every stage was a pure &lt;code&gt;Transformer&lt;/code&gt;, and the only DataFrame surface stages used was &lt;code&gt;withColumn&lt;/code&gt; / &lt;code&gt;drop&lt;/code&gt; / &lt;code&gt;select&lt;/code&gt;. That reduces to an array of plain objects and a 215-line runner. The generalisable move: audit the surface a framework's users actually touch before assuming you need the framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption going in
&lt;/h2&gt;

&lt;p&gt;ScaleDP is a document-processing library built on Apache Spark. The pipelines look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PipelineModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;PdfDataToImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolution&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;TesseractOcr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputCol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outputCol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepFormatting&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Ner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;inputCol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;outputCol&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ner&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan was to run the same pipelines in a browser. The obvious reading of "built on Spark" is that Spark is doing something load-bearing — partitioning, shuffles, a query planner, lazy evaluation, a distributed scheduler — and that replacing it means either reimplementing a scheduler or accepting a much smaller feature set.&lt;/p&gt;

&lt;p&gt;So before writing anything, we read every stage and made two lists: which Spark APIs are actually called, and which Spark &lt;em&gt;semantics&lt;/em&gt; are actually depended on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the audit found
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Finding one: there are no &lt;code&gt;Estimator&lt;/code&gt;s.&lt;/strong&gt; Spark ML's core abstraction is the &lt;code&gt;Estimator&lt;/code&gt;/&lt;code&gt;Transformer&lt;/code&gt; split — &lt;code&gt;fit()&lt;/code&gt; produces a model from data, &lt;code&gt;transform()&lt;/code&gt; applies it. Half the complexity of a pipeline framework exists to model that duality: fitting in dependency order, caching fitted models, distinguishing a &lt;code&gt;Pipeline&lt;/code&gt; from a &lt;code&gt;PipelineModel&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;ScaleDP has none. Every stage is a pure &lt;code&gt;Transformer&lt;/code&gt;. Nothing is fitted, because OCR models and NER models arrive pre-trained. The entire &lt;code&gt;fit&lt;/code&gt; half of the framework was unused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finding two: the DataFrame surface is three methods.&lt;/strong&gt; Grepping every stage for DataFrame operations turned up &lt;code&gt;withColumn&lt;/code&gt;, &lt;code&gt;drop&lt;/code&gt; and &lt;code&gt;select&lt;/code&gt;. No joins. No &lt;code&gt;groupBy&lt;/code&gt;. No window functions. No aggregations. No SQL.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;posexplode&lt;/code&gt; appears, for turning a PDF into one row per page — which is the one genuinely interesting operation, and it is "return several rows instead of one".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finding three: no stage depends on laziness.&lt;/strong&gt; Nothing relied on the query planner reordering work, pushing down a predicate, or fusing stages. The pipeline is executed in the order written.&lt;/p&gt;

&lt;p&gt;Three findings, one conclusion: &lt;strong&gt;the Spark coupling is one abstraction thick.&lt;/strong&gt; Underneath is an ordered list of pure functions over records with named fields.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe87xlydqaeoepoqe5wd8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe87xlydqaeoepoqe5wd8.png" alt="Stages use withColumn, drop, select and posexplode, and none of the rest of the DataFrame API" width="800" height="287"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What was left after removing it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** One record flowing through the pipeline. Stages read and write named fields. */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the data model. &lt;code&gt;withColumn&lt;/code&gt; becomes &lt;code&gt;{ ...row, [outputCol]: value }&lt;/code&gt;. &lt;code&gt;drop&lt;/code&gt; becomes &lt;code&gt;delete row[inputCol]&lt;/code&gt;. &lt;code&gt;select&lt;/code&gt; is not needed at all, because a plain object already lets you read whatever you want.&lt;/p&gt;

&lt;p&gt;And the stage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Stage&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;P&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nx"&gt;BaseStageParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BaseStageParams&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;

  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;P&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="cm"&gt;/**
   * Transform one row's input value into this stage's output value.
   * Throwing is fine and expected -- `transform` converts it into the output
   * schema's `exception` field unless `propagateError` is set.
   */&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StageContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="cm"&gt;/** Value written to `outputCol` when `apply` throws. */&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="nf"&gt;onError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;

  &lt;span class="cm"&gt;/** A stage may emit several rows per input row -- PDF page explosion. */&lt;/span&gt;
  &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;expand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StageContext&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Row&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two abstract methods and one optional override. &lt;code&gt;apply&lt;/code&gt; for the single-value case, &lt;code&gt;expand&lt;/code&gt; for &lt;code&gt;posexplode&lt;/code&gt;, &lt;code&gt;onError&lt;/code&gt; for the error contract. The base class handles column wiring, error capture, &lt;code&gt;keepInputData&lt;/code&gt; and timing — so those behave identically across all eighteen stages instead of being reimplemented eighteen times.&lt;/p&gt;

&lt;p&gt;The runner is a nested loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;throwIfAborted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;215 lines including the timing columns, the input normalisation and the docs comments.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxriix6ijfkpao549lw9a.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxriix6ijfkpao549lw9a.png" alt="A DataFrame operation reduces to an object spread, and the whole runner to 215 lines" width="800" height="248"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Spark was genuinely providing
&lt;/h2&gt;

&lt;p&gt;This is the honest other half, and skipping it would make the post a sales pitch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Horizontal scale.&lt;/strong&gt; Forty executors, forty PDFs, wall-clock time divided by forty. One tab is one machine, and there is no story here beyond "open more tabs", which is not a story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spilling to disk.&lt;/strong&gt; A dataset larger than memory. The browser runner holds its rows in memory. A 300 DPI A4 page is roughly 35 MB of raw pixels, so a long document is a real constraint rather than a theoretical one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fault tolerance across a cluster.&lt;/strong&gt; A lost executor is retried elsewhere. A crashed tab is a crashed tab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Being the thing your data platform already runs.&lt;/strong&gt; Not a technical property, and often the deciding one.&lt;/p&gt;

&lt;p&gt;If your workload needs any of those, use the Python library. The pipeline you wrote in TypeScript ports back nearly line for line, which is exactly why the stage and parameter names were kept identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The generalisable part
&lt;/h2&gt;

&lt;p&gt;The framework question is usually asked as "can we replace X?" — which invites an answer scoped to everything X can do. The more useful question is &lt;strong&gt;"which of X's capabilities does our code actually call, and which of its semantics do we actually depend on?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those are two different lists and the second is the harder one. Method calls you can grep for. Semantic dependencies — laziness, ordering guarantees, transactionality, at-least-once delivery — hide in the absence of code. The way to find them is to ask, per stage, "what breaks if this ran eagerly / in a different order / twice?"&lt;/p&gt;

&lt;p&gt;In this case both lists came back nearly empty, and the port became a transcription rather than a rewrite. That is not the usual outcome, and the audit is what told us which one we were in. It cost about a day and would have been worth it either way: had the answer come back the other way, we would have known before writing code instead of three weeks in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;We gave up the ability to say "just add executors".&lt;/strong&gt; The ceiling is one machine, permanently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;215 lines of runner is 215 lines we now maintain.&lt;/strong&gt; Spark's scheduler is battle-tested; ours is not. It is small enough to read in one sitting, which is the mitigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Matching Spark-era names locks in API decisions we did not make.&lt;/strong&gt; &lt;code&gt;inputCol&lt;/code&gt;/&lt;code&gt;outputCol&lt;/code&gt; is not what you would design fresh for TypeScript. Portability was worth more than taste — a pipeline should read the same in both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audit is only as good as the reading.&lt;/strong&gt; We could have missed a semantic dependency. The parity test suites, which diff against goldens generated by running the real Python, are what would catch it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/pipelines-and-rows" rel="noopener noreferrer"&gt;Pipelines and rows&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/stages" rel="noopener noreferrer"&gt;The stage lifecycle&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/porting-from-python" rel="noopener noreferrer"&gt;Porting from Python ScaleDP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/ScaleDP" rel="noopener noreferrer"&gt;Python: StabRise/ScaleDP&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;TypeScript: StabRise/scaledp-ts&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>opensource</category>
      <category>spark</category>
      <category>webdev</category>
      <category>documentprocessing</category>
    </item>
    <item>
      <title>Your OCR pipeline probably uploads the document. It doesn't have to</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Thu, 03 Sep 2026 10:27:36 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/your-ocr-pipeline-probably-uploads-the-document-it-doesnt-have-to-5c9d</link>
      <guid>https://dev.to/mykola_melnyk_ml/your-ocr-pipeline-probably-uploads-the-document-it-doesnt-have-to-5c9d</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq8dzr3s3odfiw8gp0hhy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq8dzr3s3odfiw8gp0hhy.png" alt="Your OCR pipeline probably uploads the document" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Written 2026-09-03. Code examples target &lt;code&gt;@stabrise/scaledp@0.1.1&lt;/code&gt;. No prior OCR or ONNX knowledge assumed.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Every mainstream OCR API has the same architecture — your document goes over the wire to someone else's disk. Browser-native inference removes that box entirely: &lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;&lt;code&gt;@stabrise/scaledp&lt;/code&gt;&lt;/a&gt; runs PDF rendering, text detection, OCR and entity recognition on &lt;code&gt;onnxruntime-web&lt;/code&gt;, in the tab, with no upload. That is a real win for sensitive documents and a real cost in first-load bytes. This post is about both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture every OCR service has
&lt;/h2&gt;

&lt;p&gt;Draw the data flow of any hosted document API and you get three boxes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[your app] --HTTPS--&amp;gt; [their API] --&amp;gt; [their storage + GPUs]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third box is the one that generates paperwork. It is why OCR procurement involves a data processing agreement, a retention policy, a sub-processor list, and a conversation with whoever owns compliance. The engineering is five lines; the approval is five weeks.&lt;/p&gt;

&lt;p&gt;That box also has a price. Hosted OCR is billed per page — typically somewhere between $0.50 and $1.50 per thousand pages, plus egress. For a product that OCRs every uploaded file, that is a line item that grows exactly as fast as your usage does.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybwevlln0p85j9f6hlc6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fybwevlln0p85j9f6hlc6.png" alt="Hosted OCR routes the document through a third party; browser-native inference has no third box in the path" width="800" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of this is a criticism of hosted APIs. For 10,000 pages a night they are the correct answer, and I say so in the trade-offs below. The point is narrower: &lt;strong&gt;the third box is not a law of physics.&lt;/strong&gt; The models are small enough to run in a browser, and &lt;code&gt;onnxruntime-web&lt;/code&gt; is good enough to run them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same pipeline, without the third box
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[your app] --&amp;gt; [WASM/WebGPU in the tab]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a complete, runnable pipeline. It renders a PDF, reads it with PaddleOCR, and pulls out entities — and it makes no request to any server of ours, because there isn't one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;configure&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PdfToImage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/pdf&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PaddleTextRecognizer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/ocr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GlinerNer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/ner&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indexeddb&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;workerSrc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/pdf.worker.min.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;onProgress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loaded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;loaded&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PdfToImage&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaddleTextRecognizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;keepFormatting&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;GlinerNer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;person&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;organization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;phone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`page &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;:`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entities&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entity_group&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;entity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;page 1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;INVOICE 2024-0417  Acme Industries GmbH  Rechnungsdatum 14.03.2024 …&lt;/span&gt;
  &lt;span class="s"&gt;organization = Acme Industries GmbH&lt;/span&gt;
  &lt;span class="s"&gt;person = Marta Feldmann&lt;/span&gt;
  &lt;span class="s"&gt;email = accounts@acme-industries.de&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row per page. Each stage reads a named field on the row and writes another one — &lt;code&gt;PdfToImage&lt;/code&gt; writes &lt;code&gt;image&lt;/code&gt;, &lt;code&gt;PaddleTextRecognizer&lt;/code&gt; reads it and writes &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;GlinerNer&lt;/code&gt; reads that and writes &lt;code&gt;ner&lt;/code&gt;. That is the whole composition model, and it is the same one the Python &lt;a href="https://github.com/StabRise/ScaleDP" rel="noopener noreferrer"&gt;ScaleDP&lt;/a&gt; library uses on Spark.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually disappears
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The DPA.&lt;/strong&gt; Not "is easier to get" — is not needed for this data flow. There is no processor, because there is no third party in the path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The retention question.&lt;/strong&gt; "How long do you keep it?" has an answer that requires no policy document: the bytes were in a tab, and the tab closed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Egress and per-page cost.&lt;/strong&gt; After the first load, the marginal cost of a page is CPU time on a machine you are not paying for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round-trip latency on re-runs.&lt;/strong&gt; This is the underrated one. Changing a threshold and re-running is 200 ms, not a network round trip, which changes what kind of UI you can build. An interactive parameter panel over a hosted API is a rate-limiting problem; over a local pipeline it is just a re-render.&lt;/p&gt;

&lt;h2&gt;
  
  
  What appears instead
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Bytes on first load.&lt;/strong&gt; PaddleOCR's default preset (&lt;code&gt;v6-small&lt;/code&gt;) is about 6 MB of weights — genuinely small. The default GLiNER NER model is &lt;strong&gt;~580 MB&lt;/strong&gt;. That is not a rounding error, and it is the single biggest objection to this whole approach. Mitigations exist — smaller models (&lt;code&gt;gliner-pii-edge&lt;/code&gt; is ~181 MB), IndexedDB caching so it downloads once, self-hosting the weights from your own origin — but the first visit pays.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy0vdc5r34jrhovrfw6us.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy0vdc5r34jrhovrfw6us.png" alt="OCR weights are about 6 MB; the default zero-shot NER model is about 580 MB" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A device you do not control.&lt;/strong&gt; Your server has known cores and known memory. Your user's machine has neither. WebGPU may be available or may not; threads need response headers your host may not send. The library reports what it actually got rather than assuming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isWebGpuAvailable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isCrossOriginIsolated&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@stabrise/scaledp/ocr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;webgpu&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isWebGpuAvailable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;   &lt;span class="c1"&gt;// false on plenty of real machines&lt;/span&gt;
  &lt;span class="na"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;isCrossOriginIsolated&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;    &lt;span class="c1"&gt;// false unless COOP/COEP are set&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Throughput.&lt;/strong&gt; One tab is one machine. There is no horizontal scaling story here beyond "open more tabs", and that is not a story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;At volume, a server wins and it is not close.&lt;/strong&gt; If you are processing 10,000 pages a night, the per-page cost of a GPU you already own beats the per-page cost of downloading 580 MB to ten thousand browsers. Use the &lt;a href="https://github.com/StabRise/ScaleDP" rel="noopener noreferrer"&gt;Python sibling&lt;/a&gt;; the pipeline code ports almost mechanically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 580 MB is real and unavoidable if you want good multilingual NER.&lt;/strong&gt; You can drop to a 181 MB model, drop NER entirely and keep OCR at 6 MB, or self-host from a fast origin — but you cannot make a good zero-shot NER model small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First run is much slower than the tenth.&lt;/strong&gt; Model download dominates the first pipeline run completely. Design the UI around that (&lt;code&gt;onProgress&lt;/code&gt;, a cache check before you start) rather than hoping users are patient.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AGPL-3.0-or-later.&lt;/strong&gt; Same licence as the Python library. If that does not work for a closed-source product, &lt;a href="https://stabrise.com" rel="noopener noreferrer"&gt;StabRise&lt;/a&gt; does commercial licensing. Worth knowing before you build on it, not after.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @stabrise/scaledp onnxruntime-web ppu-paddle-ocr pdfjs-dist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engines are optional peer dependencies — install only the ones your pipeline uses. Importing the core pulls in no ML runtime at all.&lt;/p&gt;

&lt;p&gt;Drop your own PDF into the builder at &lt;a href="https://scaledp-ts.stabrise.com/demo" rel="noopener noreferrer"&gt;scaledp-ts.stabrise.com/demo&lt;/a&gt;, pick a detector and a recognizer, and watch the network panel while it runs. After the models are cached you can turn the wifi off and run it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/scaledp-ts" rel="noopener noreferrer"&gt;Repo: StabRise/scaledp-ts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://scaledp-ts.stabrise.com/docs/quickstart" rel="noopener noreferrer"&gt;Quickstart&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/models-and-cache" rel="noopener noreferrer"&gt;Models and caching&lt;/a&gt; — sizes, self-hosting, private repos&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://scaledp-ts.stabrise.com/docs/concepts/execution-providers" rel="noopener noreferrer"&gt;Execution providers&lt;/a&gt; — WebGPU, threads, and what your host has to send&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/StabRise/ScaleDP" rel="noopener noreferrer"&gt;Python sibling: StabRise/ScaleDP&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>privacy</category>
      <category>opensource</category>
      <category>ocr</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Tue, 25 Aug 2026 07:55:12 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/-53bn</link>
      <guid>https://dev.to/mykola_melnyk_ml/-53bn</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/mykola_melnyk_ml/i-built-a-windows-app-that-actually-removes-pii-from-pdfs-not-just-covers-it-lhf" class="crayons-story__hidden-navigation-link"&gt;I Built a Windows App That Actually Removes PII From PDFs (Not Just Covers It)&lt;/a&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/mykola_melnyk_ml" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4082372%2F697b1ae8-84e9-4705-884a-5142627d34c1.jpg" alt="mykola_melnyk_ml profile" class="crayons-avatar__image" width="96" height="96"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/mykola_melnyk_ml" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Mykola Melnyk
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Mykola Melnyk
                
                
              
              &lt;div id="story-author-preview-content-4481584" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/mykola_melnyk_ml" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4082372%2F697b1ae8-84e9-4705-884a-5142627d34c1.jpg" class="crayons-avatar__image" alt="" width="96" height="96"&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Mykola Melnyk&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/mykola_melnyk_ml/i-built-a-windows-app-that-actually-removes-pii-from-pdfs-not-just-covers-it-lhf" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Aug 25&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/mykola_melnyk_ml/i-built-a-windows-app-that-actually-removes-pii-from-pdfs-not-just-covers-it-lhf" id="article-link-4481584"&gt;
          I Built a Windows App That Actually Removes PII From PDFs (Not Just Covers It)
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/windows"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;windows&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/privacy"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;privacy&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/ai"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;ai&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/productivity"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;productivity&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/mykola_melnyk_ml/i-built-a-windows-app-that-actually-removes-pii-from-pdfs-not-just-covers-it-lhf" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="24" height="24"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;3&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/mykola_melnyk_ml/i-built-a-windows-app-that-actually-removes-pii-from-pdfs-not-just-covers-it-lhf#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              1&lt;span class="hidden s:inline"&gt;&amp;nbsp;comment&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            3 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
    </item>
    <item>
      <title>I Built a Windows App That Actually Removes PII From PDFs (Not Just Covers It)</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Tue, 25 Aug 2026 06:59:57 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/i-built-a-windows-app-that-actually-removes-pii-from-pdfs-not-just-covers-it-lhf</link>
      <guid>https://dev.to/mykola_melnyk_ml/i-built-a-windows-app-that-actually-removes-pii-from-pdfs-not-just-covers-it-lhf</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0b3ox68gr4nlc3uaggf4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0b3ox68gr4nlc3uaggf4.png" alt="PDF Redaction Studio Desktop sign-in screen" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Redacting a PDF sounds simple until you actually have to do it properly. Drawing a black rectangle over a name or an SSN doesn't remove it — the text is still sitting underneath, extractable with a copy-paste or a basic PDF parser. Doing it right means the sensitive content is gone from the file, not hidden under a layer.&lt;/p&gt;

&lt;p&gt;I've been working on &lt;strong&gt;PDF Redaction Studio Desktop&lt;/strong&gt;, a native Windows (and Linux) app that handles this end to end: AI-based PII detection, a review step so you stay in control of what gets flagged, and an export that actually strips the matched content out of the PDF.&lt;/p&gt;

&lt;p&gt;It just landed on the Microsoft Store, so here's a walkthrough of what it does and how it's put together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a desktop app instead of just a web app
&lt;/h2&gt;

&lt;p&gt;A web version already existed, but a chunk of users — legal, HR, healthcare, anyone handling regulated documents — didn't want to upload files anywhere to get them redacted. The desktop app runs the detection stack (OCR, PII/NER models, face detection) locally, so documents stay on the machine by default. No Docker, no backend to stand up, no account required to just open a PDF and start reviewing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dashboard
&lt;/h2&gt;

&lt;p&gt;Once installed, you land on a dashboard that tracks documents, completed redactions, and page usage — useful if you're processing a batch rather than a one-off file.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg37i2mr9n88oyzfpn6pn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg37i2mr9n88oyzfpn6pn.png" alt="Dashboard showing document list, usage stats, and quick actions" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Opening a document
&lt;/h2&gt;

&lt;p&gt;Drag a PDF in, or click to browse. Nothing happens with the file until you tell it to.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw30pcnqaohdm1ucrl53h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw30pcnqaohdm1ucrl53h.png" alt="Start Redacting Your PDF dialog with drag-and-drop upload area" width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Detection runs locally
&lt;/h2&gt;

&lt;p&gt;This is the part I care about most. Detection can run against a cloud API or entirely in-browser/on-device using a local model — no network round-trip for the document contents. You pick the model, tune the detection threshold (higher = fewer but more confident matches), and set how many CPU threads to throw at it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F58rax0silaqcpll446ik.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F58rax0silaqcpll446ik.png" alt="PII detection settings panel with local model selection, detection threshold slider, and CPU thread count" width="800" height="526"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Reviewing what got flagged
&lt;/h2&gt;

&lt;p&gt;After detection, matches are grouped by category — names, SSNs, emails, phone numbers, driver's license numbers, addresses, credit cards, faces — and highlighted directly on the page. You can filter by category, add anything the model missed, and remove false positives before committing to anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbn81rpapua0m74hz518y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbn81rpapua0m74hz518y.png" alt="Redaction review UI with PII highlighted on a document page and category filters in the sidebar" width="800" height="455"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only after you're happy with the review does export actually strip the matched content from the PDF — text, embedded objects, and image regions covering faces or signatures all get removed rather than painted over.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's under the hood, roughly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;OCR and layout parsing for scanned documents, not just digital-text PDFs&lt;/li&gt;
&lt;li&gt;A multilingual PII/NER model bundled for local, offline detection&lt;/li&gt;
&lt;li&gt;Face and signature detection for scanned forms and ID pages&lt;/li&gt;
&lt;li&gt;Custom rule support for anything the built-in categories don't cover — internal codenames, account numbers, whatever your compliance checklist needs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it stands today
&lt;/h2&gt;

&lt;p&gt;Windows and Linux builds are out now; macOS is in progress. If you're already comfortable with command-line tools there's also a Snap package for Linux, and the desktop app shares the same detection engine as the web version, so behavior is consistent across both.&lt;/p&gt;

&lt;p&gt;The free tier covers 100 pages a month, which is enough to actually try it against your own documents before deciding whether it fits your workflow.&lt;/p&gt;

&lt;p&gt;If you try it and hit a document type it doesn't handle well — dense forms, unusual layouts, non-Latin scripts — I'd genuinely like to hear about it. That's the kind of edge case that's easy to miss without real-world documents to test against.&lt;/p&gt;

</description>
      <category>windows</category>
      <category>privacy</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Automating PDF Redaction in n8n (No Custom API Code Required)</title>
      <dc:creator>Mykola Melnyk</dc:creator>
      <pubDate>Tue, 18 Aug 2026 09:55:15 +0000</pubDate>
      <link>https://dev.to/mykola_melnyk_ml/automating-pdf-redaction-in-n8n-no-custom-api-code-required-3561</link>
      <guid>https://dev.to/mykola_melnyk_ml/automating-pdf-redaction-in-n8n-no-custom-api-code-required-3561</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs3x725kxvniakl60g0cu.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs3x725kxvniakl60g0cu.jpg" alt="Complete workflow: Manual Trigger → HTTP Request → PDF Redaction, all executed successfully" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you run n8n workflows that touch PDFs — invoices, contracts, HR forms, scanned intake documents — sooner or later you hit the same wall: someone downstream doesn't need to see the names, emails, or ID numbers in that file. Maybe it's going to a shared drive, a support ticket, an analytics pipeline, or a third-party API you'd rather not hand raw PII to.&lt;/p&gt;

&lt;p&gt;The usual fix is writing a custom &lt;code&gt;HTTP Request&lt;/code&gt; call to some redaction API, wiring up auth headers, and hand-rolling the JSON body every time. There's now a community node that skips that step: &lt;a href="https://www.npmjs.com/package/@stabrise/n8n-nodes-pdf-redaction" rel="noopener noreferrer"&gt;&lt;code&gt;@stabrise/n8n-nodes-pdf-redaction&lt;/code&gt;&lt;/a&gt;, which drops PDF redaction straight into the node panel.&lt;/p&gt;

&lt;p&gt;This post walks through installing it and building a small end-to-end workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the node actually does
&lt;/h2&gt;

&lt;p&gt;It wraps the &lt;a href="https://pdf-redaction.com" rel="noopener noreferrer"&gt;PDF Redaction&lt;/a&gt; API in three operations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anonymize&lt;/strong&gt; — pick PII categories (names, emails, addresses, credit cards, faces, signatures, and more) and get back a redacted PDF where the matched content is genuinely removed, not just covered with a black box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anonymize with Custom Prompt&lt;/strong&gt; — skip the tag list and describe what to redact in plain English, e.g. &lt;code&gt;"redact anything that looks like an internal project codename"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect PII&lt;/strong&gt; — scan a document and get back the entities found (with bounding boxes), without touching the file. Useful for an audit step or for branching a workflow before you commit to redacting anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both digital and scanned/OCR'd PDFs are supported, across multiple languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;In n8n: &lt;strong&gt;Settings → Community Nodes → Install&lt;/strong&gt;, then enter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@stabrise/n8n-nodes-pdf-redaction
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That requires n8n 1.60.0+. Once it's installed, "PDF Redaction" shows up as a node, and "PDF Redaction API" shows up as a credential type.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frikzux3h5xskcwf9powg.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frikzux3h5xskcwf9powg.jpg" alt="Install community node dialog with the package name filled in" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Get an API key
&lt;/h2&gt;

&lt;p&gt;Grab a free key at &lt;a href="https://pdf-redaction.com/apikeys/" rel="noopener noreferrer"&gt;pdf-redaction.com/apikeys&lt;/a&gt; — the free tier gives you 100 pages/month, 10 pages per request, 5 requests/minute, which is plenty for testing a workflow. In n8n, create a new &lt;strong&gt;PDF Redaction API&lt;/strong&gt; credential and paste the key in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build a minimal workflow
&lt;/h2&gt;

&lt;p&gt;Here's a three-node workflow that fetches a public sample PDF and redacts a face in it — you can reproduce this exactly, no files of your own needed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Manual Trigger&lt;/strong&gt; → &lt;strong&gt;2. HTTP Request&lt;/strong&gt; → &lt;strong&gt;3. PDF Redaction&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;HTTP Request node config:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Method: &lt;code&gt;GET&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;URL: &lt;code&gt;https://raw.githubusercontent.com/StabRise/pdf-redaction-api/main/examples/pdfs/SampleWithFace.pdf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Options → Response → Response Format: &lt;code&gt;File&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That writes the PDF as binary data into a field called &lt;code&gt;data&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;PDF Redaction node config:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Credential: your PDF Redaction API credential&lt;/li&gt;
&lt;li&gt;Operation: &lt;code&gt;Anonymize&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Additional Fields → Tags: &lt;code&gt;Face&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Input/Output Binary Field: &lt;code&gt;data&lt;/code&gt; (the defaults line up with what HTTP Request produced, no renaming needed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcm4pas7n58a6ajfwpfvq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcm4pas7n58a6ajfwpfvq.jpg" alt="PDF Redaction node configured with the Anonymize operation and Face tag, reading input from the HTTP Request node" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Run it, and the output panel gives you two things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A binary &lt;code&gt;redacted.pdf&lt;/code&gt; with the face blacked out and the underlying pixel data actually removed.&lt;/li&gt;
&lt;li&gt;A JSON payload with &lt;code&gt;detected_pii&lt;/code&gt; (each match plus bounding box coordinates) and a &lt;code&gt;processing_time&lt;/code&gt; breakdown per pipeline stage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmlb2w2k2wvj36xzuhq1n.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmlb2w2k2wvj36xzuhq1n.jpg" alt="Rendered page of the redacted PDF, showing both faces blacked out" width="800" height="1132"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The original 823 kB file comes back at 377 kB — the API flattens the processed page(s) into a compact PDF rather than just painting over them.&lt;/p&gt;

&lt;p&gt;Swap the tag to something like &lt;code&gt;Person Name, Email, Address&lt;/code&gt; and you've got a general-purpose PII stripper for whatever document type flows into that branch of your workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Detect-then-branch pattern
&lt;/h2&gt;

&lt;p&gt;If you don't want to redact unconditionally, put a &lt;strong&gt;Detect PII&lt;/strong&gt; node in front of an &lt;strong&gt;IF&lt;/strong&gt; node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP Request → PDF Redaction (Detect PII) → IF (detected_pii is non-empty) → PDF Redaction (Anonymize)
                                                                            → (pass through unchanged)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Detect PII&lt;/code&gt; has no output binary field — it only returns the &lt;code&gt;detected_pii&lt;/code&gt; array — so it's cheap to run as a gate before you decide whether the full anonymize step is even necessary. This is the pattern I'd reach for in something like a document-intake pipeline where most files are already clean and you don't want to touch every single one.&lt;/p&gt;

&lt;p&gt;One gotcha worth calling out: if you leave the &lt;strong&gt;Tags&lt;/strong&gt; field empty on &lt;code&gt;Detect PII&lt;/code&gt;, you get back whatever the API detects by default, not everything it's capable of finding. Set the tags you actually care about explicitly if you want predictable results.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnu1nr66r3nvzcu6rlyj5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnu1nr66r3nvzcu6rlyj5.jpg" alt="PDF Redaction node configured with the Detect PII operation and Date, Email, Person Name tags" width="800" height="478"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;The node reads/writes n8n's standard binary data fields, so anything that can already put a PDF into a binary property works upstream — a webhook payload, an email trigger with an attachment, Read/Write File from Disk, or an S3/Google Drive node. Nothing about the redaction step cares where the PDF came from.&lt;/p&gt;

&lt;p&gt;Full API reference, the complete tag list, and more tutorials (including custom-prompt redaction) are in the &lt;a href="https://pdf-redaction.com/docs/integrations/n8n/" rel="noopener noreferrer"&gt;n8n integration docs&lt;/a&gt;. Source for the node itself is on &lt;a href="https://github.com/StabRise/n8n-nodes-pdf-redaction" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>n8n</category>
      <category>automation</category>
      <category>privacy</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
