← Otto User GuidePDF from Pictures

The Duplicate tutorial builds a small command that copies files. This one goes further: a script that turns a selection of pictures into a PDF, reports a line per page while it works, and offers a second command that reads the text in the pictures so the PDF can be searched.

Along the way it uses the three parts of the script protocol the first tutorial doesn’t touch:

The finished script is in the Otto source tree, at components/otto-files/scripts/pdf. If you’d rather read it than build it:

cp components/otto-files/scripts/pdf ~/.config/otto/files-scripts/
chmod +x ~/.config/otto/files-scripts/pdf

Open a new Files window afterwards, select some pictures and press Ctrl+P.

What you need

Pictures become pages through img2pdf, the pages are put back together with pikepdf, and the text layer comes from tesseract. Only the first two are needed for the plain command.

# Arch
sudo pacman -S python-img2pdf python-pikepdf tesseract tesseract-data-eng
# Debian and Ubuntu
sudo apt install python3-img2pdf python3-pikepdf tesseract-ocr

Each tesseract language is its own package: tesseract-data-ita, tesseract-ocr-ita and so on. The script offers exactly the ones you have installed.

1. Two commands, for pictures only

Start ~/.config/otto/files-scripts/pdf with the plain command. The when block is what keeps it out of the way: it asks for pictures, and for files rather than folders, so the command is never offered for a selection it couldn’t turn into pages.

#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import tempfile

import img2pdf
import pikepdf

PICTURES = ["png", "jpg", "jpeg", "tif", "tiff", "bmp", "webp", "gif"]


def describe():
    return {
        "commands": [
            {
                "id": "pdf",
                "title": "PDF from Pictures",
                "keywords": ["pdf", "document", "combine", "pages"],
                "when": {
                    "targets": "some",
                    "extensions": PICTURES,
                    "kinds": "files",
                },
                "arg": {
                    "prompt": "PDF name",
                    "label": "name",
                    "initial": "Scanned.pdf",
                    "preview": True,
                },
                "undo": "Make PDF",
                "progress": True,
            }
        ]
    }

A selection has to be all pictures for the command to appear. That is on purpose: a PDF made out of some of what you picked isn’t what anyone meant.

"progress": true is the new field. It changes how Files reads the script’s output during a run, and section 4 puts it to use.

2. Make the pages

One picture at a time, into a temporary folder, then all the pages into one document. Going page by page costs nothing and gives the run something to report between the pages.

def page_of(picture, destination):
    """One picture as a one-page PDF, written to `destination`."""
    with open(destination, "wb") as pdf:
        pdf.write(img2pdf.convert(picture))


def run(request):
    directory = request["situation"]["path"]
    name = (request.get("arg") or "").strip() or "Scanned.pdf"
    if not name.lower().endswith(".pdf"):
        name += ".pdf"
    name = unique(directory, name)
    destination = os.path.join(directory, name)
    pictures = request["targets"]

    with tempfile.TemporaryDirectory(prefix="otto-files-pdf-") as workspace:
        pages = []
        for index, picture in enumerate(pictures):
            page = os.path.join(workspace, f"{index:04d}.pdf")
            page_of(picture, page)
            pages.append(page)

        document = pikepdf.Pdf.new()
        for page in pages:
            with pikepdf.Pdf.open(page) as one:
                document.pages.extend(one.pages)
        document.save(destination)

    return {
        "status": f"Made “{name}” from {len(pictures)} pictures",
        "changes": [{"kind": "created", "path": destination}],
        "reload": True,
    }

The created change is what gives the command an undo step: Ctrl+Z moves the PDF to the Trash.

A name that is free. The command asks for a name, not for permission to replace anything, so it never writes over a PDF that is already there. It takes the next free name instead:

def unique(directory, name):
    """`name`, or the first "name 2.pdf" that is free."""
    stem, extension = os.path.splitext(name)
    candidate = name
    n = 2
    while os.path.exists(os.path.join(directory, candidate)):
        candidate = f"{stem} {n}{extension}"
        n += 1
    return candidate

3. A preview of one thing made of many

Duplicate previews one new name per file. This command makes a single PDF out of the whole selection, so the rows say what goes in and leave out to; the note under the list says where it all ends up.

def preview(request):
    name = (request.get("arg") or "").strip() or "Scanned.pdf"
    if not name.lower().endswith(".pdf"):
        name += ".pdf"
    rows = [{"from": os.path.basename(t)} for t in request["targets"]]
    return {"rows": rows, "note": f"{len(rows)} pictures into “{name}”"}

Rows still matter even without a to: press ↓ and Space on one to leave that picture out, and both the next preview and the run get the shorter list. The order of the rows is the order of the pages, which is the order Files shows the pictures in.

4. Say where the run has got to

A dozen scans take a while. With "progress": true declared, a run may print one JSON object per line while it works:

def report(done, total, item):
    """One line saying where the run has got to. Flushed, because the host
    reads it while the script is still working."""
    json.dump({"done": done, "total": total, "item": item}, sys.stdout)
    sys.stdout.write("\n")
    sys.stdout.flush()

Call it before each page, and once more when the pages are done and only the assembly is left:

        for index, picture in enumerate(pictures):
            report(index, len(pictures), os.path.basename(picture))
            ...
        report(len(pictures), len(pictures), "")

The last line that isn’t one of those objects is the result, so the reply at the end of run needs no changing. Files turns the lines into the window’s status line, the island and the progress ring on the Files icon in the dock. The script doesn’t need to know any of those exist.

flush() is the part that is easy to forget. Python buffers standard output when it isn’t a terminal, and without the flush every line arrives at once, when the script finishes.

If a script doesn’t know how much there is to do yet, send "total": 0 and raise it later: total is allowed to grow as the run finds more work.

5. A second command, with a list to choose from

Tesseract can put the text it finds in a picture behind the picture, so the PDF can be searched and its words copied. Which language it reads in matters, so that is what the second command asks for. This time the argument is a choice rather than a field to type in:

def tesseract_languages():
    """What tesseract can read here, or an empty list if it cannot be asked."""
    try:
        listed = subprocess.run(
            ["tesseract", "--list-langs"],
            capture_output=True, text=True, timeout=2,
        )
    except (OSError, subprocess.SubprocessError):
        return []
    if listed.returncode != 0:
        return []
    # The first line is a heading, the rest are the languages — except "osd",
    # which is tesseract's orientation detector and reads no text at all.
    return [
        line.strip()
        for line in listed.stdout.splitlines()[1:]
        if line.strip() and line.strip() != "osd"
    ]

The command is added to the list describe returns only when that comes back with something, so on a machine without tesseract there is no command that can’t work:

    languages = tesseract_languages()
    if languages:
        commands.append({
            "id": "ocr",
            "title": "Searchable PDF from Pictures",
            "keywords": ["pdf", "ocr", "text", "searchable", "scan"],
            "when": {"targets": "some", "extensions": PICTURES, "kinds": "files"},
            "arg": {
                "prompt": "Read the text as",
                "label": "language",
                "initial": languages[0],
                "choices": [{"value": code, "title": NAMES.get(code, code)}
                            for code in languages],
                "preview": True,
            },
            "undo": "Make PDF",
            "progress": True,
        })

NAMES is a small table of tesseract’s codes against the names people read, {"eng": "English", "ita": "Italiano", ...}, falling back to the code for anything not in it.

choices can be a plain list of strings, or objects with a title and an optional subtitle where the value on its own isn’t much to read. The palette completes them as you type, so ita and Italiano both find the same entry. Put the language the window is in first, so Return takes the likely one without any typing.

Both commands share one run; request["command"] says which was chosen. The searchable one names the file itself, since its question went on the language:

def read_page(picture, destination, language):
    """One picture as a one-page PDF with the text tesseract found in it."""
    base = os.path.splitext(destination)[0]
    result = subprocess.run(
        ["tesseract", picture, base, "-l", language, "pdf"],
        capture_output=True, text=True,
    )
    if result.returncode != 0:
        sys.exit((result.stderr or result.stdout).strip() or "tesseract failed")

sys.exit with a message is how a script fails: Files shows the last line of standard error and stops there. Tesseract writes its own .pdf extension on to the name it is given, which is why read_page hands it the name without one.

Reading is slower than converting, by a lot, which is what makes the progress lines from section 4 worth having.

6. Say it in more than one language

Every string a person reads can be given once per language, and describe hands the whole table over for Files to pick from:

STRINGS = {
    "en": {"title": "PDF from Pictures", "prompt": "PDF name", ...},
    "it": {"title": "PDF dalle immagini", "prompt": "Nome del PDF", ...},
}


def by_locale(key):
    """One string per language, for `describe` to hand over whole."""
    return {language: table[key] for language, table in STRINGS.items()}

Then "title": by_locale("title") in the command. For the text the script writes while it runs, such as the note and the status, the window’s language arrives with each request, as locale in the JSON and OTTO_LOCALE in the environment:

def tr(request, key, **kwargs):
    table = STRINGS.get(
        (request.get("locale") or "en").split("-")[0].lower(), STRINGS["en"]
    )
    return table[key].format(**kwargs)

The finished script also keeps a plural form beside anything counted, as into and into_many, and picks between them on count.

Trying it without Files

Every call is an ordinary process, so a request you write yourself is enough to exercise the whole thing from a terminal:

cat > /tmp/request.json <<'EOF'
{ "command": "pdf", "arg": "Holiday.pdf", "locale": "en-GB",
  "targets": ["/home/me/Pictures/a.png", "/home/me/Pictures/b.png"],
  "situation": { "path": "/tmp" } }
EOF
~/.config/otto/files-scripts/pdf describe
~/.config/otto/files-scripts/pdf preview < /tmp/request.json
~/.config/otto/files-scripts/pdf run < /tmp/request.json

The run prints its progress lines as it goes, which is the quickest way to check the flush is working. Change "command" to "ocr" and "arg" to a language code such as eng for the searchable one.

When it doesn’t show up

The full list of what a script can say is in the reference.