Files
telfax/scripts/cover_multilang.py
T
Warren 55bca92691 V1.0: Class 1 fax — real-world 4-page send to external number confirmed
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.
2026-07-24 18:47:15 +08:00

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