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 navegador

O 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.

#GarantiaComo 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" }
  ]
}
ChaveO que faz
data_dironde o acervo mora. Sem ele nada sobrevive ao reinício.
retention_secondsapaga o que passou disso, na partida. 0 guarda para sempre.
tenantssem nenhum, qualquer um que alcance a porta manda.
max_uploadteto do corpo de um pedido. Padrão 64 MiB.
async_aboveacima 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_1 e 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:

PropriedadePor 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-Filenamenome para exibição. Entra no JSON e nunca num caminho de arquivo.
Idempotency-Keyrepetir devolve o mesmo documento.
Prefer: respond-asyncdevolve 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
    }
  ]
}
CampoObrigatórioO que faz
anchorsimonde. Veio da estrutura da página.
originalsimo que estava ali, conferido antes de escrever.
textsimo texto novo.
font_sizenãocorpo novo em pontos. Ausente mantém o da linha.
fontnãoidentificador 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_rightnãoa coluna em que o trecho vive.
alignnão0 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…" }
HTTPerrorQuando
400bad_jsono corpo não é um objeto JSON válido.
400bad_anchorâncora ilegível, ou adulterada.
400bad_dpidpi fora de 12–600.
401unauthorizedchave ausente ou desconhecida.
404no_such_documentnão existe — ou é de outro inquilino.
404no_such_version · no_such_page · no_such_font · no_such_jobidem.
409stale_anchora âncora é de outra versão. Releia a estrutura da página.
409font_missing_glypha fonte não tem o caractere. Traz missing, font, page.
413too_largepassa de max_upload.
422not_a_pdf · not_a_fonto arquivo não abre.
422password_requiredPDF cifrado.
422edit_refused · glyphs_refuseda operação não pôde ser feita. A mensagem diz o quê.
429rate_limitedcota 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:

LimiteO 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:

ArquivoO que é
llms.txteste documento em texto puro, compacto, com as regras de uso e os erros.
openapi.jsonOpenAPI 3.1: esquemas, parâmetros e respostas de cada rota.

As cinco regras que evitam quase todo erro

  1. 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.
  2. 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.
  3. Mande original sempre, com o texto exato que a estrutura reportou. É a conferência que transforma um erro silencioso numa recusa.
  4. Trate 409 font_missing_glyph como 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.
  5. Leia warnings. pushed e overflow diferentes de zero significam que o texto não coube como estava — o arquivo saiu, mas o layout cedeu.