use anyhow::{Context, Result};
use printwell::{
FontOptions, Length, Margins, Orientation, PageSize, PdfMetadata, PdfOptions, RenderOptions,
};
use std::io::{Read, Write};
use std::path::PathBuf;
use crate::cli::args::ConvertArgs;
use crate::cli::utils::{load_fonts_from_dir, parse_length_mm, parse_margins, parse_page_size};
fn load_html_content(args: &ConvertArgs, is_url: bool, is_stdin: bool) -> Result<Option<String>> {
if is_url {
Ok(None)
} else if is_stdin {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("Failed to read from stdin")?;
Ok(Some(buf))
} else {
let content = std::fs::read_to_string(&args.input)
.with_context(|| format!("Failed to read input file: {}", args.input))?;
Ok(Some(content))
}
}
fn build_options(args: &ConvertArgs) -> Result<(PdfOptions, RenderOptions)> {
let custom_fonts = load_fonts_from_dir();
let page_size = if let (Some(w), Some(h)) = (&args.width, &args.height) {
PageSize::Custom {
width_mm: parse_length_mm(w).with_context(|| format!("Invalid width: {w}"))?,
height_mm: parse_length_mm(h).with_context(|| format!("Invalid height: {h}"))?,
}
} else {
let (w, h) = parse_page_size(&args.page_size)
.with_context(|| format!("Invalid page size: {}", args.page_size))?;
PageSize::Custom {
width_mm: w,
height_mm: h,
}
};
let (top, right, bottom, left) =
parse_margins(&args.margin).with_context(|| format!("Invalid margin: {}", args.margin))?;
let margins = Margins {
top: Length::Mm(top),
right: Length::Mm(right),
bottom: Length::Mm(bottom),
left: Length::Mm(left),
};
let metadata = PdfMetadata::builder()
.title(args.title.clone())
.author(args.author.clone())
.subject(args.subject.clone())
.keywords(args.keywords.clone())
.creator(args.creator.clone())
.producer(args.producer.clone())
.build();
let pdf_options = PdfOptions::builder()
.page_size(page_size)
.margins(margins)
.orientation(if args.layout.landscape {
Orientation::Landscape
} else {
Orientation::Portrait
})
.print_background(!args.layout.no_background)
.scale(args.scale)
.page_ranges(args.page_ranges.clone())
.header_template(args.header.clone())
.footer_template(args.footer.clone())
.metadata(metadata)
.build();
let font_options = FontOptions::builder().custom_fonts(custom_fonts).build();
let render_options = RenderOptions::builder().fonts(font_options).build();
Ok((pdf_options, render_options))
}
pub async fn convert(args: ConvertArgs) -> Result<()> {
use printwell::Converter;
let is_url = args.input.starts_with("http://") || args.input.starts_with("https://");
let is_stdin = args.input == "-";
let html = load_html_content(&args, is_url, is_stdin)?;
let (pdf_options, render_options) = build_options(&args)?;
let converter = Converter::new().context("Failed to create PDF converter")?;
let output = if is_url {
eprintln!("Rendering {}...", args.input);
converter
.url_to_pdf(&args.input, &render_options, &pdf_options)
.await
.with_context(|| format!("Failed to render URL: {}", args.input))?
} else if args.behavior.detect_forms || args.behavior.convert_forms {
render_with_forms(
&converter,
&args,
html.as_ref(),
&render_options,
&pdf_options,
)
.await?
} else if let Some(ref boundaries_str) = args.boundaries {
render_with_boundaries(
&converter,
&args,
html.as_ref(),
boundaries_str,
&render_options,
&pdf_options,
)
.await?
} else {
let html_content = html
.as_ref()
.ok_or_else(|| anyhow::anyhow!("HTML content required"))?;
converter
.html_to_pdf(html_content, &render_options, &pdf_options)
.await
.context("Failed to convert HTML to PDF")?
};
write_output(&output, &args, is_url, is_stdin)?;
Ok(())
}
async fn render_with_forms(
converter: &printwell::Converter,
args: &ConvertArgs,
html: Option<&String>,
render_options: &printwell::RenderOptions,
pdf_options: &printwell::PdfOptions,
) -> Result<printwell::PdfOutput> {
let html_content = html.ok_or_else(|| {
anyhow::anyhow!("HTML content required for form detection (URL input not supported)")
})?;
let selectors: Vec<&str> = args
.boundaries
.as_ref()
.map(|s| s.split(',').map(str::trim).collect::<Vec<_>>())
.unwrap_or_default();
let result = converter
.html_to_pdf_with_forms(html_content, render_options, pdf_options, &selectors)
.await
.context("Failed to convert HTML to PDF with form detection")?;
if let Some(ref boundaries_path) = args.boundaries_output {
let boundaries_json = serde_json::to_string_pretty(
&result
.boundaries
.iter()
.map(|b| {
serde_json::json!({
"selector": b.selector,
"index": b.index,
"page": b.page,
"x": b.x,
"y": b.y,
"width": b.width,
"height": b.height,
})
})
.collect::<Vec<_>>(),
)
.context("Failed to serialize boundaries")?;
std::fs::write(boundaries_path, boundaries_json)
.with_context(|| format!("Failed to write boundaries to: {boundaries_path}"))?;
}
if let Some(ref forms_path) = args.forms_output {
let forms_json = serde_json::to_string_pretty(
&result
.form_elements
.iter()
.map(|f| {
serde_json::json!({
"type": format!("{:?}", f.element_type),
"id": f.id,
"name": f.name,
"page": f.page,
"x": f.x,
"y": f.y,
"width": f.width,
"height": f.height,
"required": f.state.constraints.required,
"readonly": f.state.constraints.readonly,
"default_value": f.default_value,
"placeholder": f.placeholder,
"multiline": f.state.input.multiline,
"password": f.state.input.password,
"checked": f.state.interaction.checked,
"export_value": f.export_value,
"radio_group": f.radio_group,
"options": f.options,
"selected_index": f.selected_index,
})
})
.collect::<Vec<_>>(),
)
.context("Failed to serialize form elements")?;
std::fs::write(forms_path, &forms_json)
.with_context(|| format!("Failed to write forms to: {forms_path}"))?;
eprintln!(
"Detected {} form elements, written to: {}",
result.form_elements.len(),
forms_path
);
}
#[cfg(feature = "forms")]
if let Some(ref rules_path) = args.validate_forms {
validate_forms(&result, rules_path, args.validation_output.as_deref())?;
}
#[cfg(feature = "forms")]
if args.behavior.convert_forms && !result.form_elements.is_empty() {
return convert_forms_to_pdf(&result);
}
Ok(result.pdf)
}
#[cfg(feature = "forms")]
fn parse_validation_rule(r: &serde_json::Value) -> Option<printwell::forms::ValidationRule> {
use printwell::forms::ValidationRule;
let field_name = r.get("field_name")?.as_str()?.to_string();
Some(ValidationRule {
field_name,
required: r
.get("required")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
pattern: r.get("pattern").and_then(|v| v.as_str()).map(String::from),
min_length: r
.get("min_length")
.and_then(serde_json::Value::as_u64)
.and_then(|v| usize::try_from(v).ok()),
max_length: r
.get("max_length")
.and_then(serde_json::Value::as_u64)
.and_then(|v| usize::try_from(v).ok()),
min_value: r.get("min_value").and_then(serde_json::Value::as_f64),
max_value: r.get("max_value").and_then(serde_json::Value::as_f64),
required_message: r
.get("required_message")
.and_then(|v| v.as_str())
.map(String::from),
pattern_message: r
.get("pattern_message")
.and_then(|v| v.as_str())
.map(String::from),
length_message: r
.get("length_message")
.and_then(|v| v.as_str())
.map(String::from),
value_message: r
.get("value_message")
.and_then(|v| v.as_str())
.map(String::from),
allowed_values: r
.get("allowed_values")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
}),
..Default::default()
})
}
#[cfg(feature = "forms")]
fn convert_to_detected_element(
f: &printwell::FormElement,
) -> printwell::forms::DetectedFormElement {
use printwell::forms::{
DetectedFormElement, FieldConstraintState, FieldInputState, FieldInteractionState,
FormFieldState,
};
DetectedFormElement {
element_type: format!("{:?}", f.element_type).to_lowercase(),
id: f.id.clone(),
name: f.name.clone(),
page: f.page,
x: f.x,
y: f.y,
width: f.width,
height: f.height,
state: FormFieldState {
interaction: FieldInteractionState {
disabled: false,
checked: f.state.interaction.checked,
},
constraints: FieldConstraintState {
required: f.state.constraints.required,
readonly: f.state.constraints.readonly,
},
input: FieldInputState {
multiline: f.state.input.multiline,
password: f.state.input.password,
},
},
default_value: f.default_value.clone(),
placeholder: f.placeholder.clone(),
max_length: f.max_length,
export_value: f.export_value.clone(),
radio_group: f.radio_group.clone(),
options: f.options.clone(),
selected_index: f.selected_index,
font_size: 12.0,
}
}
#[cfg(feature = "forms")]
fn output_validation_summary(
summary: &printwell::forms::ValidationSummary,
output_path: Option<&str>,
) -> Result<()> {
let validation_json = serde_json::json!({
"total_fields": summary.total_fields,
"valid_count": summary.valid_count,
"invalid_count": summary.invalid_count,
"all_valid": summary.all_valid,
"results": summary.results.iter().map(|r| serde_json::json!({
"field_name": r.field_name,
"is_valid": r.is_valid,
"errors": r.errors,
"value": r.value,
})).collect::<Vec<_>>(),
});
if let Some(out_path) = output_path {
std::fs::write(
out_path,
serde_json::to_string_pretty(&validation_json)
.context("Failed to serialize validation results")?,
)
.with_context(|| format!("Failed to write validation results to: {out_path}"))?;
eprintln!("Validation results written to: {out_path}");
}
if summary.all_valid {
eprintln!(
"Validation passed: {}/{} fields valid",
summary.valid_count, summary.total_fields
);
} else {
eprintln!(
"Validation failed: {}/{} fields invalid",
summary.invalid_count, summary.total_fields
);
for result in &summary.results {
if !result.is_valid {
for error in &result.errors {
eprintln!(" - {error}");
}
}
}
}
Ok(())
}
#[cfg(feature = "forms")]
fn validate_forms(
result: &printwell::PdfOutputWithForms,
rules_path: &str,
output_path: Option<&str>,
) -> Result<()> {
use printwell::forms::validate_form_fields;
let rules_json = std::fs::read_to_string(rules_path)
.with_context(|| format!("Failed to read validation rules: {rules_path}"))?;
let rules: Vec<serde_json::Value> =
serde_json::from_str(&rules_json).context("Failed to parse validation rules JSON")?;
let validation_rules: Vec<_> = rules.iter().filter_map(parse_validation_rule).collect();
let detected_elements: Vec<_> = result
.form_elements
.iter()
.map(convert_to_detected_element)
.collect();
let summary = validate_form_fields(&detected_elements, &validation_rules);
output_validation_summary(&summary, output_path)
}
#[cfg(feature = "forms")]
fn convert_forms_to_pdf(result: &printwell::PdfOutputWithForms) -> Result<printwell::PdfOutput> {
use printwell::PdfOutput;
use printwell::forms::{DetectedFormElement, apply_detected_forms};
eprintln!(
"Applying {} detected form elements to PDF...",
result.form_elements.len()
);
let detected: Vec<DetectedFormElement> = result
.form_elements
.iter()
.map(|e| DetectedFormElement {
element_type: match e.element_type {
printwell::FormElementType::TextField => "text".to_string(),
printwell::FormElementType::Checkbox => "checkbox".to_string(),
printwell::FormElementType::RadioButton => "radio".to_string(),
printwell::FormElementType::Dropdown => "select".to_string(),
printwell::FormElementType::Signature => "signature".to_string(),
},
id: e.id.clone(),
name: e.name.clone(),
page: e.page,
x: e.x,
y: e.y,
width: e.width,
height: e.height,
state: printwell::forms::FormFieldState {
interaction: printwell::forms::FieldInteractionState {
disabled: false,
checked: e.state.interaction.checked,
},
constraints: printwell::forms::FieldConstraintState {
required: e.state.constraints.required,
readonly: e.state.constraints.readonly,
},
input: printwell::forms::FieldInputState {
multiline: e.state.input.multiline,
password: e.state.input.password,
},
},
default_value: e.default_value.clone(),
placeholder: e.placeholder.clone(),
max_length: e.max_length,
export_value: e.export_value.clone(),
radio_group: e.radio_group.clone(),
options: e.options.clone(),
selected_index: e.selected_index,
font_size: e.font_size,
})
.collect();
let pdf_bytes = apply_detected_forms(result.pdf.as_bytes(), &detected)
.context("Failed to apply detected form elements")?;
Ok(PdfOutput::new(pdf_bytes))
}
async fn render_with_boundaries(
converter: &printwell::Converter,
args: &ConvertArgs,
html: Option<&String>,
boundaries_str: &str,
render_options: &printwell::RenderOptions,
pdf_options: &printwell::PdfOptions,
) -> Result<printwell::PdfOutput> {
let html_content = html.ok_or_else(|| {
anyhow::anyhow!("HTML content required for boundary extraction (URL input not supported)")
})?;
let selectors: Vec<&str> = boundaries_str.split(',').map(str::trim).collect();
let result = converter
.html_to_pdf_with_boundaries(html_content, render_options, pdf_options, &selectors)
.await
.context("Failed to convert HTML to PDF with boundary extraction")?;
if let Some(ref boundaries_path) = args.boundaries_output {
let boundaries_json = serde_json::to_string_pretty(
&result
.boundaries
.iter()
.map(|b| {
serde_json::json!({
"selector": b.selector,
"index": b.index,
"page": b.page,
"x": b.x,
"y": b.y,
"width": b.width,
"height": b.height,
})
})
.collect::<Vec<_>>(),
)
.context("Failed to serialize boundaries")?;
std::fs::write(boundaries_path, boundaries_json)
.with_context(|| format!("Failed to write boundaries to: {boundaries_path}"))?;
}
Ok(result.pdf)
}
fn write_output(
output: &printwell::PdfOutput,
args: &ConvertArgs,
is_url: bool,
is_stdin: bool,
) -> Result<()> {
if let Some(ref output_path) = args.output {
output
.write_to_file(output_path)
.with_context(|| format!("Failed to write output to: {output_path}"))?;
eprintln!("Written to: {output_path}");
} else if is_url {
let output_path = "output.pdf";
output
.write_to_file(output_path)
.with_context(|| format!("Failed to write output to: {output_path}"))?;
eprintln!("Written to: {output_path}");
} else if !is_stdin {
let output_path = PathBuf::from(&args.input).with_extension("pdf");
output
.write_to_file(&output_path)
.with_context(|| format!("Failed to write output to: {}", output_path.display()))?;
eprintln!("Written to: {}", output_path.display());
} else {
std::io::stdout()
.write_all(output.as_bytes())
.context("Failed to write PDF to stdout")?;
}
Ok(())
}