# Ingesting Clinical Documents

> Turn scanned faxes, images, and CSV/CDA exports into clean text ready for OpenMed de-identification and NER, fully on-device. Use when the user has clinical documents (image scans, photographed/faxed notes, tabular CSV/TSV exports, C-CDA XML) and needs OCR or structured intake before openmed.deidentify and openmed.analyze_text, asks about openmed.multimodal, OCR engines (Tesseract / PaddleOCR), tabular redaction, or layout and reading order. Covers the verified ocr() and redact_document() entry points and the ExtractedDocument contract. Pairs before deidentifying-clinical-text and extracting-clinical-entities.

- Skill: `maziyarpanahi/ingesting-clinical-documents` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add maziyarpanahi/ingesting-clinical-documents`
- Raw SKILL.md: https://api.skillmd.com/api/skills/maziyarpanahi/ingesting-clinical-documents/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Data & Analytics
- License: Apache-2.0
- Author: maziyarpanahi (https://skillmd.com/u/maziyarpanahi)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/maziyarpanahi/ingesting-clinical-documents

---


# Ingesting Clinical Documents

Clinical text often arrives as scanned faxes, photographed notes, CSV exports, or
C-CDA XML — not plain text. `openmed.multimodal` converts these into a normalized
`ExtractedDocument` (clean text + character-offset → source-location spans) so you
can run de-identification and NER. It runs **on-device**: OCR backends are local,
no document leaves the machine.

## When to use

- You have **images / scanned faxes** of clinical notes and need text out (OCR).
- You have **CSV/TSV** patient exports that need column-aware handling.
- You have **C-CDA XML** to flatten into text.
- You are building the **intake stage** that feeds `openmed.deidentify` and
  `openmed.analyze_text`.

This is the **first** stage. After intake, hand off to
`deidentifying-clinical-text` then `extracting-clinical-entities`.

## What is supported today

`redact_document` dispatches by file extension. Live handlers:

| Input | Extensions | Path |
| --- | --- | --- |
| Images / scans | `.png .jpg .jpeg .tif .tiff .bmp .gif .webp` | OCR (`ocr()` / image handler) |
| Tables | `.csv .tsv` | column-aware tabular redaction |
| C-CDA | `.xml` (detected as CDA) | stdlib CDA adapter |

**PDF and DOCX have no live handler yet** — `redact_document("x.pdf")` raises
`UnsupportedDocumentError`. Convert PDFs to page images first (or to text with your
own tool) and feed the images through OCR. See
[references/multimodal-ingest.md](references/multimodal-ingest.md) for the full
contract, engines, and the tabular pipeline.

## Install

```bash
pip install "openmed[multimodal]"      # document intake contract + image deps
pip install "openmed[ocr-paddle]"      # add the PaddleOCR engine
# Tesseract engine also needs the system binary, e.g.:  brew install tesseract
```

## Quick start: OCR an image, then de-identify

The clean two-step intake path. `ocr()` lives in the submodule (it is intentionally
not re-exported from `openmed.multimodal`):

```python
from openmed.multimodal.ocr import ocr
import openmed

# 1) OCR a scanned/faxed note -> OcrResult -> ExtractedDocument -> plain text
result = ocr("fax_page.png", engine=None)   # None = auto-select an installed engine
doc    = result.to_document()                # ExtractedDocument
text   = doc.text                            # clean text for downstream OpenMed

# 2) De-identify, then run NER (privacy-first order)
deid = openmed.deidentify(text, method="mask", policy="hipaa_safe_harbor")
ner  = openmed.analyze_text(deid.deidentified_text, output_format="dict")

for ent in ner.entities:
    print(ent.label, ent.text, ent.confidence)
```

`engine` may be `None` (auto-select), `"tesseract"`, `"paddleocr"`, or an
`OcrEngine` instance. `OcrResult` exposes `.text` and per-word boxes via `.words`
(each `OcrWord` has `text`, `bbox`, `confidence`, `page`).

## One-step intake + redaction with `redact_document`

For images, CSV/TSV, and CDA, `redact_document` performs intake **and**
de-identification in a single, format-aware call, returning an already-redacted
`ExtractedDocument`:

```python
from openmed.multimodal import redact_document

# Image scan: OCR + redact in one call
doc = redact_document("fax_page.png")
print(doc.text)        # redacted text
print(doc.spans[:3])   # SourceSpan offsets -> page / bbox in the original scan

# CSV export: per-column classification (direct id / quasi-id / safe) + redaction
table_doc = redact_document("patients.csv")
print(table_doc.text)
```

Use `redact_document` when you want OpenMed to own intake **and** redaction
(especially for tables, where redaction is column-scoped, not free-text NER). Use
the `ocr()` → `to_document()` → `deidentify` path when you want to control the
de-identification method, policy, or mapping yourself.

## Tabular CSV/TSV redaction

CSV columns get classified before any cell is touched, so a free-text NER pass is
not run blindly over structured data:

```python
from openmed.multimodal import read_table, redact_table

view = read_table("patients.csv")            # TableView with column decisions
for col in view.columns:
    print(col.name, "->", col.assigned_class, col.action, col.canonical_label)

redacted = redact_table("patients.csv", keep_year=True)
print(redacted.text)            # redacted CSV
for entry in redacted.manifest: # PHI-SAFE audit: counts/actions per column, no raw values
    print(entry)
```

`redact_table(...)` returns a `RedactedTable` with `.text`, `.headers`, `.rows`,
`.columns`, and a PHI-safe `.manifest` (no raw cell values). See
[references/multimodal-ingest.md](references/multimodal-ingest.md) for column
classes and actions.

## Preserve layout / reading order and map back to the source

Every `ExtractedDocument` keeps character offset → source location. After detecting
PHI on `doc.text`, project a span's offset back to its page and bounding box:

```python
from openmed.multimodal.ocr import ocr
import openmed

doc  = ocr("fax_page.png").to_document()
deid = openmed.deidentify(doc.text, method="mask")

for ent in deid.pii_entities:
    loc = doc.location_at(ent.start)   # SourceSpan or None
    if loc is not None:
        print(ent.label, "page", loc.page, "bbox", loc.bbox)
```

This lets you redact pixels on the original scan, not just the extracted text.

## Hand-off to / from OpenMed

- **To `deidentifying-clinical-text`:** pass `doc.text` to `openmed.deidentify(...)`
  with a policy profile; this is the required next stage for PHI.
- **To `extracting-clinical-entities`:** run `openmed.analyze_text` on the
  **redacted** text, not raw OCR output.
- **From file conversion (out-of-process):** for PDFs/DOCX, render to page images
  with your own tool, then OCR those images through this skill.

## Edge cases & gotchas

- **`ocr()` is imported from the submodule:** `from openmed.multimodal.ocr import
  ocr`. It is deliberately not re-exported from `openmed.multimodal`.
- **No PDF/DOCX handler yet:** `redact_document` raises `UnsupportedDocumentError`
  for them. Rasterize to images first.
- **OCR needs a backend:** install `[ocr-paddle]` for PaddleOCR, or the system
  Tesseract binary for `pytesseract`. Missing backends raise
  `MissingDependencyError` with an install hint.
- **OCR is noisy:** misreads lower downstream recall. Prefer higher-DPI scans;
  inspect `OcrWord.confidence` to flag low-quality pages.
- **Tables are not free text:** `redact_table` redacts per column classification —
  don't run whole-table NER and expect structured columns to be handled correctly.
- **No raw PHI in artifacts:** the table `manifest` and any logs record
  counts/actions/labels, never raw values. Keep OCR intermediates on-device and out
  of logs.
- **Local-first:** OCR engines run locally; do not send scans to a cloud OCR API in
  a PHI workflow.

## Standards & references

- HIPAA de-identification:
  https://www.hhs.gov/hipaa/for-professionals/privacy/special-topics/de-identification/
- Tesseract OCR: https://github.com/tesseract-ocr/tesseract
- PaddleOCR: https://github.com/PaddlePaddle/PaddleOCR
- HL7 C-CDA: https://www.hl7.org/implement/standards/product_brief.cfm?product_id=492
- Full intake contract, engines, and table pipeline:
  [references/multimodal-ingest.md](references/multimodal-ingest.md)

