Help — how to use mdseal
1. Supply the document
- Paste your Markdown (or HTML) into the text area; or
- Upload a
.mdor.htmlfile — it then replaces the text, and its type is inferred from the extension.
2. Choose the output format
| Format | Result |
|---|---|
| PDF (A4) | Paginated A4 document, themed — from Markdown or HTML. |
| Slide deck PDF (16:9) | One slide per page; separate slides with a --- line. |
| PowerPoint (PPTX) | Editable presentation; expects Markdown (## headings open the slides). |
| HTML preview | Shows the themed document in the browser, with no download. |
3. Choose a theme — palette.amia.fr
The themes on offer (mdseal, carbon, dark…) are derived from
palette.amia.fr — the Palette Studio, the single
source of the palettes across the @mostajs/* ecosystem. Each palette is composed there
from a handful of brand colours, and then every useful colour is derived and checked for contrast
(WCAG): no unreadable colour is ever produced (the accent never carries text).
To add or adjust a theme: compose it on palette.amia.fr
(desktop and mobile previews, contrast table), take its brand colours, then add its entry to the
@mostajs/palette store (GALLERY). mdseal will offer it automatically.
The theme applies to the PDF and to the HTML preview. PPTX follows a PowerPoint template, not the CSS.
4. Sign a document, and protect it
Two paths, one and the same core: the Sign the PDF produced box at the foot of the converter, and the Sign screen, which takes a PDF that is already made and seals it as it stands.
The certificate
A PKCS#12 certificate (.p12 / .pfx) and its passphrase. The one you
upload wins; failing that, mdseal uses the server’s own if one is configured. The key lives
in memory only for the duration of the signature: it is neither stored nor logged, and the
passphrase never travels through the engine’s command line.
Protection — what it prevents, and what it does not
⚠ Read this before ticking. PDF permissions are instructions, not locks. They bind the readers that honour them — Acrobat and every serious one — and nothing else. A document sealed “no printing, no copying” prints and copies without difficulty in a tool that chooses to ignore them, and the PDF format offers no way to prevent it. What follows describes what mdseal writes into the document, not what it guarantees.
Ticking Encrypt (AES-256) and restrict attaches permissions to the document, which you set box by box. The table below gives the state by default:
| Permission | Default |
|---|---|
| Printing (high resolution) | refused |
| Printing (low resolution) | refused |
| Text extraction (copy and paste) | refused |
| Content modification | refused |
| Annotations and comments | refused |
| Assembly (pages) | refused |
| Reading by a screen reader | allowed |
| Filling form fields | allowed — and cannot be refused |
A ticked box means allowed, never “forbidden”: that is what the PDF format means, and what
any verifier will display — unticking “Printing” reads as print_highres : False.
Accessibility stays open on purpose: protecting a text against copying is no reason to close it to
someone who cannot see.
Allowing high-resolution printing also allows plain printing: the format ignores the “high quality” bit when printing is refused.
⚠ Filling form fields cannot be refused, and that is not an oversight: in PDF, a signature field is a form field, so sealing requires that permission. Consequence worth knowing: a PDF that already had fillable fields keeps them fillable. The seal itself detects any change — that is where the guarantee lies, not in the permission.
The DATE — what is attested, and by whom
A seal proves the document has not changed. It does not prove when it was sealed: by default the time is an attribute declared by the signer — and therefore backdatable. That is why mdseal has the moment attested by an independent timestamping authority (RFC 3161), which moves the signature from PAdES-B to PAdES-T.
⚠ Two levels of timestamp, and the gap is not one of degree. The European eIDAS Regulation (Article 41) distinguishes:
| level | what the date is worth | who must prove |
|---|---|---|
| qualified | legal presumption of accuracy | whoever disputes it |
| simple | admissible, no presumption | whoever produces the document |
| none | declared by the signer | — backdatable |
A qualified timestamp can only be obtained from a provider on the EU Trusted List.
GET /api/v1/moi returns signature.horodatage.niveau: ask for it before
committing to your own customers, and never announce more than it answers.
⚠ If the timestamping authority does not answer, sealing fails — there is no fallback. A document you believed timestamped and which is not would only reveal itself at the moment you relied on it.
The open password — the only hard lock, and it locks opening only
The Open password field changes the nature of the protection. Once filled in, the document opens in no reader at all without it: this is no longer an instruction one honours, it is encryption one does not get around. Left empty, the document stays freely readable, and its restrictions rest on the reader’s goodwill.
Entering a password switches protection on: the permissions shown then apply. mdseal does not keep it — lose it and the document is lost with it. There is no recourse, and that is precisely what makes it worth something.
⚠ What the password changes, and what it does not. Without it the document opens for everyone, and the restrictions rest on the reader’s goodwill. With it, nobody opens it without knowing it: that part is real encryption, and it is not got around. But once the document is open, the permissions are instructions again — whoever holds the password can extract the text and save a copy unencrypted and fully allowed, in a single command. The password protects access, never use.
So the only question worth asking is “who do I give the password to”. To stop a legitimate recipient from printing or copying, the PDF format offers nothing — not here, not anywhere, and be wary of anyone who claims otherwise. What is genuinely enforceable in a document sealed by mdseal is the signature: it hides nothing, but it proves the document has not changed since the seal, and on what date.
Two ways to seal — and they do not prove the same thing
mdseal seals in two ways. The difference is not technical: it is about what the document proves.
With the service certificate — nothing to upload. The document is sealed, any alteration breaks it, and it carries a date attested by an independent timestamping authority. What it does not prove: your identity. The service certificate has been verified by no third party, and a PDF reader will show “validity unknown”.
With YOUR certificate — you supply your .p12 file and its passphrase, on screen or through the API. The seal is then attributed to you, and you keep control of it. This is the path with legal weight: it is your organisation sealing, not ours.
Which to choose? For an internal document, a draft, a convenience send: the service certificate is enough. For a certificate, a diploma, a report you will rely on against someone: yours.
Where to get one? From a certification authority, as an organisation seal certificate — not the same use as a website certificate. Expect a few hundred euros a year and a verification of your organisation’s identity.
⚠️ Your passphrase only opens the file for the duration of the sealing. Neither it nor the certificate is retained.
5. Without a browser — the same conversion on the command line
This site is an interface. The converter itself is a module you can install:
npm i -g @mostajs/doc-export
doc-export report.md -o report.pdf # themed A4 PDF
doc-export course.md -f slides-pdf -t carbon # 16:9 slide deck
cat doc.md | doc-export -f html -o - # in a pipe
doc-export --capacites # what is available here
Same core, therefore the same document: this page and that command both call the same
@mostajs/doc-export. The command serves what a browser cannot — a whole folder at once,
a continuous-integration job, a machine with no network.
⚠ One difference only, and it is deliberate. This site queries the Studio live;
the command reads the gallery bundled in @mostajs/palette. A theme retouched this
morning shows up here at once, and on the command line after the module is updated. The text is the
same; the colours may be a day apart.
⚠ PDF ⇒ Chrome/Chromium, PPTX ⇒ pandoc. When they are missing the command says so and stops: it never quietly hands back a degraded document.
The signature follows the same rule and has its own command, for the same reason — this site holds no privilege over it:
npm i -g @mostajs/pdf-sign
mosta-pdf-sign sign report.pdf signed.pdf --p12 cert.p12 --pass ••• --restrict \
--open-password ••• # the hard lock
mosta-pdf-sign verify signed.pdf --trust ca.pem # intact? valid? trusted?
mosta-pdf-sign engines # can this server protect?
6. From a program — the HTTP API
The screens serve a person. The API serves a system: an invoice on each order, a report every night, a statement on every contract signed. It goes through the same engine as this page — same theme, same document.
Every call presents a key in the Authorization header. There is no public route
and no key in the URL: a secret that travels in an address ends up in the server logs, in the browser
history, and in the Referer sent to third parties.
| Call | What it does |
|---|---|
GET /api/v1/moi | Checks the key and says what it carries, what this server can do, and its size limit. Consumes no conversion. |
POST /api/v1/convert | Converts — and seals in the same pass if the sign block is supplied. |
POST /api/v1/sign | Seals a PDF that is already made. |
Convert
curl -X POST https://mdseal.dev/api/v1/convert \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/json" \
-d '{"source":"# Report\n\nText.","format":"pdf","themeId":"mdseal"}' \
-o report.pdf
| Field | Values | Default |
|---|---|---|
source | the Markdown or the HTML — required | — |
inputType | md · html | md |
format | pdf · slides-pdf · pptx · html | pdf |
themeId | a theme from the list returned by /api/v1/moi | hadhinat |
filename | the name of the file returned | document |
sign | signature block (below) — needs a key carrying sign | absent |
Sign, and protect
Two entry points, depending on where you call from — the raw PDF with options as URL parameters, or JSON with the PDF in base64:
curl -X POST "https://mdseal.dev/api/v1/sign?proteger=1&motdepasse=•••&reason=Original" \
-H "Authorization: Bearer sk_live_…" \
-H "Content-Type: application/pdf" \
--data-binary @report.pdf -o report-signed.pdf
The sign block (in /convert) and the parameters (in /sign)
carry the same names: proteger, motdepasse, permissions,
p12 (base64) and passphrase, reason, name,
location.
⚠ Two rules carried over verbatim from the screens, so that the API and the browser
return the same document. A password amounts to a request for protection: supplying it is
enough, without proteger. And a permissions block, once supplied, is a
closed list — whatever is not in it is refused, never left to the engine. Block absent, the
defaults from §4 apply.
The quotas
Two resources are counted, over two different periods — and that is not a whim. A conversion is frequent and cheap: bounded by the day, it protects the server without getting in anyone’s way. A signature is rare, costly to run, and it is what you buy: bounded by the month, like the plan — so that a busy day does not block you.
| Plan | Conversions / day | Signatures / month | Keys |
|---|---|---|---|
| Free | 20 | none | 1 |
| Pro | 500 | 200 | 3 |
| Enterprise | unlimited | 2,000 | 10 |
GET /api/v1/moi returns quotas.conversion and quotas.signature
with courant, limite and restant: enough to stop before the
refusal rather than discover it in the middle of a processing chain. That call consumes nothing.
A call that fails is not counted: it returned nothing. And the quota is checked before the rendering and before the certificate — whatever will be refused at the end is refused straight away, without spending three seconds of engine time.
What the API returns
On success, the file, with its type and its name. On failure, JSON — never an HTML
page: {"error":{"code":"…","message":"…"}}.
| Code | What it means |
|---|---|
401 UNAUTHORIZED | Key missing, invalid, revoked or suspended. All four answer in the same words, deliberately. |
403 FORBIDDEN | The key is good but does not carry this operation. |
403 PLAN_REQUIRED | Your plan does not include this operation. No amount of waiting will change that — which is why this answer carries no Retry-After. |
429 QUOTA_EXCEEDED | Allowance used up. The answer carries Retry-After (seconds) and states courant, limite and periode. |
400 BAD_REQUEST | Malformed request — the message says what is wrong. |
413 PAYLOAD_TOO_LARGE | Document beyond the limit (see limiteCorpsMo in /api/v1/moi). The answer closes the connection: shrink the payload, do not retry it unchanged. |
415 | Content type not served by this call. |
422 SIGNATURE_FAILED | The conversion succeeded, the signature failed — certificate or passphrase. |
Signing is served only if this server carries it. /api/v1/moi says so before
any call: signature.disponible, signature.motDePasseOuverture,
signature.certificatServeur.
7. Plugging mdseal into your software
Three ways to call the same door. The examples point at this server — copy them as they are.
The key is created from your account area and is shown once only. Keep it in an environment variable, never in the code: a key committed to a repository is a key to revoke.
curl
# Convertir un Markdown en PDF
curl -X POST https://mdseal.dev/api/v1/convert \
-H "Authorization: Bearer $MDSEAL_CLE" \
-H "Content-Type: application/json" \
-d '{"source":"# Devis n°42\n\nMontant : 1 200 €","format":"pdf"}' \
-o devis.pdf
# Convertir ET sceller en un seul appel
curl -X POST https://mdseal.dev/api/v1/convert \
-H "Authorization: Bearer $MDSEAL_CLE" \
-H "Content-Type: application/json" \
-d '{"source":"# Attestation","format":"pdf","sign":{"reason":"Attestation","name":"Service RH"}}' \
-o attestation-signee.pdf
# Sceller un PDF qu'on a déjà
curl -X POST https://mdseal.dev/api/v1/sign \
-H "Authorization: Bearer $MDSEAL_CLE" \
-H "Content-Type: application/json" \
-d "{\"pdf\":\"$(base64 -w0 facture.pdf)\",\"filename\":\"facture\"}" \
-o facture-signee.pdf
# Ce qu'il reste de quota
curl -H "Authorization: Bearer $MDSEAL_CLE" https://mdseal.dev/api/v1/moi
TypeScript
const CLE = process.env.MDSEAL_CLE!;
export async function sceller(markdown: string, motif: string): Promise<Buffer> {
const r = await fetch('https://mdseal.dev/api/v1/convert', {
method: 'POST',
headers: { authorization: `Bearer ${CLE}`, 'content-type': 'application/json' },
body: JSON.stringify({ source: markdown, format: 'pdf', sign: { reason: motif } }),
});
// Les deux refus ne se traitent PAS pareil, et c'est tout l'intérêt de les distinguer :
// 403 — l'offre ne comprend pas l'opération. Réessayer n'y changera rien.
// 429 — le quota est atteint, mais il revient : Retry-After dit quand.
if (r.status === 403) throw new Error('Offre insuffisante — changez de plan.');
if (r.status === 429) {
const secondes = Number(r.headers.get('retry-after') ?? 3600);
throw Object.assign(new Error('Quota atteint.'), { reessayerDans: secondes });
}
if (!r.ok) {
const { error } = await r.json() as { error: { code: string; message: string } };
throw new Error(`${error.code} — ${error.message}`);
}
return Buffer.from(await r.arrayBuffer());
}
Two refusals, two treatments — the point most integrations miss. 403 means “your plan does not allow it”: retrying changes nothing. 429 means “your quota is used up, it comes back”: the Retry-After header says when. Confusing the two makes a client loop on a permanent refusal, or drop a document it merely had to wait for.
Java
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
public final class Mdseal {
private static final HttpClient CLIENT = HttpClient.newHttpClient();
private final String cle;
public Mdseal(String cle) { this.cle = cle; }
public byte[] sceller(String markdown, String motif) throws Exception {
String corps = """
{"source": %s, "format": "pdf", "sign": {"reason": %s}}
""".formatted(json(markdown), json(motif));
HttpRequest requete = HttpRequest.newBuilder()
.uri(URI.create("https://mdseal.dev/api/v1/convert"))
.header("Authorization", "Bearer " + cle)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(corps))
.build();
HttpResponse<byte[]> r = CLIENT.send(requete, HttpResponse.BodyHandlers.ofByteArray());
// 403 : l'offre ne le permet pas — définitif. 429 : le quota revient — Retry-After dit quand.
if (r.statusCode() == 403) throw new IllegalStateException("Offre insuffisante.");
if (r.statusCode() == 429) {
String delai = r.headers().firstValue("retry-after").orElse("3600");
throw new IllegalStateException("Quota atteint, réessayer dans " + delai + " s.");
}
if (r.statusCode() != 200) throw new IllegalStateException("mdseal " + r.statusCode());
return r.body();
}
/** Échappement JSON minimal — une bibliothèque JSON fait mieux, ceci évite d'en imposer une. */
private static String json(String s) {
return '"' + s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + '"';
}
}
And here is what it looks like inside an application that already invoices:
// Dans une application qui facture déjà : la facture existe, on la scelle avant de l'envoyer.
//
// Le point important n'est pas l'appel — c'est ce qu'on fait du refus. Un 429 n'est pas une
// panne : c'est une file d'attente. Le traiter comme une erreur ferait perdre la facture.
import { sceller } from './mdseal.js';
app.post('/factures/:id/envoyer', async (req, res) => {
const facture = await depot.lire(req.params.id);
let pdf;
try {
pdf = await sceller(facture.markdown, `Facture ${facture.numero}`);
} catch (e) {
if (e.reessayerDans) {
// Le quota revient : on remet la facture en file plutôt que de la perdre.
await file.reprendre(facture.id, { dans: e.reessayerDans });
return res.status(202).json({ etat: 'en attente de quota' });
}
throw e; // 403 ou panne réelle : ça remonte, et quelqu'un doit le voir.
}
await courriel.envoyer(facture.client, { piece: pdf, nom: `facture-${facture.numero}.pdf` });
await depot.marquerEnvoyee(facture.id);
res.json({ etat: 'envoyée' });
});
The point is not the call — it is what you do with the refusal. A 429 is not a failure, it is a queue.
Tips
---= slide break (deck mode).- Page break in a PDF: put it on the heading —
<h2 style="page-break-before: always;">Title</h2>— never on an empty<div>. - Arabic is detected and rendered right-to-left automatically. Bare URLs become clickable.
Privacy
Documents are converted on this sovereign server (PDF by Chrome/Chromium, PPTX by pandoc): nothing is sent to any third-party service.