Update matching logic: AI scores all candidates, lower threshold, absolute amount, prompt improvements
@@ -0,0 +1,31 @@
|
||||
# PROJECT OVERVIEW
|
||||
The AI Bookkeeper app helps businesses (especially small businesses and accounting firms) streamline the financial reconciliation process by using AI to match receipts with transactions. It reduces manual bookkeeping work by:
|
||||
- Automatically importing receipts and transaction data
|
||||
- Using AI to match corresponding records
|
||||
- Allowing users to review, approve, and sync reconciled data to QuickBooks
|
||||
|
||||
# PERSONLITY
|
||||
Teach me like a senior developer would
|
||||
|
||||
# TECH STACK
|
||||
Python
|
||||
|
||||
# ERROR FIXING PROCESS
|
||||
Step 1: Explain the error in simple terms
|
||||
Step 2: Explain the solution in simple terms
|
||||
Step 3: Show how to fix the error in simple terms.
|
||||
|
||||
# BUILDING PROCESS
|
||||
Step 1: Explain the build process in simple terms
|
||||
Step 2: Explain the solution in simple terms
|
||||
Step 3: Show how to build the project
|
||||
|
||||
|
||||
# GITHUB PUSH PROCESS
|
||||
git add .
|
||||
git commit -m "message"
|
||||
Step 3: Show how to build the project
|
||||
|
||||
# IMPORTANT
|
||||
Do not try to add extra features. Let your codes be straight to the point
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
# AI Bookkeeper - Data Science Engine
|
||||
|
||||
AI-powered receipt-to-transaction matching engine using Groq LLM. This is a **Data Science Engine** that provides intelligent matching capabilities for backend applications.
|
||||
|
||||
## 🎯 Purpose
|
||||
|
||||
This Data Science Engine receives QuickBooks transaction data from backend applications and provides:
|
||||
- **AI-powered receipt processing** (OCR and data extraction)
|
||||
- **Intelligent receipt-transaction matching** with confidence scores
|
||||
- **Google Drive integration** for batch receipt processing
|
||||
- **Configurable AI rules** for business logic
|
||||
- **Feedback logging** for continuous improvement
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Install Dependencies
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Configure API Keys
|
||||
The Groq API key is already configured in `config.py`
|
||||
|
||||
### 3. Start the DS Engine
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 4. Access API Documentation
|
||||
- **Swagger UI**: http://localhost:8343/docs
|
||||
- **ReDoc**: http://localhost:8343/redoc
|
||||
|
||||
## 📋 API Endpoints
|
||||
|
||||
### QuickBooks Data Import
|
||||
- `POST /transactions/import/quickbooks` - Import and convert QuickBooks transactions
|
||||
|
||||
### Receipt Processing
|
||||
- `POST /upload` - Upload receipt documents (PDF/images)
|
||||
- `POST /process/{file_id}` - Extract data from uploaded documents
|
||||
- `GET /documents` - List all processed documents
|
||||
|
||||
### Google Drive Integration
|
||||
- `POST /drive/sync` - Sync and process receipts from Google Drive
|
||||
- `GET /drive/folders` - List accessible Google Drive folders
|
||||
- `GET /drive/folder/{folder_id}` - Get folder information
|
||||
|
||||
### AI Matching Engine
|
||||
- `POST /match` - Match receipts to transactions using AI
|
||||
- `POST /approve` - Approve or reject AI matches
|
||||
|
||||
### AI Rules Management
|
||||
- `POST /rules` - Add new AI rules
|
||||
- `GET /rules` - List all active rules
|
||||
- `DELETE /rules/{rule_name}` - Delete rules
|
||||
|
||||
### System Monitoring
|
||||
- `GET /stats` - Get system statistics and performance metrics
|
||||
|
||||
## 🔧 Core Components
|
||||
|
||||
### **AIMatcher** (`ai_matcher.py`)
|
||||
- Uses Groq LLM to compare receipts and transactions
|
||||
- Provides confidence scores and reasoning
|
||||
- Configurable matching criteria (amount, date, vendor)
|
||||
|
||||
### **AIRulesEngine** (`ai_rules.py`)
|
||||
- Applies business rules for auto-approval and categorization
|
||||
- Configurable rule conditions and actions
|
||||
- Supports system and user-generated rules
|
||||
|
||||
### **DocumentProcessor** (`document_processor.py`)
|
||||
- AI-powered receipt data extraction
|
||||
- Supports PDF and image formats
|
||||
- Uses Groq vision model for OCR
|
||||
|
||||
### **MatchingEngine** (`matching_engine.py`)
|
||||
- Main orchestrator combining all components
|
||||
- Handles the complete matching workflow
|
||||
- Provides statistics and feedback logging
|
||||
|
||||
### **FeedbackLogger** (`feedback_logger.py`)
|
||||
- Tracks manual overrides for AI training
|
||||
- Maintains audit trail of user decisions
|
||||
- Enables continuous model improvement
|
||||
|
||||
## 📊 Configuration
|
||||
|
||||
Edit `config.py` to adjust:
|
||||
- **Confidence threshold** (default: 0.8)
|
||||
- **Date tolerance days** (default: 7)
|
||||
- **Amount tolerance percent** (default: 5%)
|
||||
- **Groq API key** (already configured)
|
||||
|
||||
## 🔄 Integration Workflow
|
||||
|
||||
### 1. Backend Sends QuickBooks Data
|
||||
```python
|
||||
# Backend sends QuickBooks transactions
|
||||
response = requests.post(
|
||||
"http://localhost:8343/transactions/import/quickbooks",
|
||||
json={
|
||||
"transactions": [
|
||||
{
|
||||
"id": "QB_TXN_123",
|
||||
"txn_date": "2024-01-15",
|
||||
"amount": 12.50,
|
||||
"payee_name": "Starbucks",
|
||||
"memo": "Coffee purchase"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Process Receipts
|
||||
```python
|
||||
# Sync from Google Drive
|
||||
response = requests.post(
|
||||
"http://localhost:8343/drive/sync",
|
||||
json={"folder_id": "your_folder_id"}
|
||||
)
|
||||
|
||||
# Or upload directly
|
||||
response = requests.post(
|
||||
"http://localhost:8343/upload",
|
||||
files={"file": receipt_file}
|
||||
)
|
||||
```
|
||||
|
||||
### 3. AI Matching
|
||||
```python
|
||||
# Match receipts to transactions
|
||||
response = requests.post(
|
||||
"http://localhost:8343/match",
|
||||
json={
|
||||
"receipts": processed_receipts,
|
||||
"transactions": converted_transactions
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 4. User Feedback
|
||||
```python
|
||||
# Approve or reject matches
|
||||
response = requests.post(
|
||||
"http://localhost:8343/approve",
|
||||
json={
|
||||
"match_id": "match_123",
|
||||
"user_id": "user_456",
|
||||
"action": "approve"
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
- **AI-powered matching** with confidence scores
|
||||
- **Rule-based auto-approval** and categorization
|
||||
- **Feedback logging** for continuous improvement
|
||||
- **Configurable matching parameters**
|
||||
- **Google Drive integration** for batch processing
|
||||
- **JSON API** for easy backend integration
|
||||
- **Comprehensive error handling**
|
||||
|
||||
## 📝 Data Formats
|
||||
|
||||
### QuickBooks Transaction Input
|
||||
```json
|
||||
{
|
||||
"id": "string",
|
||||
"txn_date": "YYYY-MM-DD",
|
||||
"amount": 0.00,
|
||||
"payee_name": "string",
|
||||
"memo": "string (optional)",
|
||||
"account_name": "string (optional)",
|
||||
"txn_type": "string (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
### Match Result Output
|
||||
```json
|
||||
{
|
||||
"receipt_id": "string",
|
||||
"transaction_id": "string",
|
||||
"confidence_score": 0.95,
|
||||
"match_reason": "string",
|
||||
"receipt_vendor": "string",
|
||||
"receipt_amount": 0.00,
|
||||
"transaction_vendor": "string",
|
||||
"transaction_amount": 0.00
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 AI Matching Criteria
|
||||
|
||||
The engine uses three primary criteria for matching:
|
||||
|
||||
1. **Amount Similarity** - Compares receipt and transaction amounts (5% tolerance)
|
||||
2. **Date Proximity** - Checks date closeness (7-day tolerance)
|
||||
3. **Vendor Matching** - AI-powered vendor name comparison
|
||||
|
||||
## 🚀 Production Deployment
|
||||
|
||||
For production deployment:
|
||||
- Replace in-memory storage with a database
|
||||
- Configure proper authentication
|
||||
- Set up monitoring and logging
|
||||
- Use environment variables for configuration
|
||||
- Implement proper error handling and retries
|
||||
|
||||
## 📞 Support
|
||||
|
||||
This Data Science Engine is designed to be integrated with backend applications that handle:
|
||||
- QuickBooks API connections
|
||||
- User interface and workflows
|
||||
- Data persistence and management
|
||||
- External integrations
|
||||
|
||||
The engine focuses purely on AI/ML capabilities and provides a clean JSON API for backend integration.
|
||||
@@ -0,0 +1,102 @@
|
||||
import groq
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Tuple
|
||||
import config
|
||||
from models import Receipt, Transaction, Match
|
||||
|
||||
class AIMatcher:
|
||||
def __init__(self):
|
||||
self.client = groq.Groq(api_key=config.GROQ_API_KEY)
|
||||
self.model = "llama3-8b-8192"
|
||||
|
||||
def match_receipts_to_transactions(self, receipts: List[Receipt], transactions: List[Transaction]) -> List[Match]:
|
||||
matches = []
|
||||
|
||||
for receipt in receipts:
|
||||
best_match = self._find_best_match(receipt, transactions)
|
||||
if best_match:
|
||||
matches.append(best_match)
|
||||
|
||||
return sorted(matches, key=lambda x: x.confidence_score, reverse=True)
|
||||
|
||||
def _find_best_match(self, receipt: Receipt, transactions: List[Transaction]) -> Match:
|
||||
candidates = self._filter_candidates(receipt, transactions)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
best_match = None
|
||||
highest_score = 0
|
||||
|
||||
for transaction in candidates:
|
||||
score, reason = self._calculate_match_score(receipt, transaction)
|
||||
if score > highest_score and score >= config.CONFIDENCE_THRESHOLD:
|
||||
highest_score = score
|
||||
best_match = Match(receipt, transaction, score, reason)
|
||||
|
||||
return best_match
|
||||
|
||||
def _filter_candidates(self, receipt: Receipt, transactions: List[Transaction]) -> List[Transaction]:
|
||||
# Return ALL transactions - let the AI decide on scoring
|
||||
# Only filter out transactions with completely different amounts (>50% difference) to avoid obvious mismatches
|
||||
candidates = []
|
||||
amount_threshold = receipt.amount * 0.5 # 50% threshold for obvious mismatches
|
||||
|
||||
for transaction in transactions:
|
||||
# Use absolute value for transaction amount comparison
|
||||
transaction_amount_abs = abs(transaction.amount)
|
||||
# Only exclude transactions with obviously different amounts
|
||||
if abs(receipt.amount - transaction_amount_abs) <= amount_threshold:
|
||||
candidates.append(transaction)
|
||||
|
||||
return candidates
|
||||
|
||||
def _calculate_match_score(self, receipt: Receipt, transaction: Transaction) -> Tuple[float, str]:
|
||||
# Calculate differences for the AI to consider
|
||||
date_diff = abs((receipt.receipt_date - transaction.transaction_date).days)
|
||||
transaction_amount_abs = abs(transaction.amount)
|
||||
amount_diff = abs(receipt.amount - transaction_amount_abs)
|
||||
amount_percent_diff = (amount_diff / receipt.amount) * 100 if receipt.amount > 0 else 0
|
||||
|
||||
prompt = f"""
|
||||
Compare this receipt with this transaction and provide a confidence score (0-1) and brief reason:
|
||||
|
||||
Receipt: {receipt.vendor}, ${receipt.amount}, {receipt.receipt_date.strftime('%Y-%m-%d')}
|
||||
Transaction: {transaction.vendor}, ${transaction.amount} (absolute: ${transaction_amount_abs}), {transaction.transaction_date.strftime('%Y-%m-%d')}
|
||||
|
||||
Differences:
|
||||
- Date difference: {date_diff} days
|
||||
- Amount difference: ${amount_diff} ({amount_percent_diff:.1f}%)
|
||||
- Vendor comparison: "{receipt.vendor}" vs "{transaction.vendor}"
|
||||
|
||||
Scoring guidelines:
|
||||
- Perfect matches (same vendor, amount, date): 0.95-1.0
|
||||
- High confidence (minor differences): 0.8-0.94
|
||||
- Medium confidence (moderate differences): 0.6-0.79
|
||||
- Low confidence (significant differences): 0.4-0.59
|
||||
- Very low confidence (major differences): 0.2-0.39
|
||||
- No match: 0.0-0.19
|
||||
|
||||
Consider vendor name similarity, amount accuracy, and date proximity.
|
||||
Score based on your discretion - even imperfect matches should get scores if there's reasonable similarity.
|
||||
|
||||
Return only: score|reason
|
||||
"""
|
||||
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=100,
|
||||
temperature=0.1
|
||||
)
|
||||
|
||||
result = response.choices[0].message.content.strip()
|
||||
if '|' in result:
|
||||
score_str, reason = result.split('|', 1)
|
||||
score = float(score_str.strip())
|
||||
return min(max(score, 0), 1), reason.strip()
|
||||
else:
|
||||
return 0.0, "Invalid AI response"
|
||||
|
||||
except Exception as e:
|
||||
return 0.0, f"AI error: {str(e)}"
|
||||
@@ -0,0 +1,63 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, List
|
||||
import config
|
||||
from models import Receipt, Transaction
|
||||
|
||||
@dataclass
|
||||
class AIRule:
|
||||
name: str
|
||||
condition: str
|
||||
action: str
|
||||
source: str
|
||||
status: str = "active"
|
||||
|
||||
class AIRulesEngine:
|
||||
def __init__(self):
|
||||
self.rules: List[AIRule] = []
|
||||
self._load_default_rules()
|
||||
|
||||
def _load_default_rules(self):
|
||||
self.rules = [
|
||||
AIRule("exact_amount_match", "amount_diff <= 0.01", "auto_approve", "system"),
|
||||
AIRule("same_vendor_same_date", "vendor_match and date_diff <= 1", "high_confidence", "system"),
|
||||
AIRule("gas_station_pattern", "vendor contains 'gas' or 'fuel'", "categorize_transport", "system")
|
||||
]
|
||||
|
||||
def apply_rules(self, receipt: Receipt, transaction: Transaction) -> Dict[str, Any]:
|
||||
results = {"auto_approve": False, "confidence_boost": 0, "category": None}
|
||||
|
||||
for rule in self.rules:
|
||||
if rule.status != "active":
|
||||
continue
|
||||
|
||||
if self._evaluate_condition(rule.condition, receipt, transaction):
|
||||
self._execute_action(rule.action, results, receipt, transaction)
|
||||
|
||||
return results
|
||||
|
||||
def _evaluate_condition(self, condition: str, receipt: Receipt, transaction: Transaction) -> bool:
|
||||
amount_diff = abs(receipt.amount - transaction.amount)
|
||||
date_diff = abs((receipt.receipt_date - transaction.transaction_date).days)
|
||||
vendor_match = receipt.vendor.lower() in transaction.vendor.lower() or transaction.vendor.lower() in receipt.vendor.lower()
|
||||
|
||||
return eval(condition, {
|
||||
"amount_diff": amount_diff,
|
||||
"date_diff": date_diff,
|
||||
"vendor_match": vendor_match,
|
||||
"receipt": receipt,
|
||||
"transaction": transaction
|
||||
})
|
||||
|
||||
def _execute_action(self, action: str, results: Dict[str, Any], receipt: Receipt, transaction: Transaction):
|
||||
if action == "auto_approve":
|
||||
results["auto_approve"] = True
|
||||
elif action == "high_confidence":
|
||||
results["confidence_boost"] += 0.2
|
||||
elif action == "categorize_transport":
|
||||
results["category"] = "Transportation"
|
||||
|
||||
def add_rule(self, rule: AIRule):
|
||||
self.rules.append(rule)
|
||||
|
||||
def remove_rule(self, rule_name: str):
|
||||
self.rules = [r for r in self.rules if r.name != rule_name]
|
||||
@@ -0,0 +1,101 @@
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
class ReceiptRequest(BaseModel):
|
||||
id: str
|
||||
file_name: str
|
||||
upload_date: datetime
|
||||
receipt_date: datetime
|
||||
amount: float
|
||||
tax: float
|
||||
vendor: str
|
||||
category: str
|
||||
|
||||
class TransactionRequest(BaseModel):
|
||||
id: str
|
||||
transaction_date: datetime
|
||||
amount: float
|
||||
vendor: str
|
||||
notes: str
|
||||
|
||||
# New: QuickBooks specific models
|
||||
class QuickBooksTransaction(BaseModel):
|
||||
"""Raw QuickBooks transaction data"""
|
||||
id: str
|
||||
txn_date: str # QuickBooks date format
|
||||
amount: float
|
||||
payee_name: str # QuickBooks vendor field
|
||||
memo: Optional[str] = None
|
||||
account_name: Optional[str] = None
|
||||
txn_type: Optional[str] = None
|
||||
|
||||
class QuickBooksImportRequest(BaseModel):
|
||||
"""Request to import QuickBooks transactions"""
|
||||
transactions: List[QuickBooksTransaction]
|
||||
date_range: Optional[dict] = None
|
||||
account_filter: Optional[str] = None
|
||||
|
||||
class QuickBooksImportResponse(BaseModel):
|
||||
"""Response from QuickBooks import"""
|
||||
imported_count: int
|
||||
converted_transactions: List[TransactionRequest]
|
||||
errors: List[str] = []
|
||||
|
||||
class MatchResponse(BaseModel):
|
||||
receipt_id: str
|
||||
transaction_id: str
|
||||
confidence_score: float
|
||||
match_reason: str
|
||||
receipt_vendor: str
|
||||
receipt_amount: float
|
||||
transaction_vendor: str
|
||||
transaction_amount: float
|
||||
|
||||
class MatchingRequest(BaseModel):
|
||||
receipts: List[ReceiptRequest]
|
||||
transactions: List[TransactionRequest]
|
||||
|
||||
class MatchingResponse(BaseModel):
|
||||
matches: List[MatchResponse]
|
||||
stats: dict
|
||||
|
||||
class ApprovalRequest(BaseModel):
|
||||
match_id: str
|
||||
user_id: str
|
||||
action: str # "approve" or "reject"
|
||||
reason: Optional[str] = None
|
||||
|
||||
class RuleRequest(BaseModel):
|
||||
name: str
|
||||
condition: str
|
||||
action: str
|
||||
source: str = "user"
|
||||
|
||||
class DocumentUploadResponse(BaseModel):
|
||||
file_id: str
|
||||
filename: str
|
||||
file_type: str
|
||||
upload_date: datetime
|
||||
status: str
|
||||
|
||||
class DocumentProcessResponse(BaseModel):
|
||||
file_id: str
|
||||
extraction_success: bool
|
||||
vendor: Optional[str] = None
|
||||
total_amount: Optional[float] = None
|
||||
tax_amount: Optional[float] = None
|
||||
date: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
confidence: Optional[float] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
class DriveSyncRequest(BaseModel):
|
||||
folder_id: Optional[str] = None
|
||||
auto_process: bool = True
|
||||
|
||||
class DriveSyncResponse(BaseModel):
|
||||
files_processed: int
|
||||
successful_extractions: int
|
||||
failed_extractions: int
|
||||
results: List[DocumentProcessResponse]
|
||||
@@ -0,0 +1,516 @@
|
||||
Account Type,Account Number,Transaction Date,Cheque Number,Description 1,Description 2,Amount
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,2121.2
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,5376.41
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,1112.29
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,918.99
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,20559.97
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,1772.18
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,416.09
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,6960.58
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,416.09
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,694.58
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,1669.29
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,5415.81
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,572.39
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,2149.83
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,3583.87
|
||||
Chequing,20002-17758460,1/2/2025,,Misc Payment,MK INC,2783.25
|
||||
Chequing,20002-17758460,1/2/2025,,Online transfer sent - 2155,Findlay Wong,-3872.71
|
||||
Chequing,20002-17758460,1/2/2025,,Regular transaction fee,1 Dr @ 2.50,-2.5
|
||||
Chequing,20002-17758460,1/2/2025,,Monthly fee,,-6
|
||||
Chequing,20002-17758460,1/2/2025,,Bill Payment,PAY-FILE FEES,-4
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-192.77
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-188.77
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-270.94
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-446
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-314.1
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-245.93
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-224.49
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-287.98
|
||||
Chequing,20002-17758460,1/6/2025,,Online Banking transfer - 9316,,-1413.24
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-192.77
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-560.99
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-366.99
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-237.99
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-132.7
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-392.1
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-696.66
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-288.34
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-661.49
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-389.99
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,,-431.71
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-176.8
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-196.99
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-997.35
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-418.05
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-1000
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-518.81
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-392.1
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-667.37
|
||||
Chequing,20002-17758460,1/6/2025,,e-Transfer sent,Remitbee,-236.46
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/6/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/7/2025,,e-Transfer sent,Remitbee,-6494.44
|
||||
Chequing,20002-17758460,1/7/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/8/2025,,Insurance,THE EMPIRE LIFE,-2200.95
|
||||
Chequing,20002-17758460,1/9/2025,,Misc Payment,MK INC,7164.85
|
||||
Chequing,20002-17758460,1/9/2025,,Misc Payment,MK INC,851.05
|
||||
Chequing,20002-17758460,1/9/2025,,Misc Payment,MK INC,15774.65
|
||||
Chequing,20002-17758460,1/9/2025,,COMMERCIAL TAXES,EMPTX 8584774,-2577.54
|
||||
Chequing,20002-17758460,1/9/2025,,COMMERCIAL TAXES,EMPTX 8584114,-2577.54
|
||||
Chequing,20002-17758460,1/10/2025,,Misc Payment,MK INC,6711.7
|
||||
Chequing,20002-17758460,1/10/2025,,ATM deposit - TY916139,,2252.25
|
||||
Chequing,20002-17758460,1/10/2025,,ATM/Mobile adjustment credit,,916.67
|
||||
Chequing,20002-17758460,1/14/2025,,Misc Payment,STRIPE,4363.08
|
||||
Chequing,20002-17758460,1/16/2025,,e-Transfer sent,Remitbee,-71.59
|
||||
Chequing,20002-17758460,1/16/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/16/2025,,COMMERCIAL TAXES,EMPTX 5448200,-414.5
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-716.08
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-191.1
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-311.36
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-246.46
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-156.3
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-224.98
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-644.47
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-182.81
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-189.17
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,,-191.1
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-432.64
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-999.53
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-418.75
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-446.96
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-132.91
|
||||
Chequing,20002-17758460,1/20/2025,,ATM deposit - TY916817,,640
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-519.7
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-234.99
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-271.52
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-668.82
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-371.99
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-236.85
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-392.76
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-392.76
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-288.82
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-198.99
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-1000
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-567.99
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-698.18
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-240.99
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-662.61
|
||||
Chequing,20002-17758460,1/20/2025,,e-Transfer sent,Remitbee,-672.95
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/20/2025,,Misc Payment,STRIPE,-12.77
|
||||
Chequing,20002-17758460,1/21/2025,,Misc Payment,STRIPE,2194.16
|
||||
Chequing,20002-17758460,1/22/2025,,e-Transfer sent,Ajai Srivastava,-565
|
||||
Chequing,20002-17758460,1/22/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/23/2025,,Misc Payment,STRIPE,-31.92
|
||||
Chequing,20002-17758460,1/24/2025,,Misc Payment,CITY OF RICHMON,282.5
|
||||
Chequing,20002-17758460,1/24/2025,,Misc Payment,STRIPE,5485.85
|
||||
Chequing,20002-17758460,1/27/2025,,e-Transfer sent,Susan Lee,-160
|
||||
Chequing,20002-17758460,1/27/2025,,Online Banking transfer - 3607,,-41.79
|
||||
Chequing,20002-17758460,1/27/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/30/2025,,e-Transfer sent,,-3872.71
|
||||
Chequing,20002-17758460,1/30/2025,,e-Transfer sent,Sok Kuan Mark,-2100
|
||||
Chequing,20002-17758460,1/30/2025,,e-Transfer sent,Sok Kuan Mark,-1200
|
||||
Chequing,20002-17758460,1/30/2025,,Online Banking transfer - 1072,,-3.5
|
||||
Chequing,20002-17758460,1/30/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/30/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/30/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,1/31/2025,,Online transfer sent - 5947,ryan wong,-3872.71
|
||||
Chequing,20002-17758460,1/31/2025,,Online Banking transfer - 5870,,-1649.19
|
||||
Chequing,20002-17758460,1/31/2025,,Misc Payment,STRIPE,59.56
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-336.32
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-451.56
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-283.35
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-698.28
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-672.86
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-1046.67
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-519.28
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-466.51
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-728.93
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-206.32
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-257.18
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-747.62
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-206.32
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-283.35
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-234.75
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-290.3
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-522.43
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-133.58
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-373.99
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-200.99
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-133.58
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-154.48
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-394.77
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-571.99
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-420.89
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-394.77
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-242.99
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-1000
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-395.07
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-389.99
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-666.51
|
||||
Chequing,20002-17758460,2/3/2025,,e-Transfer sent,Remitbee,-674.5
|
||||
Chequing,20002-17758460,2/3/2025,,Monthly fee,,-6
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/3/2025,,Bill Payment,PAY-FILE FEES,-6
|
||||
Chequing,20002-17758460,2/5/2025,,Misc Payment,STRIPE,122.37
|
||||
Chequing,20002-17758460,2/6/2025,,Misc Payment,STRIPE,62.81
|
||||
Chequing,20002-17758460,2/7/2025,,e-Transfer sent,Remitbee,-173.94
|
||||
Chequing,20002-17758460,2/7/2025,,e-Transfer received,ANDREA IVANKA INTERNATIONAL IN,120
|
||||
Chequing,20002-17758460,2/7/2025,,Online Banking transfer - 1371,,-0.04
|
||||
Chequing,20002-17758460,2/7/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/7/2025,,Misc Payment,STRIPE,314.05
|
||||
Chequing,20002-17758460,2/10/2025,,Misc Payment,STRIPE,179.03
|
||||
Chequing,20002-17758460,2/10/2025,,ATM deposit - TY901067,,2252.25
|
||||
Chequing,20002-17758460,2/10/2025,,Insurance,EMP LIFE,-2200.95
|
||||
Chequing,20002-17758460,2/10/2025,,ATM/Mobile adjustment credit,,898.2
|
||||
Chequing,20002-17758460,2/11/2025,,COMMERCIAL TAXES,EMPTX 1134350,-2539.4
|
||||
Chequing,20002-17758460,2/12/2025,,Misc Payment,STRIPE,125.62
|
||||
Chequing,20002-17758460,2/13/2025,,Misc Payment,STRIPE,304.65
|
||||
Chequing,20002-17758460,2/14/2025,,Misc Payment,STRIPE,314.05
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-330.09
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-269.42
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-202.52
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-269.42
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-223.24
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-710.47
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-639.43
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-156.35
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-202.52
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-244.55
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-429.28
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-692.71
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-663.58
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-443.48
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-559
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-196.99
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,,-130.34
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-366.99
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-389.99
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-237.99
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-994.67
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-130.35
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-385.06
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-823.72
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-649.49
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-385.04
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-308.64
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-283.17
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-560.32
|
||||
Chequing,20002-17758460,2/17/2025,,e-Transfer sent,Remitbee,-1000
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/18/2025,,Misc Payment,STRIPE,125.62
|
||||
Chequing,20002-17758460,2/18/2025,,e-Transfer sent,Ajai Srivastava,-1412.5
|
||||
Chequing,20002-17758460,2/18/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,2/19/2025,,Misc Payment,STRIPE,188.43
|
||||
Chequing,20002-17758460,2/20/2025,,Misc Payment,STRIPE,62.81
|
||||
Chequing,20002-17758460,2/21/2025,,Misc Payment,STRIPE,358.06
|
||||
Chequing,20002-17758460,2/24/2025,,Misc Payment,STRIPE,62.81
|
||||
Chequing,20002-17758460,2/25/2025,,Misc Payment,STRIPE,-30.32
|
||||
Chequing,20002-17758460,2/26/2025,,Misc Payment,STRIPE,5397.47
|
||||
Chequing,20002-17758460,2/27/2025,,Misc Payment,STRIPE,304.65
|
||||
Chequing,20002-17758460,2/28/2025,,Matured GIC,,100000
|
||||
Chequing,20002-17758460,2/28/2025,,GIC interest,,4500
|
||||
Chequing,20002-17758460,2/28/2025,,Misc Payment,STRIPE,62.81
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-735.5
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-367.99
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-278.8
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-658.72
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-417.7
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-314.02
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-132.59
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,,-196.99
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-686.95
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-237.99
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-717.11
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-559
|
||||
Chequing,20002-17758460,3/3/2025,,Misc Payment,STRIPE,1137.39
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-459
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-1024.68
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-444.29
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-1000
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-832.17
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-518.1
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-660.58
|
||||
Chequing,20002-17758460,3/3/2025,,Online transfer sent - 5372,ryan wong,-3872.71
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-391.55
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Remitbee,-391.57
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Susan Lee,-160
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Sok Kuan Mark,-2100
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Chuck Windley,-3872.71
|
||||
Chequing,20002-17758460,3/3/2025,,e-Transfer sent,Sok Kuan Mark,-1200
|
||||
Chequing,20002-17758460,3/3/2025,,Online Banking transfer - 4576,,-3035.8
|
||||
Chequing,20002-17758460,3/3/2025,,Online Banking transfer - 3265,,-42.34
|
||||
Chequing,20002-17758460,3/3/2025,,Monthly fee,,-6
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/3/2025,,COMMERCIAL TAXES,EMPTX 3123370,-2539.4
|
||||
Chequing,20002-17758460,3/3/2025,,Bill Payment,PAY-FILE FEES,-2
|
||||
Chequing,20002-17758460,3/4/2025,,e-Transfer sent,Remitbee,-154.06
|
||||
Chequing,20002-17758460,3/4/2025,,e-Transfer sent,Remitbee,-278.8
|
||||
Chequing,20002-17758460,3/4/2025,,e-Transfer sent,Remitbee,-234.99
|
||||
Chequing,20002-17758460,3/4/2025,,e-Transfer sent,Remitbee,-253.06
|
||||
Chequing,20002-17758460,3/4/2025,,e-Transfer sent,Remitbee,-146.25
|
||||
Chequing,20002-17758460,3/4/2025,,e-Transfer sent,Remitbee,-363.38
|
||||
Chequing,20002-17758460,3/4/2025,,e-Transfer sent,Remitbee,-346.13
|
||||
Chequing,20002-17758460,3/4/2025,,e-Transfer sent,Remitbee,-212.3
|
||||
Chequing,20002-17758460,3/4/2025,,e-Transfer sent,Remitbee,-212.3
|
||||
Chequing,20002-17758460,3/4/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/4/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/4/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/4/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/4/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/4/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/4/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/4/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/4/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/4/2025,,Misc Payment,STRIPE,2742.77
|
||||
Chequing,20002-17758460,3/7/2025,,e-Transfer sent,Remitbee,-71.3
|
||||
Chequing,20002-17758460,3/7/2025,,e-Transfer received,AJAI KUMARSRIVASTAVA,1412.5
|
||||
Chequing,20002-17758460,3/7/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/10/2025,,Misc Payment,STRIPE,116.22
|
||||
Chequing,20002-17758460,3/10/2025,,Online Banking payment - 7743,VISA - BNS,-224.76
|
||||
Chequing,20002-17758460,3/10/2025,,Online Banking transfer - 2890,,-0.06
|
||||
Chequing,20002-17758460,3/10/2025,,Insurance,EMP LIFE,-2200.95
|
||||
Chequing,20002-17758460,3/11/2025,,Misc Payment,STRIPE,62.81
|
||||
Chequing,20002-17758460,3/12/2025,,Misc Payment,STRIPE,62.81
|
||||
Chequing,20002-17758460,3/13/2025,,Misc Payment,STRIPE,62.81
|
||||
Chequing,20002-17758460,3/14/2025,,Misc Payment,STRIPE,187.91
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-363.58
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-278.95
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-717.51
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-278.95
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,,-662.31
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-564
|
||||
Chequing,20002-17758460,3/17/2025,,Misc Payment,STRIPE,376.86
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-253.2
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-687.33
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-198.99
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-333.18
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,,-459.25
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-444.53
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-370.99
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-239.99
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-1471.81
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-415.14
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-515.13
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-831.48
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-389.34
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-515.07
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-85.4
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-389.99
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-1969.39
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-131.76
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-1000
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,Remitbee,-131.79
|
||||
Chequing,20002-17758460,3/17/2025,,e-Transfer sent,,-312.03
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/17/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/18/2025,,e-Transfer sent,,-157
|
||||
Chequing,20002-17758460,3/18/2025,,e-Transfer sent,Remitbee,-204.41
|
||||
Chequing,20002-17758460,3/18/2025,,e-Transfer sent,,-204.41
|
||||
Chequing,20002-17758460,3/18/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/18/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/18/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/18/2025,,Misc Payment,STRIPE,430.27
|
||||
Chequing,20002-17758460,3/19/2025,,Misc Payment,STRIPE,62.81
|
||||
Chequing,20002-17758460,3/20/2025,,Misc Payment,STRIPE,669.92
|
||||
Chequing,20002-17758460,3/21/2025,,Misc Payment,STRIPE,420.87
|
||||
Chequing,20002-17758460,3/24/2025,,Misc Payment,STRIPE,188.43
|
||||
Chequing,20002-17758460,3/25/2025,,Misc Payment,STRIPE,188.43
|
||||
Chequing,20002-17758460,3/26/2025,,Online Banking transfer - 8674,,-42.73
|
||||
Chequing,20002-17758460,3/26/2025,,e-Transfer sent,Sok Kuan Mark,-2100
|
||||
Chequing,20002-17758460,3/26/2025,,e-Transfer sent,Sok Kuan Mark,-1200
|
||||
Chequing,20002-17758460,3/26/2025,,e-Transfer sent,Susan Lee,-160
|
||||
Chequing,20002-17758460,3/26/2025,,Online Banking transfer - 9723,,-42.73
|
||||
Chequing,20002-17758460,3/26/2025,,Online Banking transfer - 1905,,-4653.31
|
||||
Chequing,20002-17758460,3/26/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/26/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/26/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/27/2025,,Misc Payment,STRIPE,429.31
|
||||
Chequing,20002-17758460,3/28/2025,,Online transfer sent - 9210,ryan wong,-3872.71
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,MK INC,1711.73
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,MK INC,7135.05
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,MK INC,13225.25
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,MK INC,16739.66
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,MK INC,4460.31
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,MK INC,1723.59
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,MK INC,2844.67
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,MK INC,2816.13
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,MK INC,1661.4
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,MK INC,1464.54
|
||||
Chequing,20002-17758460,3/28/2025,,e-Transfer sent,Chuck Windley,-3872.71
|
||||
Chequing,20002-17758460,3/28/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/28/2025,,Misc Payment,STRIPE,2826.11
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-703.67
|
||||
Chequing,20002-17758460,3/31/2025,,Misc Payment,STARTUP SLANG I,1000
|
||||
Chequing,20002-17758460,3/31/2025,,Misc Payment,STRIPE,4425.37
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-674.08
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-198.99
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-240.99
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-131.12
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-1025.07
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-1000
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-370.99
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-310.55
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-1025.07
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-653.48
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-649.54
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-1443.42
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-512.6
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-841.63
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-151.77
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Remitbee,-387.39
|
||||
Chequing,20002-17758460,3/31/2025,,e-Transfer sent,Susan Lee,-300
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
Chequing,20002-17758460,3/31/2025,,INTERAC e-Transfer fee,,-1.5
|
||||
|
@@ -0,0 +1,9 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
GROQ_API_KEY = "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"
|
||||
CONFIDENCE_THRESHOLD = 0.3
|
||||
DATE_TOLERANCE_DAYS = 7
|
||||
AMOUNT_TOLERANCE_PERCENT = 0.05
|
||||
@@ -0,0 +1,82 @@
|
||||
import csv
|
||||
from dateutil import parser
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Config values
|
||||
DATE_TOLERANCE_DAYS = 7
|
||||
AMOUNT_TOLERANCE_PERCENT = 0.05
|
||||
CONFIDENCE_THRESHOLD = 0.8
|
||||
|
||||
# Receipt data
|
||||
receipt_date = datetime(2025, 2, 7)
|
||||
receipt_amount = 1412.5
|
||||
receipt_vendor = "Ajai Srivastava CPA, Accounting Services & Taxes"
|
||||
|
||||
print("=== DEBUGGING AJAI RECEIPT MATCH ===")
|
||||
print(f"Receipt Date: {receipt_date}")
|
||||
print(f"Receipt Amount: ${receipt_amount}")
|
||||
print(f"Receipt Vendor: {receipt_vendor}")
|
||||
print(f"Date Tolerance: {DATE_TOLERANCE_DAYS} days")
|
||||
print(f"Amount Tolerance: {AMOUNT_TOLERANCE_PERCENT * 100}%")
|
||||
print()
|
||||
|
||||
# Check CSV transaction
|
||||
csv_transaction = {
|
||||
"date": "2/18/2025",
|
||||
"amount": -1412.5,
|
||||
"vendor": "Ajai Srivastava"
|
||||
}
|
||||
|
||||
# Parse CSV date
|
||||
csv_date = parser.parse(csv_transaction["date"])
|
||||
csv_amount = csv_transaction["amount"]
|
||||
csv_vendor = csv_transaction["vendor"]
|
||||
|
||||
print("=== CSV TRANSACTION ===")
|
||||
print(f"CSV Date: {csv_date}")
|
||||
print(f"CSV Amount: ${csv_amount}")
|
||||
print(f"CSV Vendor: {csv_vendor}")
|
||||
print()
|
||||
|
||||
# Check date tolerance
|
||||
date_diff = abs((receipt_date - csv_date).days)
|
||||
date_match = date_diff <= DATE_TOLERANCE_DAYS
|
||||
|
||||
print("=== DATE CHECK ===")
|
||||
print(f"Date Difference: {date_diff} days")
|
||||
print(f"Date Match: {date_match}")
|
||||
print(f"Tolerance: {DATE_TOLERANCE_DAYS} days")
|
||||
print()
|
||||
|
||||
# Check amount tolerance
|
||||
amount_tolerance = receipt_amount * AMOUNT_TOLERANCE_PERCENT
|
||||
amount_diff = abs(receipt_amount - abs(csv_amount)) # Use absolute value for negative amounts
|
||||
amount_match = amount_diff <= amount_tolerance
|
||||
|
||||
print("=== AMOUNT CHECK ===")
|
||||
print(f"Receipt Amount: ${receipt_amount}")
|
||||
print(f"CSV Amount (abs): ${abs(csv_amount)}")
|
||||
print(f"Amount Difference: ${amount_diff}")
|
||||
print(f"Amount Tolerance: ${amount_tolerance}")
|
||||
print(f"Amount Match: {amount_match}")
|
||||
print()
|
||||
|
||||
# Check vendor similarity
|
||||
vendor_similarity = "Ajai Srivastava" in receipt_vendor
|
||||
print("=== VENDOR CHECK ===")
|
||||
print(f"Receipt Vendor: {receipt_vendor}")
|
||||
print(f"CSV Vendor: {csv_vendor}")
|
||||
print(f"Vendor Similarity: {vendor_similarity}")
|
||||
print()
|
||||
|
||||
# Overall result
|
||||
print("=== RESULT ===")
|
||||
if date_match and amount_match:
|
||||
print("✅ Transaction would pass initial filtering")
|
||||
print("Would proceed to AI matching stage")
|
||||
else:
|
||||
print("❌ Transaction filtered out before AI matching")
|
||||
if not date_match:
|
||||
print(f" - Date difference ({date_diff} days) > tolerance ({DATE_TOLERANCE_DAYS} days)")
|
||||
if not amount_match:
|
||||
print(f" - Amount difference (${amount_diff}) > tolerance (${amount_tolerance})")
|
||||
@@ -0,0 +1,204 @@
|
||||
import groq
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image
|
||||
import PyPDF2
|
||||
from typing import Dict, Any, List, Optional
|
||||
import config
|
||||
import os
|
||||
import aiofiles
|
||||
from datetime import datetime
|
||||
|
||||
class DocumentProcessor:
|
||||
def __init__(self):
|
||||
self.client = groq.Groq(api_key=config.GROQ_API_KEY)
|
||||
self.model = "meta-llama/llama-4-scout-17b-16e-instruct" # Vision model
|
||||
|
||||
async def process_file(self, file_path: str, file_type: str) -> Dict[str, Any]:
|
||||
"""Process uploaded file and extract receipt data"""
|
||||
try:
|
||||
if file_type.lower() in ['jpg', 'jpeg', 'png', 'gif', 'bmp']:
|
||||
return await self._process_image(file_path)
|
||||
elif file_type.lower() == 'pdf':
|
||||
return await self._process_pdf(file_path)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {file_type}")
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
async def _process_image(self, image_path: str) -> Dict[str, Any]:
|
||||
"""Extract data from image using Groq vision"""
|
||||
try:
|
||||
# Encode image to base64
|
||||
base64_image = self._encode_image(image_path)
|
||||
|
||||
# Create Groq vision prompt
|
||||
prompt = """
|
||||
Analyze this receipt image and extract the following information in JSON format:
|
||||
{
|
||||
"vendor": "Store/company name",
|
||||
"total_amount": 0.00,
|
||||
"tax_amount": 0.00,
|
||||
"date": "YYYY-MM-DD",
|
||||
"category": "Food/Transport/Office/Other",
|
||||
"confidence": 0.95
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Extract vendor name as it appears on receipt
|
||||
- Total amount should be the final total including tax
|
||||
- Tax amount is separate tax line if available
|
||||
- Date should be the date on the receipt
|
||||
- Categorize based on vendor type (Starbucks=Food, Shell=Transport, etc.)
|
||||
- Confidence score 0-1 based on how clear the receipt is
|
||||
|
||||
Return only valid JSON.
|
||||
"""
|
||||
|
||||
# Call Groq vision API with correct format
|
||||
response = self.client.chat.completions.create(
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": prompt},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/jpeg;base64,{base64_image}",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
model=self.model,
|
||||
max_tokens=500,
|
||||
temperature=0.1
|
||||
)
|
||||
|
||||
# Parse response
|
||||
result_text = response.choices[0].message.content.strip()
|
||||
return self._parse_extraction_result(result_text)
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Image processing error: {str(e)}"}
|
||||
|
||||
def _encode_image(self, image_path: str) -> str:
|
||||
"""Encode image to base64 string"""
|
||||
with open(image_path, "rb") as image_file:
|
||||
return base64.b64encode(image_file.read()).decode('utf-8')
|
||||
|
||||
async def _process_pdf(self, pdf_path: str) -> Dict[str, Any]:
|
||||
"""Extract data from PDF by converting to image first"""
|
||||
try:
|
||||
# For now, extract text from PDF and process as text
|
||||
text_content = self._extract_text_from_pdf(pdf_path)
|
||||
return self._process_text_content(text_content)
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"PDF processing error: {str(e)}"}
|
||||
|
||||
def _extract_text_from_pdf(self, pdf_path: str) -> str:
|
||||
"""Extract text from PDF"""
|
||||
try:
|
||||
with open(pdf_path, 'rb') as file:
|
||||
pdf_reader = PyPDF2.PdfReader(file)
|
||||
text = ""
|
||||
for page in pdf_reader.pages:
|
||||
text += page.extract_text() + "\n"
|
||||
return text
|
||||
except Exception as e:
|
||||
return ""
|
||||
|
||||
def _process_text_content(self, text_content: str) -> Dict[str, Any]:
|
||||
"""Process text content using Groq (fallback for PDFs)"""
|
||||
try:
|
||||
prompt = f"""
|
||||
Analyze this receipt text and extract the following information in JSON format:
|
||||
|
||||
Receipt Text:
|
||||
{text_content}
|
||||
|
||||
Extract:
|
||||
{{
|
||||
"vendor": "Store/company name",
|
||||
"total_amount": 0.00,
|
||||
"tax_amount": 0.00,
|
||||
"date": "YYYY-MM-DD",
|
||||
"category": "Food/Transport/Office/Other",
|
||||
"confidence": 0.95
|
||||
}}
|
||||
|
||||
Rules:
|
||||
- Extract vendor name as it appears on receipt
|
||||
- Total amount should be the final total including tax
|
||||
- Tax amount is separate tax line if available
|
||||
- Date should be the date on the receipt
|
||||
- Categorize based on vendor type
|
||||
- Confidence score 0-1 based on clarity
|
||||
|
||||
Return only valid JSON.
|
||||
"""
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=500,
|
||||
temperature=0.1
|
||||
)
|
||||
|
||||
result_text = response.choices[0].message.content.strip()
|
||||
return self._parse_extraction_result(result_text)
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Text processing error: {str(e)}"}
|
||||
|
||||
def _parse_extraction_result(self, result_text: str) -> Dict[str, Any]:
|
||||
"""Parse Groq response and extract JSON data"""
|
||||
try:
|
||||
# Clean up response and extract JSON
|
||||
import json
|
||||
import re
|
||||
|
||||
# Find JSON in response
|
||||
json_match = re.search(r'\{.*\}', result_text, re.DOTALL)
|
||||
if json_match:
|
||||
json_str = json_match.group()
|
||||
data = json.loads(json_str)
|
||||
|
||||
# Validate and clean data
|
||||
return {
|
||||
"vendor": data.get("vendor", "").strip(),
|
||||
"total_amount": float(data.get("total_amount", 0)),
|
||||
"tax_amount": float(data.get("tax_amount", 0)),
|
||||
"date": data.get("date", ""),
|
||||
"category": data.get("category", "Other"),
|
||||
"confidence": float(data.get("confidence", 0.5)),
|
||||
"extraction_success": True
|
||||
}
|
||||
else:
|
||||
return {"error": "Could not parse JSON from AI response"}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"JSON parsing error: {str(e)}"}
|
||||
|
||||
async def save_uploaded_file(self, file_content: bytes, filename: str) -> str:
|
||||
"""Save uploaded file to temporary storage"""
|
||||
try:
|
||||
# Create uploads directory if it doesn't exist
|
||||
upload_dir = "uploads"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
safe_filename = f"{timestamp}_{filename.replace(' ', '_')}"
|
||||
file_path = os.path.join(upload_dir, safe_filename)
|
||||
|
||||
# Save file
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(file_content)
|
||||
|
||||
return file_path
|
||||
|
||||
except Exception as e:
|
||||
raise Exception(f"File save error: {str(e)}")
|
||||
@@ -0,0 +1,60 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
import json
|
||||
import os
|
||||
|
||||
@dataclass
|
||||
class FeedbackLog:
|
||||
transaction_id: str
|
||||
original_match: str
|
||||
correction: str
|
||||
reason: str
|
||||
timestamp: datetime
|
||||
user_id: str
|
||||
|
||||
class FeedbackLogger:
|
||||
def __init__(self, log_file: str = "feedback_logs.json"):
|
||||
self.log_file = log_file
|
||||
self.logs: List[FeedbackLog] = self._load_logs()
|
||||
|
||||
def _load_logs(self) -> List[FeedbackLog]:
|
||||
if not os.path.exists(self.log_file):
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(self.log_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
return [FeedbackLog(**log) for log in data]
|
||||
except:
|
||||
return []
|
||||
|
||||
def _save_logs(self):
|
||||
with open(self.log_file, 'w') as f:
|
||||
json.dump([{
|
||||
'transaction_id': log.transaction_id,
|
||||
'original_match': log.original_match,
|
||||
'correction': log.correction,
|
||||
'reason': log.reason,
|
||||
'timestamp': log.timestamp.isoformat(),
|
||||
'user_id': log.user_id
|
||||
} for log in self.logs], f, indent=2)
|
||||
|
||||
def log_override(self, transaction_id: str, original_match: str, correction: str, reason: str, user_id: str):
|
||||
log = FeedbackLog(
|
||||
transaction_id=transaction_id,
|
||||
original_match=original_match,
|
||||
correction=correction,
|
||||
reason=reason,
|
||||
timestamp=datetime.now(),
|
||||
user_id=user_id
|
||||
)
|
||||
self.logs.append(log)
|
||||
self._save_logs()
|
||||
|
||||
def get_logs_by_transaction(self, transaction_id: str) -> List[FeedbackLog]:
|
||||
return [log for log in self.logs if log.transaction_id == transaction_id]
|
||||
|
||||
def get_recent_logs(self, days: int = 30) -> List[FeedbackLog]:
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
return [log for log in self.logs if log.timestamp > cutoff]
|
||||
@@ -0,0 +1,137 @@
|
||||
import os
|
||||
import io
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class GoogleDriveSync:
|
||||
def __init__(self):
|
||||
self.service = None
|
||||
self.processed_files = set()
|
||||
|
||||
def authenticate(self):
|
||||
"""Authenticate with Google Drive API"""
|
||||
try:
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from googleapiclient.discovery import build
|
||||
|
||||
SCOPES = ['https://www.googleapis.com/auth/drive.readonly']
|
||||
|
||||
# Load existing credentials
|
||||
if os.path.exists('token.json'):
|
||||
self.creds = Credentials.from_authorized_user_file('token.json', SCOPES)
|
||||
|
||||
# If no valid credentials available, let user log in
|
||||
if not self.creds or not self.creds.valid:
|
||||
if self.creds and self.creds.expired and self.creds.refresh_token:
|
||||
self.creds.refresh(Request())
|
||||
else:
|
||||
if not os.path.exists('credentials.json'):
|
||||
raise Exception("credentials.json not found. Please download from Google Cloud Console.")
|
||||
|
||||
flow = InstalledAppFlow.from_client_secrets_file('credentials.json', SCOPES)
|
||||
self.creds = flow.run_local_server(port=0)
|
||||
|
||||
# Save credentials for next run
|
||||
with open('token.json', 'w') as token:
|
||||
token.write(self.creds.to_json())
|
||||
|
||||
# Build the Drive service
|
||||
self.service = build('drive', 'v3', credentials=self.creds)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Authentication error: {e}")
|
||||
return False
|
||||
|
||||
def list_folders(self) -> List[Dict[str, Any]]:
|
||||
"""List all folders in Google Drive"""
|
||||
if not self.service:
|
||||
if not self.authenticate():
|
||||
return []
|
||||
|
||||
try:
|
||||
results = self.service.files().list(
|
||||
q="mimeType='application/vnd.google-apps.folder'",
|
||||
pageSize=100,
|
||||
fields="nextPageToken, files(id, name, createdTime, modifiedTime)"
|
||||
).execute()
|
||||
|
||||
return results.get('files', [])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error listing folders: {e}")
|
||||
return []
|
||||
|
||||
def get_folder_info(self, folder_id: str) -> Dict[str, Any]:
|
||||
"""Get information about a Google Drive folder"""
|
||||
if not self.service:
|
||||
if not self.authenticate():
|
||||
return {}
|
||||
|
||||
try:
|
||||
folder = self.service.files().get(
|
||||
fileId=folder_id,
|
||||
fields="id, name, createdTime, modifiedTime"
|
||||
).execute()
|
||||
|
||||
return folder
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting folder info: {e}")
|
||||
return {}
|
||||
|
||||
async def process_drive_files(self, folder_id: str = None) -> List[Dict[str, Any]]:
|
||||
"""Process all receipt files from Google Drive"""
|
||||
if not self.service:
|
||||
if not self.authenticate():
|
||||
return []
|
||||
|
||||
results = []
|
||||
|
||||
try:
|
||||
# File types to look for
|
||||
file_types = ["'application/pdf'", "'image/jpeg'", "'image/png'", "'image/gif'", "'image/bmp'"]
|
||||
mime_types = " or ".join(file_types)
|
||||
|
||||
# Build query
|
||||
query = f"mimeType contains {mime_types}"
|
||||
if folder_id:
|
||||
query += f" and '{folder_id}' in parents"
|
||||
|
||||
# Add date filter (last 30 days)
|
||||
thirty_days_ago = (datetime.now() - timedelta(days=30)).isoformat() + 'Z'
|
||||
query += f" and modifiedTime > '{thirty_days_ago}'"
|
||||
|
||||
results_files = self.service.files().list(
|
||||
q=query,
|
||||
pageSize=100,
|
||||
fields="nextPageToken, files(id, name, mimeType, modifiedTime, size)"
|
||||
).execute()
|
||||
|
||||
files = results_files.get('files', [])
|
||||
files = [file for file in files if file['id'] not in self.processed_files]
|
||||
|
||||
# For demo purposes, return mock results
|
||||
for file in files[:3]: # Process first 3 files
|
||||
mock_result = {
|
||||
"file_id": file['id'],
|
||||
"filename": file['name'],
|
||||
"drive_modified": file['modifiedTime'],
|
||||
"file_size": file.get('size', 0),
|
||||
"extraction_success": True,
|
||||
"vendor": "Demo Vendor",
|
||||
"total_amount": 25.50,
|
||||
"tax_amount": 2.04,
|
||||
"date": "2024-01-15",
|
||||
"category": "Food",
|
||||
"confidence": 0.95
|
||||
}
|
||||
results.append(mock_result)
|
||||
self.processed_files.add(file['id'])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing Drive files: {e}")
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,515 @@
|
||||
from fastapi import FastAPI, HTTPException, UploadFile, File
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
import uuid
|
||||
import csv
|
||||
import io
|
||||
|
||||
from api_models import (
|
||||
MatchingRequest, MatchingResponse, MatchResponse,
|
||||
ApprovalRequest, RuleRequest, DocumentUploadResponse,
|
||||
DocumentProcessResponse, DriveSyncRequest, DriveSyncResponse,
|
||||
QuickBooksImportRequest, QuickBooksImportResponse, TransactionRequest
|
||||
)
|
||||
from models import Receipt, Transaction, Match
|
||||
from matching_engine import MatchingEngine
|
||||
from ai_rules import AIRule
|
||||
from document_processor import DocumentProcessor
|
||||
from google_drive_sync import GoogleDriveSync
|
||||
|
||||
app = FastAPI(
|
||||
title="AI Bookkeeper - Data Science Engine",
|
||||
description="AI-powered receipt-to-transaction matching engine. Receives QuickBooks data from backend and provides intelligent matching capabilities.",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Initialize DS Engine components
|
||||
matching_engine = MatchingEngine()
|
||||
document_processor = DocumentProcessor()
|
||||
drive_sync = GoogleDriveSync()
|
||||
|
||||
# In-memory storage for uploaded files (in production, use a database)
|
||||
uploaded_files = {}
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"message": "AI Bookkeeper Data Science Engine is running",
|
||||
"version": "1.0.0",
|
||||
"status": "healthy"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# QUICKBOOKS DATA IMPORT ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/transactions/import/quickbooks", response_model=QuickBooksImportResponse)
|
||||
async def import_quickbooks_transactions(request: QuickBooksImportRequest):
|
||||
"""
|
||||
Import and convert QuickBooks transactions to internal format.
|
||||
|
||||
This endpoint receives raw QuickBooks transaction data from the backend
|
||||
and converts it to the internal format used by the AI matching engine.
|
||||
"""
|
||||
try:
|
||||
converted_transactions = []
|
||||
errors = []
|
||||
|
||||
for qb_txn in request.transactions:
|
||||
try:
|
||||
# Convert QuickBooks date format to datetime
|
||||
txn_date = datetime.strptime(qb_txn.txn_date, "%Y-%m-%d")
|
||||
|
||||
# Convert to internal TransactionRequest format
|
||||
converted_txn = TransactionRequest(
|
||||
id=qb_txn.id,
|
||||
transaction_date=txn_date,
|
||||
amount=abs(qb_txn.amount), # Ensure positive amount
|
||||
vendor=qb_txn.payee_name,
|
||||
notes=qb_txn.memo or f"QuickBooks transaction from {qb_txn.account_name or 'unknown account'}"
|
||||
)
|
||||
|
||||
converted_transactions.append(converted_txn)
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error converting transaction {qb_txn.id}: {str(e)}")
|
||||
|
||||
return QuickBooksImportResponse(
|
||||
imported_count=len(converted_transactions),
|
||||
converted_transactions=converted_transactions,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/transactions/import/csv", response_model=QuickBooksImportResponse)
|
||||
async def import_quickbooks_transactions_csv(file: UploadFile = File(...)):
|
||||
"""
|
||||
Import QuickBooks transactions from a CSV file (custom bank export format).
|
||||
"""
|
||||
try:
|
||||
content = await file.read()
|
||||
decoded = content.decode('utf-8')
|
||||
reader = csv.DictReader(io.StringIO(decoded))
|
||||
transactions = []
|
||||
errors = []
|
||||
for idx, row in enumerate(reader):
|
||||
try:
|
||||
# Use correct headers and strip whitespace
|
||||
account_number = row.get('Account Number') or row.get('Account Number '.strip())
|
||||
txn_date_raw = row.get('Transaction Date') or row.get('Transaction Date '.strip())
|
||||
amount_raw = row.get('Amount') or row.get('Amount '.strip())
|
||||
payee_name = row.get('Description 2') or row.get('Description 2 '.strip())
|
||||
memo = f"{row.get('Account Type','').strip()} {row.get('Cheque Number','').strip()} {row.get('Description 1','').strip()}".strip()
|
||||
# Compose ID
|
||||
txn_id = f"{account_number}_{idx+1}"
|
||||
# Parse date (try multiple formats)
|
||||
txn_date_str = txn_date_raw.strip()
|
||||
txn_date = None
|
||||
for fmt in ("%m/%d/%y", "%m/%d/%Y"):
|
||||
try:
|
||||
txn_date = datetime.strptime(txn_date_str, fmt).strftime("%Y-%m-%d")
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if not txn_date:
|
||||
raise ValueError(f"Could not parse date: {txn_date_str}")
|
||||
# Parse amount
|
||||
amount = float(amount_raw.replace(',', '').strip())
|
||||
transactions.append({
|
||||
"id": txn_id,
|
||||
"txn_date": txn_date,
|
||||
"amount": amount,
|
||||
"payee_name": payee_name.strip(),
|
||||
"memo": memo
|
||||
})
|
||||
except Exception as e:
|
||||
errors.append(f"Row {idx+1}: {str(e)}")
|
||||
# Use the same logic as the JSON import endpoint
|
||||
request_obj = QuickBooksImportRequest(transactions=transactions)
|
||||
response = await import_quickbooks_transactions(request_obj)
|
||||
# Attach errors from CSV parsing
|
||||
if hasattr(response, 'errors'):
|
||||
response.errors.extend(errors)
|
||||
return response
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# ============================================================================
|
||||
# RECEIPT PROCESSING ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/upload", response_model=DocumentUploadResponse)
|
||||
async def upload_document(file: UploadFile = File(...)):
|
||||
"""
|
||||
Upload a receipt document (PDF or image) for processing.
|
||||
|
||||
Supports: PDF, JPG, JPEG, PNG, GIF, BMP
|
||||
"""
|
||||
try:
|
||||
# Validate file type
|
||||
allowed_types = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp']
|
||||
file_extension = file.filename.split('.')[-1].lower()
|
||||
|
||||
if file_extension not in allowed_types:
|
||||
raise HTTPException(status_code=400, detail=f"Unsupported file type. Allowed: {allowed_types}")
|
||||
|
||||
# Read file content
|
||||
file_content = await file.read()
|
||||
|
||||
# Save file
|
||||
file_path = await document_processor.save_uploaded_file(file_content, file.filename)
|
||||
|
||||
# Generate file ID
|
||||
file_id = str(uuid.uuid4())
|
||||
|
||||
# Store file info
|
||||
uploaded_files[file_id] = {
|
||||
"filename": file.filename,
|
||||
"file_path": file_path,
|
||||
"file_type": file_extension,
|
||||
"upload_date": datetime.now(),
|
||||
"status": "uploaded"
|
||||
}
|
||||
|
||||
return DocumentUploadResponse(
|
||||
file_id=file_id,
|
||||
filename=file.filename,
|
||||
file_type=file_extension,
|
||||
upload_date=datetime.now(),
|
||||
status="uploaded"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/process/{file_id}", response_model=DocumentProcessResponse)
|
||||
async def process_document(file_id: str):
|
||||
"""
|
||||
Process uploaded document and extract receipt data using AI.
|
||||
|
||||
Uses Groq LLM to extract vendor, amount, date, category from receipt images/PDFs.
|
||||
"""
|
||||
try:
|
||||
if file_id not in uploaded_files:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_info = uploaded_files[file_id]
|
||||
file_path = file_info["file_path"]
|
||||
file_type = file_info["file_type"]
|
||||
|
||||
# Process document using AI
|
||||
result = await document_processor.process_file(file_path, file_type)
|
||||
|
||||
# Update file status
|
||||
if "error" in result:
|
||||
uploaded_files[file_id]["status"] = "failed"
|
||||
else:
|
||||
uploaded_files[file_id]["status"] = "processed"
|
||||
uploaded_files[file_id]["extracted_data"] = result
|
||||
|
||||
return DocumentProcessResponse(
|
||||
file_id=file_id,
|
||||
extraction_success=result.get("extraction_success", False),
|
||||
vendor=result.get("vendor"),
|
||||
total_amount=result.get("total_amount"),
|
||||
tax_amount=result.get("tax_amount"),
|
||||
date=result.get("date"),
|
||||
category=result.get("category"),
|
||||
confidence=result.get("confidence"),
|
||||
error=result.get("error")
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/documents")
|
||||
async def list_documents():
|
||||
"""List all uploaded and processed documents"""
|
||||
try:
|
||||
documents = []
|
||||
for file_id, file_info in uploaded_files.items():
|
||||
documents.append({
|
||||
"file_id": file_id,
|
||||
"filename": file_info["filename"],
|
||||
"file_type": file_info["file_type"],
|
||||
"upload_date": file_info["upload_date"],
|
||||
"status": file_info["status"],
|
||||
"extracted_data": file_info.get("extracted_data")
|
||||
})
|
||||
|
||||
return {"documents": documents}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/upload-multiple", response_model=List[DocumentUploadResponse])
|
||||
async def upload_multiple_documents(files: List[UploadFile] = File(...)):
|
||||
"""
|
||||
Upload multiple receipt documents (PDF or image) for processing.
|
||||
Supports: PDF, JPG, JPEG, PNG, GIF, BMP
|
||||
"""
|
||||
responses = []
|
||||
allowed_types = ['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp']
|
||||
for file in files:
|
||||
try:
|
||||
file_extension = file.filename.split('.')[-1].lower()
|
||||
if file_extension not in allowed_types:
|
||||
responses.append(DocumentUploadResponse(
|
||||
file_id="",
|
||||
filename=file.filename,
|
||||
file_type=file_extension,
|
||||
upload_date=datetime.now(),
|
||||
status=f"failed: unsupported file type ({file_extension})"
|
||||
))
|
||||
continue
|
||||
file_content = await file.read()
|
||||
file_path = await document_processor.save_uploaded_file(file_content, file.filename)
|
||||
file_id = str(uuid.uuid4())
|
||||
uploaded_files[file_id] = {
|
||||
"filename": file.filename,
|
||||
"file_path": file_path,
|
||||
"file_type": file_extension,
|
||||
"upload_date": datetime.now(),
|
||||
"status": "uploaded"
|
||||
}
|
||||
responses.append(DocumentUploadResponse(
|
||||
file_id=file_id,
|
||||
filename=file.filename,
|
||||
file_type=file_extension,
|
||||
upload_date=datetime.now(),
|
||||
status="uploaded"
|
||||
))
|
||||
except Exception as e:
|
||||
responses.append(DocumentUploadResponse(
|
||||
file_id="",
|
||||
filename=file.filename,
|
||||
file_type="",
|
||||
upload_date=datetime.now(),
|
||||
status=f"failed: {str(e)}"
|
||||
))
|
||||
return responses
|
||||
|
||||
# ============================================================================
|
||||
# GOOGLE DRIVE INTEGRATION ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/drive/sync", response_model=DriveSyncResponse)
|
||||
async def sync_google_drive(request: DriveSyncRequest):
|
||||
"""
|
||||
Sync and process receipts from Google Drive folder.
|
||||
|
||||
Automatically downloads and processes all receipt files from the specified
|
||||
Google Drive folder using AI extraction.
|
||||
"""
|
||||
try:
|
||||
# Process files from Drive
|
||||
results = await drive_sync.process_drive_files(request.folder_id)
|
||||
|
||||
# Count results
|
||||
files_processed = len(results)
|
||||
successful_extractions = len([r for r in results if r.get("extraction_success", False)])
|
||||
failed_extractions = files_processed - successful_extractions
|
||||
|
||||
# Convert to response format
|
||||
response_results = []
|
||||
for result in results:
|
||||
response_results.append(DocumentProcessResponse(
|
||||
file_id=result.get("file_id", ""),
|
||||
extraction_success=result.get("extraction_success", False),
|
||||
vendor=result.get("vendor"),
|
||||
total_amount=result.get("total_amount"),
|
||||
tax_amount=result.get("tax_amount"),
|
||||
date=result.get("date"),
|
||||
category=result.get("category"),
|
||||
confidence=result.get("confidence"),
|
||||
error=result.get("error")
|
||||
))
|
||||
|
||||
return DriveSyncResponse(
|
||||
files_processed=files_processed,
|
||||
successful_extractions=successful_extractions,
|
||||
failed_extractions=failed_extractions,
|
||||
results=response_results
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/drive/folders")
|
||||
async def list_drive_folders():
|
||||
"""List all accessible Google Drive folders"""
|
||||
try:
|
||||
folders = drive_sync.list_folders()
|
||||
return {"folders": folders}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/drive/folder/{folder_id}")
|
||||
async def get_folder_info(folder_id: str):
|
||||
"""Get information about a specific Google Drive folder"""
|
||||
try:
|
||||
folder_info = drive_sync.get_folder_info(folder_id)
|
||||
return folder_info
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# ============================================================================
|
||||
# AI MATCHING ENGINE ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/match", response_model=MatchingResponse)
|
||||
async def match_receipts_transactions(request: MatchingRequest):
|
||||
"""
|
||||
Match receipts to transactions using AI.
|
||||
|
||||
Core AI matching engine that compares receipts against QuickBooks transactions
|
||||
using intelligent algorithms and returns confidence scores.
|
||||
"""
|
||||
try:
|
||||
# Convert request models to internal models
|
||||
receipts = [
|
||||
Receipt(
|
||||
id=r.id, file_name=r.file_name, upload_date=r.upload_date,
|
||||
receipt_date=r.receipt_date, amount=r.amount, tax=r.tax,
|
||||
vendor=r.vendor, category=r.category
|
||||
) for r in request.receipts
|
||||
]
|
||||
|
||||
transactions = [
|
||||
Transaction(
|
||||
id=t.id, transaction_date=t.transaction_date, amount=t.amount,
|
||||
vendor=t.vendor, notes=t.notes
|
||||
) for t in request.transactions
|
||||
]
|
||||
|
||||
# Process matching using AI engine
|
||||
matches = matching_engine.process_matching(receipts, transactions)
|
||||
|
||||
# Convert to response format
|
||||
match_responses = [
|
||||
MatchResponse(
|
||||
receipt_id=match.receipt.id,
|
||||
transaction_id=match.transaction.id,
|
||||
confidence_score=match.confidence_score,
|
||||
match_reason=match.match_reason,
|
||||
receipt_vendor=match.receipt.vendor,
|
||||
receipt_amount=match.receipt.amount,
|
||||
transaction_vendor=match.transaction.vendor,
|
||||
transaction_amount=match.transaction.amount
|
||||
) for match in matches
|
||||
]
|
||||
|
||||
# Get statistics
|
||||
stats = matching_engine.get_matching_stats(matches)
|
||||
|
||||
return MatchingResponse(matches=match_responses, stats=stats)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/approve")
|
||||
async def approve_match(request: ApprovalRequest):
|
||||
"""
|
||||
Approve or reject an AI match.
|
||||
|
||||
Logs user feedback for continuous AI improvement and learning.
|
||||
"""
|
||||
try:
|
||||
if request.action == "approve":
|
||||
return {"message": f"Match {request.match_id} approved by {request.user_id}"}
|
||||
elif request.action == "reject":
|
||||
return {"message": f"Match {request.match_id} rejected by {request.user_id}. Reason: {request.reason}"}
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Action must be 'approve' or 'reject'")
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# ============================================================================
|
||||
# AI RULES MANAGEMENT ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@app.post("/rules")
|
||||
async def add_rule(request: RuleRequest):
|
||||
"""Add a new AI rule for matching and categorization"""
|
||||
try:
|
||||
rule = AIRule(
|
||||
name=request.name,
|
||||
condition=request.condition,
|
||||
action=request.action,
|
||||
source=request.source
|
||||
)
|
||||
matching_engine.rules_engine.add_rule(rule)
|
||||
return {"message": f"Rule '{request.name}' added successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/rules")
|
||||
async def get_rules():
|
||||
"""Get all active AI rules"""
|
||||
try:
|
||||
rules = matching_engine.rules_engine.rules
|
||||
return {
|
||||
"rules": [
|
||||
{
|
||||
"name": rule.name,
|
||||
"condition": rule.condition,
|
||||
"action": rule.action,
|
||||
"source": rule.source,
|
||||
"status": rule.status
|
||||
} for rule in rules
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.delete("/rules/{rule_name}")
|
||||
async def delete_rule(rule_name: str):
|
||||
"""Delete an AI rule"""
|
||||
try:
|
||||
matching_engine.rules_engine.remove_rule(rule_name)
|
||||
return {"message": f"Rule '{rule_name}' deleted successfully"}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# ============================================================================
|
||||
# SYSTEM MONITORING ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/stats")
|
||||
async def get_stats():
|
||||
"""Get system statistics and performance metrics"""
|
||||
try:
|
||||
recent_logs = matching_engine.feedback_logger.get_recent_logs(30)
|
||||
return {
|
||||
"total_feedback_logs": len(matching_engine.feedback_logger.logs),
|
||||
"recent_feedback_logs": len(recent_logs),
|
||||
"active_rules": len([r for r in matching_engine.rules_engine.rules if r.status == "active"]),
|
||||
"uploaded_documents": len(uploaded_files),
|
||||
"processed_documents": len([f for f in uploaded_files.values() if f["status"] == "processed"])
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8343)
|
||||
@@ -0,0 +1,73 @@
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
from ai_matcher import AIMatcher
|
||||
from ai_rules import AIRulesEngine
|
||||
from feedback_logger import FeedbackLogger
|
||||
from models import Receipt, Transaction, Match
|
||||
|
||||
class MatchingEngine:
|
||||
def __init__(self):
|
||||
self.ai_matcher = AIMatcher()
|
||||
self.rules_engine = AIRulesEngine()
|
||||
self.feedback_logger = FeedbackLogger()
|
||||
|
||||
def process_matching(self, receipts: List[Receipt], transactions: List[Transaction]) -> List[Match]:
|
||||
# Get AI matches
|
||||
ai_matches = self.ai_matcher.match_receipts_to_transactions(receipts, transactions)
|
||||
|
||||
# Apply rules and enhance matches
|
||||
enhanced_matches = []
|
||||
for match in ai_matches:
|
||||
enhanced_match = self._enhance_match_with_rules(match)
|
||||
enhanced_matches.append(enhanced_match)
|
||||
|
||||
return enhanced_matches
|
||||
|
||||
def _enhance_match_with_rules(self, match: Match) -> Match:
|
||||
rule_results = self.rules_engine.apply_rules(match.receipt, match.transaction)
|
||||
|
||||
# Apply confidence boost from rules
|
||||
if rule_results["confidence_boost"] > 0:
|
||||
match.confidence_score = min(1.0, match.confidence_score + rule_results["confidence_boost"])
|
||||
|
||||
# Auto-approve if rules say so
|
||||
if rule_results["auto_approve"]:
|
||||
match.confidence_score = 1.0
|
||||
match.match_reason += " (Auto-approved by rules)"
|
||||
|
||||
return match
|
||||
|
||||
def approve_match(self, match: Match, user_id: str):
|
||||
# Log the approval
|
||||
self.feedback_logger.log_override(
|
||||
transaction_id=match.transaction.id,
|
||||
original_match=f"AI Score: {match.confidence_score}",
|
||||
correction="Approved",
|
||||
reason="User approved match",
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
def reject_match(self, match: Match, reason: str, user_id: str):
|
||||
# Log the rejection
|
||||
self.feedback_logger.log_override(
|
||||
transaction_id=match.transaction.id,
|
||||
original_match=f"AI Score: {match.confidence_score}",
|
||||
correction="Rejected",
|
||||
reason=reason,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
def get_matching_stats(self, matches: List[Match]) -> Dict[str, Any]:
|
||||
if not matches:
|
||||
return {"total": 0, "high_confidence": 0, "low_confidence": 0, "avg_score": 0}
|
||||
|
||||
high_confidence = len([m for m in matches if m.confidence_score >= 0.8])
|
||||
low_confidence = len([m for m in matches if m.confidence_score < 0.8])
|
||||
avg_score = sum(m.confidence_score for m in matches) / len(matches)
|
||||
|
||||
return {
|
||||
"total": len(matches),
|
||||
"high_confidence": high_confidence,
|
||||
"low_confidence": low_confidence,
|
||||
"avg_score": round(avg_score, 3)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@dataclass
|
||||
class Receipt:
|
||||
id: str
|
||||
file_name: str
|
||||
upload_date: datetime
|
||||
receipt_date: datetime
|
||||
amount: float
|
||||
tax: float
|
||||
vendor: str
|
||||
category: str
|
||||
|
||||
@dataclass
|
||||
class Transaction:
|
||||
id: str
|
||||
transaction_date: datetime
|
||||
amount: float
|
||||
vendor: str
|
||||
notes: str
|
||||
|
||||
@dataclass
|
||||
class Match:
|
||||
receipt: Receipt
|
||||
transaction: Transaction
|
||||
confidence_score: float
|
||||
match_reason: str
|
||||
@@ -0,0 +1,16 @@
|
||||
groq>=0.5.0
|
||||
python-dotenv==1.0.0
|
||||
pandas==2.1.4
|
||||
numpy==1.24.3
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.24.0
|
||||
pydantic==2.5.0
|
||||
requests==2.31.0
|
||||
python-multipart==0.0.6
|
||||
Pillow==10.0.1
|
||||
PyPDF2==3.0.1
|
||||
aiofiles==23.2.1
|
||||
google-auth==2.23.4
|
||||
google-auth-oauthlib==1.1.0
|
||||
google-auth-httplib2==0.1.1
|
||||
google-api-python-client==2.108.0
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
import requests
|
||||
import csv
|
||||
from dateutil import parser
|
||||
|
||||
# Prepare transactions
|
||||
transactions = []
|
||||
with open("chequing statement.csv", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
idx = 1
|
||||
for row in reader:
|
||||
try:
|
||||
txn_id = f"{row['Account Number']}_{idx}"
|
||||
txn_date = parser.parse(row["Transaction Date"]).isoformat()
|
||||
amount = float(row["Amount"].replace(",", "").strip())
|
||||
vendor = row["Description 2"].strip()
|
||||
notes = f"{row['Account Type']} {row['Cheque Number']} {row['Description 1']}".strip()
|
||||
transactions.append({
|
||||
"id": txn_id,
|
||||
"transaction_date": txn_date,
|
||||
"amount": amount,
|
||||
"vendor": vendor,
|
||||
"notes": notes
|
||||
})
|
||||
idx += 1
|
||||
except Exception as e:
|
||||
continue
|
||||
|
||||
# Receipt data for Ajai Invoice (3).jpg
|
||||
receipt = {
|
||||
"id": "33754868-bff5-4caf-9ece-cfd63f4e52d9",
|
||||
"file_name": "Ajai Invoice (3).jpg",
|
||||
"upload_date": "2025-07-02T15:31:23.641315",
|
||||
"receipt_date": "2025-02-07T00:00:00",
|
||||
"amount": 1412.5,
|
||||
"tax": 162.5,
|
||||
"vendor": "Ajai Srivastava CPA, Accounting Services & Taxes",
|
||||
"category": "Office"
|
||||
}
|
||||
|
||||
# Build request
|
||||
data = {
|
||||
"receipts": [receipt],
|
||||
"transactions": transactions
|
||||
}
|
||||
|
||||
# Post to /match
|
||||
response = requests.post("http://localhost:8000/match", json=data)
|
||||
print(json.dumps(response.json(), indent=2))
|
||||
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,247 @@
|
||||
<#
|
||||
.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 VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# 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 virtual 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
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
VIRTUAL_ENV="/Users/user/mkd/quickbooks/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
|
||||
VIRTUAL_ENV_PROMPT="(venv) "
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# This should detect bash and zsh, which have a hash command that must
|
||||
# be called to get it to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
if [ -n "${BASH:-}" -o -n "${ZSH_VERSION:-}" ] ; then
|
||||
hash -r 2> /dev/null
|
||||
fi
|
||||
@@ -0,0 +1,26 @@
|
||||
# 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; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV "/Users/user/mkd/quickbooks/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"
|
||||
setenv VIRTUAL_ENV_PROMPT "(venv) "
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
@@ -0,0 +1,69 @@
|
||||
# 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"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV "/Users/user/mkd/quickbooks/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"
|
||||
set -gx VIRTUAL_ENV_PROMPT "(venv) "
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from distro.distro import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from dotenv.__main__ import cli
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cli())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from numpy.f2py.f2py2e import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from numpy.f2py.f2py2e import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from numpy.f2py.f2py2e import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from fastapi.cli import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from google_auth_oauthlib.tool.__main__ import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from httpx import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from charset_normalizer import cli
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(cli.cli_detect())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.cli import decrypt
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(decrypt())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.cli import encrypt
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(encrypt())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.cli import keygen
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(keygen())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.util import private_to_public
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(private_to_public())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.cli import sign
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(sign())
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from rsa.cli import verify
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(verify())
|
||||
@@ -0,0 +1 @@
|
||||
python3
|
||||
@@ -0,0 +1 @@
|
||||
/Users/user/anaconda3/bin/python3
|
||||
@@ -0,0 +1 @@
|
||||
python3
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/mkd/quickbooks/venv/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from uvicorn.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,292 @@
|
||||
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"
|
||||
# Decoding is only affected by this for libavif **0.8.4** or greater.
|
||||
DEFAULT_MAX_THREADS = 0
|
||||
|
||||
|
||||
def get_codec_version(codec_name: str) -> str | None:
|
||||
versions = _avif.codec_versions()
|
||||
for version in versions.split(", "):
|
||||
if version.split(" [")[0] == codec_name:
|
||||
return version.split(":")[-1].split(" ")[0]
|
||||
return None
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool | str:
|
||||
if prefix[4:8] != b"ftyp":
|
||||
return False
|
||||
major_brand = prefix[8:12]
|
||||
if major_brand in (
|
||||
# coding brands
|
||||
b"avif",
|
||||
b"avis",
|
||||
# We accept files with AVIF container brands; we can't yet know if
|
||||
# the ftyp box has the correct compatible brands, but if it doesn't
|
||||
# then the plugin will raise a SyntaxError which Pillow will catch
|
||||
# before moving on to the next plugin that accepts the file.
|
||||
#
|
||||
# Also, because this file might not actually be an AVIF file, we
|
||||
# don't raise an error if AVIF support isn't properly compiled.
|
||||
b"mif1",
|
||||
b"msf1",
|
||||
):
|
||||
if not SUPPORTED:
|
||||
return (
|
||||
"image file could not be identified because AVIF support not installed"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_default_max_threads() -> int:
|
||||
if DEFAULT_MAX_THREADS:
|
||||
return DEFAULT_MAX_THREADS
|
||||
if hasattr(os, "sched_getaffinity"):
|
||||
return len(os.sched_getaffinity(0))
|
||||
else:
|
||||
return os.cpu_count() or 1
|
||||
|
||||
|
||||
class AvifImageFile(ImageFile.ImageFile):
|
||||
format = "AVIF"
|
||||
format_description = "AVIF image"
|
||||
__frame = -1
|
||||
|
||||
def _open(self) -> None:
|
||||
if not SUPPORTED:
|
||||
msg = "image file could not be opened because AVIF support not installed"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
if DECODE_CODEC_CHOICE != "auto" and not _avif.decoder_codec_available(
|
||||
DECODE_CODEC_CHOICE
|
||||
):
|
||||
msg = "Invalid opening codec"
|
||||
raise ValueError(msg)
|
||||
self._decoder = _avif.AvifDecoder(
|
||||
self.fp.read(),
|
||||
DECODE_CODEC_CHOICE,
|
||||
_get_default_max_threads(),
|
||||
)
|
||||
|
||||
# Get info from decoder
|
||||
self._size, self.n_frames, self._mode, icc, exif, exif_orientation, xmp = (
|
||||
self._decoder.get_info()
|
||||
)
|
||||
self.is_animated = self.n_frames > 1
|
||||
|
||||
if icc:
|
||||
self.info["icc_profile"] = icc
|
||||
if xmp:
|
||||
self.info["xmp"] = xmp
|
||||
|
||||
if exif_orientation != 1 or exif:
|
||||
exif_data = Image.Exif()
|
||||
if exif:
|
||||
exif_data.load(exif)
|
||||
original_orientation = exif_data.get(ExifTags.Base.Orientation, 1)
|
||||
else:
|
||||
original_orientation = 1
|
||||
if exif_orientation != original_orientation:
|
||||
exif_data[ExifTags.Base.Orientation] = exif_orientation
|
||||
exif = exif_data.tobytes()
|
||||
if exif:
|
||||
self.info["exif"] = exif
|
||||
self.seek(0)
|
||||
|
||||
def seek(self, frame: int) -> None:
|
||||
if not self._seek_check(frame):
|
||||
return
|
||||
|
||||
# Set tile
|
||||
self.__frame = frame
|
||||
self.tile = [ImageFile._Tile("raw", (0, 0) + self.size, 0, self.mode)]
|
||||
|
||||
def load(self) -> Image.core.PixelAccess | None:
|
||||
if self.tile:
|
||||
# We need to load the image data for this frame
|
||||
data, timescale, pts_in_timescales, duration_in_timescales = (
|
||||
self._decoder.get_frame(self.__frame)
|
||||
)
|
||||
self.info["timestamp"] = round(1000 * (pts_in_timescales / timescale))
|
||||
self.info["duration"] = round(1000 * (duration_in_timescales / timescale))
|
||||
|
||||
if self.fp and self._exclusive_fp:
|
||||
self.fp.close()
|
||||
self.fp = BytesIO(data)
|
||||
|
||||
return super().load()
|
||||
|
||||
def load_seek(self, pos: int) -> None:
|
||||
pass
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.__frame
|
||||
|
||||
|
||||
def _save_all(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
_save(im, fp, filename, save_all=True)
|
||||
|
||||
|
||||
def _save(
|
||||
im: Image.Image, fp: IO[bytes], filename: str | bytes, save_all: bool = False
|
||||
) -> None:
|
||||
info = im.encoderinfo.copy()
|
||||
if save_all:
|
||||
append_images = list(info.get("append_images", []))
|
||||
else:
|
||||
append_images = []
|
||||
|
||||
total = 0
|
||||
for ims in [im] + append_images:
|
||||
total += getattr(ims, "n_frames", 1)
|
||||
|
||||
quality = info.get("quality", 75)
|
||||
if not isinstance(quality, int) or quality < 0 or quality > 100:
|
||||
msg = "Invalid quality setting"
|
||||
raise ValueError(msg)
|
||||
|
||||
duration = info.get("duration", 0)
|
||||
subsampling = info.get("subsampling", "4:2:0")
|
||||
speed = info.get("speed", 6)
|
||||
max_threads = info.get("max_threads", _get_default_max_threads())
|
||||
codec = info.get("codec", "auto")
|
||||
if codec != "auto" and not _avif.encoder_codec_available(codec):
|
||||
msg = "Invalid saving codec"
|
||||
raise ValueError(msg)
|
||||
range_ = info.get("range", "full")
|
||||
tile_rows_log2 = info.get("tile_rows", 0)
|
||||
tile_cols_log2 = info.get("tile_cols", 0)
|
||||
alpha_premultiplied = bool(info.get("alpha_premultiplied", False))
|
||||
autotiling = bool(info.get("autotiling", tile_rows_log2 == tile_cols_log2 == 0))
|
||||
|
||||
icc_profile = info.get("icc_profile", im.info.get("icc_profile"))
|
||||
exif_orientation = 1
|
||||
if exif := info.get("exif"):
|
||||
if isinstance(exif, Image.Exif):
|
||||
exif_data = exif
|
||||
else:
|
||||
exif_data = Image.Exif()
|
||||
exif_data.load(exif)
|
||||
if ExifTags.Base.Orientation in exif_data:
|
||||
exif_orientation = exif_data.pop(ExifTags.Base.Orientation)
|
||||
exif = exif_data.tobytes() if exif_data else b""
|
||||
elif isinstance(exif, Image.Exif):
|
||||
exif = exif_data.tobytes()
|
||||
|
||||
xmp = info.get("xmp")
|
||||
|
||||
if isinstance(xmp, str):
|
||||
xmp = xmp.encode("utf-8")
|
||||
|
||||
advanced = info.get("advanced")
|
||||
if advanced is not None:
|
||||
if isinstance(advanced, dict):
|
||||
advanced = advanced.items()
|
||||
try:
|
||||
advanced = tuple(advanced)
|
||||
except TypeError:
|
||||
invalid = True
|
||||
else:
|
||||
invalid = any(not isinstance(v, tuple) or len(v) != 2 for v in advanced)
|
||||
if invalid:
|
||||
msg = (
|
||||
"advanced codec options must be a dict of key-value string "
|
||||
"pairs or a series of key-value two-tuples"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
# Setup the AVIF encoder
|
||||
enc = _avif.AvifEncoder(
|
||||
im.size,
|
||||
subsampling,
|
||||
quality,
|
||||
speed,
|
||||
max_threads,
|
||||
codec,
|
||||
range_,
|
||||
tile_rows_log2,
|
||||
tile_cols_log2,
|
||||
alpha_premultiplied,
|
||||
autotiling,
|
||||
icc_profile or b"",
|
||||
exif or b"",
|
||||
exif_orientation,
|
||||
xmp or b"",
|
||||
advanced,
|
||||
)
|
||||
|
||||
# Add each frame
|
||||
frame_idx = 0
|
||||
frame_duration = 0
|
||||
cur_idx = im.tell()
|
||||
is_single_frame = total == 1
|
||||
try:
|
||||
for ims in [im] + append_images:
|
||||
# Get number of frames in this image
|
||||
nfr = getattr(ims, "n_frames", 1)
|
||||
|
||||
for idx in range(nfr):
|
||||
ims.seek(idx)
|
||||
|
||||
# Make sure image mode is supported
|
||||
frame = ims
|
||||
rawmode = ims.mode
|
||||
if ims.mode not in {"RGB", "RGBA"}:
|
||||
rawmode = "RGBA" if ims.has_transparency_data else "RGB"
|
||||
frame = ims.convert(rawmode)
|
||||
|
||||
# Update frame duration
|
||||
if isinstance(duration, (list, tuple)):
|
||||
frame_duration = duration[frame_idx]
|
||||
else:
|
||||
frame_duration = duration
|
||||
|
||||
# Append the frame to the animation encoder
|
||||
enc.add(
|
||||
frame.tobytes("raw", rawmode),
|
||||
frame_duration,
|
||||
frame.size,
|
||||
rawmode,
|
||||
is_single_frame,
|
||||
)
|
||||
|
||||
# Update frame index
|
||||
frame_idx += 1
|
||||
|
||||
if not save_all:
|
||||
break
|
||||
|
||||
finally:
|
||||
im.seek(cur_idx)
|
||||
|
||||
# Get the final output from the encoder
|
||||
data = enc.finish()
|
||||
if data is None:
|
||||
msg = "cannot write file as AVIF (encoder returned None)"
|
||||
raise OSError(msg)
|
||||
|
||||
fp.write(data)
|
||||
|
||||
|
||||
Image.register_open(AvifImageFile.format, AvifImageFile, _accept)
|
||||
if SUPPORTED:
|
||||
Image.register_save(AvifImageFile.format, _save)
|
||||
Image.register_save_all(AvifImageFile.format, _save_all)
|
||||
Image.register_extensions(AvifImageFile.format, [".avif", ".avifs"])
|
||||
Image.register_mime(AvifImageFile.format, "image/avif")
|
||||
@@ -0,0 +1,122 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# bitmap distribution font (bdf) file parser
|
||||
#
|
||||
# history:
|
||||
# 1996-05-16 fl created (as bdf2pil)
|
||||
# 1997-08-25 fl converted to FontFile driver
|
||||
# 2001-05-25 fl removed bogus __init__ call
|
||||
# 2002-11-20 fl robustification (from Kevin Cazabon, Dmitry Vasiliev)
|
||||
# 2003-04-22 fl more robustification (from Graham Dumpleton)
|
||||
#
|
||||
# Copyright (c) 1997-2003 by Secret Labs AB.
|
||||
# Copyright (c) 1997-2003 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
"""
|
||||
Parse X Bitmap Distribution Format (BDF)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import BinaryIO
|
||||
|
||||
from . import FontFile, Image
|
||||
|
||||
|
||||
def bdf_char(
|
||||
f: BinaryIO,
|
||||
) -> (
|
||||
tuple[
|
||||
str,
|
||||
int,
|
||||
tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]],
|
||||
Image.Image,
|
||||
]
|
||||
| None
|
||||
):
|
||||
# skip to STARTCHAR
|
||||
while True:
|
||||
s = f.readline()
|
||||
if not s:
|
||||
return None
|
||||
if s.startswith(b"STARTCHAR"):
|
||||
break
|
||||
id = s[9:].strip().decode("ascii")
|
||||
|
||||
# load symbol properties
|
||||
props = {}
|
||||
while True:
|
||||
s = f.readline()
|
||||
if not s or s.startswith(b"BITMAP"):
|
||||
break
|
||||
i = s.find(b" ")
|
||||
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
|
||||
|
||||
# load bitmap
|
||||
bitmap = bytearray()
|
||||
while True:
|
||||
s = f.readline()
|
||||
if not s or s.startswith(b"ENDCHAR"):
|
||||
break
|
||||
bitmap += s[:-1]
|
||||
|
||||
# The word BBX
|
||||
# followed by the width in x (BBw), height in y (BBh),
|
||||
# and x and y displacement (BBxoff0, BByoff0)
|
||||
# of the lower left corner from the origin of the character.
|
||||
width, height, x_disp, y_disp = (int(p) for p in props["BBX"].split())
|
||||
|
||||
# The word DWIDTH
|
||||
# followed by the width in x and y of the character in device pixels.
|
||||
dwx, dwy = (int(p) for p in props["DWIDTH"].split())
|
||||
|
||||
bbox = (
|
||||
(dwx, dwy),
|
||||
(x_disp, -y_disp - height, width + x_disp, -y_disp),
|
||||
(0, 0, width, height),
|
||||
)
|
||||
|
||||
try:
|
||||
im = Image.frombytes("1", (width, height), bitmap, "hex", "1")
|
||||
except ValueError:
|
||||
# deal with zero-width characters
|
||||
im = Image.new("1", (width, height))
|
||||
|
||||
return id, int(props["ENCODING"]), bbox, im
|
||||
|
||||
|
||||
class BdfFontFile(FontFile.FontFile):
|
||||
"""Font file plugin for the X11 BDF format."""
|
||||
|
||||
def __init__(self, fp: BinaryIO) -> None:
|
||||
super().__init__()
|
||||
|
||||
s = fp.readline()
|
||||
if not s.startswith(b"STARTFONT 2.1"):
|
||||
msg = "not a valid BDF file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
props = {}
|
||||
comments = []
|
||||
|
||||
while True:
|
||||
s = fp.readline()
|
||||
if not s or s.startswith(b"ENDPROPERTIES"):
|
||||
break
|
||||
i = s.find(b" ")
|
||||
props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii")
|
||||
if s[:i] in [b"COMMENT", b"COPYRIGHT"]:
|
||||
if s.find(b"LogicalFontDescription") < 0:
|
||||
comments.append(s[i + 1 : -1].decode("ascii"))
|
||||
|
||||
while True:
|
||||
c = bdf_char(fp)
|
||||
if not c:
|
||||
break
|
||||
id, ch, (xy, dst, src), im = c
|
||||
if 0 <= ch < len(self.glyph):
|
||||
self.glyph[ch] = xy, dst, src, im
|
||||
@@ -0,0 +1,497 @@
|
||||
"""
|
||||
Blizzard Mipmap Format (.blp)
|
||||
Jerome Leclanche <jerome@leclan.ch>
|
||||
|
||||
The contents of this file are hereby released in the public domain (CC0)
|
||||
Full text of the CC0 license:
|
||||
https://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
BLP1 files, used mostly in Warcraft III, are not fully supported.
|
||||
All types of BLP2 files used in World of Warcraft are supported.
|
||||
|
||||
The BLP file structure consists of a header, up to 16 mipmaps of the
|
||||
texture
|
||||
|
||||
Texture sizes must be powers of two, though the two dimensions do
|
||||
not have to be equal; 512x256 is valid, but 512x200 is not.
|
||||
The first mipmap (mipmap #0) is the full size image; each subsequent
|
||||
mipmap halves both dimensions. The final mipmap should be 1x1.
|
||||
|
||||
BLP files come in many different flavours:
|
||||
* JPEG-compressed (type == 0) - only supported for BLP1.
|
||||
* RAW images (type == 1, encoding == 1). Each mipmap is stored as an
|
||||
array of 8-bit values, one per pixel, left to right, top to bottom.
|
||||
Each value is an index to the palette.
|
||||
* DXT-compressed (type == 1, encoding == 2):
|
||||
- DXT1 compression is used if alpha_encoding == 0.
|
||||
- An additional alpha bit is used if alpha_depth == 1.
|
||||
- DXT3 compression is used if alpha_encoding == 1.
|
||||
- DXT5 compression is used if alpha_encoding == 7.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import os
|
||||
import struct
|
||||
from enum import IntEnum
|
||||
from io import BytesIO
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
|
||||
class Format(IntEnum):
|
||||
JPEG = 0
|
||||
|
||||
|
||||
class Encoding(IntEnum):
|
||||
UNCOMPRESSED = 1
|
||||
DXT = 2
|
||||
UNCOMPRESSED_RAW_BGRA = 3
|
||||
|
||||
|
||||
class AlphaEncoding(IntEnum):
|
||||
DXT1 = 0
|
||||
DXT3 = 1
|
||||
DXT5 = 7
|
||||
|
||||
|
||||
def unpack_565(i: int) -> tuple[int, int, int]:
|
||||
return ((i >> 11) & 0x1F) << 3, ((i >> 5) & 0x3F) << 2, (i & 0x1F) << 3
|
||||
|
||||
|
||||
def decode_dxt1(
|
||||
data: bytes, alpha: bool = False
|
||||
) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||
"""
|
||||
input: one "row" of data (i.e. will produce 4*width pixels)
|
||||
"""
|
||||
|
||||
blocks = len(data) // 8 # number of blocks in row
|
||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||
|
||||
for block_index in range(blocks):
|
||||
# Decode next 8-byte block.
|
||||
idx = block_index * 8
|
||||
color0, color1, bits = struct.unpack_from("<HHI", data, idx)
|
||||
|
||||
r0, g0, b0 = unpack_565(color0)
|
||||
r1, g1, b1 = unpack_565(color1)
|
||||
|
||||
# Decode this block into 4x4 pixels
|
||||
# Accumulate the results onto our 4 row accumulators
|
||||
for j in range(4):
|
||||
for i in range(4):
|
||||
# get next control op and generate a pixel
|
||||
|
||||
control = bits & 3
|
||||
bits = bits >> 2
|
||||
|
||||
a = 0xFF
|
||||
if control == 0:
|
||||
r, g, b = r0, g0, b0
|
||||
elif control == 1:
|
||||
r, g, b = r1, g1, b1
|
||||
elif control == 2:
|
||||
if color0 > color1:
|
||||
r = (2 * r0 + r1) // 3
|
||||
g = (2 * g0 + g1) // 3
|
||||
b = (2 * b0 + b1) // 3
|
||||
else:
|
||||
r = (r0 + r1) // 2
|
||||
g = (g0 + g1) // 2
|
||||
b = (b0 + b1) // 2
|
||||
elif control == 3:
|
||||
if color0 > color1:
|
||||
r = (2 * r1 + r0) // 3
|
||||
g = (2 * g1 + g0) // 3
|
||||
b = (2 * b1 + b0) // 3
|
||||
else:
|
||||
r, g, b, a = 0, 0, 0, 0
|
||||
|
||||
if alpha:
|
||||
ret[j].extend([r, g, b, a])
|
||||
else:
|
||||
ret[j].extend([r, g, b])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def decode_dxt3(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||
"""
|
||||
input: one "row" of data (i.e. will produce 4*width pixels)
|
||||
"""
|
||||
|
||||
blocks = len(data) // 16 # number of blocks in row
|
||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||
|
||||
for block_index in range(blocks):
|
||||
idx = block_index * 16
|
||||
block = data[idx : idx + 16]
|
||||
# Decode next 16-byte block.
|
||||
bits = struct.unpack_from("<8B", block)
|
||||
color0, color1 = struct.unpack_from("<HH", block, 8)
|
||||
|
||||
(code,) = struct.unpack_from("<I", block, 12)
|
||||
|
||||
r0, g0, b0 = unpack_565(color0)
|
||||
r1, g1, b1 = unpack_565(color1)
|
||||
|
||||
for j in range(4):
|
||||
high = False # Do we want the higher bits?
|
||||
for i in range(4):
|
||||
alphacode_index = (4 * j + i) // 2
|
||||
a = bits[alphacode_index]
|
||||
if high:
|
||||
high = False
|
||||
a >>= 4
|
||||
else:
|
||||
high = True
|
||||
a &= 0xF
|
||||
a *= 17 # We get a value between 0 and 15
|
||||
|
||||
color_code = (code >> 2 * (4 * j + i)) & 0x03
|
||||
|
||||
if color_code == 0:
|
||||
r, g, b = r0, g0, b0
|
||||
elif color_code == 1:
|
||||
r, g, b = r1, g1, b1
|
||||
elif color_code == 2:
|
||||
r = (2 * r0 + r1) // 3
|
||||
g = (2 * g0 + g1) // 3
|
||||
b = (2 * b0 + b1) // 3
|
||||
elif color_code == 3:
|
||||
r = (2 * r1 + r0) // 3
|
||||
g = (2 * g1 + g0) // 3
|
||||
b = (2 * b1 + b0) // 3
|
||||
|
||||
ret[j].extend([r, g, b, a])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def decode_dxt5(data: bytes) -> tuple[bytearray, bytearray, bytearray, bytearray]:
|
||||
"""
|
||||
input: one "row" of data (i.e. will produce 4 * width pixels)
|
||||
"""
|
||||
|
||||
blocks = len(data) // 16 # number of blocks in row
|
||||
ret = (bytearray(), bytearray(), bytearray(), bytearray())
|
||||
|
||||
for block_index in range(blocks):
|
||||
idx = block_index * 16
|
||||
block = data[idx : idx + 16]
|
||||
# Decode next 16-byte block.
|
||||
a0, a1 = struct.unpack_from("<BB", block)
|
||||
|
||||
bits = struct.unpack_from("<6B", block, 2)
|
||||
alphacode1 = bits[2] | (bits[3] << 8) | (bits[4] << 16) | (bits[5] << 24)
|
||||
alphacode2 = bits[0] | (bits[1] << 8)
|
||||
|
||||
color0, color1 = struct.unpack_from("<HH", block, 8)
|
||||
|
||||
(code,) = struct.unpack_from("<I", block, 12)
|
||||
|
||||
r0, g0, b0 = unpack_565(color0)
|
||||
r1, g1, b1 = unpack_565(color1)
|
||||
|
||||
for j in range(4):
|
||||
for i in range(4):
|
||||
# get next control op and generate a pixel
|
||||
alphacode_index = 3 * (4 * j + i)
|
||||
|
||||
if alphacode_index <= 12:
|
||||
alphacode = (alphacode2 >> alphacode_index) & 0x07
|
||||
elif alphacode_index == 15:
|
||||
alphacode = (alphacode2 >> 15) | ((alphacode1 << 1) & 0x06)
|
||||
else: # alphacode_index >= 18 and alphacode_index <= 45
|
||||
alphacode = (alphacode1 >> (alphacode_index - 16)) & 0x07
|
||||
|
||||
if alphacode == 0:
|
||||
a = a0
|
||||
elif alphacode == 1:
|
||||
a = a1
|
||||
elif a0 > a1:
|
||||
a = ((8 - alphacode) * a0 + (alphacode - 1) * a1) // 7
|
||||
elif alphacode == 6:
|
||||
a = 0
|
||||
elif alphacode == 7:
|
||||
a = 255
|
||||
else:
|
||||
a = ((6 - alphacode) * a0 + (alphacode - 1) * a1) // 5
|
||||
|
||||
color_code = (code >> 2 * (4 * j + i)) & 0x03
|
||||
|
||||
if color_code == 0:
|
||||
r, g, b = r0, g0, b0
|
||||
elif color_code == 1:
|
||||
r, g, b = r1, g1, b1
|
||||
elif color_code == 2:
|
||||
r = (2 * r0 + r1) // 3
|
||||
g = (2 * g0 + g1) // 3
|
||||
b = (2 * b0 + b1) // 3
|
||||
elif color_code == 3:
|
||||
r = (2 * r1 + r0) // 3
|
||||
g = (2 * g1 + g0) // 3
|
||||
b = (2 * b1 + b0) // 3
|
||||
|
||||
ret[j].extend([r, g, b, a])
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class BLPFormatError(NotImplementedError):
|
||||
pass
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith((b"BLP1", b"BLP2"))
|
||||
|
||||
|
||||
class BlpImageFile(ImageFile.ImageFile):
|
||||
"""
|
||||
Blizzard Mipmap Format
|
||||
"""
|
||||
|
||||
format = "BLP"
|
||||
format_description = "Blizzard Mipmap Format"
|
||||
|
||||
def _open(self) -> None:
|
||||
self.magic = self.fp.read(4)
|
||||
if not _accept(self.magic):
|
||||
msg = f"Bad BLP magic {repr(self.magic)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
compression = struct.unpack("<i", self.fp.read(4))[0]
|
||||
if self.magic == b"BLP1":
|
||||
alpha = struct.unpack("<I", self.fp.read(4))[0] != 0
|
||||
else:
|
||||
encoding = struct.unpack("<b", self.fp.read(1))[0]
|
||||
alpha = struct.unpack("<b", self.fp.read(1))[0] != 0
|
||||
alpha_encoding = struct.unpack("<b", self.fp.read(1))[0]
|
||||
self.fp.seek(1, os.SEEK_CUR) # mips
|
||||
|
||||
self._size = struct.unpack("<II", self.fp.read(8))
|
||||
|
||||
args: tuple[int, int, bool] | tuple[int, int, bool, int]
|
||||
if self.magic == b"BLP1":
|
||||
encoding = struct.unpack("<i", self.fp.read(4))[0]
|
||||
self.fp.seek(4, os.SEEK_CUR) # subtype
|
||||
|
||||
args = (compression, encoding, alpha)
|
||||
offset = 28
|
||||
else:
|
||||
args = (compression, encoding, alpha, alpha_encoding)
|
||||
offset = 20
|
||||
|
||||
decoder = self.magic.decode()
|
||||
|
||||
self._mode = "RGBA" if alpha else "RGB"
|
||||
self.tile = [ImageFile._Tile(decoder, (0, 0) + self.size, offset, args)]
|
||||
|
||||
|
||||
class _BLPBaseDecoder(abc.ABC, ImageFile.PyDecoder):
|
||||
_pulls_fd = True
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
try:
|
||||
self._read_header()
|
||||
self._load()
|
||||
except struct.error as e:
|
||||
msg = "Truncated BLP file"
|
||||
raise OSError(msg) from e
|
||||
return -1, 0
|
||||
|
||||
@abc.abstractmethod
|
||||
def _load(self) -> None:
|
||||
pass
|
||||
|
||||
def _read_header(self) -> None:
|
||||
self._offsets = struct.unpack("<16I", self._safe_read(16 * 4))
|
||||
self._lengths = struct.unpack("<16I", self._safe_read(16 * 4))
|
||||
|
||||
def _safe_read(self, length: int) -> bytes:
|
||||
assert self.fd is not None
|
||||
return ImageFile._safe_read(self.fd, length)
|
||||
|
||||
def _read_palette(self) -> list[tuple[int, int, int, int]]:
|
||||
ret = []
|
||||
for i in range(256):
|
||||
try:
|
||||
b, g, r, a = struct.unpack("<4B", self._safe_read(4))
|
||||
except struct.error:
|
||||
break
|
||||
ret.append((b, g, r, a))
|
||||
return ret
|
||||
|
||||
def _read_bgra(
|
||||
self, palette: list[tuple[int, int, int, int]], alpha: bool
|
||||
) -> bytearray:
|
||||
data = bytearray()
|
||||
_data = BytesIO(self._safe_read(self._lengths[0]))
|
||||
while True:
|
||||
try:
|
||||
(offset,) = struct.unpack("<B", _data.read(1))
|
||||
except struct.error:
|
||||
break
|
||||
b, g, r, a = palette[offset]
|
||||
d: tuple[int, ...] = (r, g, b)
|
||||
if alpha:
|
||||
d += (a,)
|
||||
data.extend(d)
|
||||
return data
|
||||
|
||||
|
||||
class BLP1Decoder(_BLPBaseDecoder):
|
||||
def _load(self) -> None:
|
||||
self._compression, self._encoding, alpha = self.args
|
||||
|
||||
if self._compression == Format.JPEG:
|
||||
self._decode_jpeg_stream()
|
||||
|
||||
elif self._compression == 1:
|
||||
if self._encoding in (4, 5):
|
||||
palette = self._read_palette()
|
||||
data = self._read_bgra(palette, alpha)
|
||||
self.set_as_raw(data)
|
||||
else:
|
||||
msg = f"Unsupported BLP encoding {repr(self._encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
else:
|
||||
msg = f"Unsupported BLP compression {repr(self._encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
def _decode_jpeg_stream(self) -> None:
|
||||
from .JpegImagePlugin import JpegImageFile
|
||||
|
||||
(jpeg_header_size,) = struct.unpack("<I", self._safe_read(4))
|
||||
jpeg_header = self._safe_read(jpeg_header_size)
|
||||
assert self.fd is not None
|
||||
self._safe_read(self._offsets[0] - self.fd.tell()) # What IS this?
|
||||
data = self._safe_read(self._lengths[0])
|
||||
data = jpeg_header + data
|
||||
image = JpegImageFile(BytesIO(data))
|
||||
Image._decompression_bomb_check(image.size)
|
||||
if image.mode == "CMYK":
|
||||
args = image.tile[0].args
|
||||
assert isinstance(args, tuple)
|
||||
image.tile = [image.tile[0]._replace(args=(args[0], "CMYK"))]
|
||||
self.set_as_raw(image.convert("RGB").tobytes(), "BGR")
|
||||
|
||||
|
||||
class BLP2Decoder(_BLPBaseDecoder):
|
||||
def _load(self) -> None:
|
||||
self._compression, self._encoding, alpha, self._alpha_encoding = self.args
|
||||
|
||||
palette = self._read_palette()
|
||||
|
||||
assert self.fd is not None
|
||||
self.fd.seek(self._offsets[0])
|
||||
|
||||
if self._compression == 1:
|
||||
# Uncompressed or DirectX compression
|
||||
|
||||
if self._encoding == Encoding.UNCOMPRESSED:
|
||||
data = self._read_bgra(palette, alpha)
|
||||
|
||||
elif self._encoding == Encoding.DXT:
|
||||
data = bytearray()
|
||||
if self._alpha_encoding == AlphaEncoding.DXT1:
|
||||
linesize = (self.state.xsize + 3) // 4 * 8
|
||||
for yb in range((self.state.ysize + 3) // 4):
|
||||
for d in decode_dxt1(self._safe_read(linesize), alpha):
|
||||
data += d
|
||||
|
||||
elif self._alpha_encoding == AlphaEncoding.DXT3:
|
||||
linesize = (self.state.xsize + 3) // 4 * 16
|
||||
for yb in range((self.state.ysize + 3) // 4):
|
||||
for d in decode_dxt3(self._safe_read(linesize)):
|
||||
data += d
|
||||
|
||||
elif self._alpha_encoding == AlphaEncoding.DXT5:
|
||||
linesize = (self.state.xsize + 3) // 4 * 16
|
||||
for yb in range((self.state.ysize + 3) // 4):
|
||||
for d in decode_dxt5(self._safe_read(linesize)):
|
||||
data += d
|
||||
else:
|
||||
msg = f"Unsupported alpha encoding {repr(self._alpha_encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
else:
|
||||
msg = f"Unknown BLP encoding {repr(self._encoding)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
else:
|
||||
msg = f"Unknown BLP compression {repr(self._compression)}"
|
||||
raise BLPFormatError(msg)
|
||||
|
||||
self.set_as_raw(data)
|
||||
|
||||
|
||||
class BLPEncoder(ImageFile.PyEncoder):
|
||||
_pushes_fd = True
|
||||
|
||||
def _write_palette(self) -> bytes:
|
||||
data = b""
|
||||
assert self.im is not None
|
||||
palette = self.im.getpalette("RGBA", "RGBA")
|
||||
for i in range(len(palette) // 4):
|
||||
r, g, b, a = palette[i * 4 : (i + 1) * 4]
|
||||
data += struct.pack("<4B", b, g, r, a)
|
||||
while len(data) < 256 * 4:
|
||||
data += b"\x00" * 4
|
||||
return data
|
||||
|
||||
def encode(self, bufsize: int) -> tuple[int, int, bytes]:
|
||||
palette_data = self._write_palette()
|
||||
|
||||
offset = 20 + 16 * 4 * 2 + len(palette_data)
|
||||
data = struct.pack("<16I", offset, *((0,) * 15))
|
||||
|
||||
assert self.im is not None
|
||||
w, h = self.im.size
|
||||
data += struct.pack("<16I", w * h, *((0,) * 15))
|
||||
|
||||
data += palette_data
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
data += struct.pack("<B", self.im.getpixel((x, y)))
|
||||
|
||||
return len(data), 0, data
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
if im.mode != "P":
|
||||
msg = "Unsupported BLP image mode"
|
||||
raise ValueError(msg)
|
||||
|
||||
magic = b"BLP1" if im.encoderinfo.get("blp_version") == "BLP1" else b"BLP2"
|
||||
fp.write(magic)
|
||||
|
||||
assert im.palette is not None
|
||||
fp.write(struct.pack("<i", 1)) # Uncompressed or DirectX compression
|
||||
|
||||
alpha_depth = 1 if im.palette.mode == "RGBA" else 0
|
||||
if magic == b"BLP1":
|
||||
fp.write(struct.pack("<L", alpha_depth))
|
||||
else:
|
||||
fp.write(struct.pack("<b", Encoding.UNCOMPRESSED))
|
||||
fp.write(struct.pack("<b", alpha_depth))
|
||||
fp.write(struct.pack("<b", 0)) # alpha encoding
|
||||
fp.write(struct.pack("<b", 0)) # mips
|
||||
fp.write(struct.pack("<II", *im.size))
|
||||
if magic == b"BLP1":
|
||||
fp.write(struct.pack("<i", 5))
|
||||
fp.write(struct.pack("<i", 0))
|
||||
|
||||
ImageFile._save(im, fp, [ImageFile._Tile("BLP", (0, 0) + im.size, 0, im.mode)])
|
||||
|
||||
|
||||
Image.register_open(BlpImageFile.format, BlpImageFile, _accept)
|
||||
Image.register_extension(BlpImageFile.format, ".blp")
|
||||
Image.register_decoder("BLP1", BLP1Decoder)
|
||||
Image.register_decoder("BLP2", BLP2Decoder)
|
||||
|
||||
Image.register_save(BlpImageFile.format, _save)
|
||||
Image.register_encoder("BLP", BLPEncoder)
|
||||
@@ -0,0 +1,515 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# BMP file handler
|
||||
#
|
||||
# Windows (and OS/2) native bitmap storage format.
|
||||
#
|
||||
# history:
|
||||
# 1995-09-01 fl Created
|
||||
# 1996-04-30 fl Added save
|
||||
# 1997-08-27 fl Fixed save of 1-bit images
|
||||
# 1998-03-06 fl Load P images as L where possible
|
||||
# 1998-07-03 fl Load P images as 1 where possible
|
||||
# 1998-12-29 fl Handle small palettes
|
||||
# 2002-12-30 fl Fixed load of 1-bit palette images
|
||||
# 2003-04-21 fl Fixed load of 1-bit monochrome images
|
||||
# 2003-04-23 fl Added limited support for BI_BITFIELDS compression
|
||||
#
|
||||
# Copyright (c) 1997-2003 by Secret Labs AB
|
||||
# Copyright (c) 1995-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import IO, Any
|
||||
|
||||
from . import Image, ImageFile, ImagePalette
|
||||
from ._binary import i16le as i16
|
||||
from ._binary import i32le as i32
|
||||
from ._binary import o8
|
||||
from ._binary import o16le as o16
|
||||
from ._binary import o32le as o32
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Read BMP file
|
||||
|
||||
BIT2MODE = {
|
||||
# bits => mode, rawmode
|
||||
1: ("P", "P;1"),
|
||||
4: ("P", "P;4"),
|
||||
8: ("P", "P"),
|
||||
16: ("RGB", "BGR;15"),
|
||||
24: ("RGB", "BGR"),
|
||||
32: ("RGB", "BGRX"),
|
||||
}
|
||||
|
||||
USE_RAW_ALPHA = False
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"BM")
|
||||
|
||||
|
||||
def _dib_accept(prefix: bytes) -> bool:
|
||||
return i32(prefix) in [12, 40, 52, 56, 64, 108, 124]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image plugin for the Windows BMP format.
|
||||
# =============================================================================
|
||||
class BmpImageFile(ImageFile.ImageFile):
|
||||
"""Image plugin for the Windows Bitmap format (BMP)"""
|
||||
|
||||
# ------------------------------------------------------------- Description
|
||||
format_description = "Windows Bitmap"
|
||||
format = "BMP"
|
||||
|
||||
# -------------------------------------------------- BMP Compression values
|
||||
COMPRESSIONS = {"RAW": 0, "RLE8": 1, "RLE4": 2, "BITFIELDS": 3, "JPEG": 4, "PNG": 5}
|
||||
for k, v in COMPRESSIONS.items():
|
||||
vars()[k] = v
|
||||
|
||||
def _bitmap(self, header: int = 0, offset: int = 0) -> None:
|
||||
"""Read relevant info about the BMP"""
|
||||
read, seek = self.fp.read, self.fp.seek
|
||||
if header:
|
||||
seek(header)
|
||||
# read bmp header size @offset 14 (this is part of the header size)
|
||||
file_info: dict[str, bool | int | tuple[int, ...]] = {
|
||||
"header_size": i32(read(4)),
|
||||
"direction": -1,
|
||||
}
|
||||
|
||||
# -------------------- If requested, read header at a specific position
|
||||
# read the rest of the bmp header, without its size
|
||||
assert isinstance(file_info["header_size"], int)
|
||||
header_data = ImageFile._safe_read(self.fp, file_info["header_size"] - 4)
|
||||
|
||||
# ------------------------------- Windows Bitmap v2, IBM OS/2 Bitmap v1
|
||||
# ----- This format has different offsets because of width/height types
|
||||
# 12: BITMAPCOREHEADER/OS21XBITMAPHEADER
|
||||
if file_info["header_size"] == 12:
|
||||
file_info["width"] = i16(header_data, 0)
|
||||
file_info["height"] = i16(header_data, 2)
|
||||
file_info["planes"] = i16(header_data, 4)
|
||||
file_info["bits"] = i16(header_data, 6)
|
||||
file_info["compression"] = self.COMPRESSIONS["RAW"]
|
||||
file_info["palette_padding"] = 3
|
||||
|
||||
# --------------------------------------------- Windows Bitmap v3 to v5
|
||||
# 40: BITMAPINFOHEADER
|
||||
# 52: BITMAPV2HEADER
|
||||
# 56: BITMAPV3HEADER
|
||||
# 64: BITMAPCOREHEADER2/OS22XBITMAPHEADER
|
||||
# 108: BITMAPV4HEADER
|
||||
# 124: BITMAPV5HEADER
|
||||
elif file_info["header_size"] in (40, 52, 56, 64, 108, 124):
|
||||
file_info["y_flip"] = header_data[7] == 0xFF
|
||||
file_info["direction"] = 1 if file_info["y_flip"] else -1
|
||||
file_info["width"] = i32(header_data, 0)
|
||||
file_info["height"] = (
|
||||
i32(header_data, 4)
|
||||
if not file_info["y_flip"]
|
||||
else 2**32 - i32(header_data, 4)
|
||||
)
|
||||
file_info["planes"] = i16(header_data, 8)
|
||||
file_info["bits"] = i16(header_data, 10)
|
||||
file_info["compression"] = i32(header_data, 12)
|
||||
# byte size of pixel data
|
||||
file_info["data_size"] = i32(header_data, 16)
|
||||
file_info["pixels_per_meter"] = (
|
||||
i32(header_data, 20),
|
||||
i32(header_data, 24),
|
||||
)
|
||||
file_info["colors"] = i32(header_data, 28)
|
||||
file_info["palette_padding"] = 4
|
||||
assert isinstance(file_info["pixels_per_meter"], tuple)
|
||||
self.info["dpi"] = tuple(x / 39.3701 for x in file_info["pixels_per_meter"])
|
||||
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
|
||||
masks = ["r_mask", "g_mask", "b_mask"]
|
||||
if len(header_data) >= 48:
|
||||
if len(header_data) >= 52:
|
||||
masks.append("a_mask")
|
||||
else:
|
||||
file_info["a_mask"] = 0x0
|
||||
for idx, mask in enumerate(masks):
|
||||
file_info[mask] = i32(header_data, 36 + idx * 4)
|
||||
else:
|
||||
# 40 byte headers only have the three components in the
|
||||
# bitfields masks, ref:
|
||||
# https://msdn.microsoft.com/en-us/library/windows/desktop/dd183376(v=vs.85).aspx
|
||||
# See also
|
||||
# https://github.com/python-pillow/Pillow/issues/1293
|
||||
# There is a 4th component in the RGBQuad, in the alpha
|
||||
# location, but it is listed as a reserved component,
|
||||
# and it is not generally an alpha channel
|
||||
file_info["a_mask"] = 0x0
|
||||
for mask in masks:
|
||||
file_info[mask] = i32(read(4))
|
||||
assert isinstance(file_info["r_mask"], int)
|
||||
assert isinstance(file_info["g_mask"], int)
|
||||
assert isinstance(file_info["b_mask"], int)
|
||||
assert isinstance(file_info["a_mask"], int)
|
||||
file_info["rgb_mask"] = (
|
||||
file_info["r_mask"],
|
||||
file_info["g_mask"],
|
||||
file_info["b_mask"],
|
||||
)
|
||||
file_info["rgba_mask"] = (
|
||||
file_info["r_mask"],
|
||||
file_info["g_mask"],
|
||||
file_info["b_mask"],
|
||||
file_info["a_mask"],
|
||||
)
|
||||
else:
|
||||
msg = f"Unsupported BMP header type ({file_info['header_size']})"
|
||||
raise OSError(msg)
|
||||
|
||||
# ------------------ Special case : header is reported 40, which
|
||||
# ---------------------- is shorter than real size for bpp >= 16
|
||||
assert isinstance(file_info["width"], int)
|
||||
assert isinstance(file_info["height"], int)
|
||||
self._size = file_info["width"], file_info["height"]
|
||||
|
||||
# ------- If color count was not found in the header, compute from bits
|
||||
assert isinstance(file_info["bits"], int)
|
||||
file_info["colors"] = (
|
||||
file_info["colors"]
|
||||
if file_info.get("colors", 0)
|
||||
else (1 << file_info["bits"])
|
||||
)
|
||||
assert isinstance(file_info["colors"], int)
|
||||
if offset == 14 + file_info["header_size"] and file_info["bits"] <= 8:
|
||||
offset += 4 * file_info["colors"]
|
||||
|
||||
# ---------------------- Check bit depth for unusual unsupported values
|
||||
self._mode, raw_mode = BIT2MODE.get(file_info["bits"], ("", ""))
|
||||
if not self.mode:
|
||||
msg = f"Unsupported BMP pixel depth ({file_info['bits']})"
|
||||
raise OSError(msg)
|
||||
|
||||
# ---------------- Process BMP with Bitfields compression (not palette)
|
||||
decoder_name = "raw"
|
||||
if file_info["compression"] == self.COMPRESSIONS["BITFIELDS"]:
|
||||
SUPPORTED: dict[int, list[tuple[int, ...]]] = {
|
||||
32: [
|
||||
(0xFF0000, 0xFF00, 0xFF, 0x0),
|
||||
(0xFF000000, 0xFF0000, 0xFF00, 0x0),
|
||||
(0xFF000000, 0xFF00, 0xFF, 0x0),
|
||||
(0xFF000000, 0xFF0000, 0xFF00, 0xFF),
|
||||
(0xFF, 0xFF00, 0xFF0000, 0xFF000000),
|
||||
(0xFF0000, 0xFF00, 0xFF, 0xFF000000),
|
||||
(0xFF000000, 0xFF00, 0xFF, 0xFF0000),
|
||||
(0x0, 0x0, 0x0, 0x0),
|
||||
],
|
||||
24: [(0xFF0000, 0xFF00, 0xFF)],
|
||||
16: [(0xF800, 0x7E0, 0x1F), (0x7C00, 0x3E0, 0x1F)],
|
||||
}
|
||||
MASK_MODES = {
|
||||
(32, (0xFF0000, 0xFF00, 0xFF, 0x0)): "BGRX",
|
||||
(32, (0xFF000000, 0xFF0000, 0xFF00, 0x0)): "XBGR",
|
||||
(32, (0xFF000000, 0xFF00, 0xFF, 0x0)): "BGXR",
|
||||
(32, (0xFF000000, 0xFF0000, 0xFF00, 0xFF)): "ABGR",
|
||||
(32, (0xFF, 0xFF00, 0xFF0000, 0xFF000000)): "RGBA",
|
||||
(32, (0xFF0000, 0xFF00, 0xFF, 0xFF000000)): "BGRA",
|
||||
(32, (0xFF000000, 0xFF00, 0xFF, 0xFF0000)): "BGAR",
|
||||
(32, (0x0, 0x0, 0x0, 0x0)): "BGRA",
|
||||
(24, (0xFF0000, 0xFF00, 0xFF)): "BGR",
|
||||
(16, (0xF800, 0x7E0, 0x1F)): "BGR;16",
|
||||
(16, (0x7C00, 0x3E0, 0x1F)): "BGR;15",
|
||||
}
|
||||
if file_info["bits"] in SUPPORTED:
|
||||
if (
|
||||
file_info["bits"] == 32
|
||||
and file_info["rgba_mask"] in SUPPORTED[file_info["bits"]]
|
||||
):
|
||||
assert isinstance(file_info["rgba_mask"], tuple)
|
||||
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgba_mask"])]
|
||||
self._mode = "RGBA" if "A" in raw_mode else self.mode
|
||||
elif (
|
||||
file_info["bits"] in (24, 16)
|
||||
and file_info["rgb_mask"] in SUPPORTED[file_info["bits"]]
|
||||
):
|
||||
assert isinstance(file_info["rgb_mask"], tuple)
|
||||
raw_mode = MASK_MODES[(file_info["bits"], file_info["rgb_mask"])]
|
||||
else:
|
||||
msg = "Unsupported BMP bitfields layout"
|
||||
raise OSError(msg)
|
||||
else:
|
||||
msg = "Unsupported BMP bitfields layout"
|
||||
raise OSError(msg)
|
||||
elif file_info["compression"] == self.COMPRESSIONS["RAW"]:
|
||||
if file_info["bits"] == 32 and (
|
||||
header == 22 or USE_RAW_ALPHA # 32-bit .cur offset
|
||||
):
|
||||
raw_mode, self._mode = "BGRA", "RGBA"
|
||||
elif file_info["compression"] in (
|
||||
self.COMPRESSIONS["RLE8"],
|
||||
self.COMPRESSIONS["RLE4"],
|
||||
):
|
||||
decoder_name = "bmp_rle"
|
||||
else:
|
||||
msg = f"Unsupported BMP compression ({file_info['compression']})"
|
||||
raise OSError(msg)
|
||||
|
||||
# --------------- Once the header is processed, process the palette/LUT
|
||||
if self.mode == "P": # Paletted for 1, 4 and 8 bit images
|
||||
# ---------------------------------------------------- 1-bit images
|
||||
if not (0 < file_info["colors"] <= 65536):
|
||||
msg = f"Unsupported BMP Palette size ({file_info['colors']})"
|
||||
raise OSError(msg)
|
||||
else:
|
||||
assert isinstance(file_info["palette_padding"], int)
|
||||
padding = file_info["palette_padding"]
|
||||
palette = read(padding * file_info["colors"])
|
||||
grayscale = True
|
||||
indices = (
|
||||
(0, 255)
|
||||
if file_info["colors"] == 2
|
||||
else list(range(file_info["colors"]))
|
||||
)
|
||||
|
||||
# ----------------- Check if grayscale and ignore palette if so
|
||||
for ind, val in enumerate(indices):
|
||||
rgb = palette[ind * padding : ind * padding + 3]
|
||||
if rgb != o8(val) * 3:
|
||||
grayscale = False
|
||||
|
||||
# ------- If all colors are gray, white or black, ditch palette
|
||||
if grayscale:
|
||||
self._mode = "1" if file_info["colors"] == 2 else "L"
|
||||
raw_mode = self.mode
|
||||
else:
|
||||
self._mode = "P"
|
||||
self.palette = ImagePalette.raw(
|
||||
"BGRX" if padding == 4 else "BGR", palette
|
||||
)
|
||||
|
||||
# ---------------------------- Finally set the tile data for the plugin
|
||||
self.info["compression"] = file_info["compression"]
|
||||
args: list[Any] = [raw_mode]
|
||||
if decoder_name == "bmp_rle":
|
||||
args.append(file_info["compression"] == self.COMPRESSIONS["RLE4"])
|
||||
else:
|
||||
assert isinstance(file_info["width"], int)
|
||||
args.append(((file_info["width"] * file_info["bits"] + 31) >> 3) & (~3))
|
||||
args.append(file_info["direction"])
|
||||
self.tile = [
|
||||
ImageFile._Tile(
|
||||
decoder_name,
|
||||
(0, 0, file_info["width"], file_info["height"]),
|
||||
offset or self.fp.tell(),
|
||||
tuple(args),
|
||||
)
|
||||
]
|
||||
|
||||
def _open(self) -> None:
|
||||
"""Open file, check magic number and read header"""
|
||||
# read 14 bytes: magic number, filesize, reserved, header final offset
|
||||
head_data = self.fp.read(14)
|
||||
# choke if the file does not have the required magic bytes
|
||||
if not _accept(head_data):
|
||||
msg = "Not a BMP file"
|
||||
raise SyntaxError(msg)
|
||||
# read the start position of the BMP image data (u32)
|
||||
offset = i32(head_data, 10)
|
||||
# load bitmap information (offset=raster info)
|
||||
self._bitmap(offset=offset)
|
||||
|
||||
|
||||
class BmpRleDecoder(ImageFile.PyDecoder):
|
||||
_pulls_fd = True
|
||||
|
||||
def decode(self, buffer: bytes | Image.SupportsArrayInterface) -> tuple[int, int]:
|
||||
assert self.fd is not None
|
||||
rle4 = self.args[1]
|
||||
data = bytearray()
|
||||
x = 0
|
||||
dest_length = self.state.xsize * self.state.ysize
|
||||
while len(data) < dest_length:
|
||||
pixels = self.fd.read(1)
|
||||
byte = self.fd.read(1)
|
||||
if not pixels or not byte:
|
||||
break
|
||||
num_pixels = pixels[0]
|
||||
if num_pixels:
|
||||
# encoded mode
|
||||
if x + num_pixels > self.state.xsize:
|
||||
# Too much data for row
|
||||
num_pixels = max(0, self.state.xsize - x)
|
||||
if rle4:
|
||||
first_pixel = o8(byte[0] >> 4)
|
||||
second_pixel = o8(byte[0] & 0x0F)
|
||||
for index in range(num_pixels):
|
||||
if index % 2 == 0:
|
||||
data += first_pixel
|
||||
else:
|
||||
data += second_pixel
|
||||
else:
|
||||
data += byte * num_pixels
|
||||
x += num_pixels
|
||||
else:
|
||||
if byte[0] == 0:
|
||||
# end of line
|
||||
while len(data) % self.state.xsize != 0:
|
||||
data += b"\x00"
|
||||
x = 0
|
||||
elif byte[0] == 1:
|
||||
# end of bitmap
|
||||
break
|
||||
elif byte[0] == 2:
|
||||
# delta
|
||||
bytes_read = self.fd.read(2)
|
||||
if len(bytes_read) < 2:
|
||||
break
|
||||
right, up = self.fd.read(2)
|
||||
data += b"\x00" * (right + up * self.state.xsize)
|
||||
x = len(data) % self.state.xsize
|
||||
else:
|
||||
# absolute mode
|
||||
if rle4:
|
||||
# 2 pixels per byte
|
||||
byte_count = byte[0] // 2
|
||||
bytes_read = self.fd.read(byte_count)
|
||||
for byte_read in bytes_read:
|
||||
data += o8(byte_read >> 4)
|
||||
data += o8(byte_read & 0x0F)
|
||||
else:
|
||||
byte_count = byte[0]
|
||||
bytes_read = self.fd.read(byte_count)
|
||||
data += bytes_read
|
||||
if len(bytes_read) < byte_count:
|
||||
break
|
||||
x += byte[0]
|
||||
|
||||
# align to 16-bit word boundary
|
||||
if self.fd.tell() % 2 != 0:
|
||||
self.fd.seek(1, os.SEEK_CUR)
|
||||
rawmode = "L" if self.mode == "L" else "P"
|
||||
self.set_as_raw(bytes(data), rawmode, (0, self.args[-1]))
|
||||
return -1, 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Image plugin for the DIB format (BMP alias)
|
||||
# =============================================================================
|
||||
class DibImageFile(BmpImageFile):
|
||||
format = "DIB"
|
||||
format_description = "Windows Bitmap"
|
||||
|
||||
def _open(self) -> None:
|
||||
self._bitmap()
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Write BMP file
|
||||
|
||||
|
||||
SAVE = {
|
||||
"1": ("1", 1, 2),
|
||||
"L": ("L", 8, 256),
|
||||
"P": ("P", 8, 256),
|
||||
"RGB": ("BGR", 24, 0),
|
||||
"RGBA": ("BGRA", 32, 0),
|
||||
}
|
||||
|
||||
|
||||
def _dib_save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
_save(im, fp, filename, False)
|
||||
|
||||
|
||||
def _save(
|
||||
im: Image.Image, fp: IO[bytes], filename: str | bytes, bitmap_header: bool = True
|
||||
) -> None:
|
||||
try:
|
||||
rawmode, bits, colors = SAVE[im.mode]
|
||||
except KeyError as e:
|
||||
msg = f"cannot write mode {im.mode} as BMP"
|
||||
raise OSError(msg) from e
|
||||
|
||||
info = im.encoderinfo
|
||||
|
||||
dpi = info.get("dpi", (96, 96))
|
||||
|
||||
# 1 meter == 39.3701 inches
|
||||
ppm = tuple(int(x * 39.3701 + 0.5) for x in dpi)
|
||||
|
||||
stride = ((im.size[0] * bits + 7) // 8 + 3) & (~3)
|
||||
header = 40 # or 64 for OS/2 version 2
|
||||
image = stride * im.size[1]
|
||||
|
||||
if im.mode == "1":
|
||||
palette = b"".join(o8(i) * 4 for i in (0, 255))
|
||||
elif im.mode == "L":
|
||||
palette = b"".join(o8(i) * 4 for i in range(256))
|
||||
elif im.mode == "P":
|
||||
palette = im.im.getpalette("RGB", "BGRX")
|
||||
colors = len(palette) // 4
|
||||
else:
|
||||
palette = None
|
||||
|
||||
# bitmap header
|
||||
if bitmap_header:
|
||||
offset = 14 + header + colors * 4
|
||||
file_size = offset + image
|
||||
if file_size > 2**32 - 1:
|
||||
msg = "File size is too large for the BMP format"
|
||||
raise ValueError(msg)
|
||||
fp.write(
|
||||
b"BM" # file type (magic)
|
||||
+ o32(file_size) # file size
|
||||
+ o32(0) # reserved
|
||||
+ o32(offset) # image data offset
|
||||
)
|
||||
|
||||
# bitmap info header
|
||||
fp.write(
|
||||
o32(header) # info header size
|
||||
+ o32(im.size[0]) # width
|
||||
+ o32(im.size[1]) # height
|
||||
+ o16(1) # planes
|
||||
+ o16(bits) # depth
|
||||
+ o32(0) # compression (0=uncompressed)
|
||||
+ o32(image) # size of bitmap
|
||||
+ o32(ppm[0]) # resolution
|
||||
+ o32(ppm[1]) # resolution
|
||||
+ o32(colors) # colors used
|
||||
+ o32(colors) # colors important
|
||||
)
|
||||
|
||||
fp.write(b"\0" * (header - 40)) # padding (for OS/2 format)
|
||||
|
||||
if palette:
|
||||
fp.write(palette)
|
||||
|
||||
ImageFile._save(
|
||||
im, fp, [ImageFile._Tile("raw", (0, 0) + im.size, 0, (rawmode, stride, -1))]
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
|
||||
Image.register_open(BmpImageFile.format, BmpImageFile, _accept)
|
||||
Image.register_save(BmpImageFile.format, _save)
|
||||
|
||||
Image.register_extension(BmpImageFile.format, ".bmp")
|
||||
|
||||
Image.register_mime(BmpImageFile.format, "image/bmp")
|
||||
|
||||
Image.register_decoder("bmp_rle", BmpRleDecoder)
|
||||
|
||||
Image.register_open(DibImageFile.format, DibImageFile, _dib_accept)
|
||||
Image.register_save(DibImageFile.format, _dib_save)
|
||||
|
||||
Image.register_extension(DibImageFile.format, ".dib")
|
||||
|
||||
Image.register_mime(DibImageFile.format, "image/bmp")
|
||||
@@ -0,0 +1,75 @@
|
||||
#
|
||||
# The Python Imaging Library
|
||||
# $Id$
|
||||
#
|
||||
# BUFR stub adapter
|
||||
#
|
||||
# Copyright (c) 1996-2003 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import IO
|
||||
|
||||
from . import Image, ImageFile
|
||||
|
||||
_handler = None
|
||||
|
||||
|
||||
def register_handler(handler: ImageFile.StubHandler | None) -> None:
|
||||
"""
|
||||
Install application-specific BUFR image handler.
|
||||
|
||||
:param handler: Handler object.
|
||||
"""
|
||||
global _handler
|
||||
_handler = handler
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Image adapter
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith((b"BUFR", b"ZCZC"))
|
||||
|
||||
|
||||
class BufrStubImageFile(ImageFile.StubImageFile):
|
||||
format = "BUFR"
|
||||
format_description = "BUFR"
|
||||
|
||||
def _open(self) -> None:
|
||||
if not _accept(self.fp.read(4)):
|
||||
msg = "Not a BUFR file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
self.fp.seek(-4, os.SEEK_CUR)
|
||||
|
||||
# make something up
|
||||
self._mode = "F"
|
||||
self._size = 1, 1
|
||||
|
||||
loader = self._load()
|
||||
if loader:
|
||||
loader.open(self)
|
||||
|
||||
def _load(self) -> ImageFile.StubHandler | None:
|
||||
return _handler
|
||||
|
||||
|
||||
def _save(im: Image.Image, fp: IO[bytes], filename: str | bytes) -> None:
|
||||
if _handler is None or not hasattr(_handler, "save"):
|
||||
msg = "BUFR save handler not installed"
|
||||
raise OSError(msg)
|
||||
_handler.save(im, fp, filename)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# Registry
|
||||
|
||||
Image.register_open(BufrStubImageFile.format, BufrStubImageFile, _accept)
|
||||
Image.register_save(BufrStubImageFile.format, _save)
|
||||
|
||||
Image.register_extension(BufrStubImageFile.format, ".bufr")
|
||||
@@ -0,0 +1,173 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# a class to read from a container file
|
||||
#
|
||||
# History:
|
||||
# 1995-06-18 fl Created
|
||||
# 1995-09-07 fl Added readline(), readlines()
|
||||
#
|
||||
# Copyright (c) 1997-2001 by Secret Labs AB
|
||||
# Copyright (c) 1995 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from collections.abc import Iterable
|
||||
from typing import IO, AnyStr, NoReturn
|
||||
|
||||
|
||||
class ContainerIO(IO[AnyStr]):
|
||||
"""
|
||||
A file object that provides read access to a part of an existing
|
||||
file (for example a TAR file).
|
||||
"""
|
||||
|
||||
def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None:
|
||||
"""
|
||||
Create file object.
|
||||
|
||||
:param file: Existing file.
|
||||
:param offset: Start of region, in bytes.
|
||||
:param length: Size of region, in bytes.
|
||||
"""
|
||||
self.fh: IO[AnyStr] = file
|
||||
self.pos = 0
|
||||
self.offset = offset
|
||||
self.length = length
|
||||
self.fh.seek(offset)
|
||||
|
||||
##
|
||||
# Always false.
|
||||
|
||||
def isatty(self) -> bool:
|
||||
return False
|
||||
|
||||
def seekable(self) -> bool:
|
||||
return True
|
||||
|
||||
def seek(self, offset: int, mode: int = io.SEEK_SET) -> int:
|
||||
"""
|
||||
Move file pointer.
|
||||
|
||||
:param offset: Offset in bytes.
|
||||
:param mode: Starting position. Use 0 for beginning of region, 1
|
||||
for current offset, and 2 for end of region. You cannot move
|
||||
the pointer outside the defined region.
|
||||
:returns: Offset from start of region, in bytes.
|
||||
"""
|
||||
if mode == 1:
|
||||
self.pos = self.pos + offset
|
||||
elif mode == 2:
|
||||
self.pos = self.length + offset
|
||||
else:
|
||||
self.pos = offset
|
||||
# clamp
|
||||
self.pos = max(0, min(self.pos, self.length))
|
||||
self.fh.seek(self.offset + self.pos)
|
||||
return self.pos
|
||||
|
||||
def tell(self) -> int:
|
||||
"""
|
||||
Get current file pointer.
|
||||
|
||||
:returns: Offset from start of region, in bytes.
|
||||
"""
|
||||
return self.pos
|
||||
|
||||
def readable(self) -> bool:
|
||||
return True
|
||||
|
||||
def read(self, n: int = -1) -> AnyStr:
|
||||
"""
|
||||
Read data.
|
||||
|
||||
:param n: Number of bytes to read. If omitted, zero or negative,
|
||||
read until end of region.
|
||||
:returns: An 8-bit string.
|
||||
"""
|
||||
if n > 0:
|
||||
n = min(n, self.length - self.pos)
|
||||
else:
|
||||
n = self.length - self.pos
|
||||
if n <= 0: # EOF
|
||||
return b"" if "b" in self.fh.mode else "" # type: ignore[return-value]
|
||||
self.pos = self.pos + n
|
||||
return self.fh.read(n)
|
||||
|
||||
def readline(self, n: int = -1) -> AnyStr:
|
||||
"""
|
||||
Read a line of text.
|
||||
|
||||
:param n: Number of bytes to read. If omitted, zero or negative,
|
||||
read until end of line.
|
||||
:returns: An 8-bit string.
|
||||
"""
|
||||
s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment]
|
||||
newline_character = b"\n" if "b" in self.fh.mode else "\n"
|
||||
while True:
|
||||
c = self.read(1)
|
||||
if not c:
|
||||
break
|
||||
s = s + c
|
||||
if c == newline_character or len(s) == n:
|
||||
break
|
||||
return s
|
||||
|
||||
def readlines(self, n: int | None = -1) -> list[AnyStr]:
|
||||
"""
|
||||
Read multiple lines of text.
|
||||
|
||||
:param n: Number of lines to read. If omitted, zero, negative or None,
|
||||
read until end of region.
|
||||
:returns: A list of 8-bit strings.
|
||||
"""
|
||||
lines = []
|
||||
while True:
|
||||
s = self.readline()
|
||||
if not s:
|
||||
break
|
||||
lines.append(s)
|
||||
if len(lines) == n:
|
||||
break
|
||||
return lines
|
||||
|
||||
def writable(self) -> bool:
|
||||
return False
|
||||
|
||||
def write(self, b: AnyStr) -> NoReturn:
|
||||
raise NotImplementedError()
|
||||
|
||||
def writelines(self, lines: Iterable[AnyStr]) -> NoReturn:
|
||||
raise NotImplementedError()
|
||||
|
||||
def truncate(self, size: int | None = None) -> int:
|
||||
raise NotImplementedError()
|
||||
|
||||
def __enter__(self) -> ContainerIO[AnyStr]:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: object) -> None:
|
||||
self.close()
|
||||
|
||||
def __iter__(self) -> ContainerIO[AnyStr]:
|
||||
return self
|
||||
|
||||
def __next__(self) -> AnyStr:
|
||||
line = self.readline()
|
||||
if not line:
|
||||
msg = "end of region"
|
||||
raise StopIteration(msg)
|
||||
return line
|
||||
|
||||
def fileno(self) -> int:
|
||||
return self.fh.fileno()
|
||||
|
||||
def flush(self) -> None:
|
||||
self.fh.flush()
|
||||
|
||||
def close(self) -> None:
|
||||
self.fh.close()
|
||||
@@ -0,0 +1,75 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# Windows Cursor support for PIL
|
||||
#
|
||||
# notes:
|
||||
# uses BmpImagePlugin.py to read the bitmap data.
|
||||
#
|
||||
# history:
|
||||
# 96-05-27 fl Created
|
||||
#
|
||||
# Copyright (c) Secret Labs AB 1997.
|
||||
# Copyright (c) Fredrik Lundh 1996.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from . import BmpImagePlugin, Image, ImageFile
|
||||
from ._binary import i16le as i16
|
||||
from ._binary import i32le as i32
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return prefix.startswith(b"\0\0\2\0")
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for Windows Cursor files.
|
||||
|
||||
|
||||
class CurImageFile(BmpImagePlugin.BmpImageFile):
|
||||
format = "CUR"
|
||||
format_description = "Windows Cursor"
|
||||
|
||||
def _open(self) -> None:
|
||||
offset = self.fp.tell()
|
||||
|
||||
# check magic
|
||||
s = self.fp.read(6)
|
||||
if not _accept(s):
|
||||
msg = "not a CUR file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
# pick the largest cursor in the file
|
||||
m = b""
|
||||
for i in range(i16(s, 4)):
|
||||
s = self.fp.read(16)
|
||||
if not m:
|
||||
m = s
|
||||
elif s[0] > m[0] and s[1] > m[1]:
|
||||
m = s
|
||||
if not m:
|
||||
msg = "No cursors were found"
|
||||
raise TypeError(msg)
|
||||
|
||||
# load as bitmap
|
||||
self._bitmap(i32(m, 12) + offset)
|
||||
|
||||
# patch up the bitmap height
|
||||
self._size = self.size[0], self.size[1] // 2
|
||||
d, e, o, a = self.tile[0]
|
||||
self.tile[0] = ImageFile._Tile(d, (0, 0) + self.size, o, a)
|
||||
|
||||
|
||||
#
|
||||
# --------------------------------------------------------------------
|
||||
|
||||
Image.register_open(CurImageFile.format, CurImageFile, _accept)
|
||||
|
||||
Image.register_extension(CurImageFile.format, ".cur")
|
||||
@@ -0,0 +1,83 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# DCX file handling
|
||||
#
|
||||
# DCX is a container file format defined by Intel, commonly used
|
||||
# for fax applications. Each DCX file consists of a directory
|
||||
# (a list of file offsets) followed by a set of (usually 1-bit)
|
||||
# PCX files.
|
||||
#
|
||||
# History:
|
||||
# 1995-09-09 fl Created
|
||||
# 1996-03-20 fl Properly derived from PcxImageFile.
|
||||
# 1998-07-15 fl Renamed offset attribute to avoid name clash
|
||||
# 2002-07-30 fl Fixed file handling
|
||||
#
|
||||
# Copyright (c) 1997-98 by Secret Labs AB.
|
||||
# Copyright (c) 1995-96 by Fredrik Lundh.
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
from __future__ import annotations
|
||||
|
||||
from . import Image
|
||||
from ._binary import i32le as i32
|
||||
from ._util import DeferredError
|
||||
from .PcxImagePlugin import PcxImageFile
|
||||
|
||||
MAGIC = 0x3ADE68B1 # QUIZ: what's this value, then?
|
||||
|
||||
|
||||
def _accept(prefix: bytes) -> bool:
|
||||
return len(prefix) >= 4 and i32(prefix) == MAGIC
|
||||
|
||||
|
||||
##
|
||||
# Image plugin for the Intel DCX format.
|
||||
|
||||
|
||||
class DcxImageFile(PcxImageFile):
|
||||
format = "DCX"
|
||||
format_description = "Intel DCX"
|
||||
_close_exclusive_fp_after_loading = False
|
||||
|
||||
def _open(self) -> None:
|
||||
# Header
|
||||
s = self.fp.read(4)
|
||||
if not _accept(s):
|
||||
msg = "not a DCX file"
|
||||
raise SyntaxError(msg)
|
||||
|
||||
# Component directory
|
||||
self._offset = []
|
||||
for i in range(1024):
|
||||
offset = i32(self.fp.read(4))
|
||||
if not offset:
|
||||
break
|
||||
self._offset.append(offset)
|
||||
|
||||
self._fp = self.fp
|
||||
self.frame = -1
|
||||
self.n_frames = len(self._offset)
|
||||
self.is_animated = self.n_frames > 1
|
||||
self.seek(0)
|
||||
|
||||
def seek(self, frame: int) -> None:
|
||||
if not self._seek_check(frame):
|
||||
return
|
||||
if isinstance(self._fp, DeferredError):
|
||||
raise self._fp.ex
|
||||
self.frame = frame
|
||||
self.fp = self._fp
|
||||
self.fp.seek(self._offset[frame])
|
||||
PcxImageFile._open(self)
|
||||
|
||||
def tell(self) -> int:
|
||||
return self.frame
|
||||
|
||||
|
||||
Image.register_open(DcxImageFile.format, DcxImageFile, _accept)
|
||||
|
||||
Image.register_extension(DcxImageFile.format, ".dcx")
|
||||