enconvert 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "fileutils"
6
+ require "net/http"
7
+ require "uri"
8
+
9
+ require_relative "internal"
10
+ require_relative "errors"
11
+ require_relative "formats"
12
+ require_relative "results"
13
+ require_relative "v2"
14
+
15
+ module Enconvert
16
+ # Enconvert file conversion client.
17
+ #
18
+ # @example
19
+ # client = Enconvert::Client.new(api_key: "sk_...")
20
+ # result = client.convert_url_to_pdf("https://example.com")
21
+ # puts result.presigned_url
22
+ class Client
23
+ include Internal
24
+
25
+ DEFAULT_BASE_URL = "https://api.enconvert.com"
26
+ DEFAULT_TIMEOUT = 300
27
+ DEFAULT_BATCH_POLL_INTERVAL = 5
28
+ DEFAULT_BATCH_TIMEOUT = 1_800
29
+
30
+ # V2 API namespace: perceive, discover, lookup, distill, ingest, watch.
31
+ # Requires a private API key; endpoints are plan-gated (QuotaError on 402).
32
+ attr_reader :v2
33
+
34
+ def initialize(api_key:, timeout: DEFAULT_TIMEOUT, base_url: DEFAULT_BASE_URL)
35
+ raise Enconvert::Error, "Enconvert: 'api_key' is required" if api_key.nil? || api_key.to_s.strip.empty?
36
+
37
+ @api_key = api_key
38
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
39
+ @timeout = timeout
40
+ @transport = Internal::Transport.new(api_key: @api_key, base_url: @base_url, timeout: @timeout)
41
+ @v2 = Enconvert::V2.new(@transport)
42
+ end
43
+
44
+ # ------------------------------------------------------------------
45
+ # URL conversions (single page)
46
+ # ------------------------------------------------------------------
47
+
48
+ # Convert a URL to PDF.
49
+ #
50
+ # opts accepts: viewport_width, viewport_height, load_media,
51
+ # enable_scroll, output_filename, auth, cookies, headers,
52
+ # plus save_to, single_page (default true), pdf_options.
53
+ def convert_url_to_pdf(url, save_to: nil, single_page: nil, pdf_options: nil, **opts)
54
+ body = build_url_body(url, opts)
55
+ body[:single_page] = single_page.nil? ? true : single_page
56
+ body[:pdf_options] = serialize_pdf_options(pdf_options) if pdf_options
57
+
58
+ data = post_json("/v1/convert/url-to-pdf", body)
59
+ result = to_conversion_result(data)
60
+ download(result.presigned_url, save_to) if save_to
61
+ result
62
+ end
63
+
64
+ # Convert a URL to a PNG screenshot.
65
+ def convert_url_to_screenshot(url, save_to: nil, **opts)
66
+ body = build_url_body(url, opts)
67
+
68
+ data = post_json("/v1/convert/url-to-screenshot", body)
69
+ result = to_conversion_result(data)
70
+ download(result.presigned_url, save_to) if save_to
71
+ result
72
+ end
73
+
74
+ # Convert a URL to clean GitHub-Flavored Markdown with YAML frontmatter
75
+ # (title, description, url, links, images). Strips nav/footer/ads/scripts
76
+ # and extracts the main article content.
77
+ def convert_url_to_markdown(url, save_to: nil, **opts)
78
+ body = build_url_body(url, opts)
79
+
80
+ data = post_json("/v1/convert/url-to-markdown", body)
81
+ result = to_conversion_result(data)
82
+ download(result.presigned_url, save_to) if save_to
83
+ result
84
+ end
85
+
86
+ # ------------------------------------------------------------------
87
+ # Website conversions (async batch, whole-site crawl)
88
+ # ------------------------------------------------------------------
89
+
90
+ # Convert every discovered page of a website to PDF. Async-only: pages
91
+ # are discovered via sitemap or full crawl (plan-dependent), converted
92
+ # in the background, and bundled into a single ZIP. Poll with
93
+ # get_batch_status or block with wait_for_batch. Requires a private API
94
+ # key with crawl access.
95
+ def convert_website_to_pdf(url, single_page: nil, pdf_options: nil, **opts)
96
+ body = build_website_body(url, opts)
97
+ body[:single_page] = single_page unless single_page.nil?
98
+ body[:pdf_options] = serialize_pdf_options(pdf_options) if pdf_options
99
+
100
+ # No job-polling fallback: website submissions have no per-job row, so
101
+ # a 5xx here means the submission itself failed and must surface directly.
102
+ data = post_json("/v1/convert/website-to-pdf", body, job_fallback: false)
103
+ to_batch_submission(data)
104
+ end
105
+
106
+ # Screenshot every discovered page of a website (PNG). Async-only,
107
+ # bundled into a single ZIP. Poll with get_batch_status or block with
108
+ # wait_for_batch. Requires a private API key with crawl access.
109
+ def convert_website_to_screenshot(url, **opts)
110
+ body = build_website_body(url, opts)
111
+
112
+ data = post_json("/v1/convert/website-to-screenshot", body, job_fallback: false)
113
+ to_batch_submission(data)
114
+ end
115
+
116
+ # ------------------------------------------------------------------
117
+ # File conversions
118
+ # ------------------------------------------------------------------
119
+
120
+ # Convert an image between formats (jpeg, png, svg, heic, webp), or
121
+ # rasterize a PDF to JPEG. Only pairs implemented by the API are
122
+ # accepted; unsupported pairs raise before any request is made.
123
+ def convert_image(file, output_format:, save_to: nil, output_filename: nil)
124
+ part = to_file_part(file)
125
+ input_format = Formats.resolve_input_format(part.filename, Formats::IMAGE_FORMATS)
126
+ output_fmt = Formats.normalize_output_format(output_format)
127
+ endpoint = "/v1/convert/#{Formats.assert_conversion_implemented(input_format, output_fmt)}"
128
+ data = post_file(endpoint, part, output_filename: output_filename)
129
+ result = to_conversion_result(data)
130
+ download(result.presigned_url, save_to) if save_to
131
+ result
132
+ end
133
+
134
+ # Convert a document (doc, excel, ppt, odt, ods, odp, ots, pages,
135
+ # numbers, html, markdown, csv, json, xml, yaml, toml). Output
136
+ # defaults to pdf. Only pairs implemented by the API are accepted;
137
+ # unsupported pairs raise before any request is made. (EPUB has no
138
+ # dedicated pair — use convert_to_pdf / convert_to_markdown.)
139
+ def convert_document(file, output_format: "pdf", save_to: nil, output_filename: nil, pdf_options: nil)
140
+ part = to_file_part(file)
141
+ input_format = Formats.resolve_input_format(part.filename, Formats::DOCUMENT_FORMATS)
142
+ output_fmt = Formats.normalize_output_format(output_format)
143
+ endpoint = "/v1/convert/#{Formats.assert_conversion_implemented(input_format, output_fmt)}"
144
+ data = post_file(endpoint, part, output_filename: output_filename, pdf_options: pdf_options)
145
+ result = to_conversion_result(data)
146
+ download(result.presigned_url, save_to) if save_to
147
+ result
148
+ end
149
+
150
+ # Convert an uploaded file of (almost) any document format to clean
151
+ # Markdown — PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT/MD, and
152
+ # legacy/ODF office. The format is auto-detected server-side; a
153
+ # RAG-ingestion building block. Images are not supported.
154
+ def convert_to_markdown(file, save_to: nil, output_filename: nil)
155
+ part = to_file_part(file)
156
+ data = post_file("/v1/convert/anything-to-markdown", part, output_filename: output_filename)
157
+ result = to_conversion_result(data)
158
+ download(result.presigned_url, save_to) if save_to
159
+ result
160
+ end
161
+
162
+ # Convert an uploaded file of (almost) any format to PDF —
163
+ # office/ODF/Pages/Numbers/RTF/CSV, HTML, Markdown, text, raster
164
+ # images, SVG, EPUB, or an existing PDF (passthrough/normalise). The
165
+ # format is auto-detected server-side. Only `pdf_options[:grayscale]`
166
+ # is honored on this endpoint.
167
+ def convert_to_pdf(file, save_to: nil, output_filename: nil, pdf_options: nil)
168
+ part = to_file_part(file)
169
+ data = post_file("/v1/convert/anything-to-pdf", part, output_filename: output_filename, pdf_options: pdf_options)
170
+ result = to_conversion_result(data)
171
+ download(result.presigned_url, save_to) if save_to
172
+ result
173
+ end
174
+
175
+ # ------------------------------------------------------------------
176
+ # Job + batch status
177
+ # ------------------------------------------------------------------
178
+
179
+ # Poll the status of an async conversion job.
180
+ def get_job_status(job_id)
181
+ resp = @transport.request(:get, "/v1/convert/status/#{job_id}")
182
+ raise_for_status(resp)
183
+ to_job_status(JSON.parse(resp.body))
184
+ end
185
+
186
+ # Get the status of an async batch (website conversion). Returns
187
+ # aggregate counts, per-URL statuses, and download URLs. Private API
188
+ # keys only.
189
+ def get_batch_status(batch_id)
190
+ resp = @transport.request(:get, "/v1/convert/batch/#{batch_id}")
191
+ raise_for_status(resp)
192
+ to_batch_status(JSON.parse(resp.body))
193
+ end
194
+
195
+ # Poll a batch until it leaves "processing", then return its final
196
+ # status. With save_to, downloads the batch ZIP once available.
197
+ # Raises APIError 504 on timeout.
198
+ def wait_for_batch(batch_id, interval: DEFAULT_BATCH_POLL_INTERVAL, timeout: DEFAULT_BATCH_TIMEOUT, save_to: nil)
199
+ deadline = Time.now + timeout
200
+
201
+ loop do
202
+ status = get_batch_status(batch_id)
203
+ if status.status != "processing"
204
+ if save_to
205
+ if status.zip_download_url.nil?
206
+ raise APIError.new(
207
+ 500, "Batch #{batch_id} finished with status '#{status.status}' but no ZIP is available to save"
208
+ )
209
+ end
210
+ download(status.zip_download_url, save_to)
211
+ end
212
+ return status
213
+ end
214
+ raise APIError.new(504, "Batch #{batch_id} did not complete within #{timeout}s") if Time.now >= deadline
215
+
216
+ sleep(interval)
217
+ end
218
+ end
219
+
220
+ private
221
+
222
+ # ------------------------------------------------------------------
223
+ # Internal helpers (mirror of Node's postJson / postFile / pollJob /
224
+ # download / toFilePart / raiseForStatus)
225
+ # ------------------------------------------------------------------
226
+
227
+ # Request body shared by all single-URL conversions.
228
+ def build_url_body(url, opts)
229
+ body = {
230
+ url: url,
231
+ direct_download: false,
232
+ viewport_width: opts.key?(:viewport_width) ? opts[:viewport_width] : 1920,
233
+ viewport_height: opts.key?(:viewport_height) ? opts[:viewport_height] : 1080,
234
+ load_media: opts.key?(:load_media) ? opts[:load_media] : true,
235
+ enable_scroll: opts.key?(:enable_scroll) ? opts[:enable_scroll] : true
236
+ }
237
+ body[:output_filename] = opts[:output_filename] if opts[:output_filename]
238
+ append_browser_access(body, opts)
239
+ body
240
+ end
241
+
242
+ # Request body for website (whole-site) conversions. Render options are
243
+ # only sent when set — the gateway applies the same defaults per page.
244
+ def build_website_body(url, opts)
245
+ body = { url: url }
246
+ body[:crawl_mode] = opts[:crawl_mode] if opts[:crawl_mode]
247
+ body[:include_patterns] = opts[:include_patterns] if opts[:include_patterns]
248
+ body[:exclude_patterns] = opts[:exclude_patterns] if opts[:exclude_patterns]
249
+ body[:notification_email] = opts[:notification_email] if opts[:notification_email]
250
+ body[:callback_url] = opts[:callback_url] if opts[:callback_url]
251
+ body[:output_filename] = opts[:output_filename] if opts[:output_filename]
252
+ body[:viewport_width] = opts[:viewport_width] if opts.key?(:viewport_width)
253
+ body[:viewport_height] = opts[:viewport_height] if opts.key?(:viewport_height)
254
+ body[:load_media] = opts[:load_media] if opts.key?(:load_media)
255
+ body[:enable_scroll] = opts[:enable_scroll] if opts.key?(:enable_scroll)
256
+ append_browser_access(body, opts)
257
+ body
258
+ end
259
+
260
+ # Attach the plan-gated auth/cookies/headers fields when provided.
261
+ def append_browser_access(body, opts)
262
+ body[:auth] = opts[:auth] if opts[:auth]
263
+ body[:cookies] = opts[:cookies] if opts[:cookies]
264
+ body[:headers] = opts[:headers] if opts[:headers]
265
+ end
266
+
267
+ def post_json(endpoint, body, job_fallback: true)
268
+ job_id = job_fallback ? new_job_id : nil
269
+ body[:job_id] = job_id if job_id
270
+ begin
271
+ resp = @transport.request(
272
+ :post, endpoint,
273
+ headers: { "Content-Type" => "application/json" },
274
+ body: JSON.generate(body)
275
+ )
276
+ raise_for_status(resp)
277
+ data = JSON.parse(resp.body)
278
+ # Some success responses omit job_id (URL sync path); backfill the
279
+ # client-generated id so callers can still poll get_job_status with it.
280
+ job_id ? { "job_id" => job_id }.merge(data) : data
281
+ rescue APIError => e
282
+ raise unless job_id && e.status_code >= 500
283
+
284
+ poll_job(job_id)
285
+ end
286
+ end
287
+
288
+ def post_file(endpoint, part, output_filename: nil, pdf_options: nil)
289
+ job_id = new_job_id
290
+ fields = [
291
+ { name: "file", value: part.bytes, filename: part.filename, content_type: part.content_type },
292
+ { name: "direct_download", value: "false" },
293
+ { name: "job_id", value: job_id }
294
+ ]
295
+ fields << { name: "output_filename", value: output_filename } if output_filename
296
+ fields << { name: "pdf_options", value: JSON.generate(serialize_pdf_options(pdf_options)) } if pdf_options
297
+
298
+ body, boundary = build_multipart(fields)
299
+ begin
300
+ resp = @transport.request(
301
+ :post, endpoint,
302
+ headers: { "Content-Type" => "multipart/form-data; boundary=#{boundary}" },
303
+ body: body
304
+ )
305
+ raise_for_status(resp)
306
+ data = JSON.parse(resp.body)
307
+ { "job_id" => job_id }.merge(data)
308
+ rescue APIError => e
309
+ raise unless e.status_code >= 500
310
+
311
+ poll_job(job_id)
312
+ end
313
+ end
314
+
315
+ # Poll job status until success/failure. Used as fallback when the
316
+ # HTTP request fails.
317
+ def poll_job(job_id, max_wait: 300, interval: 3)
318
+ deadline = Time.now + max_wait
319
+ while Time.now < deadline
320
+ sleep(interval)
321
+ resp = @transport.request(:get, "/v1/convert/status/#{job_id}")
322
+ next if resp.code.to_i == 404
323
+
324
+ raise_for_status(resp)
325
+ data = JSON.parse(resp.body)
326
+ return data if data["status"] == "success"
327
+ raise APIError.new(500, data["error"] || "Conversion failed") if data["status"] == "failed"
328
+ end
329
+ raise APIError.new(504, "Conversion timed out")
330
+ end
331
+
332
+ # Save a presigned URL to a local file. Streams the response to disk.
333
+ # Deliberately bypasses @transport: this is a signed S3 URL and must
334
+ # NOT receive the X-API-Key header.
335
+ def download(url, dest)
336
+ uri = URI(url)
337
+ FileUtils.mkdir_p(File.dirname(dest))
338
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
339
+ req = Net::HTTP::Get.new(uri)
340
+ http.request(req) do |response|
341
+ unless response.is_a?(Net::HTTPSuccess)
342
+ raise APIError.new(response.code.to_i, "Failed to download: #{response.message}")
343
+ end
344
+
345
+ File.open(dest, "wb") do |file|
346
+ response.read_body { |chunk| file.write(chunk) }
347
+ end
348
+ end
349
+ end
350
+ end
351
+
352
+ # ------------------------------------------------------------------
353
+ # Response mappers
354
+ # ------------------------------------------------------------------
355
+
356
+ def to_conversion_result(data)
357
+ object_key = data["object_key"].is_a?(String) ? data["object_key"] : ""
358
+ # Job-status fallback responses omit `filename`; recover it from the
359
+ # object key so callers never see a nil/blank filename silently.
360
+ filename = data["filename"].is_a?(String) ? data["filename"] : basename_fallback(object_key)
361
+ ConversionResult.new(
362
+ presigned_url: data["presigned_url"].to_s,
363
+ object_key: object_key,
364
+ filename: filename,
365
+ file_size: data["file_size"].is_a?(Numeric) ? data["file_size"] : nil,
366
+ conversion_time_seconds: data["conversion_time_seconds"].is_a?(Numeric) ? data["conversion_time_seconds"] : nil,
367
+ job_id: data["job_id"].is_a?(String) ? data["job_id"] : nil
368
+ )
369
+ end
370
+
371
+ def basename_fallback(object_key)
372
+ parts = object_key.to_s.split("/")
373
+ parts.empty? ? "" : parts.last
374
+ end
375
+
376
+ def to_job_status(data)
377
+ JobStatus.new(
378
+ status: data["status"],
379
+ presigned_url: data["presigned_url"].is_a?(String) ? data["presigned_url"] : nil,
380
+ object_key: data["object_key"].is_a?(String) ? data["object_key"] : nil,
381
+ error: data["error"].is_a?(String) ? data["error"] : nil
382
+ )
383
+ end
384
+
385
+ def to_batch_submission(data)
386
+ BatchSubmission.new(
387
+ batch_id: data["batch_id"].to_s,
388
+ status: data["status"].is_a?(String) ? data["status"] : "processing",
389
+ url_count: data["url_count"].is_a?(Numeric) ? data["url_count"] : 0,
390
+ total_discovered: data["total_discovered"].is_a?(Numeric) ? data["total_discovered"] : nil,
391
+ discovery_method: data["discovery_method"].is_a?(String) ? data["discovery_method"] : nil,
392
+ output_format: data["output_format"].is_a?(String) ? data["output_format"] : nil
393
+ )
394
+ end
395
+
396
+ def to_batch_status(d)
397
+ raw_items = d["items"].is_a?(Array) ? d["items"] : []
398
+ BatchStatus.new(
399
+ batch_id: d["batch_id"].to_s,
400
+ status: d["status"],
401
+ total: d["total"].is_a?(Numeric) ? d["total"] : 0,
402
+ completed: d["completed"].is_a?(Numeric) ? d["completed"] : 0,
403
+ failed: d["failed"].is_a?(Numeric) ? d["failed"] : 0,
404
+ in_progress: d["in_progress"].is_a?(Numeric) ? d["in_progress"] : 0,
405
+ output_mode: d["output_mode"],
406
+ zip_download_url: d["zip_download_url"].is_a?(String) ? d["zip_download_url"] : nil,
407
+ items: raw_items.map do |item|
408
+ BatchItem.new(
409
+ source_url: item["source_url"].is_a?(String) ? item["source_url"] : "",
410
+ status: item["status"].is_a?(String) ? item["status"] : "",
411
+ download_url: item["download_url"].is_a?(String) ? item["download_url"] : nil,
412
+ output_file_size: item["output_file_size"].is_a?(Numeric) ? item["output_file_size"] : nil,
413
+ duration: item["duration"].is_a?(String) ? item["duration"] : nil
414
+ )
415
+ end
416
+ )
417
+ end
418
+ end
419
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Enconvert SDK exceptions.
4
+ module Enconvert
5
+ # Base class for every error raised by this SDK (including client-side
6
+ # validation errors raised before any HTTP request is made).
7
+ class Error < StandardError; end
8
+
9
+ # Raised for any HTTP response with status >= 400 that isn't one of the
10
+ # more specific subclasses below. `status_code` carries the HTTP status.
11
+ class APIError < Error
12
+ attr_reader :status_code
13
+
14
+ def initialize(status_code, message)
15
+ @status_code = status_code
16
+ super("[#{status_code}] #{message}")
17
+ end
18
+ end
19
+
20
+ # HTTP 401 or 403 — invalid or missing API key.
21
+ class AuthenticationError < APIError
22
+ def initialize(message = "Invalid or missing API key")
23
+ super(401, message)
24
+ end
25
+ end
26
+
27
+ # HTTP 429 — too many requests.
28
+ class RateLimitError < APIError
29
+ def initialize(message = "Rate limit exceeded")
30
+ super(429, message)
31
+ end
32
+ end
33
+
34
+ # HTTP 402 — plan feature not enabled or monthly quota exhausted.
35
+ # V2 endpoints raise this for disabled/quota-gated features.
36
+ class QuotaError < APIError
37
+ def initialize(message = "Plan feature not enabled or quota exhausted")
38
+ super(402, message)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Enconvert
6
+ # Format tables mirroring the gateway's CONVERTER_MAP (api/v1/convert.py).
7
+ #
8
+ # IMPLEMENTED_CONVERSIONS is the client-side gate: the gateway returns 503
9
+ # for any `{input}-to-{output}` endpoint not in its CONVERTER_MAP, so we
10
+ # reject unsupported pairs here with a useful message instead of paying a
11
+ # network round-trip for a guaranteed failure.
12
+ module Formats
13
+ # The 43 implemented `{input}-to-{output}` conversion endpoints.
14
+ IMPLEMENTED_CONVERSIONS = Set.new(
15
+ [
16
+ # Structured text (13)
17
+ "json-to-xml",
18
+ "xml-to-json",
19
+ "json-to-yaml",
20
+ "yaml-to-json",
21
+ "csv-to-json",
22
+ "json-to-csv",
23
+ "json-to-toml",
24
+ "toml-to-json",
25
+ "csv-to-xml",
26
+ "xml-to-csv",
27
+ "markdown-to-html",
28
+ "markdown-to-pdf",
29
+ "html-to-pdf",
30
+ # Documents (9) — EPUB→PDF now flows through anything-to-pdf, not a dedicated pair.
31
+ "doc-to-pdf",
32
+ "excel-to-pdf",
33
+ "ppt-to-pdf",
34
+ "odt-to-pdf",
35
+ "ods-to-pdf",
36
+ "odp-to-pdf",
37
+ "ots-to-pdf",
38
+ "pages-to-pdf",
39
+ "numbers-to-pdf",
40
+ # Images (21)
41
+ "jpeg-to-png",
42
+ "png-to-jpeg",
43
+ "jpeg-to-svg",
44
+ "svg-to-jpeg",
45
+ "jpeg-to-heic",
46
+ "heic-to-jpeg",
47
+ "jpeg-to-webp",
48
+ "webp-to-jpeg",
49
+ "png-to-svg",
50
+ "svg-to-png",
51
+ "png-to-heic",
52
+ "heic-to-png",
53
+ "png-to-webp",
54
+ "webp-to-png",
55
+ "svg-to-heic",
56
+ "heic-to-svg",
57
+ "svg-to-webp",
58
+ "webp-to-svg",
59
+ "heic-to-webp",
60
+ "webp-to-heic",
61
+ "pdf-to-jpeg"
62
+ ]
63
+ ).freeze
64
+
65
+ # Extension -> API format name (input side).
66
+ IMAGE_FORMATS = {
67
+ ".jpg" => "jpeg",
68
+ ".jpeg" => "jpeg",
69
+ ".png" => "png",
70
+ ".svg" => "svg",
71
+ ".heic" => "heic",
72
+ ".webp" => "webp",
73
+ # PDF is an image input solely for pdf-to-jpeg (rasterization).
74
+ ".pdf" => "pdf"
75
+ }.freeze
76
+
77
+ DOCUMENT_FORMATS = {
78
+ ".doc" => "doc",
79
+ ".docx" => "doc",
80
+ ".xls" => "excel",
81
+ ".xlsx" => "excel",
82
+ ".ppt" => "ppt",
83
+ ".pptx" => "ppt",
84
+ ".html" => "html",
85
+ ".htm" => "html",
86
+ ".odt" => "odt",
87
+ ".ods" => "ods",
88
+ ".odp" => "odp",
89
+ ".ots" => "ots",
90
+ ".pages" => "pages",
91
+ ".numbers" => "numbers",
92
+ # .epub has no dedicated document pair — use convert_to_pdf / convert_to_markdown.
93
+ ".md" => "markdown",
94
+ ".markdown" => "markdown",
95
+ ".csv" => "csv",
96
+ ".json" => "json",
97
+ ".xml" => "xml",
98
+ ".yaml" => "yaml",
99
+ ".yml" => "yaml",
100
+ ".toml" => "toml"
101
+ }.freeze
102
+
103
+ MIME_BY_EXT = {
104
+ ".jpg" => "image/jpeg",
105
+ ".jpeg" => "image/jpeg",
106
+ ".png" => "image/png",
107
+ ".svg" => "image/svg+xml",
108
+ ".heic" => "image/heic",
109
+ ".webp" => "image/webp",
110
+ ".pdf" => "application/pdf",
111
+ ".doc" => "application/msword",
112
+ ".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
113
+ ".xls" => "application/vnd.ms-excel",
114
+ ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
115
+ ".ppt" => "application/vnd.ms-powerpoint",
116
+ ".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
117
+ ".html" => "text/html",
118
+ ".htm" => "text/html",
119
+ ".odt" => "application/vnd.oasis.opendocument.text",
120
+ ".ods" => "application/vnd.oasis.opendocument.spreadsheet",
121
+ ".odp" => "application/vnd.oasis.opendocument.presentation",
122
+ ".epub" => "application/epub+zip",
123
+ ".md" => "text/markdown",
124
+ ".markdown" => "text/markdown",
125
+ ".csv" => "text/csv",
126
+ ".json" => "application/json",
127
+ ".xml" => "application/xml",
128
+ ".yaml" => "application/x-yaml",
129
+ ".yml" => "application/x-yaml",
130
+ ".toml" => "application/toml"
131
+ }.freeze
132
+
133
+ # Common aliases users pass that differ from the API's canonical format names.
134
+ OUTPUT_FORMAT_ALIASES = {
135
+ "jpg" => "jpeg",
136
+ "yml" => "yaml",
137
+ "htm" => "html",
138
+ "md" => "markdown"
139
+ }.freeze
140
+
141
+ module_function
142
+
143
+ def ext_of(name)
144
+ s = name.to_s
145
+ i = s.rindex(".")
146
+ i.nil? ? "" : s[i..].downcase
147
+ end
148
+
149
+ def mime_for(name)
150
+ MIME_BY_EXT[ext_of(name)] || "application/octet-stream"
151
+ end
152
+
153
+ # Map a filename's extension to its API input format, or raise.
154
+ def resolve_input_format(name, map)
155
+ ext = ext_of(name)
156
+ fmt = map[ext]
157
+ if fmt.nil?
158
+ supported = map.keys.sort.join(", ")
159
+ raise Enconvert::Error, "Unsupported file extension '#{ext}'. Supported: #{supported}"
160
+ end
161
+ fmt
162
+ end
163
+
164
+ # Lowercase, strip a leading dot, and resolve aliases (jpg, yml, htm, md).
165
+ def normalize_output_format(fmt)
166
+ f = fmt.to_s.downcase.sub(/\A\./, "")
167
+ OUTPUT_FORMAT_ALIASES[f] || f
168
+ end
169
+
170
+ # List the output formats the API implements for a given input format.
171
+ def valid_outputs_for(input_format)
172
+ prefix = "#{input_format}-to-"
173
+ IMPLEMENTED_CONVERSIONS.to_a
174
+ .select { |name| name.start_with?(prefix) }
175
+ .map { |name| name[prefix.length..] }
176
+ .sort
177
+ end
178
+
179
+ # Assert `{input}-to-{output}` is an implemented endpoint and return its
180
+ # name. Raises with the list of valid outputs for that input otherwise.
181
+ def assert_conversion_implemented(input_format, output_format)
182
+ endpoint = "#{input_format}-to-#{output_format}"
183
+ unless IMPLEMENTED_CONVERSIONS.include?(endpoint)
184
+ outputs = valid_outputs_for(input_format)
185
+ hint = if outputs.empty?
186
+ "No conversions are available for input format '#{input_format}'"
187
+ else
188
+ "Supported outputs for '#{input_format}': #{outputs.join(', ')}"
189
+ end
190
+ raise Enconvert::Error, "Conversion '#{input_format}' to '#{output_format}' is not supported. #{hint}."
191
+ end
192
+ endpoint
193
+ end
194
+ end
195
+ end