Metadata-Version: 2.4
Name: pyqv
Version: 0.1.0
Summary: Reader and decompressor for Altura QuickView databases.
Author: Cody Bromley
License: MIT
Project-URL: Homepage, https://github.com/codybrom/pyqv
Project-URL: Repository, https://github.com/codybrom/pyqv
Project-URL: Issues, https://github.com/codybrom/pyqv/issues
Keywords: quickview,altura,macintosh,lzw,decompression,retrocomputing,inside-macintosh,file-format
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: System :: Archiving :: Compression
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: test
Requires-Dist: pytest>=7.0; extra == "test"
Dynamic: license-file

# pyqv

Python library and CLI tool to read, decompress, and extract **Altura QuickView** databases.

[![CI](https://github.com/codybrom/pyqv/actions/workflows/ci.yml/badge.svg)](https://github.com/codybrom/pyqv/actions/workflows/ci.yml)
[![Python](https://img.shields.io/badge/python-3.8%2B-blue.svg)](https://www.python.org/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

---

## Overview

**pyqv** provides a pure-Python parser and decompressor for Altura QuickView reference databases.

### Features

- Fixed 12-bit LZW Decompressor: Full pure-Python implementation of Altura's custom 12-bit adaptive LZW codec (`decompress_lzw`).
- Complete File Format Parser: Reads 32-byte headers, 22-byte block topic maps, keyword indices, cross-reference tables, and embedded QuickDraw PICT images.
- CLI Tool: `pyqv` command-line utility for inspecting, extracting, and searching `.qv` databases.
- Damage-tolerant: malformed input raises a `QVError` instead of hanging, crashing, or returning silent garbage.

No third-party dependencies; Python 3.8 or newer.

---

## Installation

Not yet published to PyPI. Install from source:

```bash
git clone https://github.com/codybrom/pyqv.git
cd pyqv
pip install -e .
```

To run the test suite:

```bash
pip install -e ".[test]"
pytest
```

---

## Python API Usage

### 1. Opening a Database and Reading Topics

```python
import pyqv

# Open a .qv database
db = pyqv.read("Power PC.qv")

print(f"Total topics: {len(db.topics)}")
print(f"Total keywords: {len(db.keywords)}")

# Read and decompress a specific compressed block (kind == 160)
for topic in db.topics:
    if topic.is_compressed:
        prose_bytes = db.read_topic_data(topic)
        text = prose_bytes.decode("mac_roman", errors="replace")
        print(f"Topic Group {topic.group} #{topic.index}:")
        print(text[:200])
        break
```

### 2. Standalone LZW Decompression

```python
from pyqv import decompress_lzw

# Pass raw compressed bytes (kind == 160 block)
compressed_data = b"..."  # raw compressed bytes from .qv block
decompressed_text = decompress_lzw(compressed_data)

print(decompressed_text.decode("mac_roman"))
```

### 3. Extracting Embedded QuickDraw PICT Figures

Figures are located by their PICT version 2 signature rather than by group tag,
so they are found wherever a database stores them. Note that `topic.index` is
not unique across the topic table, so enumerate when naming files:

```python
import pyqv

db = pyqv.read("Macintosh Toolbox.qv")
pictures = db.get_pictures()

for position, (topic, pict_bytes) in enumerate(pictures):
    with open(f"figure_{position:04d}.pict", "wb") as f:
        f.write(pict_bytes)
```

### 4. Handling Damaged Files

These databases are 30 years old, and blocks do not always survive intact.
Every failure pyqv detects derives from `QVError`:

| Exception | Meaning |
| --- | --- |
| `QVError` | Base class; catch this to handle any pyqv failure |
| `InvalidDatabaseError` | Bad magic, header invariants violated, or a structure running past end of file |
| `CorruptBlockError` | A compressed block drives the LZW dictionary into a cycle, which no valid encoder produces |

```python
import pyqv

db = pyqv.read("Power PC.qv")

for topic in db.topics:
    try:
        data = db.read_topic_data(topic)
    except pyqv.QVError as exc:
        print(f"skipping {topic}: {exc}")
        continue
    ...
```

Decoding to nothing is **not** an error: an empty block, one too short to hold
a single code, and one whose first code names a slot no literal hashed into all
return `b""`. That last case is common — about half the compressed blocks in a
real database decode to nothing — so treating it as damage badly overstates how
much of a file is broken. The CLI reports the two separately, and a genuinely
damaged block produces a warning on stderr while the run continues.

---

## Command Line Interface (CLI)

`pyqv` comes with a command-line tool for quick inspection, extraction, and searching.

### Inspect a Database

```bash
pyqv inspect "Power PC.qv"
```

Output:

```text
File: Power PC.qv
  Size: 329,793 bytes
  Topics: 1,013
  Keywords: 199
  Topic Table: 172,145..194,431
  Trailer: 195,023..329,793
  Compressed Blocks (kind=160): 89, 114,961 bytes
  Decompressed Text: 84,792 bytes
  Sample Text: Mixed Mode Manager | CallOSTrapUniversalProc | DisposeRoutineDescriptor ...
  QuickDraw Pictures (PICT): 3, 34,914 bytes
```

### Extract Text and Figures

```bash
pyqv extract "Power PC.qv" --output ./extracted_powerpc
```

Text blocks are written to `<output>/text` and figures to `<output>/pictures`.
Filenames carry the record's position in the topic table as well as its index,
because `index` alone is not unique and would let one block overwrite another:

```text
extracted_powerpc/text/group_32_00007_idx_00003.txt
extracted_powerpc/pictures/pic_0000_idx_0012.pict
```

### Search Keywords and Decompressed Prose

```bash
pyqv search "Power PC.qv" "ExceptionHandler"
```

---

## Technical Details

### LZW Codec Specification

QuickView compresses topic prose using a custom variant of **Lempel-Ziv-Welch (LZW)**:

- Fixed 12-bit code width (0..4095).
- 2 codes packed into 3 bytes, MSB first (`code0 = (b0 << 4) | (b1 >> 4)`, `code1 = ((b1 & 0x0f) << 8) | b2`).
- 4,096-node array initialized with 256 root nodes (`0..255`). Hash table collision step is `0x65` (`(curr + 0x65) & 0xfff`).

A code **is** a dictionary slot index, and entries are placed *by hash*, not
sequentially. An entry for `(parent, char)` goes wherever
`((parent + char | 0x800)² >> 6) & 0xfff` points, then follows the chain and
probes by `0x65`. Two consequences trip up anyone porting this from a textbook
LZW implementation:

- **Slots `0..255` are not the literals.** The 256 roots are inserted by the
  same hash as everything else, so a first code naming an empty slot is
  ordinary — that block simply decodes to nothing. Roughly half the compressed
  blocks in a real database do exactly that.
- **A new entry has no reason to land on the code that referenced it.** In
  textbook LZW, a code used before it is defined (the KwKwK case) resolves to
  the next sequential index. Here the slot is wherever the hash put it.
  Requiring the two to match rejects most real blocks.

The 256 literals plus the 3,840 entries a stream may add fill the table
exactly, which is why allocation never runs out of slots. Only one condition
is genuinely unrecoverable: a corrupt block can leave a code's slot empty
while a later insert points a node's parent at it, closing a cycle in what
should be a strictly-decreasing chain. `decompress_lzw` bounds that walk and
raises `CorruptBlockError` rather than following the loop forever.

### Testing

The databases are copyrighted Apple documentation, so no `.qv` fixture ships in
this repository. `tests/lzw_reference.py` implements a reference *compressor*
against the same dictionary layout, letting the codec be verified round-trip on
generated corpora (including the KwKwK case and inputs that exhaust the
dictionary), and `tests/qv_builder.py` assembles synthetic containers in memory.

Know what this can and cannot show. The reference encoder shares the decoder's
model of the format, so a round trip proves the two agree — not that either is
right. An assumption wrong in both passes every test here. The suite therefore
also pins the specific places where a textbook-LZW reading of this format is
wrong (`TestKwKwKPlacement`, `TestDecodesToNothing`), because those are the
mistakes a round-trip test cannot catch on its own.

Anything claiming the decoder is *correct*, rather than self-consistent, has to
be checked against output from the real application. That verification is done
out-of-tree against prose captured from QuickView 2.0c's memory, since neither
the databases nor the captures are redistributable.

---

## License

MIT License. See [LICENSE](LICENSE) for details.
