Notepad Neo

Gerando um PDF no navegador deixando o navegador cuidar do layout

Uma página de PDF é uma lista de glifos em coordenadas. Nada no formato quebra linhas, recua uma lista ou muda de página — então ou você escreve um motor de layout, ou você toma emprestado o que já está rodando.

DH

— cria e mantém o Notepad Neo

· atualizado em · 13 min de leitura

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:

Os cinco estágios do pipeline de exportação para PDF O papel vivo do editor é clonado em um sandbox fora da tela com a largura de conteúdo exata. O clone é medido, produzindo caixas de palavras, linhas de base e preenchimentos de decoração em coordenadas do espaço do papel. Isso é paginado, e depois escrito como objetos PDF e deslocamentos de bytes. Nada no pipeline calcula uma quebra de linha. live paper #editor-paper sandbox clone left: -10000px measure boxes + baselines paginate split by band write bytes objects + xref The layout engine is stage two, and it is Blink or WebKit or Gecko. Line breaking, justification, list indentation, table cell sizing and bidirectional reordering are all read back rather than reimplemented — which is why the output matches what Print produces.
O estágio um é um clone, e não o elemento vivo, porque medir o papel vivo significaria alterar o que a pessoa está olhando, e porque o papel carrega zoom, cores de tema e padding que o PDF não pode herdar.

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

Por que um clone fora da tela é posicionado em vez de escondido Um elemento com display none não gera caixas, então getClientRects devolve uma lista vazia e todas as medições são zero. Um elemento com position fixed em menos dez mil pixels é diagramado por completo e devolve retângulos reais, permanecendo invisível para a pessoa. display: none position: fixed; left: -10000px render tree: no boxes generated el.getClientRects() → DOMRectList (length 0) render tree: el.getClientRects() → real rects, real baselines The clone is given zero padding and exactly the paper's content width, so its border box is its content box and its top-left corner is the origin of paper space. No padding arithmetic later.
Definir a largura do clone como a largura de conteúdo, e não a largura do papel, elimina uma classe inteira de erros de uma margem em todas as coordenadas seguintes.

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.

Escritas e leituras intercaladas versus duas fases separadas Intercalar uma escrita no DOM com uma leitura de geometria força um recálculo de layout antes de cada leitura, então um documento com muitos elementos paga um reflow completo por elemento. Fazer todas as escritas primeiro e todas as leituras depois custa um único recálculo de layout para o documento inteiro. INTERLEAVED — ONE REFLOW PER READ write reflow read write reflow read write reflow read · · · × N elements PHASE-SEPARATED — ONE REFLOW TOTAL Phase 1 — every style read and DOM mutation reflow Phase 2 — every geometry read, no mutations The ordering is a correctness constraint too, not only a performance one. List markers are replaced with real spans in phase 1, and every list-style-type has to be read before any is written — because list-style-type inherits, and suppressing it on one item changes what a nested item reports.
As duas fases são marcadas com comentários de faixa no código. É o tipo de invariante trivialmente fácil de quebrar com uma mudança de uma linha seis meses depois, e impossível de notar até alguém exportar um documento longo.

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.

Encontrando a linha de base do texto com uma sonda inline-block de largura zero Uma caixa de linha tem uma ascendente acima da linha de base e uma descendente abaixo, e a proporção entre elas é decidida pelas métricas da fonte, não pelo CSS. Um elemento inline-block de tamanho zero com vertical-align baseline fica com a borda inferior exatamente sobre a linha de base, então medir seu retângulo revela onde a linha de base está. Chrome e Firefox discordam sobre a altura desse retângulo, então ela precisa ser medida e não presumida. LINE BOX top of line box baseline bottom of line box Hxpg probe: 0×0 inline-block, vertical-align: baseline ascent descent The probe's bottom edge sits on the baseline by definition of vertical-align: baseline, so its rect is the answer. One probe is inserted per distinct font context, keyed on fontFamily | fontSize | lineHeight | fontWeight | fontStyle — so a document in one font pays for one probe, not one per word.
Derivar a linha de base aritmeticamente da caixa de linha exigiria as métricas de ascendente e descendente da fonte, que o DOM não expõe. A altura do próprio retângulo da sonda é definida pelo motor, e Chrome e Firefox discordam sobre ela, que é exatamente por que ela é medida em vez de presumida.

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.

O que as fontes Standard-14 conseguem e não conseguem desenhar Os bytes 0x20 a 0x7E são ASCII e mapeiam diretamente. Os bytes 0x80 a 0x9F são onde Latin-1 e CP1252 divergem, e são preenchidos a partir de uma tabela explícita de caracteres tipográficos. Os bytes 0xA0 a 0xFF mapeiam diretamente de novo. Tudo fora desse repertório não tem representação. Um pequeno conjunto de caracteres é substituído silenciosamente: espaços não separáveis e finos viram espaço comum, e caracteres de largura zero são descartados. THE WRITER'S REPERTOIRE ASCII 0x20 – 0x7E · direct CP1252 table 0x80 – 0x9F · lookup Latin-1 upper 0xA0 – 0xFF · direct everything else UNMAPPED € ‚ ƒ „ … † ‡ ˆ ‰ Š ‹ Œ Ž ‘ ’ “ ” • – — ˜ ™ š › œ ž Ÿ the one place Latin-1 and CP1252 disagree مرحبا · নমস্কার · CJK · Ελληνικά SUBSTITUTED, NOT REPORTED U+00A0 · U+2007 · U+2009 · U+202F → 0x20 U+200B · U+200C · U+200D · U+FEFF → dropped contenteditable produces these constantly. Reporting them would fire on every note.
A faixa 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.

Uma segunda camada, partindo do princípio de que a primeira tem furos

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

Coordenadas do DOM versus coordenadas do PDF A geometria do DOM tem origem no canto superior esquerdo, com y crescendo para baixo. O PDF tem origem no canto inferior esquerdo, com y crescendo para cima. Toda coordenada y medida é convertida por um único auxiliar que a subtrai da altura da página e a desloca pela posição inicial da página e pela margem superior. DOM · origin top-left PDF · origin bottom-left 0,0 y grows down First line of the note y = 36 0,0 y grows up First line of the note y = 104 const py = (y: number) => (PAGE_H_PX - margins.top - (y - page.startPx)) * PT; One helper. Every vertical coordinate in the file goes through it, and nothing flips twice.
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.

← Todos os artigos de engenharia Experimentar o editor