CreativePDF · API v1
Editar um PDF que já existe
Sem redesenhar a página, sem reencodar imagem, sem trocar a fonte por uma parecida. O que você não editou volta byte a byte — e isso é verificável de fora, não uma promessa.
OpenAPI 3.1 llms.txt · para agentes Editor no navegadorO que esta API é
Uma API para editar um PDF que já existe, preservando tudo o que não foi editado. Não é uma API de gerar PDF; há dezenas dessas, e nenhuma delas é este produto.
O motor foi escrito do zero — parser, codecs, motor de fontes, rasterizador, reconstrução de estrutura e writer. Nenhuma biblioteca de PDF entra nele. Isso importa para você por um motivo prático: quando a API recusa alguma coisa, ela sabe dizer exatamente o quê e por quê.
Esta API recusa em vez de degradar em silêncio.
Um serviço que troca a fonte por uma parecida devolve 200 e um
documento errado — e você descobre no cliente do seu cliente. Aqui, um
caractere que a fonte do documento não tem devolve 409 com a
lista do que falta e o que fazer para destravar.
As garantias
São cinco, e são o produto. Todo o resto existe para servi-las.
| # | Garantia | Como você confere |
|---|---|---|
| P1 | Nada intocado muda. Objeto não editado é reemitido byte a byte. | Baixe ver_1 e compare com o arquivo que você subiu. |
| P2 | Imagem nunca é reencodada. O XObject original é copiado verbatim. | Compare os bytes da imagem antes e depois. |
| P3 | O reflow é escopado. Editar um trecho acomoda aquele trecho, nunca recompõe a página. | Renderize as duas versões e compare: só a faixa editada muda. |
| P4 | Fonte certa ou nada. Glifo ausente é acrescentado à fonte embarcada, ou a chamada recusa. | Peça um caractere que o documento não tem e veja o 409. |
| P5 | O texto vetorial é a verdade. Em PDF nascido digital, o content stream ganha do OCR sempre. | — |
Rodar o servidor
A API é um binário que você hospeda. Não há endpoint público: os documentos são seus e ficam na sua máquina.
cpdf_server --config config.json
{
"port": 8787,
"data_dir": "/var/lib/creativepdf",
"retention_seconds": 86400,
"tenants": [
{ "id": "acme", "key": "troque-isto" }
]
}
| Chave | O que faz |
|---|---|
data_dir | onde o acervo mora. Sem ele nada sobrevive ao reinício. |
retention_seconds | apaga o que passou disso, na partida. 0 guarda para sempre. |
tenants | sem nenhum, qualquer um que alcance a porta manda. |
max_upload | teto do corpo de um pedido. Padrão 64 MiB. |
async_above | acima disso um POST vira job. Padrão 25 MiB. |
A chave de API só passa pela configuração e não é guardada: do que chega guarda-se o SHA-256 e o prefixo — o bastante para dizer em log qual chave é, sem dizer qual é.
O servidor escuta só no laço local, de propósito, e não faz TLS. Ponha um nginx ou Caddy na frente. Expor para fora é decisão de quem implanta, não um padrão que alguém descobre depois.
Editar em cinco chamadas
O caminho inteiro, do arquivo que entra ao arquivo que sai.
BASE=http://localhost:8787
AUTH="Authorization: Bearer sua-chave"
# 1. sobe o PDF
DOC=$(curl -s -H "$AUTH" --data-binary @entrada.pdf \
$BASE/v1/documents | jq -r .id)
# 2. lê a estrutura da página e escolhe um trecho
curl -s -H "$AUTH" $BASE/v1/documents/$DOC/versions/ver_1/pages/1 \
| jq '.lines[] | {text, anchor}'
# 3. troca o texto dele (a âncora veio do passo 2)
curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' -d '{
"edits": [{
"anchor": "…",
"original": "Total: R$ 1.200,00",
"text": "Total: R$ 1.350,00"
}]
}' $BASE/v1/documents/$DOC/versions/ver_1/edits
# 4. confere o que mudou, em pixels
curl -s -H "$AUTH" \
"$BASE/v1/documents/$DOC/versions/ver_2/pages/1/render?dpi=150" -o depois.png
# 5. baixa o resultado
curl -s -H "$AUTH" \
$BASE/v1/documents/$DOC/versions/ver_2/file -o saida.pdf
A resposta do passo 3 traz o que o layout teve de fazer:
{
"version": "ver_2",
"parent": "ver_1",
"warnings": { "pushed": 0.000, "overflow": 0.000 }
}
pushed e overflow não são erro.
São a resposta honesta de que o texto novo empurrou um vizinho, ou não coube
na coluna, em pontos. Quem renderiza fatura quer saber disso antes do cliente
dele ver. Zero nos dois é o caso normal.
Chave, cotas, idempotência
Autenticação
Chave de API no cabeçalho, sempre:
Authorization: Bearer sua-chave
Cada inquilino enxerga só o que é dele. Pedir um documento de outro devolve
404 — e não 403, porque 403 confirmaria
a um estranho que o identificador que ele adivinhou existe.
Cotas
Toda resposta traz o estado da cota:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 118
Estourada, vem 429 com Retry-After. A janela é
deslizante de um minuto, e não um balde por minuto de relógio — o balde deixa
passar o dobro na virada, que é justamente quando um cliente com retentativa em
cascata bate.
Idempotência
Idempotency-Key: qualquer-coisa-unica
Repetir um POST com a mesma chave devolve a mesma resposta, com
Idempotency-Replayed: true, em vez de refazer o trabalho. Sem
isso, uma retentativa de rede aplica a edição duas vezes — e a segunda
escreveria sobre um texto que já não é o que a âncora conferiu. Guardado por 24
horas, e só quando deu certo: repetir um erro transitório o tornaria permanente.
Documento e versão
Um documento é imutável. Editar não muda o documento: cria uma versão.
documento doc_7f3a…
ver_1 o arquivo como entrou, byte a byte
ver_2 ver_1 + as edições do pedido X
ver_3 ver_2 + as edições do pedido Y
Três razões, e nenhuma é estética:
- O P1 fica verificável de fora. Baixe
ver_1e compare com o que subiu. Se um byte mudou, a garantia caiu — sem precisar confiar na nossa palavra. - A âncora ganha um chão. Uma edição aponta para bytes de um content stream, e esses bytes pertencem a uma versão. Sem versão nomeada, duas edições concorrentes escrevem uma por cima da outra em silêncio.
- A gravação já é incremental. O PDF acrescenta ao fim do arquivo; o original continua lá dentro. Versão não é cópia — é o que o formato já faz.
A âncora
Uma edição precisa dizer onde. A âncora é esse endereço: um token opaco que você recebe na estrutura da página e devolve na edição.
{
"text": "Evolução sobre o mesmo mês do ano anterior",
"anchor": "ZG9jX2t5M3o4cnpqazFsZTJ8dmVyXzF8MXww…"
}
Três propriedades, cada uma tapando um buraco diferente:
| Propriedade | Por quê |
|---|---|
| Opaca | Nunca a interprete. O conteúdo pode mudar sem quebrar você — é o que permite a compatibilidade da v1 ser uma promessa de verdade. |
| Assinada | Um token forjado viraria escrita num deslocamento arbitrário do arquivo. Com HMAC, forjar exige a chave do serviço. |
| Presa à versão | Âncora de ver_1 aplicada a ver_3 aponta para
bytes que já se moveram. Devolve 409 stale_anchor e
nunca aplica. |
Linha ou palavra
A estrutura traz âncora em cada linha e em cada palavra que corresponde a um trecho editável. A de palavra é o que permite trocar uma célula de uma fileira de tabela — que num PDF costuma sair de um operador só, com as colunas separadas por saltos de posição e não por espaços.
original é obrigatório. Ele é conferido contra
o que está no arquivo. Um índice deslocado não falha sozinho: escreveria no
lugar errado, em silêncio. Conferir custa uma comparação e transforma um erro
invisível numa recusa.
Estilo: escrever com uma fonte que o documento não tem
Pôr um trecho em negrito, trocar a família de um título, escrever numa língua cujo alfabeto o arquivo não traz — é tudo a mesma operação: usar um desenho que não está no documento. Ela tem dois passos.
# 1. a fonte entra na sua biblioteca, uma vez
POST {BASE}/v1/fonts
X-Font-Name: Graphik Bold
<bytes do .otf/.ttf>
→ 201 { "id": "font_…", … }
# 2. e o trecho passa a ser escrito com ela
POST {BASE}/v1/documents/{doc}/versions/{ver}/edits
{ "edits": [ { "anchor": "…", "original": "…", "text": "…",
"font": "font_…" } ] }
A fonte é instalada no documento como um recurso novo, com só os caracteres desse texto — não o arquivo de fonte inteiro. O resto da página continua na fonte que sempre teve, e as fontes que já estavam lá não são tocadas.
Isto não afrouxa o P4. Instalar é um pedido explícito, com um
arquivo de fonte que você escolheu. Nenhuma edição troca a fonte de um trecho
por conta própria — sem font, um caractere que a fonte da linha não
tem continua devolvendo 409 font_missing_glyph.
Quando falta um glifo
Medido no corpus deste projeto: 29% do que se digita em português não
desenha com a fonte que está no documento. Não é defeito — é como um
PDF funciona. O gerador embarca só os glifos que o documento já usava, então
digitar um ç que a página não tinha esbarra num glifo que não está
lá.
A API não disfarça. Ela recusa, e diz o que fazer:
{
"error": "font_missing_glyph",
"message": "a fonte deste trecho não tem estes caracteres",
"missing": "ãçé%",
"font": "F24",
"page": 3
}
missing traz todos os caracteres que faltam, não
o primeiro. Devolver um por vez faria você subir o arquivo da fonte uma vez por
caractere.
Destravar
# 1. instale um doador na sua biblioteca (uma vez)
FONT=$(curl -s -X POST -H "$AUTH" -H 'X-Font-Name: Calibri' \
--data-binary @calibri.ttf $BASE/v1/fonts | jq -r .id)
# 2. acrescente os glifos que faltam à fonte daquele recurso
curl -s -X POST -H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"donor\":\"$FONT\",\"page\":3,\"characters\":\"ãçé%\"}" \
$BASE/v1/documents/$DOC/versions/ver_1/fonts/F24/glyphs
# 3. repita a edição contra a versão nova
O doador precisa ser a mesma fonte, ou pelo menos ter a mesma escala de em: copiar contorno entre escalas perde precisão a cada ida e volta, e a API recusa em vez de aproximar. TrueType e CFF são aceitos, dentro ou fora de envelope OpenType.
A biblioteca é por inquilino e nunca compartilhada: licença de fonte é por licenciado, e um acervo comum faria do serviço um distribuidor de fonte de terceiros.
Trabalho adiado
Quase tudo responde em centenas de milissegundos. O que passa disso vira job —
por pedido explícito, ou porque o corpo passou de async_above.
curl -X POST -H "$AUTH" \
-H 'Prefer: respond-async' \
-H 'X-Webhook-Url: https://seu-servico/aviso' \
--data-binary @grande.pdf $BASE/v1/documents
# 202 Accepted
# Location: /v1/jobs/job_19qemmwbd2gq0
{ "job": "job_19qemmwbd2gq0", "state": "queued" }
curl -H "$AUTH" $BASE/v1/jobs/job_19qemmwbd2gq0
{
"job": "job_19qemmwbd2gq0",
"state": "succeeded",
"status": 201,
"result": { "id": "doc_aayf…", "pages": 3, … }
}
A rota adiada é a mesma rota, com um adiamento no meio: o job guarda o pedido inteiro e o repete. O resultado vem completo, não um ponteiro para ele.
Webhook
O aviso chega assinado. Confira antes de confiar:
X-CreativePDF-Signature: <HMAC-SHA256 hex do corpo>
{"job":"job_…","state":"succeeded","status":201,"finished_ms":1758…}
O instante entra dentro do corpo assinado — sem ele, um aviso capturado hoje continuaria válido para sempre.
Documentos
POST/v1/documents
Cria o documento. O corpo é o arquivo PDF, cru.
X-Filename | nome para exibição. Entra no JSON e nunca num caminho de arquivo. |
Idempotency-Key | repetir devolve o mesmo documento. |
Prefer: respond-async | devolve 202 e um job. |
{ "id": "doc_7f3a…", "filename": "entrada.pdf", "pages": 8,
"objects": 97, "pdf_version": "1.5",
"xref_recovered": false, "encrypted": false,
"versions": [ { "id": "ver_1", "bytes": 708758, "parent": "" } ] }
422 password_required quando o PDF é cifrado;
422 not_a_pdf quando não abre. A conferência acontece
antes de guardar: um documento que falha em toda chamada seguinte é
pior que uma recusa na hora.
GET/v1/documents/{doc}
Metadados e histórico de versões.
DELETE/v1/documents/{doc}
Apaga o documento e todas as versões, no índice e no disco. Retenção que deixa derivado para trás não é retenção.
GET/v1/documents/{doc}/versions
As versões, em ordem.
GET/v1/documents/{doc}/versions/{ver}/file
Baixa o PDF daquela versão. ver_1 é o arquivo como entrou.
Estrutura e prévia
GET/v1/documents/{doc}/versions/{ver}/pages/{n}
A página reconstruída: blocos, parágrafos com alinhamento e recuo, linhas, palavras, trechos com estilo, tabelas, réguas, imagens, as fontes usadas e o que cada uma sabe escrever — e cabeçalho/rodapé quando detectados.
{
"width": 595.32, "height": 841.92, "truncated": false,
"fonts": [ { "resource": "F1", "baseFont": "ABCDEE+Calibri",
"subtype": "TrueType", "embedded": true, "subset": true,
"bold": false, "italic": false, "family": "Calibri",
"metricCompatible": ["Calibri", "Carlito"] } ],
"lines": [ { "x": 85.1, "y": 682.7, "w": 427.7, "h": 13.3,
"size": 11.04, "font": 0, "align": "justificado",
"furniture": "", "text": "O Índice de Confiança…",
"anchor": "ZG9jX…",
"words": [ { "x": 85.1, "w": 8.2, "text": "O",
"anchor": "ZG9jX…" } ],
"spans": [ … ] } ],
"tables": [ … ], "images": [ … ], "paths": [ … ], "rules": [ … ]
}
furniture vale "repetido",
"numeração" ou "declarado" quando a linha é
mobiliário de página — cabeçalho, rodapé, número. É aprendido comparando as
páginas entre si: uma página sozinha não sabe o que é rodapé.
Linha sem anchor não é editável por esta API: ela foi desenhada
por mais de um operador, ou vem de um Form XObject usado em vários lugares do
documento — e trocar o texto dele mudaria todos.
GET/v1/documents/{doc}/versions/{ver}/pages/{n}/render
A página rasterizada, em PNG.
?dpi= | de 12 a 600. Padrão 72. |
É o mesmo rasterizador do editor no navegador — servidor e navegador produzem o mesmo pixel. Use para conferência automática: renderize a versão nova e a anterior e compare. O que mudou tem de ser só o que você pediu.
Edição
POST/v1/documents/{doc}/versions/{ver}/edits
{
"edits": [
{
"anchor": "ZG9jX…",
"original": "de 2020",
"text": "de 2027",
"font_size": 16,
"box_left": 191.5,
"box_right": 297.7,
"align": 2
}
]
}
| Campo | Obrigatório | O que faz |
|---|---|---|
anchor | sim | onde. Veio da estrutura da página. |
original | sim | o que estava ali, conferido antes de escrever. |
text | sim | o texto novo. |
font_size | não | corpo novo em pontos. Ausente mantém o da linha. |
font | não | identificador de uma fonte da sua biblioteca (POST /v1/fonts). Instala essa fonte no documento como recurso novo, com só os caracteres deste texto, e escreve o trecho com ela. É assim que negrito, itálico e troca de família chegam ao arquivo. |
box_left, box_right | não | a coluna em que o trecho vive. |
align | não | 0 desconhecido · 1 esquerda · 2 direita · 3 centro · 4 justificado. |
A caixa não é decoração. Com ela, um valor que cresce numa coluna alinhada à direita cresce para dentro do vazio que o documento já deixava, em vez de empurrar a coluna vizinha. Os valores vêm da própria estrutura da página.
Várias edições no mesmo pedido são aplicadas juntas. Se qualquer uma for recusada, nenhuma é gravada.
Fontes
GET/v1/fonts
A biblioteca do inquilino.
POST/v1/fonts
Instala um arquivo de fonte. O corpo é o arquivo; X-Font-Name dá o nome.
{ "id": "font_vk9v…", "name": "Graphik", "bytes": 73228,
"unitsPerEm": 1000, "glyphs": 75, "outlines": "cff" }
unitsPerEm é o que decide se a fonte serve de doadora para um
documento: ela tem de bater com a do subset embarcado.
DELETE/v1/fonts/{font}
POST/v1/documents/{doc}/versions/{ver}/fonts/{resource}/glyphs
Acrescenta glifos ao subset embarcado, tirando os contornos do doador, e
devolve uma versão nova. {resource} é o nome que a estrutura da
página reporta: F1, R13, TT0.
{ "donor": "font_vk9v…", "page": 3, "characters": "ãçé%" }
→ { "version": "ver_2", "added": 5 }
added pode ser maior que o número de caracteres: um
á costuma ser um glifo composto, que referencia o a
e o acento — e eles vão junto, ou o composto apontaria para o índice errado e
desenharia outra letra.
Jobs
GET/v1/jobs/{job}
Estado e resultado de um trabalho adiado. queued,
running, succeeded ou failed.
GET/v1/openapi.json
O contrato, servido pelo próprio binário — os dois sobem juntos ou nenhum sobe.
npx @openapitools/openapi-generator-cli generate \
-i http://localhost:8787/v1/openapi.json -g typescript-fetch -o sdk/ts
Erros
Todo erro tem a mesma forma, e traz o dado que explica quando existe um:
{ "error": "stale_anchor", "message": "esta âncora é de outra versão…" }
| HTTP | error | Quando |
|---|---|---|
| 400 | bad_json | o corpo não é um objeto JSON válido. |
| 400 | bad_anchor | âncora ilegível, ou adulterada. |
| 400 | bad_dpi | dpi fora de 12–600. |
| 401 | unauthorized | chave ausente ou desconhecida. |
| 404 | no_such_document | não existe — ou é de outro inquilino. |
| 404 | no_such_version · no_such_page · no_such_font · no_such_job | idem. |
| 409 | stale_anchor | a âncora é de outra versão. Releia a estrutura da página. |
| 409 | font_missing_glyph | a fonte não tem o caractere. Traz missing, font, page. |
| 413 | too_large | passa de max_upload. |
| 422 | not_a_pdf · not_a_font | o arquivo não abre. |
| 422 | password_required | PDF cifrado. |
| 422 | edit_refused · glyphs_refused | a operação não pôde ser feita. A mensagem diz o quê. |
| 429 | rate_limited | cota do minuto esgotada. Veja Retry-After. |
Uma recusa que não diz o que fazer é um beco sem saída. Toda mensagem aqui é escrita para ser lida por quem vai agir sobre ela — pessoa ou programa.
Limites conhecidos
O que este servidor não faz, dito antes de você descobrir:
| Limite | O que fazer |
|---|---|
| Não faz TLS. Uma pilha TLS pela metade seria pior que nenhuma. | Ponha nginx ou Caddy na frente. |
| Uma edição por vez. As conexões são atendidas em paralelo, até 64; o que decide sobre um documento continua serial, sob um mutex só. | Um cliente lento não segura mais a fila. Para vazão de CPU, um processo por núcleo atrás de um balanceador. |
| Job em andamento não sobrevive ao reinício. | Repita com a mesma Idempotency-Key. |
A prévia pode usar fonte emprestada. Quando o PDF não embarca a fonte — as 14 padrão podem ser omitidas —, o render desenha com uma do acervo do servidor, e duas máquinas com fontes diferentes desenham diferente. Nada disso encosta no arquivo: a edição continua recusando glifo que a fonte do documento não tem. |
Para prévia idêntica entre máquinas, system_fonts: false e um fonts_dir versionado. |
| O webhook sai em texto claro. | O HMAC garante que ninguém forjou, não que ninguém leu. |
Sem data_dir, nada sobrevive ao reinício — nem a chave que assina as âncoras. |
Configure data_dir. |
Privacidade
O produto foi construído mirando extrato bancário e laudo médico, e isso está
no desenho: retenção curta por padrão (24 horas, configurável até zero),
DELETE que apaga de verdade, e log sem conteúdo — identificador,
tamanho, tempo, código de erro, nunca o texto editado.
Para agentes e modelos
Se você é um programa lendo isto, comece por um destes dois — são densos e feitos para ser consumidos inteiros:
| Arquivo | O que é |
|---|---|
llms.txt | este documento em texto puro, compacto, com as regras de uso e os erros. |
openapi.json | OpenAPI 3.1: esquemas, parâmetros e respostas de cada rota. |
As cinco regras que evitam quase todo erro
- Nunca construa uma âncora. Leia a estrutura da página e devolva o token como veio. Interpretá-lo é o caminho mais rápido para escrever no lugar errado.
- Reeleia a estrutura depois de cada edição. Cada edição cria uma versão nova, e as âncoras da anterior não valem nela.
- Mande
originalsempre, com o texto exato que a estrutura reportou. É a conferência que transforma um erro silencioso numa recusa. - Trate
409 font_missing_glyphcomo um passo, não como falha. Instale um doador e chame a rota de glifos; depois repita a edição contra a versão nova. - Leia
warnings.pushedeoverflowdiferentes de zero significam que o texto não coube como estava — o arquivo saiu, mas o layout cedeu.