55bca92691
Core features: - Class 1 T.30 protocol: full send/receive implementation - HDLC: DLE-stuffing, FCS strip, USR5637 bit-reversal handling - T.4 MH encoder/decoder (1728px A4 standard) - Document pipeline: PDF (Ghostscript), PNG, TIFF input - Width clamping: US Letter 1734px → 1728px fax standard - Cover page: CJK rasterization (TW/CN/JP/EN), TIFF + HTML output - OCR verification: Tesseract 5 with eng+chi_tra, CJK space-tolerant - API server (axum): health, send, jobs, cover, retry, cancel - Background worker: auto-poll queue, speed fallback, retry policy - Modem detection, pool management Real-world test results (2026-07-23): - V90 → 25153038: 4 pages, V.17 12000 bps, 2:33 ✅ - USR5637 → 25153038: 4 pages, V.17 12000 bps, 2:26 ✅ - Both faxes confirmed received on remote machine Tested: loopback (100% pixel match), multi-page, all input formats, cover pages, OCR verify, API endpoints, worker processing. 13 unit tests pass, 0 new clippy warnings.
331 lines
10 KiB
Python
331 lines
10 KiB
Python
from PIL import Image, ImageDraw, ImageFont
|
|
import sys, json, os
|
|
|
|
config = json.loads(sys.argv[1])
|
|
|
|
# Parse configuration
|
|
language = config.get('language', 'english')
|
|
format_config = config.get('format', {})
|
|
layout = config.get('layout', 'standard')
|
|
fonts_config = config.get('fonts', {})
|
|
images_config = config.get('images', {})
|
|
content = config.get('content', {})
|
|
|
|
# Page dimensions (A4 at 204x196 dpi)
|
|
W, H = 1728, 2291
|
|
img = Image.new('L', (W, H), 255)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Font fallback chains
|
|
FONT_FALLBACKS = {
|
|
'english': [
|
|
'/System/Library/Fonts/Helvetica.ttc',
|
|
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
|
],
|
|
'chinese': [
|
|
'/System/Library/AssetsV2/com_apple_MobileAsset_Font8/86ba2c91f017a3749571a82f2c6d890ac7ffb2fb.asset/AssetData/PingFang.ttc',
|
|
'/System/Library/Fonts/STHeiti Medium.ttc',
|
|
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
|
],
|
|
'japanese': [
|
|
'/System/Library/Fonts/ヒラギノ角ゴシック W3.ttc',
|
|
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
|
],
|
|
'korean': [
|
|
'/System/Library/Fonts/AppleSDGothicNeo.ttc',
|
|
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
|
],
|
|
'german': [
|
|
'/System/Library/Fonts/Helvetica.ttc',
|
|
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
|
],
|
|
'french': [
|
|
'/System/Library/Fonts/Helvetica.ttc',
|
|
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
|
],
|
|
'spanish': [
|
|
'/System/Library/Fonts/Helvetica.ttc',
|
|
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
|
|
],
|
|
}
|
|
|
|
# Language-specific labels
|
|
LABELS = {
|
|
'english': {
|
|
'title': 'FACSIMILE',
|
|
'to': 'TO:',
|
|
'from': 'FROM:',
|
|
'date': 'DATE:',
|
|
'pages': 'PAGES:',
|
|
'subject': 'SUBJECT:',
|
|
'notes': 'NOTES:',
|
|
'urgency': {
|
|
'normal': 'NORMAL',
|
|
'urgent': 'URGENT',
|
|
'very_urgent': 'VERY URGENT',
|
|
'fyi': 'FOR YOUR INFORMATION',
|
|
'reply': 'PLEASE REPLY',
|
|
},
|
|
},
|
|
'chinese': {
|
|
'title': '傳真',
|
|
'to': '收件人:',
|
|
'from': '發件人:',
|
|
'date': '日期:',
|
|
'pages': '頁數:',
|
|
'subject': '主題:',
|
|
'notes': '備註:',
|
|
'urgency': {
|
|
'normal': '普通',
|
|
'urgent': '緊急',
|
|
'very_urgent': '非常緊急',
|
|
'fyi': '參考',
|
|
'reply': '請回覆',
|
|
},
|
|
},
|
|
'japanese': {
|
|
'title': 'FAX',
|
|
'to': '宛先:',
|
|
'from': '送信者:',
|
|
'date': '日付:',
|
|
'pages': 'ページ:',
|
|
'subject': '件名:',
|
|
'notes': '備考:',
|
|
'urgency': {
|
|
'normal': '通常',
|
|
'urgent': '緊急',
|
|
'very_urgent': '至急',
|
|
'fyi': '参考',
|
|
'reply': '返信願',
|
|
},
|
|
},
|
|
'korean': {
|
|
'title': '팩스',
|
|
'to': '수신:',
|
|
'from': '발신:',
|
|
'date': '날짜:',
|
|
'pages': '페이지:',
|
|
'subject': '제목:',
|
|
'notes': '참고:',
|
|
'urgency': {
|
|
'normal': '일반',
|
|
'urgent': '긴급',
|
|
'very_urgent': '매우 긴급',
|
|
'fyi': '참고',
|
|
'reply': '회신 요망',
|
|
},
|
|
},
|
|
'german': {
|
|
'title': 'FAX',
|
|
'to': 'AN:',
|
|
'from': 'VON:',
|
|
'date': 'DATUM:',
|
|
'pages': 'SEITEN:',
|
|
'subject': 'BETREFF:',
|
|
'notes': 'NOTIZEN:',
|
|
'urgency': {
|
|
'normal': 'NORMAL',
|
|
'urgent': 'DRINGEND',
|
|
'very_urgent': 'SEHR DRINGEND',
|
|
'fyi': 'ZUR INFORMATION',
|
|
'reply': 'BITTE ANTWORT',
|
|
},
|
|
},
|
|
'french': {
|
|
'title': 'FAX',
|
|
'to': 'À:',
|
|
'from': 'DE:',
|
|
'date': 'DATE:',
|
|
'pages': 'PAGES:',
|
|
'subject': 'OBJET:',
|
|
'notes': 'NOTES:',
|
|
'urgency': {
|
|
'normal': 'NORMAL',
|
|
'urgent': 'URGENT',
|
|
'very_urgent': 'TRÈS URGENT',
|
|
'fyi': 'POUR INFORMATION',
|
|
'reply': 'MERCI DE RÉPONDRE',
|
|
},
|
|
},
|
|
'spanish': {
|
|
'title': 'FAX',
|
|
'to': 'PARA:',
|
|
'from': 'DE:',
|
|
'date': 'FECHA:',
|
|
'pages': 'PÁGINAS:',
|
|
'subject': 'ASUNTO:',
|
|
'notes': 'NOTAS:',
|
|
'urgency': {
|
|
'normal': 'NORMAL',
|
|
'urgent': 'URGENTE',
|
|
'very_urgent': 'MUY URGENTE',
|
|
'fyi': 'PARA SU INFORMACIÓN',
|
|
'reply': 'POR FAVOR RESPONDA',
|
|
},
|
|
},
|
|
}
|
|
|
|
def load_font(lang, size, index=0):
|
|
"""Load font with fallback chain"""
|
|
fallbacks = FONT_FALLBACKS.get(lang, FONT_FALLBACKS['english'])
|
|
for font_path in fallbacks:
|
|
if os.path.exists(font_path):
|
|
try:
|
|
if 'PingFang' in font_path:
|
|
# PingFang font indices: 2=Light, 6=Medium, 10=Semibold
|
|
if size >= 60:
|
|
return ImageFont.truetype(font_path, size, index=10)
|
|
elif size >= 40:
|
|
return ImageFont.truetype(font_path, size, index=6)
|
|
else:
|
|
return ImageFont.truetype(font_path, size, index=2)
|
|
else:
|
|
return ImageFont.truetype(font_path, size, index=index)
|
|
except Exception as e:
|
|
continue
|
|
return ImageFont.load_default()
|
|
|
|
def load_image(path):
|
|
"""Load image in various formats (PNG, JPEG, TIFF, BMP, GIF, WebP)"""
|
|
try:
|
|
img = Image.open(path)
|
|
# Convert to grayscale if needed
|
|
if img.mode not in ('L', 'RGB'):
|
|
img = img.convert('RGB')
|
|
return img
|
|
except Exception as e:
|
|
print(json.dumps({"ok": False, "error": f"Failed to load image {path}: {str(e)}"}))
|
|
return None
|
|
|
|
def add_image_with_opacity(draw, img, position, size, opacity):
|
|
"""Add image with specified opacity"""
|
|
if img is None:
|
|
return
|
|
|
|
# Resize if size specified
|
|
if size:
|
|
img = img.resize((size['width'], size['height']), Image.LANCZOS)
|
|
|
|
# Apply opacity
|
|
if opacity < 1.0:
|
|
# Create alpha mask
|
|
alpha = img.split()[-1] if img.mode == 'RGBA' else Image.new('L', img.size, 255)
|
|
alpha = alpha.point(lambda p: int(p * opacity))
|
|
if img.mode != 'RGBA':
|
|
img = img.convert('RGBA')
|
|
img.putalpha(alpha)
|
|
|
|
# Paste onto base image
|
|
if img.mode == 'RGBA':
|
|
base.paste(img, position, img)
|
|
else:
|
|
base.paste(img, position)
|
|
|
|
# Load fonts
|
|
font_size_title = fonts_config.get('title', 72)
|
|
font_size_heading = fonts_config.get('heading', 44)
|
|
font_size_body = fonts_config.get('body', 44)
|
|
font_size_small = fonts_config.get('small', 36)
|
|
|
|
font_title = load_font(language, font_size_title)
|
|
font_bold = load_font(language, font_size_heading)
|
|
font_normal = load_font(language, font_size_body)
|
|
font_small = load_font(language, font_size_small)
|
|
|
|
# Get labels for language
|
|
labels = LABELS.get(language, LABELS['english'])
|
|
|
|
# Add background image
|
|
if 'background' in images_config:
|
|
bg_config = images_config['background']
|
|
bg_img = load_image(bg_config['path'])
|
|
if bg_img:
|
|
opacity = bg_config.get('opacity', 0.3)
|
|
add_image_with_opacity(draw, bg_img, (0, 0), None, opacity)
|
|
|
|
# Add logo
|
|
if 'logo' in images_config:
|
|
logo_config = images_config['logo']
|
|
logo_img = load_image(logo_config['path'])
|
|
if logo_img:
|
|
pos = logo_config.get('position', {'x': 50, 'y': 50})
|
|
size = logo_config.get('size')
|
|
opacity = logo_config.get('opacity', 1.0)
|
|
# Convert percentage to pixels
|
|
x = int(W * pos['x'] / 100)
|
|
y = int(H * pos['y'] / 100)
|
|
add_image_with_opacity(draw, logo_img, (x, y), size, opacity)
|
|
|
|
# Draw footer bar
|
|
draw.rectangle([(0, 2030), (W, 2230)], fill=0)
|
|
|
|
# Draw title
|
|
title = content.get('title', labels['title'])
|
|
_, _, tw, _ = draw.textbbox((0, 0), title, font=font_title)
|
|
draw.text(((W - tw) / 2, 2080), title, fill=255, font=font_title)
|
|
|
|
# Draw line separator
|
|
draw.line([(100, 1980), (W - 100, 1980)], fill=0, width=3)
|
|
|
|
# Date string
|
|
from datetime import datetime
|
|
date_str = content.get('date', datetime.now().strftime('%Y/%m/%d %H:%M'))
|
|
|
|
# Content fields
|
|
fields = [
|
|
(labels['to'], content.get('to', '')),
|
|
(labels['from'], content.get('from', '')),
|
|
(labels['date'], date_str),
|
|
(labels['pages'], str(content.get('total_pages', 1))),
|
|
(labels['subject'], content.get('subject', '')),
|
|
]
|
|
|
|
y = 1880
|
|
for label, value in fields:
|
|
draw.text((100, y), label, fill=0, font=font_bold)
|
|
draw.text((300, y), value, fill=0, font=font_normal)
|
|
y -= 90
|
|
|
|
# Draw urgency if specified
|
|
if 'urgency' in content:
|
|
urgency = content['urgency']
|
|
urgency_label = labels['urgency'].get(urgency, urgency)
|
|
# Draw urgency box
|
|
box_width = 300
|
|
box_x = W - box_width - 100
|
|
draw.rectangle([(box_x, y - 30), (W - 100, y + 30)], outline=0, width=3)
|
|
_, _, tw, th = draw.textbbox((0, 0), urgency_label, font=font_bold)
|
|
draw.text((box_x + (box_width - tw) / 2, y - th / 2), urgency_label, fill=0, font=font_bold)
|
|
y -= 100
|
|
|
|
# Draw line separator
|
|
draw.line([(100, y), (W - 100, y)], fill=0, width=3)
|
|
y -= 70
|
|
|
|
# Draw notes
|
|
notes = content.get('notes', '')
|
|
if notes:
|
|
draw.text((100, y), labels['notes'], fill=0, font=font_bold)
|
|
y -= 50
|
|
for line in notes.split('\n'):
|
|
draw.text((100, y), line, fill=0, font=font_small)
|
|
y -= 40
|
|
|
|
# Add watermark
|
|
if 'watermark' in images_config:
|
|
wm_config = images_config['watermark']
|
|
wm_img = load_image(wm_config['path'])
|
|
if wm_img:
|
|
pos = wm_config.get('position', {'x': 50, 'y': 50})
|
|
size = wm_config.get('size')
|
|
opacity = wm_config.get('opacity', 0.1)
|
|
# Convert percentage to pixels
|
|
x = int(W * pos['x'] / 100)
|
|
y = int(H * pos['y'] / 100)
|
|
add_image_with_opacity(draw, wm_img, (x, y), size, opacity)
|
|
|
|
# Save output
|
|
output_path = content.get('output', '/tmp/cover.tif')
|
|
img.save(output_path, dpi=(204, 196))
|
|
|
|
print(json.dumps({"ok": True, "output": output_path})) |