idconvert v0.1
This commit is contained in:
parent
1ef9a1a839
commit
001e09d4aa
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(rm -rf frontend)",
|
||||||
|
"Bash(npx nuxi@latest init frontend --no-install)",
|
||||||
|
"Bash(npx nuxi@latest init frontend --no-install --template minimal)",
|
||||||
|
"Bash(npm install:*)",
|
||||||
|
"Bash(npm --version)",
|
||||||
|
"Bash(npx nuxt:*)",
|
||||||
|
"Bash(pip3 install:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# ── PocketBase ────────────────────────────────────────────────────────────────
|
||||||
|
POCKETBASE_URL=http://localhost:8090
|
||||||
|
# Generate in PocketBase admin → Settings → API keys
|
||||||
|
POCKETBASE_ADMIN_TOKEN=
|
||||||
|
|
||||||
|
# ── MinIO / S3 ────────────────────────────────────────────────────────────────
|
||||||
|
S3_BUCKET=idconvert
|
||||||
|
S3_ENDPOINT_URL=http://localhost:9000
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
AWS_ACCESS_KEY_ID=minioadmin
|
||||||
|
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||||
|
S3_PRESIGN_EXPIRY_SECONDS=3600
|
||||||
|
|
||||||
|
# ── Stripe ────────────────────────────────────────────────────────────────────
|
||||||
|
# Add your keys from https://dashboard.stripe.com/apikeys
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||||
|
|
||||||
|
# ── Frontend ──────────────────────────────────────────────────────────────────
|
||||||
|
FRONTEND_ORIGIN=http://localhost:3000
|
||||||
|
NUXT_PUBLIC_API_BASE=http://localhost:8000
|
||||||
|
NUXT_PUBLIC_POCKETBASE_URL=http://localhost:8090
|
||||||
|
NUXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||||
|
|
@ -0,0 +1,376 @@
|
||||||
|
# IDconvert — Project Brief
|
||||||
|
|
||||||
|
**Last updated:** April 2026
|
||||||
|
**Status:** Pre-development — Phase 1 MVP ready to build
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
Designers build documents in Adobe InDesign. Their clients — NGOs, professional associations, corporates — need editable Word versions to update content themselves. Rebuilding a document from scratch in Word is unbillable, time-consuming, and beneath a professional designer's workflow.
|
||||||
|
|
||||||
|
The only serious existing solution is ID2Office by Recosoft — a native InDesign plugin at $229/year that requires InDesign to be installed. There is no standalone web tool that accepts an exported IDML file and returns a clean DOCX without InDesign.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The Solution
|
||||||
|
|
||||||
|
**IDconvert** — a web-based IDML to DOCX conversion tool. No InDesign required. Upload the IDML file, receive an editable Word document that preserves layout, typography, images, tables and reading flow.
|
||||||
|
|
||||||
|
**IDtag** — a free InDesign ExtendScript that prepares complex multi-column documents for more accurate conversion. Phase 2 only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Products
|
||||||
|
|
||||||
|
### IDtag (Phase 2 — Free)
|
||||||
|
- Plain `.jsx` ExtendScript file
|
||||||
|
- Designer runs it inside InDesign before exporting IDML
|
||||||
|
- Tags threaded text frames with metadata for accurate multi-column reconstruction
|
||||||
|
- No licensing, no activation, distributed freely from the website
|
||||||
|
- Serves as a top-of-funnel entry point into IDconvert
|
||||||
|
|
||||||
|
### IDconvert (Phase 1 — Paid)
|
||||||
|
- Web SaaS, no software installation required
|
||||||
|
- Upload IDML → scan → confirm → download DOCX
|
||||||
|
- Credit-based pricing
|
||||||
|
- Pre-conversion scan report with font warnings and layout notices
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works (User Flow)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Designer runs IDtag in InDesign (Phase 2, optional for single column)
|
||||||
|
2. Designer exports IDML from InDesign normally
|
||||||
|
3. Designer or client uploads IDML to IDconvert
|
||||||
|
4. IDconvert scans the file — shows font report and warnings
|
||||||
|
5. User reviews notices, confirms conversion
|
||||||
|
6. DOCX downloads — ready to edit in Word
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the DOCX Contains
|
||||||
|
|
||||||
|
- All text, fully editable, with paragraph and character styles mapped to Word equivalents
|
||||||
|
- Layout preserved via anchored linked text boxes — single and multi-column
|
||||||
|
- Images embedded and positioned to match original layout
|
||||||
|
- Tables with structure and formatting intact
|
||||||
|
- Hyperlinks active and clickable
|
||||||
|
- Page numbers as native Word footer fields, matched font/size/colour
|
||||||
|
- Clear warnings for anything that could not be perfectly replicated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What It Does Not Do
|
||||||
|
|
||||||
|
- Pixel-perfect PDF replication (not possible in Word)
|
||||||
|
- Master page headers and footers (Phase 1 exclusion)
|
||||||
|
- Complex contour text wrap (simplified to square wrap with warning)
|
||||||
|
- Keynote export
|
||||||
|
- Require InDesign to be installed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Target Users
|
||||||
|
|
||||||
|
### Maya — Graphic Designer
|
||||||
|
Freelance, 6 years experience. Designs annual reports, brand guides, cookbooks, association publications in InDesign. Needs to hand off editable Word documents to clients as a standard project deliverable. Builds IDconvert into her production workflow and bills clients for the conversion as part of her production fee.
|
||||||
|
|
||||||
|
**Pain:** Client always asks for Word version after the PDF is approved. Rebuilding it is unpaid time.
|
||||||
|
|
||||||
|
### Richard — Operations Manager
|
||||||
|
Regional engineering firm. Receives designed documents from an external studio. Needs to update figures, swap names, and edit body copy before board submissions — in Word, because that is what his team uses.
|
||||||
|
|
||||||
|
**Pain:** Designer sends a PDF. Richard cannot edit it. He needs a Word version that still looks professional.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Competitive Positioning
|
||||||
|
|
||||||
|
| | ID2Office | IDconvert |
|
||||||
|
|---|---|---|
|
||||||
|
| Requires InDesign | Yes | No |
|
||||||
|
| Price | $229/year | From $9 (credit pack) |
|
||||||
|
| Delivery model | Native plugin | Web tool |
|
||||||
|
| Multi-column support | Yes (years of iteration) | Phase 2 |
|
||||||
|
| Pre-conversion warnings | No | Yes |
|
||||||
|
| Font report | No | Yes |
|
||||||
|
| Caribbean market focus | No | Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pricing
|
||||||
|
|
||||||
|
### Credit Packs
|
||||||
|
| Pack | Price | Per Conversion |
|
||||||
|
|---|---|---|
|
||||||
|
| Starter — 5 credits | $19 | $3.80 |
|
||||||
|
| Studio — 20 credits | $59 | $2.95 |
|
||||||
|
| Agency — 60 credits | $149 | $2.48 |
|
||||||
|
|
||||||
|
Free tier: 1 conversion. Gated by email verification, browser fingerprint, and file hash.
|
||||||
|
No ads. Premium positioning — ID2Office charges $229/year with no scan report, no font intelligence, and requires InDesign installed.
|
||||||
|
No subscription at MVP — introduce after observing real usage frequency.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Architecture
|
||||||
|
|
||||||
|
### IDML Structure
|
||||||
|
IDML is a ZIP archive of XML files:
|
||||||
|
- `designmap.xml` — master manifest
|
||||||
|
- `Spreads/*.xml` — page geometry, frame positions
|
||||||
|
- `Stories/*.xml` — text content with formatting
|
||||||
|
- `Resources/Fonts.xml` — all fonts used
|
||||||
|
- `Resources/Styles.xml` — paragraph and character styles
|
||||||
|
|
||||||
|
Content (Stories) and layout (Spreads) are separate data sources joined by a `ParentStory` attribute on each text frame.
|
||||||
|
|
||||||
|
### Conversion Pipeline
|
||||||
|
```
|
||||||
|
Upload IDML
|
||||||
|
↓
|
||||||
|
SCAN: lightweight XML parse
|
||||||
|
- Count pages, stories, images, tables
|
||||||
|
- Classify fonts (safe / professional / unknown)
|
||||||
|
- Detect text wrap types
|
||||||
|
- Detect threading complexity
|
||||||
|
- Collect warnings array
|
||||||
|
↓
|
||||||
|
Display scan report to user
|
||||||
|
↓
|
||||||
|
User confirms → CONVERT
|
||||||
|
- Parse Spreads for frame geometry
|
||||||
|
- Parse Stories for text content
|
||||||
|
- Join on ParentStory ID
|
||||||
|
- Build DOCX:
|
||||||
|
Every frame → anchored text box
|
||||||
|
Threaded frames → linked text box chain
|
||||||
|
Images → anchored DrawingML
|
||||||
|
Page numbers → native Word footer field
|
||||||
|
Styles → mapped Word paragraph styles
|
||||||
|
↓
|
||||||
|
Download DOCX
|
||||||
|
```
|
||||||
|
|
||||||
|
### DOCX Layout Model
|
||||||
|
All content uses anchored text boxes — unified model, no strategy switching:
|
||||||
|
- Single frame → anchored text box, no linking
|
||||||
|
- Threaded story → linked text box chain via `w:linkTxbx`
|
||||||
|
- Images → anchored DrawingML at matching coordinates
|
||||||
|
- Text wrap → `wrapSquare` for all detected wraps (downgraded, user warned)
|
||||||
|
|
||||||
|
### Unit Conversion
|
||||||
|
- IDML: points
|
||||||
|
- DOCX positions: EMUs (1 inch = 914400 EMUs)
|
||||||
|
- DOCX font sizes: half-points (DXA)
|
||||||
|
- Formula: `emu = points * 914400 / 72`
|
||||||
|
- IDML frame positions are relative to spread center — must offset by page width
|
||||||
|
|
||||||
|
### Two API Endpoints
|
||||||
|
```
|
||||||
|
POST /scan costs 0 credits — returns scan report JSON
|
||||||
|
POST /convert costs 1 credit — returns DOCX file
|
||||||
|
```
|
||||||
|
Scan result cached by session. Convert reuses cached parse.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|---|---|
|
||||||
|
| InDesign script (Phase 2) | ExtendScript .jsx |
|
||||||
|
| Web frontend | Vue 3 + Vite + Tailwind CSS |
|
||||||
|
| Backend API | FastAPI (Python) |
|
||||||
|
| IDML parsing | Python zipfile + lxml |
|
||||||
|
| DOCX generation | python-docx |
|
||||||
|
| Font registry | Custom Python classification dict |
|
||||||
|
| Auth + credits | PocketBase |
|
||||||
|
| File storage | S3-compatible object storage |
|
||||||
|
| Payments | Stripe |
|
||||||
|
| Deployment | Dokploy |
|
||||||
|
| Phase 2 pipeline | n8n |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Conversion Font Report
|
||||||
|
|
||||||
|
Every font in the document is classified and reported before the user spends a credit:
|
||||||
|
|
||||||
|
```
|
||||||
|
FONTS
|
||||||
|
─────────────────────────────────────────
|
||||||
|
✓ Arial Available in Word — no action needed
|
||||||
|
✓ Georgia Available in Word — no action needed
|
||||||
|
|
||||||
|
⚠ Freight Text Pro Not a Word system font
|
||||||
|
Used for: Body text (pages 1–8)
|
||||||
|
Action: Install on client machine
|
||||||
|
If missing: Word substitutes Georgia — minor reflow possible
|
||||||
|
|
||||||
|
⚠ Proxima Nova Not a Word system font
|
||||||
|
Used for: Headings, captions (all pages)
|
||||||
|
Action: Install on client machine
|
||||||
|
If missing: Word substitutes Calibri — spacing may differ
|
||||||
|
|
||||||
|
◎ DM Mono Unknown font
|
||||||
|
Used for: Pull quotes (pages 2, 8)
|
||||||
|
Action: Verify availability or supply to client
|
||||||
|
If missing: Word substitutes Courier New
|
||||||
|
─────────────────────────────────────────
|
||||||
|
💡 Supply a /Fonts folder alongside the DOCX
|
||||||
|
and ask your client to install before opening.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Validation and Security
|
||||||
|
|
||||||
|
Every uploaded file passes a strict validation gate before any processing occurs. This protects against malicious uploads, renamed files, raw .indd files, and ZIP bombs.
|
||||||
|
|
||||||
|
### Validation sequence (in order):
|
||||||
|
1. File size check — reject above 50MB before touching the file
|
||||||
|
2. Magic byte check — read actual file signature, not the extension. Must be `application/zip`
|
||||||
|
3. Open as ZIP — reject corrupted or fake archives
|
||||||
|
4. ZIP bomb protection — sum uncompressed sizes before extracting anything. Reject above 200MB uncompressed
|
||||||
|
5. Entry count limit — reject archives with more than 500 entries
|
||||||
|
6. Path traversal check — reject any entry with `../` in the path
|
||||||
|
7. IDML structure check — must contain `designmap.xml`, `Spreads/`, `Stories/`, `Resources/`
|
||||||
|
8. XML validity check — parse `designmap.xml` to confirm it is real XML, not injected content
|
||||||
|
9. Executable content check — reject if any entry has an executable extension (.exe, .sh, .py, .js etc.)
|
||||||
|
|
||||||
|
### Common innocent mistake — .indd instead of .idml
|
||||||
|
The most frequent user error is uploading a raw InDesign `.indd` file instead of an IDML export.
|
||||||
|
This gets a specific, helpful error message:
|
||||||
|
> "This appears to be an InDesign document file. Please export it as IDML first: File → Export → InDesign Markup (IDML)"
|
||||||
|
|
||||||
|
All other rejections get a clear but non-specific error — never tell a malicious actor which check they failed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Layer 1 — Email verification with disposable domain blocking (Abstract API or Kickbox)
|
||||||
|
Layer 2 — Browser fingerprinting via FingerprintJS (free tier) — persists across sessions
|
||||||
|
Layer 3 — File hashing — same IDML file cannot be converted free across multiple accounts
|
||||||
|
Layer 4 — IP rate limiting via slowapi — 1 free conversion per IP per day
|
||||||
|
|
||||||
|
Start with layers 1, 2, and 3 at MVP. Layer 3 is especially effective for this product since IDML files are project-specific assets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Design Direction
|
||||||
|
|
||||||
|
Reference: ilovepdf.com and tools.pdf24.org
|
||||||
|
|
||||||
|
Core principle: upload zone is the entire hero. No marketing content above the fold on the tool page.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────────────────────────────┐
|
||||||
|
│ IDconvert logo Login/Signup│
|
||||||
|
├───────────────────────────────────────┤
|
||||||
|
│ Convert InDesign to Word. │
|
||||||
|
│ Upload your IDML file to begin. │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ Drop IDML file here or │ │
|
||||||
|
│ │ [ Browse files ] │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ 🔒 Files deleted after 1 hour │
|
||||||
|
├───────────────────────────────────────┤
|
||||||
|
│ SCAN REPORT (appears after upload) │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ │
|
||||||
|
│ │ 12 pages │ │ 8 stories│ ... │
|
||||||
|
│ └──────────┘ └──────────┘ │
|
||||||
|
│ FONTS │
|
||||||
|
│ ✓ Arial Safe │
|
||||||
|
│ ⚠ Freight Text Pro Install needed │
|
||||||
|
│ NOTICES │
|
||||||
|
│ ⚠ Text wrap simplified — pages 4, 7 │
|
||||||
|
│ 1 credit · 4 remaining │
|
||||||
|
│ [ Cancel ] [ Convert → ] │
|
||||||
|
├───────────────────────────────────────┤
|
||||||
|
│ Footer — minimal, links only │
|
||||||
|
└───────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Palette:
|
||||||
|
- Background: #F8F9FA
|
||||||
|
- Primary CTA: #1a56db (Convert button only)
|
||||||
|
- Warning: #F59E0B
|
||||||
|
- Success: #10B981
|
||||||
|
- UI type: Inter or DM Sans
|
||||||
|
- Technical/filenames: DM Mono
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Development Timeline
|
||||||
|
|
||||||
|
### Phase 1 — IDconvert MVP (Weeks 1–5)
|
||||||
|
|
||||||
|
**Week 1 — IDML Parser**
|
||||||
|
- Unzip and read IDML structure
|
||||||
|
- Extract tagged text frames in correct order
|
||||||
|
- Extract paragraph styles and map to Word styles
|
||||||
|
- Extract inline images
|
||||||
|
- Font extraction and classification
|
||||||
|
|
||||||
|
**Week 2 — DOCX Builder**
|
||||||
|
- Anchored text box generation from frame geometry
|
||||||
|
- Linked text box chains for threaded stories
|
||||||
|
- Paragraph and character style mapping
|
||||||
|
- Image embedding as anchored DrawingML
|
||||||
|
- Page number footer generation
|
||||||
|
|
||||||
|
**Week 3 — Scan Endpoint + Warning System**
|
||||||
|
- Lightweight pre-conversion parse
|
||||||
|
- Font report with substitute mapping and usage context
|
||||||
|
- Wrap detection and downgrade warnings
|
||||||
|
- Warnings array with page-level detail
|
||||||
|
- Session-based scan cache
|
||||||
|
|
||||||
|
**Week 4 — Web Tool UI + Credits**
|
||||||
|
- Vue 3 frontend — upload zone, scan report, font report, warning list
|
||||||
|
- PocketBase auth and credit system
|
||||||
|
- Stripe credit pack checkout
|
||||||
|
- Convert endpoint wiring
|
||||||
|
- File deletion after 1 hour
|
||||||
|
|
||||||
|
**Week 5 — Testing and Launch**
|
||||||
|
- End-to-end test with real annual report, brand guide, and cookbook files
|
||||||
|
- Edge case cleanup
|
||||||
|
- Soft launch
|
||||||
|
|
||||||
|
### Phase 2 — IDtag + Multi-Column (Weeks 6–9)
|
||||||
|
|
||||||
|
**Week 6** — IDtag ExtendScript (frame tagging, thread metadata, panel UI)
|
||||||
|
**Week 7** — Parser update (read IDtag metadata from IDML labels)
|
||||||
|
**Week 8** — DOCX builder update (enhanced multi-column reconstruction)
|
||||||
|
**Week 9** — Testing with magazine layouts, multi-column reports, release
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MVP Feature List (User-Facing Language)
|
||||||
|
|
||||||
|
- **Layout preserved** — your document opens in Word with the same page structure, columns and content positioning as the original design
|
||||||
|
- **Text is fully editable** — all text can be selected, edited and reformatted directly in Word without any special software
|
||||||
|
- **Styles carried over** — headings, body text, captions and other text styles are mapped to equivalent Word styles so formatting stays consistent when you edit
|
||||||
|
- **Images included** — all images from the original design are embedded in the Word document and positioned to match the layout
|
||||||
|
- **Tables converted** — tables come across with their structure, content and basic formatting intact and ready to edit
|
||||||
|
- **Clickable links preserved** — any hyperlinks in the original document remain active and clickable in the Word version
|
||||||
|
- **Page numbers matched** — page numbers are reproduced in Word using the same font, size and colour as the original design
|
||||||
|
- **Font report included** — before converting, you are told exactly which fonts need to be installed on your computer for the document to display correctly
|
||||||
|
- **Honest warnings upfront** — any design features that cannot be perfectly replicated in Word are clearly explained before you convert, so there are no surprises
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accepted Limitations (Document Clearly in UI)
|
||||||
|
|
||||||
|
- Text wrap inside anchored text boxes is unreliable — simplified to square wrap, user warned
|
||||||
|
- Font reflow when client lacks designer's fonts — user warned with install instructions
|
||||||
|
- Heavy text editing may cause text box overflow in Word — include how-to-edit guide with download
|
||||||
|
- Master page headers and footers excluded at MVP
|
||||||
|
- Pixel-perfect PDF match is not the goal — structurally identical and editable is the goal
|
||||||
|
|
@ -0,0 +1,495 @@
|
||||||
|
// IDexport.jsx — runs inside Adobe InDesign
|
||||||
|
// Exports the active document as idconvert_export.json
|
||||||
|
// Upload the resulting JSON to IDconvert to generate an editable Word document.
|
||||||
|
// Version: 1.0
|
||||||
|
|
||||||
|
#target indesign
|
||||||
|
|
||||||
|
// ── btoa polyfill (ExtendScript / ES3 does not have btoa built-in) ───────────
|
||||||
|
|
||||||
|
var _b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
|
||||||
|
function btoa(input) {
|
||||||
|
var output = '';
|
||||||
|
var i = 0;
|
||||||
|
var len = input.length;
|
||||||
|
while (i < len) {
|
||||||
|
var b0 = input.charCodeAt(i++) & 0xff;
|
||||||
|
var b1 = (i < len) ? (input.charCodeAt(i++) & 0xff) : 0;
|
||||||
|
var b2 = (i < len) ? (input.charCodeAt(i++) & 0xff) : 0;
|
||||||
|
output += _b64chars.charAt(b0 >> 2);
|
||||||
|
output += _b64chars.charAt(((b0 & 3) << 4) | (b1 >> 4));
|
||||||
|
output += _b64chars.charAt(((b1 & 15) << 2) | (b2 >> 6));
|
||||||
|
output += _b64chars.charAt(b2 & 63);
|
||||||
|
}
|
||||||
|
var pad = len % 3;
|
||||||
|
if (pad === 1) output = output.slice(0, -2) + '==';
|
||||||
|
else if (pad === 2) output = output.slice(0, -1) + '=';
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON polyfill (ExtendScript / ES3 does not have JSON built-in) ────────────
|
||||||
|
|
||||||
|
var JSON = (function () {
|
||||||
|
function stringify(val) {
|
||||||
|
if (val === null) return 'null';
|
||||||
|
if (val === undefined) return 'null';
|
||||||
|
var t = typeof val;
|
||||||
|
if (t === 'boolean') return val ? 'true' : 'false';
|
||||||
|
if (t === 'number') {
|
||||||
|
if (!isFinite(val)) return 'null';
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
if (t === 'string') {
|
||||||
|
return '"' + val
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/\r/g, '\\r')
|
||||||
|
.replace(/\t/g, '\\t') + '"';
|
||||||
|
}
|
||||||
|
if (t === 'object') {
|
||||||
|
if (val instanceof Array) {
|
||||||
|
var items = [];
|
||||||
|
for (var i = 0; i < val.length; i++) {
|
||||||
|
items.push(stringify(val[i]));
|
||||||
|
}
|
||||||
|
return '[' + items.join(',') + ']';
|
||||||
|
}
|
||||||
|
var pairs = [];
|
||||||
|
for (var k in val) {
|
||||||
|
if (val.hasOwnProperty(k)) {
|
||||||
|
var v = stringify(val[k]);
|
||||||
|
if (v !== undefined) {
|
||||||
|
pairs.push('"' + k + '":' + v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '{' + pairs.join(',') + '}';
|
||||||
|
}
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
return { stringify: stringify };
|
||||||
|
}());
|
||||||
|
|
||||||
|
function exportDocument() {
|
||||||
|
if (app.documents.length === 0) {
|
||||||
|
alert('No document is open. Please open an InDesign document first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = app.activeDocument;
|
||||||
|
|
||||||
|
if (!doc.saved) {
|
||||||
|
alert('Please save your document before exporting.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force all scripting measurements to points for the duration of this export.
|
||||||
|
// page.bounds and geometricBounds respect app.scriptPreferences.measurementUnit,
|
||||||
|
// so without this the values would be in whatever the document ruler is set to
|
||||||
|
// (inches, mm, picas, etc.) and page dimensions in Word would be wrong.
|
||||||
|
var originalUnit = app.scriptPreferences.measurementUnit;
|
||||||
|
app.scriptPreferences.measurementUnit = MeasurementUnits.POINTS;
|
||||||
|
|
||||||
|
var output;
|
||||||
|
try {
|
||||||
|
output = {
|
||||||
|
version: '1.0',
|
||||||
|
generator: 'IDexport',
|
||||||
|
document: extractDocumentInfo(doc),
|
||||||
|
styles: extractStyles(doc),
|
||||||
|
colors: extractColors(doc),
|
||||||
|
fonts_used: extractFonts(doc),
|
||||||
|
pages: extractPages(doc)
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Always restore the original unit even if an error occurs
|
||||||
|
app.scriptPreferences.measurementUnit = originalUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
var exportPath = doc.filePath + '/idconvert_export.json';
|
||||||
|
var file = new File(exportPath);
|
||||||
|
file.encoding = 'UTF-8';
|
||||||
|
file.open('w');
|
||||||
|
file.write(JSON.stringify(output));
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
alert('IDexport complete.\nFile saved to:\n' + exportPath +
|
||||||
|
'\n\nUpload idconvert_export.json to IDconvert to generate your Word document.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Document info ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractDocumentInfo(doc) {
|
||||||
|
var page = doc.pages[0];
|
||||||
|
var bounds = page.bounds; // [top, left, bottom, right]
|
||||||
|
return {
|
||||||
|
name: doc.name.replace('.indd', ''),
|
||||||
|
page_width_pt: bounds[3] - bounds[1],
|
||||||
|
page_height_pt: bounds[2] - bounds[0],
|
||||||
|
page_count: doc.pages.length,
|
||||||
|
facing_pages: doc.documentPreferences.facingPages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractStyles(doc) {
|
||||||
|
var paragraphStyles = [];
|
||||||
|
for (var i = 0; i < doc.paragraphStyles.length; i++) {
|
||||||
|
var s = doc.paragraphStyles[i];
|
||||||
|
if (s.name === '[No Paragraph Style]' || s.name === '[Basic Paragraph]') continue;
|
||||||
|
try {
|
||||||
|
paragraphStyles.push({
|
||||||
|
name: s.name,
|
||||||
|
font: safeGet(s, 'appliedFont') ? s.appliedFont.name : null,
|
||||||
|
size_pt: safeGet(s, 'pointSize', null),
|
||||||
|
leading_pt: safeGet(s, 'leading', null),
|
||||||
|
space_before_pt: safeGet(s, 'spaceBefore', 0),
|
||||||
|
space_after_pt: safeGet(s, 'spaceAfter', 0),
|
||||||
|
alignment: alignmentToString(safeGet(s, 'justification', null)),
|
||||||
|
color_hex: resolveColor(safeGet(s, 'fillColor', null)),
|
||||||
|
bold: safeGet(s, 'fontStyle', '').toLowerCase().indexOf('bold') >= 0,
|
||||||
|
italic: safeGet(s, 'fontStyle', '').toLowerCase().indexOf('italic') >= 0
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
var characterStyles = [];
|
||||||
|
for (var j = 0; j < doc.characterStyles.length; j++) {
|
||||||
|
var cs = doc.characterStyles[j];
|
||||||
|
if (cs.name === '[No Character Style]') continue;
|
||||||
|
try {
|
||||||
|
characterStyles.push({
|
||||||
|
name: cs.name,
|
||||||
|
font: safeGet(cs, 'appliedFont') ? cs.appliedFont.name : null,
|
||||||
|
bold: safeGet(cs, 'fontStyle', '').toLowerCase().indexOf('bold') >= 0,
|
||||||
|
italic: safeGet(cs, 'fontStyle', '').toLowerCase().indexOf('italic') >= 0,
|
||||||
|
color_hex: resolveColor(safeGet(cs, 'fillColor', null))
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { paragraph: paragraphStyles, character: characterStyles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Colors ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractColors(doc) {
|
||||||
|
var colors = {};
|
||||||
|
for (var i = 0; i < doc.colors.length; i++) {
|
||||||
|
var c = doc.colors[i];
|
||||||
|
try { colors[c.name] = colorToHex(c); } catch(e) {}
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fonts ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractFonts(doc) {
|
||||||
|
var fonts = [];
|
||||||
|
var seen = {};
|
||||||
|
for (var i = 0; i < doc.fonts.length; i++) {
|
||||||
|
var f = doc.fonts[i];
|
||||||
|
if (!seen[f.name]) {
|
||||||
|
seen[f.name] = true;
|
||||||
|
fonts.push({
|
||||||
|
name: f.name,
|
||||||
|
postscript: safeGet(f, 'postscriptName', f.name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pages ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractPages(doc) {
|
||||||
|
var pages = [];
|
||||||
|
for (var i = 0; i < doc.pages.length; i++) {
|
||||||
|
var page = doc.pages[i];
|
||||||
|
var bounds = page.bounds;
|
||||||
|
pages.push({
|
||||||
|
page_number: page.name,
|
||||||
|
width_pt: bounds[3] - bounds[1],
|
||||||
|
height_pt: bounds[2] - bounds[0],
|
||||||
|
items: extractPageItems(page, doc)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPageItems(page, doc) {
|
||||||
|
var items = [];
|
||||||
|
var allItems = page.allPageItems;
|
||||||
|
|
||||||
|
for (var i = 0; i < allItems.length; i++) {
|
||||||
|
var item = allItems[i];
|
||||||
|
try {
|
||||||
|
var extracted = extractItem(item, page, doc);
|
||||||
|
if (extracted) items.push(extracted);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort top-to-bottom, left-to-right (reading order)
|
||||||
|
items.sort(function(a, b) {
|
||||||
|
if (Math.abs(a.y_pt - b.y_pt) < 5) return a.x_pt - b.x_pt;
|
||||||
|
return a.y_pt - b.y_pt;
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractItem(item, page, doc) {
|
||||||
|
var bounds = item.geometricBounds; // [top, left, bottom, right] in spread coords
|
||||||
|
var pageBounds = page.bounds; // [top, left, bottom, right] of page
|
||||||
|
var x = bounds[1] - pageBounds[1];
|
||||||
|
var y = bounds[0] - pageBounds[0];
|
||||||
|
var width = bounds[3] - bounds[1];
|
||||||
|
var height = bounds[2] - bounds[0];
|
||||||
|
|
||||||
|
var typeName = item.constructor.name;
|
||||||
|
|
||||||
|
if (typeName === 'TextFrame') {
|
||||||
|
return extractTextFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeName === 'Rectangle' || typeName === 'Oval' || typeName === 'GraphicFrame') {
|
||||||
|
if (item.graphics && item.graphics.length > 0) {
|
||||||
|
return extractImageFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
return extractShapeFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text frames ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractTextFrame(frame, x, y, width, height) {
|
||||||
|
if (hasAutoPageNumber(frame)) {
|
||||||
|
return extractPageNumber(frame, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
var paragraphs = [];
|
||||||
|
try {
|
||||||
|
// Use frame.texts[0].paragraphs — only the text visible in THIS frame,
|
||||||
|
// not frame.parentStory.paragraphs which returns the entire threaded story.
|
||||||
|
var frameText = frame.texts[0];
|
||||||
|
for (var i = 0; i < frameText.paragraphs.length; i++) {
|
||||||
|
var para = frameText.paragraphs[i];
|
||||||
|
paragraphs.push(extractParagraph(para));
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
var threadId = '';
|
||||||
|
var threadPos = 1;
|
||||||
|
try {
|
||||||
|
threadId = frame.parentStory.id.toString();
|
||||||
|
var prev = frame.previousTextFrame;
|
||||||
|
while (prev !== null) {
|
||||||
|
threadPos++;
|
||||||
|
prev = prev.previousTextFrame;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'text_frame',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
thread_id: threadId,
|
||||||
|
thread_position: threadPos,
|
||||||
|
x_pt: x,
|
||||||
|
y_pt: y,
|
||||||
|
width_pt: width,
|
||||||
|
height_pt: height,
|
||||||
|
column_count: safeGet(frame.textFramePreferences, 'textColumnCount', 1),
|
||||||
|
paragraphs: paragraphs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParagraph(para) {
|
||||||
|
var styleName = '[No Paragraph Style]';
|
||||||
|
try { styleName = para.appliedParagraphStyle.name; } catch(e) {}
|
||||||
|
|
||||||
|
var runs = [];
|
||||||
|
var characters = para.characters;
|
||||||
|
for (var i = 0; i < characters.length; i++) {
|
||||||
|
var run = buildRun(characters[i]);
|
||||||
|
if (runs.length > 0 && runsMatch(runs[runs.length - 1], run)) {
|
||||||
|
runs[runs.length - 1].text += run.text;
|
||||||
|
} else {
|
||||||
|
runs.push(run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: styleName,
|
||||||
|
text: para.contents,
|
||||||
|
runs: runs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRun(ch) {
|
||||||
|
var styleName = null;
|
||||||
|
try {
|
||||||
|
if (ch.appliedCharacterStyle.name !== '[No Character Style]') {
|
||||||
|
styleName = ch.appliedCharacterStyle.name;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
var fontStyle = safeGet(ch, 'fontStyle', '').toLowerCase();
|
||||||
|
return {
|
||||||
|
text: ch.contents,
|
||||||
|
style: styleName,
|
||||||
|
bold: fontStyle.indexOf('bold') >= 0,
|
||||||
|
italic: fontStyle.indexOf('italic') >= 0,
|
||||||
|
color_hex: resolveColor(safeGet(ch, 'fillColor', null)),
|
||||||
|
font: safeGet(ch, 'appliedFont') ? ch.appliedFont.name : null,
|
||||||
|
size_pt: safeGet(ch, 'pointSize', null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runsMatch(a, b) {
|
||||||
|
return a.style === b.style &&
|
||||||
|
a.bold === b.bold &&
|
||||||
|
a.italic === b.italic &&
|
||||||
|
a.color_hex === b.color_hex &&
|
||||||
|
a.font === b.font &&
|
||||||
|
a.size_pt === b.size_pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Images ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractImageFrame(frame, x, y, width, height) {
|
||||||
|
var imageData = null;
|
||||||
|
var imageName = 'image';
|
||||||
|
try {
|
||||||
|
var graphic = frame.graphics[0];
|
||||||
|
imageName = graphic.itemLink.name;
|
||||||
|
var tempFile = new File(Folder.temp + '/idconvert_temp.jpg');
|
||||||
|
frame.exportFile(ExportFormat.JPG, tempFile, false);
|
||||||
|
imageData = fileToBase64(tempFile);
|
||||||
|
tempFile.remove();
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'image_frame',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
width_pt: width, height_pt: height,
|
||||||
|
image_name: imageName,
|
||||||
|
image_data_b64: imageData,
|
||||||
|
fit_mode: 'fill_proportionally'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shapes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractShapeFrame(frame, x, y, width, height) {
|
||||||
|
var fillColor = null;
|
||||||
|
try {
|
||||||
|
if (frame.fillColor.name !== 'None' && frame.fillColor.name !== '[None]') {
|
||||||
|
fillColor = colorToHex(frame.fillColor);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
if (!fillColor) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'shape',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
width_pt: width, height_pt: height,
|
||||||
|
fill_hex: fillColor
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page numbers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractPageNumber(frame, x, y) {
|
||||||
|
var run = null;
|
||||||
|
try { run = frame.parentStory.characters[0]; } catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'page_number',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
font: run ? (run.appliedFont ? run.appliedFont.name : null) : null,
|
||||||
|
size_pt: run ? safeGet(run, 'pointSize', 9) : 9,
|
||||||
|
color_hex: run ? resolveColor(safeGet(run, 'fillColor', null)) : '000000',
|
||||||
|
alignment: run ? alignmentToString(safeGet(run, 'justification', null)) : 'center'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAutoPageNumber(frame) {
|
||||||
|
try {
|
||||||
|
var characters = frame.parentStory.texts[0].characters;
|
||||||
|
for (var i = 0; i < characters.length; i++) {
|
||||||
|
if (characters[i].contents === SpecialCharacters.AUTO_PAGE_NUMBER) return true;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveColor(colorObj) {
|
||||||
|
if (!colorObj) return null;
|
||||||
|
try {
|
||||||
|
if (colorObj.name === 'None' || colorObj.name === '[None]') return null;
|
||||||
|
return colorToHex(colorObj);
|
||||||
|
} catch(e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorToHex(colorObj) {
|
||||||
|
try {
|
||||||
|
var vals = colorObj.colorValue;
|
||||||
|
if (colorObj.space === ColorSpace.CMYK) {
|
||||||
|
var c = vals[0]/100, m = vals[1]/100, y = vals[2]/100, k = vals[3]/100;
|
||||||
|
return toHex(Math.round(255*(1-c)*(1-k))) +
|
||||||
|
toHex(Math.round(255*(1-m)*(1-k))) +
|
||||||
|
toHex(Math.round(255*(1-y)*(1-k)));
|
||||||
|
}
|
||||||
|
if (colorObj.space === ColorSpace.RGB) {
|
||||||
|
return toHex(vals[0]) + toHex(vals[1]) + toHex(vals[2]);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
return '000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(n) {
|
||||||
|
var h = Math.max(0, Math.min(255, Math.round(n))).toString(16);
|
||||||
|
return h.length === 1 ? '0' + h : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignmentToString(justification) {
|
||||||
|
if (!justification) return 'left';
|
||||||
|
var map = {
|
||||||
|
1: 'left', 2: 'center', 3: 'right', 4: 'justify',
|
||||||
|
1514227313: 'left', 1514731619: 'center',
|
||||||
|
1514731618: 'right', 1514599026: 'justify'
|
||||||
|
};
|
||||||
|
return map[justification] || 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToBase64(file) {
|
||||||
|
file.open('r');
|
||||||
|
file.encoding = 'BINARY';
|
||||||
|
var content = file.read();
|
||||||
|
file.close();
|
||||||
|
return btoa(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeGet(obj, prop, fallback) {
|
||||||
|
try {
|
||||||
|
var val = obj[prop];
|
||||||
|
return (val !== undefined && val !== null) ? val : fallback;
|
||||||
|
} catch(e) {
|
||||||
|
return (fallback !== undefined) ? fallback : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exportDocument();
|
||||||
|
|
@ -0,0 +1,560 @@
|
||||||
|
// IDexport.jsx — runs inside Adobe InDesign
|
||||||
|
// Exports the active document as idconvert_export.json
|
||||||
|
// Upload the resulting JSON to IDconvert to generate an editable Word document.
|
||||||
|
// Version: 1.0
|
||||||
|
|
||||||
|
#target indesign
|
||||||
|
|
||||||
|
// ── btoa polyfill (ExtendScript / ES3 does not have btoa built-in) ───────────
|
||||||
|
|
||||||
|
var _b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
|
||||||
|
function btoa(input) {
|
||||||
|
var output = '';
|
||||||
|
var i = 0;
|
||||||
|
var len = input.length;
|
||||||
|
while (i < len) {
|
||||||
|
var b0 = input.charCodeAt(i++) & 0xff;
|
||||||
|
var b1 = (i < len) ? (input.charCodeAt(i++) & 0xff) : 0;
|
||||||
|
var b2 = (i < len) ? (input.charCodeAt(i++) & 0xff) : 0;
|
||||||
|
output += _b64chars.charAt(b0 >> 2);
|
||||||
|
output += _b64chars.charAt(((b0 & 3) << 4) | (b1 >> 4));
|
||||||
|
output += _b64chars.charAt(((b1 & 15) << 2) | (b2 >> 6));
|
||||||
|
output += _b64chars.charAt(b2 & 63);
|
||||||
|
}
|
||||||
|
var pad = len % 3;
|
||||||
|
if (pad === 1) output = output.slice(0, -2) + '==';
|
||||||
|
else if (pad === 2) output = output.slice(0, -1) + '=';
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON polyfill (ExtendScript / ES3 does not have JSON built-in) ────────────
|
||||||
|
|
||||||
|
var JSON = (function () {
|
||||||
|
function stringify(val) {
|
||||||
|
if (val === null) return 'null';
|
||||||
|
if (val === undefined) return 'null';
|
||||||
|
var t = typeof val;
|
||||||
|
if (t === 'boolean') return val ? 'true' : 'false';
|
||||||
|
if (t === 'number') {
|
||||||
|
if (!isFinite(val)) return 'null';
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
if (t === 'string') {
|
||||||
|
return '"' + val
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/\r/g, '\\r')
|
||||||
|
.replace(/\t/g, '\\t') + '"';
|
||||||
|
}
|
||||||
|
if (t === 'object') {
|
||||||
|
if (val instanceof Array) {
|
||||||
|
var items = [];
|
||||||
|
for (var i = 0; i < val.length; i++) {
|
||||||
|
items.push(stringify(val[i]));
|
||||||
|
}
|
||||||
|
return '[' + items.join(',') + ']';
|
||||||
|
}
|
||||||
|
var pairs = [];
|
||||||
|
for (var k in val) {
|
||||||
|
if (val.hasOwnProperty(k)) {
|
||||||
|
var v = stringify(val[k]);
|
||||||
|
if (v !== undefined) {
|
||||||
|
pairs.push('"' + k + '":' + v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '{' + pairs.join(',') + '}';
|
||||||
|
}
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
return { stringify: stringify };
|
||||||
|
}());
|
||||||
|
|
||||||
|
function exportDocument() {
|
||||||
|
if (app.documents.length === 0) {
|
||||||
|
alert('No document is open. Please open an InDesign document first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = app.activeDocument;
|
||||||
|
|
||||||
|
if (!doc.saved) {
|
||||||
|
alert('Please save your document before exporting.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force all scripting measurements to points for the duration of this export.
|
||||||
|
// page.bounds and geometricBounds respect app.scriptPreferences.measurementUnit,
|
||||||
|
// so without this the values would be in whatever the document ruler is set to
|
||||||
|
// (inches, mm, picas, etc.) and page dimensions in Word would be wrong.
|
||||||
|
var originalUnit = app.scriptPreferences.measurementUnit;
|
||||||
|
app.scriptPreferences.measurementUnit = MeasurementUnits.POINTS;
|
||||||
|
|
||||||
|
var output;
|
||||||
|
try {
|
||||||
|
output = {
|
||||||
|
version: '1.0',
|
||||||
|
generator: 'IDexport',
|
||||||
|
document: extractDocumentInfo(doc),
|
||||||
|
styles: extractStyles(doc),
|
||||||
|
colors: extractColors(doc),
|
||||||
|
fonts_used: extractFonts(doc),
|
||||||
|
pages: extractPages(doc)
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Always restore the original unit even if an error occurs
|
||||||
|
app.scriptPreferences.measurementUnit = originalUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
var exportPath = doc.filePath + '/idconvert_export.json';
|
||||||
|
var file = new File(exportPath);
|
||||||
|
file.encoding = 'UTF-8';
|
||||||
|
file.open('w');
|
||||||
|
file.write(JSON.stringify(output));
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
alert('IDexport complete.\nFile saved to:\n' + exportPath +
|
||||||
|
'\n\nUpload idconvert_export.json to IDconvert to generate your Word document.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Document info ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractDocumentInfo(doc) {
|
||||||
|
var page = doc.pages[0];
|
||||||
|
var bounds = page.bounds; // [top, left, bottom, right]
|
||||||
|
return {
|
||||||
|
name: doc.name.replace('.indd', ''),
|
||||||
|
page_width_pt: bounds[3] - bounds[1],
|
||||||
|
page_height_pt: bounds[2] - bounds[0],
|
||||||
|
page_count: doc.pages.length,
|
||||||
|
facing_pages: doc.documentPreferences.facingPages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractStyles(doc) {
|
||||||
|
var paragraphStyles = [];
|
||||||
|
for (var i = 0; i < doc.paragraphStyles.length; i++) {
|
||||||
|
var s = doc.paragraphStyles[i];
|
||||||
|
if (s.name === '[No Paragraph Style]' || s.name === '[Basic Paragraph]') continue;
|
||||||
|
try {
|
||||||
|
paragraphStyles.push({
|
||||||
|
name: s.name,
|
||||||
|
font: safeGet(s, 'appliedFont') ? s.appliedFont.name : null,
|
||||||
|
size_pt: safeGet(s, 'pointSize', null),
|
||||||
|
leading_pt: safeGet(s, 'leading', null),
|
||||||
|
space_before_pt: safeGet(s, 'spaceBefore', 0),
|
||||||
|
space_after_pt: safeGet(s, 'spaceAfter', 0),
|
||||||
|
alignment: alignmentToString(safeGet(s, 'justification', null)),
|
||||||
|
color_hex: resolveColor(safeGet(s, 'fillColor', null)),
|
||||||
|
bold: safeGet(s, 'fontStyle', '').toLowerCase().indexOf('bold') >= 0,
|
||||||
|
italic: safeGet(s, 'fontStyle', '').toLowerCase().indexOf('italic') >= 0
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
var characterStyles = [];
|
||||||
|
for (var j = 0; j < doc.characterStyles.length; j++) {
|
||||||
|
var cs = doc.characterStyles[j];
|
||||||
|
if (cs.name === '[No Character Style]') continue;
|
||||||
|
try {
|
||||||
|
characterStyles.push({
|
||||||
|
name: cs.name,
|
||||||
|
font: safeGet(cs, 'appliedFont') ? cs.appliedFont.name : null,
|
||||||
|
bold: safeGet(cs, 'fontStyle', '').toLowerCase().indexOf('bold') >= 0,
|
||||||
|
italic: safeGet(cs, 'fontStyle', '').toLowerCase().indexOf('italic') >= 0,
|
||||||
|
color_hex: resolveColor(safeGet(cs, 'fillColor', null))
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { paragraph: paragraphStyles, character: characterStyles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Colors ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractColors(doc) {
|
||||||
|
var colors = {};
|
||||||
|
for (var i = 0; i < doc.colors.length; i++) {
|
||||||
|
var c = doc.colors[i];
|
||||||
|
try { colors[c.name] = colorToHex(c); } catch(e) {}
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fonts ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractFonts(doc) {
|
||||||
|
var fonts = [];
|
||||||
|
var seen = {};
|
||||||
|
for (var i = 0; i < doc.fonts.length; i++) {
|
||||||
|
var f = doc.fonts[i];
|
||||||
|
if (!seen[f.name]) {
|
||||||
|
seen[f.name] = true;
|
||||||
|
fonts.push({
|
||||||
|
name: f.name,
|
||||||
|
postscript: safeGet(f, 'postscriptName', f.name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pages ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractPages(doc) {
|
||||||
|
var pages = [];
|
||||||
|
for (var i = 0; i < doc.pages.length; i++) {
|
||||||
|
var page = doc.pages[i];
|
||||||
|
var bounds = page.bounds;
|
||||||
|
var bgData = hasVisualBackground(page) ? extractPageBackground(doc, page) : null;
|
||||||
|
pages.push({
|
||||||
|
page_number: page.name,
|
||||||
|
width_pt: bounds[3] - bounds[1],
|
||||||
|
height_pt: bounds[2] - bounds[0],
|
||||||
|
background_b64: bgData,
|
||||||
|
items: extractPageItems(page, doc)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the page has any filled shapes/rules worth exporting as background.
|
||||||
|
function hasVisualBackground(page) {
|
||||||
|
var allItems = page.allPageItems;
|
||||||
|
for (var i = 0; i < allItems.length; i++) {
|
||||||
|
var item = allItems[i];
|
||||||
|
var typeName = item.constructor.name;
|
||||||
|
if (typeName === 'GraphicLine') return true;
|
||||||
|
if (typeName === 'Rectangle' || typeName === 'Oval' || typeName === 'Polygon') {
|
||||||
|
var hasImage = item.graphics && item.graphics.length > 0;
|
||||||
|
if (!hasImage) {
|
||||||
|
try {
|
||||||
|
var fn = item.fillColor.name;
|
||||||
|
if (fn !== 'None' && fn !== '[None]') return true;
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hides text and image frames, exports the page as JPEG (showing only background
|
||||||
|
// shapes/rules/fills), then restores visibility. Returns base64-encoded JPEG or null.
|
||||||
|
function extractPageBackground(doc, page) {
|
||||||
|
// Collect items to hide: text frames and image-bearing graphic frames
|
||||||
|
var hidden = [];
|
||||||
|
var allItems = page.allPageItems;
|
||||||
|
for (var i = 0; i < allItems.length; i++) {
|
||||||
|
var item = allItems[i];
|
||||||
|
var typeName = item.constructor.name;
|
||||||
|
var shouldHide = typeName === 'TextFrame';
|
||||||
|
if (!shouldHide && (typeName === 'Rectangle' || typeName === 'Oval' || typeName === 'GraphicFrame')) {
|
||||||
|
shouldHide = !!(item.graphics && item.graphics.length > 0);
|
||||||
|
}
|
||||||
|
if (shouldHide) {
|
||||||
|
try {
|
||||||
|
hidden.push({ item: item, wasVisible: item.visible });
|
||||||
|
item.visible = false;
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bgData = null;
|
||||||
|
try {
|
||||||
|
app.jpegExportPreferences.exportingSpread = false;
|
||||||
|
app.jpegExportPreferences.jpegQuality = JPEGOptionsQuality.HIGH;
|
||||||
|
app.jpegExportPreferences.jpegRenderingStyle = JPEGOptionsFormat.BASELINE_ENCODING;
|
||||||
|
app.jpegExportPreferences.resolution = 150;
|
||||||
|
app.jpegExportPreferences.pageRange = page.name;
|
||||||
|
|
||||||
|
var tempFile = new File(Folder.temp + '/idconvert_bg_' + page.name + '.jpg');
|
||||||
|
doc.exportFile(ExportFormat.JPG, tempFile, false);
|
||||||
|
bgData = fileToBase64(tempFile);
|
||||||
|
tempFile.remove();
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Always restore visibility
|
||||||
|
for (var j = 0; j < hidden.length; j++) {
|
||||||
|
try { hidden[j].item.visible = hidden[j].wasVisible; } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bgData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPageItems(page, doc) {
|
||||||
|
var items = [];
|
||||||
|
var allItems = page.allPageItems;
|
||||||
|
|
||||||
|
for (var i = 0; i < allItems.length; i++) {
|
||||||
|
var item = allItems[i];
|
||||||
|
try {
|
||||||
|
var extracted = extractItem(item, page, doc);
|
||||||
|
if (extracted) items.push(extracted);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort top-to-bottom, left-to-right (reading order)
|
||||||
|
items.sort(function(a, b) {
|
||||||
|
if (Math.abs(a.y_pt - b.y_pt) < 5) return a.x_pt - b.x_pt;
|
||||||
|
return a.y_pt - b.y_pt;
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractItem(item, page, doc) {
|
||||||
|
var bounds = item.geometricBounds; // [top, left, bottom, right] in spread coords
|
||||||
|
var pageBounds = page.bounds; // [top, left, bottom, right] of page
|
||||||
|
var x = bounds[1] - pageBounds[1];
|
||||||
|
var y = bounds[0] - pageBounds[0];
|
||||||
|
var width = bounds[3] - bounds[1];
|
||||||
|
var height = bounds[2] - bounds[0];
|
||||||
|
|
||||||
|
var typeName = item.constructor.name;
|
||||||
|
|
||||||
|
if (typeName === 'TextFrame') {
|
||||||
|
return extractTextFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeName === 'Rectangle' || typeName === 'Oval' || typeName === 'GraphicFrame') {
|
||||||
|
if (item.graphics && item.graphics.length > 0) {
|
||||||
|
return extractImageFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
return extractShapeFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text frames ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractTextFrame(frame, x, y, width, height) {
|
||||||
|
if (hasAutoPageNumber(frame)) {
|
||||||
|
return extractPageNumber(frame, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
var paragraphs = [];
|
||||||
|
try {
|
||||||
|
// Use frame.texts[0].paragraphs — only the text visible in THIS frame,
|
||||||
|
// not frame.parentStory.paragraphs which returns the entire threaded story.
|
||||||
|
var frameText = frame.texts[0];
|
||||||
|
for (var i = 0; i < frameText.paragraphs.length; i++) {
|
||||||
|
var para = frameText.paragraphs[i];
|
||||||
|
paragraphs.push(extractParagraph(para));
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
var threadId = '';
|
||||||
|
var threadPos = 1;
|
||||||
|
try {
|
||||||
|
threadId = frame.parentStory.id.toString();
|
||||||
|
var prev = frame.previousTextFrame;
|
||||||
|
while (prev !== null) {
|
||||||
|
threadPos++;
|
||||||
|
prev = prev.previousTextFrame;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'text_frame',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
thread_id: threadId,
|
||||||
|
thread_position: threadPos,
|
||||||
|
x_pt: x,
|
||||||
|
y_pt: y,
|
||||||
|
width_pt: width,
|
||||||
|
height_pt: height,
|
||||||
|
column_count: safeGet(frame.textFramePreferences, 'textColumnCount', 1),
|
||||||
|
paragraphs: paragraphs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParagraph(para) {
|
||||||
|
var styleName = '[No Paragraph Style]';
|
||||||
|
try { styleName = para.appliedParagraphStyle.name; } catch(e) {}
|
||||||
|
|
||||||
|
var runs = [];
|
||||||
|
var characters = para.characters;
|
||||||
|
for (var i = 0; i < characters.length; i++) {
|
||||||
|
var run = buildRun(characters[i]);
|
||||||
|
if (runs.length > 0 && runsMatch(runs[runs.length - 1], run)) {
|
||||||
|
runs[runs.length - 1].text += run.text;
|
||||||
|
} else {
|
||||||
|
runs.push(run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: styleName,
|
||||||
|
text: para.contents,
|
||||||
|
runs: runs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRun(ch) {
|
||||||
|
var styleName = null;
|
||||||
|
try {
|
||||||
|
if (ch.appliedCharacterStyle.name !== '[No Character Style]') {
|
||||||
|
styleName = ch.appliedCharacterStyle.name;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
var fontStyle = safeGet(ch, 'fontStyle', '').toLowerCase();
|
||||||
|
return {
|
||||||
|
text: ch.contents,
|
||||||
|
style: styleName,
|
||||||
|
bold: fontStyle.indexOf('bold') >= 0,
|
||||||
|
italic: fontStyle.indexOf('italic') >= 0,
|
||||||
|
color_hex: resolveColor(safeGet(ch, 'fillColor', null)),
|
||||||
|
font: safeGet(ch, 'appliedFont') ? ch.appliedFont.name : null,
|
||||||
|
size_pt: safeGet(ch, 'pointSize', null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runsMatch(a, b) {
|
||||||
|
return a.style === b.style &&
|
||||||
|
a.bold === b.bold &&
|
||||||
|
a.italic === b.italic &&
|
||||||
|
a.color_hex === b.color_hex &&
|
||||||
|
a.font === b.font &&
|
||||||
|
a.size_pt === b.size_pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Images ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractImageFrame(frame, x, y, width, height) {
|
||||||
|
var imageData = null;
|
||||||
|
var imageName = 'image';
|
||||||
|
try {
|
||||||
|
var graphic = frame.graphics[0];
|
||||||
|
imageName = graphic.itemLink.name;
|
||||||
|
var tempFile = new File(Folder.temp + '/idconvert_temp.jpg');
|
||||||
|
frame.exportFile(ExportFormat.JPG, tempFile, false);
|
||||||
|
imageData = fileToBase64(tempFile);
|
||||||
|
tempFile.remove();
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'image_frame',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
width_pt: width, height_pt: height,
|
||||||
|
image_name: imageName,
|
||||||
|
image_data_b64: imageData,
|
||||||
|
fit_mode: 'fill_proportionally'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shapes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractShapeFrame(frame, x, y, width, height) {
|
||||||
|
var fillColor = null;
|
||||||
|
try {
|
||||||
|
if (frame.fillColor.name !== 'None' && frame.fillColor.name !== '[None]') {
|
||||||
|
fillColor = colorToHex(frame.fillColor);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
if (!fillColor) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'shape',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
width_pt: width, height_pt: height,
|
||||||
|
fill_hex: fillColor
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page numbers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractPageNumber(frame, x, y) {
|
||||||
|
var run = null;
|
||||||
|
try { run = frame.parentStory.characters[0]; } catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'page_number',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
font: run ? (run.appliedFont ? run.appliedFont.name : null) : null,
|
||||||
|
size_pt: run ? safeGet(run, 'pointSize', 9) : 9,
|
||||||
|
color_hex: run ? resolveColor(safeGet(run, 'fillColor', null)) : '000000',
|
||||||
|
alignment: run ? alignmentToString(safeGet(run, 'justification', null)) : 'center'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAutoPageNumber(frame) {
|
||||||
|
try {
|
||||||
|
var characters = frame.parentStory.texts[0].characters;
|
||||||
|
for (var i = 0; i < characters.length; i++) {
|
||||||
|
if (characters[i].contents === SpecialCharacters.AUTO_PAGE_NUMBER) return true;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveColor(colorObj) {
|
||||||
|
if (!colorObj) return null;
|
||||||
|
try {
|
||||||
|
if (colorObj.name === 'None' || colorObj.name === '[None]') return null;
|
||||||
|
return colorToHex(colorObj);
|
||||||
|
} catch(e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorToHex(colorObj) {
|
||||||
|
try {
|
||||||
|
var vals = colorObj.colorValue;
|
||||||
|
if (colorObj.space === ColorSpace.CMYK) {
|
||||||
|
var c = vals[0]/100, m = vals[1]/100, y = vals[2]/100, k = vals[3]/100;
|
||||||
|
return toHex(Math.round(255*(1-c)*(1-k))) +
|
||||||
|
toHex(Math.round(255*(1-m)*(1-k))) +
|
||||||
|
toHex(Math.round(255*(1-y)*(1-k)));
|
||||||
|
}
|
||||||
|
if (colorObj.space === ColorSpace.RGB) {
|
||||||
|
return toHex(vals[0]) + toHex(vals[1]) + toHex(vals[2]);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
return '000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(n) {
|
||||||
|
var h = Math.max(0, Math.min(255, Math.round(n))).toString(16);
|
||||||
|
return h.length === 1 ? '0' + h : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignmentToString(justification) {
|
||||||
|
if (!justification) return 'left';
|
||||||
|
var map = {
|
||||||
|
1: 'left', 2: 'center', 3: 'right', 4: 'justify',
|
||||||
|
1514227313: 'left', 1514731619: 'center',
|
||||||
|
1514731618: 'right', 1514599026: 'justify'
|
||||||
|
};
|
||||||
|
return map[justification] || 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToBase64(file) {
|
||||||
|
file.open('r');
|
||||||
|
file.encoding = 'BINARY';
|
||||||
|
var content = file.read();
|
||||||
|
file.close();
|
||||||
|
return btoa(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeGet(obj, prop, fallback) {
|
||||||
|
try {
|
||||||
|
var val = obj[prop];
|
||||||
|
return (val !== undefined && val !== null) ? val : fallback;
|
||||||
|
} catch(e) {
|
||||||
|
return (fallback !== undefined) ? fallback : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exportDocument();
|
||||||
|
|
@ -0,0 +1,786 @@
|
||||||
|
// IDexport.jsx — runs inside Adobe InDesign
|
||||||
|
// Exports the active document as idconvert_export.json
|
||||||
|
// Upload the resulting JSON to IDconvert to generate an editable Word document.
|
||||||
|
// Version: 1.0
|
||||||
|
|
||||||
|
#target indesign
|
||||||
|
|
||||||
|
// ── btoa polyfill (ExtendScript / ES3 does not have btoa built-in) ───────────
|
||||||
|
|
||||||
|
var _b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
|
||||||
|
function btoa(input) {
|
||||||
|
var output = '';
|
||||||
|
var i = 0;
|
||||||
|
var len = input.length;
|
||||||
|
while (i < len) {
|
||||||
|
var b0 = input.charCodeAt(i++) & 0xff;
|
||||||
|
var b1 = (i < len) ? (input.charCodeAt(i++) & 0xff) : 0;
|
||||||
|
var b2 = (i < len) ? (input.charCodeAt(i++) & 0xff) : 0;
|
||||||
|
output += _b64chars.charAt(b0 >> 2);
|
||||||
|
output += _b64chars.charAt(((b0 & 3) << 4) | (b1 >> 4));
|
||||||
|
output += _b64chars.charAt(((b1 & 15) << 2) | (b2 >> 6));
|
||||||
|
output += _b64chars.charAt(b2 & 63);
|
||||||
|
}
|
||||||
|
var pad = len % 3;
|
||||||
|
if (pad === 1) output = output.slice(0, -2) + '==';
|
||||||
|
else if (pad === 2) output = output.slice(0, -1) + '=';
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON polyfill (ExtendScript / ES3 does not have JSON built-in) ────────────
|
||||||
|
|
||||||
|
var JSON = (function () {
|
||||||
|
function stringify(val) {
|
||||||
|
if (val === null) return 'null';
|
||||||
|
if (val === undefined) return 'null';
|
||||||
|
var t = typeof val;
|
||||||
|
if (t === 'boolean') return val ? 'true' : 'false';
|
||||||
|
if (t === 'number') {
|
||||||
|
if (!isFinite(val)) return 'null';
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
if (t === 'string') {
|
||||||
|
return '"' + val
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/\r/g, '\\r')
|
||||||
|
.replace(/\t/g, '\\t') + '"';
|
||||||
|
}
|
||||||
|
if (t === 'object') {
|
||||||
|
if (val instanceof Array) {
|
||||||
|
var items = [];
|
||||||
|
for (var i = 0; i < val.length; i++) {
|
||||||
|
items.push(stringify(val[i]));
|
||||||
|
}
|
||||||
|
return '[' + items.join(',') + ']';
|
||||||
|
}
|
||||||
|
var pairs = [];
|
||||||
|
for (var k in val) {
|
||||||
|
if (val.hasOwnProperty(k)) {
|
||||||
|
var v = stringify(val[k]);
|
||||||
|
if (v !== undefined) {
|
||||||
|
pairs.push('"' + k + '":' + v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '{' + pairs.join(',') + '}';
|
||||||
|
}
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
return { stringify: stringify };
|
||||||
|
}());
|
||||||
|
|
||||||
|
function exportDocument() {
|
||||||
|
if (app.documents.length === 0) {
|
||||||
|
alert('No document is open. Please open an InDesign document first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = app.activeDocument;
|
||||||
|
|
||||||
|
if (!doc.saved) {
|
||||||
|
alert('Please save your document before exporting.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force all scripting measurements to points for the duration of this export.
|
||||||
|
// page.bounds and geometricBounds respect app.scriptPreferences.measurementUnit,
|
||||||
|
// so without this the values would be in whatever the document ruler is set to
|
||||||
|
// (inches, mm, picas, etc.) and page dimensions in Word would be wrong.
|
||||||
|
var originalUnit = app.scriptPreferences.measurementUnit;
|
||||||
|
app.scriptPreferences.measurementUnit = MeasurementUnits.POINTS;
|
||||||
|
|
||||||
|
var output;
|
||||||
|
try {
|
||||||
|
output = {
|
||||||
|
version: '1.0',
|
||||||
|
generator: 'IDexport',
|
||||||
|
document: extractDocumentInfo(doc),
|
||||||
|
styles: extractStyles(doc),
|
||||||
|
colors: extractColors(doc),
|
||||||
|
fonts_used: extractFonts(doc),
|
||||||
|
pages: extractPages(doc)
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Always restore the original unit even if an error occurs
|
||||||
|
app.scriptPreferences.measurementUnit = originalUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
var exportPath = doc.filePath + '/idconvert_export.json';
|
||||||
|
var file = new File(exportPath);
|
||||||
|
file.encoding = 'UTF-8';
|
||||||
|
file.open('w');
|
||||||
|
file.write(JSON.stringify(output));
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
alert('IDexport complete.\nFile saved to:\n' + exportPath +
|
||||||
|
'\n\nUpload idconvert_export.json to IDconvert to generate your Word document.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Document info ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractDocumentInfo(doc) {
|
||||||
|
var page = doc.pages[0];
|
||||||
|
var bounds = page.bounds; // [top, left, bottom, right]
|
||||||
|
return {
|
||||||
|
name: doc.name.replace('.indd', ''),
|
||||||
|
page_width_pt: bounds[3] - bounds[1],
|
||||||
|
page_height_pt: bounds[2] - bounds[0],
|
||||||
|
page_count: doc.pages.length,
|
||||||
|
facing_pages: doc.documentPreferences.facingPages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractStyles(doc) {
|
||||||
|
var paragraphStyles = [];
|
||||||
|
for (var i = 0; i < doc.paragraphStyles.length; i++) {
|
||||||
|
var s = doc.paragraphStyles[i];
|
||||||
|
if (s.name === '[No Paragraph Style]' || s.name === '[Basic Paragraph]') continue;
|
||||||
|
try {
|
||||||
|
paragraphStyles.push({
|
||||||
|
name: s.name,
|
||||||
|
font: safeGet(s, 'appliedFont') ? s.appliedFont.name : null,
|
||||||
|
size_pt: safeGet(s, 'pointSize', null),
|
||||||
|
leading_pt: safeGet(s, 'leading', null),
|
||||||
|
space_before_pt: safeGet(s, 'spaceBefore', 0),
|
||||||
|
space_after_pt: safeGet(s, 'spaceAfter', 0),
|
||||||
|
alignment: alignmentToString(safeGet(s, 'justification', null)),
|
||||||
|
color_hex: resolveColor(safeGet(s, 'fillColor', null)),
|
||||||
|
bold: safeGet(s, 'fontStyle', '').toLowerCase().indexOf('bold') >= 0,
|
||||||
|
italic: safeGet(s, 'fontStyle', '').toLowerCase().indexOf('italic') >= 0
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
var characterStyles = [];
|
||||||
|
for (var j = 0; j < doc.characterStyles.length; j++) {
|
||||||
|
var cs = doc.characterStyles[j];
|
||||||
|
if (cs.name === '[No Character Style]') continue;
|
||||||
|
try {
|
||||||
|
characterStyles.push({
|
||||||
|
name: cs.name,
|
||||||
|
font: safeGet(cs, 'appliedFont') ? cs.appliedFont.name : null,
|
||||||
|
bold: safeGet(cs, 'fontStyle', '').toLowerCase().indexOf('bold') >= 0,
|
||||||
|
italic: safeGet(cs, 'fontStyle', '').toLowerCase().indexOf('italic') >= 0,
|
||||||
|
color_hex: resolveColor(safeGet(cs, 'fillColor', null))
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { paragraph: paragraphStyles, character: characterStyles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Colors ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractColors(doc) {
|
||||||
|
var colors = {};
|
||||||
|
for (var i = 0; i < doc.colors.length; i++) {
|
||||||
|
var c = doc.colors[i];
|
||||||
|
try { colors[c.name] = colorToHex(c); } catch(e) {}
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fonts ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractFonts(doc) {
|
||||||
|
var fonts = [];
|
||||||
|
var seen = {};
|
||||||
|
for (var i = 0; i < doc.fonts.length; i++) {
|
||||||
|
var f = doc.fonts[i];
|
||||||
|
if (!seen[f.name]) {
|
||||||
|
seen[f.name] = true;
|
||||||
|
fonts.push({
|
||||||
|
name: f.name,
|
||||||
|
postscript: safeGet(f, 'postscriptName', f.name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pages ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractPages(doc) {
|
||||||
|
var pages = [];
|
||||||
|
for (var i = 0; i < doc.pages.length; i++) {
|
||||||
|
var page = doc.pages[i];
|
||||||
|
var bounds = page.bounds;
|
||||||
|
var pageWidthPt = bounds[3] - bounds[1];
|
||||||
|
var pageHeightPt = bounds[2] - bounds[0];
|
||||||
|
var layoutInfo = detectPageLayout(page, pageWidthPt);
|
||||||
|
var bgData = hasVisualBackground(page) ? extractPageBackground(doc, page) : null;
|
||||||
|
pages.push({
|
||||||
|
page_number: page.name,
|
||||||
|
width_pt: pageWidthPt,
|
||||||
|
height_pt: pageHeightPt,
|
||||||
|
layout: layoutInfo.layout,
|
||||||
|
column_gutter_pt: layoutInfo.column_gutter_pt || null,
|
||||||
|
background_b64: bgData,
|
||||||
|
items: extractPageItems(page, doc, pageWidthPt, layoutInfo)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if the page has any filled shapes/rules worth exporting as background.
|
||||||
|
function hasVisualBackground(page) {
|
||||||
|
var allItems = page.allPageItems;
|
||||||
|
for (var i = 0; i < allItems.length; i++) {
|
||||||
|
var item = allItems[i];
|
||||||
|
var typeName = item.constructor.name;
|
||||||
|
if (typeName === 'GraphicLine') return true;
|
||||||
|
if (typeName === 'Rectangle' || typeName === 'Oval' || typeName === 'Polygon') {
|
||||||
|
var hasImage = item.graphics && item.graphics.length > 0;
|
||||||
|
if (!hasImage) {
|
||||||
|
try {
|
||||||
|
var fn = item.fillColor.name;
|
||||||
|
if (fn !== 'None' && fn !== '[None]') return true;
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hides text and image frames, exports the page as JPEG (showing only background
|
||||||
|
// shapes/rules/fills), then restores visibility. Returns base64-encoded JPEG or null.
|
||||||
|
function extractPageBackground(doc, page) {
|
||||||
|
// Collect items to hide: text frames and image-bearing graphic frames
|
||||||
|
var hidden = [];
|
||||||
|
var allItems = page.allPageItems;
|
||||||
|
for (var i = 0; i < allItems.length; i++) {
|
||||||
|
var item = allItems[i];
|
||||||
|
var typeName = item.constructor.name;
|
||||||
|
var shouldHide = typeName === 'TextFrame';
|
||||||
|
if (!shouldHide && (typeName === 'Rectangle' || typeName === 'Oval' || typeName === 'GraphicFrame')) {
|
||||||
|
shouldHide = !!(item.graphics && item.graphics.length > 0);
|
||||||
|
}
|
||||||
|
if (shouldHide) {
|
||||||
|
try {
|
||||||
|
hidden.push({ item: item, wasVisible: item.visible });
|
||||||
|
item.visible = false;
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bgData = null;
|
||||||
|
try {
|
||||||
|
app.jpegExportPreferences.exportingSpread = false;
|
||||||
|
app.jpegExportPreferences.jpegQuality = JPEGOptionsQuality.HIGH;
|
||||||
|
app.jpegExportPreferences.jpegRenderingStyle = JPEGOptionsFormat.BASELINE_ENCODING;
|
||||||
|
app.jpegExportPreferences.resolution = 150;
|
||||||
|
app.jpegExportPreferences.pageRange = page.name;
|
||||||
|
|
||||||
|
// InDesign appends a zero-padded page suffix to exported JPEG files
|
||||||
|
// (e.g. idconvert_bg_10001.jpg) even when a single page is requested.
|
||||||
|
// Use a base path and then search for the actual written file.
|
||||||
|
var baseName = 'idconvert_bg_' + page.name;
|
||||||
|
var tempBase = new File(Folder.temp + '/' + baseName + '.jpg');
|
||||||
|
doc.exportFile(ExportFormat.JPG, tempBase, false);
|
||||||
|
|
||||||
|
// Find the file InDesign actually wrote — exact path or suffixed variant
|
||||||
|
var actualFile = null;
|
||||||
|
if (tempBase.exists) {
|
||||||
|
actualFile = tempBase;
|
||||||
|
} else {
|
||||||
|
var matches = new Folder(Folder.temp).getFiles(baseName + '*.jpg');
|
||||||
|
if (matches && matches.length > 0) actualFile = matches[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualFile) {
|
||||||
|
bgData = fileToBase64(actualFile);
|
||||||
|
actualFile.remove();
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Always restore visibility
|
||||||
|
for (var j = 0; j < hidden.length; j++) {
|
||||||
|
try { hidden[j].item.visible = hidden[j].wasVisible; } catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bgData;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPageItems(page, doc, pageWidthPt, layoutInfo) {
|
||||||
|
var allItems = page.allPageItems;
|
||||||
|
var groupMap = {}; // InDesign group id → [extracted items]
|
||||||
|
var ungrouped = []; // items not inside an InDesign group
|
||||||
|
|
||||||
|
for (var i = 0; i < allItems.length; i++) {
|
||||||
|
var item = allItems[i];
|
||||||
|
// Skip Group container nodes — their children appear separately in allPageItems
|
||||||
|
if (item.constructor.name === 'Group') continue;
|
||||||
|
try {
|
||||||
|
var extracted = extractItem(item, page, doc);
|
||||||
|
if (!extracted) continue;
|
||||||
|
var gid = getGroupId(item);
|
||||||
|
if (gid) {
|
||||||
|
if (!groupMap[gid]) groupMap[gid] = [];
|
||||||
|
groupMap[gid].push(extracted);
|
||||||
|
} else {
|
||||||
|
ungrouped.push(extracted);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert InDesign groups → frame_group items
|
||||||
|
var finalItems = [];
|
||||||
|
for (var gid in groupMap) {
|
||||||
|
var gItems = groupMap[gid];
|
||||||
|
if (gItems.length > 1) {
|
||||||
|
var fg = buildFrameGroup(gItems);
|
||||||
|
if (fg) {
|
||||||
|
finalItems.push(fg);
|
||||||
|
} else {
|
||||||
|
for (var k = 0; k < gItems.length; k++) ungrouped.push(gItems[k]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ungrouped.push(gItems[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proximity grouping on remaining ungrouped items
|
||||||
|
var proximityResult = detectProximityGroups(ungrouped);
|
||||||
|
for (var p = 0; p < proximityResult.length; p++) finalItems.push(proximityResult[p]);
|
||||||
|
|
||||||
|
// Sort by layout
|
||||||
|
if (layoutInfo && layoutInfo.layout === 'two_column') {
|
||||||
|
return sortTwoColumn(finalItems, layoutInfo.midpoint_pt, pageWidthPt);
|
||||||
|
}
|
||||||
|
finalItems.sort(function(a, b) {
|
||||||
|
if (Math.abs(a.y_pt - b.y_pt) < 5) return a.x_pt - b.x_pt;
|
||||||
|
return a.y_pt - b.y_pt;
|
||||||
|
});
|
||||||
|
return finalItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout detection ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function detectPageLayout(page, pageWidthPt) {
|
||||||
|
var allItems = page.allPageItems;
|
||||||
|
var leftCount = 0, rightCount = 0;
|
||||||
|
var leftMaxRight = 0, rightMinLeft = pageWidthPt;
|
||||||
|
var midpoint = pageWidthPt / 2;
|
||||||
|
|
||||||
|
for (var i = 0; i < allItems.length; i++) {
|
||||||
|
var item = allItems[i];
|
||||||
|
if (item.constructor.name !== 'TextFrame') continue;
|
||||||
|
try { if (hasAutoPageNumber(item)) continue; } catch(e) {}
|
||||||
|
|
||||||
|
var bounds = item.geometricBounds;
|
||||||
|
var pageBounds = page.bounds;
|
||||||
|
var x = bounds[1] - pageBounds[1];
|
||||||
|
var width = bounds[3] - bounds[1];
|
||||||
|
|
||||||
|
// Ignore full-width frames — they span both columns
|
||||||
|
if (width > pageWidthPt * 0.75) continue;
|
||||||
|
|
||||||
|
var xCenter = x + width / 2;
|
||||||
|
if (xCenter < midpoint) {
|
||||||
|
leftCount++;
|
||||||
|
leftMaxRight = Math.max(leftMaxRight, x + width);
|
||||||
|
} else {
|
||||||
|
rightCount++;
|
||||||
|
rightMinLeft = Math.min(rightMinLeft, x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leftCount > 0 && rightCount > 0) {
|
||||||
|
var gutter = rightMinLeft - leftMaxRight;
|
||||||
|
if (gutter >= 0) {
|
||||||
|
return { layout: 'two_column', column_gutter_pt: gutter, midpoint_pt: midpoint };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { layout: 'single', column_gutter_pt: null, midpoint_pt: midpoint };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Frame grouping ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Returns the InDesign Group id if item is a direct child of a Group, else null.
|
||||||
|
function getGroupId(item) {
|
||||||
|
try {
|
||||||
|
var par = item.parent;
|
||||||
|
if (par && par.constructor.name === 'Group') return par.id.toString();
|
||||||
|
} catch(e) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a frame_group from 2+ co-located items, sorted left-to-right.
|
||||||
|
function buildFrameGroup(items) {
|
||||||
|
if (!items || items.length < 2) return null;
|
||||||
|
items.sort(function(a, b) { return a.x_pt - b.x_pt; });
|
||||||
|
|
||||||
|
var minX = items[0].x_pt;
|
||||||
|
var minY = items[0].y_pt;
|
||||||
|
var maxBottom = items[0].y_pt + (items[0].height_pt || 0);
|
||||||
|
var totalWidth = 0;
|
||||||
|
var columns = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
var it = items[i];
|
||||||
|
minY = Math.min(minY, it.y_pt);
|
||||||
|
maxBottom = Math.max(maxBottom, it.y_pt + (it.height_pt || 0));
|
||||||
|
totalWidth += (it.width_pt || 100);
|
||||||
|
columns.push({ width_pt: it.width_pt || 100, items: [it] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'frame_group',
|
||||||
|
id: 'fg_' + items[0].id,
|
||||||
|
x_pt: minX,
|
||||||
|
y_pt: minY,
|
||||||
|
width_pt: totalWidth,
|
||||||
|
height_pt: maxBottom - minY,
|
||||||
|
columns: columns
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if two items should be proximity-grouped into a side-by-side table.
|
||||||
|
function areProximityGroup(a, b) {
|
||||||
|
if (!a.width_pt || !b.width_pt || !a.height_pt || !b.height_pt) return false;
|
||||||
|
|
||||||
|
// Must be horizontally distinct (side-by-side, not stacked)
|
||||||
|
var aCx = a.x_pt + a.width_pt / 2;
|
||||||
|
var bCx = b.x_pt + b.width_pt / 2;
|
||||||
|
var minW = Math.min(a.width_pt, b.width_pt);
|
||||||
|
if (Math.abs(aCx - bCx) < minW * 0.5) return false;
|
||||||
|
|
||||||
|
// Y ranges must overlap by ≥ 60% of the smaller item's height
|
||||||
|
var overlap = Math.min(a.y_pt + a.height_pt, b.y_pt + b.height_pt) -
|
||||||
|
Math.max(a.y_pt, b.y_pt);
|
||||||
|
if (overlap < Math.min(a.height_pt, b.height_pt) * 0.6) return false;
|
||||||
|
|
||||||
|
// Horizontal gap must be ≤ 30pt
|
||||||
|
var gap = Math.min(
|
||||||
|
Math.abs((a.x_pt + a.width_pt) - b.x_pt),
|
||||||
|
Math.abs((b.x_pt + b.width_pt) - a.x_pt)
|
||||||
|
);
|
||||||
|
if (gap > 30) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups ungrouped items by spatial proximity using union-find.
|
||||||
|
function detectProximityGroups(items) {
|
||||||
|
var n = items.length;
|
||||||
|
if (n === 0) return [];
|
||||||
|
|
||||||
|
var parent = [];
|
||||||
|
for (var i = 0; i < n; i++) parent[i] = i;
|
||||||
|
|
||||||
|
function find(x) {
|
||||||
|
while (parent[x] !== x) { parent[x] = parent[parent[x]]; x = parent[x]; }
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
function unite(x, y) { parent[find(x)] = find(y); }
|
||||||
|
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
for (var j = i + 1; j < n; j++) {
|
||||||
|
if (areProximityGroup(items[i], items[j])) unite(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupsObj = {};
|
||||||
|
for (var i = 0; i < n; i++) {
|
||||||
|
var root = find(i);
|
||||||
|
if (!groupsObj[root]) groupsObj[root] = [];
|
||||||
|
groupsObj[root].push(items[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = [];
|
||||||
|
for (var r in groupsObj) {
|
||||||
|
var g = groupsObj[r];
|
||||||
|
if (g.length > 1) {
|
||||||
|
var fg = buildFrameGroup(g);
|
||||||
|
if (fg) { result.push(fg); }
|
||||||
|
else { for (var k = 0; k < g.length; k++) result.push(g[k]); }
|
||||||
|
} else {
|
||||||
|
result.push(g[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sorts items for two-column flow: left column first, then right column.
|
||||||
|
// A column_break marker is inserted at the boundary for the Word builder to use.
|
||||||
|
function sortTwoColumn(items, midpointPt, pageWidthPt) {
|
||||||
|
var leftCol = [], rightCol = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
var item = items[i];
|
||||||
|
var xCenter = item.x_pt + (item.width_pt || 0) / 2;
|
||||||
|
var fullWidth = (item.width_pt || 0) > pageWidthPt * 0.75;
|
||||||
|
if (fullWidth || xCenter <= midpointPt) {
|
||||||
|
leftCol.push(item);
|
||||||
|
} else {
|
||||||
|
rightCol.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
leftCol.sort(function(a, b) { return a.y_pt - b.y_pt; });
|
||||||
|
rightCol.sort(function(a, b) { return a.y_pt - b.y_pt; });
|
||||||
|
|
||||||
|
if (rightCol.length === 0) return leftCol;
|
||||||
|
|
||||||
|
// Insert a column_break marker so the Word builder knows where to break
|
||||||
|
var marker = { type: 'column_break', id: 'col_break', x_pt: 0, y_pt: 0, width_pt: 0, height_pt: 0 };
|
||||||
|
return leftCol.concat([marker], rightCol);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractItem(item, page, doc) {
|
||||||
|
var bounds = item.geometricBounds; // [top, left, bottom, right] in spread coords
|
||||||
|
var pageBounds = page.bounds; // [top, left, bottom, right] of page
|
||||||
|
var x = bounds[1] - pageBounds[1];
|
||||||
|
var y = bounds[0] - pageBounds[0];
|
||||||
|
var width = bounds[3] - bounds[1];
|
||||||
|
var height = bounds[2] - bounds[0];
|
||||||
|
|
||||||
|
var typeName = item.constructor.name;
|
||||||
|
|
||||||
|
if (typeName === 'TextFrame') {
|
||||||
|
return extractTextFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeName === 'Rectangle' || typeName === 'Oval' || typeName === 'GraphicFrame') {
|
||||||
|
if (item.graphics && item.graphics.length > 0) {
|
||||||
|
return extractImageFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
return extractShapeFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text frames ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractTextFrame(frame, x, y, width, height) {
|
||||||
|
if (hasAutoPageNumber(frame)) {
|
||||||
|
return extractPageNumber(frame, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
var paragraphs = [];
|
||||||
|
try {
|
||||||
|
// Use frame.texts[0].paragraphs — only the text visible in THIS frame,
|
||||||
|
// not frame.parentStory.paragraphs which returns the entire threaded story.
|
||||||
|
var frameText = frame.texts[0];
|
||||||
|
for (var i = 0; i < frameText.paragraphs.length; i++) {
|
||||||
|
var para = frameText.paragraphs[i];
|
||||||
|
paragraphs.push(extractParagraph(para));
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
var threadId = '';
|
||||||
|
var threadPos = 1;
|
||||||
|
try {
|
||||||
|
threadId = frame.parentStory.id.toString();
|
||||||
|
var prev = frame.previousTextFrame;
|
||||||
|
while (prev !== null) {
|
||||||
|
threadPos++;
|
||||||
|
prev = prev.previousTextFrame;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'text_frame',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
thread_id: threadId,
|
||||||
|
thread_position: threadPos,
|
||||||
|
x_pt: x,
|
||||||
|
y_pt: y,
|
||||||
|
width_pt: width,
|
||||||
|
height_pt: height,
|
||||||
|
column_count: safeGet(frame.textFramePreferences, 'textColumnCount', 1),
|
||||||
|
paragraphs: paragraphs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParagraph(para) {
|
||||||
|
var styleName = '[No Paragraph Style]';
|
||||||
|
try { styleName = para.appliedParagraphStyle.name; } catch(e) {}
|
||||||
|
|
||||||
|
var runs = [];
|
||||||
|
var characters = para.characters;
|
||||||
|
for (var i = 0; i < characters.length; i++) {
|
||||||
|
var run = buildRun(characters[i]);
|
||||||
|
if (runs.length > 0 && runsMatch(runs[runs.length - 1], run)) {
|
||||||
|
runs[runs.length - 1].text += run.text;
|
||||||
|
} else {
|
||||||
|
runs.push(run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: styleName,
|
||||||
|
text: para.contents,
|
||||||
|
runs: runs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRun(ch) {
|
||||||
|
var styleName = null;
|
||||||
|
try {
|
||||||
|
if (ch.appliedCharacterStyle.name !== '[No Character Style]') {
|
||||||
|
styleName = ch.appliedCharacterStyle.name;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
var fontStyle = safeGet(ch, 'fontStyle', '').toLowerCase();
|
||||||
|
return {
|
||||||
|
text: ch.contents,
|
||||||
|
style: styleName,
|
||||||
|
bold: fontStyle.indexOf('bold') >= 0,
|
||||||
|
italic: fontStyle.indexOf('italic') >= 0,
|
||||||
|
color_hex: resolveColor(safeGet(ch, 'fillColor', null)),
|
||||||
|
font: safeGet(ch, 'appliedFont') ? ch.appliedFont.name : null,
|
||||||
|
size_pt: safeGet(ch, 'pointSize', null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runsMatch(a, b) {
|
||||||
|
return a.style === b.style &&
|
||||||
|
a.bold === b.bold &&
|
||||||
|
a.italic === b.italic &&
|
||||||
|
a.color_hex === b.color_hex &&
|
||||||
|
a.font === b.font &&
|
||||||
|
a.size_pt === b.size_pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Images ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractImageFrame(frame, x, y, width, height) {
|
||||||
|
var imageData = null;
|
||||||
|
var imageName = 'image';
|
||||||
|
try {
|
||||||
|
var graphic = frame.graphics[0];
|
||||||
|
imageName = graphic.itemLink.name;
|
||||||
|
var tempFile = new File(Folder.temp + '/idconvert_temp.jpg');
|
||||||
|
frame.exportFile(ExportFormat.JPG, tempFile, false);
|
||||||
|
imageData = fileToBase64(tempFile);
|
||||||
|
tempFile.remove();
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'image_frame',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
width_pt: width, height_pt: height,
|
||||||
|
image_name: imageName,
|
||||||
|
image_data_b64: imageData,
|
||||||
|
fit_mode: 'fill_proportionally'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shapes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractShapeFrame(frame, x, y, width, height) {
|
||||||
|
var fillColor = null;
|
||||||
|
try {
|
||||||
|
if (frame.fillColor.name !== 'None' && frame.fillColor.name !== '[None]') {
|
||||||
|
fillColor = colorToHex(frame.fillColor);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
if (!fillColor) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'shape',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
width_pt: width, height_pt: height,
|
||||||
|
fill_hex: fillColor
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page numbers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractPageNumber(frame, x, y) {
|
||||||
|
var run = null;
|
||||||
|
try { run = frame.parentStory.characters[0]; } catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'page_number',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
font: run ? (run.appliedFont ? run.appliedFont.name : null) : null,
|
||||||
|
size_pt: run ? safeGet(run, 'pointSize', 9) : 9,
|
||||||
|
color_hex: run ? resolveColor(safeGet(run, 'fillColor', null)) : '000000',
|
||||||
|
alignment: run ? alignmentToString(safeGet(run, 'justification', null)) : 'center'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAutoPageNumber(frame) {
|
||||||
|
try {
|
||||||
|
var characters = frame.parentStory.texts[0].characters;
|
||||||
|
for (var i = 0; i < characters.length; i++) {
|
||||||
|
if (characters[i].contents === SpecialCharacters.AUTO_PAGE_NUMBER) return true;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveColor(colorObj) {
|
||||||
|
if (!colorObj) return null;
|
||||||
|
try {
|
||||||
|
if (colorObj.name === 'None' || colorObj.name === '[None]') return null;
|
||||||
|
return colorToHex(colorObj);
|
||||||
|
} catch(e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorToHex(colorObj) {
|
||||||
|
try {
|
||||||
|
var vals = colorObj.colorValue;
|
||||||
|
if (colorObj.space === ColorSpace.CMYK) {
|
||||||
|
var c = vals[0]/100, m = vals[1]/100, y = vals[2]/100, k = vals[3]/100;
|
||||||
|
return toHex(Math.round(255*(1-c)*(1-k))) +
|
||||||
|
toHex(Math.round(255*(1-m)*(1-k))) +
|
||||||
|
toHex(Math.round(255*(1-y)*(1-k)));
|
||||||
|
}
|
||||||
|
if (colorObj.space === ColorSpace.RGB) {
|
||||||
|
return toHex(vals[0]) + toHex(vals[1]) + toHex(vals[2]);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
return '000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(n) {
|
||||||
|
var h = Math.max(0, Math.min(255, Math.round(n))).toString(16);
|
||||||
|
return h.length === 1 ? '0' + h : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignmentToString(justification) {
|
||||||
|
if (!justification) return 'left';
|
||||||
|
var map = {
|
||||||
|
1: 'left', 2: 'center', 3: 'right', 4: 'justify',
|
||||||
|
1514227313: 'left', 1514731619: 'center',
|
||||||
|
1514731618: 'right', 1514599026: 'justify'
|
||||||
|
};
|
||||||
|
return map[justification] || 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToBase64(file) {
|
||||||
|
file.open('r');
|
||||||
|
file.encoding = 'BINARY';
|
||||||
|
var content = file.read();
|
||||||
|
file.close();
|
||||||
|
return btoa(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeGet(obj, prop, fallback) {
|
||||||
|
try {
|
||||||
|
var val = obj[prop];
|
||||||
|
return (val !== undefined && val !== null) ? val : fallback;
|
||||||
|
} catch(e) {
|
||||||
|
return (fallback !== undefined) ? fallback : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exportDocument();
|
||||||
|
|
@ -0,0 +1,539 @@
|
||||||
|
// IDexport.jsx — runs inside Adobe InDesign
|
||||||
|
// Exports the active document as idconvert_export.json
|
||||||
|
// Upload the resulting JSON to IDconvert to generate an editable Word document.
|
||||||
|
// Version: 2.0
|
||||||
|
|
||||||
|
#target indesign
|
||||||
|
|
||||||
|
// ── btoa polyfill (ExtendScript / ES3 does not have btoa built-in) ───────────
|
||||||
|
|
||||||
|
var _b64chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||||
|
|
||||||
|
function btoa(input) {
|
||||||
|
var output = '';
|
||||||
|
var i = 0;
|
||||||
|
var len = input.length;
|
||||||
|
while (i < len) {
|
||||||
|
var b0 = input.charCodeAt(i++) & 0xff;
|
||||||
|
var b1 = (i < len) ? (input.charCodeAt(i++) & 0xff) : 0;
|
||||||
|
var b2 = (i < len) ? (input.charCodeAt(i++) & 0xff) : 0;
|
||||||
|
output += _b64chars.charAt(b0 >> 2);
|
||||||
|
output += _b64chars.charAt(((b0 & 3) << 4) | (b1 >> 4));
|
||||||
|
output += _b64chars.charAt(((b1 & 15) << 2) | (b2 >> 6));
|
||||||
|
output += _b64chars.charAt(b2 & 63);
|
||||||
|
}
|
||||||
|
var pad = len % 3;
|
||||||
|
if (pad === 1) output = output.slice(0, -2) + '==';
|
||||||
|
else if (pad === 2) output = output.slice(0, -1) + '=';
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSON polyfill (ExtendScript / ES3 does not have JSON built-in) ────────────
|
||||||
|
|
||||||
|
var JSON = (function () {
|
||||||
|
function stringify(val) {
|
||||||
|
if (val === null) return 'null';
|
||||||
|
if (val === undefined) return 'null';
|
||||||
|
var t = typeof val;
|
||||||
|
if (t === 'boolean') return val ? 'true' : 'false';
|
||||||
|
if (t === 'number') {
|
||||||
|
if (!isFinite(val)) return 'null';
|
||||||
|
return String(val);
|
||||||
|
}
|
||||||
|
if (t === 'string') {
|
||||||
|
return '"' + val
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/\r/g, '\\r')
|
||||||
|
.replace(/\t/g, '\\t') + '"';
|
||||||
|
}
|
||||||
|
if (t === 'object') {
|
||||||
|
if (val instanceof Array) {
|
||||||
|
var items = [];
|
||||||
|
for (var i = 0; i < val.length; i++) {
|
||||||
|
items.push(stringify(val[i]));
|
||||||
|
}
|
||||||
|
return '[' + items.join(',') + ']';
|
||||||
|
}
|
||||||
|
var pairs = [];
|
||||||
|
for (var k in val) {
|
||||||
|
if (val.hasOwnProperty(k)) {
|
||||||
|
var v = stringify(val[k]);
|
||||||
|
if (v !== undefined) {
|
||||||
|
pairs.push('"' + k + '":' + v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '{' + pairs.join(',') + '}';
|
||||||
|
}
|
||||||
|
return 'null';
|
||||||
|
}
|
||||||
|
return { stringify: stringify };
|
||||||
|
}());
|
||||||
|
|
||||||
|
function exportDocument() {
|
||||||
|
if (app.documents.length === 0) {
|
||||||
|
alert('No document is open. Please open an InDesign document first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = app.activeDocument;
|
||||||
|
|
||||||
|
if (!doc.saved) {
|
||||||
|
alert('Please save your document before exporting.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force all scripting measurements to points for the duration of this export.
|
||||||
|
var originalUnit = app.scriptPreferences.measurementUnit;
|
||||||
|
app.scriptPreferences.measurementUnit = MeasurementUnits.POINTS;
|
||||||
|
|
||||||
|
var output;
|
||||||
|
try {
|
||||||
|
output = {
|
||||||
|
version: '2.0',
|
||||||
|
generator: 'IDexport',
|
||||||
|
document: extractDocumentInfo(doc),
|
||||||
|
styles: extractStyles(doc),
|
||||||
|
colors: extractColors(doc),
|
||||||
|
fonts_used: extractFonts(doc),
|
||||||
|
pages: extractPages(doc)
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
app.scriptPreferences.measurementUnit = originalUnit;
|
||||||
|
}
|
||||||
|
|
||||||
|
var exportPath = doc.filePath + '/idconvert_export.json';
|
||||||
|
var file = new File(exportPath);
|
||||||
|
file.encoding = 'UTF-8';
|
||||||
|
file.open('w');
|
||||||
|
file.write(JSON.stringify(output));
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
alert('IDexport complete.\nFile saved to:\n' + exportPath +
|
||||||
|
'\n\nUpload idconvert_export.json to IDconvert to generate your Word document.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Document info ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractDocumentInfo(doc) {
|
||||||
|
var page = doc.pages[0];
|
||||||
|
var bounds = page.bounds; // [top, left, bottom, right]
|
||||||
|
return {
|
||||||
|
name: doc.name.replace('.indd', ''),
|
||||||
|
page_width_pt: bounds[3] - bounds[1],
|
||||||
|
page_height_pt: bounds[2] - bounds[0],
|
||||||
|
page_count: doc.pages.length,
|
||||||
|
facing_pages: doc.documentPreferences.facingPages
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractStyles(doc) {
|
||||||
|
var paragraphStyles = [];
|
||||||
|
for (var i = 0; i < doc.paragraphStyles.length; i++) {
|
||||||
|
var s = doc.paragraphStyles[i];
|
||||||
|
if (s.name === '[No Paragraph Style]' || s.name === '[Basic Paragraph]') continue;
|
||||||
|
try {
|
||||||
|
paragraphStyles.push({
|
||||||
|
name: s.name,
|
||||||
|
font: safeGet(s, 'appliedFont') ? s.appliedFont.name : null,
|
||||||
|
size_pt: safeGet(s, 'pointSize', null),
|
||||||
|
leading_pt: safeGet(s, 'leading', null),
|
||||||
|
space_before_pt: safeGet(s, 'spaceBefore', 0),
|
||||||
|
space_after_pt: safeGet(s, 'spaceAfter', 0),
|
||||||
|
alignment: alignmentToString(safeGet(s, 'justification', null)),
|
||||||
|
color_hex: resolveColor(safeGet(s, 'fillColor', null)),
|
||||||
|
bold: safeGet(s, 'fontStyle', '').toLowerCase().indexOf('bold') >= 0,
|
||||||
|
italic: safeGet(s, 'fontStyle', '').toLowerCase().indexOf('italic') >= 0
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
var characterStyles = [];
|
||||||
|
for (var j = 0; j < doc.characterStyles.length; j++) {
|
||||||
|
var cs = doc.characterStyles[j];
|
||||||
|
if (cs.name === '[No Character Style]') continue;
|
||||||
|
try {
|
||||||
|
characterStyles.push({
|
||||||
|
name: cs.name,
|
||||||
|
font: safeGet(cs, 'appliedFont') ? cs.appliedFont.name : null,
|
||||||
|
bold: safeGet(cs, 'fontStyle', '').toLowerCase().indexOf('bold') >= 0,
|
||||||
|
italic: safeGet(cs, 'fontStyle', '').toLowerCase().indexOf('italic') >= 0,
|
||||||
|
color_hex: resolveColor(safeGet(cs, 'fillColor', null))
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { paragraph: paragraphStyles, character: characterStyles };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Colors ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractColors(doc) {
|
||||||
|
var colors = {};
|
||||||
|
for (var i = 0; i < doc.colors.length; i++) {
|
||||||
|
var c = doc.colors[i];
|
||||||
|
try { colors[c.name] = colorToHex(c); } catch(e) {}
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fonts ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractFonts(doc) {
|
||||||
|
var fonts = [];
|
||||||
|
var seen = {};
|
||||||
|
for (var i = 0; i < doc.fonts.length; i++) {
|
||||||
|
var f = doc.fonts[i];
|
||||||
|
if (!seen[f.name]) {
|
||||||
|
seen[f.name] = true;
|
||||||
|
fonts.push({
|
||||||
|
name: f.name,
|
||||||
|
postscript: safeGet(f, 'postscriptName', f.name)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fonts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pages ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractPages(doc) {
|
||||||
|
var pages = [];
|
||||||
|
for (var i = 0; i < doc.pages.length; i++) {
|
||||||
|
var page = doc.pages[i];
|
||||||
|
var bounds = page.bounds;
|
||||||
|
var pageWidthPt = bounds[3] - bounds[1];
|
||||||
|
var pageHeightPt = bounds[2] - bounds[0];
|
||||||
|
pages.push({
|
||||||
|
page_number: page.name,
|
||||||
|
width_pt: pageWidthPt,
|
||||||
|
height_pt: pageHeightPt,
|
||||||
|
items: extractPageItems(page, doc)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extracts all items from a page, sorted top-to-bottom left-to-right.
|
||||||
|
// Items are placed absolutely in Word — no grouping or layout detection needed.
|
||||||
|
function extractPageItems(page, doc) {
|
||||||
|
var allItems = page.allPageItems;
|
||||||
|
var items = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < allItems.length; i++) {
|
||||||
|
var item = allItems[i];
|
||||||
|
// Skip Group container nodes — their children appear separately in allPageItems
|
||||||
|
if (item.constructor.name === 'Group') continue;
|
||||||
|
try {
|
||||||
|
var extracted = extractItem(item, page, doc);
|
||||||
|
if (extracted) items.push(extracted);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort top-to-bottom, left-to-right — shapes will be behind text in Word
|
||||||
|
items.sort(function(a, b) {
|
||||||
|
if (Math.abs(a.y_pt - b.y_pt) < 5) return a.x_pt - b.x_pt;
|
||||||
|
return a.y_pt - b.y_pt;
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractItem(item, page, doc) {
|
||||||
|
var bounds = item.geometricBounds; // [top, left, bottom, right] in spread coords
|
||||||
|
var pageBounds = page.bounds;
|
||||||
|
var x = bounds[1] - pageBounds[1];
|
||||||
|
var y = bounds[0] - pageBounds[0];
|
||||||
|
var width = bounds[3] - bounds[1];
|
||||||
|
var height = bounds[2] - bounds[0];
|
||||||
|
|
||||||
|
var typeName = item.constructor.name;
|
||||||
|
|
||||||
|
if (typeName === 'TextFrame') {
|
||||||
|
return extractTextFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeName === 'GraphicLine') {
|
||||||
|
return extractShapeFrame(item, x, y, width, height, 'line');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeName === 'Rectangle' || typeName === 'GraphicFrame') {
|
||||||
|
if (item.graphics && item.graphics.length > 0) {
|
||||||
|
return extractImageFrame(item, x, y, width, height);
|
||||||
|
}
|
||||||
|
return extractShapeFrame(item, x, y, width, height, 'rect');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeName === 'Oval') {
|
||||||
|
return extractShapeFrame(item, x, y, width, height, 'ellipse');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeName === 'Polygon') {
|
||||||
|
return extractShapeFrame(item, x, y, width, height, 'rect');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Text frames ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractTextFrame(frame, x, y, width, height) {
|
||||||
|
if (hasAutoPageNumber(frame)) {
|
||||||
|
return extractPageNumber(frame, x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
var paragraphs = [];
|
||||||
|
try {
|
||||||
|
var frameText = frame.texts[0];
|
||||||
|
for (var i = 0; i < frameText.paragraphs.length; i++) {
|
||||||
|
var para = frameText.paragraphs[i];
|
||||||
|
paragraphs.push(extractParagraph(para));
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
var threadId = '';
|
||||||
|
var threadPos = 1;
|
||||||
|
try {
|
||||||
|
threadId = frame.parentStory.id.toString();
|
||||||
|
var prev = frame.previousTextFrame;
|
||||||
|
while (prev !== null) {
|
||||||
|
threadPos++;
|
||||||
|
prev = prev.previousTextFrame;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'text_frame',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
thread_id: threadId,
|
||||||
|
thread_position: threadPos,
|
||||||
|
x_pt: x,
|
||||||
|
y_pt: y,
|
||||||
|
width_pt: width,
|
||||||
|
height_pt: height,
|
||||||
|
column_count: safeGet(frame.textFramePreferences, 'textColumnCount', 1),
|
||||||
|
paragraphs: paragraphs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractParagraph(para) {
|
||||||
|
var styleName = '[No Paragraph Style]';
|
||||||
|
try { styleName = para.appliedParagraphStyle.name; } catch(e) {}
|
||||||
|
|
||||||
|
var runs = [];
|
||||||
|
var characters = para.characters;
|
||||||
|
for (var i = 0; i < characters.length; i++) {
|
||||||
|
var run = buildRun(characters[i]);
|
||||||
|
if (runs.length > 0 && runsMatch(runs[runs.length - 1], run)) {
|
||||||
|
runs[runs.length - 1].text += run.text;
|
||||||
|
} else {
|
||||||
|
runs.push(run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: styleName,
|
||||||
|
text: para.contents,
|
||||||
|
runs: runs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRun(ch) {
|
||||||
|
var styleName = null;
|
||||||
|
try {
|
||||||
|
if (ch.appliedCharacterStyle.name !== '[No Character Style]') {
|
||||||
|
styleName = ch.appliedCharacterStyle.name;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
var fontStyle = safeGet(ch, 'fontStyle', '').toLowerCase();
|
||||||
|
return {
|
||||||
|
text: ch.contents,
|
||||||
|
style: styleName,
|
||||||
|
bold: fontStyle.indexOf('bold') >= 0,
|
||||||
|
italic: fontStyle.indexOf('italic') >= 0,
|
||||||
|
color_hex: resolveColor(safeGet(ch, 'fillColor', null)),
|
||||||
|
font: safeGet(ch, 'appliedFont') ? ch.appliedFont.name : null,
|
||||||
|
size_pt: safeGet(ch, 'pointSize', null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runsMatch(a, b) {
|
||||||
|
return a.style === b.style &&
|
||||||
|
a.bold === b.bold &&
|
||||||
|
a.italic === b.italic &&
|
||||||
|
a.color_hex === b.color_hex &&
|
||||||
|
a.font === b.font &&
|
||||||
|
a.size_pt === b.size_pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Images ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractImageFrame(frame, x, y, width, height) {
|
||||||
|
var imageData = null;
|
||||||
|
var imageName = 'image';
|
||||||
|
try {
|
||||||
|
var graphic = frame.graphics[0];
|
||||||
|
imageName = graphic.itemLink.name;
|
||||||
|
var tempFile = new File(Folder.temp + '/idconvert_temp.jpg');
|
||||||
|
frame.exportFile(ExportFormat.JPG, tempFile, false);
|
||||||
|
// InDesign may append a page suffix — search for the actual file
|
||||||
|
var actualFile = null;
|
||||||
|
if (tempFile.exists) {
|
||||||
|
actualFile = tempFile;
|
||||||
|
} else {
|
||||||
|
var matches = new Folder(Folder.temp).getFiles('idconvert_temp*.jpg');
|
||||||
|
if (matches && matches.length > 0) actualFile = matches[0];
|
||||||
|
}
|
||||||
|
if (actualFile) {
|
||||||
|
imageData = fileToBase64(actualFile);
|
||||||
|
actualFile.remove();
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'image_frame',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
width_pt: width, height_pt: height,
|
||||||
|
image_name: imageName,
|
||||||
|
image_data_b64: imageData,
|
||||||
|
fit_mode: 'fill_proportionally'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shapes ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Exports a shape (rectangle, oval, line, polygon) with fill and/or stroke.
|
||||||
|
// shape_type: 'rect' | 'ellipse' | 'line'
|
||||||
|
function extractShapeFrame(frame, x, y, width, height, shapeType) {
|
||||||
|
var fillColor = null;
|
||||||
|
var strokeColor = null;
|
||||||
|
var strokeWeight = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (frame.fillColor.name !== 'None' && frame.fillColor.name !== '[None]') {
|
||||||
|
fillColor = colorToHex(frame.fillColor);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (frame.strokeColor.name !== 'None' && frame.strokeColor.name !== '[None]') {
|
||||||
|
strokeColor = colorToHex(frame.strokeColor);
|
||||||
|
strokeWeight = safeGet(frame, 'strokeWeight', 1);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Skip completely invisible shapes
|
||||||
|
if (!fillColor && !strokeColor) return null;
|
||||||
|
|
||||||
|
// Ensure lines have enough height/width to render
|
||||||
|
if (shapeType === 'line') {
|
||||||
|
if (height < 1) height = strokeWeight || 1;
|
||||||
|
if (width < 1) width = strokeWeight || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'shape',
|
||||||
|
shape_type: shapeType,
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
width_pt: width, height_pt: height,
|
||||||
|
fill_hex: fillColor,
|
||||||
|
stroke_hex: strokeColor,
|
||||||
|
stroke_weight_pt: strokeWeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page numbers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function extractPageNumber(frame, x, y) {
|
||||||
|
var run = null;
|
||||||
|
try { run = frame.parentStory.characters[0]; } catch(e) {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'page_number',
|
||||||
|
id: frame.id.toString(),
|
||||||
|
x_pt: x, y_pt: y,
|
||||||
|
font: run ? (run.appliedFont ? run.appliedFont.name : null) : null,
|
||||||
|
size_pt: run ? safeGet(run, 'pointSize', 9) : 9,
|
||||||
|
color_hex: run ? resolveColor(safeGet(run, 'fillColor', null)) : '000000',
|
||||||
|
alignment: run ? alignmentToString(safeGet(run, 'justification', null)) : 'center'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAutoPageNumber(frame) {
|
||||||
|
try {
|
||||||
|
var characters = frame.parentStory.texts[0].characters;
|
||||||
|
for (var i = 0; i < characters.length; i++) {
|
||||||
|
if (characters[i].contents === SpecialCharacters.AUTO_PAGE_NUMBER) return true;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function resolveColor(colorObj) {
|
||||||
|
if (!colorObj) return null;
|
||||||
|
try {
|
||||||
|
if (colorObj.name === 'None' || colorObj.name === '[None]') return null;
|
||||||
|
return colorToHex(colorObj);
|
||||||
|
} catch(e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorToHex(colorObj) {
|
||||||
|
try {
|
||||||
|
var vals = colorObj.colorValue;
|
||||||
|
if (colorObj.space === ColorSpace.CMYK) {
|
||||||
|
var c = vals[0]/100, m = vals[1]/100, y = vals[2]/100, k = vals[3]/100;
|
||||||
|
return toHex(Math.round(255*(1-c)*(1-k))) +
|
||||||
|
toHex(Math.round(255*(1-m)*(1-k))) +
|
||||||
|
toHex(Math.round(255*(1-y)*(1-k)));
|
||||||
|
}
|
||||||
|
if (colorObj.space === ColorSpace.RGB) {
|
||||||
|
return toHex(vals[0]) + toHex(vals[1]) + toHex(vals[2]);
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
return '000000';
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHex(n) {
|
||||||
|
var h = Math.max(0, Math.min(255, Math.round(n))).toString(16);
|
||||||
|
return h.length === 1 ? '0' + h : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function alignmentToString(justification) {
|
||||||
|
if (!justification) return 'left';
|
||||||
|
var map = {
|
||||||
|
1: 'left', 2: 'center', 3: 'right', 4: 'justify',
|
||||||
|
1514227313: 'left', 1514731619: 'center',
|
||||||
|
1514731618: 'right', 1514599026: 'justify'
|
||||||
|
};
|
||||||
|
return map[justification] || 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToBase64(file) {
|
||||||
|
file.open('r');
|
||||||
|
file.encoding = 'BINARY';
|
||||||
|
var content = file.read();
|
||||||
|
file.close();
|
||||||
|
return btoa(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeGet(obj, prop, fallback) {
|
||||||
|
try {
|
||||||
|
var val = obj[prop];
|
||||||
|
return (val !== undefined && val !== null) ? val : fallback;
|
||||||
|
} catch(e) {
|
||||||
|
return (fallback !== undefined) ? fallback : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
exportDocument();
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system deps for python-docx / lxml
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libxml2-dev libxslt-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
frontend_origin: str = 'http://localhost:3000'
|
||||||
|
pocketbase_url: str = 'http://localhost:8090'
|
||||||
|
pocketbase_admin_token: str = '' # PocketBase admin API key for server-side writes
|
||||||
|
# MinIO / S3-compatible storage
|
||||||
|
s3_bucket: str = 'idconvert'
|
||||||
|
s3_endpoint_url: str = 'http://localhost:9000'
|
||||||
|
s3_region: str = 'us-east-1'
|
||||||
|
aws_access_key_id: str = 'minioadmin'
|
||||||
|
aws_secret_access_key: str = 'minioadmin'
|
||||||
|
s3_presign_expiry_seconds: int = 3600
|
||||||
|
# Stripe
|
||||||
|
stripe_secret_key: str = ''
|
||||||
|
stripe_webhook_secret: str = ''
|
||||||
|
stripe_publishable_key: str = ''
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = '.env'
|
||||||
|
env_file_encoding = 'utf-8'
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
class IDConvertException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class FileValidationError(IDConvertException):
|
||||||
|
def __init__(self, message: str, error_code: str = 'FILE_INVALID'):
|
||||||
|
self.message = message
|
||||||
|
self.error_code = error_code
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
class InsufficientCreditsError(IDConvertException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ConversionError(IDConvertException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ExportParseError(IDConvertException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class StorageError(IDConvertException):
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
def setup_logging():
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
structlog.processors.TimeStamper(fmt='iso'),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.dev.ConsoleRenderer(),
|
||||||
|
],
|
||||||
|
wrapper_class=structlog.make_filtering_bound_logger(20), # INFO
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.PrintLoggerFactory(),
|
||||||
|
)
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""Async HTTP client for PocketBase REST API.
|
||||||
|
|
||||||
|
All PocketBase I/O goes through this module. Repositories import functions
|
||||||
|
from here — they do not construct httpx clients directly.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
# ── Internal helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _client() -> httpx.AsyncClient:
|
||||||
|
return httpx.AsyncClient(base_url=settings.pocketbase_url, timeout=10.0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── User operations ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_user_by_token(token: str) -> dict:
|
||||||
|
"""Validate a PocketBase auth token and return the user record.
|
||||||
|
|
||||||
|
Raises httpx.HTTPStatusError if the token is invalid or expired.
|
||||||
|
"""
|
||||||
|
async with _client() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
'/api/collections/users/auth-refresh',
|
||||||
|
headers={'Authorization': f'Bearer {token}'},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
record = body['record']
|
||||||
|
record['token'] = body['token'] # refreshed token
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_id(user_id: str, admin_token: str) -> dict:
|
||||||
|
"""Fetch a user record by ID using an admin token."""
|
||||||
|
async with _client() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f'/api/collections/users/records/{user_id}',
|
||||||
|
headers={'Authorization': f'Bearer {admin_token}'},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Conversion operations ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_conversion(user_id: str, file_hash: str, admin_token: str) -> str:
|
||||||
|
"""Create a conversion record. Triggers the credit-deduction hook atomically.
|
||||||
|
|
||||||
|
Returns the new record ID. Raises httpx.HTTPStatusError on failure,
|
||||||
|
including INSUFFICIENT_CREDITS from the hook.
|
||||||
|
"""
|
||||||
|
async with _client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
'/api/collections/conversions/records',
|
||||||
|
json={'user': user_id, 'file_hash': file_hash, 'status': 'pending'},
|
||||||
|
headers={'Authorization': f'Bearer {admin_token}'},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()['id']
|
||||||
|
|
||||||
|
|
||||||
|
async def update_conversion_status(record_id: str, status: str, admin_token: str) -> None:
|
||||||
|
"""Update conversion status to 'complete' or 'failed'."""
|
||||||
|
async with _client() as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
f'/api/collections/conversions/records/{record_id}',
|
||||||
|
json={'status': status},
|
||||||
|
headers={'Authorization': f'Bearer {admin_token}'},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_conversion_download_url(record_id: str, url: str, admin_token: str) -> None:
|
||||||
|
"""Store the MinIO presigned download URL on the conversion record."""
|
||||||
|
async with _client() as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
f'/api/collections/conversions/records/{record_id}',
|
||||||
|
json={'status': 'complete', 'download_url': url},
|
||||||
|
headers={'Authorization': f'Bearer {admin_token}'},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Purchase operations ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_purchase(user_id: str, credits: int, stripe_id: str, admin_token: str) -> str:
|
||||||
|
"""Create a purchase record. Triggers the credit top-up hook atomically."""
|
||||||
|
async with _client() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
'/api/collections/purchases/records',
|
||||||
|
json={'user': user_id, 'credits': credits, 'stripe_id': stripe_id},
|
||||||
|
headers={'Authorization': f'Bearer {admin_token}'},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()['id']
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
"""MinIO / S3-compatible object storage.
|
||||||
|
|
||||||
|
Provides upload and presigned URL generation for converted DOCX files.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import structlog
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from core.exceptions import StorageError
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
# Pack credit values — used by payment service to resolve pack → credit count
|
||||||
|
PACK_CREDITS: dict[str, int] = {
|
||||||
|
'starter': 5,
|
||||||
|
'studio': 20,
|
||||||
|
'agency': 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _s3_client():
|
||||||
|
return boto3.client(
|
||||||
|
's3',
|
||||||
|
endpoint_url=settings.s3_endpoint_url,
|
||||||
|
region_name=settings.s3_region,
|
||||||
|
aws_access_key_id=settings.aws_access_key_id,
|
||||||
|
aws_secret_access_key=settings.aws_secret_access_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_docx(object_key: str, docx_bytes: bytes) -> str:
|
||||||
|
"""Upload a DOCX file to MinIO and return a presigned download URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_key: S3 object key, e.g. 'conversions/<session_id>.docx'
|
||||||
|
docx_bytes: Raw DOCX bytes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Presigned URL valid for settings.s3_presign_expiry_seconds.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
StorageError: If upload or presign fails.
|
||||||
|
"""
|
||||||
|
client = _s3_client()
|
||||||
|
try:
|
||||||
|
client.put_object(
|
||||||
|
Bucket=settings.s3_bucket,
|
||||||
|
Key=object_key,
|
||||||
|
Body=docx_bytes,
|
||||||
|
ContentType='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
)
|
||||||
|
log.info('docx_uploaded', key=object_key, size_bytes=len(docx_bytes))
|
||||||
|
except ClientError as e:
|
||||||
|
log.error('docx_upload_failed', key=object_key, error=str(e))
|
||||||
|
raise StorageError(f'Upload failed: {e}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = client.generate_presigned_url(
|
||||||
|
'get_object',
|
||||||
|
Params={'Bucket': settings.s3_bucket, 'Key': object_key},
|
||||||
|
ExpiresIn=settings.s3_presign_expiry_seconds,
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
except ClientError as e:
|
||||||
|
log.error('docx_presign_failed', key=object_key, error=str(e))
|
||||||
|
raise StorageError(f'Presign failed: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
def storage_available() -> bool:
|
||||||
|
"""Return True if MinIO/S3 is reachable and the bucket exists."""
|
||||||
|
try:
|
||||||
|
client = _s3_client()
|
||||||
|
client.head_bucket(Bucket=settings.s3_bucket)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""FastAPI dependency injection wiring.
|
||||||
|
|
||||||
|
All service and repository instances are created here. No business logic.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import Depends, Header, HTTPException
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from export.validator import ExportValidator
|
||||||
|
from export.scanner import IDExportScanner
|
||||||
|
from repositories.user_repository import UserRepository
|
||||||
|
from repositories.conversion_repository import ConversionRepository
|
||||||
|
from services.scan_service import ScanService
|
||||||
|
from services.conversion_service import ConversionService
|
||||||
|
from services.user_service import UserService
|
||||||
|
from services.payment_service import PaymentService
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
# ── Singletons (stateless services safe to share) ─────────────────────────────
|
||||||
|
|
||||||
|
_validator = ExportValidator()
|
||||||
|
_scanner = IDExportScanner()
|
||||||
|
_scan_service = ScanService(_validator, _scanner)
|
||||||
|
_user_repo = UserRepository()
|
||||||
|
_conversion_repo = ConversionRepository()
|
||||||
|
_user_service = UserService(_user_repo)
|
||||||
|
_payment_service = PaymentService()
|
||||||
|
_conversion_service = ConversionService(_scan_service, _conversion_repo)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Service getters ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_scan_service() -> ScanService:
|
||||||
|
return _scan_service
|
||||||
|
|
||||||
|
def get_conversion_service() -> ConversionService:
|
||||||
|
return _conversion_service
|
||||||
|
|
||||||
|
def get_user_service() -> UserService:
|
||||||
|
return _user_service
|
||||||
|
|
||||||
|
def get_payment_service() -> PaymentService:
|
||||||
|
return _payment_service
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin token ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_admin_token() -> str:
|
||||||
|
"""Return the PocketBase admin token from settings.
|
||||||
|
|
||||||
|
This token is used for server-side writes (creating conversion/purchase
|
||||||
|
records) that must bypass normal user permission rules.
|
||||||
|
"""
|
||||||
|
return getattr(settings, 'pocketbase_admin_token', '')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth dependencies ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_optional_user(
|
||||||
|
authorization: Optional[str] = Header(None),
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Extract and validate the Bearer token. Returns user dict or None.
|
||||||
|
|
||||||
|
Used on endpoints where auth is optional (e.g. scan — no credits needed).
|
||||||
|
"""
|
||||||
|
if not authorization or not authorization.startswith('Bearer '):
|
||||||
|
return None
|
||||||
|
token = authorization.split(' ', 1)[1]
|
||||||
|
try:
|
||||||
|
user = await _user_repo.get_by_token(token)
|
||||||
|
return user
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_or_401(
|
||||||
|
authorization: Optional[str] = Header(None),
|
||||||
|
) -> dict:
|
||||||
|
"""Extract and validate the Bearer token. Raises 401 if missing or invalid.
|
||||||
|
|
||||||
|
Used on endpoints that require authentication (convert, users/me, payments).
|
||||||
|
"""
|
||||||
|
if not authorization or not authorization.startswith('Bearer '):
|
||||||
|
raise HTTPException(401, 'Authentication required')
|
||||||
|
token = authorization.split(' ', 1)[1]
|
||||||
|
try:
|
||||||
|
return await _user_repo.get_by_token(token)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(401, 'Invalid or expired token')
|
||||||
|
|
@ -0,0 +1,276 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from export.models import (
|
||||||
|
IDExportCharacterStyle,
|
||||||
|
IDExportDocument,
|
||||||
|
IDExportImageFrame,
|
||||||
|
IDExportPage,
|
||||||
|
IDExportPageNumber,
|
||||||
|
IDExportParagraph,
|
||||||
|
IDExportParagraphStyle,
|
||||||
|
IDExportRun,
|
||||||
|
IDExportShape,
|
||||||
|
IDExportTable,
|
||||||
|
IDExportTableCell,
|
||||||
|
IDExportTableRow,
|
||||||
|
IDExportTextFrame,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportDocumentFactory:
|
||||||
|
"""Builds IDExportDocument from a validated idconvert_export.json dict."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportDocument:
|
||||||
|
doc_info = data.get('document', {})
|
||||||
|
styles_data = data.get('styles', {})
|
||||||
|
|
||||||
|
paragraph_styles = [
|
||||||
|
IDExportParagraphStyleFactory.from_dict(s)
|
||||||
|
for s in styles_data.get('paragraph', [])
|
||||||
|
]
|
||||||
|
character_styles = [
|
||||||
|
IDExportCharacterStyleFactory.from_dict(s)
|
||||||
|
for s in styles_data.get('character', [])
|
||||||
|
]
|
||||||
|
|
||||||
|
pages = [
|
||||||
|
IDExportPageFactory.from_dict(p)
|
||||||
|
for p in data.get('pages', [])
|
||||||
|
]
|
||||||
|
|
||||||
|
# Find first page_number item across all pages for footer use
|
||||||
|
page_number_style: Optional[IDExportPageNumber] = None
|
||||||
|
for page in pages:
|
||||||
|
for item in page.items:
|
||||||
|
if isinstance(item, IDExportPageNumber):
|
||||||
|
page_number_style = item
|
||||||
|
break
|
||||||
|
if page_number_style:
|
||||||
|
break
|
||||||
|
|
||||||
|
return IDExportDocument(
|
||||||
|
name=doc_info.get('name', 'Document'),
|
||||||
|
page_width_pt=float(doc_info.get('page_width_pt', 595.28)),
|
||||||
|
page_height_pt=float(doc_info.get('page_height_pt', 841.89)),
|
||||||
|
page_count=int(doc_info.get('page_count', len(pages))),
|
||||||
|
facing_pages=bool(doc_info.get('facing_pages', False)),
|
||||||
|
paragraph_styles=paragraph_styles,
|
||||||
|
character_styles=character_styles,
|
||||||
|
colors=data.get('colors', {}),
|
||||||
|
fonts_used=data.get('fonts_used', []),
|
||||||
|
pages=pages,
|
||||||
|
page_number_style=page_number_style,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportParagraphStyleFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportParagraphStyle:
|
||||||
|
return IDExportParagraphStyle(
|
||||||
|
name=data.get('name', ''),
|
||||||
|
font=data.get('font'),
|
||||||
|
size_pt=_optional_float(data.get('size_pt')),
|
||||||
|
leading_pt=_optional_float(data.get('leading_pt')),
|
||||||
|
space_before_pt=float(data.get('space_before_pt', 0)),
|
||||||
|
space_after_pt=float(data.get('space_after_pt', 0)),
|
||||||
|
alignment=data.get('alignment', 'left'),
|
||||||
|
color_hex=data.get('color_hex'),
|
||||||
|
bold=bool(data.get('bold', False)),
|
||||||
|
italic=bool(data.get('italic', False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportCharacterStyleFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportCharacterStyle:
|
||||||
|
return IDExportCharacterStyle(
|
||||||
|
name=data.get('name', ''),
|
||||||
|
font=data.get('font'),
|
||||||
|
bold=bool(data.get('bold', False)),
|
||||||
|
italic=bool(data.get('italic', False)),
|
||||||
|
color_hex=data.get('color_hex'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportPageFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportPage:
|
||||||
|
items = [
|
||||||
|
IDExportItemFactory.from_dict(item)
|
||||||
|
for item in data.get('items', [])
|
||||||
|
if item
|
||||||
|
]
|
||||||
|
items = [i for i in items if i is not None]
|
||||||
|
|
||||||
|
return IDExportPage(
|
||||||
|
page_number=data.get('page_number', 1),
|
||||||
|
width_pt=float(data.get('width_pt', 595.28)),
|
||||||
|
height_pt=float(data.get('height_pt', 841.89)),
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportItemFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict):
|
||||||
|
item_type = data.get('type', '')
|
||||||
|
if item_type == 'text_frame':
|
||||||
|
return IDExportTextFrameFactory.from_dict(data)
|
||||||
|
if item_type == 'image_frame':
|
||||||
|
return IDExportImageFrameFactory.from_dict(data)
|
||||||
|
if item_type == 'table':
|
||||||
|
return IDExportTableFactory.from_dict(data)
|
||||||
|
if item_type == 'page_number':
|
||||||
|
return IDExportPageNumberFactory.from_dict(data)
|
||||||
|
if item_type == 'shape':
|
||||||
|
return IDExportShapeFactory.from_dict(data)
|
||||||
|
# frame_group, column_break, and unknown types not converted
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportShapeFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportShape:
|
||||||
|
return IDExportShape(
|
||||||
|
id=str(data.get('id', '')),
|
||||||
|
x_pt=float(data.get('x_pt', 0)),
|
||||||
|
y_pt=float(data.get('y_pt', 0)),
|
||||||
|
width_pt=float(data.get('width_pt', 0)),
|
||||||
|
height_pt=float(data.get('height_pt', 0)),
|
||||||
|
shape_type=data.get('shape_type', 'rect'),
|
||||||
|
fill_hex=data.get('fill_hex'),
|
||||||
|
stroke_hex=data.get('stroke_hex'),
|
||||||
|
stroke_weight_pt=float(data.get('stroke_weight_pt', 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportTextFrameFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportTextFrame:
|
||||||
|
paragraphs = [
|
||||||
|
IDExportParagraphFactory.from_dict(p)
|
||||||
|
for p in data.get('paragraphs', [])
|
||||||
|
]
|
||||||
|
return IDExportTextFrame(
|
||||||
|
id=str(data.get('id', '')),
|
||||||
|
thread_id=str(data.get('thread_id', '')),
|
||||||
|
thread_position=int(data.get('thread_position', 1)),
|
||||||
|
x_pt=float(data.get('x_pt', 0)),
|
||||||
|
y_pt=float(data.get('y_pt', 0)),
|
||||||
|
width_pt=float(data.get('width_pt', 0)),
|
||||||
|
height_pt=float(data.get('height_pt', 0)),
|
||||||
|
column_count=int(data.get('column_count', 1)),
|
||||||
|
paragraphs=paragraphs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportParagraphFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportParagraph:
|
||||||
|
runs = [
|
||||||
|
IDExportRunFactory.from_dict(r)
|
||||||
|
for r in data.get('runs', [])
|
||||||
|
]
|
||||||
|
return IDExportParagraph(
|
||||||
|
style=data.get('style', ''),
|
||||||
|
text=data.get('text', ''),
|
||||||
|
runs=runs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportRunFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportRun:
|
||||||
|
return IDExportRun(
|
||||||
|
text=data.get('text', ''),
|
||||||
|
style=data.get('style'),
|
||||||
|
bold=bool(data.get('bold', False)),
|
||||||
|
italic=bool(data.get('italic', False)),
|
||||||
|
color_hex=data.get('color_hex'),
|
||||||
|
font=data.get('font'),
|
||||||
|
size_pt=_optional_float(data.get('size_pt')),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportImageFrameFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportImageFrame:
|
||||||
|
return IDExportImageFrame(
|
||||||
|
id=str(data.get('id', '')),
|
||||||
|
x_pt=float(data.get('x_pt', 0)),
|
||||||
|
y_pt=float(data.get('y_pt', 0)),
|
||||||
|
width_pt=float(data.get('width_pt', 0)),
|
||||||
|
height_pt=float(data.get('height_pt', 0)),
|
||||||
|
image_name=data.get('image_name', 'image'),
|
||||||
|
image_data_b64=data.get('image_data_b64'),
|
||||||
|
fit_mode=data.get('fit_mode', 'fill_proportionally'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportTableFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportTable:
|
||||||
|
rows = [
|
||||||
|
IDExportTableRowFactory.from_dict(r)
|
||||||
|
for r in data.get('rows', [])
|
||||||
|
]
|
||||||
|
return IDExportTable(
|
||||||
|
id=str(data.get('id', '')),
|
||||||
|
x_pt=float(data.get('x_pt', 0)),
|
||||||
|
y_pt=float(data.get('y_pt', 0)),
|
||||||
|
width_pt=float(data.get('width_pt', 0)),
|
||||||
|
column_widths_pt=[float(w) for w in data.get('column_widths_pt', [])],
|
||||||
|
rows=rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportTableRowFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportTableRow:
|
||||||
|
cells = [
|
||||||
|
IDExportTableCellFactory.from_dict(c)
|
||||||
|
for c in data.get('cells', [])
|
||||||
|
]
|
||||||
|
return IDExportTableRow(
|
||||||
|
is_header=bool(data.get('is_header', False)),
|
||||||
|
cells=cells,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportTableCellFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportTableCell:
|
||||||
|
return IDExportTableCell(
|
||||||
|
text=data.get('text', ''),
|
||||||
|
style=data.get('style'),
|
||||||
|
col_span=int(data.get('col_span', 1)),
|
||||||
|
row_span=int(data.get('row_span', 1)),
|
||||||
|
background_hex=data.get('background_hex'),
|
||||||
|
text_color_hex=data.get('text_color_hex'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportPageNumberFactory:
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(data: dict) -> IDExportPageNumber:
|
||||||
|
return IDExportPageNumber(
|
||||||
|
id=str(data.get('id', '')),
|
||||||
|
x_pt=float(data.get('x_pt', 0)),
|
||||||
|
y_pt=float(data.get('y_pt', 0)),
|
||||||
|
font=data.get('font'),
|
||||||
|
size_pt=float(data.get('size_pt', 9)),
|
||||||
|
color_hex=data.get('color_hex', '000000') or '000000',
|
||||||
|
alignment=data.get('alignment', 'center'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_float(value) -> Optional[float]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
SYSTEM_SAFE = {
|
||||||
|
'Arial', 'Helvetica', 'Times New Roman', 'Georgia',
|
||||||
|
'Calibri', 'Cambria', 'Trebuchet MS', 'Verdana',
|
||||||
|
'Courier New', 'Tahoma', 'Garamond',
|
||||||
|
}
|
||||||
|
|
||||||
|
SUBSTITUTION_MAP = {
|
||||||
|
'Freight Text Pro': ('Georgia', 'serif body substitute'),
|
||||||
|
'Minion Pro': ('Georgia', 'serif body substitute'),
|
||||||
|
'Proxima Nova': ('Calibri', 'sans-serif substitute'),
|
||||||
|
'Myriad Pro': ('Calibri', 'sans-serif substitute'),
|
||||||
|
'Futura PT': ('Trebuchet MS', 'geometric sans substitute'),
|
||||||
|
'Neue Haas Grotesk': ('Arial', 'neutral sans substitute'),
|
||||||
|
'Brandon Grotesque': ('Trebuchet MS', 'geometric sans substitute'),
|
||||||
|
'Cormorant Garamond': ('Garamond', 'close serif match'),
|
||||||
|
'Acumin Pro': ('Calibri', 'sans-serif substitute'),
|
||||||
|
'Adobe Caslon Pro': ('Georgia', 'serif body substitute'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_font(name: str) -> str:
|
||||||
|
"""Returns 'safe', 'professional', or 'unknown'."""
|
||||||
|
if name in SYSTEM_SAFE:
|
||||||
|
return 'safe'
|
||||||
|
if name in SUBSTITUTION_MAP:
|
||||||
|
return 'professional'
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def get_substitute(name: str) -> Tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Returns (substitute_name, quality_note) or (None, None)."""
|
||||||
|
if name in SUBSTITUTION_MAP:
|
||||||
|
return SUBSTITUTION_MAP[name]
|
||||||
|
return None, None
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportRun:
|
||||||
|
text: str
|
||||||
|
style: Optional[str]
|
||||||
|
bold: bool
|
||||||
|
italic: bool
|
||||||
|
color_hex: Optional[str]
|
||||||
|
font: Optional[str]
|
||||||
|
size_pt: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportParagraph:
|
||||||
|
style: str
|
||||||
|
text: str
|
||||||
|
runs: List[IDExportRun] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportTextFrame:
|
||||||
|
id: str
|
||||||
|
thread_id: str
|
||||||
|
thread_position: int
|
||||||
|
x_pt: float
|
||||||
|
y_pt: float
|
||||||
|
width_pt: float
|
||||||
|
height_pt: float
|
||||||
|
column_count: int
|
||||||
|
paragraphs: List[IDExportParagraph] = field(default_factory=list)
|
||||||
|
type: str = 'text_frame'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportImageFrame:
|
||||||
|
id: str
|
||||||
|
x_pt: float
|
||||||
|
y_pt: float
|
||||||
|
width_pt: float
|
||||||
|
height_pt: float
|
||||||
|
image_name: str
|
||||||
|
image_data_b64: Optional[str]
|
||||||
|
fit_mode: str
|
||||||
|
type: str = 'image_frame'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportShape:
|
||||||
|
id: str
|
||||||
|
x_pt: float
|
||||||
|
y_pt: float
|
||||||
|
width_pt: float
|
||||||
|
height_pt: float
|
||||||
|
shape_type: str # 'rect' | 'ellipse' | 'line'
|
||||||
|
fill_hex: Optional[str]
|
||||||
|
stroke_hex: Optional[str]
|
||||||
|
stroke_weight_pt: float
|
||||||
|
type: str = 'shape'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportTableCell:
|
||||||
|
text: str
|
||||||
|
style: Optional[str]
|
||||||
|
col_span: int
|
||||||
|
row_span: int
|
||||||
|
background_hex: Optional[str]
|
||||||
|
text_color_hex: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportTableRow:
|
||||||
|
is_header: bool
|
||||||
|
cells: List[IDExportTableCell] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportTable:
|
||||||
|
id: str
|
||||||
|
x_pt: float
|
||||||
|
y_pt: float
|
||||||
|
width_pt: float
|
||||||
|
column_widths_pt: List[float]
|
||||||
|
rows: List[IDExportTableRow] = field(default_factory=list)
|
||||||
|
type: str = 'table'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportPageNumber:
|
||||||
|
id: str
|
||||||
|
x_pt: float
|
||||||
|
y_pt: float
|
||||||
|
font: Optional[str]
|
||||||
|
size_pt: float
|
||||||
|
color_hex: str
|
||||||
|
alignment: str
|
||||||
|
type: str = 'page_number'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportParagraphStyle:
|
||||||
|
name: str
|
||||||
|
font: Optional[str] = None
|
||||||
|
size_pt: Optional[float] = None
|
||||||
|
leading_pt: Optional[float] = None
|
||||||
|
space_before_pt: float = 0.0
|
||||||
|
space_after_pt: float = 0.0
|
||||||
|
alignment: str = 'left'
|
||||||
|
color_hex: Optional[str] = None
|
||||||
|
bold: bool = False
|
||||||
|
italic: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportCharacterStyle:
|
||||||
|
name: str
|
||||||
|
font: Optional[str] = None
|
||||||
|
bold: bool = False
|
||||||
|
italic: bool = False
|
||||||
|
color_hex: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportPage:
|
||||||
|
page_number: Union[int, str]
|
||||||
|
width_pt: float
|
||||||
|
height_pt: float
|
||||||
|
items: List[Any] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IDExportDocument:
|
||||||
|
name: str
|
||||||
|
page_width_pt: float
|
||||||
|
page_height_pt: float
|
||||||
|
page_count: int
|
||||||
|
facing_pages: bool
|
||||||
|
paragraph_styles: List[IDExportParagraphStyle]
|
||||||
|
character_styles: List[IDExportCharacterStyle]
|
||||||
|
colors: Dict[str, str]
|
||||||
|
fonts_used: List[Dict[str, str]]
|
||||||
|
pages: List[IDExportPage]
|
||||||
|
page_number_style: Optional[IDExportPageNumber] = None
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from export.factories import IDExportDocumentFactory
|
||||||
|
from export.models import IDExportDocument
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportParser:
|
||||||
|
"""Parses a validated idconvert_export.json dict into an IDExportDocument."""
|
||||||
|
|
||||||
|
def parse(self, data: dict) -> IDExportDocument:
|
||||||
|
"""Build the full domain model from the validated JSON dict."""
|
||||||
|
return IDExportDocumentFactory.from_dict(data)
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from export.fonts import classify_font, get_substitute
|
||||||
|
from models.scan import FontEntry, ScanReport, ScanWarning
|
||||||
|
|
||||||
|
|
||||||
|
class IDExportScanner:
|
||||||
|
"""Lightweight scan of idconvert_export.json — builds the pre-conversion report."""
|
||||||
|
|
||||||
|
def scan(self, data: dict) -> ScanReport:
|
||||||
|
"""Scan a validated export dict and return a ScanReport."""
|
||||||
|
pages = data.get('pages', [])
|
||||||
|
fonts_used = data.get('fonts_used', [])
|
||||||
|
|
||||||
|
page_count = len(pages)
|
||||||
|
frame_count, image_count, table_count = self._count_items(pages)
|
||||||
|
fonts = self._classify_fonts(fonts_used, data.get('styles', {}))
|
||||||
|
warnings = self._collect_warnings(data)
|
||||||
|
|
||||||
|
return ScanReport(
|
||||||
|
pages=page_count,
|
||||||
|
stories=frame_count,
|
||||||
|
images=image_count,
|
||||||
|
tables=table_count,
|
||||||
|
fonts=fonts,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _count_items(self, pages: list):
|
||||||
|
frame_count = image_count = table_count = 0
|
||||||
|
for page in pages:
|
||||||
|
for item in page.get('items', []):
|
||||||
|
t = item.get('type', '')
|
||||||
|
if t == 'text_frame':
|
||||||
|
frame_count += 1
|
||||||
|
elif t == 'image_frame':
|
||||||
|
image_count += 1
|
||||||
|
elif t == 'table':
|
||||||
|
table_count += 1
|
||||||
|
return frame_count, image_count, table_count
|
||||||
|
|
||||||
|
def _classify_fonts(self, fonts_used: list, styles: dict) -> List[FontEntry]:
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
# Fonts declared in fonts_used
|
||||||
|
for f in fonts_used:
|
||||||
|
name = f.get('name', '')
|
||||||
|
if name and name not in seen:
|
||||||
|
seen[name] = 'General'
|
||||||
|
|
||||||
|
# Cross-reference paragraph styles for "used for" context
|
||||||
|
for style in styles.get('paragraph', []):
|
||||||
|
font = style.get('font')
|
||||||
|
if not font:
|
||||||
|
continue
|
||||||
|
style_name = style.get('name', '').lower()
|
||||||
|
if 'head' in style_name:
|
||||||
|
ctx = 'Headings'
|
||||||
|
elif 'caption' in style_name:
|
||||||
|
ctx = 'Captions'
|
||||||
|
elif 'body' in style_name or 'text' in style_name:
|
||||||
|
ctx = 'Body text'
|
||||||
|
else:
|
||||||
|
ctx = 'General'
|
||||||
|
seen[font] = ctx
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for name, ctx in seen.items():
|
||||||
|
status = classify_font(name)
|
||||||
|
substitute, quality = get_substitute(name)
|
||||||
|
result.append(FontEntry(
|
||||||
|
name=name,
|
||||||
|
status=status,
|
||||||
|
substitute=substitute,
|
||||||
|
substitute_quality=quality,
|
||||||
|
used_for=ctx,
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _collect_warnings(self, data: dict) -> List[ScanWarning]:
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
# Check for multi-column frames
|
||||||
|
for page in data.get('pages', []):
|
||||||
|
for item in page.get('items', []):
|
||||||
|
if item.get('type') == 'text_frame' and item.get('column_count', 1) > 1:
|
||||||
|
warnings.append(ScanWarning(
|
||||||
|
type='multi_column',
|
||||||
|
severity='info',
|
||||||
|
page=page.get('page_number'),
|
||||||
|
message='Multi-column text frame will flow as single column in Word',
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check for shapes (not converted)
|
||||||
|
shape_pages = set()
|
||||||
|
for page in data.get('pages', []):
|
||||||
|
for item in page.get('items', []):
|
||||||
|
if item.get('type') == 'shape':
|
||||||
|
shape_pages.add(page.get('page_number'))
|
||||||
|
if shape_pages:
|
||||||
|
warnings.append(ScanWarning(
|
||||||
|
type='shapes_excluded',
|
||||||
|
severity='info',
|
||||||
|
page=None,
|
||||||
|
message='Decorative shapes and rules are not converted to Word',
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check for page numbers
|
||||||
|
for page in data.get('pages', []):
|
||||||
|
for item in page.get('items', []):
|
||||||
|
if item.get('type') == 'page_number':
|
||||||
|
warnings.append(ScanWarning(
|
||||||
|
type='page_number',
|
||||||
|
severity='info',
|
||||||
|
page=None,
|
||||||
|
message='Page numbers converted to native Word footer fields',
|
||||||
|
))
|
||||||
|
return warnings # only one warning needed
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from core.exceptions import FileValidationError
|
||||||
|
|
||||||
|
MAX_FILE_SIZE_MB = 100
|
||||||
|
SUPPORTED_VERSIONS = {'1.0', '2.0'}
|
||||||
|
REQUIRED_FIELDS = {'document', 'styles', 'pages', 'fonts_used'}
|
||||||
|
|
||||||
|
|
||||||
|
class ExportValidator:
|
||||||
|
"""Validates idconvert_export.json before any parsing occurs."""
|
||||||
|
|
||||||
|
def validate(self, content: bytes) -> dict:
|
||||||
|
"""Run all validation checks. Returns parsed dict on success.
|
||||||
|
Raises FileValidationError on any failure.
|
||||||
|
"""
|
||||||
|
# 1. File size
|
||||||
|
size_mb = len(content) / (1024 * 1024)
|
||||||
|
if size_mb > MAX_FILE_SIZE_MB:
|
||||||
|
raise FileValidationError(
|
||||||
|
f'File exceeds {MAX_FILE_SIZE_MB}MB limit.',
|
||||||
|
'FILE_TOO_LARGE',
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Valid JSON
|
||||||
|
try:
|
||||||
|
data = json.loads(content)
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
raise FileValidationError(
|
||||||
|
'This does not appear to be a valid IDexport file. '
|
||||||
|
'Please run IDexport.jsx in InDesign and upload the '
|
||||||
|
'idconvert_export.json file it produces.',
|
||||||
|
'INVALID_JSON',
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise FileValidationError(
|
||||||
|
'IDexport file has an unexpected format.',
|
||||||
|
'INVALID_FORMAT',
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Generator signature
|
||||||
|
if data.get('generator') != 'IDexport':
|
||||||
|
raise FileValidationError(
|
||||||
|
'This JSON file was not produced by IDexport. '
|
||||||
|
'Please run IDexport.jsx in InDesign to generate '
|
||||||
|
'a compatible export file.',
|
||||||
|
'WRONG_GENERATOR',
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Version check
|
||||||
|
if data.get('version') not in SUPPORTED_VERSIONS:
|
||||||
|
raise FileValidationError(
|
||||||
|
'This IDexport file was created with an incompatible version. '
|
||||||
|
'Please download the latest IDexport.jsx from IDconvert.',
|
||||||
|
'VERSION_MISMATCH',
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Required top-level fields
|
||||||
|
for f in REQUIRED_FIELDS:
|
||||||
|
if f not in data:
|
||||||
|
raise FileValidationError(
|
||||||
|
f'IDexport file is missing required field: {f}. '
|
||||||
|
'Please re-run IDexport.jsx and try again.',
|
||||||
|
'MISSING_FIELD',
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Pages must be a non-empty list
|
||||||
|
if not isinstance(data.get('pages'), list) or len(data['pages']) == 0:
|
||||||
|
raise FileValidationError(
|
||||||
|
'IDexport file contains no pages.',
|
||||||
|
'NO_PAGES',
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from core.exceptions import FileValidationError, InsufficientCreditsError, ConversionError, ExportParseError
|
||||||
|
from core.logging import setup_logging
|
||||||
|
from routers import upload, users, payments
|
||||||
|
|
||||||
|
setup_logging()
|
||||||
|
|
||||||
|
app = FastAPI(title='IDconvert API', version='0.1.0')
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=['http://localhost:3000', 'http://localhost:3001', 'http://127.0.0.1:3000', 'http://127.0.0.1:3001'],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=['*'],
|
||||||
|
allow_headers=['*'],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Exception handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.exception_handler(FileValidationError)
|
||||||
|
async def file_validation_handler(request: Request, exc: FileValidationError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={'error': exc.error_code, 'message': exc.message},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(InsufficientCreditsError)
|
||||||
|
async def credits_handler(request: Request, exc: InsufficientCreditsError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=402,
|
||||||
|
content={'error': 'INSUFFICIENT_CREDITS', 'message': str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(ConversionError)
|
||||||
|
async def conversion_handler(request: Request, exc: ConversionError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={'error': 'CONVERSION_FAILED', 'message': str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(ExportParseError)
|
||||||
|
async def parse_error_handler(request: Request, exc: ExportParseError):
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=422,
|
||||||
|
content={'error': 'EXPORT_PARSE_ERROR', 'message': str(exc)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.include_router(upload.router)
|
||||||
|
app.include_router(users.router)
|
||||||
|
app.include_router(payments.router)
|
||||||
|
|
||||||
|
@app.get('/health')
|
||||||
|
async def health():
|
||||||
|
return {'status': 'ok'}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionRequest(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
filename: str = 'idconvert_export.json'
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionResult(BaseModel):
|
||||||
|
download_url: str
|
||||||
|
filename: str
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class FontEntry(BaseModel):
|
||||||
|
name: str
|
||||||
|
status: str # 'safe' | 'professional' | 'unknown'
|
||||||
|
substitute: Optional[str] = None
|
||||||
|
substitute_quality: Optional[str] = None
|
||||||
|
used_for: str = ''
|
||||||
|
|
||||||
|
|
||||||
|
class ScanWarning(BaseModel):
|
||||||
|
type: str
|
||||||
|
severity: str # 'info' | 'warning' | 'error'
|
||||||
|
page: Optional[int] = None
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ScanReport(BaseModel):
|
||||||
|
session_id: str = ''
|
||||||
|
pages: int
|
||||||
|
stories: int
|
||||||
|
images: int
|
||||||
|
tables: int
|
||||||
|
fonts: List[FontEntry] = []
|
||||||
|
warnings: List[ScanWarning] = []
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Conversion repository — all conversion data access via PocketBase."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
import core.pocketbase as pb
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionRepository:
|
||||||
|
"""Manages conversion records in PocketBase.
|
||||||
|
|
||||||
|
Creating a record triggers the credit-deduction hook atomically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def create(self, user_id: str, file_hash: str, admin_token: str) -> str:
|
||||||
|
"""Create a pending conversion record. Returns the record ID.
|
||||||
|
|
||||||
|
The PocketBase hook fires inside a SQLite transaction to deduct credits.
|
||||||
|
Raises httpx.HTTPStatusError if user has insufficient credits.
|
||||||
|
"""
|
||||||
|
return await pb.create_conversion(user_id, file_hash, admin_token)
|
||||||
|
|
||||||
|
async def mark_complete(self, record_id: str, download_url: str, admin_token: str) -> None:
|
||||||
|
"""Mark a conversion complete and store the download URL."""
|
||||||
|
await pb.update_conversion_download_url(record_id, download_url, admin_token)
|
||||||
|
|
||||||
|
async def mark_failed(self, record_id: str, admin_token: str) -> None:
|
||||||
|
"""Mark a conversion failed, triggering the credit refund hook."""
|
||||||
|
await pb.update_conversion_status(record_id, 'failed', admin_token)
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
"""User repository — all user data access via PocketBase."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
import core.pocketbase as pb
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class UserRepository:
|
||||||
|
"""Reads user records from PocketBase."""
|
||||||
|
|
||||||
|
async def get_by_token(self, token: str) -> dict:
|
||||||
|
"""Validate token and return the user record (with refreshed token).
|
||||||
|
|
||||||
|
Raises httpx.HTTPStatusError on invalid/expired token.
|
||||||
|
"""
|
||||||
|
return await pb.get_user_by_token(token)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
fastapi>=0.115.0
|
||||||
|
uvicorn[standard]>=0.30.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
lxml>=5.0.0
|
||||||
|
python-docx>=1.1.0
|
||||||
|
pydantic>=2.0.0
|
||||||
|
pydantic-settings>=2.0.0
|
||||||
|
structlog>=24.0.0
|
||||||
|
httpx>=0.27.0
|
||||||
|
stripe>=10.0.0
|
||||||
|
boto3>=1.34.0
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Payments router — Stripe payment intent creation and webhook."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
import stripe
|
||||||
|
|
||||||
|
from dependencies import get_current_user_or_401, get_payment_service, get_admin_token
|
||||||
|
from services.payment_service import PaymentService
|
||||||
|
|
||||||
|
router = APIRouter(prefix='/api', tags=['payments'])
|
||||||
|
|
||||||
|
|
||||||
|
class CreateIntentRequest(BaseModel):
|
||||||
|
pack: str # 'starter' | 'studio' | 'agency'
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/payments/create-intent')
|
||||||
|
async def create_payment_intent(
|
||||||
|
body: CreateIntentRequest,
|
||||||
|
user=Depends(get_current_user_or_401),
|
||||||
|
service: PaymentService = Depends(get_payment_service),
|
||||||
|
):
|
||||||
|
"""Create a Stripe PaymentIntent for the selected credit pack.
|
||||||
|
|
||||||
|
Returns the client_secret needed to confirm payment in the frontend.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return service.create_payment_intent(body.pack, user['id'])
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(400, str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/payments/webhook')
|
||||||
|
async def stripe_webhook(
|
||||||
|
request: Request,
|
||||||
|
stripe_signature: str = Header(None, alias='stripe-signature'),
|
||||||
|
service: PaymentService = Depends(get_payment_service),
|
||||||
|
admin_token: str = Depends(get_admin_token),
|
||||||
|
):
|
||||||
|
"""Receive Stripe webhook events.
|
||||||
|
|
||||||
|
Stripe sends payment_intent.succeeded here after a successful payment.
|
||||||
|
FastAPI verifies the signature before processing.
|
||||||
|
"""
|
||||||
|
payload = await request.body()
|
||||||
|
if not stripe_signature:
|
||||||
|
raise HTTPException(400, 'Missing stripe-signature header')
|
||||||
|
|
||||||
|
try:
|
||||||
|
await service.handle_webhook(payload, stripe_signature, admin_token)
|
||||||
|
except stripe.error.SignatureVerificationError:
|
||||||
|
raise HTTPException(400, 'Invalid Stripe webhook signature')
|
||||||
|
|
||||||
|
return {'received': True}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
from fastapi import APIRouter, Depends, UploadFile, File
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from models.scan import ScanReport
|
||||||
|
from models.conversion import ConversionRequest, ConversionResult
|
||||||
|
from services.scan_service import ScanService
|
||||||
|
from services.conversion_service import ConversionService, get_conversion_result
|
||||||
|
from dependencies import (
|
||||||
|
get_scan_service,
|
||||||
|
get_conversion_service,
|
||||||
|
get_optional_user,
|
||||||
|
get_admin_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix='/api', tags=['upload'])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/scan', response_model=ScanReport)
|
||||||
|
async def scan_file(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
service: ScanService = Depends(get_scan_service),
|
||||||
|
):
|
||||||
|
"""Validate and scan an idconvert_export.json. Costs no credits."""
|
||||||
|
return await service.scan(file)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/convert', response_model=ConversionResult)
|
||||||
|
async def convert_file(
|
||||||
|
body: ConversionRequest,
|
||||||
|
service: ConversionService = Depends(get_conversion_service),
|
||||||
|
user: Optional[dict] = Depends(get_optional_user),
|
||||||
|
admin_token: str = Depends(get_admin_token),
|
||||||
|
):
|
||||||
|
"""Convert the cached scan to DOCX.
|
||||||
|
|
||||||
|
Deducts 1 credit if the user is authenticated via Bearer token.
|
||||||
|
Works without auth for local development (no credit deduction).
|
||||||
|
"""
|
||||||
|
return await service.convert(body.session_id, body.filename, user, admin_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/download/{session_id}')
|
||||||
|
async def download_file(session_id: str):
|
||||||
|
"""Fallback download endpoint when MinIO is not configured."""
|
||||||
|
result = get_conversion_result(session_id)
|
||||||
|
if not result:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(404, 'File not found or session expired')
|
||||||
|
docx_bytes, filename = result
|
||||||
|
return Response(
|
||||||
|
content=docx_bytes,
|
||||||
|
media_type='application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
headers={'Content-Disposition': f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Users router — authenticated user profile endpoint."""
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from dependencies import get_current_user_or_401
|
||||||
|
|
||||||
|
router = APIRouter(prefix='/api', tags=['users'])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get('/users/me')
|
||||||
|
async def get_me(user: dict = Depends(get_current_user_or_401)):
|
||||||
|
"""Return the current user's profile and credit balance."""
|
||||||
|
return {
|
||||||
|
'id': user.get('id'),
|
||||||
|
'email': user.get('email'),
|
||||||
|
'name': user.get('name', ''),
|
||||||
|
'credits_balance': user.get('credits_balance', 0),
|
||||||
|
'free_used': user.get('free_used', False),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from export.parser import IDExportParser
|
||||||
|
from word.builder import DocxBuilder
|
||||||
|
from services.scan_service import ScanService
|
||||||
|
from repositories.conversion_repository import ConversionRepository
|
||||||
|
from models.conversion import ConversionResult
|
||||||
|
from core.exceptions import ConversionError, ExportParseError, InsufficientCreditsError
|
||||||
|
import core.storage as storage
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
# In-memory fallback for download when MinIO is not available
|
||||||
|
_conversion_results: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
scan_service: ScanService,
|
||||||
|
conversion_repo: ConversionRepository,
|
||||||
|
):
|
||||||
|
self.scan_service = scan_service
|
||||||
|
self.conversion_repo = conversion_repo
|
||||||
|
self.parser = IDExportParser()
|
||||||
|
self.builder = DocxBuilder()
|
||||||
|
|
||||||
|
async def convert(
|
||||||
|
self,
|
||||||
|
session_id: str,
|
||||||
|
filename: str = 'document',
|
||||||
|
user: Optional[dict] = None,
|
||||||
|
admin_token: str = '',
|
||||||
|
) -> ConversionResult:
|
||||||
|
"""Parse the cached export data and build a DOCX.
|
||||||
|
|
||||||
|
If a user is provided, a PocketBase conversion record is created
|
||||||
|
(triggering atomic credit deduction). On failure the record is marked
|
||||||
|
failed, which triggers the credit refund hook.
|
||||||
|
"""
|
||||||
|
cached = self.scan_service.get_cached(session_id)
|
||||||
|
if not cached:
|
||||||
|
raise ConversionError('Session expired or not found. Please re-upload your file.')
|
||||||
|
|
||||||
|
data, scan_report = cached
|
||||||
|
file_hash = _hash_data(data)
|
||||||
|
|
||||||
|
# ── Credit deduction (if authenticated) ──────────────────────────────
|
||||||
|
conversion_id: Optional[str] = None
|
||||||
|
if user and admin_token:
|
||||||
|
try:
|
||||||
|
conversion_id = await self.conversion_repo.create(
|
||||||
|
user['id'], file_hash, admin_token
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
if 'INSUFFICIENT_CREDITS' in error_str:
|
||||||
|
raise InsufficientCreditsError('You have no credits remaining.')
|
||||||
|
log.error('conversion_record_create_failed', error=error_str)
|
||||||
|
raise ConversionError('Failed to register conversion. Please try again.')
|
||||||
|
|
||||||
|
# ── Build DOCX ───────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
document = self.parser.parse(data)
|
||||||
|
except Exception as e:
|
||||||
|
log.error('export_parse_failed', error=str(e))
|
||||||
|
await self._mark_failed(conversion_id, admin_token)
|
||||||
|
raise ExportParseError(f'Failed to parse export: {e}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
docx_bytes = self.builder.build(document)
|
||||||
|
except Exception as e:
|
||||||
|
log.error('docx_build_failed', error=str(e))
|
||||||
|
await self._mark_failed(conversion_id, admin_token)
|
||||||
|
raise ConversionError(f'Failed to build DOCX: {e}')
|
||||||
|
|
||||||
|
output_filename = (
|
||||||
|
filename.replace('.json', '').replace('idconvert_export', 'document') + '.docx'
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Upload to MinIO or fall back to in-memory ─────────────────────────
|
||||||
|
download_url = await self._store(session_id, docx_bytes, output_filename)
|
||||||
|
|
||||||
|
if conversion_id and admin_token:
|
||||||
|
await self.conversion_repo.mark_complete(conversion_id, download_url, admin_token)
|
||||||
|
|
||||||
|
_conversion_results[session_id] = (docx_bytes, output_filename)
|
||||||
|
|
||||||
|
return ConversionResult(download_url=download_url, filename=output_filename)
|
||||||
|
|
||||||
|
async def _store(self, session_id: str, docx_bytes: bytes, filename: str) -> str:
|
||||||
|
"""Upload to MinIO and return presigned URL; fall back to local endpoint."""
|
||||||
|
try:
|
||||||
|
object_key = f'conversions/{session_id}/{filename}'
|
||||||
|
return storage.upload_docx(object_key, docx_bytes)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning('minio_unavailable_fallback', error=str(e))
|
||||||
|
return f'/api/download/{session_id}'
|
||||||
|
|
||||||
|
async def _mark_failed(self, conversion_id: Optional[str], admin_token: str) -> None:
|
||||||
|
if conversion_id and admin_token:
|
||||||
|
try:
|
||||||
|
await self.conversion_repo.mark_failed(conversion_id, admin_token)
|
||||||
|
except Exception as e:
|
||||||
|
log.error('mark_failed_error', error=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def get_conversion_result(session_id: str):
|
||||||
|
"""Return (docx_bytes, filename) from in-memory store, or None."""
|
||||||
|
return _conversion_results.get(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_data(data: dict) -> str:
|
||||||
|
import json
|
||||||
|
raw = json.dumps(data, sort_keys=True).encode()
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Payment service — Stripe payment intent creation and webhook handling."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
import stripe
|
||||||
|
|
||||||
|
from config import settings
|
||||||
|
from core.storage import PACK_CREDITS
|
||||||
|
import core.pocketbase as pb
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
PACK_PRICES: dict[str, int] = {
|
||||||
|
'starter': 1900, # $19.00 in cents
|
||||||
|
'studio': 5900, # $59.00
|
||||||
|
'agency': 14900, # $149.00
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentService:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
stripe.api_key = settings.stripe_secret_key
|
||||||
|
|
||||||
|
def create_payment_intent(self, pack: str, user_id: str) -> dict:
|
||||||
|
"""Create a Stripe PaymentIntent for the selected credit pack.
|
||||||
|
|
||||||
|
Returns dict with client_secret and amount.
|
||||||
|
Raises ValueError for unknown pack names.
|
||||||
|
"""
|
||||||
|
if pack not in PACK_PRICES:
|
||||||
|
raise ValueError(f'Unknown pack: {pack}')
|
||||||
|
|
||||||
|
amount = PACK_PRICES[pack]
|
||||||
|
credits = PACK_CREDITS[pack]
|
||||||
|
|
||||||
|
intent = stripe.PaymentIntent.create(
|
||||||
|
amount=amount,
|
||||||
|
currency='usd',
|
||||||
|
metadata={'user_id': user_id, 'pack': pack, 'credits': credits},
|
||||||
|
automatic_payment_methods={'enabled': True},
|
||||||
|
)
|
||||||
|
log.info('payment_intent_created', user_id=user_id, pack=pack, amount=amount)
|
||||||
|
return {
|
||||||
|
'client_secret': intent.client_secret,
|
||||||
|
'amount': amount,
|
||||||
|
'credits': credits,
|
||||||
|
'pack': pack,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def handle_webhook(self, payload: bytes, sig_header: str, admin_token: str) -> None:
|
||||||
|
"""Verify Stripe webhook signature and process payment_intent.succeeded.
|
||||||
|
|
||||||
|
On success: creates a purchase record in PocketBase (triggers credit top-up hook).
|
||||||
|
Raises stripe.error.SignatureVerificationError if signature is invalid.
|
||||||
|
"""
|
||||||
|
event = stripe.Webhook.construct_event(
|
||||||
|
payload, sig_header, settings.stripe_webhook_secret
|
||||||
|
)
|
||||||
|
|
||||||
|
if event['type'] != 'payment_intent.succeeded':
|
||||||
|
return # Ignore other events
|
||||||
|
|
||||||
|
intent = event['data']['object']
|
||||||
|
user_id = intent['metadata'].get('user_id')
|
||||||
|
pack = intent['metadata'].get('pack')
|
||||||
|
credits = PACK_CREDITS.get(pack, 0)
|
||||||
|
|
||||||
|
if not user_id or not credits:
|
||||||
|
log.warning('webhook_missing_metadata', intent_id=intent['id'])
|
||||||
|
return
|
||||||
|
|
||||||
|
await pb.create_purchase(user_id, credits, intent['id'], admin_token)
|
||||||
|
log.info('purchase_credited',
|
||||||
|
user_id=user_id, pack=pack, credits=credits, intent_id=intent['id'])
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from fastapi import UploadFile
|
||||||
|
|
||||||
|
from export.validator import ExportValidator
|
||||||
|
from export.scanner import IDExportScanner
|
||||||
|
from models.scan import ScanReport
|
||||||
|
|
||||||
|
# In-memory scan cache: session_id → (validated_data_dict, ScanReport)
|
||||||
|
# Replace with PocketBase/Redis when wiring auth
|
||||||
|
_scan_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ScanService:
|
||||||
|
def __init__(self, validator: ExportValidator, scanner: IDExportScanner):
|
||||||
|
self.validator = validator
|
||||||
|
self.scanner = scanner
|
||||||
|
|
||||||
|
async def scan(self, file: UploadFile) -> ScanReport:
|
||||||
|
"""Validate and scan the uploaded idconvert_export.json."""
|
||||||
|
content = await file.read()
|
||||||
|
|
||||||
|
# Validate — raises FileValidationError on failure
|
||||||
|
data = self.validator.validate(content)
|
||||||
|
|
||||||
|
# Run lightweight scan for pre-conversion report
|
||||||
|
report = self.scanner.scan(data)
|
||||||
|
|
||||||
|
# Cache validated data dict and report by session ID
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
report.session_id = session_id
|
||||||
|
_scan_cache[session_id] = (data, report)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def get_cached(self, session_id: str) -> Optional[Tuple[dict, ScanReport]]:
|
||||||
|
"""Return (data_dict, report) for the session, or None if expired/missing."""
|
||||||
|
return _scan_cache.get(session_id)
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""User service — business logic for user profile reads."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from repositories.user_repository import UserRepository
|
||||||
|
|
||||||
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
class UserService:
|
||||||
|
def __init__(self, repo: UserRepository):
|
||||||
|
self.repo = repo
|
||||||
|
|
||||||
|
async def get_me(self, token: str) -> dict:
|
||||||
|
"""Return the authenticated user's profile.
|
||||||
|
|
||||||
|
Returns a dict with id, email, name, credits_balance, free_used.
|
||||||
|
Raises httpx.HTTPStatusError if the token is invalid.
|
||||||
|
"""
|
||||||
|
record = await self.repo.get_by_token(token)
|
||||||
|
return {
|
||||||
|
'id': record.get('id'),
|
||||||
|
'email': record.get('email'),
|
||||||
|
'name': record.get('name', ''),
|
||||||
|
'credits_balance': record.get('credits_balance', 0),
|
||||||
|
'free_used': record.get('free_used', False),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,241 @@
|
||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Activate a Python virtual environment for the current PowerShell session.
|
||||||
|
|
||||||
|
.Description
|
||||||
|
Pushes the python executable for a virtual environment to the front of the
|
||||||
|
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||||
|
in a Python virtual environment. Makes use of the command line switches as
|
||||||
|
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||||
|
|
||||||
|
.Parameter VenvDir
|
||||||
|
Path to the directory that contains the virtual environment to activate. The
|
||||||
|
default value for this is the parent of the directory that the Activate.ps1
|
||||||
|
script is located within.
|
||||||
|
|
||||||
|
.Parameter Prompt
|
||||||
|
The prompt prefix to display when this virtual environment is activated. By
|
||||||
|
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||||
|
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Verbose
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and shows extra information about the activation as it executes.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||||
|
Activates the Python virtual environment located in the specified location.
|
||||||
|
|
||||||
|
.Example
|
||||||
|
Activate.ps1 -Prompt "MyPython"
|
||||||
|
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||||
|
and prefixes the current prompt with the specified string (surrounded in
|
||||||
|
parentheses) while the virtual environment is active.
|
||||||
|
|
||||||
|
.Notes
|
||||||
|
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||||
|
execution policy for the user. You can do this by issuing the following PowerShell
|
||||||
|
command:
|
||||||
|
|
||||||
|
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
|
|
||||||
|
For more information on Execution Policies:
|
||||||
|
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||||
|
|
||||||
|
#>
|
||||||
|
Param(
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$VenvDir,
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[String]
|
||||||
|
$Prompt
|
||||||
|
)
|
||||||
|
|
||||||
|
<# Function declarations --------------------------------------------------- #>
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Synopsis
|
||||||
|
Remove all shell session elements added by the Activate script, including the
|
||||||
|
addition of the virtual environment's Python executable from the beginning of
|
||||||
|
the PATH variable.
|
||||||
|
|
||||||
|
.Parameter NonDestructive
|
||||||
|
If present, do not remove this function from the global namespace for the
|
||||||
|
session.
|
||||||
|
|
||||||
|
#>
|
||||||
|
function global:deactivate ([switch]$NonDestructive) {
|
||||||
|
# Revert to original values
|
||||||
|
|
||||||
|
# The prior prompt:
|
||||||
|
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||||
|
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||||
|
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PYTHONHOME:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prior PATH:
|
||||||
|
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||||
|
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||||
|
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the VIRTUAL_ENV altogether:
|
||||||
|
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||||
|
Remove-Item -Path env:VIRTUAL_ENV
|
||||||
|
}
|
||||||
|
|
||||||
|
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||||
|
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||||
|
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
# Leave deactivate function in the global namespace if requested:
|
||||||
|
if (-not $NonDestructive) {
|
||||||
|
Remove-Item -Path function:deactivate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<#
|
||||||
|
.Description
|
||||||
|
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||||
|
given folder, and returns them in a map.
|
||||||
|
|
||||||
|
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||||
|
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||||
|
then it is considered a `key = value` line. The left hand string is the key,
|
||||||
|
the right hand is the value.
|
||||||
|
|
||||||
|
If the value starts with a `'` or a `"` then the first and last character is
|
||||||
|
stripped from the value before being captured.
|
||||||
|
|
||||||
|
.Parameter ConfigDir
|
||||||
|
Path to the directory that contains the `pyvenv.cfg` file.
|
||||||
|
#>
|
||||||
|
function Get-PyVenvConfig(
|
||||||
|
[String]
|
||||||
|
$ConfigDir
|
||||||
|
) {
|
||||||
|
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||||
|
|
||||||
|
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||||
|
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||||
|
|
||||||
|
# An empty map will be returned if no config file is found.
|
||||||
|
$pyvenvConfig = @{ }
|
||||||
|
|
||||||
|
if ($pyvenvConfigPath) {
|
||||||
|
|
||||||
|
Write-Verbose "File exists, parse `key = value` lines"
|
||||||
|
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||||
|
|
||||||
|
$pyvenvConfigContent | ForEach-Object {
|
||||||
|
$keyval = $PSItem -split "\s*=\s*", 2
|
||||||
|
if ($keyval[0] -and $keyval[1]) {
|
||||||
|
$val = $keyval[1]
|
||||||
|
|
||||||
|
# Remove extraneous quotations around a string value.
|
||||||
|
if ("'""".Contains($val.Substring(0, 1))) {
|
||||||
|
$val = $val.Substring(1, $val.Length - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
$pyvenvConfig[$keyval[0]] = $val
|
||||||
|
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $pyvenvConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<# Begin Activate script --------------------------------------------------- #>
|
||||||
|
|
||||||
|
# Determine the containing directory of this script
|
||||||
|
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||||
|
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||||
|
|
||||||
|
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||||
|
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||||
|
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||||
|
|
||||||
|
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||||
|
# First, get the location of the virtual environment, it might not be
|
||||||
|
# VenvExecDir if specified on the command line.
|
||||||
|
if ($VenvDir) {
|
||||||
|
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||||
|
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||||
|
Write-Verbose "VenvDir=$VenvDir"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||||
|
# as `prompt`.
|
||||||
|
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||||
|
|
||||||
|
# Next, set the prompt from the command line, or the config file, or
|
||||||
|
# just use the name of the virtual environment folder.
|
||||||
|
if ($Prompt) {
|
||||||
|
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||||
|
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||||
|
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||||
|
$Prompt = $pyvenvCfg['prompt'];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virutal environment)"
|
||||||
|
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||||
|
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Verbose "Prompt = '$Prompt'"
|
||||||
|
Write-Verbose "VenvDir='$VenvDir'"
|
||||||
|
|
||||||
|
# Deactivate any currently active virtual environment, but leave the
|
||||||
|
# deactivate function in place.
|
||||||
|
deactivate -nondestructive
|
||||||
|
|
||||||
|
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||||
|
# that there is an activated venv.
|
||||||
|
$env:VIRTUAL_ENV = $VenvDir
|
||||||
|
|
||||||
|
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||||
|
|
||||||
|
Write-Verbose "Setting prompt to '$Prompt'"
|
||||||
|
|
||||||
|
# Set the prompt to include the env name
|
||||||
|
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||||
|
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||||
|
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||||
|
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||||
|
|
||||||
|
function global:prompt {
|
||||||
|
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||||
|
_OLD_VIRTUAL_PROMPT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clear PYTHONHOME
|
||||||
|
if (Test-Path -Path Env:PYTHONHOME) {
|
||||||
|
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
Remove-Item -Path Env:PYTHONHOME
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add the venv to the PATH
|
||||||
|
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||||
|
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
# This file must be used with "source bin/activate" *from bash*
|
||||||
|
# you cannot run it directly
|
||||||
|
|
||||||
|
deactivate () {
|
||||||
|
# reset old environment variables
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||||
|
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||||
|
export PATH
|
||||||
|
unset _OLD_VIRTUAL_PATH
|
||||||
|
fi
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||||
|
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||||
|
export PYTHONHOME
|
||||||
|
unset _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This should detect bash and zsh, which have a hash command that must
|
||||||
|
# be called to get it to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||||
|
hash -r 2> /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||||
|
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||||
|
export PS1
|
||||||
|
unset _OLD_VIRTUAL_PS1
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset VIRTUAL_ENV
|
||||||
|
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||||
|
# Self destruct!
|
||||||
|
unset -f deactivate
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# unset irrelevant variables
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
VIRTUAL_ENV="/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv"
|
||||||
|
export VIRTUAL_ENV
|
||||||
|
|
||||||
|
_OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||||
|
export PATH
|
||||||
|
|
||||||
|
# unset PYTHONHOME if set
|
||||||
|
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||||
|
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||||
|
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||||
|
unset PYTHONHOME
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||||
|
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||||
|
PS1="(venv) ${PS1:-}"
|
||||||
|
export PS1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# This should detect bash and zsh, which have a hash command that must
|
||||||
|
# be called to get it to forget past commands. Without forgetting
|
||||||
|
# past commands the $PATH changes we made may not be respected
|
||||||
|
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||||
|
hash -r 2> /dev/null
|
||||||
|
fi
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||||
|
# You cannot run it directly.
|
||||||
|
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||||
|
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||||
|
|
||||||
|
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||||
|
|
||||||
|
# Unset irrelevant variables.
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
setenv VIRTUAL_ENV "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv"
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PATH="$PATH"
|
||||||
|
setenv PATH "$VIRTUAL_ENV/bin:$PATH"
|
||||||
|
|
||||||
|
|
||||||
|
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||||
|
|
||||||
|
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||||
|
set prompt = "(venv) $prompt"
|
||||||
|
endif
|
||||||
|
|
||||||
|
alias pydoc python -m pydoc
|
||||||
|
|
||||||
|
rehash
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||||
|
# (https://fishshell.com/); you cannot run it directly.
|
||||||
|
|
||||||
|
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||||
|
# reset old environment variables
|
||||||
|
if test -n "$_OLD_VIRTUAL_PATH"
|
||||||
|
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||||
|
set -e _OLD_VIRTUAL_PATH
|
||||||
|
end
|
||||||
|
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||||
|
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||||
|
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||||
|
functions -e fish_prompt
|
||||||
|
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||||
|
functions -c _old_fish_prompt fish_prompt
|
||||||
|
functions -e _old_fish_prompt
|
||||||
|
end
|
||||||
|
|
||||||
|
set -e VIRTUAL_ENV
|
||||||
|
if test "$argv[1]" != "nondestructive"
|
||||||
|
# Self-destruct!
|
||||||
|
functions -e deactivate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Unset irrelevant variables.
|
||||||
|
deactivate nondestructive
|
||||||
|
|
||||||
|
set -gx VIRTUAL_ENV "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv"
|
||||||
|
|
||||||
|
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||||
|
set -gx PATH "$VIRTUAL_ENV/bin" $PATH
|
||||||
|
|
||||||
|
# Unset PYTHONHOME if set.
|
||||||
|
if set -q PYTHONHOME
|
||||||
|
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||||
|
set -e PYTHONHOME
|
||||||
|
end
|
||||||
|
|
||||||
|
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||||
|
# fish uses a function instead of an env var to generate the prompt.
|
||||||
|
|
||||||
|
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||||
|
functions -c fish_prompt _old_fish_prompt
|
||||||
|
|
||||||
|
# With the original prompt function renamed, we can override with our own.
|
||||||
|
function fish_prompt
|
||||||
|
# Save the return status of the last command.
|
||||||
|
set -l old_status $status
|
||||||
|
|
||||||
|
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||||
|
printf "%s%s%s" (set_color 4B8BBE) "(venv) " (set_color normal)
|
||||||
|
|
||||||
|
# Restore the return status of the previous command.
|
||||||
|
echo "exit $old_status" | .
|
||||||
|
# Output the original/"old" prompt.
|
||||||
|
_old_fish_prompt
|
||||||
|
end
|
||||||
|
|
||||||
|
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||||
|
end
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
'''exec' "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3" "$0" "$@"
|
||||||
|
' '''
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dotenv.__main__ import cli
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(cli())
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
'''exec' "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3" "$0" "$@"
|
||||||
|
' '''
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from fastapi.cli import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
'''exec' "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3" "$0" "$@"
|
||||||
|
' '''
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from httpx import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
#!/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import argparse
|
||||||
|
from pprint import pformat
|
||||||
|
|
||||||
|
import jmespath
|
||||||
|
from jmespath import exceptions
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('expression')
|
||||||
|
parser.add_argument('-f', '--filename',
|
||||||
|
help=('The filename containing the input data. '
|
||||||
|
'If a filename is not given then data is '
|
||||||
|
'read from stdin.'))
|
||||||
|
parser.add_argument('--ast', action='store_true',
|
||||||
|
help=('Pretty print the AST, do not search the data.'))
|
||||||
|
args = parser.parse_args()
|
||||||
|
expression = args.expression
|
||||||
|
if args.ast:
|
||||||
|
# Only print the AST
|
||||||
|
expression = jmespath.compile(args.expression)
|
||||||
|
sys.stdout.write(pformat(expression.parsed))
|
||||||
|
sys.stdout.write('\n')
|
||||||
|
return 0
|
||||||
|
if args.filename:
|
||||||
|
with open(args.filename, 'r') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
else:
|
||||||
|
data = sys.stdin.read()
|
||||||
|
data = json.loads(data)
|
||||||
|
try:
|
||||||
|
sys.stdout.write(json.dumps(
|
||||||
|
jmespath.search(expression, data), indent=4, ensure_ascii=False))
|
||||||
|
sys.stdout.write('\n')
|
||||||
|
except exceptions.ArityError as e:
|
||||||
|
sys.stderr.write("invalid-arity: %s\n" % e)
|
||||||
|
return 1
|
||||||
|
except exceptions.JMESPathTypeError as e:
|
||||||
|
sys.stderr.write("invalid-type: %s\n" % e)
|
||||||
|
return 1
|
||||||
|
except exceptions.UnknownFunctionError as e:
|
||||||
|
sys.stderr.write("unknown-function: %s\n" % e)
|
||||||
|
return 1
|
||||||
|
except exceptions.ParseError as e:
|
||||||
|
sys.stderr.write("syntax-error: %s\n" % e)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
'''exec' "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3" "$0" "$@"
|
||||||
|
' '''
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from charset_normalizer.cli import cli_detect
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(cli_detect())
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
'''exec' "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3" "$0" "$@"
|
||||||
|
' '''
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
'''exec' "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3" "$0" "$@"
|
||||||
|
' '''
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
'''exec' "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3" "$0" "$@"
|
||||||
|
' '''
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pip._internal.cli.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
python3
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/Library/Developer/CommandLineTools/usr/bin/python3
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
python3
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
'''exec' "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3" "$0" "$@"
|
||||||
|
' '''
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from uvicorn.main import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
'''exec' "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3" "$0" "$@"
|
||||||
|
' '''
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from watchfiles.cli import cli
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(cli())
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
'''exec' "/Users/JasonJFraser/Desktop/desktop files/apps/idconvert/backend/venv/bin/python3" "$0" "$@"
|
||||||
|
' '''
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from websockets.cli import main
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||||
|
sys.exit(main())
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/venv/lib/python3.9/site-packages/PIL/.dylibs/libbrotlicommon.1.1.0.dylib
Executable file
BIN
backend/venv/lib/python3.9/site-packages/PIL/.dylibs/libbrotlicommon.1.1.0.dylib
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,291 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from . import ExifTags, Image, ImageFile
|
||||||
|
|
||||||
|
try:
|
||||||
|
from . import _avif
|
||||||
|
|
||||||
|
SUPPORTED = True
|
||||||
|
except ImportError:
|
||||||
|
SUPPORTED = False
|
||||||
|
|
||||||
|
# Decoder options as module globals, until there is a way to pass parameters
|
||||||
|
# to Image.open (see https://github.com/python-pillow/Pillow/issues/569)
|
||||||
|
DECODE_CODEC_CHOICE = "auto"
|
||||||
|
DEFAULT_MAX_THREADS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def get_codec_version(codec_name: str) -> str | None:
|
||||||
|
versions = _avif.codec_versions()
|
||||||
|
for version in versions.split(", "):
|
||||||
|
if version.split(" [")[0] == codec_name:
|
||||||
|
return version.split(":")[-1].split(" ")[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool | str:
|
||||||
|
if prefix[4:8] != b"ftyp":
|
||||||
|
return False
|
||||||
|
major_brand = prefix[8:12]
|
||||||
|
if major_brand in (
|
||||||
|
# coding brands
|
||||||
|
b"avif",
|
||||||
|
b"avis",
|
||||||
|
# We accept files with AVIF container brands; we can't yet know if
|
||||||
|
# the ftyp box has the correct compatible brands, but if it doesn't
|
||||||
|
# then the plugin will raise a SyntaxError which Pillow will catch
|
||||||
|
# before moving on to the next plugin that accepts the file.
|
||||||
|
#
|
||||||
|
# Also, because this file might not actually be an AVIF file, we
|
||||||
|
# don't raise an error if AVIF support isn't properly compiled.
|
||||||
|
b"mif1",
|
||||||
|
b"msf1",
|
||||||
|
):
|
||||||
|
if not SUPPORTED:
|
||||||
|
return (
|
||||||
|
"image file could not be identified because AVIF support not installed"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_max_threads() -> int:
|
||||||
|
if DEFAULT_MAX_THREADS:
|
||||||
|
return DEFAULT_MAX_THREADS
|
||||||
|
if hasattr(os, "sched_getaffinity"):
|
||||||
|
return len(os.sched_getaffinity(0))
|
||||||
|
else:
|
||||||
|
return os.cpu_count() or 1
|
||||||
|
|
||||||
|
|
||||||
|
class AvifImageFile(ImageFile.ImageFile):
|
||||||
|
format = "AVIF"
|
||||||
|
format_description = "AVIF image"
|
||||||
|
__frame = -1
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
if not SUPPORTED:
|
||||||
|
msg = "image file could not be opened because AVIF support not installed"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
if DECODE_CODEC_CHOICE != "auto" and not _avif.decoder_codec_available(
|
||||||
|
DECODE_CODEC_CHOICE
|
||||||
|
):
|
||||||
|
msg = "Invalid opening codec"
|
||||||
|
raise ValueError(msg)
|
||||||
|
self._decoder = _avif.AvifDecoder(
|
||||||
|
self.fp.read(),
|
||||||
|
DECODE_CODEC_CHOICE,
|
||||||
|
_get_default_max_threads(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get info from decoder
|
||||||
|
self._size, self.n_frames, self._mode, icc, exif, exif_orientation, xmp = (
|
||||||
|
self._decoder.get_info()
|
||||||
|
)
|
||||||
|
self.is_animated = self.n_frames > 1
|
||||||
|
|
||||||
|
if icc:
|
||||||
|
self.info["icc_profile"] = icc
|
||||||
|
if xmp:
|
||||||
|
self.info["xmp"] = xmp
|
||||||
|
|
||||||
|
if exif_orientation != 1 or exif:
|
||||||
|
exif_data = Image.Exif()
|
||||||
|
if exif:
|
||||||
|
exif_data.load(exif)
|
||||||
|
original_orientation = exif_data.get(ExifTags.Base.Orientation, 1)
|
||||||
|
else:
|
||||||
|
original_orientation = 1
|
||||||
|
if exif_orientation != original_orientation:
|
||||||
|
exif_data[ExifTags.Base.Orientation] = exif_orientation
|
||||||
|
exif = exif_data.tobytes()
|
||||||
|
if exif:
|
||||||
|
self.info["exif"] = exif
|
||||||
|
self.seek(0)
|
||||||
|
|
||||||
|
def seek(self, frame: int) -> None:
|
||||||
|
if not self._seek_check(frame):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Set tile
|
||||||
|
self.__frame = frame
|
||||||
|
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
|
||||||
|
|
||||||
|
def load(self) -> Image.core.PixelAccess | None:
|
||||||
|
if self.tile:
|
||||||
|
# We need to load the image data for this frame
|
||||||
|
data, timescale, pts_in_timescales, duration_in_timescales = (
|
||||||
|
self._decoder.get_frame(self.__frame)
|
||||||
|
)
|
||||||
|
self.info["timestamp"] = round(1000 * (pts_in_timescales / timescale))
|
||||||
|
self.info["duration"] = round(1000 * (duration_in_timescales / timescale))
|
||||||
|
|
||||||
|
if self.fp and self._exclusive_fp:
|
||||||
|
self.fp.close()
|
||||||
|
self.fp = BytesIO(data)
|
||||||
|
|
||||||
|
return super().load()
|
||||||
|
|
||||||
|
def load_seek(self, pos: int) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def tell(self) -> int:
|
||||||
|
return self.__frame
|
||||||
|
|
||||||
|
|
||||||
|
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||||
|
_save(im, fp, filename, save_all=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _save(
|
||||||
|
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
|
||||||
|
) -> None:
|
||||||
|
info = im.encoderinfo.copy()
|
||||||
|
if save_all:
|
||||||
|
append_images = list(info.get("append_images", []))
|
||||||
|
else:
|
||||||
|
append_images = []
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for ims in [im] + append_images:
|
||||||
|
total += getattr(ims, "n_frames", 1)
|
||||||
|
|
||||||
|
quality = info.get("quality", 75)
|
||||||
|
if not isinstance(quality, int) or quality < 0 or quality > 100:
|
||||||
|
msg = "Invalid quality setting"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
duration = info.get("duration", 0)
|
||||||
|
subsampling = info.get("subsampling", "4:2:0")
|
||||||
|
speed = info.get("speed", 6)
|
||||||
|
max_threads = info.get("max_threads", _get_default_max_threads())
|
||||||
|
codec = info.get("codec", "auto")
|
||||||
|
if codec != "auto" and not _avif.encoder_codec_available(codec):
|
||||||
|
msg = "Invalid saving codec"
|
||||||
|
raise ValueError(msg)
|
||||||
|
range_ = info.get("range", "full")
|
||||||
|
tile_rows_log2 = info.get("tile_rows", 0)
|
||||||
|
tile_cols_log2 = info.get("tile_cols", 0)
|
||||||
|
alpha_premultiplied = bool(info.get("alpha_premultiplied", False))
|
||||||
|
autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0))
|
||||||
|
|
||||||
|
icc_profile = info.get("icc_profile", im.info.get("icc_profile"))
|
||||||
|
exif_orientation = 1
|
||||||
|
if exif := info.get("exif"):
|
||||||
|
if isinstance(exif, Image.Exif):
|
||||||
|
exif_data = exif
|
||||||
|
else:
|
||||||
|
exif_data = Image.Exif()
|
||||||
|
exif_data.load(exif)
|
||||||
|
if ExifTags.Base.Orientation in exif_data:
|
||||||
|
exif_orientation = exif_data.pop(ExifTags.Base.Orientation)
|
||||||
|
exif = exif_data.tobytes() if exif_data else b""
|
||||||
|
elif isinstance(exif, Image.Exif):
|
||||||
|
exif = exif_data.tobytes()
|
||||||
|
|
||||||
|
xmp = info.get("xmp")
|
||||||
|
|
||||||
|
if isinstance(xmp, str):
|
||||||
|
xmp = xmp.encode("utf-8")
|
||||||
|
|
||||||
|
advanced = info.get("advanced")
|
||||||
|
if advanced is not None:
|
||||||
|
if isinstance(advanced, dict):
|
||||||
|
advanced = advanced.items()
|
||||||
|
try:
|
||||||
|
advanced = tuple(advanced)
|
||||||
|
except TypeError:
|
||||||
|
invalid = True
|
||||||
|
else:
|
||||||
|
invalid = any(not isinstance(v, tuple) or len(v) != 2 for v in advanced)
|
||||||
|
if invalid:
|
||||||
|
msg = (
|
||||||
|
"advanced codec options must be a dict of key-value string "
|
||||||
|
"pairs or a series of key-value two-tuples"
|
||||||
|
)
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
# Setup the AVIF encoder
|
||||||
|
enc = _avif.AvifEncoder(
|
||||||
|
im.size,
|
||||||
|
subsampling,
|
||||||
|
quality,
|
||||||
|
speed,
|
||||||
|
max_threads,
|
||||||
|
codec,
|
||||||
|
range_,
|
||||||
|
tile_rows_log2,
|
||||||
|
tile_cols_log2,
|
||||||
|
alpha_premultiplied,
|
||||||
|
autotiling,
|
||||||
|
icc_profile or b"",
|
||||||
|
exif or b"",
|
||||||
|
exif_orientation,
|
||||||
|
xmp or b"",
|
||||||
|
advanced,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add each frame
|
||||||
|
frame_idx = 0
|
||||||
|
frame_duration = 0
|
||||||
|
cur_idx = im.tell()
|
||||||
|
is_single_frame = total == 1
|
||||||
|
try:
|
||||||
|
for ims in [im] + append_images:
|
||||||
|
# Get number of frames in this image
|
||||||
|
nfr = getattr(ims, "n_frames", 1)
|
||||||
|
|
||||||
|
for idx in range(nfr):
|
||||||
|
ims.seek(idx)
|
||||||
|
|
||||||
|
# Make sure image mode is supported
|
||||||
|
frame = ims
|
||||||
|
rawmode = ims.mode
|
||||||
|
if ims.mode not in {"RGB", "RGBA"}:
|
||||||
|
rawmode = "RGBA" if ims.has_transparency_data else "RGB"
|
||||||
|
frame = ims.convert(rawmode)
|
||||||
|
|
||||||
|
# Update frame duration
|
||||||
|
if isinstance(duration, (list, tuple)):
|
||||||
|
frame_duration = duration[frame_idx]
|
||||||
|
else:
|
||||||
|
frame_duration = duration
|
||||||
|
|
||||||
|
# Append the frame to the animation encoder
|
||||||
|
enc.add(
|
||||||
|
frame.tobytes("raw", rawmode),
|
||||||
|
frame_duration,
|
||||||
|
frame.size,
|
||||||
|
rawmode,
|
||||||
|
is_single_frame,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update frame index
|
||||||
|
frame_idx += 1
|
||||||
|
|
||||||
|
if not save_all:
|
||||||
|
break
|
||||||
|
|
||||||
|
finally:
|
||||||
|
im.seek(cur_idx)
|
||||||
|
|
||||||
|
# Get the final output from the encoder
|
||||||
|
data = enc.finish()
|
||||||
|
if data is None:
|
||||||
|
msg = "cannot write file as AVIF (encoder returned None)"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
fp.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
Image.register_open(AvifImageFile.format, AvifImageFile, _accept)
|
||||||
|
if SUPPORTED:
|
||||||
|
Image.register_save(AvifImageFile.format, _save)
|
||||||
|
Image.register_save_all(AvifImageFile.format, _save_all)
|
||||||
|
Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"])
|
||||||
|
Image.register_mime(AvifImageFile.format, "image/avif")
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# bitmap distribution font (bdf) file parser
|
||||||
|
#
|
||||||
|
# history:
|
||||||
|
# 1996-05-16 fl created (as bdf2pil)
|
||||||
|
# 1997-08-25 fl converted to FontFile driver
|
||||||
|
# 2001-05-25 fl removed bogus __init__ call
|
||||||
|
# 2002-11-20 fl robustification (from Kevin Cazabon, Dmitry Vasiliev)
|
||||||
|
# 2003-04-22 fl more robustification (from Graham Dumpleton)
|
||||||
|
#
|
||||||
|
# Copyright (c) 1997-2003 by Secret Labs AB.
|
||||||
|
# Copyright (c) 1997-2003 by Fredrik Lundh.
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Parse X Bitmap Distribution Format (BDF)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import BinaryIO
|
||||||
|
|
||||||
|
from . import FontFile, Image
|
||||||
|
|
||||||
|
|
||||||
|
def bdf_char(
|
||||||
|
f: BinaryIO,
|
||||||
|
) -> (
|
||||||
|
tuple[
|
||||||
|
str,
|
||||||
|
int,
|
||||||
|
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]],
|
||||||
|
Image.Image,
|
||||||
|
]
|
||||||
|
| None
|
||||||
|
):
|
||||||
|
# skip to STARTCHAR
|
||||||
|
while True:
|
||||||
|
s = f.readline()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
if s.startswith(b"STARTCHAR"):
|
||||||
|
break
|
||||||
|
id = s[9:].strip().decode("ascii")
|
||||||
|
|
||||||
|
# load symbol properties
|
||||||
|
props = {}
|
||||||
|
while True:
|
||||||
|
s = f.readline()
|
||||||
|
if not s or s.startswith(b"BITMAP"):
|
||||||
|
break
|
||||||
|
i = s.find(b" ")
|
||||||
|
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
|
||||||
|
|
||||||
|
# load bitmap
|
||||||
|
bitmap = bytearray()
|
||||||
|
while True:
|
||||||
|
s = f.readline()
|
||||||
|
if not s or s.startswith(b"ENDCHAR"):
|
||||||
|
break
|
||||||
|
bitmap += s[:-1]
|
||||||
|
|
||||||
|
# The word BBX
|
||||||
|
# followed by the width in x (BBw), height in y (BBh),
|
||||||
|
# and x and y displacement (BBxoff0, BByoff0)
|
||||||
|
# of the lower left corner from the origin of the character.
|
||||||
|
width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split())
|
||||||
|
|
||||||
|
# The word DWIDTH
|
||||||
|
# followed by the width in x and y of the character in device pixels.
|
||||||
|
dwx, dwy = (int(p) for p in props["DWIDTH"].split())
|
||||||
|
|
||||||
|
bbox = (
|
||||||
|
(dwx, dwy),
|
||||||
|
(x_disp, -y_disp - height, width + x_disp, -y_disp),
|
||||||
|
(0, 0, width, height),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
im = Image.frombytes("1", (width, height), bitmap, "hex", "1")
|
||||||
|
except ValueError:
|
||||||
|
# deal with zero-width characters
|
||||||
|
im = Image.new("1", (width, height))
|
||||||
|
|
||||||
|
return id, int(props["ENCODING"]), bbox, im
|
||||||
|
|
||||||
|
|
||||||
|
class BdfFontFile(FontFile.FontFile):
|
||||||
|
"""Font file plugin for the X11 BDF format."""
|
||||||
|
|
||||||
|
def __init__(self, fp: BinaryIO) -> None:
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
s = fp.readline()
|
||||||
|
if not s.startswith(b"STARTFONT 2.1"):
|
||||||
|
msg = "not a valid BDF file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
props = {}
|
||||||
|
comments = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
s = fp.readline()
|
||||||
|
if not s or s.startswith(b"ENDPROPERTIES"):
|
||||||
|
break
|
||||||
|
i = s.find(b" ")
|
||||||
|
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
|
||||||
|
if s[:i] in [b"COMMENT", b"COPYRIGHT"]:
|
||||||
|
if s.find(b"LogicalFontDescription") < 0:
|
||||||
|
comments.append(s[i + 1 : -1].decode("ascii"))
|
||||||
|
|
||||||
|
while True:
|
||||||
|
c = bdf_char(fp)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
id, ch, (xy, dst, src), im = c
|
||||||
|
if 0 <= ch < len(self.glyph):
|
||||||
|
self.glyph[ch] = xy, dst, src, im
|
||||||
|
|
@ -0,0 +1,497 @@
|
||||||
|
"""
|
||||||
|
Blizzard Mipmap Format (.blp)
|
||||||
|
Jerome Leclanche <jerome@leclan.ch>
|
||||||
|
|
||||||
|
The contents of this file are hereby released in the public domain (CC0)
|
||||||
|
Full text of the CC0 license:
|
||||||
|
https://creativecommons.org/publicdomain/zero/1.0/
|
||||||
|
|
||||||
|
BLP1 files, used mostly in Warcraft III, are not fully supported.
|
||||||
|
All types of BLP2 files used in World of Warcraft are supported.
|
||||||
|
|
||||||
|
The BLP file structure consists of a header, up to 16 mipmaps of the
|
||||||
|
texture
|
||||||
|
|
||||||
|
Texture sizes must be powers of two, though the two dimensions do
|
||||||
|
not have to be equal; 512x256 is valid, but 512x200 is not.
|
||||||
|
The first mipmap (mipmap #0) is the full size image; each subsequent
|
||||||
|
mipmap halves both dimensions. The final mipmap should be 1x1.
|
||||||
|
|
||||||
|
BLP files come in many different flavours:
|
||||||
|
* JPEG-compressed (type == 0) - only supported for BLP1.
|
||||||
|
* RAW images (type == 1, encoding == 1). Each mipmap is stored as an
|
||||||
|
array of 8-bit values, one per pixel, left to right, top to bottom.
|
||||||
|
Each value is an index to the palette.
|
||||||
|
* DXT-compressed (type == 1, encoding == 2):
|
||||||
|
- DXT1 compression is used if alpha_encoding == 0.
|
||||||
|
- An additional alpha bit is used if alpha_depth == 1.
|
||||||
|
- DXT3 compression is used if alpha_encoding == 1.
|
||||||
|
- DXT5 compression is used if alpha_encoding == 7.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
from enum import IntEnum
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from . import Image, ImageFile
|
||||||
|
|
||||||
|
|
||||||
|
class Format(IntEnum):
|
||||||
|
JPEG = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Encoding(IntEnum):
|
||||||
|
UNCOMPRESSED = 1
|
||||||
|
DXT = 2
|
||||||
|
UNCOMPRESSED_RAW_BGRA = 3
|
||||||
|
|
||||||
|
|
||||||
|
class AlphaEncoding(IntEnum):
|
||||||
|
DXT1 = 0
|
||||||
|
DXT3 = 1
|
||||||
|
DXT5 = 7
|
||||||
|
|
||||||
|
|
||||||
|
def unpack_565(i: int) -> tuple[int, int, int]:
|
||||||
|
return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3
|
||||||
|
|
||||||
|
|
||||||
|
def decode_dxt1(
|
||||||
|
data: bytes, alpha: bool = False
|
||||||
|
) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||||
|
"""
|
||||||
|
input: one "row" of data (i.e. will produce 4*width pixels)
|
||||||
|
"""
|
||||||
|
|
||||||
|
blocks = len(data) // 8 # number of blocks in row
|
||||||
|
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||||
|
|
||||||
|
for block_index in range(blocks):
|
||||||
|
# Decode next 8-byte block.
|
||||||
|
idx = block_index * 8
|
||||||
|
color0, color1, bits = struct.unpack_from("<HHI", data, idx)
|
||||||
|
|
||||||
|
r0, g0, b0 = unpack_565(color0)
|
||||||
|
r1, g1, b1 = unpack_565(color1)
|
||||||
|
|
||||||
|
# Decode this block into 4x4 pixels
|
||||||
|
# Accumulate the results onto our 4 row accumulators
|
||||||
|
for j in range(4):
|
||||||
|
for i in range(4):
|
||||||
|
# get next control op and generate a pixel
|
||||||
|
|
||||||
|
control = bits & 3
|
||||||
|
bits = bits >> 2
|
||||||
|
|
||||||
|
a = 0xFF
|
||||||
|
if control == 0:
|
||||||
|
r, g, b = r0, g0, b0
|
||||||
|
elif control == 1:
|
||||||
|
r, g, b = r1, g1, b1
|
||||||
|
elif control == 2:
|
||||||
|
if color0 > color1:
|
||||||
|
r = (2 * r0 + r1) // 3
|
||||||
|
g = (2 * g0 + g1) // 3
|
||||||
|
b = (2 * b0 + b1) // 3
|
||||||
|
else:
|
||||||
|
r = (r0 + r1) // 2
|
||||||
|
g = (g0 + g1) // 2
|
||||||
|
b = (b0 + b1) // 2
|
||||||
|
elif control == 3:
|
||||||
|
if color0 > color1:
|
||||||
|
r = (2 * r1 + r0) // 3
|
||||||
|
g = (2 * g1 + g0) // 3
|
||||||
|
b = (2 * b1 + b0) // 3
|
||||||
|
else:
|
||||||
|
r, g, b, a = 0, 0, 0, 0
|
||||||
|
|
||||||
|
if alpha:
|
||||||
|
ret[j].extend([r, g, b, a])
|
||||||
|
else:
|
||||||
|
ret[j].extend([r, g, b])
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def decode_dxt3(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||||
|
"""
|
||||||
|
input: one "row" of data (i.e. will produce 4*width pixels)
|
||||||
|
"""
|
||||||
|
|
||||||
|
blocks = len(data) // 16 # number of blocks in row
|
||||||
|
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||||
|
|
||||||
|
for block_index in range(blocks):
|
||||||
|
idx = block_index * 16
|
||||||
|
block = data[idx : idx + 16]
|
||||||
|
# Decode next 16-byte block.
|
||||||
|
bits = struct.unpack_from("<8B", block)
|
||||||
|
color0, color1 = struct.unpack_from("<HH", block, 8)
|
||||||
|
|
||||||
|
(code,) = struct.unpack_from("<I", block, 12)
|
||||||
|
|
||||||
|
r0, g0, b0 = unpack_565(color0)
|
||||||
|
r1, g1, b1 = unpack_565(color1)
|
||||||
|
|
||||||
|
for j in range(4):
|
||||||
|
high = False # Do we want the higher bits?
|
||||||
|
for i in range(4):
|
||||||
|
alphacode_index = (4 * j + i) // 2
|
||||||
|
a = bits[alphacode_index]
|
||||||
|
if high:
|
||||||
|
high = False
|
||||||
|
a >>= 4
|
||||||
|
else:
|
||||||
|
high = True
|
||||||
|
a &= 0xF
|
||||||
|
a *= 17 # We get a value between 0 and 15
|
||||||
|
|
||||||
|
color_code = (code >> 2 * (4 * j + i)) & 0x03
|
||||||
|
|
||||||
|
if color_code == 0:
|
||||||
|
r, g, b = r0, g0, b0
|
||||||
|
elif color_code == 1:
|
||||||
|
r, g, b = r1, g1, b1
|
||||||
|
elif color_code == 2:
|
||||||
|
r = (2 * r0 + r1) // 3
|
||||||
|
g = (2 * g0 + g1) // 3
|
||||||
|
b = (2 * b0 + b1) // 3
|
||||||
|
elif color_code == 3:
|
||||||
|
r = (2 * r1 + r0) // 3
|
||||||
|
g = (2 * g1 + g0) // 3
|
||||||
|
b = (2 * b1 + b0) // 3
|
||||||
|
|
||||||
|
ret[j].extend([r, g, b, a])
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||||
|
"""
|
||||||
|
input: one "row" of data (i.e. will produce 4 * width pixels)
|
||||||
|
"""
|
||||||
|
|
||||||
|
blocks = len(data) // 16 # number of blocks in row
|
||||||
|
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||||
|
|
||||||
|
for block_index in range(blocks):
|
||||||
|
idx = block_index * 16
|
||||||
|
block = data[idx : idx + 16]
|
||||||
|
# Decode next 16-byte block.
|
||||||
|
a0, a1 = struct.unpack_from("<BB", block)
|
||||||
|
|
||||||
|
bits = struct.unpack_from("<6B", block, 2)
|
||||||
|
alphacode1 = bits[2] | (bits[3] << 8) | (bits[4] << 16) | (bits[5] << 24)
|
||||||
|
alphacode2 = bits[0] | (bits[1] << 8)
|
||||||
|
|
||||||
|
color0, color1 = struct.unpack_from("<HH", block, 8)
|
||||||
|
|
||||||
|
(code,) = struct.unpack_from("<I", block, 12)
|
||||||
|
|
||||||
|
r0, g0, b0 = unpack_565(color0)
|
||||||
|
r1, g1, b1 = unpack_565(color1)
|
||||||
|
|
||||||
|
for j in range(4):
|
||||||
|
for i in range(4):
|
||||||
|
# get next control op and generate a pixel
|
||||||
|
alphacode_index = 3 * (4 * j + i)
|
||||||
|
|
||||||
|
if alphacode_index <= 12:
|
||||||
|
alphacode = (alphacode2 >> alphacode_index) & 0x07
|
||||||
|
elif alphacode_index == 15:
|
||||||
|
alphacode = (alphacode2 >> 15) | ((alphacode1 << 1) & 0x06)
|
||||||
|
else: # alphacode_index >= 18 and alphacode_index <= 45
|
||||||
|
alphacode = (alphacode1 >> (alphacode_index - 16)) & 0x07
|
||||||
|
|
||||||
|
if alphacode == 0:
|
||||||
|
a = a0
|
||||||
|
elif alphacode == 1:
|
||||||
|
a = a1
|
||||||
|
elif a0 > a1:
|
||||||
|
a = ((8 - alphacode) * a0 + (alphacode - 1) * a1) // 7
|
||||||
|
elif alphacode == 6:
|
||||||
|
a = 0
|
||||||
|
elif alphacode == 7:
|
||||||
|
a = 255
|
||||||
|
else:
|
||||||
|
a = ((6 - alphacode) * a0 + (alphacode - 1) * a1) // 5
|
||||||
|
|
||||||
|
color_code = (code >> 2 * (4 * j + i)) & 0x03
|
||||||
|
|
||||||
|
if color_code == 0:
|
||||||
|
r, g, b = r0, g0, b0
|
||||||
|
elif color_code == 1:
|
||||||
|
r, g, b = r1, g1, b1
|
||||||
|
elif color_code == 2:
|
||||||
|
r = (2 * r0 + r1) // 3
|
||||||
|
g = (2 * g0 + g1) // 3
|
||||||
|
b = (2 * b0 + b1) // 3
|
||||||
|
elif color_code == 3:
|
||||||
|
r = (2 * r1 + r0) // 3
|
||||||
|
g = (2 * g1 + g0) // 3
|
||||||
|
b = (2 * b1 + b0) // 3
|
||||||
|
|
||||||
|
ret[j].extend([r, g, b, a])
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class BLPFormatError(NotImplementedError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return prefix.startswith((b"BLP1", b"BLP2"))
|
||||||
|
|
||||||
|
|
||||||
|
class BlpImageFile(ImageFile.ImageFile):
|
||||||
|
"""
|
||||||
|
Blizzard Mipmap Format
|
||||||
|
"""
|
||||||
|
|
||||||
|
format = "BLP"
|
||||||
|
format_description = "Blizzard Mipmap Format"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
self.magic = self.fp.read(4)
|
||||||
|
if not _accept(self.magic):
|
||||||
|
msg = f"Bad BLP magic {repr(self.magic)}"
|
||||||
|
raise BLPFormatError(msg)
|
||||||
|
|
||||||
|
compression = struct.unpack("<i", self.fp.read(4))[0]
|
||||||
|
if self.magic == b"BLP1":
|
||||||
|
alpha = struct.unpack("<I", self.fp.read(4))[0] != 0
|
||||||
|
else:
|
||||||
|
encoding = struct.unpack("<b", self.fp.read(1))[0]
|
||||||
|
alpha = struct.unpack("<b", self.fp.read(1))[0] != 0
|
||||||
|
alpha_encoding = struct.unpack("<b", self.fp.read(1))[0]
|
||||||
|
self.fp.seek(1, os.SEEK_CUR) # mips
|
||||||
|
|
||||||
|
self._size = struct.unpack("<II", self.fp.read(8))
|
||||||
|
|
||||||
|
args: tuple[int, int, bool] | tuple[int, int, bool, int]
|
||||||
|
if self.magic == b"BLP1":
|
||||||
|
encoding = struct.unpack("<i", self.fp.read(4))[0]
|
||||||
|
self.fp.seek(4, os.SEEK_CUR) # subtype
|
||||||
|
|
||||||
|
args = (compression, encoding, alpha)
|
||||||
|
offset = 28
|
||||||
|
else:
|
||||||
|
args = (compression, encoding, alpha, alpha_encoding)
|
||||||
|
offset = 20
|
||||||
|
|
||||||
|
decoder = self.magic.decode()
|
||||||
|
|
||||||
|
self._mode = "RGBA" if alpha else "RGB"
|
||||||
|
self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)]
|
||||||
|
|
||||||
|
|
||||||
|
class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder):
|
||||||
|
_pulls_fd = True
|
||||||
|
|
||||||
|
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
self._read_header()
|
||||||
|
self._load()
|
||||||
|
except struct.error as e:
|
||||||
|
msg = "Truncated BLP file"
|
||||||
|
raise OSError(msg) from e
|
||||||
|
return -1, 0
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _load(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _read_header(self) -> None:
|
||||||
|
self._offsets = struct.unpack("<16I", self._safe_read(16 * 4))
|
||||||
|
self._lengths = struct.unpack("<16I", self._safe_read(16 * 4))
|
||||||
|
|
||||||
|
def _safe_read(self, length: int) -> bytes:
|
||||||
|
assert self.fd is not None
|
||||||
|
return ImageFile._safe_read(self.fd, length)
|
||||||
|
|
||||||
|
def _read_palette(self) -> list[tuple[int, int, int, int]]:
|
||||||
|
ret = []
|
||||||
|
for i in range(256):
|
||||||
|
try:
|
||||||
|
b, g, r, a = struct.unpack("<4B", self._safe_read(4))
|
||||||
|
except struct.error:
|
||||||
|
break
|
||||||
|
ret.append((b, g, r, a))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def _read_bgra(
|
||||||
|
self, palette: list[tuple[int, int, int, int]], alpha: bool
|
||||||
|
) -> bytearray:
|
||||||
|
data = bytearray()
|
||||||
|
_data = BytesIO(self._safe_read(self._lengths[0]))
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
(offset,) = struct.unpack("<B", _data.read(1))
|
||||||
|
except struct.error:
|
||||||
|
break
|
||||||
|
b, g, r, a = palette[offset]
|
||||||
|
d: tuple[int, ...] = (r, g, b)
|
||||||
|
if alpha:
|
||||||
|
d += (a,)
|
||||||
|
data.extend(d)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class BLP1Decoder(_BLPBaseDecoder):
|
||||||
|
def _load(self) -> None:
|
||||||
|
self._compression, self._encoding, alpha = self.args
|
||||||
|
|
||||||
|
if self._compression == Format.JPEG:
|
||||||
|
self._decode_jpeg_stream()
|
||||||
|
|
||||||
|
elif self._compression == 1:
|
||||||
|
if self._encoding in (4, 5):
|
||||||
|
palette = self._read_palette()
|
||||||
|
data = self._read_bgra(palette, alpha)
|
||||||
|
self.set_as_raw(data)
|
||||||
|
else:
|
||||||
|
msg = f"Unsupported BLP encoding {repr(self._encoding)}"
|
||||||
|
raise BLPFormatError(msg)
|
||||||
|
else:
|
||||||
|
msg = f"Unsupported BLP compression {repr(self._encoding)}"
|
||||||
|
raise BLPFormatError(msg)
|
||||||
|
|
||||||
|
def _decode_jpeg_stream(self) -> None:
|
||||||
|
from .JpegImagePlugin import JpegImageFile
|
||||||
|
|
||||||
|
(jpeg_header_size,) = struct.unpack("<I", self._safe_read(4))
|
||||||
|
jpeg_header = self._safe_read(jpeg_header_size)
|
||||||
|
assert self.fd is not None
|
||||||
|
self._safe_read(self._offsets[0] - self.fd.tell()) # What IS this?
|
||||||
|
data = self._safe_read(self._lengths[0])
|
||||||
|
data = jpeg_header + data
|
||||||
|
image = JpegImageFile(BytesIO(data))
|
||||||
|
Image._decompression_bomb_check(image.size)
|
||||||
|
if image.mode == "CMYK":
|
||||||
|
args = image.tile[0].args
|
||||||
|
assert isinstance(args, tuple)
|
||||||
|
image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))]
|
||||||
|
self.set_as_raw(image.convert("RGB").tobytes(), "BGR")
|
||||||
|
|
||||||
|
|
||||||
|
class BLP2Decoder(_BLPBaseDecoder):
|
||||||
|
def _load(self) -> None:
|
||||||
|
self._compression, self._encoding, alpha, self._alpha_encoding = self.args
|
||||||
|
|
||||||
|
palette = self._read_palette()
|
||||||
|
|
||||||
|
assert self.fd is not None
|
||||||
|
self.fd.seek(self._offsets[0])
|
||||||
|
|
||||||
|
if self._compression == 1:
|
||||||
|
# Uncompressed or DirectX compression
|
||||||
|
|
||||||
|
if self._encoding == Encoding.UNCOMPRESSED:
|
||||||
|
data = self._read_bgra(palette, alpha)
|
||||||
|
|
||||||
|
elif self._encoding == Encoding.DXT:
|
||||||
|
data = bytearray()
|
||||||
|
if self._alpha_encoding == AlphaEncoding.DXT1:
|
||||||
|
linesize = (self.state.xsize + 3) // 4 * 8
|
||||||
|
for yb in range((self.state.ysize + 3) // 4):
|
||||||
|
for d in decode_dxt1(self._safe_read(linesize), alpha):
|
||||||
|
data += d
|
||||||
|
|
||||||
|
elif self._alpha_encoding == AlphaEncoding.DXT3:
|
||||||
|
linesize = (self.state.xsize + 3) // 4 * 16
|
||||||
|
for yb in range((self.state.ysize + 3) // 4):
|
||||||
|
for d in decode_dxt3(self._safe_read(linesize)):
|
||||||
|
data += d
|
||||||
|
|
||||||
|
elif self._alpha_encoding == AlphaEncoding.DXT5:
|
||||||
|
linesize = (self.state.xsize + 3) // 4 * 16
|
||||||
|
for yb in range((self.state.ysize + 3) // 4):
|
||||||
|
for d in decode_dxt5(self._safe_read(linesize)):
|
||||||
|
data += d
|
||||||
|
else:
|
||||||
|
msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}"
|
||||||
|
raise BLPFormatError(msg)
|
||||||
|
else:
|
||||||
|
msg = f"Unknown BLP encoding {repr(self._encoding)}"
|
||||||
|
raise BLPFormatError(msg)
|
||||||
|
|
||||||
|
else:
|
||||||
|
msg = f"Unknown BLP compression {repr(self._compression)}"
|
||||||
|
raise BLPFormatError(msg)
|
||||||
|
|
||||||
|
self.set_as_raw(data)
|
||||||
|
|
||||||
|
|
||||||
|
class BLPEncoder(ImageFile.PyEncoder):
|
||||||
|
_pushes_fd = True
|
||||||
|
|
||||||
|
def _write_palette(self) -> bytes:
|
||||||
|
data = b""
|
||||||
|
assert self.im is not None
|
||||||
|
palette = self.im.getpalette("RGBA", "RGBA")
|
||||||
|
for i in range(len(palette) // 4):
|
||||||
|
r, g, b, a = palette[i * 4 : (i + 1) * 4]
|
||||||
|
data += struct.pack("<4B", b, g, r, a)
|
||||||
|
while len(data) < 256 * 4:
|
||||||
|
data += b"\x00" * 4
|
||||||
|
return data
|
||||||
|
|
||||||
|
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
|
||||||
|
palette_data = self._write_palette()
|
||||||
|
|
||||||
|
offset = 20 + 16 * 4 * 2 + len(palette_data)
|
||||||
|
data = struct.pack("<16I", offset, *((0,) * 15))
|
||||||
|
|
||||||
|
assert self.im is not None
|
||||||
|
w, h = self.im.size
|
||||||
|
data += struct.pack("<16I", w * h, *((0,) * 15))
|
||||||
|
|
||||||
|
data += palette_data
|
||||||
|
|
||||||
|
for y in range(h):
|
||||||
|
for x in range(w):
|
||||||
|
data += struct.pack("<B", self.im.getpixel((x, y)))
|
||||||
|
|
||||||
|
return len(data), 0, data
|
||||||
|
|
||||||
|
|
||||||
|
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||||
|
if im.mode != "P":
|
||||||
|
msg = "Unsupported BLP image mode"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2"
|
||||||
|
fp.write(magic)
|
||||||
|
|
||||||
|
assert im.palette is not None
|
||||||
|
fp.write(struct.pack("<i", 1)) # Uncompressed or DirectX compression
|
||||||
|
|
||||||
|
alpha_depth = 1 if im.palette.mode == "RGBA" else 0
|
||||||
|
if magic == b"BLP1":
|
||||||
|
fp.write(struct.pack("<L", alpha_depth))
|
||||||
|
else:
|
||||||
|
fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
|
||||||
|
fp.write(struct.pack("<b", alpha_depth))
|
||||||
|
fp.write(struct.pack("<b", 0)) # alpha encoding
|
||||||
|
fp.write(struct.pack("<b", 0)) # mips
|
||||||
|
fp.write(struct.pack("<II", *im.size))
|
||||||
|
if magic == b"BLP1":
|
||||||
|
fp.write(struct.pack("<i", 5))
|
||||||
|
fp.write(struct.pack("<i", 0))
|
||||||
|
|
||||||
|
ImageFile._save(im, fp, [ImageFile._Tile("BLP", (0, 0) + im.size, 0, im.mode)])
|
||||||
|
|
||||||
|
|
||||||
|
Image.register_open(BlpImageFile.format, BlpImageFile, _accept)
|
||||||
|
Image.register_extension(BlpImageFile.format, ".blp")
|
||||||
|
Image.register_decoder("BLP1", BLP1Decoder)
|
||||||
|
Image.register_decoder("BLP2", BLP2Decoder)
|
||||||
|
|
||||||
|
Image.register_save(BlpImageFile.format, _save)
|
||||||
|
Image.register_encoder("BLP", BLPEncoder)
|
||||||
|
|
@ -0,0 +1,515 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library.
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# BMP file handler
|
||||||
|
#
|
||||||
|
# Windows (and OS/2) native bitmap storage format.
|
||||||
|
#
|
||||||
|
# history:
|
||||||
|
# 1995-09-01 fl Created
|
||||||
|
# 1996-04-30 fl Added save
|
||||||
|
# 1997-08-27 fl Fixed save of 1-bit images
|
||||||
|
# 1998-03-06 fl Load P images as L where possible
|
||||||
|
# 1998-07-03 fl Load P images as 1 where possible
|
||||||
|
# 1998-12-29 fl Handle small palettes
|
||||||
|
# 2002-12-30 fl Fixed load of 1-bit palette images
|
||||||
|
# 2003-04-21 fl Fixed load of 1-bit monochrome images
|
||||||
|
# 2003-04-23 fl Added limited support for BI_BITFIELDS compression
|
||||||
|
#
|
||||||
|
# Copyright (c) 1997-2003 by Secret Labs AB
|
||||||
|
# Copyright (c) 1995-2003 by Fredrik Lundh
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import IO, Any
|
||||||
|
|
||||||
|
from . import Image, ImageFile, ImagePalette
|
||||||
|
from ._binary import i16le as i16
|
||||||
|
from ._binary import i32le as i32
|
||||||
|
from ._binary import o8
|
||||||
|
from ._binary import o16le as o16
|
||||||
|
from ._binary import o32le as o32
|
||||||
|
|
||||||
|
#
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
# Read BMP file
|
||||||
|
|
||||||
|
BIT2MODE = {
|
||||||
|
# bits => mode, rawmode
|
||||||
|
1: ("P", "P;1"),
|
||||||
|
4: ("P", "P;4"),
|
||||||
|
8: ("P", "P"),
|
||||||
|
16: ("RGB", "BGR;15"),
|
||||||
|
24: ("RGB", "BGR"),
|
||||||
|
32: ("RGB", "BGRX"),
|
||||||
|
}
|
||||||
|
|
||||||
|
USE_RAW_ALPHA = False
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return prefix.startswith(b"BM")
|
||||||
|
|
||||||
|
|
||||||
|
def _dib_accept(prefix: bytes) -> bool:
|
||||||
|
return i32(prefix) in [12, 40, 52, 56, 64, 108, 124]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Image plugin for the Windows BMP format.
|
||||||
|
# =============================================================================
|
||||||
|
class BmpImageFile(ImageFile.ImageFile):
|
||||||
|
"""Image plugin for the Windows Bitmap format (BMP)"""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Description
|
||||||
|
format_description = "Windows Bitmap"
|
||||||
|
format = "BMP"
|
||||||
|
|
||||||
|
# -------------------------------------------------- BMP Compression values
|
||||||
|
COMPRESSIONS = {"RAW": 0, "RLE8": 1, "RLE4": 2, "BITFIELDS": 3, "JPEG": 4, "PNG": 5}
|
||||||
|
for k, v in COMPRESSIONS.items():
|
||||||
|
vars()[k] = v
|
||||||
|
|
||||||
|
def _bitmap(self, header: int = 0, offset: int = 0) -> None:
|
||||||
|
"""Read relevant info about the BMP"""
|
||||||
|
read, seek = self.fp.read, self.fp.seek
|
||||||
|
if header:
|
||||||
|
seek(header)
|
||||||
|
# read bmp header size @offset 14 (this is part of the header size)
|
||||||
|
file_info: dict[str, bool | int | tuple[int, ...]] = {
|
||||||
|
"header_size": i32(read(4)),
|
||||||
|
"direction": -1,
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------- If requested, read header at a specific position
|
||||||
|
# read the rest of the bmp header, without its size
|
||||||
|
assert isinstance(file_info["header_size"], int)
|
||||||
|
header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4)
|
||||||
|
|
||||||
|
# ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1
|
||||||
|
# ----- This format has different offsets because of width/height types
|
||||||
|
# 12: BITMAPCOREHEADER/OS21XBITMAPHEADER
|
||||||
|
if file_info["header_size"] == 12:
|
||||||
|
file_info["width"] = i16(header_data, 0)
|
||||||
|
file_info["height"] = i16(header_data, 2)
|
||||||
|
file_info["planes"] = i16(header_data, 4)
|
||||||
|
file_info["bits"] = i16(header_data, 6)
|
||||||
|
file_info["compression"] = self.COMPRESSIONS["RAW"]
|
||||||
|
file_info["palette_padding"] = 3
|
||||||
|
|
||||||
|
# --------------------------------------------- Windows Bitmap v3 to v5
|
||||||
|
# 40: BITMAPINFOHEADER
|
||||||
|
# 52: BITMAPV2HEADER
|
||||||
|
# 56: BITMAPV3HEADER
|
||||||
|
# 64: BITMAPCOREHEADER2/OS22XBITMAPHEADER
|
||||||
|
# 108: BITMAPV4HEADER
|
||||||
|
# 124: BITMAPV5HEADER
|
||||||
|
elif file_info["header_size"] in (40, 52, 56, 64, 108, 124):
|
||||||
|
file_info["y_flip"] = header_data[7] == 0xFF
|
||||||
|
file_info["direction"] = 1 if file_info["y_flip"] else -1
|
||||||
|
file_info["width"] = i32(header_data, 0)
|
||||||
|
file_info["height"] = (
|
||||||
|
i32(header_data, 4)
|
||||||
|
if not file_info["y_flip"]
|
||||||
|
else 2**32 - i32(header_data, 4)
|
||||||
|
)
|
||||||
|
file_info["planes"] = i16(header_data, 8)
|
||||||
|
file_info["bits"] = i16(header_data, 10)
|
||||||
|
file_info["compression"] = i32(header_data, 12)
|
||||||
|
# byte size of pixel data
|
||||||
|
file_info["data_size"] = i32(header_data, 16)
|
||||||
|
file_info["pixels_per_meter"] = (
|
||||||
|
i32(header_data, 20),
|
||||||
|
i32(header_data, 24),
|
||||||
|
)
|
||||||
|
file_info["colors"] = i32(header_data, 28)
|
||||||
|
file_info["palette_padding"] = 4
|
||||||
|
assert isinstance(file_info["pixels_per_meter"], tuple)
|
||||||
|
self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"])
|
||||||
|
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
|
||||||
|
masks = ["r_mask", "g_mask", "b_mask"]
|
||||||
|
if len(header_data) >= 48:
|
||||||
|
if len(header_data) >= 52:
|
||||||
|
masks.append("a_mask")
|
||||||
|
else:
|
||||||
|
file_info["a_mask"] = 0x0
|
||||||
|
for idx, mask in enumerate(masks):
|
||||||
|
file_info[mask] = i32(header_data, 36 + idx * 4)
|
||||||
|
else:
|
||||||
|
# 40 byte headers only have the three components in the
|
||||||
|
# bitfields masks, ref:
|
||||||
|
# https://msdn.microsoft.com/en-us/library/windows/desktop/dd183376(v=vs.85).aspx
|
||||||
|
# See also
|
||||||
|
# https://github.com/python-pillow/Pillow/issues/1293
|
||||||
|
# There is a 4th component in the RGBQuad, in the alpha
|
||||||
|
# location, but it is listed as a reserved component,
|
||||||
|
# and it is not generally an alpha channel
|
||||||
|
file_info["a_mask"] = 0x0
|
||||||
|
for mask in masks:
|
||||||
|
file_info[mask] = i32(read(4))
|
||||||
|
assert isinstance(file_info["r_mask"], int)
|
||||||
|
assert isinstance(file_info["g_mask"], int)
|
||||||
|
assert isinstance(file_info["b_mask"], int)
|
||||||
|
assert isinstance(file_info["a_mask"], int)
|
||||||
|
file_info["rgb_mask"] = (
|
||||||
|
file_info["r_mask"],
|
||||||
|
file_info["g_mask"],
|
||||||
|
file_info["b_mask"],
|
||||||
|
)
|
||||||
|
file_info["rgba_mask"] = (
|
||||||
|
file_info["r_mask"],
|
||||||
|
file_info["g_mask"],
|
||||||
|
file_info["b_mask"],
|
||||||
|
file_info["a_mask"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
msg = f"Unsupported BMP header type ({file_info['header_size']})"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
# ------------------ Special case : header is reported 40, which
|
||||||
|
# ---------------------- is shorter than real size for bpp >= 16
|
||||||
|
assert isinstance(file_info["width"], int)
|
||||||
|
assert isinstance(file_info["height"], int)
|
||||||
|
self._size = file_info["width"], file_info["height"]
|
||||||
|
|
||||||
|
# ------- If color count was not found in the header, compute from bits
|
||||||
|
assert isinstance(file_info["bits"], int)
|
||||||
|
file_info["colors"] = (
|
||||||
|
file_info["colors"]
|
||||||
|
if file_info.get("colors", 0)
|
||||||
|
else (1 << file_info["bits"])
|
||||||
|
)
|
||||||
|
assert isinstance(file_info["colors"], int)
|
||||||
|
if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
|
||||||
|
offset += 4 * file_info["colors"]
|
||||||
|
|
||||||
|
# ---------------------- Check bit depth for unusual unsupported values
|
||||||
|
self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", ""))
|
||||||
|
if not self.mode:
|
||||||
|
msg = f"Unsupported BMP pixel depth ({file_info['bits']})"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
# ---------------- Process BMP with Bitfields compression (not palette)
|
||||||
|
decoder_name = "raw"
|
||||||
|
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
|
||||||
|
SUPPORTED: dict[int, list[tuple[int, ...]]] = {
|
||||||
|
32: [
|
||||||
|
(0xFF0000, 0xFF00, 0xFF, 0x0),
|
||||||
|
(0xFF000000, 0xFF0000, 0xFF00, 0x0),
|
||||||
|
(0xFF000000, 0xFF00, 0xFF, 0x0),
|
||||||
|
(0xFF000000, 0xFF0000, 0xFF00, 0xFF),
|
||||||
|
(0xFF, 0xFF00, 0xFF0000, 0xFF000000),
|
||||||
|
(0xFF0000, 0xFF00, 0xFF, 0xFF000000),
|
||||||
|
(0xFF000000, 0xFF00, 0xFF, 0xFF0000),
|
||||||
|
(0x0, 0x0, 0x0, 0x0),
|
||||||
|
],
|
||||||
|
24: [(0xFF0000, 0xFF00, 0xFF)],
|
||||||
|
16: [(0xF800, 0x7E0, 0x1F), (0x7C00, 0x3E0, 0x1F)],
|
||||||
|
}
|
||||||
|
MASK_MODES = {
|
||||||
|
(32, (0xFF0000, 0xFF00, 0xFF, 0x0)): "BGRX",
|
||||||
|
(32, (0xFF000000, 0xFF0000, 0xFF00, 0x0)): "XBGR",
|
||||||
|
(32, (0xFF000000, 0xFF00, 0xFF, 0x0)): "BGXR",
|
||||||
|
(32, (0xFF000000, 0xFF0000, 0xFF00, 0xFF)): "ABGR",
|
||||||
|
(32, (0xFF, 0xFF00, 0xFF0000, 0xFF000000)): "RGBA",
|
||||||
|
(32, (0xFF0000, 0xFF00, 0xFF, 0xFF000000)): "BGRA",
|
||||||
|
(32, (0xFF000000, 0xFF00, 0xFF, 0xFF0000)): "BGAR",
|
||||||
|
(32, (0x0, 0x0, 0x0, 0x0)): "BGRA",
|
||||||
|
(24, (0xFF0000, 0xFF00, 0xFF)): "BGR",
|
||||||
|
(16, (0xF800, 0x7E0, 0x1F)): "BGR;16",
|
||||||
|
(16, (0x7C00, 0x3E0, 0x1F)): "BGR;15",
|
||||||
|
}
|
||||||
|
if file_info["bits"] in SUPPORTED:
|
||||||
|
if (
|
||||||
|
file_info["bits"] == 32
|
||||||
|
and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]]
|
||||||
|
):
|
||||||
|
assert isinstance(file_info["rgba_mask"], tuple)
|
||||||
|
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])]
|
||||||
|
self._mode = "RGBA" if "A" in raw_mode else self.mode
|
||||||
|
elif (
|
||||||
|
file_info["bits"] in (24, 16)
|
||||||
|
and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]]
|
||||||
|
):
|
||||||
|
assert isinstance(file_info["rgb_mask"], tuple)
|
||||||
|
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])]
|
||||||
|
else:
|
||||||
|
msg = "Unsupported BMP bitfields layout"
|
||||||
|
raise OSError(msg)
|
||||||
|
else:
|
||||||
|
msg = "Unsupported BMP bitfields layout"
|
||||||
|
raise OSError(msg)
|
||||||
|
elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
|
||||||
|
if file_info["bits"] == 32 and (
|
||||||
|
header == 22 or USE_RAW_ALPHA # 32-bit .cur offset
|
||||||
|
):
|
||||||
|
raw_mode, self._mode = "BGRA", "RGBA"
|
||||||
|
elif file_info["compression"] in (
|
||||||
|
self.COMPRESSIONS["RLE8"],
|
||||||
|
self.COMPRESSIONS["RLE4"],
|
||||||
|
):
|
||||||
|
decoder_name = "bmp_rle"
|
||||||
|
else:
|
||||||
|
msg = f"Unsupported BMP compression ({file_info['compression']})"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
# --------------- Once the header is processed, process the palette/LUT
|
||||||
|
if self.mode == "P": # Paletted for 1, 4 and 8 bit images
|
||||||
|
# ---------------------------------------------------- 1-bit images
|
||||||
|
if not (0 < file_info["colors"] <= 65536):
|
||||||
|
msg = f"Unsupported BMP Palette size ({file_info['colors']})"
|
||||||
|
raise OSError(msg)
|
||||||
|
else:
|
||||||
|
assert isinstance(file_info["palette_padding"], int)
|
||||||
|
padding = file_info["palette_padding"]
|
||||||
|
palette = read(padding * file_info["colors"])
|
||||||
|
grayscale = True
|
||||||
|
indices = (
|
||||||
|
(0, 255)
|
||||||
|
if file_info["colors"] == 2
|
||||||
|
else list(range(file_info["colors"]))
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------- Check if grayscale and ignore palette if so
|
||||||
|
for ind, val in enumerate(indices):
|
||||||
|
rgb = palette[ind * padding : ind * padding + 3]
|
||||||
|
if rgb != o8(val) * 3:
|
||||||
|
grayscale = False
|
||||||
|
|
||||||
|
# ------- If all colors are gray, white or black, ditch palette
|
||||||
|
if grayscale:
|
||||||
|
self._mode = "1" if file_info["colors"] == 2 else "L"
|
||||||
|
raw_mode = self.mode
|
||||||
|
else:
|
||||||
|
self._mode = "P"
|
||||||
|
self.palette = ImagePalette.raw(
|
||||||
|
"BGRX" if padding == 4 else "BGR", palette
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------- Finally set the tile data for the plugin
|
||||||
|
self.info["compression"] = file_info["compression"]
|
||||||
|
args: list[Any] = [raw_mode]
|
||||||
|
if decoder_name == "bmp_rle":
|
||||||
|
args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"])
|
||||||
|
else:
|
||||||
|
assert isinstance(file_info["width"], int)
|
||||||
|
args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3))
|
||||||
|
args.append(file_info["direction"])
|
||||||
|
self.tile = [
|
||||||
|
ImageFile._Tile(
|
||||||
|
decoder_name,
|
||||||
|
(0, 0, file_info["width"], file_info["height"]),
|
||||||
|
offset or self.fp.tell(),
|
||||||
|
tuple(args),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
"""Open file, check magic number and read header"""
|
||||||
|
# read 14 bytes: magic number, filesize, reserved, header final offset
|
||||||
|
head_data = self.fp.read(14)
|
||||||
|
# choke if the file does not have the required magic bytes
|
||||||
|
if not _accept(head_data):
|
||||||
|
msg = "Not a BMP file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
# read the start position of the BMP image data (u32)
|
||||||
|
offset = i32(head_data, 10)
|
||||||
|
# load bitmap information (offset=raster info)
|
||||||
|
self._bitmap(offset=offset)
|
||||||
|
|
||||||
|
|
||||||
|
class BmpRleDecoder(ImageFile.PyDecoder):
|
||||||
|
_pulls_fd = True
|
||||||
|
|
||||||
|
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||||
|
assert self.fd is not None
|
||||||
|
rle4 = self.args[1]
|
||||||
|
data = bytearray()
|
||||||
|
x = 0
|
||||||
|
dest_length = self.state.xsize * self.state.ysize
|
||||||
|
while len(data) < dest_length:
|
||||||
|
pixels = self.fd.read(1)
|
||||||
|
byte = self.fd.read(1)
|
||||||
|
if not pixels or not byte:
|
||||||
|
break
|
||||||
|
num_pixels = pixels[0]
|
||||||
|
if num_pixels:
|
||||||
|
# encoded mode
|
||||||
|
if x + num_pixels > self.state.xsize:
|
||||||
|
# Too much data for row
|
||||||
|
num_pixels = max(0, self.state.xsize - x)
|
||||||
|
if rle4:
|
||||||
|
first_pixel = o8(byte[0] >> 4)
|
||||||
|
second_pixel = o8(byte[0] & 0x0F)
|
||||||
|
for index in range(num_pixels):
|
||||||
|
if index % 2 == 0:
|
||||||
|
data += first_pixel
|
||||||
|
else:
|
||||||
|
data += second_pixel
|
||||||
|
else:
|
||||||
|
data += byte * num_pixels
|
||||||
|
x += num_pixels
|
||||||
|
else:
|
||||||
|
if byte[0] == 0:
|
||||||
|
# end of line
|
||||||
|
while len(data) % self.state.xsize != 0:
|
||||||
|
data += b"\x00"
|
||||||
|
x = 0
|
||||||
|
elif byte[0] == 1:
|
||||||
|
# end of bitmap
|
||||||
|
break
|
||||||
|
elif byte[0] == 2:
|
||||||
|
# delta
|
||||||
|
bytes_read = self.fd.read(2)
|
||||||
|
if len(bytes_read) < 2:
|
||||||
|
break
|
||||||
|
right, up = self.fd.read(2)
|
||||||
|
data += b"\x00" * (right + up * self.state.xsize)
|
||||||
|
x = len(data) % self.state.xsize
|
||||||
|
else:
|
||||||
|
# absolute mode
|
||||||
|
if rle4:
|
||||||
|
# 2 pixels per byte
|
||||||
|
byte_count = byte[0] // 2
|
||||||
|
bytes_read = self.fd.read(byte_count)
|
||||||
|
for byte_read in bytes_read:
|
||||||
|
data += o8(byte_read >> 4)
|
||||||
|
data += o8(byte_read & 0x0F)
|
||||||
|
else:
|
||||||
|
byte_count = byte[0]
|
||||||
|
bytes_read = self.fd.read(byte_count)
|
||||||
|
data += bytes_read
|
||||||
|
if len(bytes_read) < byte_count:
|
||||||
|
break
|
||||||
|
x += byte[0]
|
||||||
|
|
||||||
|
# align to 16-bit word boundary
|
||||||
|
if self.fd.tell() % 2 != 0:
|
||||||
|
self.fd.seek(1, os.SEEK_CUR)
|
||||||
|
rawmode = "L" if self.mode == "L" else "P"
|
||||||
|
self.set_as_raw(bytes(data), rawmode, (0, self.args[-1]))
|
||||||
|
return -1, 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Image plugin for the DIB format (BMP alias)
|
||||||
|
# =============================================================================
|
||||||
|
class DibImageFile(BmpImageFile):
|
||||||
|
format = "DIB"
|
||||||
|
format_description = "Windows Bitmap"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
self._bitmap()
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
# Write BMP file
|
||||||
|
|
||||||
|
|
||||||
|
SAVE = {
|
||||||
|
"1": ("1", 1, 2),
|
||||||
|
"L": ("L", 8, 256),
|
||||||
|
"P": ("P", 8, 256),
|
||||||
|
"RGB": ("BGR", 24, 0),
|
||||||
|
"RGBA": ("BGRA", 32, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||||
|
_save(im, fp, filename, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _save(
|
||||||
|
im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
rawmode, bits, colors = SAVE[im.mode]
|
||||||
|
except KeyError as e:
|
||||||
|
msg = f"cannot write mode {im.mode} as BMP"
|
||||||
|
raise OSError(msg) from e
|
||||||
|
|
||||||
|
info = im.encoderinfo
|
||||||
|
|
||||||
|
dpi = info.get("dpi", (96, 96))
|
||||||
|
|
||||||
|
# 1 meter == 39.3701 inches
|
||||||
|
ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi)
|
||||||
|
|
||||||
|
stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3)
|
||||||
|
header = 40 # or 64 for OS/2 version 2
|
||||||
|
image = stride * im.size[1]
|
||||||
|
|
||||||
|
if im.mode == "1":
|
||||||
|
palette = b"".join(o8(i) * 3 + b"\x00" for i in (0, 255))
|
||||||
|
elif im.mode == "L":
|
||||||
|
palette = b"".join(o8(i) * 3 + b"\x00" for i in range(256))
|
||||||
|
elif im.mode == "P":
|
||||||
|
palette = im.im.getpalette("RGB", "BGRX")
|
||||||
|
colors = len(palette) // 4
|
||||||
|
else:
|
||||||
|
palette = None
|
||||||
|
|
||||||
|
# bitmap header
|
||||||
|
if bitmap_header:
|
||||||
|
offset = 14 + header + colors * 4
|
||||||
|
file_size = offset + image
|
||||||
|
if file_size > 2**32 - 1:
|
||||||
|
msg = "File size is too large for the BMP format"
|
||||||
|
raise ValueError(msg)
|
||||||
|
fp.write(
|
||||||
|
b"BM" # file type (magic)
|
||||||
|
+ o32(file_size) # file size
|
||||||
|
+ o32(0) # reserved
|
||||||
|
+ o32(offset) # image data offset
|
||||||
|
)
|
||||||
|
|
||||||
|
# bitmap info header
|
||||||
|
fp.write(
|
||||||
|
o32(header) # info header size
|
||||||
|
+ o32(im.size[0]) # width
|
||||||
|
+ o32(im.size[1]) # height
|
||||||
|
+ o16(1) # planes
|
||||||
|
+ o16(bits) # depth
|
||||||
|
+ o32(0) # compression (0=uncompressed)
|
||||||
|
+ o32(image) # size of bitmap
|
||||||
|
+ o32(ppm[0]) # resolution
|
||||||
|
+ o32(ppm[1]) # resolution
|
||||||
|
+ o32(colors) # colors used
|
||||||
|
+ o32(colors) # colors important
|
||||||
|
)
|
||||||
|
|
||||||
|
fp.write(b"\0" * (header - 40)) # padding (for OS/2 format)
|
||||||
|
|
||||||
|
if palette:
|
||||||
|
fp.write(palette)
|
||||||
|
|
||||||
|
ImageFile._save(
|
||||||
|
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
|
||||||
|
|
||||||
|
Image.register_open(BmpImageFile.format, BmpImageFile, _accept)
|
||||||
|
Image.register_save(BmpImageFile.format, _save)
|
||||||
|
|
||||||
|
Image.register_extension(BmpImageFile.format, ".bmp")
|
||||||
|
|
||||||
|
Image.register_mime(BmpImageFile.format, "image/bmp")
|
||||||
|
|
||||||
|
Image.register_decoder("bmp_rle", BmpRleDecoder)
|
||||||
|
|
||||||
|
Image.register_open(DibImageFile.format, DibImageFile, _dib_accept)
|
||||||
|
Image.register_save(DibImageFile.format, _dib_save)
|
||||||
|
|
||||||
|
Image.register_extension(DibImageFile.format, ".dib")
|
||||||
|
|
||||||
|
Image.register_mime(DibImageFile.format, "image/bmp")
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# BUFR stub adapter
|
||||||
|
#
|
||||||
|
# Copyright (c) 1996-2003 by Fredrik Lundh
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from . import Image, ImageFile
|
||||||
|
|
||||||
|
_handler = None
|
||||||
|
|
||||||
|
|
||||||
|
def register_handler(handler: ImageFile.StubHandler | None) -> None:
|
||||||
|
"""
|
||||||
|
Install application-specific BUFR image handler.
|
||||||
|
|
||||||
|
:param handler: Handler object.
|
||||||
|
"""
|
||||||
|
global _handler
|
||||||
|
_handler = handler
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
# Image adapter
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return prefix.startswith((b"BUFR", b"ZCZC"))
|
||||||
|
|
||||||
|
|
||||||
|
class BufrStubImageFile(ImageFile.StubImageFile):
|
||||||
|
format = "BUFR"
|
||||||
|
format_description = "BUFR"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
if not _accept(self.fp.read(4)):
|
||||||
|
msg = "Not a BUFR file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
self.fp.seek(-4, os.SEEK_CUR)
|
||||||
|
|
||||||
|
# make something up
|
||||||
|
self._mode = "F"
|
||||||
|
self._size = 1, 1
|
||||||
|
|
||||||
|
loader = self._load()
|
||||||
|
if loader:
|
||||||
|
loader.open(self)
|
||||||
|
|
||||||
|
def _load(self) -> ImageFile.StubHandler | None:
|
||||||
|
return _handler
|
||||||
|
|
||||||
|
|
||||||
|
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||||
|
if _handler is None or not hasattr(_handler, "save"):
|
||||||
|
msg = "BUFR save handler not installed"
|
||||||
|
raise OSError(msg)
|
||||||
|
_handler.save(im, fp, filename)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
|
||||||
|
Image.register_open(BufrStubImageFile.format, BufrStubImageFile, _accept)
|
||||||
|
Image.register_save(BufrStubImageFile.format, _save)
|
||||||
|
|
||||||
|
Image.register_extension(BufrStubImageFile.format, ".bufr")
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library.
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# a class to read from a container file
|
||||||
|
#
|
||||||
|
# History:
|
||||||
|
# 1995-06-18 fl Created
|
||||||
|
# 1995-09-07 fl Added readline(), readlines()
|
||||||
|
#
|
||||||
|
# Copyright (c) 1997-2001 by Secret Labs AB
|
||||||
|
# Copyright (c) 1995 by Fredrik Lundh
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from typing import IO, AnyStr, NoReturn
|
||||||
|
|
||||||
|
|
||||||
|
class ContainerIO(IO[AnyStr]):
|
||||||
|
"""
|
||||||
|
A file object that provides read access to a part of an existing
|
||||||
|
file (for example a TAR file).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None:
|
||||||
|
"""
|
||||||
|
Create file object.
|
||||||
|
|
||||||
|
:param file: Existing file.
|
||||||
|
:param offset: Start of region, in bytes.
|
||||||
|
:param length: Size of region, in bytes.
|
||||||
|
"""
|
||||||
|
self.fh: IO[AnyStr] = file
|
||||||
|
self.pos = 0
|
||||||
|
self.offset = offset
|
||||||
|
self.length = length
|
||||||
|
self.fh.seek(offset)
|
||||||
|
|
||||||
|
##
|
||||||
|
# Always false.
|
||||||
|
|
||||||
|
def isatty(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def seekable(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def seek(self, offset: int, mode: int = io.SEEK_SET) -> int:
|
||||||
|
"""
|
||||||
|
Move file pointer.
|
||||||
|
|
||||||
|
:param offset: Offset in bytes.
|
||||||
|
:param mode: Starting position. Use 0 for beginning of region, 1
|
||||||
|
for current offset, and 2 for end of region. You cannot move
|
||||||
|
the pointer outside the defined region.
|
||||||
|
:returns: Offset from start of region, in bytes.
|
||||||
|
"""
|
||||||
|
if mode == 1:
|
||||||
|
self.pos = self.pos + offset
|
||||||
|
elif mode == 2:
|
||||||
|
self.pos = self.length + offset
|
||||||
|
else:
|
||||||
|
self.pos = offset
|
||||||
|
# clamp
|
||||||
|
self.pos = max(0, min(self.pos, self.length))
|
||||||
|
self.fh.seek(self.offset + self.pos)
|
||||||
|
return self.pos
|
||||||
|
|
||||||
|
def tell(self) -> int:
|
||||||
|
"""
|
||||||
|
Get current file pointer.
|
||||||
|
|
||||||
|
:returns: Offset from start of region, in bytes.
|
||||||
|
"""
|
||||||
|
return self.pos
|
||||||
|
|
||||||
|
def readable(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def read(self, n: int = -1) -> AnyStr:
|
||||||
|
"""
|
||||||
|
Read data.
|
||||||
|
|
||||||
|
:param n: Number of bytes to read. If omitted, zero or negative,
|
||||||
|
read until end of region.
|
||||||
|
:returns: An 8-bit string.
|
||||||
|
"""
|
||||||
|
if n > 0:
|
||||||
|
n = min(n, self.length - self.pos)
|
||||||
|
else:
|
||||||
|
n = self.length - self.pos
|
||||||
|
if n <= 0: # EOF
|
||||||
|
return b"" if "b" in self.fh.mode else "" # type: ignore[return-value]
|
||||||
|
self.pos = self.pos + n
|
||||||
|
return self.fh.read(n)
|
||||||
|
|
||||||
|
def readline(self, n: int = -1) -> AnyStr:
|
||||||
|
"""
|
||||||
|
Read a line of text.
|
||||||
|
|
||||||
|
:param n: Number of bytes to read. If omitted, zero or negative,
|
||||||
|
read until end of line.
|
||||||
|
:returns: An 8-bit string.
|
||||||
|
"""
|
||||||
|
s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment]
|
||||||
|
newline_character = b"\n" if "b" in self.fh.mode else "\n"
|
||||||
|
while True:
|
||||||
|
c = self.read(1)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
s = s + c
|
||||||
|
if c == newline_character or len(s) == n:
|
||||||
|
break
|
||||||
|
return s
|
||||||
|
|
||||||
|
def readlines(self, n: int | None = -1) -> list[AnyStr]:
|
||||||
|
"""
|
||||||
|
Read multiple lines of text.
|
||||||
|
|
||||||
|
:param n: Number of lines to read. If omitted, zero, negative or None,
|
||||||
|
read until end of region.
|
||||||
|
:returns: A list of 8-bit strings.
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
while True:
|
||||||
|
s = self.readline()
|
||||||
|
if not s:
|
||||||
|
break
|
||||||
|
lines.append(s)
|
||||||
|
if len(lines) == n:
|
||||||
|
break
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def writable(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def write(self, b: AnyStr) -> NoReturn:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def writelines(self, lines: Iterable[AnyStr]) -> NoReturn:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def truncate(self, size: int | None = None) -> int:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def __enter__(self) -> ContainerIO[AnyStr]:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def __iter__(self) -> ContainerIO[AnyStr]:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __next__(self) -> AnyStr:
|
||||||
|
line = self.readline()
|
||||||
|
if not line:
|
||||||
|
msg = "end of region"
|
||||||
|
raise StopIteration(msg)
|
||||||
|
return line
|
||||||
|
|
||||||
|
def fileno(self) -> int:
|
||||||
|
return self.fh.fileno()
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
self.fh.flush()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.fh.close()
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library.
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# Windows Cursor support for PIL
|
||||||
|
#
|
||||||
|
# notes:
|
||||||
|
# uses BmpImagePlugin.py to read the bitmap data.
|
||||||
|
#
|
||||||
|
# history:
|
||||||
|
# 96-05-27 fl Created
|
||||||
|
#
|
||||||
|
# Copyright (c) Secret Labs AB 1997.
|
||||||
|
# Copyright (c) Fredrik Lundh 1996.
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from . import BmpImagePlugin, Image, ImageFile
|
||||||
|
from ._binary import i16le as i16
|
||||||
|
from ._binary import i32le as i32
|
||||||
|
|
||||||
|
#
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return prefix.startswith(b"\0\0\2\0")
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
# Image plugin for Windows Cursor files.
|
||||||
|
|
||||||
|
|
||||||
|
class CurImageFile(BmpImagePlugin.BmpImageFile):
|
||||||
|
format = "CUR"
|
||||||
|
format_description = "Windows Cursor"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
offset = self.fp.tell()
|
||||||
|
|
||||||
|
# check magic
|
||||||
|
s = self.fp.read(6)
|
||||||
|
if not _accept(s):
|
||||||
|
msg = "not a CUR file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
# pick the largest cursor in the file
|
||||||
|
m = b""
|
||||||
|
for i in range(i16(s, 4)):
|
||||||
|
s = self.fp.read(16)
|
||||||
|
if not m:
|
||||||
|
m = s
|
||||||
|
elif s[0] > m[0] and s[1] > m[1]:
|
||||||
|
m = s
|
||||||
|
if not m:
|
||||||
|
msg = "No cursors were found"
|
||||||
|
raise TypeError(msg)
|
||||||
|
|
||||||
|
# load as bitmap
|
||||||
|
self._bitmap(i32(m, 12) + offset)
|
||||||
|
|
||||||
|
# patch up the bitmap height
|
||||||
|
self._size = self.size[0], self.size[1] // 2
|
||||||
|
d, e, o, a = self.tile[0]
|
||||||
|
self.tile[0] = ImageFile._Tile(d, (0, 0) + self.size, o, a)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
|
||||||
|
Image.register_open(CurImageFile.format, CurImageFile, _accept)
|
||||||
|
|
||||||
|
Image.register_extension(CurImageFile.format, ".cur")
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library.
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# DCX file handling
|
||||||
|
#
|
||||||
|
# DCX is a container file format defined by Intel, commonly used
|
||||||
|
# for fax applications. Each DCX file consists of a directory
|
||||||
|
# (a list of file offsets) followed by a set of (usually 1-bit)
|
||||||
|
# PCX files.
|
||||||
|
#
|
||||||
|
# History:
|
||||||
|
# 1995-09-09 fl Created
|
||||||
|
# 1996-03-20 fl Properly derived from PcxImageFile.
|
||||||
|
# 1998-07-15 fl Renamed offset attribute to avoid name clash
|
||||||
|
# 2002-07-30 fl Fixed file handling
|
||||||
|
#
|
||||||
|
# Copyright (c) 1997-98 by Secret Labs AB.
|
||||||
|
# Copyright (c) 1995-96 by Fredrik Lundh.
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from . import Image
|
||||||
|
from ._binary import i32le as i32
|
||||||
|
from ._util import DeferredError
|
||||||
|
from .PcxImagePlugin import PcxImageFile
|
||||||
|
|
||||||
|
MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then?
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return len(prefix) >= 4 and i32(prefix) == MAGIC
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
# Image plugin for the Intel DCX format.
|
||||||
|
|
||||||
|
|
||||||
|
class DcxImageFile(PcxImageFile):
|
||||||
|
format = "DCX"
|
||||||
|
format_description = "Intel DCX"
|
||||||
|
_close_exclusive_fp_after_loading = False
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
# Header
|
||||||
|
s = self.fp.read(4)
|
||||||
|
if not _accept(s):
|
||||||
|
msg = "not a DCX file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
# Component directory
|
||||||
|
self._offset = []
|
||||||
|
for i in range(1024):
|
||||||
|
offset = i32(self.fp.read(4))
|
||||||
|
if not offset:
|
||||||
|
break
|
||||||
|
self._offset.append(offset)
|
||||||
|
|
||||||
|
self._fp = self.fp
|
||||||
|
self.frame = -1
|
||||||
|
self.n_frames = len(self._offset)
|
||||||
|
self.is_animated = self.n_frames > 1
|
||||||
|
self.seek(0)
|
||||||
|
|
||||||
|
def seek(self, frame: int) -> None:
|
||||||
|
if not self._seek_check(frame):
|
||||||
|
return
|
||||||
|
if isinstance(self._fp, DeferredError):
|
||||||
|
raise self._fp.ex
|
||||||
|
self.frame = frame
|
||||||
|
self.fp = self._fp
|
||||||
|
self.fp.seek(self._offset[frame])
|
||||||
|
PcxImageFile._open(self)
|
||||||
|
|
||||||
|
def tell(self) -> int:
|
||||||
|
return self.frame
|
||||||
|
|
||||||
|
|
||||||
|
Image.register_open(DcxImageFile.format, DcxImageFile, _accept)
|
||||||
|
|
||||||
|
Image.register_extension(DcxImageFile.format, ".dcx")
|
||||||
|
|
@ -0,0 +1,624 @@
|
||||||
|
"""
|
||||||
|
A Pillow plugin for .dds files (S3TC-compressed aka DXTC)
|
||||||
|
Jerome Leclanche <jerome@leclan.ch>
|
||||||
|
|
||||||
|
Documentation:
|
||||||
|
https://web.archive.org/web/20170802060935/http://oss.sgi.com/projects/ogl-sample/registry/EXT/texture_compression_s3tc.txt
|
||||||
|
|
||||||
|
The contents of this file are hereby released in the public domain (CC0)
|
||||||
|
Full text of the CC0 license:
|
||||||
|
https://creativecommons.org/publicdomain/zero/1.0/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
from enum import IntEnum, IntFlag
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from . import Image, ImageFile, ImagePalette
|
||||||
|
from ._binary import i32le as i32
|
||||||
|
from ._binary import o8
|
||||||
|
from ._binary import o32le as o32
|
||||||
|
|
||||||
|
# Magic ("DDS ")
|
||||||
|
DDS_MAGIC = 0x20534444
|
||||||
|
|
||||||
|
|
||||||
|
# DDS flags
|
||||||
|
class DDSD(IntFlag):
|
||||||
|
CAPS = 0x1
|
||||||
|
HEIGHT = 0x2
|
||||||
|
WIDTH = 0x4
|
||||||
|
PITCH = 0x8
|
||||||
|
PIXELFORMAT = 0x1000
|
||||||
|
MIPMAPCOUNT = 0x20000
|
||||||
|
LINEARSIZE = 0x80000
|
||||||
|
DEPTH = 0x800000
|
||||||
|
|
||||||
|
|
||||||
|
# DDS caps
|
||||||
|
class DDSCAPS(IntFlag):
|
||||||
|
COMPLEX = 0x8
|
||||||
|
TEXTURE = 0x1000
|
||||||
|
MIPMAP = 0x400000
|
||||||
|
|
||||||
|
|
||||||
|
class DDSCAPS2(IntFlag):
|
||||||
|
CUBEMAP = 0x200
|
||||||
|
CUBEMAP_POSITIVEX = 0x400
|
||||||
|
CUBEMAP_NEGATIVEX = 0x800
|
||||||
|
CUBEMAP_POSITIVEY = 0x1000
|
||||||
|
CUBEMAP_NEGATIVEY = 0x2000
|
||||||
|
CUBEMAP_POSITIVEZ = 0x4000
|
||||||
|
CUBEMAP_NEGATIVEZ = 0x8000
|
||||||
|
VOLUME = 0x200000
|
||||||
|
|
||||||
|
|
||||||
|
# Pixel Format
|
||||||
|
class DDPF(IntFlag):
|
||||||
|
ALPHAPIXELS = 0x1
|
||||||
|
ALPHA = 0x2
|
||||||
|
FOURCC = 0x4
|
||||||
|
PALETTEINDEXED8 = 0x20
|
||||||
|
RGB = 0x40
|
||||||
|
LUMINANCE = 0x20000
|
||||||
|
|
||||||
|
|
||||||
|
# dxgiformat.h
|
||||||
|
class DXGI_FORMAT(IntEnum):
|
||||||
|
UNKNOWN = 0
|
||||||
|
R32G32B32A32_TYPELESS = 1
|
||||||
|
R32G32B32A32_FLOAT = 2
|
||||||
|
R32G32B32A32_UINT = 3
|
||||||
|
R32G32B32A32_SINT = 4
|
||||||
|
R32G32B32_TYPELESS = 5
|
||||||
|
R32G32B32_FLOAT = 6
|
||||||
|
R32G32B32_UINT = 7
|
||||||
|
R32G32B32_SINT = 8
|
||||||
|
R16G16B16A16_TYPELESS = 9
|
||||||
|
R16G16B16A16_FLOAT = 10
|
||||||
|
R16G16B16A16_UNORM = 11
|
||||||
|
R16G16B16A16_UINT = 12
|
||||||
|
R16G16B16A16_SNORM = 13
|
||||||
|
R16G16B16A16_SINT = 14
|
||||||
|
R32G32_TYPELESS = 15
|
||||||
|
R32G32_FLOAT = 16
|
||||||
|
R32G32_UINT = 17
|
||||||
|
R32G32_SINT = 18
|
||||||
|
R32G8X24_TYPELESS = 19
|
||||||
|
D32_FLOAT_S8X24_UINT = 20
|
||||||
|
R32_FLOAT_X8X24_TYPELESS = 21
|
||||||
|
X32_TYPELESS_G8X24_UINT = 22
|
||||||
|
R10G10B10A2_TYPELESS = 23
|
||||||
|
R10G10B10A2_UNORM = 24
|
||||||
|
R10G10B10A2_UINT = 25
|
||||||
|
R11G11B10_FLOAT = 26
|
||||||
|
R8G8B8A8_TYPELESS = 27
|
||||||
|
R8G8B8A8_UNORM = 28
|
||||||
|
R8G8B8A8_UNORM_SRGB = 29
|
||||||
|
R8G8B8A8_UINT = 30
|
||||||
|
R8G8B8A8_SNORM = 31
|
||||||
|
R8G8B8A8_SINT = 32
|
||||||
|
R16G16_TYPELESS = 33
|
||||||
|
R16G16_FLOAT = 34
|
||||||
|
R16G16_UNORM = 35
|
||||||
|
R16G16_UINT = 36
|
||||||
|
R16G16_SNORM = 37
|
||||||
|
R16G16_SINT = 38
|
||||||
|
R32_TYPELESS = 39
|
||||||
|
D32_FLOAT = 40
|
||||||
|
R32_FLOAT = 41
|
||||||
|
R32_UINT = 42
|
||||||
|
R32_SINT = 43
|
||||||
|
R24G8_TYPELESS = 44
|
||||||
|
D24_UNORM_S8_UINT = 45
|
||||||
|
R24_UNORM_X8_TYPELESS = 46
|
||||||
|
X24_TYPELESS_G8_UINT = 47
|
||||||
|
R8G8_TYPELESS = 48
|
||||||
|
R8G8_UNORM = 49
|
||||||
|
R8G8_UINT = 50
|
||||||
|
R8G8_SNORM = 51
|
||||||
|
R8G8_SINT = 52
|
||||||
|
R16_TYPELESS = 53
|
||||||
|
R16_FLOAT = 54
|
||||||
|
D16_UNORM = 55
|
||||||
|
R16_UNORM = 56
|
||||||
|
R16_UINT = 57
|
||||||
|
R16_SNORM = 58
|
||||||
|
R16_SINT = 59
|
||||||
|
R8_TYPELESS = 60
|
||||||
|
R8_UNORM = 61
|
||||||
|
R8_UINT = 62
|
||||||
|
R8_SNORM = 63
|
||||||
|
R8_SINT = 64
|
||||||
|
A8_UNORM = 65
|
||||||
|
R1_UNORM = 66
|
||||||
|
R9G9B9E5_SHAREDEXP = 67
|
||||||
|
R8G8_B8G8_UNORM = 68
|
||||||
|
G8R8_G8B8_UNORM = 69
|
||||||
|
BC1_TYPELESS = 70
|
||||||
|
BC1_UNORM = 71
|
||||||
|
BC1_UNORM_SRGB = 72
|
||||||
|
BC2_TYPELESS = 73
|
||||||
|
BC2_UNORM = 74
|
||||||
|
BC2_UNORM_SRGB = 75
|
||||||
|
BC3_TYPELESS = 76
|
||||||
|
BC3_UNORM = 77
|
||||||
|
BC3_UNORM_SRGB = 78
|
||||||
|
BC4_TYPELESS = 79
|
||||||
|
BC4_UNORM = 80
|
||||||
|
BC4_SNORM = 81
|
||||||
|
BC5_TYPELESS = 82
|
||||||
|
BC5_UNORM = 83
|
||||||
|
BC5_SNORM = 84
|
||||||
|
B5G6R5_UNORM = 85
|
||||||
|
B5G5R5A1_UNORM = 86
|
||||||
|
B8G8R8A8_UNORM = 87
|
||||||
|
B8G8R8X8_UNORM = 88
|
||||||
|
R10G10B10_XR_BIAS_A2_UNORM = 89
|
||||||
|
B8G8R8A8_TYPELESS = 90
|
||||||
|
B8G8R8A8_UNORM_SRGB = 91
|
||||||
|
B8G8R8X8_TYPELESS = 92
|
||||||
|
B8G8R8X8_UNORM_SRGB = 93
|
||||||
|
BC6H_TYPELESS = 94
|
||||||
|
BC6H_UF16 = 95
|
||||||
|
BC6H_SF16 = 96
|
||||||
|
BC7_TYPELESS = 97
|
||||||
|
BC7_UNORM = 98
|
||||||
|
BC7_UNORM_SRGB = 99
|
||||||
|
AYUV = 100
|
||||||
|
Y410 = 101
|
||||||
|
Y416 = 102
|
||||||
|
NV12 = 103
|
||||||
|
P010 = 104
|
||||||
|
P016 = 105
|
||||||
|
OPAQUE_420 = 106
|
||||||
|
YUY2 = 107
|
||||||
|
Y210 = 108
|
||||||
|
Y216 = 109
|
||||||
|
NV11 = 110
|
||||||
|
AI44 = 111
|
||||||
|
IA44 = 112
|
||||||
|
P8 = 113
|
||||||
|
A8P8 = 114
|
||||||
|
B4G4R4A4_UNORM = 115
|
||||||
|
P208 = 130
|
||||||
|
V208 = 131
|
||||||
|
V408 = 132
|
||||||
|
SAMPLER_FEEDBACK_MIN_MIP_OPAQUE = 189
|
||||||
|
SAMPLER_FEEDBACK_MIP_REGION_USED_OPAQUE = 190
|
||||||
|
|
||||||
|
|
||||||
|
class D3DFMT(IntEnum):
|
||||||
|
UNKNOWN = 0
|
||||||
|
R8G8B8 = 20
|
||||||
|
A8R8G8B8 = 21
|
||||||
|
X8R8G8B8 = 22
|
||||||
|
R5G6B5 = 23
|
||||||
|
X1R5G5B5 = 24
|
||||||
|
A1R5G5B5 = 25
|
||||||
|
A4R4G4B4 = 26
|
||||||
|
R3G3B2 = 27
|
||||||
|
A8 = 28
|
||||||
|
A8R3G3B2 = 29
|
||||||
|
X4R4G4B4 = 30
|
||||||
|
A2B10G10R10 = 31
|
||||||
|
A8B8G8R8 = 32
|
||||||
|
X8B8G8R8 = 33
|
||||||
|
G16R16 = 34
|
||||||
|
A2R10G10B10 = 35
|
||||||
|
A16B16G16R16 = 36
|
||||||
|
A8P8 = 40
|
||||||
|
P8 = 41
|
||||||
|
L8 = 50
|
||||||
|
A8L8 = 51
|
||||||
|
A4L4 = 52
|
||||||
|
V8U8 = 60
|
||||||
|
L6V5U5 = 61
|
||||||
|
X8L8V8U8 = 62
|
||||||
|
Q8W8V8U8 = 63
|
||||||
|
V16U16 = 64
|
||||||
|
A2W10V10U10 = 67
|
||||||
|
D16_LOCKABLE = 70
|
||||||
|
D32 = 71
|
||||||
|
D15S1 = 73
|
||||||
|
D24S8 = 75
|
||||||
|
D24X8 = 77
|
||||||
|
D24X4S4 = 79
|
||||||
|
D16 = 80
|
||||||
|
D32F_LOCKABLE = 82
|
||||||
|
D24FS8 = 83
|
||||||
|
D32_LOCKABLE = 84
|
||||||
|
S8_LOCKABLE = 85
|
||||||
|
L16 = 81
|
||||||
|
VERTEXDATA = 100
|
||||||
|
INDEX16 = 101
|
||||||
|
INDEX32 = 102
|
||||||
|
Q16W16V16U16 = 110
|
||||||
|
R16F = 111
|
||||||
|
G16R16F = 112
|
||||||
|
A16B16G16R16F = 113
|
||||||
|
R32F = 114
|
||||||
|
G32R32F = 115
|
||||||
|
A32B32G32R32F = 116
|
||||||
|
CxV8U8 = 117
|
||||||
|
A1 = 118
|
||||||
|
A2B10G10R10_XR_BIAS = 119
|
||||||
|
BINARYBUFFER = 199
|
||||||
|
|
||||||
|
UYVY = i32(b"UYVY")
|
||||||
|
R8G8_B8G8 = i32(b"RGBG")
|
||||||
|
YUY2 = i32(b"YUY2")
|
||||||
|
G8R8_G8B8 = i32(b"GRGB")
|
||||||
|
DXT1 = i32(b"DXT1")
|
||||||
|
DXT2 = i32(b"DXT2")
|
||||||
|
DXT3 = i32(b"DXT3")
|
||||||
|
DXT4 = i32(b"DXT4")
|
||||||
|
DXT5 = i32(b"DXT5")
|
||||||
|
DX10 = i32(b"DX10")
|
||||||
|
BC4S = i32(b"BC4S")
|
||||||
|
BC4U = i32(b"BC4U")
|
||||||
|
BC5S = i32(b"BC5S")
|
||||||
|
BC5U = i32(b"BC5U")
|
||||||
|
ATI1 = i32(b"ATI1")
|
||||||
|
ATI2 = i32(b"ATI2")
|
||||||
|
MULTI2_ARGB8 = i32(b"MET1")
|
||||||
|
|
||||||
|
|
||||||
|
# Backward compatibility layer
|
||||||
|
module = sys.modules[__name__]
|
||||||
|
for item in DDSD:
|
||||||
|
assert item.name is not None
|
||||||
|
setattr(module, f"DDSD_{item.name}", item.value)
|
||||||
|
for item1 in DDSCAPS:
|
||||||
|
assert item1.name is not None
|
||||||
|
setattr(module, f"DDSCAPS_{item1.name}", item1.value)
|
||||||
|
for item2 in DDSCAPS2:
|
||||||
|
assert item2.name is not None
|
||||||
|
setattr(module, f"DDSCAPS2_{item2.name}", item2.value)
|
||||||
|
for item3 in DDPF:
|
||||||
|
assert item3.name is not None
|
||||||
|
setattr(module, f"DDPF_{item3.name}", item3.value)
|
||||||
|
|
||||||
|
DDS_FOURCC = DDPF.FOURCC
|
||||||
|
DDS_RGB = DDPF.RGB
|
||||||
|
DDS_RGBA = DDPF.RGB | DDPF.ALPHAPIXELS
|
||||||
|
DDS_LUMINANCE = DDPF.LUMINANCE
|
||||||
|
DDS_LUMINANCEA = DDPF.LUMINANCE | DDPF.ALPHAPIXELS
|
||||||
|
DDS_ALPHA = DDPF.ALPHA
|
||||||
|
DDS_PAL8 = DDPF.PALETTEINDEXED8
|
||||||
|
|
||||||
|
DDS_HEADER_FLAGS_TEXTURE = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
|
||||||
|
DDS_HEADER_FLAGS_MIPMAP = DDSD.MIPMAPCOUNT
|
||||||
|
DDS_HEADER_FLAGS_VOLUME = DDSD.DEPTH
|
||||||
|
DDS_HEADER_FLAGS_PITCH = DDSD.PITCH
|
||||||
|
DDS_HEADER_FLAGS_LINEARSIZE = DDSD.LINEARSIZE
|
||||||
|
|
||||||
|
DDS_HEIGHT = DDSD.HEIGHT
|
||||||
|
DDS_WIDTH = DDSD.WIDTH
|
||||||
|
|
||||||
|
DDS_SURFACE_FLAGS_TEXTURE = DDSCAPS.TEXTURE
|
||||||
|
DDS_SURFACE_FLAGS_MIPMAP = DDSCAPS.COMPLEX | DDSCAPS.MIPMAP
|
||||||
|
DDS_SURFACE_FLAGS_CUBEMAP = DDSCAPS.COMPLEX
|
||||||
|
|
||||||
|
DDS_CUBEMAP_POSITIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEX
|
||||||
|
DDS_CUBEMAP_NEGATIVEX = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEX
|
||||||
|
DDS_CUBEMAP_POSITIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEY
|
||||||
|
DDS_CUBEMAP_NEGATIVEY = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEY
|
||||||
|
DDS_CUBEMAP_POSITIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_POSITIVEZ
|
||||||
|
DDS_CUBEMAP_NEGATIVEZ = DDSCAPS2.CUBEMAP | DDSCAPS2.CUBEMAP_NEGATIVEZ
|
||||||
|
|
||||||
|
DXT1_FOURCC = D3DFMT.DXT1
|
||||||
|
DXT3_FOURCC = D3DFMT.DXT3
|
||||||
|
DXT5_FOURCC = D3DFMT.DXT5
|
||||||
|
|
||||||
|
DXGI_FORMAT_R8G8B8A8_TYPELESS = DXGI_FORMAT.R8G8B8A8_TYPELESS
|
||||||
|
DXGI_FORMAT_R8G8B8A8_UNORM = DXGI_FORMAT.R8G8B8A8_UNORM
|
||||||
|
DXGI_FORMAT_R8G8B8A8_UNORM_SRGB = DXGI_FORMAT.R8G8B8A8_UNORM_SRGB
|
||||||
|
DXGI_FORMAT_BC5_TYPELESS = DXGI_FORMAT.BC5_TYPELESS
|
||||||
|
DXGI_FORMAT_BC5_UNORM = DXGI_FORMAT.BC5_UNORM
|
||||||
|
DXGI_FORMAT_BC5_SNORM = DXGI_FORMAT.BC5_SNORM
|
||||||
|
DXGI_FORMAT_BC6H_UF16 = DXGI_FORMAT.BC6H_UF16
|
||||||
|
DXGI_FORMAT_BC6H_SF16 = DXGI_FORMAT.BC6H_SF16
|
||||||
|
DXGI_FORMAT_BC7_TYPELESS = DXGI_FORMAT.BC7_TYPELESS
|
||||||
|
DXGI_FORMAT_BC7_UNORM = DXGI_FORMAT.BC7_UNORM
|
||||||
|
DXGI_FORMAT_BC7_UNORM_SRGB = DXGI_FORMAT.BC7_UNORM_SRGB
|
||||||
|
|
||||||
|
|
||||||
|
class DdsImageFile(ImageFile.ImageFile):
|
||||||
|
format = "DDS"
|
||||||
|
format_description = "DirectDraw Surface"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
if not _accept(self.fp.read(4)):
|
||||||
|
msg = "not a DDS file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
(header_size,) = struct.unpack("<I", self.fp.read(4))
|
||||||
|
if header_size != 124:
|
||||||
|
msg = f"Unsupported header size {repr(header_size)}"
|
||||||
|
raise OSError(msg)
|
||||||
|
header_bytes = self.fp.read(header_size - 4)
|
||||||
|
if len(header_bytes) != 120:
|
||||||
|
msg = f"Incomplete header: {len(header_bytes)} bytes"
|
||||||
|
raise OSError(msg)
|
||||||
|
header = io.BytesIO(header_bytes)
|
||||||
|
|
||||||
|
flags, height, width = struct.unpack("<3I", header.read(12))
|
||||||
|
self._size = (width, height)
|
||||||
|
extents = (0, 0) + self.size
|
||||||
|
|
||||||
|
pitch, depth, mipmaps = struct.unpack("<3I", header.read(12))
|
||||||
|
struct.unpack("<11I", header.read(44)) # reserved
|
||||||
|
|
||||||
|
# pixel format
|
||||||
|
pfsize, pfflags, fourcc, bitcount = struct.unpack("<4I", header.read(16))
|
||||||
|
n = 0
|
||||||
|
rawmode = None
|
||||||
|
if pfflags & DDPF.RGB:
|
||||||
|
# Texture contains uncompressed RGB data
|
||||||
|
if pfflags & DDPF.ALPHAPIXELS:
|
||||||
|
self._mode = "RGBA"
|
||||||
|
mask_count = 4
|
||||||
|
else:
|
||||||
|
self._mode = "RGB"
|
||||||
|
mask_count = 3
|
||||||
|
|
||||||
|
masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4))
|
||||||
|
self.tile = [ImageFile._Tile("dds_rgb", extents, 0, (bitcount, masks))]
|
||||||
|
return
|
||||||
|
elif pfflags & DDPF.LUMINANCE:
|
||||||
|
if bitcount == 8:
|
||||||
|
self._mode = "L"
|
||||||
|
elif bitcount == 16 and pfflags & DDPF.ALPHAPIXELS:
|
||||||
|
self._mode = "LA"
|
||||||
|
else:
|
||||||
|
msg = f"Unsupported bitcount {bitcount} for {pfflags}"
|
||||||
|
raise OSError(msg)
|
||||||
|
elif pfflags & DDPF.PALETTEINDEXED8:
|
||||||
|
self._mode = "P"
|
||||||
|
self.palette = ImagePalette.raw("RGBA", self.fp.read(1024))
|
||||||
|
self.palette.mode = "RGBA"
|
||||||
|
elif pfflags & DDPF.FOURCC:
|
||||||
|
offset = header_size + 4
|
||||||
|
if fourcc == D3DFMT.DXT1:
|
||||||
|
self._mode = "RGBA"
|
||||||
|
self.pixel_format = "DXT1"
|
||||||
|
n = 1
|
||||||
|
elif fourcc == D3DFMT.DXT3:
|
||||||
|
self._mode = "RGBA"
|
||||||
|
self.pixel_format = "DXT3"
|
||||||
|
n = 2
|
||||||
|
elif fourcc == D3DFMT.DXT5:
|
||||||
|
self._mode = "RGBA"
|
||||||
|
self.pixel_format = "DXT5"
|
||||||
|
n = 3
|
||||||
|
elif fourcc in (D3DFMT.BC4U, D3DFMT.ATI1):
|
||||||
|
self._mode = "L"
|
||||||
|
self.pixel_format = "BC4"
|
||||||
|
n = 4
|
||||||
|
elif fourcc == D3DFMT.BC5S:
|
||||||
|
self._mode = "RGB"
|
||||||
|
self.pixel_format = "BC5S"
|
||||||
|
n = 5
|
||||||
|
elif fourcc in (D3DFMT.BC5U, D3DFMT.ATI2):
|
||||||
|
self._mode = "RGB"
|
||||||
|
self.pixel_format = "BC5"
|
||||||
|
n = 5
|
||||||
|
elif fourcc == D3DFMT.DX10:
|
||||||
|
offset += 20
|
||||||
|
# ignoring flags which pertain to volume textures and cubemaps
|
||||||
|
(dxgi_format,) = struct.unpack("<I", self.fp.read(4))
|
||||||
|
self.fp.read(16)
|
||||||
|
if dxgi_format in (
|
||||||
|
DXGI_FORMAT.BC1_UNORM,
|
||||||
|
DXGI_FORMAT.BC1_TYPELESS,
|
||||||
|
):
|
||||||
|
self._mode = "RGBA"
|
||||||
|
self.pixel_format = "BC1"
|
||||||
|
n = 1
|
||||||
|
elif dxgi_format in (DXGI_FORMAT.BC2_TYPELESS, DXGI_FORMAT.BC2_UNORM):
|
||||||
|
self._mode = "RGBA"
|
||||||
|
self.pixel_format = "BC2"
|
||||||
|
n = 2
|
||||||
|
elif dxgi_format in (DXGI_FORMAT.BC3_TYPELESS, DXGI_FORMAT.BC3_UNORM):
|
||||||
|
self._mode = "RGBA"
|
||||||
|
self.pixel_format = "BC3"
|
||||||
|
n = 3
|
||||||
|
elif dxgi_format in (DXGI_FORMAT.BC4_TYPELESS, DXGI_FORMAT.BC4_UNORM):
|
||||||
|
self._mode = "L"
|
||||||
|
self.pixel_format = "BC4"
|
||||||
|
n = 4
|
||||||
|
elif dxgi_format in (DXGI_FORMAT.BC5_TYPELESS, DXGI_FORMAT.BC5_UNORM):
|
||||||
|
self._mode = "RGB"
|
||||||
|
self.pixel_format = "BC5"
|
||||||
|
n = 5
|
||||||
|
elif dxgi_format == DXGI_FORMAT.BC5_SNORM:
|
||||||
|
self._mode = "RGB"
|
||||||
|
self.pixel_format = "BC5S"
|
||||||
|
n = 5
|
||||||
|
elif dxgi_format == DXGI_FORMAT.BC6H_UF16:
|
||||||
|
self._mode = "RGB"
|
||||||
|
self.pixel_format = "BC6H"
|
||||||
|
n = 6
|
||||||
|
elif dxgi_format == DXGI_FORMAT.BC6H_SF16:
|
||||||
|
self._mode = "RGB"
|
||||||
|
self.pixel_format = "BC6HS"
|
||||||
|
n = 6
|
||||||
|
elif dxgi_format in (
|
||||||
|
DXGI_FORMAT.BC7_TYPELESS,
|
||||||
|
DXGI_FORMAT.BC7_UNORM,
|
||||||
|
DXGI_FORMAT.BC7_UNORM_SRGB,
|
||||||
|
):
|
||||||
|
self._mode = "RGBA"
|
||||||
|
self.pixel_format = "BC7"
|
||||||
|
n = 7
|
||||||
|
if dxgi_format == DXGI_FORMAT.BC7_UNORM_SRGB:
|
||||||
|
self.info["gamma"] = 1 / 2.2
|
||||||
|
elif dxgi_format in (
|
||||||
|
DXGI_FORMAT.R8G8B8A8_TYPELESS,
|
||||||
|
DXGI_FORMAT.R8G8B8A8_UNORM,
|
||||||
|
DXGI_FORMAT.R8G8B8A8_UNORM_SRGB,
|
||||||
|
):
|
||||||
|
self._mode = "RGBA"
|
||||||
|
if dxgi_format == DXGI_FORMAT.R8G8B8A8_UNORM_SRGB:
|
||||||
|
self.info["gamma"] = 1 / 2.2
|
||||||
|
else:
|
||||||
|
msg = f"Unimplemented DXGI format {dxgi_format}"
|
||||||
|
raise NotImplementedError(msg)
|
||||||
|
else:
|
||||||
|
msg = f"Unimplemented pixel format {repr(fourcc)}"
|
||||||
|
raise NotImplementedError(msg)
|
||||||
|
else:
|
||||||
|
msg = f"Unknown pixel format flags {pfflags}"
|
||||||
|
raise NotImplementedError(msg)
|
||||||
|
|
||||||
|
if n:
|
||||||
|
self.tile = [
|
||||||
|
ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format))
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
self.tile = [ImageFile._Tile("raw", extents, 0, rawmode or self.mode)]
|
||||||
|
|
||||||
|
def load_seek(self, pos: int) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DdsRgbDecoder(ImageFile.PyDecoder):
|
||||||
|
_pulls_fd = True
|
||||||
|
|
||||||
|
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||||
|
assert self.fd is not None
|
||||||
|
bitcount, masks = self.args
|
||||||
|
|
||||||
|
# Some masks will be padded with zeros, e.g. R 0b11 G 0b1100
|
||||||
|
# Calculate how many zeros each mask is padded with
|
||||||
|
mask_offsets = []
|
||||||
|
# And the maximum value of each channel without the padding
|
||||||
|
mask_totals = []
|
||||||
|
for mask in masks:
|
||||||
|
offset = 0
|
||||||
|
if mask != 0:
|
||||||
|
while mask >> (offset + 1) << (offset + 1) == mask:
|
||||||
|
offset += 1
|
||||||
|
mask_offsets.append(offset)
|
||||||
|
mask_totals.append(mask >> offset)
|
||||||
|
|
||||||
|
data = bytearray()
|
||||||
|
bytecount = bitcount // 8
|
||||||
|
dest_length = self.state.xsize * self.state.ysize * len(masks)
|
||||||
|
while len(data) < dest_length:
|
||||||
|
value = int.from_bytes(self.fd.read(bytecount), "little")
|
||||||
|
for i, mask in enumerate(masks):
|
||||||
|
masked_value = value & mask
|
||||||
|
# Remove the zero padding, and scale it to 8 bits
|
||||||
|
data += o8(
|
||||||
|
int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255)
|
||||||
|
)
|
||||||
|
self.set_as_raw(data)
|
||||||
|
return -1, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||||
|
if im.mode not in ("RGB", "RGBA", "L", "LA"):
|
||||||
|
msg = f"cannot write mode {im.mode} as DDS"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
flags = DDSD.CAPS | DDSD.HEIGHT | DDSD.WIDTH | DDSD.PIXELFORMAT
|
||||||
|
bitcount = len(im.getbands()) * 8
|
||||||
|
pixel_format = im.encoderinfo.get("pixel_format")
|
||||||
|
args: tuple[int] | str
|
||||||
|
if pixel_format:
|
||||||
|
codec_name = "bcn"
|
||||||
|
flags |= DDSD.LINEARSIZE
|
||||||
|
pitch = (im.width + 3) * 4
|
||||||
|
rgba_mask = [0, 0, 0, 0]
|
||||||
|
pixel_flags = DDPF.FOURCC
|
||||||
|
if pixel_format == "DXT1":
|
||||||
|
fourcc = D3DFMT.DXT1
|
||||||
|
args = (1,)
|
||||||
|
elif pixel_format == "DXT3":
|
||||||
|
fourcc = D3DFMT.DXT3
|
||||||
|
args = (2,)
|
||||||
|
elif pixel_format == "DXT5":
|
||||||
|
fourcc = D3DFMT.DXT5
|
||||||
|
args = (3,)
|
||||||
|
else:
|
||||||
|
fourcc = D3DFMT.DX10
|
||||||
|
if pixel_format == "BC2":
|
||||||
|
args = (2,)
|
||||||
|
dxgi_format = DXGI_FORMAT.BC2_TYPELESS
|
||||||
|
elif pixel_format == "BC3":
|
||||||
|
args = (3,)
|
||||||
|
dxgi_format = DXGI_FORMAT.BC3_TYPELESS
|
||||||
|
elif pixel_format == "BC5":
|
||||||
|
args = (5,)
|
||||||
|
dxgi_format = DXGI_FORMAT.BC5_TYPELESS
|
||||||
|
if im.mode != "RGB":
|
||||||
|
msg = "only RGB mode can be written as BC5"
|
||||||
|
raise OSError(msg)
|
||||||
|
else:
|
||||||
|
msg = f"cannot write pixel format {pixel_format}"
|
||||||
|
raise OSError(msg)
|
||||||
|
else:
|
||||||
|
codec_name = "raw"
|
||||||
|
flags |= DDSD.PITCH
|
||||||
|
pitch = (im.width * bitcount + 7) // 8
|
||||||
|
|
||||||
|
alpha = im.mode[-1] == "A"
|
||||||
|
if im.mode[0] == "L":
|
||||||
|
pixel_flags = DDPF.LUMINANCE
|
||||||
|
args = im.mode
|
||||||
|
if alpha:
|
||||||
|
rgba_mask = [0x000000FF, 0x000000FF, 0x000000FF]
|
||||||
|
else:
|
||||||
|
rgba_mask = [0xFF000000, 0xFF000000, 0xFF000000]
|
||||||
|
else:
|
||||||
|
pixel_flags = DDPF.RGB
|
||||||
|
args = im.mode[::-1]
|
||||||
|
rgba_mask = [0x00FF0000, 0x0000FF00, 0x000000FF]
|
||||||
|
|
||||||
|
if alpha:
|
||||||
|
r, g, b, a = im.split()
|
||||||
|
im = Image.merge("RGBA", (a, r, g, b))
|
||||||
|
if alpha:
|
||||||
|
pixel_flags |= DDPF.ALPHAPIXELS
|
||||||
|
rgba_mask.append(0xFF000000 if alpha else 0)
|
||||||
|
|
||||||
|
fourcc = D3DFMT.UNKNOWN
|
||||||
|
fp.write(
|
||||||
|
o32(DDS_MAGIC)
|
||||||
|
+ struct.pack(
|
||||||
|
"<7I",
|
||||||
|
124, # header size
|
||||||
|
flags, # flags
|
||||||
|
im.height,
|
||||||
|
im.width,
|
||||||
|
pitch,
|
||||||
|
0, # depth
|
||||||
|
0, # mipmaps
|
||||||
|
)
|
||||||
|
+ struct.pack("11I", *((0,) * 11)) # reserved
|
||||||
|
# pfsize, pfflags, fourcc, bitcount
|
||||||
|
+ struct.pack("<4I", 32, pixel_flags, fourcc, bitcount)
|
||||||
|
+ struct.pack("<4I", *rgba_mask) # dwRGBABitMask
|
||||||
|
+ struct.pack("<5I", DDSCAPS.TEXTURE, 0, 0, 0, 0)
|
||||||
|
)
|
||||||
|
if fourcc == D3DFMT.DX10:
|
||||||
|
fp.write(
|
||||||
|
# dxgi_format, 2D resource, misc, array size, straight alpha
|
||||||
|
struct.pack("<5I", dxgi_format, 3, 0, 0, 1)
|
||||||
|
)
|
||||||
|
ImageFile._save(im, fp, [ImageFile._Tile(codec_name, (0, 0) + im.size, 0, args)])
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return prefix.startswith(b"DDS ")
|
||||||
|
|
||||||
|
|
||||||
|
Image.register_open(DdsImageFile.format, DdsImageFile, _accept)
|
||||||
|
Image.register_decoder("dds_rgb", DdsRgbDecoder)
|
||||||
|
Image.register_save(DdsImageFile.format, _save)
|
||||||
|
Image.register_extension(DdsImageFile.format, ".dds")
|
||||||
|
|
@ -0,0 +1,476 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library.
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# EPS file handling
|
||||||
|
#
|
||||||
|
# History:
|
||||||
|
# 1995-09-01 fl Created (0.1)
|
||||||
|
# 1996-05-18 fl Don't choke on "atend" fields, Ghostscript interface (0.2)
|
||||||
|
# 1996-08-22 fl Don't choke on floating point BoundingBox values
|
||||||
|
# 1996-08-23 fl Handle files from Macintosh (0.3)
|
||||||
|
# 2001-02-17 fl Use 're' instead of 'regex' (Python 2.1) (0.4)
|
||||||
|
# 2003-09-07 fl Check gs.close status (from Federico Di Gregorio) (0.5)
|
||||||
|
# 2014-05-07 e Handling of EPS with binary preview and fixed resolution
|
||||||
|
# resizing
|
||||||
|
#
|
||||||
|
# Copyright (c) 1997-2003 by Secret Labs AB.
|
||||||
|
# Copyright (c) 1995-2003 by Fredrik Lundh
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from . import Image, ImageFile
|
||||||
|
from ._binary import i32le as i32
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$")
|
||||||
|
field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$")
|
||||||
|
|
||||||
|
gs_binary: str | bool | None = None
|
||||||
|
gs_windows_binary = None
|
||||||
|
|
||||||
|
|
||||||
|
def has_ghostscript() -> bool:
|
||||||
|
global gs_binary, gs_windows_binary
|
||||||
|
if gs_binary is None:
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
if gs_windows_binary is None:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
for binary in ("gswin32c", "gswin64c", "gs"):
|
||||||
|
if shutil.which(binary) is not None:
|
||||||
|
gs_windows_binary = binary
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
gs_windows_binary = False
|
||||||
|
gs_binary = gs_windows_binary
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
subprocess.check_call(["gs", "--version"], stdout=subprocess.DEVNULL)
|
||||||
|
gs_binary = "gs"
|
||||||
|
except OSError:
|
||||||
|
gs_binary = False
|
||||||
|
return gs_binary is not False
|
||||||
|
|
||||||
|
|
||||||
|
def Ghostscript(
|
||||||
|
tile: list[ImageFile._Tile],
|
||||||
|
size: tuple[int, int],
|
||||||
|
fp: IO[bytes],
|
||||||
|
scale: int = 1,
|
||||||
|
transparency: bool = False,
|
||||||
|
) -> Image.core.ImagingCore:
|
||||||
|
"""Render an image using Ghostscript"""
|
||||||
|
global gs_binary
|
||||||
|
if not has_ghostscript():
|
||||||
|
msg = "Unable to locate Ghostscript on paths"
|
||||||
|
raise OSError(msg)
|
||||||
|
assert isinstance(gs_binary, str)
|
||||||
|
|
||||||
|
# Unpack decoder tile
|
||||||
|
args = tile[0].args
|
||||||
|
assert isinstance(args, tuple)
|
||||||
|
length, bbox = args
|
||||||
|
|
||||||
|
# Hack to support hi-res rendering
|
||||||
|
scale = int(scale) or 1
|
||||||
|
width = size[0] * scale
|
||||||
|
height = size[1] * scale
|
||||||
|
# resolution is dependent on bbox and size
|
||||||
|
res_x = 72.0 * width / (bbox[2] - bbox[0])
|
||||||
|
res_y = 72.0 * height / (bbox[3] - bbox[1])
|
||||||
|
|
||||||
|
out_fd, outfile = tempfile.mkstemp()
|
||||||
|
os.close(out_fd)
|
||||||
|
|
||||||
|
infile_temp = None
|
||||||
|
if hasattr(fp, "name") and os.path.exists(fp.name):
|
||||||
|
infile = fp.name
|
||||||
|
else:
|
||||||
|
in_fd, infile_temp = tempfile.mkstemp()
|
||||||
|
os.close(in_fd)
|
||||||
|
infile = infile_temp
|
||||||
|
|
||||||
|
# Ignore length and offset!
|
||||||
|
# Ghostscript can read it
|
||||||
|
# Copy whole file to read in Ghostscript
|
||||||
|
with open(infile_temp, "wb") as f:
|
||||||
|
# fetch length of fp
|
||||||
|
fp.seek(0, io.SEEK_END)
|
||||||
|
fsize = fp.tell()
|
||||||
|
# ensure start position
|
||||||
|
# go back
|
||||||
|
fp.seek(0)
|
||||||
|
lengthfile = fsize
|
||||||
|
while lengthfile > 0:
|
||||||
|
s = fp.read(min(lengthfile, 100 * 1024))
|
||||||
|
if not s:
|
||||||
|
break
|
||||||
|
lengthfile -= len(s)
|
||||||
|
f.write(s)
|
||||||
|
|
||||||
|
if transparency:
|
||||||
|
# "RGBA"
|
||||||
|
device = "pngalpha"
|
||||||
|
else:
|
||||||
|
# "pnmraw" automatically chooses between
|
||||||
|
# PBM ("1"), PGM ("L"), and PPM ("RGB").
|
||||||
|
device = "pnmraw"
|
||||||
|
|
||||||
|
# Build Ghostscript command
|
||||||
|
command = [
|
||||||
|
gs_binary,
|
||||||
|
"-q", # quiet mode
|
||||||
|
f"-g{width:d}x{height:d}", # set output geometry (pixels)
|
||||||
|
f"-r{res_x:f}x{res_y:f}", # set input DPI (dots per inch)
|
||||||
|
"-dBATCH", # exit after processing
|
||||||
|
"-dNOPAUSE", # don't pause between pages
|
||||||
|
"-dSAFER", # safe mode
|
||||||
|
f"-sDEVICE={device}",
|
||||||
|
f"-sOutputFile={outfile}", # output file
|
||||||
|
# adjust for image origin
|
||||||
|
"-c",
|
||||||
|
f"{-bbox[0]} {-bbox[1]} translate",
|
||||||
|
"-f",
|
||||||
|
infile, # input file
|
||||||
|
# showpage (see https://bugs.ghostscript.com/show_bug.cgi?id=698272)
|
||||||
|
"-c",
|
||||||
|
"showpage",
|
||||||
|
]
|
||||||
|
|
||||||
|
# push data through Ghostscript
|
||||||
|
try:
|
||||||
|
startupinfo = None
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
startupinfo = subprocess.STARTUPINFO()
|
||||||
|
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
||||||
|
subprocess.check_call(command, startupinfo=startupinfo)
|
||||||
|
with Image.open(outfile) as out_im:
|
||||||
|
out_im.load()
|
||||||
|
return out_im.im.copy()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(outfile)
|
||||||
|
if infile_temp:
|
||||||
|
os.unlink(infile_temp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return prefix.startswith(b"%!PS") or (
|
||||||
|
len(prefix) >= 4 and i32(prefix) == 0xC6D3D0C5
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
# Image plugin for Encapsulated PostScript. This plugin supports only
|
||||||
|
# a few variants of this format.
|
||||||
|
|
||||||
|
|
||||||
|
class EpsImageFile(ImageFile.ImageFile):
|
||||||
|
"""EPS File Parser for the Python Imaging Library"""
|
||||||
|
|
||||||
|
format = "EPS"
|
||||||
|
format_description = "Encapsulated Postscript"
|
||||||
|
|
||||||
|
mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"}
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
(length, offset) = self._find_offset(self.fp)
|
||||||
|
|
||||||
|
# go to offset - start of "%!PS"
|
||||||
|
self.fp.seek(offset)
|
||||||
|
|
||||||
|
self._mode = "RGB"
|
||||||
|
|
||||||
|
# When reading header comments, the first comment is used.
|
||||||
|
# When reading trailer comments, the last comment is used.
|
||||||
|
bounding_box: list[int] | None = None
|
||||||
|
imagedata_size: tuple[int, int] | None = None
|
||||||
|
|
||||||
|
byte_arr = bytearray(255)
|
||||||
|
bytes_mv = memoryview(byte_arr)
|
||||||
|
bytes_read = 0
|
||||||
|
reading_header_comments = True
|
||||||
|
reading_trailer_comments = False
|
||||||
|
trailer_reached = False
|
||||||
|
|
||||||
|
def check_required_header_comments() -> None:
|
||||||
|
"""
|
||||||
|
The EPS specification requires that some headers exist.
|
||||||
|
This should be checked when the header comments formally end,
|
||||||
|
when image data starts, or when the file ends, whichever comes first.
|
||||||
|
"""
|
||||||
|
if "PS-Adobe" not in self.info:
|
||||||
|
msg = 'EPS header missing "%!PS-Adobe" comment'
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
if "BoundingBox" not in self.info:
|
||||||
|
msg = 'EPS header missing "%%BoundingBox" comment'
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
def read_comment(s: str) -> bool:
|
||||||
|
nonlocal bounding_box, reading_trailer_comments
|
||||||
|
try:
|
||||||
|
m = split.match(s)
|
||||||
|
except re.error as e:
|
||||||
|
msg = "not an EPS file"
|
||||||
|
raise SyntaxError(msg) from e
|
||||||
|
|
||||||
|
if not m:
|
||||||
|
return False
|
||||||
|
|
||||||
|
k, v = m.group(1, 2)
|
||||||
|
self.info[k] = v
|
||||||
|
if k == "BoundingBox":
|
||||||
|
if v == "(atend)":
|
||||||
|
reading_trailer_comments = True
|
||||||
|
elif not bounding_box or (trailer_reached and reading_trailer_comments):
|
||||||
|
try:
|
||||||
|
# Note: The DSC spec says that BoundingBox
|
||||||
|
# fields should be integers, but some drivers
|
||||||
|
# put floating point values there anyway.
|
||||||
|
bounding_box = [int(float(i)) for i in v.split()]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
|
||||||
|
while True:
|
||||||
|
byte = self.fp.read(1)
|
||||||
|
if byte == b"":
|
||||||
|
# if we didn't read a byte we must be at the end of the file
|
||||||
|
if bytes_read == 0:
|
||||||
|
if reading_header_comments:
|
||||||
|
check_required_header_comments()
|
||||||
|
break
|
||||||
|
elif byte in b"\r\n":
|
||||||
|
# if we read a line ending character, ignore it and parse what
|
||||||
|
# we have already read. if we haven't read any other characters,
|
||||||
|
# continue reading
|
||||||
|
if bytes_read == 0:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# ASCII/hexadecimal lines in an EPS file must not exceed
|
||||||
|
# 255 characters, not including line ending characters
|
||||||
|
if bytes_read >= 255:
|
||||||
|
# only enforce this for lines starting with a "%",
|
||||||
|
# otherwise assume it's binary data
|
||||||
|
if byte_arr[0] == ord("%"):
|
||||||
|
msg = "not an EPS file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
else:
|
||||||
|
if reading_header_comments:
|
||||||
|
check_required_header_comments()
|
||||||
|
reading_header_comments = False
|
||||||
|
# reset bytes_read so we can keep reading
|
||||||
|
# data until the end of the line
|
||||||
|
bytes_read = 0
|
||||||
|
byte_arr[bytes_read] = byte[0]
|
||||||
|
bytes_read += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if reading_header_comments:
|
||||||
|
# Load EPS header
|
||||||
|
|
||||||
|
# if this line doesn't start with a "%",
|
||||||
|
# or does start with "%%EndComments",
|
||||||
|
# then we've reached the end of the header/comments
|
||||||
|
if byte_arr[0] != ord("%") or bytes_mv[:13] == b"%%EndComments":
|
||||||
|
check_required_header_comments()
|
||||||
|
reading_header_comments = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
s = str(bytes_mv[:bytes_read], "latin-1")
|
||||||
|
if not read_comment(s):
|
||||||
|
m = field.match(s)
|
||||||
|
if m:
|
||||||
|
k = m.group(1)
|
||||||
|
if k.startswith("PS-Adobe"):
|
||||||
|
self.info["PS-Adobe"] = k[9:]
|
||||||
|
else:
|
||||||
|
self.info[k] = ""
|
||||||
|
elif s[0] == "%":
|
||||||
|
# handle non-DSC PostScript comments that some
|
||||||
|
# tools mistakenly put in the Comments section
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
msg = "bad EPS header"
|
||||||
|
raise OSError(msg)
|
||||||
|
elif bytes_mv[:11] == b"%ImageData:":
|
||||||
|
# Check for an "ImageData" descriptor
|
||||||
|
# https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577413_pgfId-1035096
|
||||||
|
|
||||||
|
# If we've already read an "ImageData" descriptor,
|
||||||
|
# don't read another one.
|
||||||
|
if imagedata_size:
|
||||||
|
bytes_read = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Values:
|
||||||
|
# columns
|
||||||
|
# rows
|
||||||
|
# bit depth (1 or 8)
|
||||||
|
# mode (1: L, 2: LAB, 3: RGB, 4: CMYK)
|
||||||
|
# number of padding channels
|
||||||
|
# block size (number of bytes per row per channel)
|
||||||
|
# binary/ascii (1: binary, 2: ascii)
|
||||||
|
# data start identifier (the image data follows after a single line
|
||||||
|
# consisting only of this quoted value)
|
||||||
|
image_data_values = byte_arr[11:bytes_read].split(None, 7)
|
||||||
|
columns, rows, bit_depth, mode_id = (
|
||||||
|
int(value) for value in image_data_values[:4]
|
||||||
|
)
|
||||||
|
|
||||||
|
if bit_depth == 1:
|
||||||
|
self._mode = "1"
|
||||||
|
elif bit_depth == 8:
|
||||||
|
try:
|
||||||
|
self._mode = self.mode_map[mode_id]
|
||||||
|
except ValueError:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Parse the columns and rows after checking the bit depth and mode
|
||||||
|
# in case the bit depth and/or mode are invalid.
|
||||||
|
imagedata_size = columns, rows
|
||||||
|
elif bytes_mv[:5] == b"%%EOF":
|
||||||
|
break
|
||||||
|
elif trailer_reached and reading_trailer_comments:
|
||||||
|
# Load EPS trailer
|
||||||
|
s = str(bytes_mv[:bytes_read], "latin-1")
|
||||||
|
read_comment(s)
|
||||||
|
elif bytes_mv[:9] == b"%%Trailer":
|
||||||
|
trailer_reached = True
|
||||||
|
bytes_read = 0
|
||||||
|
|
||||||
|
# A "BoundingBox" is always required,
|
||||||
|
# even if an "ImageData" descriptor size exists.
|
||||||
|
if not bounding_box:
|
||||||
|
msg = "cannot determine EPS bounding box"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
# An "ImageData" size takes precedence over the "BoundingBox".
|
||||||
|
self._size = imagedata_size or (
|
||||||
|
bounding_box[2] - bounding_box[0],
|
||||||
|
bounding_box[3] - bounding_box[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.tile = [
|
||||||
|
ImageFile._Tile("eps", (0, 0) + self.size, offset, (length, bounding_box))
|
||||||
|
]
|
||||||
|
|
||||||
|
def _find_offset(self, fp: IO[bytes]) -> tuple[int, int]:
|
||||||
|
s = fp.read(4)
|
||||||
|
|
||||||
|
if s == b"%!PS":
|
||||||
|
# for HEAD without binary preview
|
||||||
|
fp.seek(0, io.SEEK_END)
|
||||||
|
length = fp.tell()
|
||||||
|
offset = 0
|
||||||
|
elif i32(s) == 0xC6D3D0C5:
|
||||||
|
# FIX for: Some EPS file not handled correctly / issue #302
|
||||||
|
# EPS can contain binary data
|
||||||
|
# or start directly with latin coding
|
||||||
|
# more info see:
|
||||||
|
# https://web.archive.org/web/20160528181353/http://partners.adobe.com/public/developer/en/ps/5002.EPSF_Spec.pdf
|
||||||
|
s = fp.read(8)
|
||||||
|
offset = i32(s)
|
||||||
|
length = i32(s, 4)
|
||||||
|
else:
|
||||||
|
msg = "not an EPS file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
return length, offset
|
||||||
|
|
||||||
|
def load(
|
||||||
|
self, scale: int = 1, transparency: bool = False
|
||||||
|
) -> Image.core.PixelAccess | None:
|
||||||
|
# Load EPS via Ghostscript
|
||||||
|
if self.tile:
|
||||||
|
self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency)
|
||||||
|
self._mode = self.im.mode
|
||||||
|
self._size = self.im.size
|
||||||
|
self.tile = []
|
||||||
|
return Image.Image.load(self)
|
||||||
|
|
||||||
|
def load_seek(self, pos: int) -> None:
|
||||||
|
# we can't incrementally load, so force ImageFile.parser to
|
||||||
|
# use our custom load method by defining this method.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes, eps: int = 1) -> None:
|
||||||
|
"""EPS Writer for the Python Imaging Library."""
|
||||||
|
|
||||||
|
# make sure image data is available
|
||||||
|
im.load()
|
||||||
|
|
||||||
|
# determine PostScript image mode
|
||||||
|
if im.mode == "L":
|
||||||
|
operator = (8, 1, b"image")
|
||||||
|
elif im.mode == "RGB":
|
||||||
|
operator = (8, 3, b"false 3 colorimage")
|
||||||
|
elif im.mode == "CMYK":
|
||||||
|
operator = (8, 4, b"false 4 colorimage")
|
||||||
|
else:
|
||||||
|
msg = "image mode is not supported"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
if eps:
|
||||||
|
# write EPS header
|
||||||
|
fp.write(b"%!PS-Adobe-3.0 EPSF-3.0\n")
|
||||||
|
fp.write(b"%%Creator: PIL 0.1 EpsEncode\n")
|
||||||
|
# fp.write("%%CreationDate: %s"...)
|
||||||
|
fp.write(b"%%%%BoundingBox: 0 0 %d %d\n" % im.size)
|
||||||
|
fp.write(b"%%Pages: 1\n")
|
||||||
|
fp.write(b"%%EndComments\n")
|
||||||
|
fp.write(b"%%Page: 1 1\n")
|
||||||
|
fp.write(b"%%ImageData: %d %d " % im.size)
|
||||||
|
fp.write(b'%d %d 0 1 1 "%s"\n' % operator)
|
||||||
|
|
||||||
|
# image header
|
||||||
|
fp.write(b"gsave\n")
|
||||||
|
fp.write(b"10 dict begin\n")
|
||||||
|
fp.write(b"/buf %d string def\n" % (im.size[0] * operator[1]))
|
||||||
|
fp.write(b"%d %d scale\n" % im.size)
|
||||||
|
fp.write(b"%d %d 8\n" % im.size) # <= bits
|
||||||
|
fp.write(b"[%d 0 0 -%d 0 %d]\n" % (im.size[0], im.size[1], im.size[1]))
|
||||||
|
fp.write(b"{ currentfile buf readhexstring pop } bind\n")
|
||||||
|
fp.write(operator[2] + b"\n")
|
||||||
|
if hasattr(fp, "flush"):
|
||||||
|
fp.flush()
|
||||||
|
|
||||||
|
ImageFile._save(im, fp, [ImageFile._Tile("eps", (0, 0) + im.size)])
|
||||||
|
|
||||||
|
fp.write(b"\n%%%%EndBinary\n")
|
||||||
|
fp.write(b"grestore end\n")
|
||||||
|
if hasattr(fp, "flush"):
|
||||||
|
fp.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
Image.register_open(EpsImageFile.format, EpsImageFile, _accept)
|
||||||
|
|
||||||
|
Image.register_save(EpsImageFile.format, _save)
|
||||||
|
|
||||||
|
Image.register_extensions(EpsImageFile.format, [".ps", ".eps"])
|
||||||
|
|
||||||
|
Image.register_mime(EpsImageFile.format, "application/postscript")
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library.
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# EXIF tags
|
||||||
|
#
|
||||||
|
# Copyright (c) 2003 by Secret Labs AB
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
This module provides constants and clear-text names for various
|
||||||
|
well-known EXIF tags.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
|
class Base(IntEnum):
|
||||||
|
# possibly incomplete
|
||||||
|
InteropIndex = 0x0001
|
||||||
|
ProcessingSoftware = 0x000B
|
||||||
|
NewSubfileType = 0x00FE
|
||||||
|
SubfileType = 0x00FF
|
||||||
|
ImageWidth = 0x0100
|
||||||
|
ImageLength = 0x0101
|
||||||
|
BitsPerSample = 0x0102
|
||||||
|
Compression = 0x0103
|
||||||
|
PhotometricInterpretation = 0x0106
|
||||||
|
Thresholding = 0x0107
|
||||||
|
CellWidth = 0x0108
|
||||||
|
CellLength = 0x0109
|
||||||
|
FillOrder = 0x010A
|
||||||
|
DocumentName = 0x010D
|
||||||
|
ImageDescription = 0x010E
|
||||||
|
Make = 0x010F
|
||||||
|
Model = 0x0110
|
||||||
|
StripOffsets = 0x0111
|
||||||
|
Orientation = 0x0112
|
||||||
|
SamplesPerPixel = 0x0115
|
||||||
|
RowsPerStrip = 0x0116
|
||||||
|
StripByteCounts = 0x0117
|
||||||
|
MinSampleValue = 0x0118
|
||||||
|
MaxSampleValue = 0x0119
|
||||||
|
XResolution = 0x011A
|
||||||
|
YResolution = 0x011B
|
||||||
|
PlanarConfiguration = 0x011C
|
||||||
|
PageName = 0x011D
|
||||||
|
FreeOffsets = 0x0120
|
||||||
|
FreeByteCounts = 0x0121
|
||||||
|
GrayResponseUnit = 0x0122
|
||||||
|
GrayResponseCurve = 0x0123
|
||||||
|
T4Options = 0x0124
|
||||||
|
T6Options = 0x0125
|
||||||
|
ResolutionUnit = 0x0128
|
||||||
|
PageNumber = 0x0129
|
||||||
|
TransferFunction = 0x012D
|
||||||
|
Software = 0x0131
|
||||||
|
DateTime = 0x0132
|
||||||
|
Artist = 0x013B
|
||||||
|
HostComputer = 0x013C
|
||||||
|
Predictor = 0x013D
|
||||||
|
WhitePoint = 0x013E
|
||||||
|
PrimaryChromaticities = 0x013F
|
||||||
|
ColorMap = 0x0140
|
||||||
|
HalftoneHints = 0x0141
|
||||||
|
TileWidth = 0x0142
|
||||||
|
TileLength = 0x0143
|
||||||
|
TileOffsets = 0x0144
|
||||||
|
TileByteCounts = 0x0145
|
||||||
|
SubIFDs = 0x014A
|
||||||
|
InkSet = 0x014C
|
||||||
|
InkNames = 0x014D
|
||||||
|
NumberOfInks = 0x014E
|
||||||
|
DotRange = 0x0150
|
||||||
|
TargetPrinter = 0x0151
|
||||||
|
ExtraSamples = 0x0152
|
||||||
|
SampleFormat = 0x0153
|
||||||
|
SMinSampleValue = 0x0154
|
||||||
|
SMaxSampleValue = 0x0155
|
||||||
|
TransferRange = 0x0156
|
||||||
|
ClipPath = 0x0157
|
||||||
|
XClipPathUnits = 0x0158
|
||||||
|
YClipPathUnits = 0x0159
|
||||||
|
Indexed = 0x015A
|
||||||
|
JPEGTables = 0x015B
|
||||||
|
OPIProxy = 0x015F
|
||||||
|
JPEGProc = 0x0200
|
||||||
|
JpegIFOffset = 0x0201
|
||||||
|
JpegIFByteCount = 0x0202
|
||||||
|
JpegRestartInterval = 0x0203
|
||||||
|
JpegLosslessPredictors = 0x0205
|
||||||
|
JpegPointTransforms = 0x0206
|
||||||
|
JpegQTables = 0x0207
|
||||||
|
JpegDCTables = 0x0208
|
||||||
|
JpegACTables = 0x0209
|
||||||
|
YCbCrCoefficients = 0x0211
|
||||||
|
YCbCrSubSampling = 0x0212
|
||||||
|
YCbCrPositioning = 0x0213
|
||||||
|
ReferenceBlackWhite = 0x0214
|
||||||
|
XMLPacket = 0x02BC
|
||||||
|
RelatedImageFileFormat = 0x1000
|
||||||
|
RelatedImageWidth = 0x1001
|
||||||
|
RelatedImageLength = 0x1002
|
||||||
|
Rating = 0x4746
|
||||||
|
RatingPercent = 0x4749
|
||||||
|
ImageID = 0x800D
|
||||||
|
CFARepeatPatternDim = 0x828D
|
||||||
|
BatteryLevel = 0x828F
|
||||||
|
Copyright = 0x8298
|
||||||
|
ExposureTime = 0x829A
|
||||||
|
FNumber = 0x829D
|
||||||
|
IPTCNAA = 0x83BB
|
||||||
|
ImageResources = 0x8649
|
||||||
|
ExifOffset = 0x8769
|
||||||
|
InterColorProfile = 0x8773
|
||||||
|
ExposureProgram = 0x8822
|
||||||
|
SpectralSensitivity = 0x8824
|
||||||
|
GPSInfo = 0x8825
|
||||||
|
ISOSpeedRatings = 0x8827
|
||||||
|
OECF = 0x8828
|
||||||
|
Interlace = 0x8829
|
||||||
|
TimeZoneOffset = 0x882A
|
||||||
|
SelfTimerMode = 0x882B
|
||||||
|
SensitivityType = 0x8830
|
||||||
|
StandardOutputSensitivity = 0x8831
|
||||||
|
RecommendedExposureIndex = 0x8832
|
||||||
|
ISOSpeed = 0x8833
|
||||||
|
ISOSpeedLatitudeyyy = 0x8834
|
||||||
|
ISOSpeedLatitudezzz = 0x8835
|
||||||
|
ExifVersion = 0x9000
|
||||||
|
DateTimeOriginal = 0x9003
|
||||||
|
DateTimeDigitized = 0x9004
|
||||||
|
OffsetTime = 0x9010
|
||||||
|
OffsetTimeOriginal = 0x9011
|
||||||
|
OffsetTimeDigitized = 0x9012
|
||||||
|
ComponentsConfiguration = 0x9101
|
||||||
|
CompressedBitsPerPixel = 0x9102
|
||||||
|
ShutterSpeedValue = 0x9201
|
||||||
|
ApertureValue = 0x9202
|
||||||
|
BrightnessValue = 0x9203
|
||||||
|
ExposureBiasValue = 0x9204
|
||||||
|
MaxApertureValue = 0x9205
|
||||||
|
SubjectDistance = 0x9206
|
||||||
|
MeteringMode = 0x9207
|
||||||
|
LightSource = 0x9208
|
||||||
|
Flash = 0x9209
|
||||||
|
FocalLength = 0x920A
|
||||||
|
Noise = 0x920D
|
||||||
|
ImageNumber = 0x9211
|
||||||
|
SecurityClassification = 0x9212
|
||||||
|
ImageHistory = 0x9213
|
||||||
|
TIFFEPStandardID = 0x9216
|
||||||
|
MakerNote = 0x927C
|
||||||
|
UserComment = 0x9286
|
||||||
|
SubsecTime = 0x9290
|
||||||
|
SubsecTimeOriginal = 0x9291
|
||||||
|
SubsecTimeDigitized = 0x9292
|
||||||
|
AmbientTemperature = 0x9400
|
||||||
|
Humidity = 0x9401
|
||||||
|
Pressure = 0x9402
|
||||||
|
WaterDepth = 0x9403
|
||||||
|
Acceleration = 0x9404
|
||||||
|
CameraElevationAngle = 0x9405
|
||||||
|
XPTitle = 0x9C9B
|
||||||
|
XPComment = 0x9C9C
|
||||||
|
XPAuthor = 0x9C9D
|
||||||
|
XPKeywords = 0x9C9E
|
||||||
|
XPSubject = 0x9C9F
|
||||||
|
FlashPixVersion = 0xA000
|
||||||
|
ColorSpace = 0xA001
|
||||||
|
ExifImageWidth = 0xA002
|
||||||
|
ExifImageHeight = 0xA003
|
||||||
|
RelatedSoundFile = 0xA004
|
||||||
|
ExifInteroperabilityOffset = 0xA005
|
||||||
|
FlashEnergy = 0xA20B
|
||||||
|
SpatialFrequencyResponse = 0xA20C
|
||||||
|
FocalPlaneXResolution = 0xA20E
|
||||||
|
FocalPlaneYResolution = 0xA20F
|
||||||
|
FocalPlaneResolutionUnit = 0xA210
|
||||||
|
SubjectLocation = 0xA214
|
||||||
|
ExposureIndex = 0xA215
|
||||||
|
SensingMethod = 0xA217
|
||||||
|
FileSource = 0xA300
|
||||||
|
SceneType = 0xA301
|
||||||
|
CFAPattern = 0xA302
|
||||||
|
CustomRendered = 0xA401
|
||||||
|
ExposureMode = 0xA402
|
||||||
|
WhiteBalance = 0xA403
|
||||||
|
DigitalZoomRatio = 0xA404
|
||||||
|
FocalLengthIn35mmFilm = 0xA405
|
||||||
|
SceneCaptureType = 0xA406
|
||||||
|
GainControl = 0xA407
|
||||||
|
Contrast = 0xA408
|
||||||
|
Saturation = 0xA409
|
||||||
|
Sharpness = 0xA40A
|
||||||
|
DeviceSettingDescription = 0xA40B
|
||||||
|
SubjectDistanceRange = 0xA40C
|
||||||
|
ImageUniqueID = 0xA420
|
||||||
|
CameraOwnerName = 0xA430
|
||||||
|
BodySerialNumber = 0xA431
|
||||||
|
LensSpecification = 0xA432
|
||||||
|
LensMake = 0xA433
|
||||||
|
LensModel = 0xA434
|
||||||
|
LensSerialNumber = 0xA435
|
||||||
|
CompositeImage = 0xA460
|
||||||
|
CompositeImageCount = 0xA461
|
||||||
|
CompositeImageExposureTimes = 0xA462
|
||||||
|
Gamma = 0xA500
|
||||||
|
PrintImageMatching = 0xC4A5
|
||||||
|
DNGVersion = 0xC612
|
||||||
|
DNGBackwardVersion = 0xC613
|
||||||
|
UniqueCameraModel = 0xC614
|
||||||
|
LocalizedCameraModel = 0xC615
|
||||||
|
CFAPlaneColor = 0xC616
|
||||||
|
CFALayout = 0xC617
|
||||||
|
LinearizationTable = 0xC618
|
||||||
|
BlackLevelRepeatDim = 0xC619
|
||||||
|
BlackLevel = 0xC61A
|
||||||
|
BlackLevelDeltaH = 0xC61B
|
||||||
|
BlackLevelDeltaV = 0xC61C
|
||||||
|
WhiteLevel = 0xC61D
|
||||||
|
DefaultScale = 0xC61E
|
||||||
|
DefaultCropOrigin = 0xC61F
|
||||||
|
DefaultCropSize = 0xC620
|
||||||
|
ColorMatrix1 = 0xC621
|
||||||
|
ColorMatrix2 = 0xC622
|
||||||
|
CameraCalibration1 = 0xC623
|
||||||
|
CameraCalibration2 = 0xC624
|
||||||
|
ReductionMatrix1 = 0xC625
|
||||||
|
ReductionMatrix2 = 0xC626
|
||||||
|
AnalogBalance = 0xC627
|
||||||
|
AsShotNeutral = 0xC628
|
||||||
|
AsShotWhiteXY = 0xC629
|
||||||
|
BaselineExposure = 0xC62A
|
||||||
|
BaselineNoise = 0xC62B
|
||||||
|
BaselineSharpness = 0xC62C
|
||||||
|
BayerGreenSplit = 0xC62D
|
||||||
|
LinearResponseLimit = 0xC62E
|
||||||
|
CameraSerialNumber = 0xC62F
|
||||||
|
LensInfo = 0xC630
|
||||||
|
ChromaBlurRadius = 0xC631
|
||||||
|
AntiAliasStrength = 0xC632
|
||||||
|
ShadowScale = 0xC633
|
||||||
|
DNGPrivateData = 0xC634
|
||||||
|
MakerNoteSafety = 0xC635
|
||||||
|
CalibrationIlluminant1 = 0xC65A
|
||||||
|
CalibrationIlluminant2 = 0xC65B
|
||||||
|
BestQualityScale = 0xC65C
|
||||||
|
RawDataUniqueID = 0xC65D
|
||||||
|
OriginalRawFileName = 0xC68B
|
||||||
|
OriginalRawFileData = 0xC68C
|
||||||
|
ActiveArea = 0xC68D
|
||||||
|
MaskedAreas = 0xC68E
|
||||||
|
AsShotICCProfile = 0xC68F
|
||||||
|
AsShotPreProfileMatrix = 0xC690
|
||||||
|
CurrentICCProfile = 0xC691
|
||||||
|
CurrentPreProfileMatrix = 0xC692
|
||||||
|
ColorimetricReference = 0xC6BF
|
||||||
|
CameraCalibrationSignature = 0xC6F3
|
||||||
|
ProfileCalibrationSignature = 0xC6F4
|
||||||
|
AsShotProfileName = 0xC6F6
|
||||||
|
NoiseReductionApplied = 0xC6F7
|
||||||
|
ProfileName = 0xC6F8
|
||||||
|
ProfileHueSatMapDims = 0xC6F9
|
||||||
|
ProfileHueSatMapData1 = 0xC6FA
|
||||||
|
ProfileHueSatMapData2 = 0xC6FB
|
||||||
|
ProfileToneCurve = 0xC6FC
|
||||||
|
ProfileEmbedPolicy = 0xC6FD
|
||||||
|
ProfileCopyright = 0xC6FE
|
||||||
|
ForwardMatrix1 = 0xC714
|
||||||
|
ForwardMatrix2 = 0xC715
|
||||||
|
PreviewApplicationName = 0xC716
|
||||||
|
PreviewApplicationVersion = 0xC717
|
||||||
|
PreviewSettingsName = 0xC718
|
||||||
|
PreviewSettingsDigest = 0xC719
|
||||||
|
PreviewColorSpace = 0xC71A
|
||||||
|
PreviewDateTime = 0xC71B
|
||||||
|
RawImageDigest = 0xC71C
|
||||||
|
OriginalRawFileDigest = 0xC71D
|
||||||
|
SubTileBlockSize = 0xC71E
|
||||||
|
RowInterleaveFactor = 0xC71F
|
||||||
|
ProfileLookTableDims = 0xC725
|
||||||
|
ProfileLookTableData = 0xC726
|
||||||
|
OpcodeList1 = 0xC740
|
||||||
|
OpcodeList2 = 0xC741
|
||||||
|
OpcodeList3 = 0xC74E
|
||||||
|
NoiseProfile = 0xC761
|
||||||
|
|
||||||
|
|
||||||
|
"""Maps EXIF tags to tag names."""
|
||||||
|
TAGS = {
|
||||||
|
**{i.value: i.name for i in Base},
|
||||||
|
0x920C: "SpatialFrequencyResponse",
|
||||||
|
0x9214: "SubjectLocation",
|
||||||
|
0x9215: "ExposureIndex",
|
||||||
|
0x828E: "CFAPattern",
|
||||||
|
0x920B: "FlashEnergy",
|
||||||
|
0x9216: "TIFF/EPStandardID",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class GPS(IntEnum):
|
||||||
|
GPSVersionID = 0x00
|
||||||
|
GPSLatitudeRef = 0x01
|
||||||
|
GPSLatitude = 0x02
|
||||||
|
GPSLongitudeRef = 0x03
|
||||||
|
GPSLongitude = 0x04
|
||||||
|
GPSAltitudeRef = 0x05
|
||||||
|
GPSAltitude = 0x06
|
||||||
|
GPSTimeStamp = 0x07
|
||||||
|
GPSSatellites = 0x08
|
||||||
|
GPSStatus = 0x09
|
||||||
|
GPSMeasureMode = 0x0A
|
||||||
|
GPSDOP = 0x0B
|
||||||
|
GPSSpeedRef = 0x0C
|
||||||
|
GPSSpeed = 0x0D
|
||||||
|
GPSTrackRef = 0x0E
|
||||||
|
GPSTrack = 0x0F
|
||||||
|
GPSImgDirectionRef = 0x10
|
||||||
|
GPSImgDirection = 0x11
|
||||||
|
GPSMapDatum = 0x12
|
||||||
|
GPSDestLatitudeRef = 0x13
|
||||||
|
GPSDestLatitude = 0x14
|
||||||
|
GPSDestLongitudeRef = 0x15
|
||||||
|
GPSDestLongitude = 0x16
|
||||||
|
GPSDestBearingRef = 0x17
|
||||||
|
GPSDestBearing = 0x18
|
||||||
|
GPSDestDistanceRef = 0x19
|
||||||
|
GPSDestDistance = 0x1A
|
||||||
|
GPSProcessingMethod = 0x1B
|
||||||
|
GPSAreaInformation = 0x1C
|
||||||
|
GPSDateStamp = 0x1D
|
||||||
|
GPSDifferential = 0x1E
|
||||||
|
GPSHPositioningError = 0x1F
|
||||||
|
|
||||||
|
|
||||||
|
"""Maps EXIF GPS tags to tag names."""
|
||||||
|
GPSTAGS = {i.value: i.name for i in GPS}
|
||||||
|
|
||||||
|
|
||||||
|
class Interop(IntEnum):
|
||||||
|
InteropIndex = 0x0001
|
||||||
|
InteropVersion = 0x0002
|
||||||
|
RelatedImageFileFormat = 0x1000
|
||||||
|
RelatedImageWidth = 0x1001
|
||||||
|
RelatedImageHeight = 0x1002
|
||||||
|
|
||||||
|
|
||||||
|
class IFD(IntEnum):
|
||||||
|
Exif = 0x8769
|
||||||
|
GPSInfo = 0x8825
|
||||||
|
MakerNote = 0x927C
|
||||||
|
Makernote = 0x927C # Deprecated
|
||||||
|
Interop = 0xA005
|
||||||
|
IFD1 = -1
|
||||||
|
|
||||||
|
|
||||||
|
class LightSource(IntEnum):
|
||||||
|
Unknown = 0x00
|
||||||
|
Daylight = 0x01
|
||||||
|
Fluorescent = 0x02
|
||||||
|
Tungsten = 0x03
|
||||||
|
Flash = 0x04
|
||||||
|
Fine = 0x09
|
||||||
|
Cloudy = 0x0A
|
||||||
|
Shade = 0x0B
|
||||||
|
DaylightFluorescent = 0x0C
|
||||||
|
DayWhiteFluorescent = 0x0D
|
||||||
|
CoolWhiteFluorescent = 0x0E
|
||||||
|
WhiteFluorescent = 0x0F
|
||||||
|
StandardLightA = 0x11
|
||||||
|
StandardLightB = 0x12
|
||||||
|
StandardLightC = 0x13
|
||||||
|
D55 = 0x14
|
||||||
|
D65 = 0x15
|
||||||
|
D75 = 0x16
|
||||||
|
D50 = 0x17
|
||||||
|
ISO = 0x18
|
||||||
|
Other = 0xFF
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# FITS file handling
|
||||||
|
#
|
||||||
|
# Copyright (c) 1998-2003 by Fredrik Lundh
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import math
|
||||||
|
|
||||||
|
from . import Image, ImageFile
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return prefix.startswith(b"SIMPLE")
|
||||||
|
|
||||||
|
|
||||||
|
class FitsImageFile(ImageFile.ImageFile):
|
||||||
|
format = "FITS"
|
||||||
|
format_description = "FITS"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
assert self.fp is not None
|
||||||
|
|
||||||
|
headers: dict[bytes, bytes] = {}
|
||||||
|
header_in_progress = False
|
||||||
|
decoder_name = ""
|
||||||
|
while True:
|
||||||
|
header = self.fp.read(80)
|
||||||
|
if not header:
|
||||||
|
msg = "Truncated FITS file"
|
||||||
|
raise OSError(msg)
|
||||||
|
keyword = header[:8].strip()
|
||||||
|
if keyword in (b"SIMPLE", b"XTENSION"):
|
||||||
|
header_in_progress = True
|
||||||
|
elif headers and not header_in_progress:
|
||||||
|
# This is now a data unit
|
||||||
|
break
|
||||||
|
elif keyword == b"END":
|
||||||
|
# Seek to the end of the header unit
|
||||||
|
self.fp.seek(math.ceil(self.fp.tell() / 2880) * 2880)
|
||||||
|
if not decoder_name:
|
||||||
|
decoder_name, offset, args = self._parse_headers(headers)
|
||||||
|
|
||||||
|
header_in_progress = False
|
||||||
|
continue
|
||||||
|
|
||||||
|
if decoder_name:
|
||||||
|
# Keep going to read past the headers
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = header[8:].split(b"/")[0].strip()
|
||||||
|
if value.startswith(b"="):
|
||||||
|
value = value[1:].strip()
|
||||||
|
if not headers and (not _accept(keyword) or value != b"T"):
|
||||||
|
msg = "Not a FITS file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
headers[keyword] = value
|
||||||
|
|
||||||
|
if not decoder_name:
|
||||||
|
msg = "No image data"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
offset += self.fp.tell() - 80
|
||||||
|
self.tile = [ImageFile._Tile(decoder_name, (0, 0) + self.size, offset, args)]
|
||||||
|
|
||||||
|
def _get_size(
|
||||||
|
self, headers: dict[bytes, bytes], prefix: bytes
|
||||||
|
) -> tuple[int, int] | None:
|
||||||
|
naxis = int(headers[prefix + b"NAXIS"])
|
||||||
|
if naxis == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if naxis == 1:
|
||||||
|
return 1, int(headers[prefix + b"NAXIS1"])
|
||||||
|
else:
|
||||||
|
return int(headers[prefix + b"NAXIS1"]), int(headers[prefix + b"NAXIS2"])
|
||||||
|
|
||||||
|
def _parse_headers(
|
||||||
|
self, headers: dict[bytes, bytes]
|
||||||
|
) -> tuple[str, int, tuple[str | int, ...]]:
|
||||||
|
prefix = b""
|
||||||
|
decoder_name = "raw"
|
||||||
|
offset = 0
|
||||||
|
if (
|
||||||
|
headers.get(b"XTENSION") == b"'BINTABLE'"
|
||||||
|
and headers.get(b"ZIMAGE") == b"T"
|
||||||
|
and headers[b"ZCMPTYPE"] == b"'GZIP_1 '"
|
||||||
|
):
|
||||||
|
no_prefix_size = self._get_size(headers, prefix) or (0, 0)
|
||||||
|
number_of_bits = int(headers[b"BITPIX"])
|
||||||
|
offset = no_prefix_size[0] * no_prefix_size[1] * (number_of_bits // 8)
|
||||||
|
|
||||||
|
prefix = b"Z"
|
||||||
|
decoder_name = "fits_gzip"
|
||||||
|
|
||||||
|
size = self._get_size(headers, prefix)
|
||||||
|
if not size:
|
||||||
|
return "", 0, ()
|
||||||
|
|
||||||
|
self._size = size
|
||||||
|
|
||||||
|
number_of_bits = int(headers[prefix + b"BITPIX"])
|
||||||
|
if number_of_bits == 8:
|
||||||
|
self._mode = "L"
|
||||||
|
elif number_of_bits == 16:
|
||||||
|
self._mode = "I;16"
|
||||||
|
elif number_of_bits == 32:
|
||||||
|
self._mode = "I"
|
||||||
|
elif number_of_bits in (-32, -64):
|
||||||
|
self._mode = "F"
|
||||||
|
|
||||||
|
args: tuple[str | int, ...]
|
||||||
|
if decoder_name == "raw":
|
||||||
|
args = (self.mode, 0, -1)
|
||||||
|
else:
|
||||||
|
args = (number_of_bits,)
|
||||||
|
return decoder_name, offset, args
|
||||||
|
|
||||||
|
|
||||||
|
class FitsGzipDecoder(ImageFile.PyDecoder):
|
||||||
|
_pulls_fd = True
|
||||||
|
|
||||||
|
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||||
|
assert self.fd is not None
|
||||||
|
value = gzip.decompress(self.fd.read())
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
offset = 0
|
||||||
|
number_of_bits = min(self.args[0] // 8, 4)
|
||||||
|
for y in range(self.state.ysize):
|
||||||
|
row = bytearray()
|
||||||
|
for x in range(self.state.xsize):
|
||||||
|
row += value[offset + (4 - number_of_bits) : offset + 4]
|
||||||
|
offset += 4
|
||||||
|
rows.append(row)
|
||||||
|
self.set_as_raw(bytes([pixel for row in rows[::-1] for pixel in row]))
|
||||||
|
return -1, 0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
|
||||||
|
Image.register_open(FitsImageFile.format, FitsImageFile, _accept)
|
||||||
|
Image.register_decoder("fits_gzip", FitsGzipDecoder)
|
||||||
|
|
||||||
|
Image.register_extensions(FitsImageFile.format, [".fit", ".fits"])
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library.
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# FLI/FLC file handling.
|
||||||
|
#
|
||||||
|
# History:
|
||||||
|
# 95-09-01 fl Created
|
||||||
|
# 97-01-03 fl Fixed parser, setup decoder tile
|
||||||
|
# 98-07-15 fl Renamed offset attribute to avoid name clash
|
||||||
|
#
|
||||||
|
# Copyright (c) Secret Labs AB 1997-98.
|
||||||
|
# Copyright (c) Fredrik Lundh 1995-97.
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from . import Image, ImageFile, ImagePalette
|
||||||
|
from ._binary import i16le as i16
|
||||||
|
from ._binary import i32le as i32
|
||||||
|
from ._binary import o8
|
||||||
|
from ._util import DeferredError
|
||||||
|
|
||||||
|
#
|
||||||
|
# decoder
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return (
|
||||||
|
len(prefix) >= 6
|
||||||
|
and i16(prefix, 4) in [0xAF11, 0xAF12]
|
||||||
|
and i16(prefix, 14) in [0, 3] # flags
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
# Image plugin for the FLI/FLC animation format. Use the <b>seek</b>
|
||||||
|
# method to load individual frames.
|
||||||
|
|
||||||
|
|
||||||
|
class FliImageFile(ImageFile.ImageFile):
|
||||||
|
format = "FLI"
|
||||||
|
format_description = "Autodesk FLI/FLC Animation"
|
||||||
|
_close_exclusive_fp_after_loading = False
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
# HEAD
|
||||||
|
s = self.fp.read(128)
|
||||||
|
if not (_accept(s) and s[20:22] == b"\x00\x00"):
|
||||||
|
msg = "not an FLI/FLC file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
# frames
|
||||||
|
self.n_frames = i16(s, 6)
|
||||||
|
self.is_animated = self.n_frames > 1
|
||||||
|
|
||||||
|
# image characteristics
|
||||||
|
self._mode = "P"
|
||||||
|
self._size = i16(s, 8), i16(s, 10)
|
||||||
|
|
||||||
|
# animation speed
|
||||||
|
duration = i32(s, 16)
|
||||||
|
magic = i16(s, 4)
|
||||||
|
if magic == 0xAF11:
|
||||||
|
duration = (duration * 1000) // 70
|
||||||
|
self.info["duration"] = duration
|
||||||
|
|
||||||
|
# look for palette
|
||||||
|
palette = [(a, a, a) for a in range(256)]
|
||||||
|
|
||||||
|
s = self.fp.read(16)
|
||||||
|
|
||||||
|
self.__offset = 128
|
||||||
|
|
||||||
|
if i16(s, 4) == 0xF100:
|
||||||
|
# prefix chunk; ignore it
|
||||||
|
self.__offset = self.__offset + i32(s)
|
||||||
|
self.fp.seek(self.__offset)
|
||||||
|
s = self.fp.read(16)
|
||||||
|
|
||||||
|
if i16(s, 4) == 0xF1FA:
|
||||||
|
# look for palette chunk
|
||||||
|
number_of_subchunks = i16(s, 6)
|
||||||
|
chunk_size: int | None = None
|
||||||
|
for _ in range(number_of_subchunks):
|
||||||
|
if chunk_size is not None:
|
||||||
|
self.fp.seek(chunk_size - 6, os.SEEK_CUR)
|
||||||
|
s = self.fp.read(6)
|
||||||
|
chunk_type = i16(s, 4)
|
||||||
|
if chunk_type in (4, 11):
|
||||||
|
self._palette(palette, 2 if chunk_type == 11 else 0)
|
||||||
|
break
|
||||||
|
chunk_size = i32(s)
|
||||||
|
if not chunk_size:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.palette = ImagePalette.raw(
|
||||||
|
"RGB", b"".join(o8(r) + o8(g) + o8(b) for (r, g, b) in palette)
|
||||||
|
)
|
||||||
|
|
||||||
|
# set things up to decode first frame
|
||||||
|
self.__frame = -1
|
||||||
|
self._fp = self.fp
|
||||||
|
self.__rewind = self.fp.tell()
|
||||||
|
self.seek(0)
|
||||||
|
|
||||||
|
def _palette(self, palette: list[tuple[int, int, int]], shift: int) -> None:
|
||||||
|
# load palette
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
for e in range(i16(self.fp.read(2))):
|
||||||
|
s = self.fp.read(2)
|
||||||
|
i = i + s[0]
|
||||||
|
n = s[1]
|
||||||
|
if n == 0:
|
||||||
|
n = 256
|
||||||
|
s = self.fp.read(n * 3)
|
||||||
|
for n in range(0, len(s), 3):
|
||||||
|
r = s[n] << shift
|
||||||
|
g = s[n + 1] << shift
|
||||||
|
b = s[n + 2] << shift
|
||||||
|
palette[i] = (r, g, b)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
def seek(self, frame: int) -> None:
|
||||||
|
if not self._seek_check(frame):
|
||||||
|
return
|
||||||
|
if frame < self.__frame:
|
||||||
|
self._seek(0)
|
||||||
|
|
||||||
|
for f in range(self.__frame + 1, frame + 1):
|
||||||
|
self._seek(f)
|
||||||
|
|
||||||
|
def _seek(self, frame: int) -> None:
|
||||||
|
if isinstance(self._fp, DeferredError):
|
||||||
|
raise self._fp.ex
|
||||||
|
if frame == 0:
|
||||||
|
self.__frame = -1
|
||||||
|
self._fp.seek(self.__rewind)
|
||||||
|
self.__offset = 128
|
||||||
|
else:
|
||||||
|
# ensure that the previous frame was loaded
|
||||||
|
self.load()
|
||||||
|
|
||||||
|
if frame != self.__frame + 1:
|
||||||
|
msg = f"cannot seek to frame {frame}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
self.__frame = frame
|
||||||
|
|
||||||
|
# move to next frame
|
||||||
|
self.fp = self._fp
|
||||||
|
self.fp.seek(self.__offset)
|
||||||
|
|
||||||
|
s = self.fp.read(4)
|
||||||
|
if not s:
|
||||||
|
msg = "missing frame size"
|
||||||
|
raise EOFError(msg)
|
||||||
|
|
||||||
|
framesize = i32(s)
|
||||||
|
|
||||||
|
self.decodermaxblock = framesize
|
||||||
|
self.tile = [ImageFile._Tile("fli", (0, 0) + self.size, self.__offset)]
|
||||||
|
|
||||||
|
self.__offset += framesize
|
||||||
|
|
||||||
|
def tell(self) -> int:
|
||||||
|
return self.__frame
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# registry
|
||||||
|
|
||||||
|
Image.register_open(FliImageFile.format, FliImageFile, _accept)
|
||||||
|
|
||||||
|
Image.register_extensions(FliImageFile.format, [".fli", ".flc"])
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# base class for raster font file parsers
|
||||||
|
#
|
||||||
|
# history:
|
||||||
|
# 1997-06-05 fl created
|
||||||
|
# 1997-08-19 fl restrict image width
|
||||||
|
#
|
||||||
|
# Copyright (c) 1997-1998 by Secret Labs AB
|
||||||
|
# Copyright (c) 1997-1998 by Fredrik Lundh
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import BinaryIO
|
||||||
|
|
||||||
|
from . import Image, _binary
|
||||||
|
|
||||||
|
WIDTH = 800
|
||||||
|
|
||||||
|
|
||||||
|
def puti16(
|
||||||
|
fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int]
|
||||||
|
) -> None:
|
||||||
|
"""Write network order (big-endian) 16-bit sequence"""
|
||||||
|
for v in values:
|
||||||
|
if v < 0:
|
||||||
|
v += 65536
|
||||||
|
fp.write(_binary.o16be(v))
|
||||||
|
|
||||||
|
|
||||||
|
class FontFile:
|
||||||
|
"""Base class for raster font file handlers."""
|
||||||
|
|
||||||
|
bitmap: Image.Image | None = None
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.info: dict[bytes, bytes | int] = {}
|
||||||
|
self.glyph: list[
|
||||||
|
tuple[
|
||||||
|
tuple[int, int],
|
||||||
|
tuple[int, int, int, int],
|
||||||
|
tuple[int, int, int, int],
|
||||||
|
Image.Image,
|
||||||
|
]
|
||||||
|
| None
|
||||||
|
] = [None] * 256
|
||||||
|
|
||||||
|
def __getitem__(self, ix: int) -> (
|
||||||
|
tuple[
|
||||||
|
tuple[int, int],
|
||||||
|
tuple[int, int, int, int],
|
||||||
|
tuple[int, int, int, int],
|
||||||
|
Image.Image,
|
||||||
|
]
|
||||||
|
| None
|
||||||
|
):
|
||||||
|
return self.glyph[ix]
|
||||||
|
|
||||||
|
def compile(self) -> None:
|
||||||
|
"""Create metrics and bitmap"""
|
||||||
|
|
||||||
|
if self.bitmap:
|
||||||
|
return
|
||||||
|
|
||||||
|
# create bitmap large enough to hold all data
|
||||||
|
h = w = maxwidth = 0
|
||||||
|
lines = 1
|
||||||
|
for glyph in self.glyph:
|
||||||
|
if glyph:
|
||||||
|
d, dst, src, im = glyph
|
||||||
|
h = max(h, src[3] - src[1])
|
||||||
|
w = w + (src[2] - src[0])
|
||||||
|
if w > WIDTH:
|
||||||
|
lines += 1
|
||||||
|
w = src[2] - src[0]
|
||||||
|
maxwidth = max(maxwidth, w)
|
||||||
|
|
||||||
|
xsize = maxwidth
|
||||||
|
ysize = lines * h
|
||||||
|
|
||||||
|
if xsize == 0 and ysize == 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.ysize = h
|
||||||
|
|
||||||
|
# paste glyphs into bitmap
|
||||||
|
self.bitmap = Image.new("1", (xsize, ysize))
|
||||||
|
self.metrics: list[
|
||||||
|
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]]
|
||||||
|
| None
|
||||||
|
] = [None] * 256
|
||||||
|
x = y = 0
|
||||||
|
for i in range(256):
|
||||||
|
glyph = self[i]
|
||||||
|
if glyph:
|
||||||
|
d, dst, src, im = glyph
|
||||||
|
xx = src[2] - src[0]
|
||||||
|
x0, y0 = x, y
|
||||||
|
x = x + xx
|
||||||
|
if x > WIDTH:
|
||||||
|
x, y = 0, y + h
|
||||||
|
x0, y0 = x, y
|
||||||
|
x = xx
|
||||||
|
s = src[0] + x0, src[1] + y0, src[2] + x0, src[3] + y0
|
||||||
|
self.bitmap.paste(im.crop(src), s)
|
||||||
|
self.metrics[i] = d, dst, s
|
||||||
|
|
||||||
|
def save(self, filename: str) -> None:
|
||||||
|
"""Save font"""
|
||||||
|
|
||||||
|
self.compile()
|
||||||
|
|
||||||
|
# font data
|
||||||
|
if not self.bitmap:
|
||||||
|
msg = "No bitmap created"
|
||||||
|
raise ValueError(msg)
|
||||||
|
self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG")
|
||||||
|
|
||||||
|
# font metrics
|
||||||
|
with open(os.path.splitext(filename)[0] + ".pil", "wb") as fp:
|
||||||
|
fp.write(b"PILfont\n")
|
||||||
|
fp.write(f";;;;;;{self.ysize};\n".encode("ascii")) # HACK!!!
|
||||||
|
fp.write(b"DATA\n")
|
||||||
|
for id in range(256):
|
||||||
|
m = self.metrics[id]
|
||||||
|
if not m:
|
||||||
|
puti16(fp, (0,) * 10)
|
||||||
|
else:
|
||||||
|
puti16(fp, m[0] + m[1] + m[2])
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
#
|
||||||
|
# THIS IS WORK IN PROGRESS
|
||||||
|
#
|
||||||
|
# The Python Imaging Library.
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# FlashPix support for PIL
|
||||||
|
#
|
||||||
|
# History:
|
||||||
|
# 97-01-25 fl Created (reads uncompressed RGB images only)
|
||||||
|
#
|
||||||
|
# Copyright (c) Secret Labs AB 1997.
|
||||||
|
# Copyright (c) Fredrik Lundh 1997.
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import olefile
|
||||||
|
|
||||||
|
from . import Image, ImageFile
|
||||||
|
from ._binary import i32le as i32
|
||||||
|
|
||||||
|
# we map from colour field tuples to (mode, rawmode) descriptors
|
||||||
|
MODES = {
|
||||||
|
# opacity
|
||||||
|
(0x00007FFE,): ("A", "L"),
|
||||||
|
# monochrome
|
||||||
|
(0x00010000,): ("L", "L"),
|
||||||
|
(0x00018000, 0x00017FFE): ("RGBA", "LA"),
|
||||||
|
# photo YCC
|
||||||
|
(0x00020000, 0x00020001, 0x00020002): ("RGB", "YCC;P"),
|
||||||
|
(0x00028000, 0x00028001, 0x00028002, 0x00027FFE): ("RGBA", "YCCA;P"),
|
||||||
|
# standard RGB (NIFRGB)
|
||||||
|
(0x00030000, 0x00030001, 0x00030002): ("RGB", "RGB"),
|
||||||
|
(0x00038000, 0x00038001, 0x00038002, 0x00037FFE): ("RGBA", "RGBA"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return prefix.startswith(olefile.MAGIC)
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
# Image plugin for the FlashPix images.
|
||||||
|
|
||||||
|
|
||||||
|
class FpxImageFile(ImageFile.ImageFile):
|
||||||
|
format = "FPX"
|
||||||
|
format_description = "FlashPix"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
#
|
||||||
|
# read the OLE directory and see if this is a likely
|
||||||
|
# to be a FlashPix file
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.ole = olefile.OleFileIO(self.fp)
|
||||||
|
except OSError as e:
|
||||||
|
msg = "not an FPX file; invalid OLE file"
|
||||||
|
raise SyntaxError(msg) from e
|
||||||
|
|
||||||
|
root = self.ole.root
|
||||||
|
if not root or root.clsid != "56616700-C154-11CE-8553-00AA00A1F95B":
|
||||||
|
msg = "not an FPX file; bad root CLSID"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
self._open_index(1)
|
||||||
|
|
||||||
|
def _open_index(self, index: int = 1) -> None:
|
||||||
|
#
|
||||||
|
# get the Image Contents Property Set
|
||||||
|
|
||||||
|
prop = self.ole.getproperties(
|
||||||
|
[f"Data Object Store {index:06d}", "\005Image Contents"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# size (highest resolution)
|
||||||
|
|
||||||
|
assert isinstance(prop[0x1000002], int)
|
||||||
|
assert isinstance(prop[0x1000003], int)
|
||||||
|
self._size = prop[0x1000002], prop[0x1000003]
|
||||||
|
|
||||||
|
size = max(self.size)
|
||||||
|
i = 1
|
||||||
|
while size > 64:
|
||||||
|
size = size // 2
|
||||||
|
i += 1
|
||||||
|
self.maxid = i - 1
|
||||||
|
|
||||||
|
# mode. instead of using a single field for this, flashpix
|
||||||
|
# requires you to specify the mode for each channel in each
|
||||||
|
# resolution subimage, and leaves it to the decoder to make
|
||||||
|
# sure that they all match. for now, we'll cheat and assume
|
||||||
|
# that this is always the case.
|
||||||
|
|
||||||
|
id = self.maxid << 16
|
||||||
|
|
||||||
|
s = prop[0x2000002 | id]
|
||||||
|
|
||||||
|
if not isinstance(s, bytes) or (bands := i32(s, 4)) > 4:
|
||||||
|
msg = "Invalid number of bands"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
# note: for now, we ignore the "uncalibrated" flag
|
||||||
|
colors = tuple(i32(s, 8 + i * 4) & 0x7FFFFFFF for i in range(bands))
|
||||||
|
|
||||||
|
self._mode, self.rawmode = MODES[colors]
|
||||||
|
|
||||||
|
# load JPEG tables, if any
|
||||||
|
self.jpeg = {}
|
||||||
|
for i in range(256):
|
||||||
|
id = 0x3000001 | (i << 16)
|
||||||
|
if id in prop:
|
||||||
|
self.jpeg[i] = prop[id]
|
||||||
|
|
||||||
|
self._open_subimage(1, self.maxid)
|
||||||
|
|
||||||
|
def _open_subimage(self, index: int = 1, subimage: int = 0) -> None:
|
||||||
|
#
|
||||||
|
# setup tile descriptors for a given subimage
|
||||||
|
|
||||||
|
stream = [
|
||||||
|
f"Data Object Store {index:06d}",
|
||||||
|
f"Resolution {subimage:04d}",
|
||||||
|
"Subimage 0000 Header",
|
||||||
|
]
|
||||||
|
|
||||||
|
fp = self.ole.openstream(stream)
|
||||||
|
|
||||||
|
# skip prefix
|
||||||
|
fp.read(28)
|
||||||
|
|
||||||
|
# header stream
|
||||||
|
s = fp.read(36)
|
||||||
|
|
||||||
|
size = i32(s, 4), i32(s, 8)
|
||||||
|
# tilecount = i32(s, 12)
|
||||||
|
tilesize = i32(s, 16), i32(s, 20)
|
||||||
|
# channels = i32(s, 24)
|
||||||
|
offset = i32(s, 28)
|
||||||
|
length = i32(s, 32)
|
||||||
|
|
||||||
|
if size != self.size:
|
||||||
|
msg = "subimage mismatch"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
# get tile descriptors
|
||||||
|
fp.seek(28 + offset)
|
||||||
|
s = fp.read(i32(s, 12) * length)
|
||||||
|
|
||||||
|
x = y = 0
|
||||||
|
xsize, ysize = size
|
||||||
|
xtile, ytile = tilesize
|
||||||
|
self.tile = []
|
||||||
|
|
||||||
|
for i in range(0, len(s), length):
|
||||||
|
x1 = min(xsize, x + xtile)
|
||||||
|
y1 = min(ysize, y + ytile)
|
||||||
|
|
||||||
|
compression = i32(s, i + 8)
|
||||||
|
|
||||||
|
if compression == 0:
|
||||||
|
self.tile.append(
|
||||||
|
ImageFile._Tile(
|
||||||
|
"raw",
|
||||||
|
(x, y, x1, y1),
|
||||||
|
i32(s, i) + 28,
|
||||||
|
self.rawmode,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif compression == 1:
|
||||||
|
# FIXME: the fill decoder is not implemented
|
||||||
|
self.tile.append(
|
||||||
|
ImageFile._Tile(
|
||||||
|
"fill",
|
||||||
|
(x, y, x1, y1),
|
||||||
|
i32(s, i) + 28,
|
||||||
|
(self.rawmode, s[12:16]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif compression == 2:
|
||||||
|
internal_color_conversion = s[14]
|
||||||
|
jpeg_tables = s[15]
|
||||||
|
rawmode = self.rawmode
|
||||||
|
|
||||||
|
if internal_color_conversion:
|
||||||
|
# The image is stored as usual (usually YCbCr).
|
||||||
|
if rawmode == "RGBA":
|
||||||
|
# For "RGBA", data is stored as YCbCrA based on
|
||||||
|
# negative RGB. The following trick works around
|
||||||
|
# this problem :
|
||||||
|
jpegmode, rawmode = "YCbCrK", "CMYK"
|
||||||
|
else:
|
||||||
|
jpegmode = None # let the decoder decide
|
||||||
|
|
||||||
|
else:
|
||||||
|
# The image is stored as defined by rawmode
|
||||||
|
jpegmode = rawmode
|
||||||
|
|
||||||
|
self.tile.append(
|
||||||
|
ImageFile._Tile(
|
||||||
|
"jpeg",
|
||||||
|
(x, y, x1, y1),
|
||||||
|
i32(s, i) + 28,
|
||||||
|
(rawmode, jpegmode),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# FIXME: jpeg tables are tile dependent; the prefix
|
||||||
|
# data must be placed in the tile descriptor itself!
|
||||||
|
|
||||||
|
if jpeg_tables:
|
||||||
|
self.tile_prefix = self.jpeg[jpeg_tables]
|
||||||
|
|
||||||
|
else:
|
||||||
|
msg = "unknown/invalid compression"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
x = x + xtile
|
||||||
|
if x >= xsize:
|
||||||
|
x, y = 0, y + ytile
|
||||||
|
if y >= ysize:
|
||||||
|
break # isn't really required
|
||||||
|
|
||||||
|
self.stream = stream
|
||||||
|
self._fp = self.fp
|
||||||
|
self.fp = None
|
||||||
|
|
||||||
|
def load(self) -> Image.core.PixelAccess | None:
|
||||||
|
if not self.fp:
|
||||||
|
self.fp = self.ole.openstream(self.stream[:2] + ["Subimage 0000 Data"])
|
||||||
|
|
||||||
|
return ImageFile.ImageFile.load(self)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.ole.close()
|
||||||
|
super().close()
|
||||||
|
|
||||||
|
def __exit__(self, *args: object) -> None:
|
||||||
|
self.ole.close()
|
||||||
|
super().__exit__()
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
Image.register_open(FpxImageFile.format, FpxImageFile, _accept)
|
||||||
|
|
||||||
|
Image.register_extension(FpxImageFile.format, ".fpx")
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""
|
||||||
|
A Pillow loader for .ftc and .ftu files (FTEX)
|
||||||
|
Jerome Leclanche <jerome@leclan.ch>
|
||||||
|
|
||||||
|
The contents of this file are hereby released in the public domain (CC0)
|
||||||
|
Full text of the CC0 license:
|
||||||
|
https://creativecommons.org/publicdomain/zero/1.0/
|
||||||
|
|
||||||
|
Independence War 2: Edge Of Chaos - Texture File Format - 16 October 2001
|
||||||
|
|
||||||
|
The textures used for 3D objects in Independence War 2: Edge Of Chaos are in a
|
||||||
|
packed custom format called FTEX. This file format uses file extensions FTC
|
||||||
|
and FTU.
|
||||||
|
* FTC files are compressed textures (using standard texture compression).
|
||||||
|
* FTU files are not compressed.
|
||||||
|
Texture File Format
|
||||||
|
The FTC and FTU texture files both use the same format. This
|
||||||
|
has the following structure:
|
||||||
|
{header}
|
||||||
|
{format_directory}
|
||||||
|
{data}
|
||||||
|
Where:
|
||||||
|
{header} = {
|
||||||
|
u32:magic,
|
||||||
|
u32:version,
|
||||||
|
u32:width,
|
||||||
|
u32:height,
|
||||||
|
u32:mipmap_count,
|
||||||
|
u32:format_count
|
||||||
|
}
|
||||||
|
|
||||||
|
* The "magic" number is "FTEX".
|
||||||
|
* "width" and "height" are the dimensions of the texture.
|
||||||
|
* "mipmap_count" is the number of mipmaps in the texture.
|
||||||
|
* "format_count" is the number of texture formats (different versions of the
|
||||||
|
same texture) in this file.
|
||||||
|
|
||||||
|
{format_directory} = format_count * { u32:format, u32:where }
|
||||||
|
|
||||||
|
The format value is 0 for DXT1 compressed textures and 1 for 24-bit RGB
|
||||||
|
uncompressed textures.
|
||||||
|
The texture data for a format starts at the position "where" in the file.
|
||||||
|
|
||||||
|
Each set of texture data in the file has the following structure:
|
||||||
|
{data} = format_count * { u32:mipmap_size, mipmap_size * { u8 } }
|
||||||
|
* "mipmap_size" is the number of bytes in that mip level. For compressed
|
||||||
|
textures this is the size of the texture data compressed with DXT1. For 24 bit
|
||||||
|
uncompressed textures, this is 3 * width * height. Following this are the image
|
||||||
|
bytes for that mipmap level.
|
||||||
|
|
||||||
|
Note: All data is stored in little-Endian (Intel) byte order.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from enum import IntEnum
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from . import Image, ImageFile
|
||||||
|
|
||||||
|
MAGIC = b"FTEX"
|
||||||
|
|
||||||
|
|
||||||
|
class Format(IntEnum):
|
||||||
|
DXT1 = 0
|
||||||
|
UNCOMPRESSED = 1
|
||||||
|
|
||||||
|
|
||||||
|
class FtexImageFile(ImageFile.ImageFile):
|
||||||
|
format = "FTEX"
|
||||||
|
format_description = "Texture File Format (IW2:EOC)"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
if not _accept(self.fp.read(4)):
|
||||||
|
msg = "not an FTEX file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
struct.unpack("<i", self.fp.read(4)) # version
|
||||||
|
self._size = struct.unpack("<2i", self.fp.read(8))
|
||||||
|
mipmap_count, format_count = struct.unpack("<2i", self.fp.read(8))
|
||||||
|
|
||||||
|
# Only support single-format files.
|
||||||
|
# I don't know of any multi-format file.
|
||||||
|
assert format_count == 1
|
||||||
|
|
||||||
|
format, where = struct.unpack("<2i", self.fp.read(8))
|
||||||
|
self.fp.seek(where)
|
||||||
|
(mipmap_size,) = struct.unpack("<i", self.fp.read(4))
|
||||||
|
|
||||||
|
data = self.fp.read(mipmap_size)
|
||||||
|
|
||||||
|
if format == Format.DXT1:
|
||||||
|
self._mode = "RGBA"
|
||||||
|
self.tile = [ImageFile._Tile("bcn", (0, 0) + self.size, 0, (1,))]
|
||||||
|
elif format == Format.UNCOMPRESSED:
|
||||||
|
self._mode = "RGB"
|
||||||
|
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, "RGB")]
|
||||||
|
else:
|
||||||
|
msg = f"Invalid texture compression format: {repr(format)}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
self.fp.close()
|
||||||
|
self.fp = BytesIO(data)
|
||||||
|
|
||||||
|
def load_seek(self, pos: int) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return prefix.startswith(MAGIC)
|
||||||
|
|
||||||
|
|
||||||
|
Image.register_open(FtexImageFile.format, FtexImageFile, _accept)
|
||||||
|
Image.register_extensions(FtexImageFile.format, [".ftc", ".ftu"])
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library
|
||||||
|
#
|
||||||
|
# load a GIMP brush file
|
||||||
|
#
|
||||||
|
# History:
|
||||||
|
# 96-03-14 fl Created
|
||||||
|
# 16-01-08 es Version 2
|
||||||
|
#
|
||||||
|
# Copyright (c) Secret Labs AB 1997.
|
||||||
|
# Copyright (c) Fredrik Lundh 1996.
|
||||||
|
# Copyright (c) Eric Soroos 2016.
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# See https://github.com/GNOME/gimp/blob/mainline/devel-docs/gbr.txt for
|
||||||
|
# format documentation.
|
||||||
|
#
|
||||||
|
# This code Interprets version 1 and 2 .gbr files.
|
||||||
|
# Version 1 files are obsolete, and should not be used for new
|
||||||
|
# brushes.
|
||||||
|
# Version 2 files are saved by GIMP v2.8 (at least)
|
||||||
|
# Version 3 files have a format specifier of 18 for 16bit floats in
|
||||||
|
# the color depth field. This is currently unsupported by Pillow.
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from . import Image, ImageFile
|
||||||
|
from ._binary import i32be as i32
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return len(prefix) >= 8 and i32(prefix, 0) >= 20 and i32(prefix, 4) in (1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
##
|
||||||
|
# Image plugin for the GIMP brush format.
|
||||||
|
|
||||||
|
|
||||||
|
class GbrImageFile(ImageFile.ImageFile):
|
||||||
|
format = "GBR"
|
||||||
|
format_description = "GIMP brush file"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
header_size = i32(self.fp.read(4))
|
||||||
|
if header_size < 20:
|
||||||
|
msg = "not a GIMP brush"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
version = i32(self.fp.read(4))
|
||||||
|
if version not in (1, 2):
|
||||||
|
msg = f"Unsupported GIMP brush version: {version}"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
width = i32(self.fp.read(4))
|
||||||
|
height = i32(self.fp.read(4))
|
||||||
|
color_depth = i32(self.fp.read(4))
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
msg = "not a GIMP brush"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
if color_depth not in (1, 4):
|
||||||
|
msg = f"Unsupported GIMP brush color depth: {color_depth}"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
if version == 1:
|
||||||
|
comment_length = header_size - 20
|
||||||
|
else:
|
||||||
|
comment_length = header_size - 28
|
||||||
|
magic_number = self.fp.read(4)
|
||||||
|
if magic_number != b"GIMP":
|
||||||
|
msg = "not a GIMP brush, bad magic number"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
self.info["spacing"] = i32(self.fp.read(4))
|
||||||
|
|
||||||
|
comment = self.fp.read(comment_length)[:-1]
|
||||||
|
|
||||||
|
if color_depth == 1:
|
||||||
|
self._mode = "L"
|
||||||
|
else:
|
||||||
|
self._mode = "RGBA"
|
||||||
|
|
||||||
|
self._size = width, height
|
||||||
|
|
||||||
|
self.info["comment"] = comment
|
||||||
|
|
||||||
|
# Image might not be small
|
||||||
|
Image._decompression_bomb_check(self.size)
|
||||||
|
|
||||||
|
# Data is an uncompressed block of w * h * bytes/pixel
|
||||||
|
self._data_size = width * height * color_depth
|
||||||
|
|
||||||
|
def load(self) -> Image.core.PixelAccess | None:
|
||||||
|
if self._im is None:
|
||||||
|
self.im = Image.core.new(self.mode, self.size)
|
||||||
|
self.frombytes(self.fp.read(self._data_size))
|
||||||
|
return Image.Image.load(self)
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# registry
|
||||||
|
|
||||||
|
|
||||||
|
Image.register_open(GbrImageFile.format, GbrImageFile, _accept)
|
||||||
|
Image.register_extension(GbrImageFile.format, ".gbr")
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library.
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# GD file handling
|
||||||
|
#
|
||||||
|
# History:
|
||||||
|
# 1996-04-12 fl Created
|
||||||
|
#
|
||||||
|
# Copyright (c) 1997 by Secret Labs AB.
|
||||||
|
# Copyright (c) 1996 by Fredrik Lundh.
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
.. note::
|
||||||
|
This format cannot be automatically recognized, so the
|
||||||
|
class is not registered for use with :py:func:`PIL.Image.open()`. To open a
|
||||||
|
gd file, use the :py:func:`PIL.GdImageFile.open()` function instead.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
THE GD FORMAT IS NOT DESIGNED FOR DATA INTERCHANGE. This
|
||||||
|
implementation is provided for convenience and demonstrational
|
||||||
|
purposes only.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from . import ImageFile, ImagePalette, UnidentifiedImageError
|
||||||
|
from ._binary import i16be as i16
|
||||||
|
from ._binary import i32be as i32
|
||||||
|
from ._typing import StrOrBytesPath
|
||||||
|
|
||||||
|
|
||||||
|
class GdImageFile(ImageFile.ImageFile):
|
||||||
|
"""
|
||||||
|
Image plugin for the GD uncompressed format. Note that this format
|
||||||
|
is not supported by the standard :py:func:`PIL.Image.open()` function. To use
|
||||||
|
this plugin, you have to import the :py:mod:`PIL.GdImageFile` module and
|
||||||
|
use the :py:func:`PIL.GdImageFile.open()` function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
format = "GD"
|
||||||
|
format_description = "GD uncompressed images"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
# Header
|
||||||
|
assert self.fp is not None
|
||||||
|
|
||||||
|
s = self.fp.read(1037)
|
||||||
|
|
||||||
|
if i16(s) not in [65534, 65535]:
|
||||||
|
msg = "Not a valid GD 2.x .gd file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
self._mode = "P"
|
||||||
|
self._size = i16(s, 2), i16(s, 4)
|
||||||
|
|
||||||
|
true_color = s[6]
|
||||||
|
true_color_offset = 2 if true_color else 0
|
||||||
|
|
||||||
|
# transparency index
|
||||||
|
tindex = i32(s, 7 + true_color_offset)
|
||||||
|
if tindex < 256:
|
||||||
|
self.info["transparency"] = tindex
|
||||||
|
|
||||||
|
self.palette = ImagePalette.raw(
|
||||||
|
"RGBX", s[7 + true_color_offset + 6 : 7 + true_color_offset + 6 + 256 * 4]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.tile = [
|
||||||
|
ImageFile._Tile(
|
||||||
|
"raw",
|
||||||
|
(0, 0) + self.size,
|
||||||
|
7 + true_color_offset + 6 + 256 * 4,
|
||||||
|
"L",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile:
|
||||||
|
"""
|
||||||
|
Load texture from a GD image file.
|
||||||
|
|
||||||
|
:param fp: GD file name, or an opened file handle.
|
||||||
|
:param mode: Optional mode. In this version, if the mode argument
|
||||||
|
is given, it must be "r".
|
||||||
|
:returns: An image instance.
|
||||||
|
:raises OSError: If the image could not be read.
|
||||||
|
"""
|
||||||
|
if mode != "r":
|
||||||
|
msg = "bad mode"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return GdImageFile(fp)
|
||||||
|
except SyntaxError as e:
|
||||||
|
msg = "cannot identify this image file"
|
||||||
|
raise UnidentifiedImageError(msg) from e
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,149 @@
|
||||||
|
#
|
||||||
|
# Python Imaging Library
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# stuff to read (and render) GIMP gradient files
|
||||||
|
#
|
||||||
|
# History:
|
||||||
|
# 97-08-23 fl Created
|
||||||
|
#
|
||||||
|
# Copyright (c) Secret Labs AB 1997.
|
||||||
|
# Copyright (c) Fredrik Lundh 1997.
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Stuff to translate curve segments to palette values (derived from
|
||||||
|
the corresponding code in GIMP, written by Federico Mena Quintero.
|
||||||
|
See the GIMP distribution for more information.)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from math import log, pi, sin, sqrt
|
||||||
|
from typing import IO, Callable
|
||||||
|
|
||||||
|
from ._binary import o8
|
||||||
|
|
||||||
|
EPSILON = 1e-10
|
||||||
|
"""""" # Enable auto-doc for data member
|
||||||
|
|
||||||
|
|
||||||
|
def linear(middle: float, pos: float) -> float:
|
||||||
|
if pos <= middle:
|
||||||
|
if middle < EPSILON:
|
||||||
|
return 0.0
|
||||||
|
else:
|
||||||
|
return 0.5 * pos / middle
|
||||||
|
else:
|
||||||
|
pos = pos - middle
|
||||||
|
middle = 1.0 - middle
|
||||||
|
if middle < EPSILON:
|
||||||
|
return 1.0
|
||||||
|
else:
|
||||||
|
return 0.5 + 0.5 * pos / middle
|
||||||
|
|
||||||
|
|
||||||
|
def curved(middle: float, pos: float) -> float:
|
||||||
|
return pos ** (log(0.5) / log(max(middle, EPSILON)))
|
||||||
|
|
||||||
|
|
||||||
|
def sine(middle: float, pos: float) -> float:
|
||||||
|
return (sin((-pi / 2.0) + pi * linear(middle, pos)) + 1.0) / 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def sphere_increasing(middle: float, pos: float) -> float:
|
||||||
|
return sqrt(1.0 - (linear(middle, pos) - 1.0) ** 2)
|
||||||
|
|
||||||
|
|
||||||
|
def sphere_decreasing(middle: float, pos: float) -> float:
|
||||||
|
return 1.0 - sqrt(1.0 - linear(middle, pos) ** 2)
|
||||||
|
|
||||||
|
|
||||||
|
SEGMENTS = [linear, curved, sine, sphere_increasing, sphere_decreasing]
|
||||||
|
"""""" # Enable auto-doc for data member
|
||||||
|
|
||||||
|
|
||||||
|
class GradientFile:
|
||||||
|
gradient: (
|
||||||
|
list[
|
||||||
|
tuple[
|
||||||
|
float,
|
||||||
|
float,
|
||||||
|
float,
|
||||||
|
list[float],
|
||||||
|
list[float],
|
||||||
|
Callable[[float, float], float],
|
||||||
|
]
|
||||||
|
]
|
||||||
|
| None
|
||||||
|
) = None
|
||||||
|
|
||||||
|
def getpalette(self, entries: int = 256) -> tuple[bytes, str]:
|
||||||
|
assert self.gradient is not None
|
||||||
|
palette = []
|
||||||
|
|
||||||
|
ix = 0
|
||||||
|
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
|
||||||
|
|
||||||
|
for i in range(entries):
|
||||||
|
x = i / (entries - 1)
|
||||||
|
|
||||||
|
while x1 < x:
|
||||||
|
ix += 1
|
||||||
|
x0, x1, xm, rgb0, rgb1, segment = self.gradient[ix]
|
||||||
|
|
||||||
|
w = x1 - x0
|
||||||
|
|
||||||
|
if w < EPSILON:
|
||||||
|
scale = segment(0.5, 0.5)
|
||||||
|
else:
|
||||||
|
scale = segment((xm - x0) / w, (x - x0) / w)
|
||||||
|
|
||||||
|
# expand to RGBA
|
||||||
|
r = o8(int(255 * ((rgb1[0] - rgb0[0]) * scale + rgb0[0]) + 0.5))
|
||||||
|
g = o8(int(255 * ((rgb1[1] - rgb0[1]) * scale + rgb0[1]) + 0.5))
|
||||||
|
b = o8(int(255 * ((rgb1[2] - rgb0[2]) * scale + rgb0[2]) + 0.5))
|
||||||
|
a = o8(int(255 * ((rgb1[3] - rgb0[3]) * scale + rgb0[3]) + 0.5))
|
||||||
|
|
||||||
|
# add to palette
|
||||||
|
palette.append(r + g + b + a)
|
||||||
|
|
||||||
|
return b"".join(palette), "RGBA"
|
||||||
|
|
||||||
|
|
||||||
|
class GimpGradientFile(GradientFile):
|
||||||
|
"""File handler for GIMP's gradient format."""
|
||||||
|
|
||||||
|
def __init__(self, fp: IO[bytes]) -> None:
|
||||||
|
if not fp.readline().startswith(b"GIMP Gradient"):
|
||||||
|
msg = "not a GIMP gradient file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
line = fp.readline()
|
||||||
|
|
||||||
|
# GIMP 1.2 gradient files don't contain a name, but GIMP 1.3 files do
|
||||||
|
if line.startswith(b"Name: "):
|
||||||
|
line = fp.readline().strip()
|
||||||
|
|
||||||
|
count = int(line)
|
||||||
|
|
||||||
|
self.gradient = []
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
s = fp.readline().split()
|
||||||
|
w = [float(x) for x in s[:11]]
|
||||||
|
|
||||||
|
x0, x1 = w[0], w[2]
|
||||||
|
xm = w[1]
|
||||||
|
rgb0 = w[3:7]
|
||||||
|
rgb1 = w[7:11]
|
||||||
|
|
||||||
|
segment = SEGMENTS[int(s[11])]
|
||||||
|
cspace = int(s[12])
|
||||||
|
|
||||||
|
if cspace != 0:
|
||||||
|
msg = "cannot handle HSV colour space"
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
self.gradient.append((x0, x1, xm, rgb0, rgb1, segment))
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
#
|
||||||
|
# Python Imaging Library
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# stuff to read GIMP palette files
|
||||||
|
#
|
||||||
|
# History:
|
||||||
|
# 1997-08-23 fl Created
|
||||||
|
# 2004-09-07 fl Support GIMP 2.0 palette files.
|
||||||
|
#
|
||||||
|
# Copyright (c) Secret Labs AB 1997-2004. All rights reserved.
|
||||||
|
# Copyright (c) Fredrik Lundh 1997-2004.
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
|
||||||
|
class GimpPaletteFile:
|
||||||
|
"""File handler for GIMP's palette format."""
|
||||||
|
|
||||||
|
rawmode = "RGB"
|
||||||
|
|
||||||
|
def _read(self, fp: IO[bytes], limit: bool = True) -> None:
|
||||||
|
if not fp.readline().startswith(b"GIMP Palette"):
|
||||||
|
msg = "not a GIMP palette file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
palette: list[int] = []
|
||||||
|
i = 0
|
||||||
|
while True:
|
||||||
|
if limit and i == 256 + 3:
|
||||||
|
break
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
s = fp.readline()
|
||||||
|
if not s:
|
||||||
|
break
|
||||||
|
|
||||||
|
# skip fields and comment lines
|
||||||
|
if re.match(rb"\w+:|#", s):
|
||||||
|
continue
|
||||||
|
if limit and len(s) > 100:
|
||||||
|
msg = "bad palette file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
v = s.split(maxsplit=3)
|
||||||
|
if len(v) < 3:
|
||||||
|
msg = "bad palette entry"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
palette += (int(v[i]) for i in range(3))
|
||||||
|
if limit and len(palette) == 768:
|
||||||
|
break
|
||||||
|
|
||||||
|
self.palette = bytes(palette)
|
||||||
|
|
||||||
|
def __init__(self, fp: IO[bytes]) -> None:
|
||||||
|
self._read(fp)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def frombytes(cls, data: bytes) -> GimpPaletteFile:
|
||||||
|
self = cls.__new__(cls)
|
||||||
|
self._read(BytesIO(data), False)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def getpalette(self) -> tuple[bytes, str]:
|
||||||
|
return self.palette, self.rawmode
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
#
|
||||||
|
# The Python Imaging Library
|
||||||
|
# $Id$
|
||||||
|
#
|
||||||
|
# GRIB stub adapter
|
||||||
|
#
|
||||||
|
# Copyright (c) 1996-2003 by Fredrik Lundh
|
||||||
|
#
|
||||||
|
# See the README file for information on usage and redistribution.
|
||||||
|
#
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from . import Image, ImageFile
|
||||||
|
|
||||||
|
_handler = None
|
||||||
|
|
||||||
|
|
||||||
|
def register_handler(handler: ImageFile.StubHandler | None) -> None:
|
||||||
|
"""
|
||||||
|
Install application-specific GRIB image handler.
|
||||||
|
|
||||||
|
:param handler: Handler object.
|
||||||
|
"""
|
||||||
|
global _handler
|
||||||
|
_handler = handler
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
# Image adapter
|
||||||
|
|
||||||
|
|
||||||
|
def _accept(prefix: bytes) -> bool:
|
||||||
|
return prefix.startswith(b"GRIB") and prefix[7] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class GribStubImageFile(ImageFile.StubImageFile):
|
||||||
|
format = "GRIB"
|
||||||
|
format_description = "GRIB"
|
||||||
|
|
||||||
|
def _open(self) -> None:
|
||||||
|
if not _accept(self.fp.read(8)):
|
||||||
|
msg = "Not a GRIB file"
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
self.fp.seek(-8, os.SEEK_CUR)
|
||||||
|
|
||||||
|
# make something up
|
||||||
|
self._mode = "F"
|
||||||
|
self._size = 1, 1
|
||||||
|
|
||||||
|
loader = self._load()
|
||||||
|
if loader:
|
||||||
|
loader.open(self)
|
||||||
|
|
||||||
|
def _load(self) -> ImageFile.StubHandler | None:
|
||||||
|
return _handler
|
||||||
|
|
||||||
|
|
||||||
|
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||||
|
if _handler is None or not hasattr(_handler, "save"):
|
||||||
|
msg = "GRIB save handler not installed"
|
||||||
|
raise OSError(msg)
|
||||||
|
_handler.save(im, fp, filename)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
|
||||||
|
Image.register_open(GribStubImageFile.format, GribStubImageFile, _accept)
|
||||||
|
Image.register_save(GribStubImageFile.format, _save)
|
||||||
|
|
||||||
|
Image.register_extension(GribStubImageFile.format, ".grib")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue