Metadata-Version: 2.4
Name: rebolt-cgm
Version: 0.1.0
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Rust
Classifier: Typing :: Typed
Requires-Dist: pillow>=10 ; extra == 'pil'
Provides-Extra: pil
License-File: LICENSE.md
License-File: COMMERCIAL.md
License-File: licenses/THIRD-PARTY-NOTICES.md
License-File: licenses/adobe-core14-afm/MustRead.html
License-File: licenses/adobe-core14-afm/README.md
License-File: licenses/adobe-glyph-list/LICENSE.md
License-File: licenses/adobe-glyph-list/README.md
License-File: licenses/fonts/GUST-FONT-LICENSE.txt
License-File: licenses/fonts/MANIFEST-TeX-Gyre-Cursor.txt
License-File: licenses/fonts/MANIFEST-TeX-Gyre-Heros.txt
License-File: licenses/fonts/MANIFEST-TeX-Gyre-Termes.txt
License-File: licenses/fonts/NOTICE.txt
License-File: licenses/fonts/README-TeX-Gyre-Cursor.txt
License-File: licenses/fonts/README-TeX-Gyre-Heros.txt
License-File: licenses/fonts/README-TeX-Gyre-Termes.txt
License-File: licenses/fonts/lppl.txt
License-File: licenses/unicode-adobe-symbol/README.md
Summary: CGM figures (ISO/IEC 8632, ATA iSpec 2200, S1000D) as SVG, PNG crops with sidecars, and typed tables
License-Expression: PolyForm-Noncommercial-1.0.0 AND BSD-2-Clause AND BSD-3-Clause AND LPPL-1.3c AND LicenseRef-Adobe-AFM AND LicenseRef-Unicode-Mappings AND MIT AND Unicode-3.0 AND Zlib
Requires-Python: >=3.10
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM

# rebolt-cgm

Load a CGM figure from an aircraft maintenance manual in Python, and get its SVG, hotspots, text and PNG crops with sidecars.

`rebolt_cgm` reads a CGM (ISO/IEC 8632) file in the WebCGM 2.0 or 2.1, S1000D or ATA iSpec 2200 profile, binary or clear text, plain or gzip-compressed, and applies its ATA CMP companion. It gives the SVG, the sheet, the hotspots, the text runs and the diagnostics. It renders any region to PNG with a sidecar that maps the pixels back to the sheet, and splits a sheet into tiles. Use it to convert manuals in bulk, or to crop figures for a vision model and map the model's answer back to the hotspots.

The package is a thin layer over the cgm-tools Rust library, so it draws exactly what the `cgm` command line and the browser viewer draw. A `Figure` is immutable. Loading and rendering release the GIL, so threads that share one figure render in parallel. Results are frozen dataclasses generated from the Rust types, and the package ships type hints. Failures raise `cgm.LoadError`, `cgm.RegionError` or `cgm.RenderError`, each with a typed `kind`.

## Install

```sh
pip install rebolt-cgm
pip install "rebolt-cgm[pil]"   # with Pillow, for Rendered.to_pil()
```

There is a wheel for CPython 3.10 and later on macOS (arm64 and x86_64), Linux (x86_64 and aarch64, glibc 2.17 or later) and Windows (x86_64). It has no required dependencies. The import package is `rebolt_cgm`, and the examples import it as `cgm`.

## Quick start

```python
import rebolt_cgm as cgm

figure = cgm.Figure.load("figure.cgm", companion=cgm.DISCOVER)
print([hotspot.id for hotspot in figure.hotspots])
with open("figure.svg", "w") as file:
    file.write(figure.svg)
rendered = figure.render(cgm.RegionSheet(), cgm.OutputSizeDpi(dpi=150.0))
with open("figure.png", "wb") as file:
    file.write(rendered.png)
```

`companion=cgm.DISCOVER` looks for the companion beside the CGM file, as `cgm convert --discover-companion` does. To name it, pass its path or its bytes: `companion="figure.cmp"`. `Figure.load` also takes the CGM as bytes.

## Convert a folder to SVG and PNG

For large batches, use the CLI: `cgm convert DIR --out OUT --svg --png --discover-companion` converts in parallel, resumes after an interruption, and writes a report. For a few hundred files, or to keep the work in Python:

```python
import pathlib
from concurrent.futures import ThreadPoolExecutor

import rebolt_cgm as cgm

source, out = pathlib.Path("figures"), pathlib.Path("out")
out.mkdir(exist_ok=True)


def convert(path: pathlib.Path) -> str | None:
    try:
        figure = cgm.Figure.load(path, companion=cgm.DISCOVER)
        png = figure.render(cgm.RegionSheet(), cgm.OutputSizeDpi(dpi=150.0)).png
    except (cgm.LoadError, cgm.RenderError) as error:
        return f"{path.name}: {error.kind}"
    (out / f"{path.stem}.svg").write_text(figure.svg)
    (out / f"{path.stem}.png").write_bytes(png)
    return None


with ThreadPoolExecutor() as pool:
    failures = [f for f in pool.map(convert, sorted(source.glob("*.cgm"))) if f]
print("\n".join(failures))
```

## Crop a hotspot for a vision model, with its sidecar

The sidecar lists every hotspot and text run that shows in the image, with its box in image pixels, so a model's answer about the image maps back to the figure.

```python
import dataclasses
import json

import rebolt_cgm as cgm

figure = cgm.Figure.load("figures/pump.cgm", companion=cgm.DISCOVER)
crop = figure.render(
    cgm.RegionHotspot(id=cgm.HotspotId("VALVE_1"), margin=cgm.MarginMillimetres(5.0)),
    cgm.OutputSizeFit(width=1568, height=1568),
)
image = crop.png                                    # PNG bytes for the model request
for hotspot in crop.sidecar.hotspots:
    print(hotspot.id, hotspot.bizdesc, hotspot.pixels)
sidecar_json = json.dumps(dataclasses.asdict(crop.sidecar))

try:
    figure.render(cgm.RegionHotspot(id=cgm.HotspotId("NOPE"), margin=cgm.MarginFraction(0.0)),
                  cgm.OutputSizeWidth(px=800))
except cgm.RenderError as error:
    assert isinstance(error.kind, cgm.RenderErrorUnknownHotspot)
```

The same crop from the shell is `cgm render FIG.cgm --discover-companion --region hotspot:ID:5mm --fit 1568x1568 --out crop.png --sidecar crop.json`, and the PNG is byte-identical. In both, the size is the size of the image you get, after `rotation` or `--rotate` turns it.

## Tile a sheet at 300 DPI

```python
from concurrent.futures import ThreadPoolExecutor

import rebolt_cgm as cgm

figure = cgm.Figure.load("figures/schematic.cgm")
tiles = figure.tiles(3, 2, cgm.MarginMillimetres(10.0))   # neighbours share 10 mm


def render(tile: cgm.Tile) -> cgm.Rendered:
    return figure.render(cgm.RegionUnits(rect=tile.rect), cgm.OutputSizeDpi(dpi=300.0))


with ThreadPoolExecutor() as pool:
    for tile, rendered in zip(tiles, pool.map(render, tiles)):
        with open(f"r{tile.row}c{tile.column}.png", "wb") as file:
            file.write(rendered.png)
```

## A CGM with several pictures

ISO/IEC 8632-1 lets a metafile hold any number of pictures, and each is its own image. WebCGM, S1000D and ATA GREXCHANGE files hold one, so most callers never select. `Figure.pictures` lists them without writing any SVG, `picture=` selects one, `cgm.PictureIndex(1)` by zero-based index or `cgm.PictureName("Detail A")` by its BEGIN PICTURE identifier, and `Figure.load_all` loads every picture from one decode. A companion applies to the first picture only; the others report `companion-picture`.

```python
import rebolt_cgm as cgm

for info in cgm.Figure.pictures("deck.cgm"):
    print(info.index, info.name)
detail = cgm.Figure.load("deck.cgm", picture=cgm.PictureIndex(1))
by_name = cgm.Figure.load("deck.cgm", picture=cgm.PictureName("Detail A"))  # exactly one picture has this name
for figure in cgm.Figure.load_all("deck.cgm"):
    with open(f"deck~p{figure.picture.index}.svg", "w") as file:
        file.write(figure.svg)
```

Without `picture=`, a CGM with several pictures raises `LoadError` with kind `LoadErrorPictureCount`.

A metafile may also hold no picture (ISO/IEC 8632-1 B.3.1). `Figure.pictures` then returns `()`, `Figure.load_all` returns `()`, and `Figure.load` raises `LoadError` with kind `LoadErrorPictureCount(found=0)`, whose message says there is nothing to write. Its `.diagnostics` hold the information `no-picture`.

## Checking a profile

Loading checks no profile rule. `profile=` on `Figure.load` and `Figure.load_all` checks the CGM against the rules of a profile: `cgm.ProfileCheck.DETECT` for the one its METAFILE DESCRIPTION declares, or `WEBCGM`, `S1000D` or `GREXCHANGE` whatever it declares. What the check finds joins `.diagnostics` with source `cgm.DiagnosticSource.PROFILE`; it never fails the load, and the SVG is the same with and without it.

```python
checked = cgm.Figure.load("fig.cgm", profile=cgm.ProfileCheck.DETECT)
findings = [d for d in checked.diagnostics if d.source is cgm.DiagnosticSource.PROFILE]
```

## Errors

| Exception | When | `kind` |
|---|---|---|
| `LoadError` | the CGM has an error, holds several pictures and none is selected or holds none, the selection names no single picture, or the picture cannot be written as SVG | `LoadErrorDecode`, `LoadErrorPictureCount`, `LoadErrorPictureOutOfRange`, `LoadErrorUnknownPictureName`, `LoadErrorAmbiguousPictureName`, `LoadErrorUnrepresentable`; `.diagnostics` holds every diagnostic |
| `RenderError` | the region or size cannot be placed on the sheet, the hotspot is unknown or has no box, or the image is above 64 Mpx | `RenderErrorUnknownHotspot`, `RenderErrorNoPhysicalSize`, `RenderErrorTooLarge`, ... |
| `RegionError` | `tiles` with a negative overlap, or a millimetre overlap on a sheet with abstract scaling | `RegionErrorInvalidRegion`, `RegionErrorNoPhysicalSize` |
| `ValueError` | a request value outside its Rust type: a negative rectangle, a non-finite number, a negative pixel count, zero tiles, a picture index outside 0 to 2^32 - 1 | |
| `OSError` | a file cannot be read | |

All three typed exceptions subclass `cgm.CgmError`. Branch on the class and its `kind`, not on the message.

## Development

The classes in `python/rebolt_cgm/_types.py` are generated from `schema/cgm.schema.json`, which is generated from the Rust types. After changing a Rust type, regenerate both:

```sh
cargo run -p cgm-schema -- write
```

The test `generated_files_are_fresh` (`cargo test -p cgm-schema`) fails while either file is stale. To build and test the wheel:

```sh
cargo build --release -p cgm-cli                 # the parity test compares with `cgm render`
cd crates/cgm-python
python3 -I ../../tools/release/licenses.py write python .   # LICENSE.md, COMMERCIAL.md, licenses/
maturin build --release -o dist
pip install dist/rebolt_cgm-*.whl pytest mypy Pillow
pytest tests                                     # includes mypy --strict and stubtest
```

## Licence

rebolt-cgm is free for research, education, personal and non-profit use under the [PolyForm Noncommercial License 1.0.0](https://polyformproject.org/licenses/noncommercial/1.0.0), in `LICENSE.md` among the wheel's licence files.

Any use by a company needs a commercial licence. That includes evaluation, internal tools and pipelines, and running the package on a server or behind an API. To get a commercial licence, write to [founders@rebolt.ai](mailto:founders@rebolt.ai). `COMMERCIAL.md` lists the uses that need one.

Copyright (c) 2026 Tieki Inc (Rebolt AI). The wheel also carries third-party components under their own licences, which `licenses/THIRD-PARTY-NOTICES.md` lists.

