newfig
Fabrice P. Lauss𝕪s inelike Web

newfig

newfig is a python script that opens a slot in a numbered set of figures: newfig 3 sends every piece of fig3 material up to fig4, fig4 up to fig5, and so on, then leaves a fresh fig3 behind for the figure that is going to take that place. It keeps the company of bigbib2itsybitsy in the small tackle of paper-writing.

Written with Claude Opus 5 on 7 September (2026). Version 1.0.0.

The idea

Inserting a figure in the middle of a paper is a renaming job that is trivial to describe and unpleasant to do by hand. It has to go from the top down, since renaming fig3 to fig4 first is how one destroys fig4. It has to catch every piece of every figure, not merely the pdf that the paper includes: the Mathematica notebook that drew it, the tex of its caption, the panels fig3a.png and fig3b.png, sometimes a whole fig3/ directory of data. And it has to stop somewhere.

Where it stops is the only real decision, and the answer is the first gap. A directory that runs fig1 to fig7 and then jumps to fig10 is not a set of ten figures with three missing: fig10 is something else—a figure dropped two drafts ago, a piece of the next paper, a curiosity kept aside. Shifting it would be officious. So the run that moves is figN, figN+1, … figM where figM+1 is the first one absent, and everything past that hole is reported and left exactly where it is. -a shifts those too, for the rare directory whose gaps mean nothing.

The freed slot is not left empty. By default it is filled with copies of the figure that has just moved up, one per extension it had, so that the paper still compiles the minute after the shift; one simply sees figure 3 twice, which is a far better reminder that it has yet to be drawn than a missing-file error. And the notebook that made the old figure is now sitting there under the name of the new one, which is very often exactly where the new figure starts. --placeholder empty leaves zero-byte files instead, --placeholder none leaves nothing at all.

Usage

laussy@azag:~/papers/tiling/figs$ newfig 3
newfig: fig3→fig4 … fig5→fig6  (7 files moved)
   fig3a.png  →  fig4a.png
   fig3.pdf  →  fig4.pdf
   fig3.nb  →  fig4.nb
   fig4.pdf  →  fig5.pdf
   fig4.nb  →  fig5.nb
   fig5.pdf  →  fig6.pdf
   fig5.nb  →  fig6.nb
  slot 3 open again — placeholders (copies of the old fig3): fig3.nb, fig3.pdf, fig3a.png
  untouched (past the gap): fig8
  backup in .newfig/07JyT   (newfig --undo to put it all back)
newfig 3                       # here
newfig 3 ~/papers/tiling/figs  # there
newfig 3 -n                    # dry run: what would move, and nothing moves
newfig 3 -a                    # shift everything above 3, gaps and all
newfig 3 -p plot               # the material is plot3.*, not fig3.*
newfig 3 --placeholder empty   # zero-byte files in the freed slot
newfig --undo                  # put the last shift back
Option What it does
-a, --all shift every figure above the number, gaps and all
-p, --prefix the material is called NAME3.*: plot, panel, F
--placeholder copy (default), empty, or none: what is left in the freed slot
-n, --dry-run say what would happen and touch nothing
-u, --undo undo the last newfig done in the directory
--no-backup do not copy anything into .newfig/ first, and so no undo either
-q, --quiet only complain

What counts as figure 3

Anything whose name begins with the prefix, the number, and then whatever it likes: fig3.pdf, fig3.nb, fig3.tex, fig3a.png, fig3_v2.svg, fig-3.pdf, Figure3.png, fig03.pdf, and the directory fig3/ with everything in it. The panels travel with their figure, fig3a.png becoming fig4a.png, and zero-padding is kept, fig09 going to fig10. Everything else in the directory—notes.txt, figures.tex, the tex source itself—is invisible to the script.

Nothing is destroyed

Before a single file moves, every piece of the run is copied into .newfig/tag/ inside the directory, the tag being a fresh Anno Fabri tag, together with a manifest of what went where. Then

newfig --undo

reads the newest manifest and walks it backwards, and the directory is exactly as it was. The placeholders are not deleted by the undo but moved into .newfig/tag/undone/: by the time one undoes a shift, the placeholder may well have become the real new figure, and that is not a thing a tidying-up may throw away.

Three more precautions, none of which should ever fire: a target name that exists although nothing is moving out of it aborts the run before anything is touched; a rename that fails halfway puts back the ones that had already moved; and hidden entries, .newfig/ first among them, are never candidates for a shift.

What it does not touch

The tex source, above all. \includegraphics{fig3} goes on pointing at slot 3, and that is the entire point of the exercise: in a paper the figure numbers are positions, not names, and the file names follow them. Nor does it go into subdirectories—one directory at a time—and nor does it renumber anything downwards. Closing a slot back up, after a figure is dropped, is still done by hand.

Version history

The file

#!/usr/bin/env python3
# newfig — v1.0.0 — open a slot for a new figure in a numbered figure set.
#
#   newfig 3        fig3.* -> fig4.*, fig4.* -> fig5.*, ... then a fresh fig3.*
#
# Every piece of material whose name begins with figN — fig3.pdf, fig3.nb,
# fig3.tex, fig3a.png, figure3_v2.svg, a whole fig3/ directory — is moved up
# by one, and so is everything above it, UP TO THE FIRST GAP: the run that is
# shifted is N, N+1, ... M where M+1 is missing.  A figure set that jumps from
# fig7 to fig10 therefore keeps fig10 where it is (-a shifts those too).
# The slot N is then filled with placeholders, one per extension the old figN
# had, so nothing that includes fig3.pdf breaks while the real new figure 3
# is being made (--placeholder empty|none for a different taste).
#
# Nothing is destroyed: everything the run touches is copied first into
# .newfig/<AF tag>/ inside the directory, with a manifest, and
#
#   newfig --undo
#
# puts the last shift back exactly as it was.
#
#   newfig 3                 shift, in the current directory
#   newfig 3 ~/paper/figs    ... in that one
#   newfig 3 -n              dry run: say what would move, touch nothing
#   newfig 3 -a              shift every figure above 3, gaps and all
#   newfig 3 -p plot         the material is called plot3.*, not fig3.*
#   newfig 3 --placeholder empty|none|copy      what to leave in the slot
#   newfig --undo [DIR]      undo the last newfig done here
#
# F.P. Laussy & Claude (Opus 5), Sun Sep 7 2026

import argparse
import json
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path

VERSION = "1.0.0"
BACKUP_ROOT = ".newfig"


# ---------------------------------------------------------------- utilities

def af_tag():
    """A fresh 5-char AF tag, or a timestamp if AF is not around."""
    try:
        out = subprocess.run(["af"], capture_output=True, text=True, timeout=5)
        tag = out.stdout.strip()
        if re.fullmatch(r"[0-9A-Za-z]{5,6}", tag):
            return tag
    except Exception:
        pass
    return time.strftime("%Y%m%d-%H%M%S")


def figure_re(prefix):
    """Match  <prefix><digits><anything>  — the digits right after the prefix."""
    head = re.escape(prefix) if prefix else r"fig(?:ure)?"
    return re.compile(r"^(?P<head>" + head + r"[-_. ]?)(?P<num>\d+)(?P<tail>.*)$",
                      re.IGNORECASE | re.DOTALL)


def scan(directory, pattern):
    """{figure number: [(name, match), ...]} for everything in the directory."""
    found = {}
    for name in sorted(os.listdir(directory)):
        if name.startswith("."):          # .newfig/ and friends stay out of it
            continue
        m = pattern.match(name)
        if m:
            found.setdefault(int(m.group("num")), []).append((name, m))
    return found


def bumped(m, delta):
    """The same name with its figure number moved by delta, zero-padding kept."""
    num = m.group("num")
    new = str(int(num) + delta)
    if num.startswith("0") and len(new) < len(num):
        new = new.zfill(len(num))
    return m.group("head") + new + m.group("tail")


def copy_into(src, dst):
    if src.is_dir() and not src.is_symlink():
        shutil.copytree(src, dst, symlinks=True)
    else:
        shutil.copy2(src, dst, follow_symlinks=False)


def plural(n, word):
    return f"{n} {word}" + ("" if n == 1 else "s")


# ------------------------------------------------------------------- shift

def shift(args):
    d = Path(args.directory)
    if not d.is_dir():
        sys.exit(f"newfig: {d} is not a directory")

    start = args.number
    label = args.prefix or "fig"
    pattern = figure_re(args.prefix)
    found = scan(d, pattern)

    if not found:
        sys.exit(f"newfig: no {label}<number> material in {d}")

    if start not in found:
        print(f"newfig: nothing named {label}{start} in {d} — "
              f"the slot is already free.")
        print("        figures here: " + ", ".join(str(n) for n in sorted(found)))
        return 0

    # the run to shift: N, N+1, ... up to the first gap (or everything, with -a)
    if args.all:
        run = [n for n in sorted(found) if n >= start]
    else:
        run, n = [], start
        while n in found:
            run.append(n)
            n += 1
    left_alone = [n for n in sorted(found) if n > start and n not in run]

    moves = []                                     # (old name, new name), high first
    for n in sorted(run, reverse=True):
        for name, m in found[n]:
            moves.append((name, bumped(m, 1)))

    for _, new in moves:                           # never overwrite anything
        if (d / new).exists() and new not in {old for old, _ in moves}:
            sys.exit(f"newfig: {new} already exists — refusing to overwrite it")

    slot = [name for name, _ in found[start]]      # what figN is made of

    if args.dry_run:
        print(f"newfig: would shift {plural(len(moves), 'file')} "
              f"in {d} (figures {run[0]}{run[-1]}{run[0]+1}{run[-1]+1}):")
        for old, new in reversed(moves):
            print(f"   {old}{new}")
        if args.placeholder != "none":
            what = (f"copies of the old {label}{start}"
                    if args.placeholder == "copy" else "empty")
            print(f"  and leave placeholders ({what}) in slot {start}: "
                  + ", ".join(slot))
        if left_alone:
            print("  untouched (past the gap): "
                  + ", ".join(f"{label}{n}" for n in left_alone))
        return 0

    # --- backup ------------------------------------------------------------
    backup = None
    if args.backup:
        backup = d / BACKUP_ROOT / af_tag()
        while backup.exists():
            backup = backup.with_name(backup.name + "-b")
        backup.mkdir(parents=True)
        for old, _ in moves:
            copy_into(d / old, backup / old)

    # --- shift, highest figure first ---------------------------------------
    done = []
    try:
        for old, new in moves:
            os.rename(d / old, d / new)
            done.append((old, new))
    except OSError as e:
        for old, new in reversed(done):            # put back what already moved
            os.rename(d / new, d / old)
        sys.exit(f"newfig: {e} — nothing was changed")

    # --- placeholders in the freed slot ------------------------------------
    made = []
    if args.placeholder != "none":
        for name, m in found[start]:
            fresh = d / name
            source = d / bumped(m, 1)
            if args.placeholder == "copy":
                copy_into(source, fresh)
            else:                                   # empty
                if source.is_dir() and not source.is_symlink():
                    fresh.mkdir()
                else:
                    fresh.touch()
            made.append(name)

    if backup:
        (backup / "manifest.json").write_text(json.dumps({
            "version": VERSION,
            "when": time.strftime("%Y-%m-%d %H:%M:%S"),
            "directory": str(d.resolve()),
            "number": start,
            "moves": moves,
            "placeholders": made,
        }, indent=1) + "\n")

    # --- report ------------------------------------------------------------
    if not args.quiet:
        span = (f"{label}{start}{label}{start+1}" if len(run) == 1 else
                f"{label}{start}{label}{start+1} … "
                f"{label}{run[-1]}{label}{run[-1]+1}")
        print(f"newfig: {span}  ({plural(len(moves), 'file')} moved)")
        for old, new in reversed(moves):
            print(f"   {old}{new}")
        if made:
            what = (f"copies of the old {label}{start}"
                    if args.placeholder == "copy" else "empty")
            print(f"  slot {start} open again — placeholders ({what}): "
                  + ", ".join(made))
        if left_alone:
            print("  untouched (past the gap): "
                  + ", ".join(f"{label}{n}" for n in left_alone))
        if backup:
            print(f"  backup in {backup}   (newfig --undo to put it all back)")
    return 0


# -------------------------------------------------------------------- undo

def undo(args):
    d = Path(args.directory)
    root = d / BACKUP_ROOT
    manifests = sorted(root.glob("*/manifest.json"),
                       key=lambda p: p.stat().st_mtime) if root.is_dir() else []
    if not manifests:
        sys.exit(f"newfig: no newfig to undo in {d}")

    man = manifests[-1]
    info = json.loads(man.read_text())
    moves = [(old, new) for old, new in info["moves"]]

    if args.dry_run:
        print(f"newfig: would undo the shift of {info['when']} "
              f"({plural(len(moves), 'file')}):")
        for old, new in moves:
            print(f"   {new}{old}")
        return 0

    # the placeholders sit where the shift came from: park them, never delete
    parked = []
    for name in info.get("placeholders", []):
        p = d / name
        if p.exists():
            keep = man.parent / "undone"
            keep.mkdir(exist_ok=True)
            target = keep / name
            if target.exists():
                shutil.rmtree(target) if target.is_dir() else target.unlink()
            shutil.move(str(p), str(target))
            parked.append(name)

    for old, new in reversed(moves):               # exactly backwards
        if not (d / new).exists():
            print(f"newfig: {new} is gone — skipped", file=sys.stderr)
            continue
        if (d / old).exists():
            sys.exit(f"newfig: {old} is back already — undo stopped, "
                     f"nothing more moved")
        os.rename(d / new, d / old)

    man.rename(man.with_suffix(".json.undone"))

    if not args.quiet:
        print(f"newfig: undid the shift of {info['when']} in {d} "
              f"({plural(len(moves), 'file')} back in place)")
        if parked:
            print(f"  placeholders moved out of the way into "
                  f"{man.parent / 'undone'}: " + ", ".join(parked))
        print(f"  the copies are still in {man.parent} — remove it when happy")
    return 0


# -------------------------------------------------------------------- main

def main():
    p = argparse.ArgumentParser(
        prog="newfig",
        description="Open a slot for a new figure: fig3.* → fig4.*, fig4.* → "
                    "fig5.*, … up to the first gap, leaving fresh fig3 "
                    "placeholders behind.",
        epilog="Everything moved is copied into .newfig/<tag>/ first; "
               "newfig --undo puts the last shift back.")
    p.add_argument("number", nargs="?", metavar="NUMBER",
                   help="the figure number to free")
    p.add_argument("directory", nargs="?",
                   help="where the material is (default: here)")
    p.add_argument("-a", "--all", action="store_true",
                   help="shift every figure above NUMBER, gaps and all")
    p.add_argument("-p", "--prefix", metavar="NAME",
                   help="material is called NAME<n>.* (default: fig or figure)")
    p.add_argument("--placeholder", choices=("copy", "empty", "none"),
                   default="copy",
                   help="what to leave in the freed slot (default: copy, so "
                        "that a LaTeX run still works)")
    p.add_argument("-n", "--dry-run", action="store_true",
                   help="say what would happen, touch nothing")
    p.add_argument("--no-backup", dest="backup", action="store_false",
                   help="do not copy anything into .newfig/ first (no undo)")
    p.add_argument("-u", "--undo", action="store_true",
                   help="undo the last newfig done in the directory")
    p.add_argument("-q", "--quiet", action="store_true",
                   help="only complain")
    p.add_argument("-V", "--version", action="version",
                   version=f"newfig {VERSION}")
    args = p.parse_args()

    if args.undo:                                  # newfig --undo [DIR]
        if args.number is not None and args.directory is None:
            args.number, args.directory = None, args.number
        if args.number is not None:
            p.error("--undo takes a directory, not a figure number")
        args.directory = args.directory or "."
        return undo(args)

    if args.number is None:
        p.error("which figure number should be freed?")
    if not re.fullmatch(r"\d+", args.number):
        p.error(f"{args.number!r} is not a figure number")
    args.number = int(args.number)
    args.directory = args.directory or "."
    return shift(args)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        sys.exit(130)