Skip to content

Commit 0c8e637

Browse files
authored
Merge pull request #3 from fapulito/csv_output
v1.0 Custom Textract App in Flask
2 parents e721ede + 50d1608 commit 0c8e637

3 files changed

Lines changed: 273 additions & 67 deletions

File tree

app.py

Lines changed: 120 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,146 @@
11
import os
22
import boto3
3-
from flask import Flask, render_template, request, redirect, url_for
43
import time
4+
import csv
5+
import io
6+
from flask import Flask, render_template, request, redirect, url_for, jsonify
7+
from dotenv import load_dotenv
8+
from botocore.client import Config
9+
10+
load_dotenv()
511

612
app = Flask(__name__)
713

8-
# AWS Configuration
9-
AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID')
10-
AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY')
11-
AWS_REGION = os.environ.get('AWS_REGION')
14+
# --- Configuration and AWS Clients ---
1215
S3_BUCKET = os.environ.get('S3_BUCKET')
16+
AWS_REGION = os.environ.get('AWS_REGION')
17+
if not all([S3_BUCKET, AWS_REGION, os.environ.get('AWS_ACCESS_KEY_ID'), os.environ.get('AWS_SECRET_ACCESS_KEY')]):
18+
raise ValueError("One or more essential environment variables are missing.")
1319

14-
s3 = boto3.client(
15-
's3',
16-
aws_access_key_id=AWS_ACCESS_KEY_ID,
17-
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
18-
region_name=AWS_REGION
20+
# The robust S3 client configuration
21+
s3_config = Config(
22+
signature_version='s3v4',
23+
s3={'addressing_style': 'path'}
1924
)
2025

21-
textract = boto3.client(
22-
'textract',
23-
aws_access_key_id=AWS_ACCESS_KEY_ID,
24-
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
25-
region_name=AWS_REGION
26+
s3 = boto3.client(
27+
's3',
28+
region_name=AWS_REGION,
29+
config=s3_config
2630
)
31+
textract = boto3.client('textract', region_name=AWS_REGION)
2732

33+
# --- Routes ---
34+
# ... (all other routes remain the same)
2835
@app.route('/')
2936
def index():
3037
return render_template('index.html')
3138

3239
@app.route('/upload', methods=['POST'])
3340
def upload():
3441
if 'file' not in request.files:
35-
return redirect(request.url)
36-
42+
return "No file part in the request.", 400
43+
3744
file = request.files['file']
38-
3945
if file.filename == '':
40-
return redirect(request.url)
46+
return "No file selected.", 400
4147

4248
if file:
43-
s3.upload_fileobj(
44-
file,
45-
S3_BUCKET,
46-
file.filename,
47-
ExtraArgs={'ContentType': file.content_type}
48-
)
49-
50-
response = textract.start_document_text_detection(
51-
DocumentLocation={
52-
'S3Object': {
53-
'Bucket': S3_BUCKET,
54-
'Name': file.filename
55-
}
56-
}
57-
)
58-
59-
job_id = response['JobId']
60-
return redirect(url_for('result', job_id=job_id))
61-
62-
@app.route('/result/<job_id>')
63-
def result(job_id):
64-
response = textract.get_document_text_detection(JobId=job_id)
65-
66-
while response['JobStatus'] == 'IN_PROGRESS':
67-
time.sleep(5)
49+
try:
50+
s3.upload_fileobj(
51+
file,
52+
S3_BUCKET,
53+
file.filename,
54+
ExtraArgs={'ContentType': file.content_type}
55+
)
56+
response = textract.start_document_text_detection(
57+
DocumentLocation={'S3Object': {'Bucket': S3_BUCKET, 'Name': file.filename}}
58+
)
59+
return redirect(url_for('status', job_id=response['JobId'], original_filename=file.filename))
60+
except Exception as e:
61+
return f"An error occurred: {str(e)}", 500
62+
63+
return redirect(url_for('index'))
64+
65+
@app.route('/status/<job_id>/<original_filename>')
66+
def status(job_id, original_filename):
67+
return render_template('status.html', job_id=job_id, original_filename=original_filename)
68+
69+
@app.route('/api/check_status/<job_id>')
70+
def check_status(job_id):
71+
try:
6872
response = textract.get_document_text_detection(JobId=job_id)
69-
70-
if response['JobStatus'] == 'SUCCEEDED':
71-
blocks = []
72-
pages = [response]
73-
74-
while 'NextToken' in pages[-1]:
75-
pages.append(textract.get_document_text_detection(JobId=job_id, NextToken=pages[-1]['NextToken']))
76-
77-
for page in pages:
78-
blocks.extend(page['Blocks'])
79-
80-
extracted_text = ''
81-
for block in blocks:
82-
if block['BlockType'] == 'LINE':
83-
extracted_text += block['Text'] + '\n'
84-
return render_template('result.html', text=extracted_text)
85-
86-
return "Error processing document."
73+
status = response.get('JobStatus')
74+
return jsonify({'status': status})
75+
except Exception as e:
76+
return jsonify({'status': 'FAILED', 'error': str(e)})
77+
78+
@app.route('/process_result/<job_id>/<original_filename>')
79+
def process_result(job_id, original_filename):
80+
try:
81+
response = textract.get_document_text_detection(JobId=job_id)
82+
if response.get('JobStatus') == 'SUCCEEDED':
83+
blocks = get_all_textract_blocks(job_id, response)
84+
csv_filename = create_and_upload_csv(blocks, original_filename)
85+
return redirect(url_for('success', csv_filename=csv_filename))
86+
else:
87+
return "Job did not succeed. Status: " + response.get('JobStatus'), 500
88+
except Exception as e:
89+
return f"An error occurred during final processing: {str(e)}", 500
90+
91+
# --- UPDATED SUCCESS FUNCTION ---
92+
@app.route('/success/<csv_filename>')
93+
def success(csv_filename):
94+
try:
95+
download_url = s3.generate_presigned_url(
96+
'get_object',
97+
Params={'Bucket': S3_BUCKET, 'Key': csv_filename},
98+
ExpiresIn=300
99+
)
100+
# --- THIS IS THE NEW LINE ---
101+
print("--- Generated Download URL ---\n", download_url, "\n--------------------------")
102+
# -----------------------------
103+
104+
except Exception as e:
105+
print(f"Error generating presigned URL: {e}")
106+
download_url = None
107+
108+
return render_template(
109+
'result.html',
110+
csv_filename=csv_filename,
111+
bucket_name=S3_BUCKET,
112+
download_url=download_url
113+
)
114+
115+
# ... (all helper functions remain the same)
116+
def get_all_textract_blocks(job_id, initial_response):
117+
blocks = initial_response['Blocks']
118+
next_token = initial_response.get('NextToken')
119+
while next_token:
120+
response = textract.get_document_text_detection(JobId=job_id, NextToken=next_token)
121+
blocks.extend(response['Blocks'])
122+
next_token = response.get('NextToken')
123+
return blocks
124+
125+
def create_and_upload_csv(blocks, original_filename):
126+
string_buffer = io.StringIO()
127+
writer = csv.writer(string_buffer)
128+
writer.writerow(['DetectedText'])
129+
for block in blocks:
130+
if block['BlockType'] == 'LINE':
131+
writer.writerow([block['Text']])
132+
csv_string = string_buffer.getvalue()
133+
csv_bytes = csv_string.encode('utf-8')
134+
bytes_buffer = io.BytesIO(csv_bytes)
135+
base_filename = os.path.splitext(original_filename)[0]
136+
csv_filename = f"{base_filename}_result.csv"
137+
s3.upload_fileobj(
138+
bytes_buffer,
139+
S3_BUCKET,
140+
csv_filename,
141+
ExtraArgs={'ContentType': 'text/csv'}
142+
)
143+
return csv_filename
87144

88145
if __name__ == '__main__':
89-
app.run()
146+
app.run(debug=True)

templates/result.html

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,102 @@
22
<html lang="en">
33
<head>
44
<meta charset="UTF-8">
5-
<title>OCR Result</title>
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Processing Complete</title>
7+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
8+
<style>
9+
/* All CSS remains the same */
10+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; background-color: #f4f7f9; color: #333; padding: 20px 0; }
11+
.container { background-color: white; padding: 40px 50px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); text-align: center; max-width: 500px; width: 90%; margin-bottom: 30px; }
12+
h1 { font-size: 1.8rem; margin-bottom: 10px; color: #2c3e50; }
13+
p { font-size: 1rem; color: #7f8c8d; margin-bottom: 25px; line-height: 1.6; }
14+
.fa-check-circle { font-size: 3rem; color: #2ecc71; margin-bottom: 20px; }
15+
.download-btn { display: inline-block; background-color: #27ae60; color: white; text-decoration: none; padding: 15px 30px; border-radius: 10px; font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: background-color 0.3s ease; margin-bottom: 15px; }
16+
.download-btn:hover { background-color: #229954; }
17+
.download-btn .fa-download { margin-right: 10px; }
18+
.small-text { font-size: 0.8rem; color: #95a5a6; margin-top: 0; margin-bottom: 30px; }
19+
.upload-another-btn { display: inline-block; background-color: #3498db; color: white; text-decoration: none; padding: 15px 30px; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: background-color 0.3s ease; }
20+
.upload-another-btn:hover { background-color: #2980b9; }
21+
#print-preview-container { display: none; }
22+
.print-btn { background-color: #8e44ad; color: white; border: none; padding: 15px 20px; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: background-color 0.3s ease; }
23+
.print-btn:hover { background-color: #732d91; }
24+
.print-btn .fa-print { margin-right: 10px; }
25+
#preview-content { background-color: #fdfdfe; border: 1px solid #ecf0f1; border-radius: 8px; padding: 20px; margin-top: 20px; text-align: left; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-family: 'Courier New', Courier, monospace; font-size: 0.9rem; line-height: 1.5; color: #34495e; }
26+
@media print {
27+
body > *:not(#print-preview-container) { display: none !important; }
28+
#print-preview-container, #preview-content { display: block !important; box-shadow: none !important; border: none !important; max-height: none !important; overflow: visible !important; width: 100% !important; max-width: 100% !important; margin: 0 !important; padding: 0 !important; }
29+
}
30+
</style>
631
</head>
732
<body>
8-
<h1>Extracted Text</h1>
9-
<pre>{{ text }}</pre>
10-
<a href="/">Upload another document</a>
33+
34+
<!-- Main Results Box -->
35+
<main class="container">
36+
<i class="fa-solid fa-check-circle"></i>
37+
<h1>Processing Complete</h1>
38+
<p>Your document has been processed and the results are ready for download.</p>
39+
40+
{% if download_url %}
41+
<a href="{{ download_url }}" class="download-btn" download>
42+
<i class="fa-solid fa-download"></i> Download {{ csv_filename }}
43+
</a>
44+
<p class="small-text">Note: This secure link will expire in 5 minutes.</p>
45+
{% else %}
46+
<p>Could not generate a download link. Please check the S3 bucket <b>{{ bucket_name }}</b> for the file <b>{{ csv_filename }}</b>.</p>
47+
{% endif %}
48+
49+
<a href="/" class="upload-another-btn">Upload Another Document</a>
50+
</main>
51+
52+
<!-- Print Preview Box -->
53+
<section class="container" id="print-preview-container">
54+
<button class="print-btn" id="print-button">
55+
<i class="fa-solid fa-print"></i> Preview & Print Results
56+
</button>
57+
<div id="preview-content" aria-live="polite"></div>
58+
</section>
59+
60+
<script>
61+
document.addEventListener('DOMContentLoaded', function() {
62+
const printButton = document.getElementById('print-button');
63+
const previewContainer = document.getElementById('print-preview-container');
64+
const previewContent = document.getElementById('preview-content');
65+
66+
// --- THE FIX IS ON THIS LINE ---
67+
// The |safe filter prevents Flask/Jinja2 from escaping the '&' and
68+
// other special characters in the URL, ensuring the URL is not corrupted.
69+
const downloadUrl = "{{ download_url | safe }}";
70+
// -----------------------------
71+
72+
if (downloadUrl) {
73+
previewContainer.style.display = 'block';
74+
previewContent.style.display = 'none';
75+
}
76+
77+
printButton.addEventListener('click', function() {
78+
previewContent.textContent = 'Loading preview...';
79+
previewContent.style.display = 'block';
80+
81+
fetch(downloadUrl)
82+
.then(response => {
83+
if (!response.ok) {
84+
throw new Error(`Network response was not ok (status: ${response.status})`);
85+
}
86+
return response.text();
87+
})
88+
.then(csvText => {
89+
const rows = csvText.split('\n');
90+
const extractedText = rows.slice(1).join('\n');
91+
previewContent.textContent = extractedText.trim();
92+
window.print();
93+
})
94+
.catch(error => {
95+
console.error('Error fetching or processing CSV:', error);
96+
previewContent.textContent = 'Error: Could not load the file for preview. Please try downloading it directly.';
97+
});
98+
});
99+
});
100+
</script>
101+
11102
</body>
12103
</html>

templates/status.html

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>Processing Document...</title>
7+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
8+
<style>
9+
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background-color: #f4f7f9; color: #333; }
10+
.container { background-color: white; padding: 40px 50px; border-radius: 20px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); text-align: center; max-width: 500px; width: 90%; }
11+
h1 { font-size: 1.8rem; margin-bottom: 10px; color: #2c3e50; }
12+
p { font-size: 1rem; color: #7f8c8d; margin-bottom: 30px; }
13+
.fa-hourglass-half { font-size: 3rem; color: #3498db; }
14+
</style>
15+
</head>
16+
<body>
17+
<main class="container">
18+
<i class="fa-solid fa-hourglass-half fa-spin"></i>
19+
<h1>Processing Document</h1>
20+
<p>Please wait while we extract the text. This page will automatically update when complete.</p>
21+
</main>
22+
23+
<script>
24+
const jobId = "{{ job_id }}";
25+
const originalFilename = "{{ original_filename }}";
26+
27+
function checkStatus() {
28+
fetch(`/api/check_status/${jobId}`)
29+
.then(response => response.json())
30+
.then(data => {
31+
console.log("Current status:", data.status);
32+
if (data.status === 'SUCCEEDED') {
33+
// Job is done, redirect to the final processing URL
34+
window.location.href = `/process_result/${jobId}/${originalFilename}`;
35+
} else if (data.status === 'FAILED') {
36+
// Handle failure
37+
document.querySelector('h1').textContent = 'Processing Failed';
38+
document.querySelector('p').textContent = 'An error occurred. Please try again.';
39+
document.querySelector('.fa-hourglass-half').classList.remove('fa-spin', 'fa-hourglass-half');
40+
document.querySelector('.fa-solid').classList.add('fa-circle-exclamation');
41+
} else {
42+
// If still in progress, check again after 3 seconds
43+
setTimeout(checkStatus, 3000);
44+
}
45+
})
46+
.catch(err => {
47+
console.error("Error checking status:", err);
48+
// Handle network error
49+
document.querySelector('h1').textContent = 'Connection Error';
50+
document.querySelector('p').textContent = 'Could not check the job status. Please check your connection and try again.';
51+
});
52+
}
53+
54+
// Start checking immediately when the page loads
55+
document.addEventListener('DOMContentLoaded', checkStatus);
56+
</script>
57+
</body>
58+
</html>

0 commit comments

Comments
 (0)