Rust File Conversion SDK#
enconvert is the official Rust crate for the EnConvert API. It is a blocking client built on reqwest's blocking API, so there is no async runtime, no tokio in your dependency tree, and no .await anywhere in your code. Twelve methods on Enconvert cover file conversion: URL to PDF, screenshots, Markdown extraction, image and document format pairs, anything-to-PDF, and whole-site batches. A second namespace, client.v2(), adds twenty-three methods for web intelligence: perceive, discover, lookup, distill, ingest, and watch. Every option struct derives Default, and every failure is one error enum.
enconvert (0.1.0) · Source: conversionapi/rust-sdk · Rust: 2021 edition · License: MIT
Install#
cargo add enconvert
Or add it by hand. distill and perceive schema extraction take a serde_json::Map<String, Value>, so add serde_json too if you plan to use them:
[dependencies]
enconvert = "0.1"
serde_json = "1"
The crate pulls in reqwest (blocking, json, multipart), serde, serde_json, thiserror, and uuid. There are no optional features to enable.
Quick start#
use enconvert::{Enconvert, PerceiveOptions, PerceiveOutputName, UrlToPdfOptions};
fn main() -> Result<(), enconvert::Error> {
let key = std::env::var("ENCONVERT_API_KEY").expect("ENCONVERT_API_KEY is not set");
let client = Enconvert::new(key)?;
// Convert a URL to PDF and write the result straight to disk.
let pdf = client.convert_url_to_pdf("https://example.com", UrlToPdfOptions {
save_to: Some("page.pdf".into()),
..Default::default()
})?;
println!("{}", pdf.presigned_url);
// Read the same page the way your agent should, with a quality score attached.
let op = client.v2().perceive("https://example.com", PerceiveOptions {
outputs: Some(vec![PerceiveOutputName::Markdown, PerceiveOutputName::Structured]),
..Default::default()
})?;
println!("{:?}", op.render_quality); // e.g. Some(0.93)
Ok(())
}
Every method blocks the calling thread, which makes the SDK easy to drop into a CLI, a build script, a worker thread, or a synchronous web handler. All public types are re-exported at the crate root, so the snippets below need nothing but use enconvert::{...} and the client from above.
reqwest's blocking client cannot be driven from a thread already owned by a Tokio (or similar) reactor. From async code, wrap each call in tokio::task::spawn_blocking.
What the client exposes#
| Surface | Reached as | What it covers |
|---|---|---|
| File conversion | client.<method> |
12 methods: URL to PDF, screenshot, and Markdown; image and document pairs; anything-to-Markdown and anything-to-PDF; whole-site batches; job and batch status |
| Web intelligence | client.v2().<method> |
23 methods across perceive, discover, lookup, distill, ingest, and watch |
| Format tables | valid_outputs_for, IMPLEMENTED_CONVERSIONS |
The 43 implemented {input}-to-{output} endpoints, checked client side before a request is sent |
| Errors | enconvert::Error |
One enum, nine variants, with is_authentication, is_quota, is_rate_limit, is_server_error, and status_code |
Nothing is a builder and nothing is async. The idiomatic call shape is a struct literal with ..Default::default().
File conversion#
convert_url_to_pdf#
Render any public URL to a PDF.
use enconvert::{PdfOptions, PdfOrientation, UrlToPdfOptions};
let result = client.convert_url_to_pdf("https://example.com", UrlToPdfOptions {
single_page: Some(false),
pdf_options: Some(PdfOptions {
page_size: Some("A4".to_string()),
orientation: Some(PdfOrientation::Landscape),
..Default::default()
}),
save_to: Some("report.pdf".into()),
..Default::default()
})?;
println!("{} ({:?} bytes)", result.filename, result.file_size);
| Field | Type | Default | Description |
|---|---|---|---|
save_to |
Option<PathBuf> |
- | Local path to write the PDF to. Parent directories are created automatically. |
single_page |
Option<bool> |
true |
true produces one continuous page. false paginates using pdf_options.page_size. |
pdf_options |
Option<PdfOptions> |
- | Page geometry, scale, grayscale, header and footer. See PDF options. |
render.viewport_width / render.viewport_height |
Option<u32> |
1920 / 1080 |
Browser viewport in pixels. |
render.load_media / render.enable_scroll |
Option<bool> |
true |
Wait for images and video, and scroll top to bottom to trigger lazy loaders. |
render.output_filename |
Option<String> |
auto | Override the generated filename. |
render.auth / render.cookies / render.headers |
see types | - | HTTP Basic Auth, up to 50 injected cookies, up to 20 extra request headers. |
The render block is UrlRenderOptions, shared by every URL and website conversion. Do not combine auth with an Authorization header: the API rejects the conflict.
convert_url_to_screenshot#
Capture a PNG of any URL. UrlToScreenshotOptions carries the same render block plus save_to, and nothing else.
use enconvert::{UrlRenderOptions, UrlToScreenshotOptions};
client.convert_url_to_screenshot("https://example.com", UrlToScreenshotOptions {
render: UrlRenderOptions { viewport_width: Some(1440), ..Default::default() },
save_to: Some("shot.png".into()),
})?;
convert_url_to_markdown#
Extract clean GitHub-Flavored Markdown from a URL. Navigation, footers, ads, and scripts are stripped, the main article body is kept, and YAML frontmatter (title, description, url, links, images) is prepended.
use enconvert::UrlToMarkdownOptions;
client.convert_url_to_markdown("https://example.com/article", UrlToMarkdownOptions {
save_to: Some("article.md".into()),
..Default::default()
})?;
Useful for RAG pipelines, CMS imports, and training-data collection. For a scored read with artifacts and structured extraction in one call, use perceive instead.
convert_image#
Convert between jpeg, png, svg, heic, and webp (all 20 ordered pairs), or rasterize a PDF to JPEG.
use enconvert::{ConvertImageOptions, NamedFile};
// From a path: the input format comes from the extension.
client.convert_image("photo.heic", ConvertImageOptions {
output_format: "webp".to_string(),
save_to: Some("photo.webp".into()),
..Default::default()
})?;
// From bytes: supply a filename so the extension can still be read.
let data = std::fs::read("photo.heic")?;
client.convert_image(
NamedFile { data, filename: "photo.heic".to_string(), content_type: None },
ConvertImageOptions { output_format: "png".to_string(), ..Default::default() },
)?;
output_format is the only required field; save_to and output_filename are optional. The first argument is anything that converts into FileInput: a &str or String path, a &Path or PathBuf, a Vec<u8> or &[u8] of raw bytes, or a NamedFile when you have bytes and want to declare the filename and content type yourself.
convert_document#
Convert documents and data formats. output_format defaults to "pdf", and yml, htm, md, and jpg are normalized to their canonical names. save_to, output_filename, and pdf_options are optional.
use enconvert::{ConvertDocumentOptions, PdfOptions};
// docx to pdf (the default output format)
client.convert_document("report.docx", ConvertDocumentOptions {
save_to: Some("report.pdf".into()),
..Default::default()
})?;
// json to yaml
client.convert_document("data.json", ConvertDocumentOptions {
output_format: Some("yaml".to_string()),
save_to: Some("data.yaml".into()),
..Default::default()
})?;
// markdown to pdf with custom page setup
client.convert_document("README.md", ConvertDocumentOptions {
pdf_options: Some(PdfOptions { page_size: Some("A4".to_string()), ..Default::default() }),
..Default::default()
})?;
Recognized input extensions: .doc, .docx, .xls, .xlsx, .ppt, .pptx, .html, .htm, .odt, .ods, .odp, .ots, .pages, .numbers, .md, .markdown, .csv, .json, .xml, .yaml, .yml, .toml.
| Input format | Valid outputs |
|---|---|
json |
csv, toml, xml, yaml |
xml |
csv, json |
csv |
json, xml |
yaml, toml |
json |
markdown |
html, pdf |
html |
pdf |
doc, excel, ppt, odt, ods, odp, ots, pages, numbers |
pdf |
jpeg, png, svg, heic, webp |
each other, all 20 pairs |
pdf |
jpeg |
EPUB has no dedicated document pair. Send .epub files through convert_to_pdf or convert_to_markdown instead.
convert_image and convert_document both resolve the input format from the file extension and check the pair against the client-side table, so an unimplemented pair returns Error::UnsupportedConversion with the valid outputs listed, before any HTTP request is made. Query the same table directly:
use enconvert::{valid_outputs_for, IMPLEMENTED_CONVERSIONS};
println!("{:?}", valid_outputs_for("json")); // ["csv", "toml", "xml", "yaml"]
println!("{}", IMPLEMENTED_CONVERSIONS.len()); // 43
convert_to_markdown#
Upload a document of almost any type and get clean Markdown back: PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, and legacy or ODF office files. The format is detected server side, so no client-side extension check runs. Images are not supported.
use enconvert::ConvertToMarkdownOptions;
client.convert_to_markdown("handbook.pdf", ConvertToMarkdownOptions {
save_to: Some("handbook.md".into()),
..Default::default()
})?;
The output is one heading-aware .md file, which makes it a good building block for RAG chunking: a semantic chunker can split on the document's own heading hierarchy instead of arbitrary character counts. There are no PDF options here, only save_to and output_filename.
convert_to_pdf#
Upload almost anything and get a PDF back: office, ODF, Pages, Numbers, RTF, CSV, HTML, Markdown, plain text, raster images, SVG, EPUB, or an existing PDF for passthrough.
use enconvert::{ConvertToPdfOptions, PdfOptions};
client.convert_to_pdf("slides.pptx", ConvertToPdfOptions {
save_to: Some("slides.pdf".into()),
..Default::default()
})?;
// PDF in, grayscale PDF out.
client.convert_to_pdf("scan.pdf", ConvertToPdfOptions {
pdf_options: Some(PdfOptions { grayscale: Some(true), ..Default::default() }),
save_to: Some("scan-gray.pdf".into()),
..Default::default()
})?;
pdf_options.grayscale is honored here. The anything-to-PDF endpoint auto-detects the input and applies its own geometry. For control over page size, orientation, margins, scale, headers, and footers, use convert_url_to_pdf or convert_document with an HTML or Markdown input.
convert_website_to_pdf and convert_website_to_screenshot#
Discover every page of a website, convert each one in the background, and collect a single ZIP. Both are asynchronous only: they return a BatchSubmission, never a finished file.
use enconvert::{CrawlMode, WaitForBatchOptions, WebsiteConversionOptions, WebsiteToPdfOptions};
let batch = client.convert_website_to_pdf("https://example.com", WebsiteToPdfOptions {
website: WebsiteConversionOptions {
crawl_mode: Some(CrawlMode::Sitemap),
exclude_patterns: Some(vec!["/blog/tag/".to_string()]),
notification_email: Some("[email protected]".to_string()),
..Default::default()
},
..Default::default()
})?;
// Block until the batch settles, then save the ZIP.
let status = client.wait_for_batch(&batch.batch_id, WaitForBatchOptions {
save_to: Some("site.zip".into()),
..Default::default()
})?;
println!("{} of {} pages converted", status.completed, status.total);
| Field | Type | Default | Description |
|---|---|---|---|
website.crawl_mode |
Option<CrawlMode> |
Auto |
Auto, Sitemap (sitemap.xml only), or Full (sitemap plus a breadth-first crawl). |
website.include_patterns / website.exclude_patterns |
Option<Vec<String>> |
- | Crawl only, or skip, URLs matching these patterns. Full crawl mode. |
website.notification_email / website.callback_url |
Option<String> |
project owner / - | Address emailed, and webhook posted, when the batch finishes. |
website.render |
UrlRenderOptions |
- | Same viewport, media, scroll, auth, cookie, and header fields as single-URL conversions. |
single_page / pdf_options |
see above | - | PDF batches only. |
convert_website_to_screenshot takes WebsiteToScreenshotOptions, a type alias for WebsiteConversionOptions, and produces a ZIP of PNGs. wait_for_batch polls get_batch_status every interval_ms (default 5000) until the batch leaves Processing, then optionally downloads the ZIP. If timeout_ms (default 1,800,000, that is 30 minutes) elapses first, it returns Error::Api { status: 504, .. }.
get_job_status and get_batch_status#
use enconvert::JobStatusValue;
let status = client.get_job_status("job_abc123")?;
match status.status {
JobStatusValue::Success => println!("{:?}", status.presigned_url),
JobStatusValue::Failed => eprintln!("{:?}", status.error),
other => println!("still running: {other:?}"),
}
let batch = client.get_batch_status("batch_abc123")?; // aggregate counts and per-URL items
println!("{}/{} done, {} failed", batch.completed, batch.total, batch.failed);
get_job_status directly. The SDK polls it for you when a synchronous conversion returns 5xx. See Timeout recovery.
Web intelligence (V2)#
Everything under client.v2() turns web pages into agent-ready data. The one thing every read has in common is render_quality, a score from 0.0 to 1.0 attached to each rendered page. A low score means the page did not render cleanly: a challenge page, a cookie or login wall, an empty SPA shell, or an HTTP error. The content still comes back, but it comes back flagged, with a named deductions map and a warnings list, so a bad read never quietly enters your agent's context. Perceive, distill, lookup, and watch results all carry it. Start with the V2 overview for the concepts behind the six capabilities.
Perceive#
Render one URL into the artifacts you ask for. Synchronous: the call returns a completed operation with 15-minute signed artifact URLs. See perceive.
use enconvert::{PerceiveExtractName, PerceiveOptions, PerceiveOutputName};
let op = client.v2().perceive("https://example.com", PerceiveOptions {
outputs: Some(vec![PerceiveOutputName::Markdown, PerceiveOutputName::Screenshot]),
extract: Some(vec![PerceiveExtractName::Tables, PerceiveExtractName::Metadata]),
..Default::default()
})?;
println!("{:?}", op.render_quality);
println!("{:?}", op.deductions); // e.g. {"http_error": 0.7} when something is wrong
println!("{:?}", op.outputs.get("markdown").and_then(|a| a.url.as_ref()));
println!("{:?}", op.structured);
// Artifact URLs are re-signed on every status read.
client.v2().get_perceive_operation(&op.operation_id)?;
| Option | Type | Default | Description |
|---|---|---|---|
outputs |
Option<Vec<PerceiveOutputName>> |
markdown, structured |
Markdown, HtmlCleaned, HtmlRaw, Screenshot, ScreenshotFullPage, Pdf, Links, Images, Structured. |
extract |
Option<Vec<PerceiveExtractName>> |
- | Tables, Prices, Contacts, Metadata, MainContent, Headings, StructuredData, Technologies, All. |
schema |
Option<Map<String, Value>> |
- | JSON schema for structured extraction. |
only_main_content |
Option<bool> |
true |
Strip nav, header, footer, and cookie banners from the Markdown artifact and the main_content extract. |
wait_for / wait_timeout_ms / js_code |
see types | - / 30000 / - |
A CSS selector (optionally css:...) or js:<expr> to await, how long to wait (0 to 60000), and JavaScript to run after navigation (max 20000 characters). |
viewport / mobile |
Option<PerceiveViewport> / Option<bool> |
1920x1080 | Width 320 to 3840, height 240 to 2160, or a mobile device profile. |
cache_mode / block_resources |
see types | Enabled / - |
Enabled caches for 1 hour, Bypass skips, Refresh re-renders; plus resource types the browser should not load. |
headers, cookies, auth, respect_robots, pdf_options |
see UrlRenderOptions and PdfOptions |
- | Same shapes as the V1 render options. pdf_options matters only when outputs includes Pdf. |
perceive_direct streams a single artifact back as raw bytes instead of a JSON envelope. Exactly one artifact-producing output must be requested, and the SDK enforces that locally with Error::InvalidInput before sending anything.
use enconvert::{PerceiveOptions, PerceiveOutputName};
let direct = client.v2().perceive_direct("https://example.com", PerceiveOptions {
outputs: Some(vec![PerceiveOutputName::Pdf]),
..Default::default()
})?;
std::fs::write(direct.filename.as_deref().unwrap_or("page.pdf"), &direct.content)?;
// Re-download a stored artifact later. Pass None when the operation made only one.
let saved = client
.v2()
.download_perceive_artifact(&direct.operation_id, Some(PerceiveOutputName::Pdf))?;
println!("{} bytes", saved.content.len());
PerceiveDirectResult carries content, content_type, filename, operation_id, object_key, cache_hit, render_quality, source_status_code, content_hash, and warnings_count, all read from response headers. Error::Api { status: 410, .. } from download_perceive_artifact means the artifact is past its retention window.
perceive_batch takes up to 1000 URLs and one shared options block. Small batches run inline; larger ones come back with status Queued, so poll get_perceive_batch with the job_id.
use enconvert::{PerceiveBatchOptions, PerceiveBatchOutputMode, PerceiveOptions, PerceiveOutputName};
let render = PerceiveOptions {
outputs: Some(vec![PerceiveOutputName::Markdown]),
..Default::default()
};
let batch = client.v2().perceive_batch(
vec!["https://example.com/a".to_string(), "https://example.com/b".to_string()],
PerceiveBatchOptions { render, output_mode: Some(PerceiveBatchOutputMode::Zip) },
)?;
let done = client.v2().get_perceive_batch(&batch.job_id)?;
for item in &done.items {
println!("{} {:?}", item.url, item.render_quality);
}
Discover#
Enumerate a site's URLs with no browser rendering at all. See discover.
use enconvert::{DiscoverMode, DiscoverOptions};
let found = client.v2().discover("https://example.com", DiscoverOptions {
mode: Some(DiscoverMode::Hybrid),
max_urls: Some(200),
exclude_patterns: Some(vec!["/tag/".to_string()]),
..Default::default()
})?;
println!("{} urls, truncated: {}", found.total, found.truncated);
mode defaults to Hybrid (Sitemap and Crawl are the alternatives), max_urls to 100 (1 to 1000), max_depth to 2 (1 to 5), and same_domain_only to true. include_patterns then exclude_patterns apply regex filtering, max 50 each, and respect_robots honors robots.txt. The result carries urls, total, pages_crawled, truncated, robots_respected, and a sources map of raw per-source counts.
Lookup#
Run a categorized web search, and optionally render the top results in the same call. See lookup.
use enconvert::{LookupCategory, LookupOptions};
let search = client.v2().lookup("best static site generators", LookupOptions {
category: Some(LookupCategory::Web),
num_results: Some(10),
country: Some("us".to_string()),
perceive_top: Some(3),
..Default::default()
})?;
for hit in &search.results {
let quality = hit.perceive.as_ref().and_then(|p| p.render_quality);
println!("{:?} {:?} {quality:?}", hit.title, hit.url);
}
category defaults to Web (News, Images, Scholar, Patents, and Maps are the others), num_results to 10 (1 to 100), page to 1 (1 to 10), and autocorrect to true. country and locale take codes such as "us" and "en", location takes free text such as "Austin, Texas", and time_filter accepts Hour, Day, Week, Month, or Year. perceive_top (0 to 10, default 0) auto-renders the top N result URLs and attaches a full PerceiveResult to each hit. The result also carries answer_box, knowledge_graph, and perceive_operation_ids.
Distill#
Schema-driven structured extraction: give it a shape, get that shape back for every URL. An optional CSS pass runs first, and anything it misses escalates to the LLM tier. See distill.
use enconvert::{CssField, CssFieldType, CssSchema, DistillOptions};
use serde_json::json;
// CssField has no Default because `field_type` is required.
fn text_field(name: &str, selector: &str) -> CssField {
CssField {
name: name.into(), field_type: CssFieldType::Text, selector: Some(selector.into()),
attribute: None, pattern: None, default: None, transform: None, fields: None,
}
}
let distilled = client.v2().distill(DistillOptions {
urls: Some(vec!["https://example.com/pricing".to_string()]),
schema: json!({ "plans": "list of plan names with monthly prices" })
.as_object().unwrap().clone(),
css_schema: Some(CssSchema {
base_selector: ".plan-card".to_string(),
fields: vec![text_field("name", "h3"), text_field("price", ".price")],
name: None,
target_field: None,
}),
..Default::default()
})?;
for item in &distilled.results {
println!("{:?} tier {:?} css {} llm {}",
item.data, item.extraction_tier, item.fields_from_css, item.fields_from_llm);
}
Or discover a site first and distill every page it finds:
use enconvert::{DistillDiscoverFrom, DistillOptions};
use serde_json::json;
client.v2().distill(DistillOptions {
discover_from: Some(DistillDiscoverFrom {
max_pages: Some(20), // 1 to 50, caps both discovery and distillation
..DistillDiscoverFrom::new("https://example.com")
}),
schema: json!({ "title": "page title" }).as_object().unwrap().clone(),
..Default::default()
})?;
Supply exactly one of urls (max 50) or discover_from. Both, or neither, returns Error::InvalidInput before any request is sent. schema is required and is either a JSON-Schema object or a flat {field: description} map. CssFieldType covers Text, Attribute, Html, Regex, Nested, List, and NestedList; transform accepts Lowercase, Uppercase, or Strip; nesting is capped at depth 5.
Ingest#
Turn a whole site, or a pile of uploaded documents, into chunked RAG-ready JSONL through one pipeline. Ingest is always asynchronous. See ingest.
use enconvert::{
IngestChunkOptions, IngestFilesOptions, IngestMode, IngestOptions, IngestStatus, V2ListOptions,
};
// From a site.
let job = client.v2().ingest(IngestOptions {
mode: Some(IngestMode::Sitemap),
url: Some("https://docs.example.com".to_string()),
max_pages: Some(100),
chunk: Some(IngestChunkOptions { max_words: Some(512), sentence_overlap: Some(1) }),
webhook_url: Some("https://my.app/hooks/enconvert".to_string()),
..Default::default()
})?;
// Or from uploaded files: PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD,
// and legacy or ODF office documents.
let files = vec!["handbook.pdf".into(), "notes.docx".into()];
client.v2().ingest_files(files, IngestFilesOptions::default())?;
// Poll, list, cancel.
let status = client.v2().get_ingest_job(&job.job_id)?;
if status.status == IngestStatus::Completed {
println!("{:?}", status.output_url); // signed URL to the JSONL
}
client.v2().list_ingest_jobs(V2ListOptions { limit: Some(50), ..Default::default() })?;
client.v2().cancel_ingest_job(&job.job_id)?; // idempotent
mode defaults to Urls, which requires a non-empty urls list (max 1000) and rejects url; Sitemap and Crawl are the reverse, requiring the seed url. That pairing is validated client side, so a mismatch returns Error::InvalidInput immediately. max_pages defaults to 50 (1 to 1000), max_depth to 2 (1 to 5), same_domain_only to true, chunk.max_words to 512 (32 to 4000), and chunk.sentence_overlap to 1 (0 to 10). include_patterns, exclude_patterns, respect_robots, wait_for, and wait_timeout_ms behave as they do on discover and perceive. Completion webhooks are HMAC signed:
let secret = client.v2().get_webhook_secret()?;
println!("{} {} {}", secret.secret, secret.signature_header, secret.signature_scheme);
client.v2().rotate_webhook_secret()?; // old signatures stop verifying at once
client.v2().retry_ingest_webhook("ing_abc123")?; // re-deliver a completed job's webhook
Watch#
Re-render a URL on a fixed cadence and get told what changed. See watch.
use enconvert::{
SnapshotListOptions, V2ListOptions, WatchCreateOptions, WatchDiffMode, WatchUpdateStatus,
WatcherUpdate,
};
let watcher = client.v2().create_watcher("https://example.com/pricing", WatchCreateOptions {
frequency_minutes: Some(60),
diff_mode: Some(WatchDiffMode::Auto),
webhook_url: Some("https://my.app/hooks/changes".to_string()),
notify_email: Some(true),
..Default::default()
})?;
println!("{} next check {:?}", watcher.watcher_id, watcher.next_check_at);
// Read the check history, newest first.
let history = client
.v2()
.get_watcher_snapshots(&watcher.watcher_id, SnapshotListOptions { limit: Some(10) })?;
for snap in &history.snapshots {
println!("{} changed: {} similarity: {:?}", snap.checked_at, snap.has_changes, snap.similarity);
}
client.v2().list_watchers(V2ListOptions::default())?;
client.v2().get_watcher(&watcher.watcher_id)?;
// Pause it. Passing Some(String::new()) for webhook_url would clear the webhook.
client.v2().update_watcher(&watcher.watcher_id, WatcherUpdate {
status: Some(WatchUpdateStatus::Paused),
..Default::default()
})?;
client.v2().delete_watcher(&watcher.watcher_id)?; // soft delete, idempotent
frequency_minutes defaults to 60 and accepts 60 to 43200, so the hourly floor is hard. diff_mode defaults to Auto and also accepts Text, Structured, Tables, and Metadata, with track_fields narrowing the diff engine to a field or selector subset. notify_email defaults to true and emails the project owner on changes; webhook_url adds an HMAC-signed change webhook.
update_watcher requires at least one field and returns Error::InvalidInput for an all-None WatcherUpdate. Snapshot changes entries hold untrusted page content, so escape them before rendering anywhere.
PDF options#
PdfOptions is shared by convert_url_to_pdf, convert_document, convert_to_pdf, and perceive's Pdf output. Every field is optional and only sent when set.
use enconvert::{PdfHeaderFooter, PdfMargins, PdfOptions, PdfOrientation, UrlToPdfOptions};
fn block(text: &str, height: f64) -> PdfHeaderFooter {
PdfHeaderFooter { content: Some(text.into()), height: Some(height) }
}
client.convert_url_to_pdf("https://example.com", UrlToPdfOptions {
pdf_options: Some(PdfOptions {
page_size: Some("A4".to_string()),
orientation: Some(PdfOrientation::Landscape),
margins: Some(PdfMargins {
top: Some(10.0), bottom: Some(10.0), left: Some(15.0), right: Some(15.0),
}),
scale: Some(0.9),
header: Some(block("Quarterly Report", 15.0)),
footer: Some(block("Confidential", 12.0)),
..Default::default()
}),
..Default::default()
})?;
| Field | Type | Description |
|---|---|---|
page_size |
Option<String> |
"A4", "A3", "Letter", "Legal", and so on. |
page_width / page_height |
Option<f64> |
Custom dimensions. Set together, they override page_size. |
orientation |
Option<PdfOrientation> |
Portrait or Landscape. |
margins |
Option<PdfMargins> |
top, bottom, left, right, each optional. |
scale |
Option<f64> |
Render scale, for example 0.9 for 90 percent. |
grayscale |
Option<bool> |
Post-process the PDF to grayscale. |
header / footer |
Option<PdfHeaderFooter> |
content (max 2000 characters) and height. |
Full parameter semantics live in parameters and options.
Error handling#
There is one error type, enconvert::Error. It implements std::error::Error through thiserror, so ? propagates into any Box<dyn Error> or anyhow::Result.
use enconvert::{Error, UrlToPdfOptions};
match client.convert_url_to_pdf("https://example.com", UrlToPdfOptions::default()) {
Ok(result) => println!("{}", result.presigned_url),
Err(e) if e.is_authentication() => eprintln!("check ENCONVERT_API_KEY"),
Err(e) if e.is_rate_limit() => eprintln!("too many requests, back off"),
Err(e) if e.is_server_error() => eprintln!("gateway problem, retry later"),
Err(Error::UnsupportedConversion(msg)) => eprintln!("{msg}"),
Err(Error::Api { status, message }) => eprintln!("API error [{status}]: {message}"),
Err(e) => return Err(e),
}
| Variant | Returned on | Status code |
|---|---|---|
Error::Authentication(String) |
Invalid, missing, or revoked API key | 401, 403 |
Error::Quota(String) |
Raised on HTTP 402 | 402 |
Error::RateLimit(String) |
Rate limit exceeded | 429 |
Error::Api { status, message } |
Any other 4xx or 5xx response | the actual code |
Error::UnsupportedConversion(String) |
A {input}-to-{output} pair the API does not implement, caught client side |
- |
Error::InvalidInput(String) |
Bad arguments caught before the request, such as an empty API key | - |
Error::Http(reqwest::Error) |
Connection, TLS, timeout, or response-decoding failure | from the transport, when present |
Error::Io(std::io::Error) |
Reading a file to upload, or writing a download | - |
Error::Json(serde_json::Error) |
Serializing a request body | - |
Four predicates keep match arms short: is_authentication(), is_quota(), is_rate_limit(), and is_server_error(). status_code() returns Option<u16> for every variant that carries one. The full message map is in the error codes reference.
Timeout recovery#
Long URL-to-PDF renders and large document conversions can outlive a reverse-proxy timeout even when the conversion itself succeeds on the server. The SDK recovers on its own:
- Before each request it generates a UUID and sends it as
job_idin the body or the multipart form. - If that request comes back as a 5xx
Error::Api, the SDK silently switches to pollingGET /v1/convert/status/{job_id}every 3 seconds. - As soon as the job reads
success, it returns the result. As soon as it readsfailed, it returnsError::Apicarrying the server's message. - The polling deadline is 5 minutes. Past that you get
Error::Api { status: 504, message: "Conversion timed out" }.
Recovery covers convert_url_to_pdf, convert_url_to_screenshot, convert_url_to_markdown, convert_image, convert_document, convert_to_markdown, and convert_to_pdf. It deliberately does not cover the website batch submissions: those have no per-job row, so a 5xx there means the submission itself failed and surfaces directly. V2 endpoints answer directly and have no job fallback either. Responses that omit job_id get the client-generated one backfilled, so result.job_id is always something you can hand to get_job_status later.
Configuration#
use enconvert::Enconvert;
use std::time::Duration;
let client = Enconvert::with_options(
std::env::var("ENCONVERT_API_KEY").expect("ENCONVERT_API_KEY is not set"),
Some("https://api.enconvert.com"), // base URL override
Some(Duration::from_secs(300)), // request timeout
)?;
println!("enconvert {}", enconvert::VERSION);
| Argument | Type | Default | Description |
|---|---|---|---|
api_key |
impl Into<String> |
required | Private API key. An empty string returns Error::InvalidInput, and so does a key containing invalid header characters. |
base_url |
Option<&str> |
https://api.enconvert.com |
Trailing slashes are stripped. |
timeout |
Option<Duration> |
300 seconds | Applied to the whole request. |
Enconvert::new(api_key) is shorthand for with_options(api_key, None, None).
X-API-Key header value sensitive so it stays out of debug output, and it downloads signed URLs through a second, unauthenticated HTTP client so your key is never sent to object storage. Generate and rotate keys in the dashboard, and see authentication for key types.
Result shape#
Every single-file conversion method returns a ConversionResult:
pub struct ConversionResult {
pub presigned_url: String,
pub object_key: String,
pub filename: String,
pub file_size: Option<u64>,
pub conversion_time_seconds: Option<f64>,
pub job_id: Option<String>,
}
Download the file yourself from presigned_url, or pass save_to and let the SDK write it for you. Signed URLs expire, so store anything you need to keep in your own bucket.
The async paths return their own shapes. JobStatus carries status (Processing, Success, Failed, or Unknown(String)), presigned_url, object_key, and error. BatchSubmission carries batch_id, status, url_count, total_discovered, discovery_method, and output_format. BatchStatus adds the counters total, completed, failed, and in_progress, plus output_mode, zip_download_url, and a Vec<BatchItem> of per-URL rows.
V2 reads return PerceiveResult, whose outputs map is keyed by output name ("markdown", "screenshot_full_page", and so on) with V2OutputArtifact { url, object_key, size_bytes, content_type, expires_in } values. Those artifact URLs are signed for 15 minutes and re-signed on every get_perceive_operation call. Alongside them sit render_quality, status_code, deductions, cache_hit, structured, extraction_tier, tokens, cost_cents, duration_ms, warnings, and options_echo. Every response enum carries an Unknown(String) variant, so a status value added on the server after your build shipped parses cleanly instead of failing.
Source and issues#
- crates.io: enconvert
- GitHub: conversionapi/rust-sdk
- License: MIT
- Other clients: all SDKs and the REST endpoint reference
Frequently asked questions#
How do I convert files in Rust with a crates.io package?#
Run cargo add enconvert, build a client with Enconvert::new(api_key)?, and call a method such as convert_url_to_pdf, convert_image, or convert_document. Every option struct derives Default, so you write a struct literal with ..Default::default() and set only the fields you care about. Pass save_to to have the SDK write the output straight to disk.
Does the Rust SDK need tokio or an async runtime?#
No. It is built on reqwest's blocking API, so every method blocks the calling thread and there is no .await, no executor, and no tokio in your dependency tree. If your application is already async, call the SDK from tokio::task::spawn_blocking rather than directly on a reactor thread, because reqwest's blocking client cannot run inside an async runtime context.
How do I convert a URL to PDF in Rust?#
Call convert_url_to_pdf("https://example.com", UrlToPdfOptions { save_to: Some("page.pdf".into()), ..Default::default() }). Set single_page: Some(false) to paginate instead of producing one continuous page, and pass pdf_options for page size, orientation, margins, scale, grayscale, headers, and footers.
How do I convert DOCX to PDF in Rust?#
Call convert_document("report.docx", ConvertDocumentOptions { save_to: Some("report.pdf".into()), ..Default::default() }). PDF is the default output, so output_format can be left unset. The same method handles XLSX, PPTX, ODT, ODS, ODP, OTS, Pages, Numbers, HTML, and Markdown to PDF, plus data-format pairs such as JSON to YAML and CSV to XML.
How do I convert HEIC to WebP in Rust?#
Call convert_image("photo.heic", ConvertImageOptions { output_format: "webp".to_string(), ..Default::default() }). The input format comes from the file extension, and all 20 ordered pairs among jpeg, png, svg, heic, and webp are implemented, plus pdf to jpeg for rasterization. An unsupported pair returns Error::UnsupportedConversion before any network call.
How do I scrape a web page into clean Markdown from Rust?#
Two ways. convert_url_to_markdown returns a single Markdown file with YAML frontmatter. client.v2().perceive(url, PerceiveOptions { outputs: Some(vec![PerceiveOutputName::Markdown]), ..Default::default() }) returns the same content with a render_quality score, named deductions, warnings, and optional screenshots, links, or structured extraction from the same render. Use perceive when an agent is going to read the result.
How do I know whether a page actually rendered?#
Read render_quality, a score from 0.0 to 1.0 present on every V2 read. A low score means the render was not clean: a challenge page, a cookie or login wall, an empty SPA shell, or an HTTP error. The deductions map names each penalty that fired, status_code gives the upstream HTTP status, and warnings lists what went wrong. The content is still returned, just flagged.
What happens when a conversion takes longer than the proxy timeout?#
The SDK handles it. Each request carries a client-generated job_id; if the request returns 5xx, the SDK polls GET /v1/convert/status/{job_id} every 3 seconds for up to 5 minutes and returns the finished result as if nothing had gone wrong. Past the deadline you get Error::Api { status: 504, message: "Conversion timed out" }. Whole-site batch submissions are the exception, and wait_for_batch is the tool for those.
Can I use the Rust SDK from a browser or WASM target?#
No. It authenticates with a private API key that must never ship to a client, and it depends on reqwest's blocking client with native TLS and threads, neither of which exists on wasm32-unknown-unknown. Run it on a server, in a CLI, or in a worker, and have your frontend talk to your own backend.