Metadata-Version: 2.4
Name: cardbleed
Version: 0.4.3
Summary: Extend the borders of card scans for printing — continues the existing border pattern without re-encoding the original image data
Project-URL: Repository, https://github.com/ErikBavenstrand/cardbleed
Project-URL: Issues, https://github.com/ErikBavenstrand/cardbleed/issues
Author: Erik Bävenstrand
License-Expression: MIT
License-File: LICENSE
Keywords: bleed,border,card,image,printing,proxy,tcg
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Graphics :: Graphics Conversion
Classifier: Topic :: Printing
Requires-Python: >=3.11
Requires-Dist: jpeglib>=1.0
Requires-Dist: numpy>=1.26
Requires-Dist: pillow>=10
Requires-Dist: rich-click>=1.8
Description-Content-Type: text/markdown

# cardbleed

[![CI](https://github.com/ErikBavenstrand/cardbleed/actions/workflows/ci.yml/badge.svg)](https://github.com/ErikBavenstrand/cardbleed/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/cardbleed)](https://pypi.org/project/cardbleed/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)

Reshapes card scans for printing: it can just add cut **bleed**, or **fit** a
scan to an exact card trim size (e.g. 63×88 mm) with the borders each set to
their intended width. Any area it adds continues whatever border the card
already has (holofoil speckle, solid colors, gradients). On the extend path the
original image data is never re-encoded — PNG/WebP pixels stay bit-identical and
JPEGs are extended by splicing DCT coefficient blocks around the untouched
originals. (`--stretch` and shaving an over-target border resample the art and
are opt-in.)

<table>
<tr>
<td align="center"><img src="https://raw.githubusercontent.com/ErikBavenstrand/cardbleed/main/examples/demo_card.png" width="180"><br><sub>input, 400×550</sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/ErikBavenstrand/cardbleed/main/examples/demo_card_pattern.png" width="200"><br><sub>output, <code>cardbleed demo_card.png --bleed 24</code></sub></td>
</tr>
</table>

The demo card is generated by a script
([examples/make_demo.py](examples/make_demo.py)), so the repository contains
no copyrighted scans. It has the traits that make real scans annoying: a
speckled border, a brightness gradient across it, a scanner-bloom line at the
very edge, and an inner frame line. Bloom is trimmed and the frame line is
detected automatically, so sampling never crosses into it.

## Install

```bash
uv tool install cardbleed        # or: pipx install cardbleed
uvx cardbleed card.png           # or run once without installing
```

Or from source: `uv tool install git+https://github.com/ErikBavenstrand/cardbleed`

### Platforms

macOS, Linux and Windows, on Python 3.11–3.13. CI runs the selfcheck on all
three, and runs a real card through with **output redirected to a file** — the
case that matters, because a redirected stream on Windows falls back to the ANSI
codepage rather than the console's UTF-8 path (`LC_ALL=C` on Linux does the
same). When a stream cannot encode a `→`, the streams are reconfigured to UTF-8
with `errors="replace"`: at worst a `?` where a glyph should be, never a failed
run.

One inherited limit: on **Linux arm64** (a Pi, Graviton, or Docker on Apple
Silicon) `jpeglib` publishes no wheel — its wheels cover macOS, Linux
x86_64/i686 and Windows — so pip builds it from source and needs a compiler
(`apt install build-essential python3-dev`). Everywhere else installs from
wheels with no toolchain, and only the JPEG path depends on it; PNG and WebP do
not.

## Usage

```bash
# just add 2.5 mm of cut bleed on every edge
cardbleed card.png --bleed 2.5mm

# fit a scan to 63×88 with 5% side / 3.92% top-bottom borders, given where the
# border currently sits (the marks); --stretch makes the borders land exactly
cardbleed card.png --card-size 63x88 \
    --border-target 5% --border-target-top 3.92% --border-target-bottom 3.92% \
    --border-current-top 2.5% --border-current-right 3% \
    --border-current-bottom 2.4% --border-current-left 3.3% --stretch
```

Every amount is a single scalar with a unit — `5%`, `2.5mm`, or `18px`. Every
per-edge quantity (`--border-target`, `--border-current`, `--bleed`) is a
uniform base plus `-top/-right/-bottom/-left` overrides; no option ever takes a
packed list. Outputs are written next to the input (or to `--out-dir`) with an
`_ext` suffix; inputs are never overwritten.

## Fit

With `--border-target` and `--border-current`, cardbleed reshapes the scan so
the **outer trim is exactly the card aspect** while the borders land as close as
possible to target. It solves for the one degree of freedom (the card scale)
that minimizes the border error, grows each edge toward its own target (a
cropped edge takes more), and shaves an over-target border if `--crop` is on.
When the art itself is slightly off-aspect it can't hit every target exactly
without distortion — `--stretch` opts into a small resample that then lands
every border exactly. The card size and border spec are always inputs;
cardbleed stores nothing card-specific.

## Modes

Zoomed left-edge detail, one panel per setting: smart, pattern, naive,
mirror, soft.

<img src="https://raw.githubusercontent.com/ErikBavenstrand/cardbleed/main/examples/demo_detail_modes.png" width="740">

- `--mode pattern` (default) keeps structure intact: every output line is a
  real contiguous border line, and each outward pass is shifted along the
  edge by a random offset. If the border has a repeating pattern, detected by
  autocorrelation, the continuation and the offsets snap to its period so the
  pattern stays in phase. With `--shuffle 0` it degrades to a plain
  deterministic mirror.
- `--mode smart` resamples the border band stochastically. Speckle is
  re-randomized in both directions, so nothing streaks or repeats, at the
  cost of some texture structure.
- `--mode naive` replicates the outermost line straight outward (plus noise
  and smudge). Mostly useful as a baseline; it streaks on textured borders.

The gallery variants above, for reference:

<table>
<tr>
<td align="center"><img src="https://raw.githubusercontent.com/ErikBavenstrand/cardbleed/main/examples/demo_card_smart.png" width="180"><br><sub><code>--mode smart</code></sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/ErikBavenstrand/cardbleed/main/examples/demo_card_naive.png" width="180"><br><sub><code>--mode naive</code></sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/ErikBavenstrand/cardbleed/main/examples/demo_card_soft.png" width="180"><br><sub><code>--smudge 2.5 --noise 0.8</code></sub></td>
<td align="center"><img src="https://raw.githubusercontent.com/ErikBavenstrand/cardbleed/main/examples/demo_card_smart_compare.png" width="180"><br><sub><code>--compare</code> sheet</sub></td>
</tr>
</table>

## Format handling

| Format | What happens to the original data |
| --- | --- |
| PNG | re-serialized losslessly; pixels bit-identical |
| WebP | written as lossless WebP; decoded pixels preserved exactly |
| JPEG | original quantized DCT blocks are copied bit-exact into a larger coefficient grid; only the new border blocks are encoded, using the file's own quantization tables |

For JPEG the extension amounts have to align to the MCU grid (8 or 16 px).
The remainder is shifted between opposite edges, so the final dimensions are
still exactly what you asked for.

## Options

`cardbleed --help` has the full reference. The ones worth knowing:

| Flag | Default | Meaning |
| --- | --- | --- |
| `--card-size` | `63x88` | Card trim size in mm — the target aspect + mm basis |
| `--border-target` | none | Intended border, all edges (enables fit); `-top/-right/-bottom/-left` override |
| `--border-current` | none | Where the border sits now (the marks); per-edge overrides |
| `--stretch` | off | Un-distort the art so target borders land exactly (small resample) |
| `--crop` | on | Shave a border already thicker than target (never into artwork) |
| `--bleed` | none | Cut margin added outside the card, all edges; per-edge overrides |
| `--mode` | `pattern` | `pattern`, `smart`, or `naive` (see above) |
| `--edge-fill` | `auto` | Continue the border across transparent / rounded-corner / empty edge rows; `off` to disable |
| `--fill-corners` | off | Square rounded/ragged corners: fill edge background (transparent/black/white) with the nearest border. png/webp only |
| `--noise`, `--smudge` | `0.35`, `0.6` | Added grain (relative to the border's own) and ramped blur |
| `--seed` | `0` | Output is deterministic per file |
| `--jobs` | one per core | Files processed at once; `1` for the old strictly-serial batch |
| `--png-level` | `6` | PNG compression effort; `0` writes ~5× faster and ~10% larger |

Fine synthesis knobs (`--jitter`, `--shuffle`, `--sample`, `--trim`,
`--seam-feather`) live under **Advanced** in `--help`.

## Speed

A batch runs its files at once, and each file's result is identical to what a
serial run produced — same pixels, and the report still reads in input order.
Threads rather than processes, because the work is numpy and Pillow and both drop
the GIL for the loops that take the time: measured on 8 print-scale cells
(3500 × 4900, 1.5 mm bleed), **17.8 s → 1.7 s**. `--jobs 1` puts it back.

Per file, 0.4.3 rewrote the extension to stop rotating the whole image. The
analysis never looks past the outermost 20 columns of an edge and the fill never
past `trim + sample`, so a scouting strip is all that has to be turned — the old
code copied the entire float image twelve times a call. Measured on one cell:
**1231 ms → 164 ms** of synthesis (7.5×), with every output byte unchanged.

What is left is mostly PNG: on that cell, ~0.16 s of synthesis against ~0.87 s of
decoding the input and encoding the result. If your pixels never came from a file
and are not going back into one, skip the container entirely — see `bleed_pixels`
below.

## Python API

The CLI is a thin wrapper over `bleed_card`, which reshapes in-process:

```python
from cardbleed import bleed_card, Edges

bleed_card(
    "card.png", "out.png",
    card_size=(63, 88),
    border_target=Edges.symmetric(vertical="3.92%", horizontal="5%"),
    border_current=Edges(top="2.5%", right="3%", bottom="2.4%", left="3.3%"),
    stretch=True,
    bleed="2.5mm",
)
```

`bleed_pixels` is the same pipeline with no files at either end — an `(H, W[, C])`
uint8 array in, the reshaped array out:

```python
import numpy as np
from cardbleed import bleed_pixels

out = bleed_pixels(art, card_size=(63.5, 88.9), bleed="2.5mm", name="card-4")
```

Use it when the picture never came out of a file. cardbleed's promise is that a
*container's* original data survives untouched — the PNG bytes, the JPEG's own
coefficient blocks — and there is nothing to preserve when the caller holds pixels
either side. The round trip is not free: on a 3500 × 4900 cell, writing the input,
decoding it and re-encoding the answer costs ~1.3 s against ~0.17 s of synthesis,
so spooling through a scratch file spends most of its time moving bytes. Measured
end to end, **6.5× faster** on that cell and pixel-for-pixel identical to
`bleed_card`.

`name` seeds this image's random stream exactly as the filename does on the file
path, so passing the same one twice gives the same fill.

`solve_fit` is the same solver, exported so a caller can *ask what a reshape would
do* without doing it — which is what a preview or an alignment overlay needs:

```python
from cardbleed import Edges, solve_fit

plan = solve_fit(
    600, 825,
    Edges.all("4%"),                 # where the border sits in the scan
    Edges.symmetric(vertical="3.88%", horizontal="4.96%"),   # where it should be
    63.5, 88.9,
    stretch=True, crop=True,
)
plan.px_w, plan.px_h    # the size the file will be: whole pixels
plan.trim_w, plan.trim_h  # the exact-aspect card size, before rounding
plan.borders            # what each border ends up as, per edge
```

**Use `px_w`/`px_h` for the size, not `round(trim_w)`.** The reshaped art has to fit
*inside* the trim, and whole pixels do not always allow the nearest pair — an exact
615.46 × 861.65 comes out 616 × 862, because 861 would be a pixel shorter than the
art it must hold. Before 0.4.2 the plan reported only the floats, so a caller that
rounded them printed a size the file did not have.

## Migrating from 0.3

0.4 is a breaking release. `--extend` → **`--bleed`**; the old `--left/--right/
--top/--bottom`, `--target`, `--fix-aspect`, and `--corner-guard` are removed —
use `--bleed`/`--border-target` with the `-top/-right/-bottom/-left` overrides,
and the new `--border-current` + `--card-size` fit for aspect correction.

## How it works

Each edge is analyzed on the original image: bloom lines are trimmed and the
sampling band is clamped before inner border structure. The border is split
into a smooth tone component, which is continued outward mirrored so
gradients stay seam-continuous, and a texture residual, which is resampled
according to the selected mode. Noise matched to the border's measured grain
and a ramped blur are applied on top. Corners are filled in two passes so
they inherit synthesized side texture. All randomness ramps in from zero at
the seam, so the first synthesized line is an exact continuation of the edge.

If a card already has rounded corners, the corner triangles are transparent (or
black/empty) in the scan. `--edge-fill` (on by default) detects those rows per
edge and continues the nearest real border across them, so the added bleed is
border colour rather than a grown black/transparent corner. It's a no-op on
edges with no such background, and stands down when an edge is mostly empty
(nothing to continue). Original pixels are still left untouched — only the
synthesized bleed is affected.

`--fill-corners` goes one step further and squares the corners themselves: edge
background (transparent, black, or white — anything reachable from the image
border that isn't the card) is flooded and filled with the nearest border, so a
rounded-corner scan becomes a clean rectangle before the bleed is added. Unlike
the rest of cardbleed it does change those background pixels (opaque artwork is
never touched); it's opt-in and png/webp only.

## Development

```bash
git clone https://github.com/ErikBavenstrand/cardbleed && cd cardbleed
uv run cardbleed --selfcheck            # assertion suite (fixtures)
uv run cardbleed --selfcheck scan.png   # plus checks against a real scan
uv run --group dev ruff check src
uv run --group dev pyright
```

Module layout: `synthesis.py` (edge analysis and border synthesis),
`formats.py` (format-preserving I/O, including the JPEG DCT path),
`sizing.py` (px/mm/target/aspect math), `process.py` (per-file pipeline),
`cli.py`, `output.py` (stream encoding), `selfcheck.py`.

The selfcheck is the suite, so a portability guarantee has to be expressible as
one: it checks that every non-ASCII character in the package is declared in
`output.GLYPHS`, that a legacy-codepage stream survives them, and — by deleting
its own workspace at the end rather than leaving it behind — that nothing left a
file handle open, which is a silent leak on POSIX and a `PermissionError` on
Windows.

### Releasing

One command, and it is `scripts/release.sh <version> [notes.md]`:

```bash
scripts/release.sh 0.4.3 notes.md
```

It refuses a dirty tree or a branch that is not `main`, then runs the whole gate —
lint, typecheck, the selfcheck attached *and* redirected under `cp1252`, `--help`
under `ascii`, and a wheel built and installed into a throwaway venv so the
**installed entry point** is what gets exercised (`cardbleed:main` is what fixes
the output streams before click runs, and a typo there is invisible until someone
redirects their output). Only then does it bump `_version.py`, commit, write an
**annotated tag** and push. Nothing mutates until every check has passed, because
a PyPI version cannot be reused and a pushed tag is one people have.

**The tag's message is the release notes.** `Release` re-checks the gate on all
three platforms, verifies the tag's version matches `_version.py` — a green CI run
on `main` says nothing about a tag pointing elsewhere — publishes to PyPI by
trusted publishing, and creates the GitHub release with `--notes-from-tag` and the
built artifacts attached. So there is one text, written once, and no second copy to
keep in step.

## License

[MIT](LICENSE)
