# scribe.js, an OCR library whose npm name, version, and browser story all need explaining

> Scribe.js reads text out of images and PDFs and writes searchable PDFs with an invisible text layer, in JavaScript, with no build step. What the readme does not do is match the repository. The published package carries a different name, the manifest declares a version four minor releases ahead of the newest tag, the one-call convenience function has no language parameter, and the browser path is same-origin only with no bundled build and no module map. The tree underneath holds a command line tool, a protocol server, a user interface, and cloud adapters that the front page never mentions.

**scribeocr/scribe.js** — JavaScript OCR and text extraction for images and PDFs. Projects Scribe OCR: officially supported GUI front-end for Scribe.js Site at scribeocr.com, repo at github.com/scribeocr/scribeocr If you have a project or example repo that uses Scribe.js, feel free to add it to this list using a pull request.

- Repository: https://github.com/scribeocr/scribe.js
- Stars: 322 · Forks: 24
- Language: JavaScript
- License: AGPL-3.0
- Published: 2026-08-08 · Updated: 2026-08-18 · Language: en
- Canonical page: https://hysenlabs.com/projects/scribeocr-scribe-js

## The published package is not called scribe.js

The repository, the readme's heading, and the package you install are three different strings. The repository is named after the library with a dot. The readme calls it the same. The install line tells you to fetch a different name entirely:

```bash
npm i scribe.js-ocr
```

Both import examples use that name, and one of them reaches past the package boundary to reach a file inside it, which is the only way the browser path works without a bundler:

```js
// Node.js, or in the browser with a bundler
import scribe from 'scribe.js-ocr';
// In the browser without a bundler (import map or relative path):
import scribe from 'node_modules/scribe.js-ocr/scribe.js';
```

The manifest's entry point is a single file at the root of the package, named after the library rather than the package, which is why that raw path resolves to anything at all. The result is a package whose public name and whose entry file have nothing to do with each other, and a search for one of them does not find the other.

## The manifest is four minor versions ahead of the newest tag

The manifest in the default branch declares one version. The three published releases declare three others, and none of them matches:

- v0.11.0, 4 May 2026
- v0.11.3, 6 May 2026
- v0.12.0, 27 May 2026

The branch has moved since all three. The last push is 25 September 2026, four months after the newest tag, and the version in the manifest is four minor releases beyond that tag. So the code on the branch is not what the newest release contains, and the manifest is not describing the newest release either.

Which of the two numbers reaches a user installing from the registry is not something the repository settles. If releases are cut from the branch and the manifest is bumped ahead of the tag, the registry has a newer version than the newest tag. If the tag is cut and the manifest is bumped afterwards without a release, the registry is behind. The readme names no version at all, so there is no third source to break the tie.

All three release titles are the version number and nothing else, so the tags carry no description of what changed between them either.

## The one-call function cannot choose a language and is not for production

The readme opens with a single function that takes a list of inputs and returns text:

```js
const text = await scribe.extractText(['https://tesseract.projectnaptha.com/img/eng_bw.png']);
console.log(text);
await scribe.terminate();                               // release everything after all recognition completed, so a Node process can exit
```

Two things are said about it. It uses reasonable default settings, and it is generally not ideal for production use. The first is a statement about a language: the document API below takes a list of languages, and this function takes no such argument, so a caller of the simple path has no way to select one and gets whatever the library's default is.

The second sentence is a warning with no remedy attached. The readme's response to it is one paragraph later, telling you that for full control you should create a document object instead. It does not list what the extra control is, which for the visible API means a language list, a choice of output format, and a document you can hold onto. A production migration from the helper is therefore a rewrite with an undimensioned surface.

The example URL is also a remote image on somebody else's host, which is a reasonable way to make a one-line example runnable and a poor thing to copy into a project that will run in a browser under a strict content policy.

## Teardown is a manual call and it lives on the module, not the document

Read the second example again, because the shape matters more than the content:

```js
const doc = await scribe.openDocument(['receipt.png']); // image, PDF, or existing OCR file(s)
await doc.recognize({ langs: ['eng'] });                // run OCR
await doc.download('pdf', 'receipt.pdf');               // write a searchable PDF
await scribe.terminate();                               // release everything after all recognition completed, so a Node process can exit
```

The document object is created, told to recognise, and told to write a file. It has no close method and no finaliser. The teardown is a call on the module, not on the document, and the comment gives the reason in plain words: without it a Node process cannot exit.

That makes the library a module-level singleton with a manual release, and it puts the burden on the caller in two places where it is easy to miss. An error thrown between opening the document and reaching the teardown line skips it. A server that opens a document per request and relies on a framework's cleanup has no hook to attach, because the resource is shared rather than per document.

The readme does not say what the call releases, how long it takes, or whether it is safe to call while a recognition is still running. Both examples put it last, which teaches the happy path and nothing else.

## Same-origin only, no bundled build, no module map

The browser story is three restrictions and one workaround, stated in a single paragraph. Every file has to be served from the same origin as the file that imports the library, so importing it from a content delivery network does not work. There is no bundled build for browsers, so the workaround is a bare path into the installed package directory. The import map or relative path form in the setup section is the whole of it.

The manifest has an entry point field and no exports map and no browser field, so a bundler resolves the same file in both environments and the library has to detect which one it is in at runtime. The templates section offers four starting points, and they are unevenly spread: a no-build browser setup, one framework with a build tool, one bundler by version number, and one view library by major version. There is no template for a plain server framework, which is the third entry in the examples directory, and the view library template is two major versions behind the one the readme's own install path would give you.

The readme asks for templates to be contributed, and specifies the condition well: a framework is worth adding if the steps were not obvious. That is the right filter, and it is also an admission that the four listed setups are the ones where nothing surprising happened.

## The tree holds a CLI, a protocol server, a UI and cloud adapters

Count the top-level directories and the readme's framing stops making sense. There is a command line interface directory and a binary entry that points into it, and the documentation index links a page for that command line reference. There is a directory whose name is an acronym for a model context protocol server. There is a directory for cloud adapters. There is a user interface directory and a second one named after a scrolling view component, plus a build script for compiling to a second runtime and a configuration file for a hosting platform's edge runtime.

The readme describes a library, for browsers and for Node, and links five documents. It never mentions the command line tool, so a reader who finds the binary entry has to go looking for its page. It never mentions cloud adapters, so a reader who wants to know whether recognition can be offloaded to a service has nothing to read. It never mentions the protocol server, so the project's most server-shaped component is invisible from the front page.

The documentation index is the honest version of the readme. The readme is a short front door to a much larger surface, and the mismatch in scale is why a two page summary can be accurate about everything it covers and still leave out most of the project.

## The test command runs two of four projects and skips the command line spec

The test runner is configured with four projects, and there is a script for each combination. The default test script is a chain of two:

- the Node project
- the Chrome project

There is a script for Firefox and a script for both browsers together, and neither is in the chain. There is a separate script that runs the command line specification against the Node project, and that script is not in the chain either. So the command the contributing section tells you to run before opening a pull request exercises two environments out of four and skips the command line tool entirely.

The contributing section is otherwise careful. It says to clone with submodules, and the clone command is the only place in the file that uses an SSH address rather than a web one, which means the simplest instruction in the document fails for anyone without a key configured. The submodule flag is not optional, because the repository carries a submodule manifest and top-level directories that are checked out from elsewhere.

So the two paragraphs aimed at contributors are the two with hidden prerequisites: an SSH key, and a recursive clone.

## The strongest copyleft in the file appears in a badge and nowhere else

The manifest declares the copyleft licence from the GNU Affero family. The readme has no licence section, no paragraph on what it means for a user of the library, and no entry for it in the documentation index. The only place the licence appears to a reader is a badge in the header, linking to the licence text.

That matters more here than it would for a typical library, because the readme spends a paragraph directing people to embed this in things. It describes a five line programmatic entry point and lists use cases that include internal tools and products, and it asks the community to submit projects built with it. A licence that obliges network use to offer source has consequences for a service that embeds this library, and the readme does not raise them.

The one place the readme does state a standard is the projects list, and it applies that standard to other people's code: submitted projects are asked to be functional and actively maintained, and submitted examples are asked to be documented well enough for a new user to run. That is a reasonable curation policy stated in one sentence, and it is the only editorial judgement in the entire file.

## Conclusion

Use scribe.js if you need OCR in a browser or in Node without a build step and you can accept a same-origin constraint and a manual teardown call. Check four things before you commit. That you call the teardown function on every path, because the readme's own examples carry it and a Node process will not exit without it. That you use the document API rather than the one-call helper if you need to choose a recognition language, because the helper takes no language argument. That you are on the version you think you are, since the manifest and the newest tag disagree by four minor versions and the readme names none. And that you have read the licence, which is the strongest copyleft family and which the readme discusses in neither the summary nor the documentation index.

## FAQ

### Is there a free version of Scribe.js?

The library is published on npm under the copyleft licence from the GNU Affero family, with no paid tier mentioned. The readme describes it as a library for developers and points end users who want to scan documents to a separate graphical application that it calls the officially supported front end.

### What is the best JavaScript PDF library, and where does scribe.js fit?

scribe.js is described as a JavaScript library for optical character recognition and text extraction from images and PDFs, and it can also write PDFs containing an invisible text layer so an existing file becomes searchable. Rather than comparing itself to an alternative OCR library, the readme points to a comparison document inside its own repository.

### How do I use scribe.js in a browser without a build step?

Import the entry file from the installed package by relative path, which the readme shows as a path into the node_modules directory, optionally through an import map. Every file must be served from the same origin as the importing file, so loading the library from a content delivery network does not work, and there is no bundled build for browsers.

### Why does a Node process using scribe.js need to call terminate?

The readme's own examples call a teardown function on the module after recognition completes, with a comment saying it releases everything so a Node process can exit. The document object it creates has no close method, so the release is a module-level call that the caller has to make on every path, including error paths.

## Sources

- [Official README](https://github.com/scribeocr/scribe.js#readme)
- [Project repository](https://github.com/scribeocr/scribe.js)
- [Release notes](https://github.com/scribeocr/scribe.js/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/scribeocr-scribe-js
