idconvert v0.1

This commit is contained in:
Jason Fraser 2026-04-13 16:17:36 -04:00
parent 1ef9a1a839
commit 001e09d4aa
5271 changed files with 1099510 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@ -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:*)"
]
}
}

24
.env.example Normal file
View File

@ -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_...

376
BRIEF.md Normal file
View File

@ -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 18)
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 15)
**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 69)
**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

1204
CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

495
IDexport.backup.jsx Normal file
View File

@ -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();

560
IDexport.backup2.jsx Normal file
View File

@ -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();

786
IDexport.backup3.jsx Normal file
View File

@ -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();

539
IDexport.jsx Normal file
View File

@ -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();

17
backend/Dockerfile Normal file
View File

@ -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"]

23
backend/config.py Normal file
View File

@ -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
backend/core/__init__.py Normal file
View File

View File

@ -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

17
backend/core/logging.py Normal file
View File

@ -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()

102
backend/core/pocketbase.py Normal file
View File

@ -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']

79
backend/core/storage.py Normal file
View File

@ -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

94
backend/dependencies.py Normal file
View File

@ -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')

View File

276
backend/export/factories.py Normal file
View File

@ -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

38
backend/export/fonts.py Normal file
View File

@ -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

148
backend/export/models.py Normal file
View File

@ -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

12
backend/export/parser.py Normal file
View File

@ -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)

124
backend/export/scanner.py Normal file
View File

@ -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

View File

@ -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

60
backend/main.py Normal file
View File

@ -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'}

View File

View File

@ -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

29
backend/models/scan.py Normal file
View File

@ -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] = []

View File

View File

@ -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)

View File

@ -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)

11
backend/requirements.txt Normal file
View File

@ -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

View File

View File

@ -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}

55
backend/routers/upload.py Normal file
View File

@ -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}"'},
)

18
backend/routers/users.py Normal file
View File

@ -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),
}

View File

View File

@ -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()

View File

@ -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'])

View File

@ -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)

View File

@ -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),
}

View File

@ -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"

66
backend/venv/bin/activate Normal file
View File

@ -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

View File

@ -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

View File

@ -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

10
backend/venv/bin/dotenv Executable file
View File

@ -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())

10
backend/venv/bin/fastapi Executable file
View File

@ -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())

10
backend/venv/bin/httpx Executable file
View File

@ -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())

54
backend/venv/bin/jp.py Executable file
View File

@ -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())

10
backend/venv/bin/normalizer Executable file
View File

@ -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())

10
backend/venv/bin/pip Executable file
View File

@ -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())

10
backend/venv/bin/pip3 Executable file
View File

@ -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())

10
backend/venv/bin/pip3.9 Executable file
View File

@ -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())

1
backend/venv/bin/python Symbolic link
View File

@ -0,0 +1 @@
python3

1
backend/venv/bin/python3 Symbolic link
View File

@ -0,0 +1 @@
/Library/Developer/CommandLineTools/usr/bin/python3

1
backend/venv/bin/python3.9 Symbolic link
View File

@ -0,0 +1 @@
python3

10
backend/venv/bin/uvicorn Executable file
View File

@ -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())

10
backend/venv/bin/watchfiles Executable file
View File

@ -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())

10
backend/venv/bin/websockets Executable file
View File

@ -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())

View File

@ -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")

View File

@ -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

View File

@ -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)

View File

@ -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")

View File

@ -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")

View File

@ -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()

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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")

View File

@ -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

View File

@ -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"])

View File

@ -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"])

View File

@ -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])

View File

@ -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")

View File

@ -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"])

View File

@ -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")

View File

@ -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

View File

@ -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))

View File

@ -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

View File

@ -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