API Reference
Two ways to drive DynDyn.Pdf programmatically:
- Library API — reference
DynDyn.Pdf.Coreand call the converter in-process. - REST API — the
DynDyn.Pdf.WebHTTP service (upload → convert → download).
Library API
Install / reference
Reference the projects (or their packages once published):
DynDyn.Pdf.Core— the engine, options, output writers, batch converter. Pure managed.DynDyn.Pdf.Engine— the default fully-managed extraction engine (ManagedExtractionEngine).DynDyn.Pdf.Interop— native P/Invoke; only needed for--engine nativeor raster backgrounds.
The interface
namespace DynDyn.Pdf.Core;
/// Converts a PDF document to pixel-faithful HTML. Stateless and safe for concurrent use.
public interface IPdfToHtmlConverter
{
Task<ConversionResult> ConvertAsync(
string inputPdfPath,
IOutputWriter output,
ConversionOptions options,
IProgress<ConversionProgress>? progress = null,
CancellationToken cancellationToken = default);
}
Two implementations:
| Implementation | Engine | Native dependency |
|---|---|---|
DocumentConverter (recommended) |
Pluggable IExtractionEngine (pass new ManagedExtractionEngine()) |
Only if you request a raster background |
PdfToHtmlConverter |
native | Always |
Quick start (fully-managed, no native dependency)
using DynDyn.Pdf.Core;
using DynDyn.Pdf.Core.Engine;
using DynDyn.Pdf.Core.Output;
using DynDyn.Pdf.Engine;
IPdfToHtmlConverter converter = new DocumentConverter(
new ManagedExtractionEngine(),
new NativeRasterizerFactory(), // supplies raster fallback if/when a page needs it; harmless otherwise
loggerFactory: null); // ILoggerFactory optional
var result = await converter.ConvertAsync(
"document.pdf",
new SingleFileOutputWriter(), // packaging: see the table below
new ConversionOptions { Dpi = 150, Pages = PageRange.Parse("1-20") });
Console.WriteLine($"{result.PageCount} pages → {result.OutputFiles[0]} " +
$"({result.Warnings.Count} warnings, {result.Elapsed.TotalSeconds:0.0}s)");
The native variant is new PdfToHtmlConverter(loggerFactory) with the same ConvertAsync
call — use it only when you specifically want native extraction.
Output writers (IOutputWriter)
| Writer | Produces |
|---|---|
SingleFileOutputWriter |
One self-contained .html (fonts/images/SVG inlined as data URIs) |
MultiPageOutputWriter |
A folder: per-page page-NNNN.html + shared assets/ (fonts, CSS) + index.html |
ViewerOutputWriter |
One self-contained HTML app: bookmarks/TOC sidebar, page nav, zoom |
result.OutputFiles lists every file written (in page order for the multi-page writer).
Dependency injection
using DynDyn.Pdf.Engine;
services.AddDynDynPdf(); // IPdfToHtmlConverter, stateless singleton, fully-managed engine
AddDynDynPdf() lives in DynDyn.Pdf.Engine and registers the fully-managed
ManagedExtractionEngine. With nothing else registered, conversions run with zero native
dependencies and every page keeps its vector background.
Raster fallback is opt-in: register an IPageRasterizerFactory as well, and the converter picks it
up automatically.
using DynDyn.Pdf.Engine;
using DynDyn.Pdf.Native; // optional package
services.AddDynDynPdfNativeRasterizer(); // pdfium-backed raster fallback
services.AddDynDynPdf();
Order does not matter — AddDynDynPdf() resolves the factory from the provider at construction
time, so a deployment that simply omits the native package (or publishes with
-p:NativeAssets=false) degrades to the vector background instead of failing.
IPdfToHtmlConverter is stateless and safe for concurrent use — a single instance can serve
parallel conversions.
Progress & cancellation
ConvertAsync reports ConversionProgress between pipeline steps and honors a CancellationToken:
var progress = new Progress<ConversionProgress>(p =>
Console.Write($"\r{p.Stage}: {p.PagesExtracted}/{p.TotalPages} extracted, " +
$"{p.PagesComposed}/{p.TotalPages} composed ({p.Fraction:P0})"));
await converter.ConvertAsync("doc.pdf", writer, options, progress, cts.Token);
ConversionProgress fields: TotalPages, PagesExtracted, PagesComposed, Stage, and
Fraction (phase-weighted [0,1], advances monotonically across extract/fonts/compose/write).
Result types
public sealed record ConversionResult(
int PageCount,
IReadOnlyList<string> OutputFiles,
IReadOnlyList<ConversionWarning> Warnings,
TimeSpan Elapsed);
public readonly record struct ConversionWarning(int? PageIndex, string Message, WarningCode Code = WarningCode.Unspecified)
{
public WarningSeverity Severity => Code.Severity(); // derived from Code, never set independently
}
Warnings are non-fatal problems (e.g. a font that failed validation and was embedded raw). They are logged and returned — never embedded in the output HTML.
Message stays human-readable free text (it interpolates font names, page counts, exception
messages) and may change wording between releases — do not pattern-match it. Code (a WarningCode
enum member, e.g. PageExtractionFailed, FontEmbeddingRestricted, ShadingSkipped,
FontCharacterCodeUnmapped) is the stable
classifier for programmatic consumers; Severity is derived from Code and is one of:
| Severity | Meaning |
|---|---|
Info |
Nothing was lost — an intended default or deliberate substitution (unknown fsType assumed embeddable, a licence-restricted font not embedded on purpose). |
Degraded |
Content rendered, but approximated (a Type 3 glyph drawn with a substitute font, a mesh shading truncated to its triangle budget, structure nesting cut off while the text itself is kept). |
Loss |
Content was dropped and is not represented anywhere in the output (a page that failed to extract or compose, pages truncated past MaxPages, links or annotations dropped entirely). An unclassified (Unspecified) code also maps to Loss, fail-safe. |
Batch conversion (BatchConverter)
Convert many PDFs concurrently with per-document failure isolation:
using DynDyn.Pdf.Core;
var batch = new BatchConverter(
converterFactory: () => new DocumentConverter(new ManagedExtractionEngine(),
new NativeRasterizerFactory(), loggerFactory),
loggerFactory);
BatchResult result = await batch.ConvertAsync(
inputPaths: ["a.pdf", "b.pdf", "c.pdf"],
outputWriterFactory: _ => new SingleFileOutputWriter(), // a FRESH writer per document
options: new ConversionOptions { Dpi = 120 },
batchOptions: new BatchOptions { MaxConcurrentDocuments = 8 });
Console.WriteLine($"{result.Succeeded}/{result.TotalDocuments} ok, " +
$"{result.TotalPages} pages in {result.Elapsed.TotalSeconds:0.0}s");
foreach (DocumentConversionResult doc in result.Documents) // INPUT order (deterministic)
{
Console.WriteLine(doc.Success
? $"ok {doc.InputPath}: {doc.Result!.PageCount} pages"
: $"FAIL {doc.InputPath}: {doc.Error}");
}
Contract:
converterFactorymust return a NEW converter per call — each document gets its own engine/reader/font registry, so documents share no mutable state.outputWriterFactoryis called once per input — return a fresh writer each time.- One document faulting never sinks the batch: its slot is recorded as a failure (
Success = false,Errorset) and the rest continue. Only cancellation aborts the whole run. Documentsis in input order, not completion order, so aggregates are deterministic.
BatchOptions:
| Property | Default | Meaning |
|---|---|---|
MaxConcurrentDocuments |
max(1, cores / 2) |
How many PDFs convert at once |
PerDocumentParallelism |
null → max(1, cores / MaxConcurrentDocuments) |
Overrides each document's internal parallelism so jobs × per-doc never oversubscribes the CPU |
BatchResult exposes TotalDocuments, Succeeded, Failed, TotalPages, TotalWarnings,
OutputFiles (across all succeeded documents, input order), and Elapsed.
Multi-document parallelism is the ~1.35x throughput lever for the managed engine and depends on Server GC being enabled.
ConversionOptions reference
Immutable record; construct with an object initializer or with. Full table (with the CLI flag
that sets each) is in configuration.md. Summary:
| Property | Type | Default | Notes |
|---|---|---|---|
Dpi |
int |
120 |
CSS pixel scale = Dpi / 72. Clamp 18–600 in front-ends |
OutputDirectory |
string? |
input's dir | Where output is written |
Pages |
PageRange |
PageRange.All |
PageRange.Parse("1-5,8,10-") |
LineGrouping |
LineGroupingMode |
Always |
Always / Auto / Never |
LineGroupingOptions |
LineGroupingOptions |
tuned defaults | Vertical/horizontal/font-size join tolerances |
Background |
BackgroundMode |
Svg |
Svg / Raster / None |
RasterFallback |
RasterFallback |
Auto |
Never / Auto / Always; no-op without a rasterizer |
SemanticStructure |
bool |
false |
Tagged-PDF <p>/headings/lists/tables via display:contents |
FontEmbedding |
FontEmbedding |
Woff1 |
Woff1 / RawTtf (debug) |
MaxImageDimension |
int |
4096 |
Cap for resampled background images (device px/axis) |
JpegQuality |
int |
85 |
Opaque background image quality (1–100) |
MaxDegreeOfParallelism |
int |
ProcessorCount |
Managed post-processing threads |
Deterministic |
bool |
false |
Byte-reproducible output (fixed font timestamps/ordering) |
Scale |
double |
derived | Read-only: Dpi / 72.0 |
REST API
The DynDyn.Pdf.Web service exposes a small HTTP API plus a bundled upload page at /. Content type
is JSON unless noted. See deployment.md for hosting, and server.md for
a shorter delivery-form overview of the same service.
Job lifecycle
POST /api/jobs (multipart: file + options) ──▶ { "jobId": "…", "token": "…" }
│
├── GET /api/jobs/{id}/events?token=… (SSE) ──▶ progress… → done | error
├── DELETE /api/jobs/{id}?token=… cancel an in-flight/queued job
├── GET /api/jobs/{id}/download?token=… the HTML (or a .zip for per-page output)
└── GET /api/jobs/{id}/preview?token=… inline HTML preview (single/viewer only)
A job is created, run on a background task, and its progress/terminal state is streamed over SSE.
Results live on the instance's local disk keyed by jobId (in-process registry — no persistence
across restarts, no cross-instance sharing; see deployment Scaling).
Job ownership tokens
POST /api/jobs hands back a token alongside the jobId, and it is the only time the token is
returned. Every other route for that job — events, download, preview, and the cancelling
DELETE — requires the same token as a ?token= query parameter, and answers 403 Forbidden if
it is missing or doesn't match.
This is what stops one caller from reading, downloading, or cancelling another caller's job just by
guessing or observing a jobId (job ids are not treated as secret; the token is). It matters most
for anonymous/open deployments (DynDynPdf:AllowAnonymous=true) where many unrelated callers share
one API key or no key at all — the token, not the API key, is what separates their jobs from each
other.
If a caller loses the token, the job is unrecoverable through the API: there is no lookup or reissue
endpoint. The job still runs to completion (or its timeout) and is evicted normally by
DynDynPdf:JobTtlMinutes; the caller must submit a new POST /api/jobs to get a fresh jobId and
token.
POST /api/jobs — create a job
Content-Type: multipart/form-data.
| Field | Required | Values | Default | Maps to |
|---|---|---|---|---|
file |
yes | the PDF (binary) | — | input document (max 1 GB) |
engine |
no | managed, native |
managed |
extraction engine |
format |
no | single, pages, viewer |
viewer |
output packaging |
rasterFallback |
no | never, auto, always |
auto |
ConversionOptions.RasterFallback |
background |
no | svg, raster, none |
svg |
ConversionOptions.Background |
dpi |
no | integer | 120 |
clamped 18–600 server-side |
pages |
no | e.g. 1-5,8,10- |
all | PageRange (invalid → all) |
lineGrouping |
no | always, auto, never |
always |
ConversionOptions.LineGrouping |
semanticTags |
no | true/on/1 |
off | ConversionOptions.SemanticStructure |
detectDecorative |
no | true/on/1 |
off | ConversionOptions.DetectDecorative — detect running headers/footers and page numbers and emit them as non-selectable decorative text (text stays live; pixels unchanged) |
extractStyles |
no | true/on/1 |
off | ConversionOptions.ExtractStyles — deduplicate repeated style declarations into short generated CSS classes (smaller output; off = byte-identical output) |
jpegQuality |
no | integer | 85 |
ConversionOptions.JpegQuality, clamped 1–100 server-side |
rawTtf |
no | true/on/1 |
off | ConversionOptions.FontEmbedding — true selects RawTtf (debug), otherwise Woff1 |
password |
no | string | none | ConversionOptions.Password — user password for an encrypted PDF. Managed engine only |
Responses:
200 OK→{ "jobId": "<32-hex>", "token": "<opaque>" }— see Job ownership tokens400 Bad Request→{ "error": "expected multipart/form-data" },{ "error": "no PDF uploaded" }, or{ "error": "uploaded file is not a PDF (missing %PDF- header)" }(the upload's first 5 bytes are checked against the PDF magic header; this is a cheap sniff, not a parse — it does not validate the rest of the document)413 Payload Too Large→{ "error": "uploaded file exceeds the maximum allowed size (<n> bytes)" }when the upload exceedsDynDynPdf:MaxUploadBytes(default 1 GB)503 Service Unavailable→{ "error": "server busy: too many active jobs" }when the instance is at its configured concurrent-job/live-job limit
curl -sS -X POST http://localhost:8080/api/jobs \
-F file=@report.pdf \
-F format=viewer -F engine=managed -F dpi=150 -F pages=1-20
# {"jobId":"3f2c…","token":"9a1e…"}
`GET /api/jobs/
Content-Type: text/event-stream, Cache-Control: no-cache. Each SSE data: line is a JSON
object. The stream ends after a terminal done or error event. 404 if the job id is unknown,
403 if token is missing or doesn't match the job's ownership token.
If the client disconnects before the job reaches a terminal state (tab closed, navigation, lost connection), the server cancels the job rather than running it to completion or its timeout with nobody left to read the result.
progress (zero or more):
{ "type": "progress", "stage": "extract", "total": 20,
"extracted": 7, "composed": 3, "fraction": 0.42 }
done (terminal, success):
{ "type": "done", "engine": "managed", "pages": 20, "warnings": 0, "elapsed": 4.2,
"downloadUrl": "/api/jobs/3f2c…/download?token=9a1e…",
"previewUrl": "/api/jobs/3f2c…/preview?token=9a1e…",
"downloadName": "report.html" }
downloadUrl and previewUrl already carry the job's ?token=, so a client can follow them
directly without re-attaching it. previewUrl is null for the pages format (a zip has no inline
preview). engine is the engine this job was configured to run (managed or native, from the
request's engine field, normalized) — not an independent verification that engine actually
produced the output; conversion fails rather than silently substituting a different engine.
error (terminal, failure):
{ "type": "error", "message": "…" }
A distinct code is set on some failures so the client can react instead of showing a generic
error:
code |
When |
|---|---|
password_required |
The PDF is encrypted and no password field was sent, or it was wrong |
cancelled |
The job was cancelled — via DELETE /api/jobs/{id}, or the client's SSE connection dropped before the job finished |
A conversion that exceeds DynDynPdf:ConversionTimeoutSeconds also ends in an error event, with
an additional "timedOut": true field instead of a code:
{ "type": "error", "message": "conversion timed out after 600s", "timedOut": true }
Consume with the browser EventSource API:
const es = new EventSource(`/api/jobs/${jobId}/events?token=${token}`);
es.onmessage = (e) => {
const evt = JSON.parse(e.data);
if (evt.type === "progress") updateBar(evt.fraction);
else { es.close(); if (evt.type === "done") window.location = evt.downloadUrl; }
};
`DELETE /api/jobs/
Cancels an in-flight or still-queued job and immediately frees the concurrency slot it holds, instead of letting it run to completion or its timeout.
200 OK→{ "cancelled": true }404 Not Found→ unknown job id403 Forbidden→tokenmissing or doesn't match
`GET /api/jobs/
Returns the converted file with Content-Disposition set to downloadName:
single/viewer→text/html; charset=utf-8pages→application/zip(the whole per-page folder, named<doc>.html.zip)
404 if the job is unknown or hasn't produced output yet. 403 if token is missing or doesn't
match.
`GET /api/jobs/
Serves the single/viewer HTML inline (text/html) for embedding in an <iframe>. 404 for the
pages format or an unfinished/unknown job. 403 if token is missing or doesn't match.
GET /health and GET /ready
Never gated by API-key auth (static assets and these two probes are the only unguarded routes).
GET /health→200 { "status": "ok" }once the process is up; no dependency checks.GET /ready→200/503 { "status": "ready" | "unavailable", "nativeRenderer": bool, "nativeRasterizer": bool, "tempWritable": bool, "liveJobs": int }.nativeRendererreports whether the pdfium native library loaded (gatesengine=native);nativeRasterizerreports whether raster fallback can actually run — it needs pdfium and SkiaSharp (PNG encoding) loaded independently, so a platform can have one without the other.tempWritablefailing is what flipsstatustounavailableand the HTTP status to503; the native flags are reported, not gating, since the default managed + SVG pipeline needs neither.
Authentication
/api/* is gated by API-key middleware (ApiKeyAuthMiddleware), controlled by
DynDynPdf:ApiKeys and DynDynPdf:AllowAnonymous (see deployment.md):
- No keys configured (default),
AllowAnonymous=false(default): only loopback callers (127.0.0.1/::1) are served; every other caller gets401. Safe default for local/dev use. - No keys configured,
AllowAnonymous=true: the API is open to anyone who can reach it — intended for deployments that terminate auth in front of the app (reverse proxy / gateway). ApiKeysset (comma-separated): every request must present one of those keys via theX-Api-Keyheader or anapiKeyquery parameter (the query form exists soEventSourceand plain download/preview links can authenticate);AllowAnonymousis then ignored.
/api/* also carries a per-key / per-IP fixed-window rate limit (DynDynPdf:RateLimitRequestsPerMinute,
default 120/min) — partitioned on the validated API key when keys are configured, on remote IP
otherwise; never on a client-supplied value that was never checked. Every conversion still applies
the output-safety posture in security.md regardless of who calls it.