O exportador de DOCX tem uma tarefa fácil pela qual não merece crédito: ele entrega ao Word um fluxo de parágrafos e runs, e o Word decide onde as linhas quebram, onde as páginas terminam e o quanto uma lista aninhada recua. O arquivo descreve estrutura. Outra pessoa faz o layout.
O PDF não funciona assim. Uma página de PDF é um fluxo de conteúdo com operadores de desenho, e o texto é posicionado com uma matriz explícita. Não há quebra de linha, não há recuo, não há paginação, não há conceito de parágrafo. Todo glifo na página está em algum lugar porque um número o colocou ali.
O que significa que um exportador de PDF feito do zero precisa de um motor de layout. Escrever um que trate corretamente quebra de linha, texto bidirecional, marcadores de lista, células de tabela e imagens inline não é um recurso — é um navegador.
O navegador já é um motor de layout
A saída é que o layout já aconteceu. A nota está na tela, diagramada, quebrada e medida, exatamente
na largura que o papel vai ter. Toda caixa que o exportador precisa conhecer já existe na árvore de
renderização; o DOM as entrega por meio de getClientRects(), é só pedir.
Então o pipeline nunca calcula uma quebra de linha. Ele reproduz uma:
O clone não pode ser escondido com display:none
O instinto para um elemento de medição fora da tela é display: none. É exatamente o
errado. Um elemento com display: none não gera caixa nenhuma — ele não está na árvore
de renderização — então toda chamada de getClientRects() volta vazia e toda medição é
zero.
O clone precisa ser diagramado de verdade, só que em algum lugar que ninguém vê:
Três coisas das quais o clone precisa ser protegido
const host = document.createElement('div');
host.className = HOST_CLASS;
host.setAttribute('aria-hidden', 'true');
// Offscreen rather than display:none — a display:none subtree generates no
// boxes at all and every getClientRects() call would come back empty.
host.style.cssText =
'position:fixed;left:-10000px;top:0;opacity:0;pointer-events:none;' +
'z-index:-1;margin:0;padding:0;border:0;background:#fff;' +
`width:${contentWidthPx}px;`;
const clone = plainText === undefined ? prepareClone(paper) : plainPaper(plainText);
for (const [prop, value] of FORCED) clone.style.setProperty(prop, value, 'important');
clone.style.setProperty('width', `${contentWidthPx}px`, 'important');
O tema. O modo escuro é aplicado via [data-theme="dark"] no elemento
<html>, que é ancestral do sandbox. O clone o herda, e um PDF exportado à noite
sairia com texto branco sobre fundo transparente. A correção são sobrescritas explícitas em
especificidade equivalente com !important — não dá para escapar de um seletor
ancestral sendo mais específico mais abaixo.
Identidade duplicada. O clone é uma cópia profunda de um elemento com
id, então enquanto ele existe há dois nós id="editor-paper" no documento.
O getElementById devolve o primeiro, que costuma ser — mas não com garantia — o
original. Os atributos id, contenteditable, role e
aria-* são todos removidos do clone.
Interface transitória. Os destaques do Localizar e Substituir são elementos
<mark> no DOM vivo. Eles são desembrulhados do clone, porque um destaque de busca
não faz parte do documento e exportar um PDF com barras amarelas atrás de cada resultado é um
relato de bug esperando para acontecer.
Nunca intercale escritas no DOM com leituras de geometria
Esta é a regra mais rígida de todo o código, e ela é reforçada por um comentário no topo do módulo de medição e por duas fronteiras de fase rotuladas dentro dele.
Ler uma propriedade geométrica — getClientRects, offsetTop,
getBoundingClientRect — obriga o navegador a descarregar qualquer layout pendente antes
de poder responder. Escrever no DOM invalida o layout. Alterne os dois e cada leitura dispara um
reflow completo do clone. O trabalho vira quadrático de um jeito que transforma uma exportação de
menos de um segundo em um congelamento de vários segundos da thread principal.
Por que os marcadores de lista precisam ser reconstruídos
Um pseudoelemento ::marker não está no DOM. Não há nó para selecionar, não há
Range que possa contê-lo e não há getClientRects() para chamar nele. É
conteúdo gerado que o motor de layout desenha, e o único jeito de descobrir onde ele foi parar é
substituí-lo por algo real.
Então, na fase 1, todo marcador de lista vira um span posicionado de forma absoluta contendo o
marcador ou o número, e o list-style-type original é definido como none.
Na fase 2 esse span é medido como qualquer outro texto. A alternativa — calcular as posições dos
marcadores a partir do padding da lista e das métricas de fonte do item — teria que reimplementar
formatação de contadores, numeração aninhada e posicionamento de marcador em RTL, que é exatamente
a reimplementação que toda esta abordagem existe para evitar.
Medindo palavra a palavra, e sondando a linha de base
O texto é medido um token por vez. Um Range é colocado em torno de cada sequência de
caracteres sem espaço e o getClientRects() devolve sua caixa. Medir linhas inteiras
seria mais barato, mas uma linha não é endereçável — o DOM não tem um identificador para "a segunda
linha visual deste parágrafo", porque caixas de linha são um artefato de layout sem nó por trás.
Volta e meia um token devolve mais de um retângulo, o que significa que o
overflow-wrap: break-word o partiu no meio, entre duas linhas. Esse caso recorre a
retângulos por caractere. É o único lugar da descida em que esse custo é pago, e ele é pago no
token raro, não em todos.
A medição mais difícil é a vertical. Um retângulo dá o topo e a altura; o PDF precisa de uma linha de base, e não existe API que informe uma.
Sublinhados são desenhados por linha, não por palavra
function buildProbe(cs: CSSStyleDeclaration): HTMLElement {
const wrap = document.createElement('div');
wrap.style.cssText =
'position:absolute;left:0;top:0;white-space:nowrap;visibility:hidden;';
wrap.style.fontFamily = cs.fontFamily;
wrap.style.fontSize = cs.fontSize;
wrap.style.lineHeight = cs.lineHeight;
wrap.style.fontWeight = cs.fontWeight;
wrap.style.fontStyle = cs.fontStyle;
const text = document.createElement('span');
text.textContent = 'Hxg';
// A zero-sized baseline-aligned inline-block sits exactly on the baseline.
const base = document.createElement('span');
base.style.cssText = 'display:inline-block;width:0;height:0;vertical-align:baseline;';
wrap.appendChild(text);
wrap.appendChild(base);
return wrap;
}
Ler o resultado de volta é a diferença entre dois retângulos — o topo do retângulo do próprio texto e o topo da sonda, que está apoiada na linha de base:
function readProbe(probe: HTMLElement): number {
const textSpan = probe.firstElementChild as HTMLElement;
const baseSpan = probe.lastElementChild as HTMLElement;
const range = document.createRange();
range.selectNodeContents(textSpan);
const textRect = range.getClientRects()[0];
const baseRect = baseSpan.getBoundingClientRect();
if (!textRect) return 0;
return baseRect.top - textRect.top;
}
Decorações de texto são retângulos no PDF, não propriedades de texto. A tradução ingênua desenha um embaixo de cada palavra medida — e produz um sublinhado visivelmente tracejado, porque os espaços entre palavras são tokens de espaço em branco que nunca foram medidos.
Então os preenchimentos de decoração são agrupados por linha visual e fundidos em um retângulo por trecho. A chave de agrupamento é a coordenada y da linha de base, arredondada para o meio pixel mais próximo, o que basta para tolerar diferenças subpixel entre palavras vizinhas da mesma linha sem fundir por acidente duas linhas que por acaso estejam próximas.
A barreira da codificação
O PDF traz catorze fontes padrão que todo leitor é obrigado a ter — quatro pesos de Helvetica, Times e Courier, mais Symbol e ZapfDingbats. Usá-las significa que nenhum arquivo de fonte precisa ser embutido, o que mantém o exportador pequeno e a saída portátil.
Também significa /WinAnsiEncoding: todo glifo precisa mapear para um único byte em
CP1252. Isso é aproximadamente Latin-1 mais um bloco de caracteres tipográficos. Qualquer coisa
fora disso — árabe, bengali, devanágari, grego, cirílico, CJK, emoji — simplesmente não pode ser
desenhada por este gerador.
0x80–0x9F é a armadilha clássica. O Latin-1 define esses como caracteres de controle; o CP1252 os preenche com aspas curvas, travessões e o símbolo do euro — que são exatamente os caracteres que um editor de texto formatado mais produz.
O conjunto de substituições importa mais do que parece. Um contenteditable emite
espaços não separáveis o tempo todo, e a ferramenta de tamanho de fonte injeta U+200B para manter
spans vazios vivos. Se esses contassem como não representáveis, praticamente toda nota do app seria
desviada do gerador direto, e o recurso efetivamente não existiria.
Uma função decide, para que os dois chamadores não possam discordar
Há duas perguntas a responder sobre qualquer caractere: conseguimos desenhá-lo? e qual byte ele é? Respondê-las em dois lugares é como se acaba com uma verificação prévia que passa e um codificador que depois substitui por um ponto de interrogação.
Então as duas passam por uma função só, com dois valores de retorno sentinela:
/** Sentinel: the character is intentionally dropped. */
const DROP = -1;
/** Sentinel: the character has no WinAnsi representation. */
const UNMAPPED = -2;
function winAnsiByte(code: number): number {
if (code in SUBSTITUTE) {
const sub = SUBSTITUTE[code];
return sub === null ? DROP : sub;
}
// Control characters have no glyph and would corrupt the literal.
if (code < 0x20) return code === 0x09 ? 0x20 : DROP;
if (code < 0x80 || (code >= 0xa0 && code <= 0xff)) return code;
const mapped = CP1252_HIGH[code];
return mapped !== undefined ? mapped : UNMAPPED;
}
A verificação prévia percorre o texto puro da nota, junta até oito caracteres distintos que não mapeiam e, se encontrar algum, a exportação é entregue à impressão do navegador — com um aviso nomeando os caracteres, para que o resultado seja explicável em vez de misterioso:
const unsupported = findUnencodable(editor.getPlainText());
if (unsupported.length) {
showToast(
'This note uses characters (' + unsupported.slice(0, 4).join(' ') +
') that direct PDF export cannot render. Opening Print — choose ' +
'"Save as PDF" there to keep them.',
8000,
);
printAsPdf(safeFilename(tab.title));
return;
}
Esse fallback não é prêmio de consolação. A impressão do navegador embute fontes Unicode de verdade e — o que é decisivo — as molda. Árabe e urdu precisam de formas contextuais, em que o glifo de uma letra depende dos vizinhos. Bengali e devanágari precisam de reordenação e formação de conjuntos. Nenhum arquivo de fonte sozinho oferece isso; é preciso um motor de shaping, e o navegador tem um. Um gerador feito do zero que embutisse uma fonte Unicode produziria formas de letra isoladas e sem ligação: tecnicamente caracteres árabes, e ilegíveis como árabe.
Também significa que o gerador direto nunca precisa de tratamento bidirecional. Tudo o que exigiria
isso já foi desviado, e o caminho da impressão respeita os atributos dir que o editor
carimba — descrito aqui.
O codificador em si nunca lança exceção. Se ele encontra um caractere não mapeado, emite
? e adiciona o caractere a um conjunto, que o gerador devolve e o chamador mostra
como um segundo aviso. A verificação prévia deveria ter pego, e, se não pegou, a pessoa recebe um
arquivo legível e uma explicação, e não um stack trace.
As duas camadas chamam winAnsiByte, então uma lacuna só pode existir naquela única
função.
Há uma exceção deliberada ao limite do Latin-1. O título do documento nos metadados do PDF é escrito como UTF-16BE com marca de ordem de bytes, o que as strings de PDF aceitam, então uma nota com título em bengali continua legível no painel de propriedades do documento de um leitor, mesmo quando seu conteúdo teria sido desviado para a Impressão.
Escrevendo os bytes
Um PDF é um conjunto de objetos numerados seguido por uma tabela de referência cruzada que lista o deslocamento exato em bytes de cada um. Erre um deslocamento por um único byte e o arquivo não abre — o leitor busca aquela posição e não encontra um cabeçalho de objeto.
O que faz do codificador de saída a linha mais perigosa do módulo:
// Every byte here is either ASCII syntax or an already-WinAnsi-encoded
// string byte. A UTF-8 encoder would silently turn one 0xE9 into two bytes
// and desynchronise every xref offset after it.
const push = (s: string) => {
for (let i = 0; i < s.length; i++) bytes.push(s.charCodeAt(i) & 0xff);
};
O TextEncoder é a ferramenta óbvia e a errada. Ele produz UTF-8, então o
é — já codificado como o único byte 0xE9 pela etapa WinAnsi — vira dois
bytes. O fluxo de conteúdo continua legível; todo deslocamento de byte registrado depois daquele
ponto não. A falha só aparece em documentos com caracteres acentuados, que é um caso de teste
fácil de não ter.
A inversão de coordenadas
PT é 0.75 — pixels CSS para pontos de PDF a 96 dpi. page.startPx é onde a página atual começa no documento medido, que é o que transforma um fluxo contínuo de caixas em páginas separadas.Cada palavra ganha sua própria matriz de texto
A forma eficiente de desenhar uma linha de texto em PDF é um operador Tj para a linha
inteira e deixar as larguras de avanço da fonte espaçarem os glifos. É o que um PDF produzido por
um motor de layout faz.
Aqui isso está errado, porque a fonte que diagramou o texto na tela e a fonte Standard-14 que o desenha no PDF não são a mesma fonte. A Arial é compatível em métricas com a Helvetica por projeto, e a Times New Roman com a Times — mas qualquer outra face é uma aproximação, e pequenas diferenças por caractere na largura de avanço se acumulam ao longo da linha até a última palavra ficar visivelmente fora do lugar.
Então cada palavra é posicionada com sua própria Tm, na coordenada que o navegador
realmente mediu. A diferença entre a fonte da tela e a substituta deixa de ser cumulativa e vira
uma variação subpixel no espaço entre palavras.
A monoespaçada ganha uma correção extra. O avanço da Courier é exatamente 0,6 em por
definição, e a face monoespaçada que o navegador usou quase certamente não é, então um operador de
escala horizontal (Tz) estica ou comprime os glifos para bater com a largura medida.
Isso mantém os blocos de código alinhados de um jeito que o posicionamento por palavra sozinho não
manteria.
A paginação precisa respeitar o que é atômico
while (pageTop < total && bounds.length < MAX_PAGES) {
let candidate = pageTop + height;
if (candidate < total) {
for (const span of atomic) {
if (span.top >= candidate) break; // sorted — nothing later can straddle
if (span.top > pageTop && span.bottom > candidate) {
candidate = span.top; // pull the break up to this line's top
break;
}
}
}
// A single item taller than a whole page would otherwise stall the loop.
if (candidate <= pageTop) candidate = pageTop + height;
bounds.push([pageTop, candidate]);
pageTop = candidate;
}
// A PDF with zero pages is invalid, so an empty note still gets one.
if (bounds.length === 0) bounds.push([0, height]);
Dividir um documento medido em páginas é quase só aritmética, com duas exceções.
Uma linha visual é atômica — uma linha que cavalga a fronteira da página empurra a quebra para o topo dessa linha, em vez de ser fatiada no meio dos glifos. Mas um preenchimento de fundo alto, como o sombreado de um bloco de código ou uma célula de tabela, não é atômico: ele deve continuar através da quebra, então é recortado por faixa de página. Tratar os dois do mesmo jeito dá ou texto fatiado ou fundos que param abruptamente no pé da página.
Há duas proteções que vale ter. Um PDF com zero páginas é inválido, então uma nota vazia ainda produz uma página em branco. E há um teto rígido de 500 páginas, porque a única forma de chegar lá é um bug na medição — um documento que reporta altura ilimitada iria alocar até a aba morrer.
No que esta abordagem é boa e no que é ruim
Boa em: reproduzir exatamente o que a pessoa vê, porque é o mesmo layout. Permanecer pequena — o pipeline inteiro de PDF tem cerca de 1.300 linhas em sete arquivos, sem dependências e sem arquivos de fonte. Produzir texto real, selecionável e pesquisável, em vez de uma imagem. Funcionar totalmente offline, o que para um app cuja premissa inteira é que as notas nunca saem do dispositivo é o ponto, não um bônus.
Ruim em: qualquer coisa fora do CP1252, que é uma fração enorme dos sistemas de escrita do mundo e é resolvida devolvendo a tarefa ao navegador. Imagens, que não estão implementadas. Documentos muito longos, em que medir milhares de tokens na thread principal se faz sentir — o trabalho é linear, mas é síncrono. E qualquer coisa que exija tipografia de verdade: pares de kerning, ligaduras e hifenização são o que o navegador decidiu e a substituta Standard-14 aproxima.
O resumo honesto é que isto não é uma biblioteca de PDF. É um jeito de obter um bom PDF de um documento específico, no caso comum, sem embarcar uma biblioteca de PDF — e uma verificação prévia que reconhece quando está além da própria capacidade e diz isso, em vez de produzir algo errado.
- Não escreva um motor de layout. Meça o que já está rodando.
-
Fora da tela significa
position: fixed; left: -10000px, nuncadisplay: none— este último não gera caixa alguma para medir. - Separe toda escrita no DOM de toda leitura de geometria, e marque a fronteira no código para que ninguém as intercale depois.
- Meça a linha de base com uma sonda de tamanho zero e
vertical-align: baseline. Não a derive. - Funda os retângulos de decoração por linha visual, ou os sublinhados saem tracejados.
- Faça uma função só responder tanto "conseguimos codificar isto" quanto "qual byte é este", para que a verificação prévia e o codificador não possam se afastar.
- Nunca deixe um codificador UTF-8 chegar perto de bytes cujos deslocamentos você registrou.
- Conheça o limite e passe a bola com elegância. Um fallback correto vence um arquivo errado.