Add cover page support with Chinese font rendering

Features:
- Cover page generation using Python/Pillow with Apple PingFang font
- CLI commands: cover-create, job-cover for cover page management
- API endpoints: PUT /api/fax/jobs/{id}/cover for cover modification
- Send fax with cover page: --to, --from, --subject, --note options
- Multi-page TIFF parsing for PDF conversion (fix: all pages now sent)
- Preview HTML generation with pan/zoom support
- OCR verification for cover page content
- Class 2 receive support for incoming faxes

Fixes:
- Modem initialization before Class 2 mode entry
- Dial timeout handling with retry logic
- Multi-page fax transmission between pages
- Chinese character rendering in cover pages (Apple native fonts)
This commit is contained in:
Warren
2026-07-02 18:43:16 +08:00
parent b102917644
commit d1e92b32fb
20 changed files with 1849 additions and 83 deletions
Generated
+7
View File
@@ -262,6 +262,12 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bit_field" name = "bit_field"
version = "0.10.3" version = "0.10.3"
@@ -1981,6 +1987,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"base64",
"chrono", "chrono",
"clap", "clap",
"fax", "fax",
+1
View File
@@ -37,6 +37,7 @@ anyhow = "1"
# Serialization # Serialization
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
base64 = "0.22"
# Logging # Logging
tracing = "0.1" tracing = "0.1"
+61 -2
View File
@@ -2,7 +2,7 @@ use axum::{
extract::State, extract::State,
http::StatusCode, http::StatusCode,
response::Json, response::Json,
routing::{get, post}, routing::{get, post, put},
Router, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -30,6 +30,10 @@ pub struct HealthResponse {
pub struct SendFaxRequest { pub struct SendFaxRequest {
pub recipient: String, pub recipient: String,
pub document_path: String, pub document_path: String,
pub cover_to: Option<String>,
pub cover_from: Option<String>,
pub cover_subject: Option<String>,
pub cover_notes: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -37,6 +41,23 @@ pub struct SendFaxResponse {
pub job_id: JobId, pub job_id: JobId,
} }
#[derive(Deserialize)]
pub struct UpdateCoverRequest {
pub to: Option<String>,
pub from: Option<String>,
pub subject: Option<String>,
pub notes: Option<String>,
}
#[derive(Serialize)]
pub struct UpdateCoverResponse {
pub job_id: JobId,
pub cover_to: Option<String>,
pub cover_from: Option<String>,
pub cover_subject: Option<String>,
pub cover_notes: Option<String>,
}
#[derive(Serialize)] #[derive(Serialize)]
pub struct JobResponse { pub struct JobResponse {
pub id: JobId, pub id: JobId,
@@ -44,6 +65,10 @@ pub struct JobResponse {
pub status: String, pub status: String,
pub pages: u32, pub pages: u32,
pub created_at: String, pub created_at: String,
pub cover_to: Option<String>,
pub cover_from: Option<String>,
pub cover_subject: Option<String>,
pub cover_notes: Option<String>,
} }
async fn health(State(state): State<AppState>) -> Json<HealthResponse> { async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
@@ -57,7 +82,11 @@ async fn send_fax(
State(state): State<AppState>, State(state): State<AppState>,
Json(req): Json<SendFaxRequest>, Json(req): Json<SendFaxRequest>,
) -> Result<Json<SendFaxResponse>, StatusCode> { ) -> Result<Json<SendFaxResponse>, StatusCode> {
let job = FaxJob::new(req.recipient, req.document_path); let mut job = FaxJob::new(req.recipient, req.document_path);
job.cover_to = req.cover_to;
job.cover_from = req.cover_from;
job.cover_subject = req.cover_subject;
job.cover_notes = req.cover_notes;
let job_id = job.id; let job_id = job.id;
let mut queue = state.queue.lock().unwrap(); let mut queue = state.queue.lock().unwrap();
@@ -78,6 +107,10 @@ async fn list_jobs(State(state): State<AppState>) -> Json<Vec<JobResponse>> {
status: format!("{:?}", j.status), status: format!("{:?}", j.status),
pages: j.pages, pages: j.pages,
created_at: j.created_at.to_rfc3339(), created_at: j.created_at.to_rfc3339(),
cover_to: j.cover_to,
cover_from: j.cover_from,
cover_subject: j.cover_subject,
cover_notes: j.cover_notes,
}) })
.collect(); .collect();
Json(responses) Json(responses)
@@ -94,9 +127,34 @@ async fn get_job(
status: format!("{:?}", j.status), status: format!("{:?}", j.status),
pages: j.pages, pages: j.pages,
created_at: j.created_at.to_rfc3339(), created_at: j.created_at.to_rfc3339(),
cover_to: j.cover_to,
cover_from: j.cover_from,
cover_subject: j.cover_subject,
cover_notes: j.cover_notes,
})).ok_or(StatusCode::NOT_FOUND) })).ok_or(StatusCode::NOT_FOUND)
} }
async fn update_job_cover(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<JobId>,
Json(req): Json<UpdateCoverRequest>,
) -> Result<Json<UpdateCoverResponse>, StatusCode> {
let mut queue = state.queue.lock().unwrap();
queue
.update_cover(&id, req.to.clone(), req.from.clone(), req.subject.clone(), req.notes.clone())
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
// Return updated fields
let job = queue.get(&id).ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(UpdateCoverResponse {
job_id: job.id,
cover_to: job.cover_to,
cover_from: job.cover_from,
cover_subject: job.cover_subject,
cover_notes: job.cover_notes,
}))
}
pub async fn start_server(config: FaxConfig, queue: Arc<Mutex<FaxQueue>>) -> FaxResult<()> { pub async fn start_server(config: FaxConfig, queue: Arc<Mutex<FaxQueue>>) -> FaxResult<()> {
let state = AppState { let state = AppState {
config: config.clone(), config: config.clone(),
@@ -108,6 +166,7 @@ pub async fn start_server(config: FaxConfig, queue: Arc<Mutex<FaxQueue>>) -> Fax
.route("/api/fax/send", post(send_fax)) .route("/api/fax/send", post(send_fax))
.route("/api/fax/jobs", get(list_jobs)) .route("/api/fax/jobs", get(list_jobs))
.route("/api/fax/jobs/{id}", get(get_job)) .route("/api/fax/jobs/{id}", get(get_job))
.route("/api/fax/jobs/{id}/cover", put(update_job_cover))
.with_state(state); .with_state(state);
let addr = config let addr = config
+141
View File
@@ -0,0 +1,141 @@
use crate::error::{FaxError, Result};
use crate::document::convert::Page;
use std::process::Command;
const COVER_PY: &str = r#"from PIL import Image, ImageDraw, ImageFont
import sys, json, os
params = json.loads(sys.argv[1])
to = params['to']
sender = params['from']
subject = params['subject']
notes = params['notes']
total_pages = params['total_pages']
output_path = params['output']
W, H = 1728, 2291
img = Image.new('L', (W, H), 255)
draw = ImageDraw.Draw(img)
PINGFANG_PATH = '/System/Library/AssetsV2/com_apple_MobileAsset_Font8/86ba2c91f017a3749571a82f2c6d890ac7ffb2fb.asset/AssetData/PingFang.ttc'
STHEITI_PATH = '/System/Library/Fonts/STHeiti Medium.ttc'
font_path = PINGFANG_PATH if os.path.exists(PINGFANG_PATH) else STHEITI_PATH if os.path.exists(STHEITI_PATH) else None
font_title = None
font_bold = None
font_normal = None
font_small = None
if font_path:
try:
if 'PingFang' in font_path:
font_title = ImageFont.truetype(font_path, 72, index=10)
font_bold = ImageFont.truetype(font_path, 44, index=6)
font_normal = ImageFont.truetype(font_path, 44, index=2)
font_small = ImageFont.truetype(font_path, 36, index=2)
else:
font_title = ImageFont.truetype(font_path, 72, index=0)
font_bold = ImageFont.truetype(font_path, 44, index=0)
font_normal = ImageFont.truetype(font_path, 44, index=0)
font_small = ImageFont.truetype(font_path, 36, index=0)
except Exception as e:
font_path = None
if not font_path:
try:
font_title = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 72)
font_bold = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 44)
font_normal = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 44)
font_small = ImageFont.truetype('/System/Library/Fonts/Supplemental/Arial Unicode.ttf', 36)
except Exception:
font_title = ImageFont.load_default()
font_bold = font_title
font_normal = font_title
font_small = font_title
draw.rectangle([(0, 2030), (1728, 2230)], fill=0)
_, _, tw, _ = draw.textbbox((0, 0), 'FACSIMILE', font=font_title)
draw.text(((W - tw) / 2, 2080), 'FACSIMILE', fill=255, font=font_title)
draw.line([(100, 1980), (1628, 1980)], fill=0, width=3)
from datetime import datetime
date_str = datetime.now().strftime('%Y/%m/%d %H:%M')
labels = ['TO:', 'FROM:', 'DATE:', 'PAGES:', 'SUBJECT:']
values = [to, sender, date_str, str(total_pages), subject]
y = 1880
for label, value in zip(labels, values):
draw.text((100, y), label, fill=0, font=font_bold)
draw.text((300, y), value, fill=0, font=font_normal)
y -= 90
draw.line([(100, y), (1628, y)], fill=0, width=3)
y -= 70
draw.text((100, y), '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
img.save(output_path, dpi=(204, 196))
print(json.dumps({"ok": True}))
"#;
pub struct CoverParams {
pub to: String,
pub from: String,
pub subject: String,
pub notes: String,
pub total_pages: u32,
}
pub fn generate_cover_page(params: &CoverParams) -> Result<Page> {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let py_dir = std::env::temp_dir();
let py_path = py_dir.join(format!("telfax_cover_{}.py", stamp));
let tiff_path = py_dir.join(format!("telfax_cover_{}.tif", stamp));
std::fs::write(&py_path, COVER_PY)?;
let py_params = serde_json::json!({
"to": params.to,
"from": params.from,
"subject": params.subject,
"notes": params.notes,
"total_pages": params.total_pages,
"output": tiff_path.to_string_lossy(),
});
let python = if cfg!(target_os = "windows") { "python" } else { "python3" };
let output = Command::new(python)
.arg(py_path.to_string_lossy().as_ref())
.arg(py_params.to_string())
.output()
.map_err(|e| FaxError::document(format!("Python cover script failed: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
std::fs::remove_file(&py_path).ok();
std::fs::remove_file(&tiff_path).ok();
return Err(FaxError::document(format!("Cover generation error: {}", stderr)));
}
let tiff_data = std::fs::read(&tiff_path)?;
let doc = super::pdf::tiff_to_fax_document(&tiff_data)?;
std::fs::remove_file(&py_path).ok();
std::fs::remove_file(&tiff_path).ok();
Ok(doc.pages.into_iter().next().ok_or_else(|| {
FaxError::document("Cover page generation produced no pages")
})?)
}
+2
View File
@@ -1,7 +1,9 @@
pub mod convert; pub mod convert;
pub mod tiff; pub mod tiff;
pub mod pdf; pub mod pdf;
pub mod cover;
pub use convert::{document_from_image, document_from_tiff, DocumentFormat, FaxDocument, Page}; pub use convert::{document_from_image, document_from_tiff, DocumentFormat, FaxDocument, Page};
pub use tiff::TiffFaxWriter; pub use tiff::TiffFaxWriter;
pub use pdf::pdf_to_fax_document; pub use pdf::pdf_to_fax_document;
pub use cover::{generate_cover_page, CoverParams};
+17 -3
View File
@@ -55,6 +55,9 @@ pub fn tiff_to_fax_document(tiff_data: &[u8]) -> Result<FaxDocument> {
let mut decoder = Decoder::new(&mut cursor) let mut decoder = Decoder::new(&mut cursor)
.map_err(|e| FaxError::document(format!("Failed to open TIFF: {}", e)))?; .map_err(|e| FaxError::document(format!("Failed to open TIFF: {}", e)))?;
let mut pages = Vec::new();
loop {
let (width, height) = decoder.dimensions() let (width, height) = decoder.dimensions()
.map_err(|e| FaxError::document(format!("Failed to get TIFF dimensions: {}", e)))?; .map_err(|e| FaxError::document(format!("Failed to get TIFF dimensions: {}", e)))?;
@@ -85,14 +88,25 @@ pub fn tiff_to_fax_document(tiff_data: &[u8]) -> Result<FaxDocument> {
} }
} }
let page = Page { pages.push(Page {
pixels, pixels,
width_pels: width, width_pels: width,
rows: height, rows: height,
}; });
// Try to advance to next page
match decoder.next_image() {
Ok(_) => continue,
Err(_) => break,
}
}
if pages.is_empty() {
return Err(FaxError::document("TIFF contains no pages"));
}
Ok(FaxDocument { Ok(FaxDocument {
pages: vec![page], pages,
format: DocumentFormat::Tiff, format: DocumentFormat::Tiff,
}) })
} }
+13 -2
View File
@@ -2,6 +2,11 @@ use crate::document::convert::Page;
use crate::error::{FaxError, Result}; use crate::error::{FaxError, Result};
use tiff::encoder::colortype::Gray8; use tiff::encoder::colortype::Gray8;
use tiff::encoder::TiffEncoder; use tiff::encoder::TiffEncoder;
use tiff::encoder::Rational;
use tiff::tags::ResolutionUnit;
const FAX_X_DPI: u32 = 204;
const FAX_Y_DPI: u32 = 196;
pub struct TiffFaxWriter; pub struct TiffFaxWriter;
@@ -25,8 +30,14 @@ impl TiffFaxWriter {
} }
} }
encoder let mut img = encoder
.write_image::<Gray8>(page.width_pels, page.rows, &gray_data) .new_image::<Gray8>(page.width_pels, page.rows)
.map_err(|e| FaxError::document(format!("Failed to create TIFF image: {}", e)))?;
img.resolution_unit(ResolutionUnit::Inch);
img.x_resolution(Rational { n: FAX_X_DPI, d: 1 });
img.y_resolution(Rational { n: FAX_Y_DPI, d: 1 });
img.write_data(&gray_data)
.map_err(|e| FaxError::document(format!("Failed to write TIFF page: {}", e)))?; .map_err(|e| FaxError::document(format!("Failed to write TIFF page: {}", e)))?;
} }
+71 -11
View File
@@ -39,6 +39,27 @@ impl<'a> Class1Send<'a> {
self.session.transition(T30Event::Connected); self.session.transition(T30Event::Connected);
// After CONNECT, some modems need escape to enter online command mode
// Wait for silence (no CED tone yet), then escape
tracing::info!("Connection established, waiting for silence...");
std::thread::sleep(std::time::Duration::from_millis(1000));
// Send escape sequence +++ (with guard times)
self.driver.write_raw(b"+++")?;
self.driver.flush()?;
std::thread::sleep(std::time::Duration::from_millis(1000));
// Drain response
self.driver.drain()?;
// Wait for CED tone and DIS from receiver (per T.30 spec)
tracing::info!("Waiting for receiver to send CED and DIS...");
std::thread::sleep(std::time::Duration::from_millis(4000));
// Drain any pending data before entering HDLC receive mode
self.driver.drain()?;
tracing::info!("Entering HDLC receive mode...");
let dis_frame = self.receive_hdlc_frame()?; let dis_frame = self.receive_hdlc_frame()?;
tracing::info!("Received DIS frame ({} bytes)", dis_frame.len()); tracing::info!("Received DIS frame ({} bytes)", dis_frame.len());
@@ -95,32 +116,71 @@ impl<'a> Class1Send<'a> {
self.driver.write_raw(cmd.as_bytes())?; self.driver.write_raw(cmd.as_bytes())?;
self.driver.flush()?; self.driver.flush()?;
let response = self.driver.read_until(b"\r\n", 60000)?; tracing::info!("Dialing {}, waiting for response...", number);
let text = String::from_utf8_lossy(&response);
tracing::info!(response = %text, "Dial response");
if text.contains("NO CARRIER") || text.contains("BUSY") { let mut full_response = Vec::new();
let start = std::time::Instant::now();
let timeout_ms = 60000u64;
loop {
match self.driver.read_until(b"\r\n", 5000) {
Ok(line) => {
let text = String::from_utf8_lossy(&line);
tracing::info!("Dial line: {}", text.trim());
full_response.extend_from_slice(&line);
if text.contains("CONNECT") {
tracing::info!("Connection established");
return Ok(());
}
if text.contains("BUSY") {
return Err(FaxError::modem("Line is BUSY"));
}
if text.contains("NO CARRIER") {
return Err(FaxError::NoCarrier); return Err(FaxError::NoCarrier);
} }
if !text.contains("CONNECT") { if text.contains("NO DIALTONE") {
return Err(FaxError::modem(format!("Unexpected dial response: {}", text))); return Err(FaxError::modem("No dial tone"));
} }
Ok(()) }
Err(_) => {
if start.elapsed().as_millis() as u64 > timeout_ms {
break;
}
}
}
}
let text = String::from_utf8_lossy(&full_response);
Err(FaxError::modem(format!("Dial timeout, response: {}", text)))
} }
fn receive_hdlc_frame(&mut self) -> Result<Vec<u8>> { fn receive_hdlc_frame(&mut self) -> Result<Vec<u8>> {
self.driver.write_raw(b"AT+FRH=3\r")?; self.driver.write_raw(b"AT+FRH=3\r")?;
self.driver.flush()?;
let response = self.driver.read_until(b"\r\n", 15000)?; // Wait for CONNECT response - modem may take time to enter HDLC receive mode
let response = self.driver.read_until(b"CONNECT\r\n", 30000)?;
let text = String::from_utf8_lossy(&response); let text = String::from_utf8_lossy(&response);
tracing::debug!(response = %text, "FRH initial"); tracing::info!(response = %text, "FRH response");
if text.contains("NO CARRIER") || text.contains("ERROR") { if text.contains("NO CARRIER") || text.contains("ERROR") {
return Err(FaxError::NoCarrier); return Err(FaxError::NoCarrier);
} }
let result = self.driver.read_until(b"OK\r\n", 10000)?; // Now read the actual HDLC frame data until OK
Ok(result) // The modem sends the HDLC frame bytes, then "OK\r\n"
let frame_and_ok = self.driver.read_until(b"OK\r\n", 60000)?;
// Remove trailing "OK\r\n" if present
let frame_data = if frame_and_ok.ends_with(b"OK\r\n") {
&frame_and_ok[..frame_and_ok.len() - 4]
} else {
&frame_and_ok[..]
};
tracing::info!("Received {} bytes of HDLC data", frame_data.len());
Ok(frame_data.to_vec())
} }
fn transmit_hdlc_frame(&mut self, frame: &[u8]) -> Result<()> { fn transmit_hdlc_frame(&mut self, frame: &[u8]) -> Result<()> {
+2
View File
@@ -1,5 +1,7 @@
pub mod send; pub mod send;
pub mod receive;
pub mod commands; pub mod commands;
pub use send::Class2Send; pub use send::Class2Send;
pub use receive::Class2Receive;
pub use commands::Class2Commands; pub use commands::Class2Commands;
+230
View File
@@ -0,0 +1,230 @@
use crate::error::{FaxError, Result};
use crate::modem::driver::ModemDriver;
const DLE: u8 = 0x10;
const ETX: u8 = 0x03;
pub struct Class2Receive<'a> {
driver: &'a mut ModemDriver,
station_id: String,
}
impl<'a> Class2Receive<'a> {
pub fn new(driver: &'a mut ModemDriver) -> Self {
Self {
driver,
station_id: "TELFAX".to_string(),
}
}
pub fn with_station_id(mut self, id: &str) -> Self {
self.station_id = id.to_string();
self
}
fn at(&mut self) -> crate::modem::at::AtChannel<'_> {
crate::modem::at::AtChannel::new(self.driver)
}
pub fn setup_auto_answer(&mut self, rings: u8) -> Result<()> {
tracing::info!("Setting up Class 2 auto-answer ({} rings)", rings);
self.at().send_command("AT", 2000)?;
self.at().send_command("AT+FCLASS=2", 3000)?;
let station_cmd = format!("AT+FLID=\"{}\"", self.station_id);
self.at().send_command(&station_cmd, 3000)?;
let rings_cmd = format!("ATS0={}", rings);
self.at().send_command(&rings_cmd, 2000)?;
self.at().send_command("AT+FAA=1", 2000)?;
tracing::info!("Auto-answer configured");
Ok(())
}
pub fn wait_for_call(&mut self, timeout_ms: u64) -> Result<()> {
tracing::info!("Waiting for incoming call...");
// In Class 2, the modem handles T.30 negotiation automatically
// We need to wait for +FCON which indicates fax session established
let response = self.driver.read_until(b"+FCON", timeout_ms)?;
let text = String::from_utf8_lossy(&response);
tracing::info!("Fax connection: {}", text.trim());
// Wait for +FDCS (Digital Command Signal) or +FPTS (Page Transfer Start)
// The modem will handle DIS/DCS negotiation automatically
tracing::info!("Waiting for page data...");
Ok(())
}
pub fn receive_pages(&mut self) -> Result<Vec<Vec<u8>>> {
let mut pages = Vec::new();
let mut page_count = 0;
let mut receiving_page = false;
let mut page_data = Vec::new();
tracing::info!("Monitoring for page events...");
loop {
let event = self.wait_for_event(60000)?;
match event {
ReceiveEvent::Fpts => {
tracing::info!("Page {} transfer starting...", page_count + 1);
receiving_page = true;
page_data = Vec::new();
}
ReceiveEvent::Ftsi(id) => {
tracing::info!("Transmitting station: {}", id);
}
ReceiveEvent::PageData(data) => {
if receiving_page {
page_data.extend_from_slice(&data);
}
}
ReceiveEvent::PageEnd(more) => {
if receiving_page && !page_data.is_empty() {
tracing::info!("Page {} received ({} bytes)", page_count + 1, page_data.len());
pages.push(page_data.clone());
page_count += 1;
}
receiving_page = false;
if !more {
tracing::info!("No more pages");
break;
}
}
ReceiveEvent::Fhng(code) => {
tracing::info!("Session ended with code {}", code);
break;
}
ReceiveEvent::NoCarrier => {
tracing::info!("Carrier lost");
break;
}
ReceiveEvent::Error(msg) => {
tracing::error!("Error: {}", msg);
if receiving_page && !page_data.is_empty() {
pages.push(page_data.clone());
}
break;
}
_ => {}
}
}
Ok(pages)
}
fn wait_for_event(&mut self, timeout_ms: u64) -> Result<ReceiveEvent> {
let response = self.driver.read_until(b"\r\n", timeout_ms)?;
let text = String::from_utf8_lossy(&response);
let line = text.trim();
tracing::debug!("Event: {}", line);
if line.contains("+FPTS") {
Ok(ReceiveEvent::Fpts)
} else if line.contains("+FTSI") {
let id = extract_quoted_value(line);
Ok(ReceiveEvent::Ftsi(id))
} else if line.contains("+FDCS") {
Ok(ReceiveEvent::Fdcs)
} else if line.contains("+FCON") {
Ok(ReceiveEvent::Fcon)
} else if line.contains("+FET") || line.contains("+FCFR") {
let more = line.contains(",1") || line.contains("+FCFR");
Ok(ReceiveEvent::PageEnd(more))
} else if line.contains("+FHNG") {
let code = extract_number(line);
Ok(ReceiveEvent::Fhng(code))
} else if line.contains("NO CARRIER") {
Ok(ReceiveEvent::NoCarrier)
} else if line.contains("ERROR") {
Ok(ReceiveEvent::Error(line.to_string()))
} else if line.contains("OK") {
// Data transmission complete marker
Ok(ReceiveEvent::DataOk)
} else if !line.is_empty() && !line.starts_with("AT") {
// Might be data - try to read raw bytes
let data = self.read_page_stream()?;
Ok(ReceiveEvent::PageData(data))
} else {
Ok(ReceiveEvent::Unknown(line.to_string()))
}
}
fn read_page_stream(&mut self) -> Result<Vec<u8>> {
let mut data = Vec::new();
let mut dle_seen = false;
// Read until DLE-ETX
for _ in 0..100000 {
let byte = self.driver.read_byte(5000)?;
if dle_seen {
if byte == ETX {
break;
} else if byte == DLE {
data.push(DLE);
}
dle_seen = false;
} else if byte == DLE {
dle_seen = true;
} else {
data.push(byte);
}
}
Ok(data)
}
pub fn end_session(&mut self) -> Result<()> {
tracing::info!("Ending receive session");
self.driver.write_raw(b"AT+FKS\r")?;
self.driver.flush()?;
std::thread::sleep(std::time::Duration::from_millis(500));
self.driver.drain()?;
Ok(())
}
}
enum ReceiveEvent {
Fcon,
Fdcs,
Fpts,
Ftsi(String),
PageData(Vec<u8>),
PageEnd(bool),
Fhng(u8),
NoCarrier,
DataOk,
Error(String),
Unknown(String),
}
fn extract_quoted_value(s: &str) -> String {
if let Some(start) = s.find('"') {
if let Some(end) = s[start + 1..].find('"') {
return s[start + 1..start + 1 + end].to_string();
}
}
s.to_string()
}
fn extract_number(s: &str) -> u8 {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() > 1 {
parts[1].trim().split(',')
.next()
.and_then(|n| n.trim().parse().ok())
.unwrap_or(0)
} else {
0
}
}
+82 -15
View File
@@ -51,6 +51,10 @@ impl<'a> Class2Send<'a> {
pub fn send_fax(&mut self, number: &str, pages: &[Page]) -> Result<()> { pub fn send_fax(&mut self, number: &str, pages: &[Page]) -> Result<()> {
tracing::info!("Starting Class 2 fax to {}", number); tracing::info!("Starting Class 2 fax to {}", number);
// Initialize modem first
self.at().send_command("AT", 3000)?;
tracing::debug!("Modem initialized");
// Enter Class 2 mode // Enter Class 2 mode
self.at().send_command(Class2Commands::ENTER_CLASS2, 5000)?; self.at().send_command(Class2Commands::ENTER_CLASS2, 5000)?;
tracing::info!("Entered Class 2 mode"); tracing::info!("Entered Class 2 mode");
@@ -60,14 +64,18 @@ impl<'a> Class2Send<'a> {
self.at().send_command(&station_cmd, 3000)?; self.at().send_command(&station_cmd, 3000)?;
tracing::info!("Station ID set: {}", self.station_id); tracing::info!("Station ID set: {}", self.station_id);
// Set header // Set header - may not be supported on some modems
let header_cmd = Class2Commands::set_header(&self.header); let header_cmd = Class2Commands::set_header(&self.header);
self.at().send_command(&header_cmd, 3000)?; if self.at().send_command(&header_cmd, 3000).is_err() {
tracing::warn!("Header command not supported, skipping");
}
// Set capabilities // Set capabilities - skip if not supported
let speed = if self.params.speed > 0 { self.params.speed } else { BR_V17_14400 }; let speed = if self.params.speed > 0 { self.params.speed } else { BR_V17_14400 };
let dis_cmd = Class2Commands::set_dis_params(self.params.resolution, speed, self.params.ecm); let dis_cmd = Class2Commands::set_dis_params(self.params.resolution, speed, self.params.ecm);
self.at().send_command(&dis_cmd, 3000)?; if self.at().send_command(&dis_cmd, 3000).is_err() {
tracing::warn!("DIS params command not supported, using defaults");
}
// Dial // Dial
self.dial(number)?; self.dial(number)?;
@@ -94,25 +102,64 @@ impl<'a> Class2Send<'a> {
self.driver.write_raw(cmd.as_bytes())?; self.driver.write_raw(cmd.as_bytes())?;
self.driver.write_raw(b"\r")?; self.driver.write_raw(b"\r")?;
self.driver.flush()?; self.driver.flush()?;
tracing::info!("Dialing: {}", cmd);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
// Read lines until CONNECT or error
while std::time::Instant::now() < deadline {
let line = match self.driver.read_until(b"\r\n", 5000) {
Ok(l) => l,
Err(crate::error::FaxError::Timeout { .. }) => continue,
Err(e) => return Err(e),
};
let text = String::from_utf8_lossy(&line).trim().to_string();
if text.is_empty() {
continue;
}
// Wait for CONNECT or response
let response = self.driver.read_until(b"\r\n", 60000)?;
let text = String::from_utf8_lossy(&response);
tracing::info!("Dial response: {}", text); tracing::info!("Dial response: {}", text);
// Check for successful connection // Skip command echo
if text.starts_with("ATDT") || text.starts_with("ATD") {
continue;
}
// Error conditions
if text.contains("NO CARRIER") || text.contains("BUSY") { if text.contains("NO CARRIER") || text.contains("BUSY") {
return Err(FaxError::NoCarrier); return Err(FaxError::NoCarrier);
} }
if text.contains("ERROR") {
return Err(FaxError::modem("Dial command failed"));
}
if text.contains("+FHNG:") {
return Err(FaxError::protocol(format!("Fax hangup: {}", text)));
}
// Class 2: wait for fax session indicators // Connection established
// CONNECT indicates fax session is ready if text.contains("CONNECT") {
// The modem returns: CONNECT, +FCON: <params> tracing::info!("Connected!");
let extra = self.driver.read_all(5000)?; // Read remaining session info (e.g. +FCON)
let extra = self.driver.read_all(2000)?;
let extra_text = String::from_utf8_lossy(&extra); let extra_text = String::from_utf8_lossy(&extra);
if !extra_text.trim().is_empty() {
tracing::info!("Session info: {}", extra_text); tracing::info!("Session info: {}", extra_text);
if extra_text.contains("+FHNG:") {
return Err(FaxError::protocol(format!("Fax hangup: {}", extra_text.trim())));
}
}
return Ok(());
}
Ok(()) // Some modems send +FCON without CONNECT
if text.contains("+FCON") {
tracing::info!("Fax conference established");
return Ok(());
}
}
Err(FaxError::Timeout { ms: 60000 })
} }
fn send_page(&mut self, page: &Page) -> Result<()> { fn send_page(&mut self, page: &Page) -> Result<()> {
@@ -120,14 +167,30 @@ impl<'a> Class2Send<'a> {
self.driver.write_raw(b"AT+FDT\r")?; self.driver.write_raw(b"AT+FDT\r")?;
self.driver.flush()?; self.driver.flush()?;
// Wait for CONNECT // Wait for CONNECT with longer timeout and retry logic
let response = self.driver.read_until(b"CONNECT\r\n", 10000)?; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
let mut got_connect = false;
while std::time::Instant::now() < deadline {
let response = match self.driver.read_until(b"\r\n", 5000) {
Ok(r) => r,
Err(crate::error::FaxError::Timeout { .. }) => continue,
Err(e) => return Err(e),
};
let text = String::from_utf8_lossy(&response); let text = String::from_utf8_lossy(&response);
tracing::debug!("FDT response: {}", text); tracing::debug!("FDT response: {}", text);
if text.contains("CONNECT") {
got_connect = true;
break;
}
if text.contains("ERROR") { if text.contains("ERROR") {
return Err(FaxError::modem("FDT command failed")); return Err(FaxError::modem("FDT command failed"));
} }
}
if !got_connect {
return Err(FaxError::Timeout { ms: 30000 });
}
// Encode pixel data to T.4 MH format // Encode pixel data to T.4 MH format
tracing::info!("Encoding {}x{} pixels to T.4 MH...", page.width_pels, page.rows); tracing::info!("Encoding {}x{} pixels to T.4 MH...", page.width_pels, page.rows);
@@ -164,6 +227,10 @@ impl<'a> Class2Send<'a> {
let response = self.driver.read_until(b"\r\n", 10000)?; let response = self.driver.read_until(b"\r\n", 10000)?;
tracing::debug!("End page response: {}", String::from_utf8_lossy(&response)); tracing::debug!("End page response: {}", String::from_utf8_lossy(&response));
// Brief pause before next page
std::thread::sleep(std::time::Duration::from_millis(500));
self.driver.drain()?;
Ok(()) Ok(())
} }
+28 -6
View File
@@ -23,10 +23,13 @@ pub enum FrameType {
impl FrameType { impl FrameType {
pub fn from_control(ctrl: u8) -> Self { pub fn from_control(ctrl: u8) -> Self {
match ctrl { match ctrl {
0x08 => FrameType::Dis, 0x80 | 0x08 => FrameType::Dis, // DIS from called (0x80) or caller
0x28 => FrameType::Dcs, 0x28 => FrameType::Dcs, // DCS (Digital Command Signal)
0x41 => FrameType::Tcf, 0x21 => FrameType::Cfr, // CFR (Confirmation to Receive)
0x21 => FrameType::Cfr, 0x31 => FrameType::Other(0x31), // MCF (Message Confirmation)
0x41 => FrameType::Tcf, // TCF (Training Check)
0x5D | 0x42 => FrameType::Other(ctrl), // EOP / EOM
0x73 | 0x74 => FrameType::Other(ctrl), // DCN
_ => FrameType::Other(ctrl), _ => FrameType::Other(ctrl),
} }
} }
@@ -73,6 +76,8 @@ pub fn parse_hdlc_frame(data: &[u8]) -> Result<HdlcFrame> {
i += 1; i += 1;
} }
tracing::info!("HDLC raw frame: {:02X?}", frame_data);
if frame_data.len() < 4 { if frame_data.len() < 4 {
return Err(FaxError::Hdlc(format!( return Err(FaxError::Hdlc(format!(
"Frame too short: {} bytes", "Frame too short: {} bytes",
@@ -80,13 +85,30 @@ pub fn parse_hdlc_frame(data: &[u8]) -> Result<HdlcFrame> {
))); )));
} }
// T.30 HDLC frame format:
// [Address] [HDLC Control] [FCF (Facsimile Control Field)] [Information] [FCS]
// FCF indicates frame type (DIS=0x80, DCS=0x28, CFR=0x21, etc.)
let fcs_bytes = &frame_data[frame_data.len() - 2..]; let fcs_bytes = &frame_data[frame_data.len() - 2..];
let fcs = u16::from_le_bytes([fcs_bytes[0], fcs_bytes[1]]); let fcs = u16::from_le_bytes([fcs_bytes[0], fcs_bytes[1]]);
let information = frame_data[2..frame_data.len() - 2].to_vec();
// Use FCF (frame_data[2]) as the control field for T.30 purposes
// If frame has only 3 bytes before FCS, use frame_data[1] (HDLC control)
let control = if frame_data.len() > 4 {
frame_data[2] // T.30 FCF
} else {
frame_data[1] // HDLC control (fallback)
};
let information_start = if frame_data.len() > 4 { 3 } else { 2 };
let information = if information_start < frame_data.len() - 2 {
frame_data[information_start..frame_data.len() - 2].to_vec()
} else {
Vec::new()
};
Ok(HdlcFrame { Ok(HdlcFrame {
address: frame_data[0], address: frame_data[0],
control: frame_data[1], control,
information, information,
fcs, fcs,
}) })
+2
View File
@@ -13,6 +13,8 @@ pub mod error;
pub mod modem; pub mod modem;
pub mod fax; pub mod fax;
pub mod document; pub mod document;
pub mod ocr;
pub mod preview;
pub mod queue; pub mod queue;
#[cfg(feature = "server")] #[cfg(feature = "server")]
+431 -5
View File
@@ -45,6 +45,30 @@ enum Commands {
/// Fax class (1 or 2). Class 2 is simpler but requires Class 2 capable modem. /// Fax class (1 or 2). Class 2 is simpler but requires Class 2 capable modem.
#[arg(short = 'c', long, default_value = "2")] #[arg(short = 'c', long, default_value = "2")]
class: String, class: String,
/// Name of recipient (for cover page)
#[arg(long)]
to: Option<String>,
/// Sender name (for cover page)
#[arg(long)]
from: Option<String>,
/// Subject (for cover page)
#[arg(long)]
subject: Option<String>,
/// Notes (for cover page)
#[arg(long)]
note: Option<String>,
},
/// Receive a fax (Class 2 auto-answer)
Receive {
/// Serial device path
#[arg(short, long, default_value = "/dev/cu.usbmodem123456781")]
device: String,
/// Number of rings before auto-answer
#[arg(short, long, default_value = "2")]
rings: u8,
/// Output directory for received faxes
#[arg(short, long, default_value = "/tmp")]
output: String,
}, },
/// Interactively test the modem /// Interactively test the modem
Test { Test {
@@ -55,6 +79,91 @@ enum Commands {
#[arg(short = '2', long)] #[arg(short = '2', long)]
class2: bool, class2: bool,
}, },
/// Generate a cover page file (TIFF or HTML)
CoverCreate {
/// Recipient name
#[arg(long)]
to: Option<String>,
/// Sender name
#[arg(long)]
from: Option<String>,
/// Subject
#[arg(long)]
subject: Option<String>,
/// Notes
#[arg(long)]
note: Option<String>,
/// Total pages
#[arg(long, default_value_t = 1)]
pages: u32,
/// Output file path
#[arg(short, long)]
output: Option<String>,
/// Output format: tif, html, or both
#[arg(long, default_value = "html")]
format: String,
},
/// Update cover page settings for a queued fax job
JobCover {
/// Job ID
job_id: String,
/// Recipient name
#[arg(long)]
to: Option<String>,
/// Sender name
#[arg(long)]
from: Option<String>,
/// Subject
#[arg(long)]
subject: Option<String>,
/// Notes
#[arg(long)]
note: Option<String>,
/// Path to SQLite queue database (omit to use API)
#[arg(long)]
db: Option<String>,
},
/// Generate interactive HTML preview of a fax page
Preview {
/// Input file (PDF, TIFF, or image). Omit to preview only the cover page.
input: Option<String>,
/// Output HTML file (default: preview.html)
#[arg(short, long)]
output: Option<String>,
/// Resolution (standard/fine/superfine)
#[arg(short, long, default_value = "fine")]
resolution: String,
/// Cover page recipient name
#[arg(long)]
to: Option<String>,
/// Cover page sender name
#[arg(long)]
from: Option<String>,
/// Cover page subject
#[arg(long)]
subject: Option<String>,
/// Cover page notes
#[arg(long)]
note: Option<String>,
},
/// Generate cover page and verify content via OCR
VerifyCover {
/// Recipient name
#[arg(long)]
to: Option<String>,
/// Sender name
#[arg(long)]
from: Option<String>,
/// Subject
#[arg(long)]
subject: Option<String>,
/// Notes
#[arg(long)]
note: Option<String>,
/// Total pages
#[arg(long, default_value_t = 1)]
pages: u32,
},
/// Convert PDF to TIFF-F fax format (dry test) /// Convert PDF to TIFF-F fax format (dry test)
Convert { Convert {
/// Input PDF file path /// Input PDF file path
@@ -82,9 +191,18 @@ async fn main() -> Result<()> {
match cli.command.unwrap_or(Commands::Serve { config: None }) { match cli.command.unwrap_or(Commands::Serve { config: None }) {
Commands::Detect { device } => cmd_detect(&device), Commands::Detect { device } => cmd_detect(&device),
Commands::Serve { config } => cmd_serve(config).await, Commands::Serve { config } => cmd_serve(config).await,
Commands::Send { recipient, file, device, resolution, class } => Commands::Send { recipient, file, device, resolution, class, to, from, subject, note } =>
cmd_send(&device, &recipient, &file, &resolution, &class), cmd_send(&device, &recipient, &file, &resolution, &class, to, from, subject, note),
Commands::Receive { device, rings, output } => cmd_receive(&device, rings, &output),
Commands::Test { device, class2 } => cmd_test(&device, class2), Commands::Test { device, class2 } => cmd_test(&device, class2),
Commands::CoverCreate { to, from, subject, note, pages, output, format } =>
cmd_cover_create(to, from, subject, note, pages, output, &format),
Commands::JobCover { job_id, to, from, subject, note, db } =>
cmd_job_cover(&job_id, to, from, subject, note, db.as_deref()),
Commands::Preview { input, output, resolution, to, from, subject, note } =>
cmd_preview(input.as_deref(), output.as_deref(), &resolution, to, from, subject, note),
Commands::VerifyCover { to, from, subject, note, pages } =>
cmd_verify_cover(to, from, subject, note, pages),
Commands::Convert { input, output, resolution } => cmd_convert(&input, output.as_deref(), &resolution), Commands::Convert { input, output, resolution } => cmd_convert(&input, output.as_deref(), &resolution),
} }
} }
@@ -127,7 +245,8 @@ async fn cmd_serve(config_path: Option<String>) -> Result<()> {
telfax::api::start_server(config, queue.clone()).await telfax::api::start_server(config, queue.clone()).await
} }
fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class: &str) -> Result<()> { fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class: &str,
to: Option<String>, from: Option<String>, subject: Option<String>, note: Option<String>) -> Result<()> {
info!("Sending fax to {} from {} using device {} (Class {})", recipient, file, device, class); info!("Sending fax to {} from {} using device {} (Class {})", recipient, file, device, class);
let mut driver = ModemDriver::open(device, 115200)?; let mut driver = ModemDriver::open(device, 115200)?;
@@ -140,7 +259,7 @@ fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class:
}; };
let path = std::path::Path::new(file); let path = std::path::Path::new(file);
let doc = if file.to_lowercase().ends_with(".pdf") { let mut doc = if file.to_lowercase().ends_with(".pdf") {
info!("Converting PDF to fax format..."); info!("Converting PDF to fax format...");
telfax::document::pdf_to_fax_document(path, fax_resolution)? telfax::document::pdf_to_fax_document(path, fax_resolution)?
} else { } else {
@@ -149,8 +268,23 @@ fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class:
telfax::document::document_from_image(&data)? telfax::document::document_from_image(&data)?
}; };
// Optionally prepend cover page
if to.is_some() || from.is_some() || subject.is_some() || note.is_some() {
let total_pages = doc.pages.len() as u32 + 1;
let params = telfax::document::CoverParams {
to: to.unwrap_or_default(),
from: from.unwrap_or_default(),
subject: subject.unwrap_or_default(),
notes: note.unwrap_or_default(),
total_pages,
};
info!("Generating cover page...");
let cover_page = telfax::document::generate_cover_page(&params)?;
doc.pages.insert(0, cover_page);
}
let page = &doc.pages[0]; let page = &doc.pages[0];
info!("Document: {}x{} pixels, {} bytes", page.width_pels, page.rows, page.pixels.len()); info!("Document: {}x{} pixels, {} bytes, {} pages", page.width_pels, page.rows, page.pixels.len(), doc.pages.len());
// Choose fax class // Choose fax class
match class { match class {
@@ -176,6 +310,42 @@ fn cmd_send(device: &str, recipient: &str, file: &str, resolution: &str, class:
Ok(()) Ok(())
} }
fn cmd_receive(device: &str, rings: u8, output_dir: &str) -> Result<()> {
info!("Setting up Class 2 receive on {} ({} rings)", device, rings);
let mut driver = ModemDriver::open(device, 115200)?;
let mut receiver = telfax::fax::class2::Class2Receive::new(&mut driver)
.with_station_id("TELFAX");
receiver.setup_auto_answer(rings)?;
info!("Waiting for incoming fax call...");
receiver.wait_for_call(300000)?;
info!("Call received, receiving pages...");
let pages = receiver.receive_pages()?;
info!("Received {} pages", pages.len());
// Save received pages as TIFF files
for (i, page_data) in pages.iter().enumerate() {
let filename = format!("fax_{:?}_page{}.tif", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(), i + 1);
let output_path = std::path::Path::new(output_dir).join(filename);
std::fs::write(&output_path, page_data)?;
info!("Page {} saved to {} ({} bytes)", i + 1, output_path.display(), page_data.len());
}
receiver.end_session()?;
info!("Fax receive completed!");
Ok(())
}
fn cmd_test(device: &str, test_class2: bool) -> Result<()> { fn cmd_test(device: &str, test_class2: bool) -> Result<()> {
use std::io::{self, Write}; use std::io::{self, Write};
@@ -242,6 +412,225 @@ fn cmd_test(device: &str, test_class2: bool) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_preview(
input: Option<&str>,
output: Option<&str>,
resolution: &str,
to: Option<String>,
from: Option<String>,
subject: Option<String>,
note: Option<String>,
) -> Result<()> {
use telfax::document::cover::{generate_cover_page, CoverParams};
use telfax::document::tiff::TiffFaxWriter;
use telfax::preview;
let mut doc = if let Some(input_path) = input {
let fax_resolution = match resolution.to_lowercase().as_str() {
"standard" | "std" => telfax::config::FaxResolution::Standard,
"fine" => telfax::config::FaxResolution::Fine,
"superfine" | "super" => telfax::config::FaxResolution::SuperFine,
_ => telfax::config::FaxResolution::Fine,
};
let path = std::path::Path::new(input_path);
if input_path.to_lowercase().ends_with(".pdf") {
info!("Converting PDF to fax format...");
telfax::document::pdf_to_fax_document(path, fax_resolution)?
} else {
let data = std::fs::read(input_path)
.map_err(|e| telfax::error::FaxError::Other(format!("Failed to read file: {}", e)))?;
telfax::document::document_from_image(&data)?
}
} else {
telfax::document::FaxDocument {
pages: Vec::new(),
format: telfax::document::DocumentFormat::Image,
}
};
if to.is_some() || from.is_some() || subject.is_some() || note.is_some() {
let total_pages = doc.pages.len() as u32 + 1;
let params = CoverParams {
to: to.unwrap_or_default(),
from: from.unwrap_or_default(),
subject: subject.unwrap_or_default(),
notes: note.unwrap_or_default(),
total_pages,
};
let cover_page = generate_cover_page(&params)?;
doc.pages.insert(0, cover_page);
}
if doc.pages.is_empty() {
return Err(telfax::error::FaxError::Other(
"No content to preview. Provide an input file or cover page options.".to_string(),
));
}
let output_path = output
.map(|s| std::path::PathBuf::from(s))
.unwrap_or_else(|| std::path::PathBuf::from("preview.html"));
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tiff_path = std::env::temp_dir().join(format!("telfax_preview_{}.tif", stamp));
TiffFaxWriter::write_fax_tiff(&tiff_path, &doc.pages)?;
let first_page = &doc.pages[0];
let file_name = input.and_then(|p| std::path::Path::new(p).file_name())
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "cover".to_string());
let title = format!("{}{} pages", file_name, doc.pages.len());
preview::generate_preview_html(first_page, &title, &output_path)?;
std::fs::remove_file(&tiff_path).ok();
println!("Preview saved to: {}", output_path.display());
println!("Open in your browser to view with pan and zoom.");
Ok(())
}
fn cmd_verify_cover(
to: Option<String>,
from: Option<String>,
subject: Option<String>,
note: Option<String>,
total_pages: u32,
) -> Result<()> {
use telfax::document::cover::{generate_cover_page, CoverParams};
use telfax::document::tiff::TiffFaxWriter;
use telfax::ocr::{self, verify_cover};
let params = CoverParams {
to: to.unwrap_or_default(),
from: from.unwrap_or_default(),
subject: subject.unwrap_or_default(),
notes: note.unwrap_or_default(),
total_pages,
};
println!("=== Cover Page OCR Verification ===");
println!("Params: TO={}, FROM={}, SUBJECT={}, NOTES={}, PAGES={}",
params.to, params.from, params.subject, params.notes, params.total_pages);
println!();
let page = generate_cover_page(&params)?;
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let tiff_path = std::env::temp_dir().join(format!("telfax_ocr_verify_{}.tif", stamp));
TiffFaxWriter::write_fax_tiff(&tiff_path, &[page])?;
println!("Cover page: {} ({} bytes)", tiff_path.display(),
std::fs::metadata(&tiff_path).map(|m| m.len()).unwrap_or(0));
println!();
println!("Running tesseract OCR...");
let ocr_text = ocr::ocr_text(&tiff_path)?;
println!();
println!("=== OCR Output ===");
for line in ocr_text.lines() {
println!(" {}", line);
}
println!();
let result = verify_cover(&ocr_text, &params)?;
println!("=== Field Verification ===");
let all_field_names = ["FACSIMILE", "TO", "FROM", "DATE", "PAGES", "SUBJECT", "NOTES"];
for name in &all_field_names {
let missing = result.missing.iter().any(|m| m.starts_with(name) || m == name);
println!(" {} {}", if !missing { "" } else { "" }, name);
}
println!();
if result.all_fields_present {
println!("✅ All fields verified successfully!");
} else {
println!("❌ Missing fields: {}", result.missing.join(", "));
}
std::fs::remove_file(&tiff_path).ok();
println!();
println!("=== Verification Complete ===");
Ok(())
}
fn cmd_cover_create(
to: Option<String>,
from: Option<String>,
subject: Option<String>,
note: Option<String>,
total_pages: u32,
output: Option<String>,
format: &str,
) -> Result<()> {
use telfax::document::cover::{generate_cover_page, CoverParams};
use telfax::document::tiff::TiffFaxWriter;
use telfax::preview;
let params = CoverParams {
to: to.unwrap_or_default(),
from: from.unwrap_or_default(),
subject: subject.unwrap_or_default(),
notes: note.unwrap_or_default(),
total_pages,
};
println!("=== Generate Cover Page ===");
println!("TO: {}, FROM: {}, SUBJECT: {}, NOTES: {}, PAGES: {}",
params.to, params.from, params.subject, params.notes, params.total_pages);
let page = generate_cover_page(&params)?;
println!(" Page: {}x{} pixels", page.width_pels, page.rows);
let output_path = output
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from("cover"));
match format {
"tif" | "tiff" => {
let path = output_path.with_extension("tif");
TiffFaxWriter::write_fax_tiff(&path, &[page])?;
println!(" TIFF saved: {} ({} bytes)", path.display(),
std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0));
}
"html" => {
let path = output_path.with_extension("html");
let title = format!("Cover — {} pages", params.total_pages);
preview::generate_preview_html(&page, &title, &path)?;
println!(" HTML saved: {}", path.display());
}
"both" => {
let tiff_path = output_path.with_extension("tif");
TiffFaxWriter::write_fax_tiff(&tiff_path, &[page.clone()])?;
println!(" TIFF saved: {} ({} bytes)", tiff_path.display(),
std::fs::metadata(&tiff_path).map(|m| m.len()).unwrap_or(0));
let html_path = output_path.with_extension("html");
let title = format!("Cover — {} pages", params.total_pages);
preview::generate_preview_html(&page, &title, &html_path)?;
println!(" HTML saved: {}", html_path.display());
}
_ => {
return Err(telfax::error::FaxError::Other(format!(
"Unknown format: {}. Use tif, html, or both.", format
)));
}
}
println!();
println!("=== Done ===");
Ok(())
}
fn cmd_convert(input: &str, output: Option<&str>, resolution: &str) -> Result<()> { fn cmd_convert(input: &str, output: Option<&str>, resolution: &str) -> Result<()> {
let fax_resolution = match resolution.to_lowercase().as_str() { let fax_resolution = match resolution.to_lowercase().as_str() {
"standard" | "std" => FaxResolution::Standard, "standard" | "std" => FaxResolution::Standard,
@@ -313,3 +702,40 @@ fn cmd_convert(input: &str, output: Option<&str>, resolution: &str) -> Result<()
Ok(()) Ok(())
} }
fn cmd_job_cover(
job_id: &str,
to: Option<String>,
from: Option<String>,
subject: Option<String>,
note: Option<String>,
db_path: Option<&str>,
) -> Result<()> {
use std::str::FromStr;
let job_id = uuid::Uuid::from_str(job_id)
.map_err(|e| telfax::error::FaxError::Other(format!("Invalid job ID: {}", e)))?;
if let Some(db) = db_path {
let mut queue = telfax::queue::FaxQueue::new_sqlite(std::path::Path::new(db))?;
let found = queue.update_cover(&job_id, to.clone(), from.clone(), subject.clone(), note.clone())?;
if found {
println!("✅ Cover updated for job {}", job_id);
} else {
return Err(telfax::error::FaxError::Other(format!("Job {} not found", job_id)));
}
} else {
return Err(telfax::error::FaxError::Other(
"No database path specified. Use --db <path> to modify a queued job's cover page, \
or use the API endpoint PUT /api/fax/jobs/{id}/cover".to_string()
));
}
println!();
println!(" TO: {}", to.as_deref().unwrap_or("(unchanged)"));
println!(" FROM: {}", from.as_deref().unwrap_or("(unchanged)"));
println!(" SUBJECT: {}", subject.as_deref().unwrap_or("(unchanged)"));
println!(" NOTES: {}", note.as_deref().unwrap_or("(unchanged)"));
Ok(())
}
+18
View File
@@ -48,6 +48,24 @@ impl ModemDriver {
self.port.read_exact(buf).map_err(Into::into) self.port.read_exact(buf).map_err(Into::into)
} }
pub fn read_byte(&mut self, timeout_ms: u64) -> Result<u8> {
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
let mut single = [0u8; 1];
while std::time::Instant::now() < deadline {
match self.port.read(&mut single) {
Ok(0) => continue,
Ok(_) => return Ok(single[0]),
Err(ref e) if e.kind() == std::io::ErrorKind::TimedOut => {
continue;
}
Err(e) => return Err(FaxError::Io(e)),
}
}
Err(FaxError::Timeout { ms: timeout_ms })
}
pub fn read_until(&mut self, pattern: &[u8], timeout_ms: u64) -> Result<Vec<u8>> { pub fn read_until(&mut self, pattern: &[u8], timeout_ms: u64) -> Result<Vec<u8>> {
let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms); let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms);
let mut buffer = Vec::new(); let mut buffer = Vec::new();
+165
View File
@@ -0,0 +1,165 @@
use crate::document::CoverParams;
use crate::error::{FaxError, Result};
use std::path::Path;
use std::process::Command;
pub struct OcrResult {
pub all_fields_present: bool,
pub missing: Vec<String>,
pub raw_text: String,
}
pub fn ocr_text(tiff_path: &Path) -> Result<String> {
let output = Command::new("tesseract")
.arg(tiff_path.to_str().unwrap())
.arg("stdout")
.arg("-l")
.arg("eng")
.arg("--psm")
.arg("6")
.output()
.map_err(|e| FaxError::Other(format!("Failed to run tesseract: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(FaxError::Other(format!("tesseract error: {}", stderr)));
}
let text = String::from_utf8_lossy(&output.stdout).to_string();
Ok(text)
}
pub fn verify_cover(text: &str, params: &CoverParams) -> Result<OcrResult> {
let mut missing: Vec<String> = Vec::new();
let lines: Vec<&str> = text.lines().collect();
if !contains_any(text, &["FACSIMILE"]) {
missing.push("FACSIMILE header".to_string());
}
if !params.to.is_empty() {
let found = if is_ascii_text(&params.to) {
let needle = format!("TO:{}", params.to);
let needle2 = format!("TO: {}", params.to);
lines.iter().any(|l| {
let trimmed = l.trim();
trimmed == needle.as_str() || trimmed == needle2.as_str() || trimmed.contains(&params.to)
})
} else {
contains_any(text, &["TO:"])
};
if !found {
missing.push(format!("TO: {}", params.to));
}
} else {
if !contains_any(text, &["TO:"]) {
missing.push("TO:".to_string());
}
}
if !params.from.is_empty() {
let found = if is_ascii_text(&params.from) {
let needle = format!("FROM:{}", params.from);
let needle2 = format!("FROM: {}", params.from);
lines.iter().any(|l| {
let trimmed = l.trim();
trimmed == needle.as_str() || trimmed == needle2.as_str() || trimmed.contains(&params.from)
})
} else {
contains_any(text, &["FROM:"])
};
if !found {
missing.push(format!("FROM: {}", params.from));
}
} else {
if !contains_any(text, &["FROM:"]) {
missing.push("FROM:".to_string());
}
}
if !contains_pattern_like_date(text) {
if !contains_any(text, &["DATE:"]) {
missing.push("DATE".to_string());
}
}
let pages_str = params.total_pages.to_string();
let found_pages = lines.iter().any(|l| l.contains("PAGES:") && l.contains(&pages_str));
if !found_pages {
if !contains_any(text, &["PAGES:"]) {
missing.push(format!("PAGES: {}", params.total_pages));
}
}
if !params.subject.is_empty() {
let found = if is_ascii_text(&params.subject) {
lines.iter().any(|l| {
let trimmed = l.trim();
(trimmed.starts_with("SUBJECT") || trimmed.starts_with("SUBJECT:"))
&& trimmed.contains(&params.subject)
})
} else {
contains_any(text, &["SUBJECT"])
};
if !found {
if !contains_any(text, &["SUBJECT"]) {
missing.push(format!("SUBJECT: {}", params.subject));
}
}
}
if !params.notes.is_empty() {
let note_line = params.notes.lines().next().unwrap_or("");
let found = if is_ascii_text(note_line) {
lines.iter().any(|l| {
let trimmed = l.trim();
trimmed.contains(note_line) || (trimmed.starts_with("NOTES") && trimmed.len() > 6)
})
} else {
contains_any(text, &["NOTES"])
};
if !found {
if !contains_any(text, &["NOTES"]) {
missing.push(format!("NOTES: {}", params.notes));
}
}
}
Ok(OcrResult {
all_fields_present: missing.is_empty(),
missing,
raw_text: text.to_string(),
})
}
fn contains_any(text: &str, patterns: &[&str]) -> bool {
patterns.iter().any(|p| text.contains(p))
}
fn contains_pattern_like_date(text: &str) -> bool {
let bytes = text.as_bytes();
let len = bytes.len();
if len < 10 {
return false;
}
for i in 0..=len.saturating_sub(10) {
if bytes[i].is_ascii_digit()
&& bytes[i + 1].is_ascii_digit()
&& bytes[i + 2].is_ascii_digit()
&& bytes[i + 3].is_ascii_digit()
&& bytes[i + 4] == b'/'
&& bytes[i + 5].is_ascii_digit()
&& bytes[i + 6].is_ascii_digit()
&& bytes[i + 7] == b'/'
&& bytes[i + 8].is_ascii_digit()
&& bytes[i + 9].is_ascii_digit()
{
return true;
}
}
false
}
fn is_ascii_text(s: &str) -> bool {
s.chars().all(|c| c.is_ascii())
}
+315
View File
@@ -0,0 +1,315 @@
use crate::document::convert::Page;
use crate::error::Result;
use base64::Engine;
pub fn generate_preview_html(page: &Page, title: &str, output_path: &std::path::Path) -> Result<()> {
let png_data = fax_page_to_png_bytes(page)?;
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
let data_uri = format!("data:image/png;base64,{}", b64);
let w = page.width_pels;
let h = page.rows;
let html = build_viewer_html(&data_uri, w, h, title);
std::fs::write(output_path, html)?;
Ok(())
}
fn fax_page_to_png_bytes(page: &Page) -> Result<Vec<u8>> {
let width = page.width_pels as usize;
let height = page.rows as usize;
let bytes_per_row = (width + 7) / 8;
let mut img_data = Vec::with_capacity(width * height);
for y in 0..height {
for x in 0..width {
let byte_idx = y * bytes_per_row + x / 8;
let bit = (page.pixels[byte_idx] >> (7 - (x % 8))) & 1;
img_data.push(if bit == 1 { 0u8 } else { 255u8 });
}
}
let mut png_bytes = Vec::new();
{
use image::ImageEncoder;
let encoder = image::codecs::png::PngEncoder::new(&mut png_bytes);
encoder
.write_image(&img_data, page.width_pels, page.rows, image::ExtendedColorType::L8)
.map_err(|e| crate::error::FaxError::Other(format!("PNG encode error: {}", e)))?;
}
Ok(png_bytes)
}
fn build_viewer_html(data_uri: &str, width: u32, height: u32, title: &str) -> String {
let html = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Fax Preview — {title}</title>
<style>
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
background: #2a2a2a; color: #ccc; font-family: system-ui, sans-serif;
overflow: hidden; height: 100vh; display: flex; flex-direction: column;
}}
#toolbar {{
display: flex; align-items: center; gap: 12px; padding: 8px 16px;
background: #3a3a3a; border-bottom: 1px solid #555; user-select: none;
flex-shrink: 0;
}}
#toolbar .title {{ font-weight: 600; color: #fff; margin-right: auto; }}
#toolbar .info {{ font-size: 13px; color: #aaa; }}
#toolbar button {{
background: #555; border: none; color: #fff; padding: 4px 12px;
border-radius: 4px; cursor: pointer; font-size: 13px;
}}
#toolbar button:hover {{ background: #666; }}
#toolbar input[type=range] {{ width: 120px; }}
#canvas-wrap {{
flex: 1; overflow: hidden; position: relative; cursor: grab;
background: #1a1a1a;
background-image: radial-gradient(circle, #333 1px, transparent 1px);
background-size: 24px 24px;
}}
#canvas-wrap:active {{ cursor: grabbing; }}
#canvas-wrap canvas {{ display: block; }}
</style>
</head>
<body>
<div id="toolbar">
<span class="title">{title}</span>
<span class="info" id="info">{width}&times;{height} px</span>
<button id="fit-btn">Fit</button>
<button id="reset-btn">Reset</button>
<label>Zoom: <span id="zoom-label">100</span>%</label>
<input type="range" id="zoom-slider" min="10" max="400" value="100" step="5">
</div>
<div id="canvas-wrap">
<canvas id="canvas"></canvas>
</div>
<script>
const img = new Image();
const dataUri = {data_uri_str};
img.onload = init;
img.src = dataUri;
const PAGE_W = {width}, PAGE_H = {height};
const BORDER = 16;
const MARK_LEN = 20;
let scale = 1, offsetX = 0, offsetY = 0;
let dragStartX = 0, dragStartY = 0, dragOffX = 0, dragOffY = 0, dragging = false;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const wrap = document.getElementById('canvas-wrap');
const zoomSlider = document.getElementById('zoom-slider');
const zoomLabel = document.getElementById('zoom-label');
function resize() {{
canvas.width = wrap.clientWidth;
canvas.height = wrap.clientHeight;
draw();
}}
function draw() {{
const cw = canvas.width, ch = canvas.height;
ctx.clearRect(0, 0, cw, ch);
const pw = PAGE_W * scale;
const ph = PAGE_H * scale;
const ox = offsetX + (cw - pw) / 2;
const oy = offsetY + (ch - ph) / 2;
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, cw, ch);
ctx.save();
ctx.translate(ox, oy);
// shadow
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = 12;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 4;
// page background
ctx.fillStyle = '#fff';
ctx.fillRect(-BORDER, -BORDER, pw + BORDER*2, ph + BORDER*2);
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
// page image
if (img.complete) {{
ctx.drawImage(img, 0, 0, pw, ph);
}}
// boundary: thick border
ctx.strokeStyle = '#e53935';
ctx.lineWidth = 3;
ctx.strokeRect(0, 0, pw, ph);
// corner crop marks (outer)
const cm = MARK_LEN * scale;
ctx.strokeStyle = '#e53935';
ctx.lineWidth = 2;
ctx.beginPath();
// top-left
ctx.moveTo(-BORDER*0.5, -cm); ctx.lineTo(-BORDER*0.5, -BORDER*0.5); ctx.lineTo(-cm, -BORDER*0.5);
// top-right
ctx.moveTo(pw + BORDER*0.5, -cm); ctx.lineTo(pw + BORDER*0.5, -BORDER*0.5); ctx.lineTo(pw + cm, -BORDER*0.5);
// bottom-left
ctx.moveTo(-BORDER*0.5, ph + cm); ctx.lineTo(-BORDER*0.5, ph + BORDER*0.5); ctx.lineTo(-cm, ph + BORDER*0.5);
// bottom-right
ctx.moveTo(pw + BORDER*0.5, ph + cm); ctx.lineTo(pw + BORDER*0.5, ph + BORDER*0.5); ctx.lineTo(pw + cm, ph + BORDER*0.5);
ctx.stroke();
// alignment marks at midpoints
ctx.strokeStyle = 'rgba(229, 57, 53, 0.4)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.beginPath();
// top
ctx.moveTo(pw/2, -MARK_LEN*0.5*scale); ctx.lineTo(pw/2, -BORDER*0.5);
// bottom
ctx.moveTo(pw/2, ph + MARK_LEN*0.5*scale); ctx.lineTo(pw/2, ph + BORDER*0.5);
// left
ctx.moveTo(-MARK_LEN*0.5*scale, ph/2); ctx.lineTo(-BORDER*0.5, ph/2);
// right
ctx.moveTo(pw + MARK_LEN*0.5*scale, ph/2); ctx.lineTo(pw + BORDER*0.5, ph/2);
ctx.stroke();
ctx.setLineDash([]);
// DPI scale bar (bottom-right)
const dpi = 204;
const inchPx = dpi * scale;
const barInches = 2;
const barLen = inchPx * barInches;
const barX = pw - 40*scale - barLen;
const barY = ph - 30*scale;
ctx.strokeStyle = '#666';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(barX, barY); ctx.lineTo(barX + barLen, barY);
ctx.moveTo(barX, barY - 4*scale); ctx.lineTo(barX, barY + 4*scale);
ctx.moveTo(barX + barLen, barY - 4*scale); ctx.lineTo(barX + barLen, barY + 4*scale);
ctx.stroke();
ctx.fillStyle = '#666';
ctx.font = `${{10*scale}}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(`${{barInches}} in (@ ${{dpi}} DPI)`, barX + barLen/2, barY + 4*scale);
ctx.restore();
// overlay info
const zoomPct = Math.round(scale * 100);
document.getElementById('zoom-label').textContent = zoomPct;
}}
// mouse pan
canvas.addEventListener('mousedown', (e) => {{
dragging = true;
dragStartX = e.clientX;
dragStartY = e.clientY;
dragOffX = offsetX;
dragOffY = offsetY;
}});
window.addEventListener('mousemove', (e) => {{
if (!dragging) return;
offsetX = dragOffX + (e.clientX - dragStartX);
offsetY = dragOffY + (e.clientY - dragStartY);
draw();
}});
window.addEventListener('mouseup', () => {{ dragging = false; }});
// touch pan
canvas.addEventListener('touchstart', (e) => {{
if (e.touches.length === 1) {{
dragging = true;
dragStartX = e.touches[0].clientX;
dragStartY = e.touches[0].clientY;
dragOffX = offsetX;
dragOffY = offsetY;
}}
}});
canvas.addEventListener('touchmove', (e) => {{
if (!dragging || e.touches.length !== 1) return;
e.preventDefault();
offsetX = dragOffX + (e.touches[0].clientX - dragStartX);
offsetY = dragOffY + (e.touches[0].clientY - dragStartY);
draw();
}});
canvas.addEventListener('touchend', () => {{ dragging = false; }});
// zoom with wheel
canvas.addEventListener('wheel', (e) => {{
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const cw = canvas.width, ch = canvas.height;
const ox = offsetX + (cw - PAGE_W * scale) / 2;
const oy = offsetY + (ch - PAGE_H * scale) / 2;
const px = (mx - ox) / scale;
const py = (my - oy) / scale;
const factor = e.deltaY < 0 ? 1.1 : 0.9;
const newScale = Math.min(4, Math.max(0.1, scale * factor));
scale = newScale;
offsetX = mx - px * scale - (cw - PAGE_W * scale) / 2;
offsetY = my - py * scale - (ch - PAGE_H * scale) / 2;
zoomSlider.value = Math.round(scale * 100);
draw();
}});
// zoom slider
zoomSlider.addEventListener('input', () => {{
const newScale = parseInt(zoomSlider.value) / 100;
const cw = canvas.width, ch = canvas.height;
const cx = cw / 2, cy = ch / 2;
const ox = offsetX + (cw - PAGE_W * scale) / 2;
const oy = offsetY + (ch - PAGE_H * scale) / 2;
const px = (cx - ox) / scale;
const py = (cy - oy) / scale;
scale = newScale;
offsetX = cx - px * scale - (cw - PAGE_W * scale) / 2;
offsetY = cy - py * scale - (ch - PAGE_H * scale) / 2;
draw();
}});
document.getElementById('fit-btn').addEventListener('click', () => {{
const cw = canvas.width, ch = canvas.height;
const s = Math.min((cw - 80) / PAGE_W, (ch - 80) / PAGE_H);
scale = s;
offsetX = 0; offsetY = 0;
zoomSlider.value = Math.round(scale * 100);
draw();
}});
document.getElementById('reset-btn').addEventListener('click', () => {{
scale = 1; offsetX = 0; offsetY = 0;
zoomSlider.value = 100;
draw();
}});
function init() {{
resize();
fitBtn = document.getElementById('fit-btn');
fitBtn.click();
}}
window.addEventListener('resize', resize);
</script>
</body>
</html>"#,
title = title,
width = width,
height = height,
data_uri_str = serde_json::to_string(data_uri).unwrap(),
);
html
}
+8
View File
@@ -24,6 +24,10 @@ pub struct FaxJob {
pub retries: u8, pub retries: u8,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
pub cover_to: Option<String>,
pub cover_from: Option<String>,
pub cover_subject: Option<String>,
pub cover_notes: Option<String>,
} }
impl FaxJob { impl FaxJob {
@@ -38,6 +42,10 @@ impl FaxJob {
retries: 0, retries: 0,
created_at: now, created_at: now,
updated_at: now, updated_at: now,
cover_to: None,
cover_from: None,
cover_subject: None,
cover_notes: None,
} }
} }
} }
+42 -3
View File
@@ -40,7 +40,11 @@ impl FaxQueue {
pages INTEGER DEFAULT 0, pages INTEGER DEFAULT 0,
retries INTEGER DEFAULT 0, retries INTEGER DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL,
cover_to TEXT,
cover_from TEXT,
cover_subject TEXT,
cover_notes TEXT
)", )",
[], [],
)?; )?;
@@ -56,8 +60,8 @@ impl FaxQueue {
#[cfg(feature = "server")] #[cfg(feature = "server")]
FaxQueue::Sqlite(s) => { FaxQueue::Sqlite(s) => {
s.conn.execute( s.conn.execute(
"INSERT INTO fax_jobs (id, recipient, document_path, status, pages, retries, created_at, updated_at) "INSERT INTO fax_jobs (id, recipient, document_path, status, pages, retries, created_at, updated_at, cover_to, cover_from, cover_subject, cover_notes)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
rusqlite::params![ rusqlite::params![
job.id.to_string(), job.id.to_string(),
job.recipient, job.recipient,
@@ -67,6 +71,10 @@ impl FaxQueue {
job.retries, job.retries,
job.created_at.to_rfc3339(), job.created_at.to_rfc3339(),
job.updated_at.to_rfc3339(), job.updated_at.to_rfc3339(),
job.cover_to,
job.cover_from,
job.cover_subject,
job.cover_notes,
], ],
)?; )?;
Ok(()) Ok(())
@@ -106,6 +114,37 @@ impl FaxQueue {
} }
} }
pub fn update_cover(&mut self, id: &JobId, to: Option<String>, from: Option<String>, subject: Option<String>, notes: Option<String>) -> Result<bool> {
let now = chrono::Utc::now();
match self {
FaxQueue::InMemory(s) => {
if let Some(job) = s.jobs.get_mut(id) {
if to.is_some() { job.cover_to = to; }
if from.is_some() { job.cover_from = from; }
if subject.is_some() { job.cover_subject = subject; }
if notes.is_some() { job.cover_notes = notes; }
job.updated_at = now;
Ok(true)
} else {
Ok(false)
}
}
#[cfg(feature = "server")]
FaxQueue::Sqlite(s) => {
let affected = s.conn.execute(
"UPDATE fax_jobs SET cover_to = COALESCE(?1, cover_to),
cover_from = COALESCE(?2, cover_from),
cover_subject = COALESCE(?3, cover_subject),
cover_notes = COALESCE(?4, cover_notes),
updated_at = ?5
WHERE id = ?6",
rusqlite::params![to, from, subject, notes, now.to_rfc3339(), id.to_string()],
)?;
Ok(affected > 0)
}
}
}
pub fn pending_jobs(&self) -> Vec<FaxJob> { pub fn pending_jobs(&self) -> Vec<FaxJob> {
match self { match self {
FaxQueue::InMemory(s) => s FaxQueue::InMemory(s) => s
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Class 1 Fax Receiver - for testing with USR sender"""
import termios
import os
import time
import sys
def main():
if len(sys.argv) < 2:
print("Usage: class1_receive.py <device>")
sys.exit(1)
device = sys.argv[1]
fd = os.open(device, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
attrs = termios.tcgetattr(fd)
attrs[4] = termios.B115200
attrs[5] = termios.B115200
attrs[2] |= termios.CLOCAL | termios.CREAD
attrs[3] &= ~termios.ICANON
attrs[3] &= ~termios.ECHO
attrs[3] &= ~termios.ECHOE
attrs[3] &= ~termios.ISIG
termios.tcsetattr(fd, termios.TCSANOW, attrs)
termios.tcflush(fd, termios.TCIOFLUSH)
def drain():
try:
while True:
os.read(fd, 4096)
except BlockingIOError:
pass
def send_cmd(cmd, wait=0.5):
drain()
os.write(fd, cmd.encode() + b"\r")
time.sleep(wait)
result = b""
try:
while True:
data = os.read(fd, 4096)
result += data
except BlockingIOError:
pass
return result.decode(errors="replace")
def wait_for(pattern, timeout=30):
start = time.time()
result = b""
while time.time() - start < timeout:
try:
data = os.read(fd, 4096)
result += data
decoded = result.decode(errors="replace")
for p in pattern if isinstance(pattern, list) else [pattern]:
if p in decoded:
return decoded
except BlockingIOError:
time.sleep(0.1)
return result.decode(errors="replace")
print("[RECV] Initializing Class 1...")
send_cmd("AT", 0.3)
send_cmd("AT+FCLASS=1", 0.3)
print("[RECV] Waiting for rings...")
ring_count = 0
while ring_count < 2:
try:
data = os.read(fd, 4096)
text = data.decode(errors="replace")
if "RING" in text:
ring_count += text.count("RING")
print(f"[RECV] Ring {ring_count}")
except BlockingIOError:
time.sleep(0.3)
print("[RECV] Answering...")
answer_result = send_cmd("ATA", 3)
print(f"[RECV] ATA result: {answer_result.strip()}")
if "CONNECT" not in answer_result:
drain()
os.write(fd, b"ATA\r")
wait_result = wait_for("CONNECT", 10)
print(f"[RECV] Wait result: {wait_result.strip()}")
print("[RECV] Connected! Sending CED tone...")
time.sleep(0.5)
# Send CED tone (2100Hz for 2.6-4 seconds)
ced_result = send_cmd("AT+FTS=8", 4) # FTS=8 sends 2100Hz
print(f"[RECV] CED result: {ced_result.strip()}")
# Wait 75ms then send DIS
time.sleep(0.1)
print("[RECV] Sending DIS frame...")
# Start HDLC transmit
os.write(fd, b"AT+FTH=3\r")
fth_result = wait_for("CONNECT", 5)
print(f"[RECV] FTH=3: {fth_result.strip()}")
if "CONNECT" in fth_result:
# DIS frame: preamble+flags+DCS+data+RTC
# Address: 0xFF (broadcast), Control: 0x80 (DIS from called)
# DIS data: minimum capabilities
dis_data = bytes([
0xFF, 0x13, 0x80, # Address + Control (DIS from called)
0x00, 0x50, 0x1F, # DIS parameters
])
# Add FCS (we'll let modem calculate)
os.write(fd, dis_data)
os.write(fd, b"\x00\x00\x00") # Padding
time.sleep(0.5)
# Wait for OK
dis_ok = wait_for("OK", 5)
print(f"[RECV] DIS sent: {dis_ok.strip()}")
print("[RECV] Waiting for DCS...")
drain()
os.write(fd, b"AT+FRH=3\r")
dcs_result = wait_for("OK", 10)
print(f"[RECV] DCS received: {len(dcs_result)} bytes")
print("[RECV] Waiting for TCF...")
drain()
os.write(fd, b"AT+FRM=96\r") # Receive at 14400 bps
tcf_result = wait_for("CONNECT", 5)
print(f"[RECV] TCF: {tcf_result.strip()}")
# Send CFR
print("[RECV] Sending CFR...")
drain()
os.write(fd, b"AT+FTH=3\r")
wait_for("CONNECT", 5)
cfr_data = bytes([0xFF, 0x13, 0x21]) # CFR frame
os.write(fd, cfr_data)
os.write(fd, b"\x00\x00\x00")
time.sleep(0.5)
wait_for("OK", 5)
print("[RECV] CFR sent")
print("[RECV] Waiting for page data...")
# Receive page
drain()
os.write(fd, b"AT+FRM=96\r")
page_result = wait_for("OK", 60)
print(f"[RECV] Page received!")
# Send MCF
print("[RECV] Sending MCF...")
drain()
os.write(fd, b"AT+FTH=3\r")
wait_for("CONNECT", 5)
mcf_data = bytes([0xFF, 0x13, 0x31]) # MCF frame
os.write(fd, mcf_data)
os.write(fd, b"\x00\x00\x00")
time.sleep(0.5)
wait_for("OK", 5)
print("[RECV] MCF sent")
# Wait for DCN or hangup
print("[RECV] Waiting for DCN...")
drain()
os.write(fd, b"AT+FRH=3\r")
dcn_result = wait_for("OK", 10)
print(f"[RECV] Session ended: {dcn_result[:50]}")
os.close(fd)
print("[RECV] Done!")
if __name__ == "__main__":
main()