Pular para o conteúdo principal

Capítulo 3.19 · Parte II · O ofício

Uso programático: pacotes .postext

Abrir, diagramar, renderizar e criar arquivos .postext a partir do código, pacotes bilíngues, arquivos de versões anteriores e a API de baixo nível

Atualizado 2026-10-1629 minNo capítulo 3 Configuraçãoenescadefritptzhjaar

Em poucas palavras

Esta página é para quem escreve código. Um arquivo .postext guarda um livro inteiro num só arquivo: o texto, os ajustes, as fontes e as imagens. A página mostra como abrir um arquivo desses, diagramar as páginas e desenhá-las. Mostra como criar um arquivo novo a partir do seu próprio texto e como guardar dois idiomas de um livro no mesmo arquivo. Também explica como são lidos os arquivos feitos por versões anteriores do Postext.

#Pacotes (arquivos .postext)

Um arquivo .postext é um livro inteiro em um único arquivo: um arquivo zip com um manifesto preset.json, um arquivo Markdown por capítulo, os conteúdos dos recursos (bitmaps, SVGs, matrizes de impressão em PDF) e os arquivos das fontes que a configuração nomeia. O Sandbox o exporta e importa, e a skill para agentes o entrega. O pacote postext também consegue criá-lo e abri-lo, de modo que um livro pode passar entre essas ferramentas e o seu próprio programa sem perder nada.

my-book.postext
├── preset.json            manifest: name, locale, chapters, config, resources, fonts
├── chapters/01-dusk.md
├── chapters/02-night.md
├── resources/lantern.svg
└── fonts/ebgaramond-400-normal.woff2

O manifesto é descrito campo a campo no apêndice Formato do pacote de predefinição do Sandbox. Um arquivo também pode levar layouts.json, as contagens de páginas do Sandbox, para que o livro já abra paginado ali, ou um por edição de um livro multilíngue (layouts.zh-Hant.json, lido primeiro). openBundle os ignora.

A API é exportada pelo próprio postext e pelo subcaminho postext/bundle, que acrescenta as funções auxiliares de baixo nível. Importe de postext quando também for renderizar. Assim, os adaptadores de pacote e os renderizadores compartilham uma única instância do módulo, o que importa em uma CDN como o esm.sh, onde cada ponto de entrada é um build separado.

#Abrir um pacote

openBundle recebe os bytes do arquivo (um Uint8Array, um ArrayBuffer ou um Blob / File de um <input type="file">) e devolve tudo de que o motor e os seus renderizadores precisam:

import { openBundle } from 'postext';
 
const bundle = await openBundle(await file.arrayBuffer(), { locale: 'es' });
 
bundle.chapters;   // [{ title, file, markdown }, …] na ordem do livro
bundle.config;     // PostextConfig, pronto para buildDocument
bundle.resources;  // Resource[]
bundle.files;      // Map<path, Uint8Array>: todos os arquivos do pacote
CampoO que contém
manifestO preset.json validado.
id, name, descriptionDo manifesto.
locale, localesO idioma em que o conteúdo foi lido, e todos os idiomas que um pacote bilíngue traz. options.locale escolhe um: primeiro a tag exata, depois o idioma base e, por fim, o idioma do próprio pacote.
chapters{ title, file, markdown } por capítulo. Um capítulo sem título no manifesto recebe o texto do seu primeiro título #.
configA paleta de cores padrão e os tipos de recurso no idioma do pacote, depois o config do manifesto e, por fim, as substituições do idioma. O idioma do pacote é o locale acima, então um pacote em um único idioma recebe os seus próprios rótulos, seja qual for o options.locale pedido. Um manifesto que não nomeia idioma usa o que o seu config define (locale e, depois, o idioma de hifenização) ou, na falta dele, options.locale. customFonts lista as famílias de fontes do pacote. É a mesma configuração com que o Sandbox abre o pacote.
resourcesOs recursos, com as legendas do idioma escolhido. Um tamanho que falta no manifesto é lido do arquivo.
fontsUma entrada por variante: { family, weight, style, format, file, bytes }.
filesTodos os arquivos do zip, indexados pelo caminho.
thumbnail, canvasScopeO caminho da imagem de capa e o modo como o pacote pede para ser visto: o view do manifesto, com o localized[…].view do idioma servido por cima.
startOnde o livro começa, para um pacote que contém parte de um mais longo: o start do manifesto, ou o localized[…].start do idioma servido. Ausente num livro que começa pelo início.
warningsProblemas não fatais: um arquivo de fonte sem suporte, uma matriz de impressão faltando.

O fileId de um conteúdo é o seu caminho dentro do pacote. resource.svg.fileId, resource.bitmap.fileId e o fileId de cada variante de customFonts podem ser buscados diretamente em bundle.files. openBundle lança um erro quando os bytes não são um zip, quando não há um preset.json válido (na raiz ou dentro de uma única pasta de primeiro nível) ou quando falta um arquivo que o manifesto nomeia.

Pacotes gravados pelo postext 1.4 ou anterior

Todo manifesto que createBundle e o Sandbox gravam leva configVersion: 14: as regras de configuração para as quais o seu config foi escrito. Um manifesto sem isso foi gravado pelo postext 1.4 ou anterior, que resolvia dezoito coisas de outra maneira:

  • Quebras de título (regras 3): até a 1.4, um objeto headings sem quebra de H1 não tinha nenhuma (veja Sobrescritas por nível).
  • O tamanho das fórmulas (regras 4): até a 1.4, as fórmulas saíam 1,131 vez maiores do que diz fontSizeScale (veja Tamanho das fórmulas).
  • O espaço abaixo de um recurso inline (regras 5): até a 1.4, o texto depois de uma figura ou tabela com placement.position: 'here' continuava na linha seguinte da grade, sem o espaço de flutuantes abaixo dela (veja layout.inlineResourceGap em Diagramação).
  • O espaço em volta de um recurso inline dentro de um boxe (regras 6): até a 1.4, esse recurso ficava encostado no texto do boxe que o envolve (veja layout.inlineResourceGapInBoxes em Diagramação).
  • Marcações inline nos títulos (regras 6): até a 1.4, um título imprimia as palavras do seu *italic*, **bold** e outras marcações no seu próprio estilo simples (veja headings.inlineMarks em Títulos).
  • O tamanho de uma capitular (regras 6): até a 1.4, o dropCap de um texto de design sem fontSize era tão alto quanto todas as caixas de linha que abrange, com o topo acima da primeira linha (veja dropCap em Elementos de texto).
  • O espaço abaixo de uma linha com dois-pontos (regras 6): até a 1.4, keepColonWithList considerava suficiente para a lista uma linha de espaço abaixo da linha terminada em dois-pontos, e um primeiro item de duas linhas que as regras de órfãs e viúvas mantêm inteiro passava para a coluna seguinte sem ela (veja bodyText.colonListRoom).
  • As linhas que o corte de um boxe deixa (regras 6): até a 1.4, um boxe que se dividia dentro de um parágrafo ou item de lista podia deixar uma linha dele de um lado, desde que cada lado do boxe tivesse as suas splitMinLines linhas no total (veja layout.boxChildSplitMinLines em Diagramação).
  • Quebras de linha em um travessão (regras 7): até a 1.4, o Knuth-Plass nunca terminava uma linha depois de um travessão ou meia-risca colados entre palavras (say—that’s), e o quebrador linha a linha do texto formatado só entre duas letras (veja bodyText.breakAfterDashes em Texto do corpo).
  • Texto alinhado à esquerda (regras 7): até a 1.4, o texto corrido em bandeira era composto linha a linha, preenchendo cada linha antes da seguinte, independentemente do que dissesse optimalLineBreaking (veja bodyText.optimalRagged em Texto do corpo).
  • A divisão abaixo de um título (regras 8): até a 1.4, o parágrafo abaixo de um título no pé de uma coluna mantinha ali tantas linhas quantas coubessem, por poucas que passassem para a coluna seguinte (veja headings.keepWithNextSplit em Títulos).
  • O espaço abaixo de um contêiner :::paragraphs (regras 8): até a 1.4, o espaço do estilo era aplicado abaixo do último parágrafo antes do ajuste à grade, o espaço acima do bloco seguinte (o marginTop de um título) era somado abaixo dele, e o espaçamento entre parágrafos do texto ficava de fora (veja bodyText.paragraphContainerSpacing em Texto do corpo).
  • Quebras de linha no hífen de uma palavra composta (regras 8): até a 1.4, o Knuth-Plass nunca terminava uma linha justificada depois de um hífen entre duas letras (well-known) em um parágrafo sem formatação inline, ao passo que o fazia em um parágrafo com formatação (veja bodyText.breakAfterHyphens em Texto do corpo).
  • Poemas sem separador (regras 9): até a 1.22, um poema :::verse cujas linhas não traziam || era composto em hemistíquios soltos, cada linha centralizada (veja bodyText.verse.layout em Verso).
  • Um recuo de primeira linha ao lado de um recuo deslocado (regras 9): até a 1.22, o hangingIndent de um estilo de parágrafo substituía o seu firstLineIndent, e a primeira linha começava em indent (veja Estilos de parágrafo).
  • Uma barra invertida no fim de uma linha (regras 9): até a 1.22, uma barra invertida no fim de uma linha de um parágrafo, de uma citação ou de um item de lista, e \\ antes de um espaço, eram impressas, e as linhas se uniam com um espaço (veja bodyText.hardLineBreaks em Texto do corpo).
  • Cercas de código (regras 9): até a 1.22, uma cerca ``` ou ~~~ e as linhas dentro dela eram lidas como Markdown: as linhas se juntavam em parágrafos, uma linha com # virava título, e as cercas eram impressas (veja codeStyle.blocks em Listagens de código).
  • Versos partidos (regras 10): na 1.23, um verso de um poema composto verso a verso mais largo que a mancha era partido com o seu espaçamento natural, por pouco que sobrasse (veja bodyText.verse.tighten em Verso).

openBundle e readBundle leem o config de um manifesto assim, e a configuração localized de cada idioma, por meio de migrateConfig, que grava explicitamente as quebras que a 1.4 compunha e multiplica a escala das fórmulas por 1,131 (com as margens de exibição em em divididas por esse fator). Um manifesto marcado de 3 a 7, gravado por uma pré-versão da 1.5, recebe apenas as fixações das regras posteriores à sua marca. Com 3, isso é o tamanho das fórmulas, o espaço inline, as cinco fixações das regras 6, as duas das regras 7 e as três das regras 8; com 4, o espaço inline e as fixações das regras 6, 7 e 8; com 5, as fixações das regras 6, 7 e 8; com 6, as das regras 7 e 8; com 7, só as das regras 8. A divisão abaixo de um título (pinLegacyHeadingSplit) é gravada como headings.keepWithNextSplit: 'fill' no headings que as camadas deixam em vigor, quando algum capítulo lido tem um título e a configuração não nomeia um valor próprio, mantém headings.keepWithNext ativado e não desativa bodyText.avoidOrphans. As quebras em compostos (pinLegacyHyphenBreaks) são gravadas como bodyText.breakAfterHyphens: false no bodyText em vigor, quando um capítulo lido tem um hífen entre duas letras e a configuração nem já o define nem desativa optimalLineBreaking. O espaço abaixo dos contêineres (pinLegacyParagraphContainerSpacing) é gravado como bodyText.paragraphContainerSpacing: 'add' no bodyText em vigor, quando a configuração declara um estilo de parágrafo (em paragraphStyles ou nas substituições do visualizador HTML), um capítulo lido abre um contêiner :::paragraphs em uma linha própria e a configuração ainda não o define. As quebras em travessões (pinLegacyDashBreaks) são gravadas como bodyText.breakAfterDashes: false no bodyText em vigor, quando um capítulo lido tem um travessão ou meia-risca colados entre palavras (antes dele uma letra, um algarismo ou uma pontuação de fechamento, e depois dele uma letra, um algarismo ou um parêntese ou aspa de abertura; aspas antes dele contam quando uma letra, um algarismo, uma pontuação de fechamento ou um espaço sem quebra vem antes delas, como em "no"—and, mas não em said "—Hola; uma marcação inline encostada no travessão ou nas aspas, como os ** de **riddles.**—I, conta dos dois lados) e a configuração ainda não o define. A quebra em bandeira (pinLegacyRaggedBreaking) é gravada como bodyText.optimalRagged: false no bodyText em vigor, quando a configuração deixa algum texto corrido em bandeira (um textAlign diferente de 'justify' no texto do corpo, em um estilo de parágrafo, no corpo de um boxe, no corpo de uma parte ou no corpo de um estilo de seção (headingStyles[].bodyStyle), ou nas substituições do visualizador HTML), ainda não o define e não desativa optimalLineBreaking. O espaço em boxes (pinLegacyBoxResourceGap) é gravado como layout.inlineResourceGapInBoxes: false no layout em vigor, quando um recurso está incorporado em uma linha própria dentro de um :::callout dos capítulos lidos e a configuração ainda não o define. O corte de boxes (pinLegacyBoxChildCut) é gravado como layout.boxChildSplitMinLines: 1 no layout em vigor, quando um capítulo lido abre um :::callout em uma linha própria e a configuração ainda não o define. As marcações dos títulos (pinLegacyHeadingMarks) são gravadas como headings.inlineMarks: false no headings que as camadas deixam em vigor, quando um título dos capítulos lidos tem uma marcação (*, _, ^, ~, :smallcaps[ ou um link no seu texto) e a configuração não nomeia um valor próprio. As capitulares (pinLegacyDropCapSize) têm o seu tamanho da 1.4 gravado explicitamente como dropCap.fontSize, onde quer que estejam: na unidade da entrelinha do elemento quando ela é um comprimento, senão na unidade do seu tamanho de fonte. O espaço da linha com dois-pontos (pinLegacyColonListRoom) é gravado como bodyText.colonListRoom: 'line' no bodyText em vigor, quando uma lista dos capítulos lidos vem depois de uma linha terminada em dois-pontos (com linhas em branco permitidas entre elas) e a configuração nem nomeia um espaço nem desativa keepColonWithList. O espaço inline (pinLegacyInlineGap) é gravado como layout.inlineResourceGap: 'above' no layout que as camadas deixam em vigor, quando uma linha dos capítulos lidos incorpora um recurso (::resource{id="…"} sozinho na sua linha, como o parser o lê: uma menção no texto corrido ou em um trecho de código não conta) e a configuração não nomeia um espaço próprio. O tamanho é fixado no math que as camadas deixam em vigor (o math próprio de um idioma substitui o compartilhado), e somente quando os capítulos lidos têm um $: um pacote sem fórmulas mantém o seu config como foi escrito. Quando nem o manifesto nem o idioma nomeiam um math, o que está em vigor é o baseConfig de readBundle (o do próprio leitor), e ele também é fixado, já que a 1.4 compunha as fórmulas do pacote nesse tamanho: um fontSizeScale: 1.5 de base é lido como 1,5 × 1,1312. As quebras de título da base são tomadas como estão. Assim, um pacote antigo mantém o que essas regras diagramavam, e bundle.config mostra as quebras, o tamanho das fórmulas, os espaços, as marcações dos títulos, os tamanhos das capitulares, o espaço da linha com dois-pontos, o corte de boxes, as quebras em travessões, as quebras em compostos, a quebra em bandeira, a divisão abaixo de um título e o espaço abaixo de contêineres com que ele é diagramado. As correções de layout da 1.5 não têm fixação e se aplicam a ele como a qualquer livro, então uma página que elas afetam ainda pode mudar (veja Tamanho das fórmulas para a lista). Um preset.json escrito à mão para as regras de hoje define "configVersion": 14; marcar com ele o manifesto de um pacote antigo é também o jeito de uma linha de lê-lo com as regras de hoje (um pacote sem versão perde então também a sua fixação das quebras de título). Um manifesto marcado 8, escrito pelo postext 1.5 a 1.22, recebe só as quatro fixações das regras 9, como também todos os anteriores: a disposição do verso (pinLegacyVerseLayout) é escrita como bodyText.verse.layout: 'bayt' no bodyText em vigor, quando um capítulo lido compõe um poema :::verse cuja abertura não nomeia disposição e cujas linhas não trazem separador de hemistíquios, e a configuração ainda não o define; os recuos emparelhados (pinLegacyPairedIndents) tiram o firstLineIndent de todo estilo de parágrafo (em paragraphStyles ou nos ajustes do visualizador HTML) que também define um hangingIndent diferente de zero; as quebras de linha forçadas (pinLegacyHardBreaks) são escritas como bodyText.hardLineBreaks: false no bodyText em vigor, quando um capítulo lido termina uma linha de um parágrafo, de uma citação ou de um item de lista com uma barra invertida e o bloco continua abaixo, ou põe \\ antes de um espaço e mais texto (fora o código em linha e as fórmulas, as fórmulas em destaque, os títulos e os poemas :::verse), e a configuração ainda não o define; e as cercas de código (pinLegacyCodeBlocks) são escritas como codeStyle.blocks: false, quando um capítulo lido abre uma cerca ``` ou ~~~ (de três ou mais caracteres, com até três espaços de recuo) e a configuração ainda não o define. Um manifesto marcado 9, gravado pelo postext 1.23, recebe só a fixação das regras 10, como também todos os anteriores: os versos partidos (pinLegacyVerseTightening) são escritos como bodyText.verse.tighten: false no bodyText em vigor, quando um capítulo lido compõe um poema verso a verso (uma abertura :::verse que nomeia layout=lines, ou que não nomeia disposição sobre versos sem separador de hemistíquios enquanto a configuração não compõe esses poemas como bayts) e a configuração ainda não o define. Um manifesto marcado 10, gravado pelo postext 1.24, recebe só a fixação das regras 11, como também todos os anteriores: o equilíbrio de uma grade de caracteres (pinLegacyGridBalancing) é escrito como headings.balancing.enabled: true no headings em vigor, quando a configuração mesclada define cjk.grid.enabled em texto horizontal e não define ela mesma enabled, já que a 1.24 equilibrava essas páginas por padrão. As regras 11 também impedem que um título de obra seja partido depois de um só caractere, compõem os números em círculo como caracteres chineses e o texto de design CJK com as regras do corpo (#637): um manifesto marcado 10 ou anterior recebe cjk.titleMinChars: 1 (pinLegacyTitleBreaks) quando um capítulo lido tem um título (《, 〈 ou :book[), cjk.circledNumbers: 'western' (pinLegacyCircledNumbers) quando um deles tem um número em círculo (U+2460–U+24FF, U+2776–U+2793) e cjk.composeDesignText: false (pinLegacyDesignText) quando um capítulo lido ou a própria configuração tem texto CJK; cada um no cjk vigente e só se a configuração ainda não o define. Elas também cortam as tabelas em linha e compõem :::columns no texto corrido (#634): um manifesto marcado 10 ou anterior recebe tableStyle.splitInline: false (pinLegacyInlineTableSplit) no tableStyle vigente quando um capítulo lido incorpora um recurso, e layout.flowColumns: false (pinLegacyFlowColumns) no layout vigente quando um capítulo lido abre um delimitador :::columns em uma linha própria; cada um só se a configuração ainda não o define. Elas também oferecem a um flutuante o topo da coluna de uma abertura de largura de página (#639): um manifesto marcado 10 ou anterior recebe layout.floatsUnderOpener: false (pinLegacyOpenerHeadFloats) no layout vigente quando a configuração mesclada define um nível ou um estilo de título com span: 'page' e um capítulo lido tem um título, só se a configuração ainda não o define. As regras de 12 fazem uma página numa grade de caracteres conter as suas linhas (#717): um manifesto marcado 11 ou anterior cuja configuração mesclada ativa cjk.grid.enabled recebe bodyText.avoidWidows: true, e na região japan bodyText.avoidOrphans: true (pinLegacyGridBreakRules), no bodyText vigente, cada uma só se a configuração ainda não a define. Elas também mantêm dois caracteres de uma nota em duas linhas de cada lado de uma quebra de linha (#720): um manifesto assim recebe cjk.warichu.minChars: 1 (pinLegacyNoteBreaks) no cjk vigente quando um capítulo lido tem uma nota :warichu[ ou a configuração mesclada define citations.notes: 'warichu', só se a configuração ainda não o define. E na região do Japão uma palavra ocidental recebe o espaço entre han e latim junto aos seus parênteses e sinais (#721): um manifesto marcado 11 ou anterior cuja configuração mesclada resolve para a região do Japão recebe cjk.latinSpacingEdge: 'letters' (pinLegacyLatinSpacingEdge) no cjk vigente quando um capítulo lido tem kana ou kanji, só se a configuração ainda não o define. E um recurso posto no texto aceita placement.captionSide como um flutuante de coluna (#729): um manifesto marcado 11 ou anterior cuja configuração mesclada define layout.layoutType: 'oneAndHalf' recebe layout.inlineCaptionSide: false (pinLegacyInlineCaptionSide) quando um capítulo lido incorpora um recurso, só se a configuração ainda não o define.

As regras de 12 também mudam o equilíbrio de uma coluna curta, e um manifesto marcado 11 ou anterior mantém aquilo com que foi composto. A ordem dos recursos (#707): ele recebe headings.balancing.maxLinesBeforeLoose com o valor do seu próprio maxLinesPerHeading, ou 4 quando não define nenhum (pinLegacyLooseOrder), no headings vigente, quando um capítulo lido tem um título e a configuração mesclada equilibra as colunas, deixa um título receber mais de duas linhas e ainda não define a chave. A linha sob uma figura em várias colunas (#708): ele recebe headings.balancing.stretchAfterWideFloats: 'always' (pinLegacyWideFloatStretch) no headings vigente, quando um capítulo lido incorpora ou cita um recurso ou faz um boxe flutuar, e a configuração mesclada equilibra as colunas, não desliga stretchAfterFloats e ainda não define a chave. As quebras dentro dos grupos de um índice (#712): ele recebe index.groups.splitMinEntries: 1 (pinLegacyIndexGroupSplit) no index vigente, quando um capítulo lido tem uma cerca :::index numa linha própria e a configuração ainda não define a chave. A unidade do corte por igual (#711): ele recebe headings.balancing.trailingStep: 'grid' (pinLegacyTrailingStep) no headings vigente, quando um capítulo lido pode pôr texto fora da entrelinha do texto base (uma cerca :::, uma cerca de código, uma citação em bloco, uma lista, uma fórmula em destaque ou uma citação bibliográfica), e a configuração mesclada equilibra as colunas, mantém trailing ou beforeSpan ligado e ainda não define a chave.

As regras de 12 quebram o rótulo de uma referência nos seus espaços quando o rótulo são palavras (#702). Um manifesto marcado 11 ou anterior recebe crossRefs.breakLabels: false (pinLegacyRefLabelBreaks) no crossRefs vigente, quando um capítulo lido contém uma referência (:ref{…}, \ref{…}, \eqref{…} ou um @id do pandoc-crossref) e a configuração não nomeia a opção: todo rótulo continua sendo a única palavra com que foi composto.

As regras de 12 compõem uma palavra sem letras (46., 1367)) na escrita do texto ao redor (#725). Um manifesto marcado 11 ou anterior recebe bodyText.scriptContext: false (pinLegacyScriptContext) no bodyText em vigor, seja qual for o conteúdo dos capítulos, quando a configuração não nomeia o ajuste: essa palavra é composta sozinha, como foi diagramada. A regra atua numa fonte que desenha a pontuação conforme a escrita, e ao ler um manifesto não se sabe quais fontes o livro carrega.

As regras de 13 transformam em ajuste o idioma em que o texto é composto (#764), e as de 14 fazem do idioma do próprio documento o padrão dele (#770). Um manifesto marcado 13 ou anterior recebe bodyText.shapingLanguage: 'host' (pinLegacyShapingLanguage) no bodyText em vigor, seja qual for o conteúdo dos capítulos, quando a configuração não nomeia o ajuste; o que nomeia um valor fica com ele. O texto dele é medido no idioma da página que hospeda o layout, e em nenhum dentro de um worker, como foi diagramado. Um manifesto marcado 13 foi gravado pelo postext 1.29: o 1.29.1 e o 1.29.2 diagramavam no idioma do host um livro que não nomeia nenhum valor, como faz a fixação; o 1.29.0, cujo padrão era 'document', o diagramava no do documento com um canvas e um PDF que não seguiam a medida, e ele também é lido com 'host'. Um manifesto marcado 14 que não nomeia nenhum valor é diagramado no idioma do documento.

As regras de 13 também desenham a caixa de um item de tarefa (- [ ], - [x]): um quadrado que o motor desenha, com uma marca quando a tarefa está concluída, e que ocupa um espaço próprio antes do texto do item (#765). Um manifesto marcado 12 ou anterior recebe unorderedLists.taskBox: 'character' (pinLegacyTaskBox) no unorderedLists em vigor, quando algum capítulo contém um item de tarefa e a configuração não nomeia o ajuste. A caixa dele continua sendo o caractere ☐ ou ☑ composto na fonte da lista, e o texto do item começa depois da largura desse caractere, como foi diagramado. Essa largura vinha da fonte que tivesse o caractere, uma do sistema na maioria dos livros, e não é conhecida ao ler o manifesto.

import { CONFIG_VERSION, migrateConfig } from 'postext/bundle';
 
migrateConfig({ headings: { fontFamily: 'Georgia' } }, undefined, { content: 'A book with no maths.' });
// => { headings: { fontFamily: 'Georgia', levels: [{ level: 1, breakBefore: { enabled: false } }] } }
migrateConfig({ math: { fontSizeScale: 1.2 } }, 3);
// => { math: { fontSizeScale: 1.35746…, marginTop: { value: 0.7072, unit: 'em' }, marginBottom: { value: 0.7072, unit: 'em' } },
//      layout: { inlineResourceGap: 'above', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 },
//      headings: { inlineMarks: false, keepWithNextSplit: 'fill' }, bodyText: { colonListRoom: 'line', breakAfterDashes: false, breakAfterHyphens: false, verse: { layout: 'bayt', tighten: false }, hardLineBreaks: false }, codeStyle: { blocks: false } }
migrateConfig({ layout: { layoutType: 'single' } }, 4, { content: 'Text.\n\n::resource{id="fig"}' });
// => { layout: { layoutType: 'single', inlineResourceGap: 'above' } }
migrateConfig({ layout: { layoutType: 'single' } }, 5, { content: ':::callout\nText.\n\n::resource{id="fig"}\n:::' });
// => { layout: { layoutType: 'single', inlineResourceGapInBoxes: false, boxChildSplitMinLines: 1 } }
migrateConfig({ bodyText: { textAlign: 'left' } }, 6, { content: 'I say—that is all.' });
// => { bodyText: { textAlign: 'left', breakAfterDashes: false, optimalRagged: false } }
migrateConfig({ paragraphStyles: [{ id: 'verse' }] }, 7, { content: ':::paragraphs{style="verse"}\nA line.\n:::' });
// => { paragraphStyles: [{ id: 'verse' }], bodyText: { paragraphContainerSpacing: 'add' } }
migrateConfig({ bodyText: { fontFamily: 'Georgia' } }, 7, { content: 'A well-known tale.' });
// => { bodyText: { fontFamily: 'Georgia', breakAfterHyphens: false } }
migrateConfig(config, CONFIG_VERSION); // regras de hoje: o próprio `config`

content é o Markdown que a configuração diagrama (uma string ou uma lista de capítulos). Sem ele, o tamanho das fórmulas é fixado sempre que as fórmulas estão ativadas, o espaço abaixo dos contêineres sempre que a configuração declara um estilo de parágrafo, e os dois espaços, as marcações dos títulos, o espaço da linha com dois-pontos, o corte de boxes, as quebras em travessões, a divisão abaixo de um título e as quebras em compostos sempre, já que o motor não tem como saber se o livro tem uma fórmula, um contêiner :::paragraphs, uma figura inline, um título com marcações, uma lista introduzida por dois-pontos, um boxe, um travessão colado, um título ou uma palavra composta. A quebra em bandeira é fixada só pela configuração, com ou sem conteúdo. Migre uma configuração armazenada uma única vez e armazene-a de novo com CONFIG_VERSION: a fixação das fórmulas multiplica a escala, então uma configuração migrada duas vezes cresceria duas vezes. Sem ele também se fixam a disposição do verso e as cercas de código, porque o livro pode ter um poema :::verse sem separador ou uma cerca, e os recuos emparelhados são fixados só pela configuração. Também se fixam os versos partidos da 1.23, porque o livro pode compor um poema verso a verso.

#Diagramar e renderizar um pacote

Quatro funções auxiliares ligam um pacote aberto ao motor e aos renderizadores:

  • loadBundleFonts(bundle) registra as variantes do pacote em document.fonts, e os bytes delas no registro de fontes do motor para as imagens SVG (registerFontBytes). Aguarde-a antes de diagramar, porque o layout mede o texto com as fontes que o navegador tem. As famílias que o pacote nomeia mas não traz (Google Fonts) ainda precisam ser carregadas por você, como em qualquer outro documento.
  • registerBundleImages(bundle) decodifica as imagens para o renderizador de canvas (renderPage, renderToCanvas), pôsteres de vídeo incluídos. bundleImageUrl(bundle) é o resolvedor resourceImageUrl para renderToHtml, e bundleVideoUrl(bundle) o seu resolvedor resourceVideoUrl para os arquivos de vídeo que um pacote traz. Os dois recolorem as figuras SVG quando diagramStyle.singleInk está ativado, uma única vez: recolorem a marcação e marcam as imagens para que nenhum renderizador as tinja de novo (veja Tinta única no canvas e no HTML). Os dois também incorporam em cada SVG as variantes que o seu texto nomeia, a partir das fontes do próprio pacote primeiro e, depois, das variantes registradas no motor (veja Fontes no texto dos SVG); registerBundleImages(bundle, { onWarning }) e bundleImageUrl(bundle, { onWarning }) informam uma família sem variante.
  • buildBundle(bundle) diagrama os capítulos em ordem e devolve um VDTDocument por capítulo. Cada capítulo continua o anterior: contadores de títulos e de recursos, a parte aberta, a paridade de página e a numeração de páginas. Um capítulo que imprime o sumário (:::toc) ou o índice remissivo (:::index) recebe a estrutura do livro inteiro. Aceita as mesmas opções que buildDocument, mais config para substituir a configuração do pacote, cache para compartilhar um cache de medição e metadata (veja abaixo).
  • bundleResourceBytes(bundle) e bundleFontProvider(bundle, { decodeWoff2, fallback }) são as opções resourceBytes e fontProvider do renderToPdf do postext-pdf. O provedor de fontes escolhe no pacote o peso mais próximo do estilo pedido. Para uma variante .woff2 ele precisa de decompressWoff2, e para uma família que o pacote não traz ele chama fallback com os argumentos do renderizador, request incluído, e repassa o que ele devolver. Um fallback que busca só o arquivo latin de uma família imprime como caixas vazias uma família chinesa que o pacote não incorpora; um que responde com fatias, como sliceFontProvider em Fontes chinesas, japonesas e coreanas, a imprime inteira.
import { openBundle, loadBundleFonts, registerBundleImages, buildBundle, renderPage,
  bundleResourceBytes, bundleFontProvider } from 'postext';
import { renderToPdf, decompressWoff2 } from 'postext-pdf';
 
const bundle = await openBundle(bytes);
await loadBundleFonts(bundle);
await registerBundleImages(bundle);
 
const docs = buildBundle(bundle);                        // um VDTDocument por capítulo
const firstPage = renderPage(docs[0].pages[0], docs[0]); // um <canvas>
 
const pdf = await renderToPdf(docs, {                    // o livro inteiro
  fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
  resourceBytes: bundleResourceBytes(bundle),
});

Para diagramar você mesmo um único capítulo, passe bundle.chapters[i].markdown, bundle.resources e bundle.config para buildDocument, como faria com qualquer documento.

Os metadados do livro. Como no Sandbox, o front matter do primeiro capítulo é o do livro: buildBundle entrega o seu title, o seu author e o resto a todos os capítulos, de modo que os cabeços {title} e {author} se mantêm em todas as páginas e o doc.metadata de cada capítulo os traz. Um bloco de front matter no início de um capítulo posterior é ignorado: ele é encontrado pelas suas linhas --- e apagado sem ser analisado, então um YAML que o parser rejeitaria não causa dano. options.metadata fornece os valores que o front matter não define (o front matter prevalece). A contagem de páginas do livro também chega a todos os capítulos: {bookTotalPages} a imprime, enquanto {totalPages} conta as do capítulo (veja Contagem de páginas do livro).

const docs = buildBundle(bundle, { metadata: { author: 'A. Author' } });
docs[3].metadata.title;   // o `title:` do primeiro capítulo

Onde o livro começa. Um pacote pode conter parte de uma publicação mais longa: as páginas 58 a 61 de uma edição, o capítulo 4 de um livro didático. Seu start diz o que vem antes, nos termos da continuation de buildDocument: as páginas anteriores à primeira (pageIndexOffset, que decide de que lado cai a página 1 e, com ele, as margens espelhadas e os cabeçalhos de páginas pares e ímpares), a numeração de página em vigor (pageNumbering), os contadores de títulos (headings), a parte aberta (part) e os contadores de recursos, enunciados, notas de rodapé e linhas. createBundle o grava como start em preset.json, openBundle o devolve em bundle.start, e buildBundle compõe com ele o primeiro capítulo, como faria buildDocument({ markdown, continuation: start }, config), e encadeia os seguintes a partir daí; {bookTotalPages} conta também as páginas anteriores ao livro. Um manifesto sem start é lido como antes, e um leitor que não conhece o campo o ignora. bookPageCount não faz parte dele: quem lê conta as páginas. Num pacote com vários idiomas, localized[…].start dá a uma edição um começo próprio.

const { bytes } = await createBundle({
  name: 'Field notes, chapter 4',
  markdown,
  config,
  // Página 58, par, no capítulo 4.
  start: { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3 } },
});
const bundle = await openBundle(bytes);
bundle.start;                 // { pageIndexOffset: 57, pageNumbering: { startAt: 58 }, headings: { h1: 3, h2: 0, … } }
const [doc] = buildBundle(bundle);
doc.pages[0].pageLabel;       // '58'

#Exemplo ao vivo: abrir um pacote

O pen carrega do repositório um livro de exemplo de dois capítulos (lantern.postext, com tipo próprio, uma figura SVG e uma tabela). Ele registra as fontes e as imagens do pacote, diagrama o livro com buildBundle e pinta todas as páginas. Make the PDF renderiza os mesmos documentos com o postext-pdf, incorporando as fontes do pacote. Escolha um arquivo .postext seu, exportado do Sandbox, por exemplo, para vê-lo do mesmo jeito.

Postext · abrir um pacote .postext
import {
  openBundle,
  loadBundleFonts,
  registerBundleImages,
  buildBundle,
  bundleResourceBytes,
  bundleFontProvider,
  renderPage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
 
// A two-chapter book with its own typeface, an SVG figure and a table.
const SAMPLE = 'https://cdn.jsdelivr.net/gh/drnachio/postext@main/docs/examples/open-bundle/lantern.postext';
 
const status = document.getElementById('status');
const pdfButton = document.getElementById('pdf');
let current = null;
 
async function show(data) {
  // Chapters, config (fonts wired to the bundle's own files), resources and
  // every file, keyed by its path inside the bundle.
  const bundle = await openBundle(data);
 
  // Layout measures text with the fonts the browser has: register the
  // bundle's faces, and load the Google Fonts it names but does not carry
  // (the default running heads use Open Sans; see the pen's CSS).
  await loadBundleFonts(bundle);
  await document.fonts.load('600 16px "Open Sans"');
  await registerBundleImages(bundle);
 
  // One VDTDocument per chapter, each continuing the one before it.
  const docs = buildBundle(bundle);
  const pages = docs.flatMap((doc) => doc.pages.map((page) => renderPage(page, doc)));
  document.getElementById('pages').replaceChildren(...pages);
  status.textContent = `${bundle.name} · ${bundle.chapters.length} chapter(s) · ${pages.length} page(s)`
    + (bundle.warnings.length ? ` · ${bundle.warnings.length} warning(s)` : '');
  current = { bundle, docs };
  pdfButton.disabled = false;
  document.getElementById('links').hidden = true;
}
 
// Fonts the bundle does not carry come from Fontsource.
async function fontsource(family, weight, style) {
  const id = family.toLowerCase().replace(/\s+/g, '-');
  const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@latest/files/${id}-latin-${weight}-${style}.woff2`);
  if (!res.ok) throw new Error(`font fetch failed: ${res.status} ${family}`);
  return decompressWoff2(new Uint8Array(await res.arrayBuffer()));
}
 
pdfButton.addEventListener('click', async () => {
  pdfButton.disabled = true;
  status.textContent = 'Rendering the PDF…';
  const { bundle, docs } = current;
  const bytes = await renderToPdf(docs, {
    fontProvider: bundleFontProvider(bundle, { decodeWoff2: decompressWoff2, fallback: fontsource }),
    resourceBytes: bundleResourceBytes(bundle),
  });
  const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
  document.getElementById('open').href = url;
  document.getElementById('download').href = url;
  document.getElementById('links').hidden = false;
  status.textContent = `${bundle.name} · ${(bytes.length / 1024).toFixed(0)} KB PDF`;
  pdfButton.disabled = false;
});
 
document.getElementById('file').addEventListener('change', async (event) => {
  const file = event.target.files[0];
  if (!file) return;
  status.textContent = `Opening ${file.name}…`;
  await show(file).catch((err) => { status.textContent = `Could not open ${file.name}: ${err.message}`; });
});
 
const res = await fetch(SAMPLE);
await show(await res.arrayBuffer());
index.html
<p>
  <label>Open a .postext file: <input id="file" type="file" accept=".postext,application/zip"></label>
  <button id="pdf" disabled>Make the PDF</button>
  <span id="links" hidden>
    <a id="open" target="_blank" rel="noopener">open it</a> ·
    <a id="download" download="book.pdf">download it</a>
  </span>
</p>
<p id="status">Loading the sample book…</p>
<div id="pages"></div>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
#pages {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: flex-start;
}
#pages canvas {
  display: block;
  width: 240px;
  height: auto;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

Carrega um editor interativo de codepen.io. O exemplo importa a versão mais recente do postext de uma CDN.

#Criar um pacote

createBundle grava um arquivo .postext a partir de um documento: os seus capítulos, a configuração, os recursos e os conteúdos a que eles fazem referência.

import { createBundle } from 'postext';
 
const { bytes, manifest, warnings } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters: [
    { markdown: '# Dusk\n\nIt is drawn in :ref{id="lantern"}.' },
    { title: 'Night', markdown: '# Night\n\n…' },
  ],
  config,
  resources: [{
    id: 'lantern', typeId: 'figure', kind: 'svg', caption: 'The lantern.',
    svg: { fileId: 'lantern.svg', width: 240, height: 150 },
    createdAt: 0, updatedAt: 0,
  }],
  files: { 'lantern.svg': svgMarkup, 'garamond-regular': fontBytes },
});
EntradaSignificado
name, id, description, localeOs metadados do manifesto. id assume por padrão um slug de name.
chapters ou markdownO livro, um { title?, markdown } por capítulo, ou um documento único.
configO PostextConfig. Os valores iguais aos padrões ficam fora do manifesto.
resourcesOs recursos. Uma imagem nomeia o seu conteúdo por bitmap.fileId / svg.fileId (e svg.pdfFileId para uma matriz de impressão).
filesOs conteúdos por fileId (um objeto ou um Map): as imagens a que os recursos fazem referência e os arquivos de fonte a que as variantes de config.customFonts fazem referência. Os valores podem ser um Uint8Array, um ArrayBuffer, um Blob ou uma string (marcação SVG).
thumbnail{ data, mime }: uma imagem de capa (PNG, JPEG, WebP, GIF ou SVG).
canvasScope'book' pede aos visualizadores que diagramem o livro inteiro como um único canvas.
startOnde o livro começa, para um pacote que contém parte de um mais longo: a continuation com que um documento avulso seria composto. É gravado como start do manifesto.
mtimeA data de modificação gravada em todos os arquivos do zip (um Date, um timestamp ou uma string de data). Se omitida, é o momento da chamada, então duas chamadas com a mesma entrada geram bytes diferentes. Passe uma data fixa e a mesma entrada gera os mesmos bytes, que você pode comparar ou calcular o hash. Um zip guarda data e hora sem fuso horário, em passos de dois segundos, de 1980 a 2099, e a data é gravada no horário local da máquina. Para bytes que coincidam em qualquer máquina, construa a data a partir de campos locais, como new Date(1980, 0, 1): um timestamp ou uma string terminada em Z designa um instante, que cai em um horário local diferente em cada fuso ('1980-01-01T00:00:00Z' ainda é 1979 a oeste de UTC). Uma data fora desses anos, em horário local, lança um erro.
localizedMais idiomas do mesmo livro, por tag de idioma: { es: { chapters?, config?, resources? } }. As entradas acima passam então a ser o conteúdo de locale, que se torna obrigatório. Veja Pacotes bilíngues.

Ela devolve os bytes do zip, o manifest gravado como preset.json, todos os arquivos como files (caminho → bytes) e uma lista de warnings. Os arquivos recebem o nome do id do seu recurso (resources/lantern.svg) ou do nome do arquivo de fonte (fonts/…), e os capítulos, o da sua ordem e do seu título (chapters/01-dusk.md). As fontes são declaradas no fonts do manifesto, nunca dentro de config.customFonts. Algumas coisas ficam de fora, cada uma com um aviso:

  • um recurso ou uma variante de fonte cujo conteúdo não está em files
  • uma variante .woff (o renderizador de PDF não consegue incorporá-la)
  • uma família marcada com redistributable: false

No navegador, entregue bytes a um link de download: URL.createObjectURL(new Blob([bytes], { type: 'application/zip' })). No Node, grave-os com fs.writeFile. createBundle e openBundle não precisam de DOM. Os dists usam caminhos de módulo sem extensão, então no Node puro, sem bundler, eles precisam de um hook de resolução. O arquivo docs/examples/open-bundle/build-sample.mjs do repositório mostra um em poucas linhas.

#Pacotes bilíngues

Um arquivo .postext pode levar um livro em vários idiomas, e openBundle(bytes, { locale }) o lê em qualquer um deles. createBundle grava um a partir de localized: uma entrada por idioma extra, cada uma com o que difere do conteúdo principal (a entrada locale):

const { bytes, manifest } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters: [{ markdown: '# Dusk\n\n…' }, { markdown: '# Night\n\n…' }],
  config,
  resources: [lanternFigure, hoursTable],
  files: { 'lantern.svg': svgEn, 'lantern-es.svg': svgEs },
  localized: {
    es: {
      chapters: [{ markdown: '# Anochecer\n\n…' }, { markdown: '# Noche\n\n…' }],
      config: { headings: { levels: [{ level: 1, numberingTemplate: 'Capítulo {1}' }] } },
      resources: [
        { id: 'lantern', caption: 'El farol.', svg: { fileId: 'lantern-es.svg', width: 240, height: 150 } },
        { id: 'hours', caption: 'Horas de luz.' },
      ],
    },
  },
});
 
const es = await openBundle(bytes, { locale: 'es' });   // capítulos, configuração e legendas em espanhol
  • chapters: o livro naquele idioma. Os arquivos de capítulo vão para uma pasta por idioma (chapters/en/01-dusk.md, chapters/es/01-anochecer.md) e o chapters do manifesto vira um mapa idioma → capítulos. Um idioma sem chapters lê os principais; quando nenhum idioma tem capítulos próprios, eles continuam sendo uma única lista.
  • config: a configuração para aquele idioma. Cada chave de primeiro nível substitui por inteiro a chave compartilhada quando o pacote é lido naquele idioma, então o headings acima substitui o objeto headings inteiro. As chaves omitidas, ou iguais às compartilhadas, são compartilhadas e não são gravadas, então passar a configuração completa do idioma funciona tão bem quanto passar só as poucas chaves que mudam. Uma chave definida com os seus padrões enquanto a compartilhada não está (layout: {}) é gravada como foi dada, de modo que redefine o valor compartilhado. As fontes são compartilhadas: as famílias do customFonts de um idioma se juntam ao fonts do pacote.
  • resources: o texto dos recursos compartilhados, associados pelo id: caption, note, altText e a table de uma tabela. Uma imagem com palavras pode ter a sua própria arte: bitmap.fileId ou svg.fileId (e svg.pdfFileId) nomeiam outro conteúdo em files, gravado como resources/es/lantern.svg. Os outros campos, como o tipo ou o posicionamento, são compartilhados. Um id que não está entre os resources fica de fora com um aviso, e uma imagem de idioma que falta deixa o idioma com a compartilhada, também com um aviso.

O manifesto lista todos os idiomas em locales (['en', 'es']), mantém o principal como locale e guarda o resto em localized. openBundle sem idioma lê o idioma principal.

Que idioma o leitor recebe. openBundle(bytes, { locale }) serve o idioma exato; na falta dele, o idioma base (es-MX lê es); e, na falta deste, o principal. bundle.locale diz qual foi servido. Os capítulos e o texto vêm sempre do mesmo idioma. O idioma principal mantém o texto compartilhado mesmo quando localized traz uma variante regional dele: um pacote pt-PT com uma entrada pt-BR lê as legendas brasileiras só para pt-BR, e as compartilhadas para pt-PT e pt.

#Exemplo ao vivo: criar um pacote

O pen constrói um livro de dois capítulos com uma figura SVG e lista os arquivos que createBundle gravou, junto com o manifesto. Ele oferece o zip para download e depois o abre de novo com openBundle e pinta a primeira página: o percurso completo em poucas linhas. Importe o arquivo baixado no Sandbox para continuar trabalhando nele lá.

Postext · criar um pacote .postext
import { createBundle, openBundle, registerBundleImages, buildBundle, renderPage } from 'https://esm.sh/postext';
 
// A picture resource names its payload by fileId; the bytes (here, SVG
// markup) go in `files` under that same id.
const lanternSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 150">
  <rect width="240" height="150" fill="#f3efe6"/>
  <path d="M100 36 h40 l8 14 h-56 z" fill="#2f3e46"/>
  <rect x="98" y="50" width="44" height="58" rx="4" fill="#f6c453" stroke="#2f3e46" stroke-width="4"/>
  <circle cx="120" cy="79" r="11" fill="#fff4c2"/>
  <path d="M94 108 h52 l-6 12 h-40 z" fill="#2f3e46"/>
</svg>`;
 
const resources = [{
  id: 'lantern',
  typeId: 'figure',
  kind: 'svg',
  caption: 'The lantern by the door.',
  svg: { fileId: 'lantern.svg', width: 240, height: 150 },
  createdAt: 0,
  updatedAt: 0,
}];
 
const text = 'The lantern hung from a nail by the door, and every evening someone lit it. Nobody remembered who had put the nail there, or why the lantern was never moved.';
 
// One entry per chapter; a chapter without a title takes its first # heading.
const chapters = [
  { markdown: `# Dusk\n\n${text} It is drawn in :ref{id="lantern"}.\n\n${text}\n\n${text}` },
  { markdown: `# Night\n\n${text}\n\n${text}` },
];
 
const config = {
  layout: { layoutType: 'double' },
  // Two short chapters that run on, with no blank verso between them (an
  // H1 otherwise opens on a fresh recto), as in the open-bundle sample.
  headings: { levels: [{ level: 1, numberingTemplate: 'Chapter {1}', breakBefore: { enabled: false } }] },
};
 
// Everything a .postext file holds: manifest, chapters, resources, fonts.
const { bytes, manifest, files, warnings } = await createBundle({
  name: 'The Lantern',
  locale: 'en',
  chapters,
  config,
  resources,
  files: { 'lantern.svg': lanternSvg },
});
if (warnings.length) console.warn(warnings);
 
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/zip' }));
document.getElementById('download').href = url;
document.getElementById('actions').hidden = false;
document.getElementById('files').replaceChildren(...Object.entries(files).map(([path, data]) => {
  const li = document.createElement('li');
  li.textContent = `${path} (${data.length} B)`;
  return li;
}));
document.getElementById('manifest').textContent = JSON.stringify(manifest, null, 2);
 
// Round trip: open the file just written, the way any program would.
await Promise.all([
  document.fonts.load('16px "EB Garamond"'),
  document.fonts.load('bold 16px "EB Garamond"'),
  document.fonts.load('bold 16px "Open Sans"'), // the default heading face
]);
const bundle = await openBundle(bytes);
await registerBundleImages(bundle);
const [firstChapter] = buildBundle(bundle);
document.getElementById('page').replaceChildren(renderPage(firstChapter.pages[0], firstChapter));
document.getElementById('status').textContent =
  `${bundle.name}: ${bundle.chapters.length} chapters, ${(bytes.length / 1024).toFixed(1)} KB`;
index.html
<p id="status">Building the bundle…</p>
<p id="actions" hidden>
  <a id="download" download="lantern.postext">Download lantern.postext</a> ·
  <a href="https://postext.dev/en/sandbox" target="_blank" rel="noopener">open the Sandbox</a> and import it (Projects → New → Import .postext…)
</p>
<div id="output">
  <section>
    <h3>Files in the bundle</h3>
    <ul id="files"></ul>
    <h3>preset.json</h3>
    <pre id="manifest"></pre>
  </section>
  <section>
    <h3>Opened again: page 1</h3>
    <div id="page"></div>
  </section>
</div>
style.css
body {
  margin: 16px;
  font-family: system-ui, sans-serif;
  background: #e8e8e8;
}
#output {
  display: flex;
  flex-wrap: wrap;
  gap: 24px;
  align-items: flex-start;
}
#output section {
  flex: 1 1 280px;
  min-width: 0;
}
h3 {
  margin: 8px 0;
  font-size: 14px;
}
ul {
  margin: 0;
  padding-left: 20px;
  font-family: ui-monospace, monospace;
  font-size: 13px;
}
pre {
  max-height: 320px;
  overflow: auto;
  padding: 8px;
  background: #fff;
  font-size: 12px;
}
#page canvas {
  display: block;
  max-width: 100%;
  height: auto;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

Carrega um editor interativo de codepen.io. O exemplo importa a versão mais recente do postext de uma CDN.

#Trabalhar com pacotes

Como o Sandbox, a skill para agentes e o pacote postext leem e gravam o mesmo arquivo, um arquivo .postext é uma forma prática de passar um livro de uma ferramenta para outra:

  • Comece por um pacote. Porte uma publicação existente com a skill para agentes, ou faça o design de um livro no Sandbox e exporte-o (Baixar (.postext) no menu ⋯ da linha do livro no painel Livros). Carregue o arquivo no seu programa com openBundle para renderizá-lo em canvas, HTML ou PDF. Mantenha o arquivo como fonte do livro: edite os capítulos, a configuração ou os recursos no código e grave-o de volta com createBundle, ou simplesmente recarregue-o sempre que ele mudar.
  • Depure e faça o ajuste fino no Sandbox. Quando algo na saída do seu programa precisa de ajuste (uma figura que cai na página errada, um estilo de título, o equilíbrio das colunas), exporte com createBundle o que o seu programa diagrama. Importe esse arquivo no Sandbox (Livros → Novo → Abrir um arquivo .postext…), corrija o texto, o design ou as figuras com a visualização ao vivo, o painel Verificações e a vista PDF, e exporte-o de novo. O seu programa então carrega o arquivo corrigido com openBundle. Ou copie o que mudou de volta para o seu código: o config do manifesto contém só os valores que diferem dos padrões, então ele se lê como um diff curto.

#API de baixo nível

postext/bundle também exporta as peças sobre as quais openBundle e createBundle são construídas, para aplicações que guardam ou servem pacotes do seu próprio jeito (um diretório descompactado servido por HTTP, registros em um banco de dados):

  • openBundleZip(bytes) / zipBundle(files, { mtime }): a camada do arquivo compactado. Na abertura, tolera uma pasta raiz e ignora as entradas __MACOSX e os arquivos ocultos. Caminhos que saem do pacote são recusados. mtime data os arquivos como o campo de mesmo nome na entrada de createBundle.
  • readBundle(manifest, readFile, options) lê um manifesto e uma função readFile(path) e devolve capítulos, configuração, recursos, imagens e fontes. options define o idioma, como os identificadores de arquivo são nomeados (ids), a configuração base (baseConfig, por baixo da do manifesto; por padrão, a paleta e os tipos de recurso de bundleBaseConfig no idioma do pacote, que resolveBundleConfigLocale(manifest, locale) devolve, e uma aplicação que passe a sua própria baseConfig deve localizá-la para esse idioma; com um manifesto anterior a configVersion: 4, o seu math é fixado com o do pacote; com um anterior a 5, o espaço em linha do seu layout; com um anterior a 6, o espaço nos boxes do seu layout, a folga depois de dois-pontos do seu bodyText, as marcas em linha dos seus headings e o tamanho das suas capitulares; com um anterior a 7, as quebras depois de travessão e a composição em bandeira do seu bodyText; e com um anterior a 8, a divisão logo abaixo de um título dos seus headings e as quebras depois do hífen de palavras compostas e o espaço sob os contêineres :::paragraphs do seu bodyText; veja Pacotes gravados pelo postext 1.4 ou anterior) e como os tamanhos intrínsecos são medidos. readResolution lê do arquivo a resolução de cada bitmap e a guarda em bitmap.fileResolution; por padrão vale true quando o pacote define layout.bitmapResolution: 'file'.
  • planBundle(meta, content) / resolveBundleFiles(plan, sources): o lado da gravação, dividido em um plano puro (nomes de arquivo e manifesto) e na resolução dos bytes por meio das funções readBlob / readFont.
  • isBundleManifest(value), as funções que escolhem o idioma (pickChapterSpecs, pickLocaleOverrides, pickBundleView, resolveBundleLocale, resolveBundleConfigLocale), svgSize / bitmapSize / bitmapInfo (os pixels de um bitmap e a resolução que o arquivo informa) e os tipos do formato (BundleManifest, BundleResourceSpec, BundleFontFamilySpec, …).
  • CONFIG_VERSION, migrateConfig(config, configVersion, { content }), pinLegacyHeadingBreaks(config), pinLegacyMathSize(config), pinLegacyInlineGap(config), pinLegacyBoxResourceGap(config), pinLegacyHeadingMarks(config), pinLegacyDropCapSize(config), pinLegacyColonListRoom(config), pinLegacyBoxChildCut(config), pinLegacyDashBreaks(config), pinLegacyRaggedBreaking(config), pinLegacyHeadingSplit(config), pinLegacyParagraphContainerSpacing(config), pinLegacyHyphenBreaks(config) e LEGACY_MATH_SIZE (0,5 ÷ 0,442): uma configuração guardada, expressa nos termos atuais (veja Pacotes gravados pelo postext 1.4 ou anterior). readBundle aplica isso; uma aplicação que guarda configurações do seu próprio jeito também pode aplicar, uma vez por cópia guardada.

O Sandbox é construído sobre essas peças. Ele acrescenta os seus próprios identificadores de armazenamento e os registros de páginas de layouts.json.