Em poucas palavras
A folha técnica de quatro páginas de um sensor eletrônico de temperatura. Mostra como montar tabelas com dados de uma planilha, com títulos compartilhados e uma tabela longa que continua na página seguinte.
O que você vai compor
Este é o datasheet de quatro páginas do PX-7021, um sensor de temperatura de um fabricante imaginário, a Pyxis Microdevices. Quase tudo são tabelas, com cabeçalhos cheios de células mescladas e um mapa de registradores mais comprido que uma coluna. A primeira página abre com uma faixa violeta com o código da peça e três números-chave. Na página 2, uma tabela ocupa a largura da página. O cabeçalho de duas linhas põe Value sobre Min, Typ e Max; os grupos de parâmetros são mesclados ao longo da primeira coluna, e um parâmetro a cada dois recebe fundo cinza. O mapa de registradores quebra no pé da página 3 e continua na página 4 com o cabeçalho repetido, ao lado dos códigos de pedido e acima do desenho do encapsulamento. As tabelas são dados de planilha colados como TSV, porque o Postext não interpreta tabelas Markdown.
Esta receita responde a
- Como faço uma tabela com linhas de cabeçalho, células mescladas, larguras de coluna e alinhamento por célula?
- Como divido uma tabela longa entre páginas com o cabeçalho repetido e um aviso de “continua”?
- Como dou estilos diferentes a várias tabelas (preenchimentos, células zebradas, molduras arredondadas) em um mesmo documento?
- Como mantenho os diagramas nítidos e com texto selecionável no PDF (SVG, originais para impressão)?
- Como adiciono imagens e tabelas a partir do código (recursos) em vez de usar ![]() do Markdown?
A resposta curta
const at = (row, column) => ({ row, col: column });
const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) });
const C = { group: 0, param: 1, symbol: 2, conditions: 3, min: 4, max: 6, unit: 7 }; // columns
function electricalTable(tsv) {
// In 1.4.1 parseTSV makes plain cells and leaves headerRowCount unset: the head is two rows.
let m = Object.assign(parseTSV(tsv), { headerRowCount: 2,
columnWidths: [22, 44, 16, 38, 15, 15, 15, 15] }); // weights: mm of the 180 mm measure
// 'Parameter' covers two columns and two rows; 'Value' spans Min, Typ and Max. mergeCells
// marks the covered cells hiddenBy, so no column shifts (gotcha: merged-cells-hiddenby).
for (const range of [span(0, C.group, 1, C.param), span(0, C.symbol, 1, C.symbol),
span(0, C.conditions, 1, C.conditions), span(0, C.min, 0, C.max),
span(0, C.unit, 1, C.unit)]) {
m = mergeCells(m, range); // 'Value' is centred over its three columns, the rest set left
m = setAlignment(m, range.start, range.start.col === C.min ? 'center' : 'left', 'middle');
}
// Min, Typ and Max go right, over their figures; setAlignment clears a vAlign it is not given.
for (let c = C.min; c <= C.max; c++) m = setAlignment(m, at(1, c), 'right');
let zebra = false;
for (let r = m.headerRowCount; r < m.rows.length; r++) {
const row = m.rows[r];
if (row[C.param].content) zebra = !zebra; // a parameter keeps one fill over its conditions
for (let c = 0; c < row.length; c++) {
// An empty cell continues the one above: a group, or a parameter and its symbol.
if (c <= C.symbol && row[c].content) {
let end = r;
while (m.rows[end + 1] && !m.rows[end + 1][c].content
&& (c === C.group || !m.rows[end + 1][C.param].content)) end++;
m = mergeCells(m, span(r, c, end, c));
}
// Figures flush right, as datasheets set them (no decimal tab: gap tab-stops).
m = setAlignment(m, at(r, c), c >= C.min && c <= C.max ? 'right' : 'left', 'middle');
// A table style has no zebra rows, so they are filled cell by cell (gotcha: no-zebra). Each
// helper returns a new model, cheap at 20 rows; for thousands, set the cell fields directly.
if (c === C.group) m = setCellBackground(m, at(r, c), col('tint'));
else if (zebra) m = setCellBackground(m, at(r, c), col('zebra'));
}
}
return m;
}
Ingredientes
- Funcionalidades
- Tabelas a partir de dadosTabelas que passam de páginaPreenchimento de célulasEstilos de tabela com nomeEstilo de tabelaEstilo de legendaAtributos de títuloSobrescritos e subscritosChips no textoFiguras e tabelas como recursosPosicionamento de figurasAberturas desenhadasFaixa de capítulo em largura totalImagens nos designs de páginaCabeços e fóliosTítulos numeradosBoxesPaleta de cores semânticaExportação para PDF
- Também usa
- Citações que posicionam as figurasEstilos de títuloFigura e Tabela no seu idiomaQuebras de página e de colunaCabeços por tipo de páginaEstilos de parágrafoFontes incorporadas ao PDFTipos de recurso personalizados
- Tipografia
- Fira Sans, Fira Sans Condensed, Fira Mono (SIL OFL 1.1)
- Materiais
- Nenhum: todas as imagens são desenhadas em código
Preparo
#1 · Colar os dados e depois dar forma à tabela
O código é a resposta curta acima. No postext 1.4.1, parseTSV deixa headerRowCount sem definir, então as duas linhas de cabeçalho são declaradas à mão; se a tabela se dividisse, as duas se repetiriam em cada parte. Os pesos de columnWidths somam 180, a largura da mancha em milímetros, de modo que cada peso é a largura da sua coluna na página. mergeCells mantém no lugar cada célula coberta e a marca com hiddenBy, assim nenhuma coluna se desloca. setAlignment trabalha célula por célula e apaga o alinhamento vertical quando você não o informa, por isso a maioria das chamadas passa 'middle' de novo. O cinza alterna de um parâmetro para o seguinte, o que mantém as duas linhas de Accuracy no mesmo fundo; alternando por linha, a segunda ficaria branca.
#2 · Cada modelo vira um recurso
const svgResource = (id, caption, altText, [w, h], placement) => ({ id, typeId: 'figure',
kind: 'svg', caption, altText, placement, createdAt: 0, updatedAt: 0,
svg: { fileId: `${id}.svg`, width: w * SCALE, height: h * SCALE } }); // fitted to its slot
const table = (id, caption, model, { styleId, placement, note } = {}) => ({ id, typeId: 'table',
kind: 'table', caption, note, placement, table: { model, styleId }, createdAt: 0, updatedAt: 0 });
// A float takes the first free slot after its first :ref; the logo is never cited, only drawn.
const resources = [
svgResource('logo', '', 'Pyxis Microdevices', LOGO),
svgResource('pinout', 'Pin configuration, 8-pin DFN, top view.', 'The package from above: '
+ 'pins 1 to 4 down the left side, 5 to 8 up the right.', PINOUT),
svgResource('circuit', 'Typical application, bus address 48h.', 'The sensor with a 100 nF '
+ 'capacitor, address pins to ground, and SDA, SCL and ALERT pulled up to a host.', CIRCUIT),
svgResource('outline', 'Package outline and land pattern, 8-pin DFN, in mm.', 'Top, bottom '
+ 'and side views of the 2 × 2 mm body, and the land pattern with its two vias.', OUTLINE,
{ position: 'bottom', span: 'page' }), // a strip across the foot of a page
table('electrical', 'Electrical characteristics, *V*~DD~ = 1.6 V to 5.5 V and *T*~A~ = −40 °C '
+ 'to 125 °C unless noted', electricalTable(electrical), { styleId: 'electrical',
placement: { position: 'top', span: 'page' }, note: 'Typical values at 3.3 V and 25 °C. '
+ '^1^ Tested at 25 °C and 50 °C, the rest by characterization. ^2^ Characterized, not '
+ 'tested in production. ^3^ One conversion a second, bus idle.' }),
// No placement: these float, and only a floated table splits (gotcha: here-table-no-split).
table('pins', 'Pin functions', groupedTable(pins, [9, 16, 10, 52]), { styleId: 'grouped',
note: 'Types: P power, G ground, I input, O open-drain output, I/O open-drain input and '
+ 'output.' }),
table('registers', 'Register map', groupedTable(registers, [9, 17, 11, 50]), {
styleId: 'grouped', note: 'Reset values apply at power-on and after a general-call reset.' }),
table('ordering', 'Order codes', Object.assign(parseTSV(ordering), { headerRowCount: 1,
columnWidths: [23, 29, 18, 17] }), { styleId: 'ordering',
note: 'WLCSP-4: fixed address 48h, no ALERT output.' }),
];
Cada tabela e cada desenho é um recurso com legenda, e as tabelas acrescentam uma nota e um estilo nomeado. Legendas e notas aceitam itálico, subscrito e sobrescrito: a tabela elétrica dá as condições de teste com *V*~DD~ na legenda e numera as notas com ^1^ na nota. Um flutuante ocupa o primeiro espaço livre depois do seu primeiro :ref. A Tabela 1 é citada na página 1 e posicionada na cabeça de uma página, na largura toda, então abre a página 2. O desenho do encapsulamento vai no pé de uma página, na largura toda, e as outras tabelas mantêm o posicionamento padrão, um flutuante com a largura de uma coluna. Uma tabela posicionada here nunca se divide.
#3 · Deixar uma tabela longa se dividir sozinha
function groupedTable(tsv, columnWidths) {
let m = Object.assign(parseTSV(tsv), { headerRowCount: 1, columnWidths });
const codes = [0, 2]; // Pin and Type, Addr. and Reset: short codes, centred, in a bare chip
// A TSV cell holds no line break: the data writes \n, and a line opening with • is a list.
m.rows = m.rows.map((row, r) => row.map((cell, c) => ({ ...cell,
content: r > 0 && row[1].content && codes.includes(c) ? `:chip[${cell.content}]{style="code"}`
: cell.content.replaceAll('\\n', '\n') })));
for (let r = 0; r < m.rows.length; r++) {
const last = m.rows[r].length - 1;
if (r >= m.headerRowCount && !m.rows[r][1].content) { // a lone first cell heads a group
m = setCellBackground(mergeCells(m, span(r, 0, r, last)), at(r, 0), col('tint'));
} else for (const c of codes) m = setAlignment(m, at(r, c), 'center');
}
return m; // no split code: the engine cuts it between rows and repeats the head
}
A tabela de pinos e o mapa de registradores saem os dois de groupedTable. Uma linha com só a primeira célula preenchida é mesclada na largura da tabela e recebe o fundo violeta de título de grupo. Uma célula TSV não comporta quebra de linha, então os dados escrevem \n e a função a transforma numa quebra de verdade; uma linha que começa com • é então composta como item de lista. Os códigos curtos usam o estilo de chip code, que não tem fundo, borda nem preenchimento lateral e os compõe em Fira Mono a 0,9 em. Quando uma tabela flutuante não cabe na coluna vazia que lhe é oferecida, o motor a corta entre linhas e põe o resto no próximo espaço livre, sob o cabeçalho repetido, com (continued) depois da legenda. A linha de continuesMarker, “Continued on the next page”, vai sob a primeira parte, e a nota espera pela última (Tabelas mais altas que a página).
#4 · Um estilo da casa, uma variante por tabela
tableStyle: { headerBackground: col('brand'), headerColor: col('paper'), headerFontFamily: COND,
headerFontSize: pt(8.5), bodyFontSize: pt(8), rules: 'horizontal', borderColor: col('rule'),
borderWidth: pt(0.5), cellPadding: mm(1.3) },
tableStyles: [ // a resource picks one with table.styleId
// White rules cut the fills apart and show where each merge and each group ends.
{ id: 'electrical', borderColor: col('paper'), borderWidth: pt(1.4), cellPadding: mm(1.1) },
// Written out although it is the default: 'clip' and 'hide' cut a table taller than the page.
{ id: 'grouped', overflow: 'split', continuedSuffix: '(continued)',
continuesMarker: 'Continued on the next page' },
// A compact list in the condensed face, boxed by a rounded outer frame.
{ id: 'ordering', headerBackground: col('tint'), headerColor: col('brand'),
rules: 'outer', borderRadius: mm(1.5), bodyFontFamily: COND },
],
captionStyle: { fontFamily: COND, fontSize: pt(9.5), labelColor: col('brand'), gap: mm(2),
note: { fontSize: pt(7.5), color: col('muted') } },
tableStyle guarda o que todas as tabelas compartilham. Cada entrada de tableStyles define só o que as suas tabelas mudam, e um recurso escolhe uma com table.styleId. A tabela elétrica troca os fios finos cinza por fios brancos de 1,4 pt, que separam os fundos e mostram onde termina cada mesclagem. A tabela de códigos de pedido ganha uma moldura externa arredondada e a Fira Sans Condensed, para que as quatro colunas caibam numa coluna de texto.
#5 · Rótulos que continuam texto no PDF
// An SVG drawn as an image cannot use the page's fonts (gotcha: svg-no-webfonts): the PDF sets
// its labels as real text in the faces it embeds; the canvas copy embeds the face itself.
async function fontFace(family) { // the same TTF the PDF embeds, from the pdf kit block
const ttf = await fontsourceProvider(family, 400, 'normal');
const base64 = btoa(Array.from(ttf, (b) => String.fromCharCode(b)).join(''));
return `@font-face{font-family:'${family}';src:url(data:font/ttf;base64,${base64})}`;
}
async function loadDrawing(fileId, markup, face) {
await loadSvg(fileId, markup); // registers the plain SVG and keeps its bytes for the PDF
const img = new Image();
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(
markup.replace(/<svg[^>]*>/, (tag) => `${tag}<style>${face}</style>`))}`;
await img.decode();
registerResourceImage(fileId, img); // replaces the canvas copy only
}
Um SVG chega ao canvas como imagem, e uma imagem não pode usar as fontes web da página, então os rótulos da pinagem, do circuito e do desenho do encapsulamento cairiam numa fonte do sistema. O PDF recebe a marcação pura e compõe cada <text> como texto selecionável nas fontes que incorpora. Para o canvas, cada desenho é registrado uma segunda vez com o arquivo da Fira Mono dentro, baixado uma única vez pelo carregador de fontes do bloco pdf do kit.
#6 · Abertura, cabeço e rodapé
const place = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
...(size && { size }) });
const inset = (edge, y, size) => // a page corner, moved in by the side margin
place('page', edge, edge.endsWith('left') ? MARGIN.x : -MARGIN.x, y, size);
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
content, fontFamily: family, fontSize: pt(size), color: col(color), placement, ...extra });
const caps = (size, weight = 600) => ({ fontWeight: weight, letterSpacing: pt(size * 0.16),
textTransform: 'uppercase' });
const badge = { ...caps(7.5, 700), box: { backgroundColor: col('hazard'),
padding: { top: pt(1.6), bottom: pt(1.4), left: pt(4), right: pt(4) } } };
const mark = (size, edge, y) => ({ kind: 'image', id: 'mark', resourceId: 'logo', // by id
placement: inset(edge, y, { width: mm(size) }) });
const maker = (size, color, y) => text('maker', 'Pyxis Microdevices', COND, size, color,
place('#mark', 'right-of', size / 4, y), caps(size));
const keyFigure = (n, y) => [ // {attr.k1} over its label {attr.k1l}, flush right on the band
text(`k${n}`, `{attr.k${n}}`, COND, 22, 'paper', inset('top-right', y), { fontWeight: 600 }),
text(`k${n}l`, `{attr.k${n}l}`, COND, 7, 'tint', inset('top-right', y + 9), caps(7))];
const BAND = 84; // mm: the violet band across the head of page 1
// The band under the top margin and 9 mm or more of white, in whole grid lines (16 here).
const OPENER_LINES = Math.ceil((BAND - MARGIN.y + 9) / (LEAD * 25.4 / 72));
const opener = {
enabled: true, minHeight: pt(OPENER_LINES * LEAD), // so the columns under it start on the grid
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('brand') }, placement: {
anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } },
mark(6, 'top-left', 12), maker(8.5, 'paper', 1.7),
text('doc', 'Datasheet {subtitle} · {publishDate}', MONO, 7.5, 'tint',
inset('top-right', 13.6)),
text('kicker', '{attr.kicker}', COND, 9.5, 'tint', inset('top-left', 30), caps(9.5)),
text('title', '{titleText}', COND, 64, 'paper', place('#kicker', 'below', 0, 0.5),
{ fontWeight: 700, lineHeight: 1 }),
// A design text that overflows its width ends in '…' (gotcha: overflow-ellipsis-default).
text('lead', '{attr.lead}', 'Fira Sans', 12, 'paper', place('#title', 'below', 0, 2.5,
{ width: mm(108) }), { lineHeight: 1.32, align: 'left', overflow: 'wrap' }),
text('status', 'Preliminary', COND, 7.5, 'ink', place('#lead', 'below', 0, 4.5), badge),
...[1, 2, 3].flatMap((n) => keyFigure(n, 15 + 15 * n)),
] },
};
const header = { elements: [ // body pages only: the opener has its band
text('running-title', '{chapterTitle}', COND, 9, 'brand', inset('top-left', 10.4),
{ fontWeight: 700, pages: 'body' }),
text('flag', 'Preliminary', COND, 7.5, 'ink', inset('top-right', 10.2),
{ ...badge, pages: 'body' }),
{ kind: 'rule', id: 'hairline', pages: 'body', thickness: pt(0.5), color: col('rule'),
placement: inset('top-left', 15.5, { width: mm(TRIM[0] - 2 * MARGIN.x) }) },
] };
const footer = { elements: [ // every page: an image element works in a running slot too
mark(4.5, 'bottom-left', -9), maker(7, 'ink', 1.2),
text('folio', '{pageNumber}/{totalPages}', COND, 8, 'brand', inset('bottom-right', -9.6),
{ fontWeight: 700 }),
text('doc', '{subtitle} ·', MONO, 7, 'muted', place('#folio', 'left-of', -1.5, 0.4)),
] };
O código da peça é o título de nível 1, e o slot de design dele desenha a abertura: uma caixa violeta até a sangria, o logotipo, e um antetítulo, uma linha fina e três números-chave lidos dos atributos do título. Elementos de imagem funcionam nos slots de cabeçalho e rodapé assim como nas aberturas, então o rodapé começa com um que aponta para o recurso do logotipo. Os elementos do cabeço usam pages: 'body', então nenhum deles sai na primeira página.
A receita completa
// ═══ Postext Cookbook · Nº 010 · Datasheet: tables from data, merged headers ════════ // https://postext.dev/en/cookbook/technical-datasheet // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Fira Sans, Fira Sans Condensed, Fira Mono (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // The datasheet of a fictional sensor. Postext reads no pipe tables, so the tables are data: // TSV pasted from a spreadsheet, parsed into table models, then merged, aligned and filled. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, parseTSV, mergeCells, setAlignment, setCellBackground, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'technical-datasheet'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { // eight named colours; every colour in the config links to one of them ink: '#16181d', brand: '#5a2a8a', // text; Pyxis violet: the band, table heads, numbers tint: '#eee6f5', zebra: '#f3f4f6', // violet wash for groups and pins; every other row hazard: '#f2b705', rule: '#c9ccd3', // maximum ratings and the badge; hairlines muted: '#5d636d', paper: '#ffffff', // running heads, notes and units; white }; // 1.4.1 designs ignore paletteId and read the hex: col() sets both (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ // the defaults link to 'main-color', so it is set to the brand violet ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'brand (defaults)', value: { hex: palette.brand, model: 'hex' } }, ]; // #region answer: pasted TSV becomes a table: two header rows, merged cells, zebra fills const at = (row, column) => ({ row, col: column }); const span = (r0, c0, r1, c1) => ({ start: at(r0, c0), end: at(r1, c1) }); const C = { group: 0, param: 1, symbol: 2, conditions: 3, min: 4, max: 6, unit: 7 }; // columns function electricalTable(tsv) { // In 1.4.1 parseTSV makes plain cells and leaves headerRowCount unset: the head is two rows. let m = Object.assign(parseTSV(tsv), { headerRowCount: 2, columnWidths: [22, 44, 16, 38, 15, 15, 15, 15] }); // weights: mm of the 180 mm measure // 'Parameter' covers two columns and two rows; 'Value' spans Min, Typ and Max. mergeCells // marks the covered cells hiddenBy, so no column shifts (gotcha: merged-cells-hiddenby). for (const range of [span(0, C.group, 1, C.param), span(0, C.symbol, 1, C.symbol), span(0, C.conditions, 1, C.conditions), span(0, C.min, 0, C.max), span(0, C.unit, 1, C.unit)]) { m = mergeCells(m, range); // 'Value' is centred over its three columns, the rest set left m = setAlignment(m, range.start, range.start.col === C.min ? 'center' : 'left', 'middle'); } // Min, Typ and Max go right, over their figures; setAlignment clears a vAlign it is not given. for (let c = C.min; c <= C.max; c++) m = setAlignment(m, at(1, c), 'right'); let zebra = false; for (let r = m.headerRowCount; r < m.rows.length; r++) { const row = m.rows[r]; if (row[C.param].content) zebra = !zebra; // a parameter keeps one fill over its conditions for (let c = 0; c < row.length; c++) { // An empty cell continues the one above: a group, or a parameter and its symbol. if (c <= C.symbol && row[c].content) { let end = r; while (m.rows[end + 1] && !m.rows[end + 1][c].content && (c === C.group || !m.rows[end + 1][C.param].content)) end++; m = mergeCells(m, span(r, c, end, c)); } // Figures flush right, as datasheets set them (no decimal tab: gap tab-stops). m = setAlignment(m, at(r, c), c >= C.min && c <= C.max ? 'right' : 'left', 'middle'); // A table style has no zebra rows, so they are filled cell by cell (gotcha: no-zebra). Each // helper returns a new model, cheap at 20 rows; for thousands, set the cell fields directly. if (c === C.group) m = setCellBackground(m, at(r, c), col('tint')); else if (zebra) m = setCellBackground(m, at(r, c), col('zebra')); } } return m; } // #endregion // #region split: group rows, codes and lists in cells; a table taller than its slot splits function groupedTable(tsv, columnWidths) { let m = Object.assign(parseTSV(tsv), { headerRowCount: 1, columnWidths }); const codes = [0, 2]; // Pin and Type, Addr. and Reset: short codes, centred, in a bare chip // A TSV cell holds no line break: the data writes \n, and a line opening with • is a list. m.rows = m.rows.map((row, r) => row.map((cell, c) => ({ ...cell, content: r > 0 && row[1].content && codes.includes(c) ? `:chip[${cell.content}]{style="code"}` : cell.content.replaceAll('\\n', '\n') }))); for (let r = 0; r < m.rows.length; r++) { const last = m.rows[r].length - 1; if (r >= m.headerRowCount && !m.rows[r][1].content) { // a lone first cell heads a group m = setCellBackground(mergeCells(m, span(r, 0, r, last)), at(r, 0), col('tint')); } else for (const c of codes) m = setAlignment(m, at(r, c), 'center'); } return m; // no split code: the engine cuts it between rows and repeats the head } // #endregion const TRIM = [216, 279]; // mm: US Letter const MARGIN = { y: 20, x: 18 }; // mm: equal side margins, since a loose sheet has no spine const LEAD = 13; // pt: the body leading, the baseline grid the headings and the opener keep to const COND = 'Fira Sans Condensed', MONO = 'Fira Mono'; // the display and the label faces // #region furniture: the opener band, a running head, and the logo in every footer const place = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const inset = (edge, y, size) => // a page corner, moved in by the side margin place('page', edge, edge.endsWith('left') ? MARGIN.x : -MARGIN.x, y, size); const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), placement, ...extra }); const caps = (size, weight = 600) => ({ fontWeight: weight, letterSpacing: pt(size * 0.16), textTransform: 'uppercase' }); const badge = { ...caps(7.5, 700), box: { backgroundColor: col('hazard'), padding: { top: pt(1.6), bottom: pt(1.4), left: pt(4), right: pt(4) } } }; const mark = (size, edge, y) => ({ kind: 'image', id: 'mark', resourceId: 'logo', // by id placement: inset(edge, y, { width: mm(size) }) }); const maker = (size, color, y) => text('maker', 'Pyxis Microdevices', COND, size, color, place('#mark', 'right-of', size / 4, y), caps(size)); const keyFigure = (n, y) => [ // {attr.k1} over its label {attr.k1l}, flush right on the band text(`k${n}`, `{attr.k${n}}`, COND, 22, 'paper', inset('top-right', y), { fontWeight: 600 }), text(`k${n}l`, `{attr.k${n}l}`, COND, 7, 'tint', inset('top-right', y + 9), caps(7))]; const BAND = 84; // mm: the violet band across the head of page 1 // The band under the top margin and 9 mm or more of white, in whole grid lines (16 here). const OPENER_LINES = Math.ceil((BAND - MARGIN.y + 9) / (LEAD * 25.4 / 72)); const opener = { enabled: true, minHeight: pt(OPENER_LINES * LEAD), // so the columns under it start on the grid slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('brand') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, mark(6, 'top-left', 12), maker(8.5, 'paper', 1.7), text('doc', 'Datasheet {subtitle} · {publishDate}', MONO, 7.5, 'tint', inset('top-right', 13.6)), text('kicker', '{attr.kicker}', COND, 9.5, 'tint', inset('top-left', 30), caps(9.5)), text('title', '{titleText}', COND, 64, 'paper', place('#kicker', 'below', 0, 0.5), { fontWeight: 700, lineHeight: 1 }), // A design text that overflows its width ends in '…' (gotcha: overflow-ellipsis-default). text('lead', '{attr.lead}', 'Fira Sans', 12, 'paper', place('#title', 'below', 0, 2.5, { width: mm(108) }), { lineHeight: 1.32, align: 'left', overflow: 'wrap' }), text('status', 'Preliminary', COND, 7.5, 'ink', place('#lead', 'below', 0, 4.5), badge), ...[1, 2, 3].flatMap((n) => keyFigure(n, 15 + 15 * n)), ] }, }; const header = { elements: [ // body pages only: the opener has its band text('running-title', '{chapterTitle}', COND, 9, 'brand', inset('top-left', 10.4), { fontWeight: 700, pages: 'body' }), text('flag', 'Preliminary', COND, 7.5, 'ink', inset('top-right', 10.2), { ...badge, pages: 'body' }), { kind: 'rule', id: 'hairline', pages: 'body', thickness: pt(0.5), color: col('rule'), placement: inset('top-left', 15.5, { width: mm(TRIM[0] - 2 * MARGIN.x) }) }, ] }; const footer = { elements: [ // every page: an image element works in a running slot too mark(4.5, 'bottom-left', -9), maker(7, 'ink', 1.2), text('folio', '{pageNumber}/{totalPages}', COND, 8, 'brand', inset('bottom-right', -9.6), { fontWeight: 700 }), text('doc', '{subtitle} ·', MONO, 7, 'muted', place('#folio', 'left-of', -1.5, 0.4)), ] }; // #endregion const config = () => ({ // a factory: configs are cached by identity (gotcha: config-cache-identity) // Tables and figures count 1, 2, 3 through the document; table captions sit above. resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, numberingTemplate: '{n}', ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })), colorPalette, layout: { layoutType: 'double', gutterWidth: mm(6) }, page: { width: mm(TRIM[0]), height: mm(TRIM[1]), dpi: 150, margins: { top: mm(MARGIN.y), bottom: mm(MARGIN.y), left: mm(MARGIN.x), right: mm(MARGIN.x) } }, bodyText: { // ragged-right sans with space between paragraphs, as reports are set fontFamily: 'Fira Sans', fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'), boldFontWeight: 600, boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', referenceColor: col('brand'), firstLineIndent: pt(0), paragraphSpacing: true }, headings: { fontFamily: COND, color: col('ink'), levels: [ // H1 restated: any headings object drops its break (gotcha: headings-drop-h1-break) { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener, marginBottom: pt(0) }, // the opener's minHeight sets the gap // Margin and line add up to whole grid lines (three, then two), so the grid adds no air. { level: 2, fontSize: pt(13), lineHeight: pt(2 * LEAD), color: col('brand'), numberingTemplate: '{2}', marginTop: pt(LEAD), marginBottom: pt(0) }, { level: 3, fontSize: pt(10), lineHeight: pt(LEAD), fontWeight: 600, numberingTemplate: '{2}.{3}', marginTop: pt(LEAD), marginBottom: pt(0) }, ] }, headingStyles: [{ id: 'lead-in', marginTop: pt(0) }], // under the opener, level with column 2 unorderedLists: { color: col('brand'), fontWeight: 400, gap: mm(2.4), marginTop: pt(0), marginBottom: pt(0), itemSpacing: pt(2) }, paragraphStyles: [{ id: 'colophon', fontSize: pt(7.5), lineHeight: pt(10), color: col('muted') }], chipStyles: [ // the first style is the default: pin names in mono on the violet wash { id: 'pin', fontFamily: MONO, fontSize: em(0.88), color: col('brand'), background: col('tint'), borderWidth: pt(0), borderRadius: pt(1.2) }, { id: 'code', fontFamily: MONO, fontSize: em(0.9), color: col('ink'), // bare: a face change backgroundEnabled: false, borderWidth: pt(0), paddingX: pt(0) }, ...[['preview', 'hazard', 'ink'], ['planned', 'zebra', 'muted'], ['active', 'brand', 'paper']] .map(([id, fill, ink]) => ({ id, fontFamily: COND, bold: true, color: col(ink), background: col(fill), borderWidth: pt(0), borderRadius: pt(1.2) })), ], calloutStyles: [{ id: 'ratings', title: 'Absolute maximum ratings', backgroundEnabled: false, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('hazard') }, // no fill padding: { top: mm(2.2), right: mm(0), bottom: mm(0), left: mm(0) }, marginTop: pt(2), titleStyle: { fontSize: pt(9), ...caps(9, 700), color: col('ink') }, body: { fontSize: pt(8.5), lineHeight: pt(12) }, lists: { bulletChar: '–', color: col('muted') } }], // #region styles: a house table style, then one named variant per table, stating what differs tableStyle: { headerBackground: col('brand'), headerColor: col('paper'), headerFontFamily: COND, headerFontSize: pt(8.5), bodyFontSize: pt(8), rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.3) }, tableStyles: [ // a resource picks one with table.styleId // White rules cut the fills apart and show where each merge and each group ends. { id: 'electrical', borderColor: col('paper'), borderWidth: pt(1.4), cellPadding: mm(1.1) }, // Written out although it is the default: 'clip' and 'hide' cut a table taller than the page. { id: 'grouped', overflow: 'split', continuedSuffix: '(continued)', continuesMarker: 'Continued on the next page' }, // A compact list in the condensed face, boxed by a rounded outer frame. { id: 'ordering', headerBackground: col('tint'), headerColor: col('brand'), rules: 'outer', borderRadius: mm(1.5), bodyFontFamily: COND }, ], captionStyle: { fontFamily: COND, fontSize: pt(9.5), labelColor: col('brand'), gap: mm(2), note: { fontSize: pt(7.5), color: col('muted') } }, // #endregion header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Amostra em Markdown · 107 linhas · content.en.md
title: "PX-7021 digital temperature sensor" subtitle: "DS-7021 · Rev. 0.3" author: "Pyxis Microdevices" publishDate: "September 2026" --- # PX-7021 {kicker="Digital temperature sensor" lead="±0.1 °C accuracy from 1.4 µA, with an I²C and SMBus interface, in a 2 × 2 mm package" k1="±0.1 °C" k1l="Accuracy, −20 °C to 50 °C" k2="1.4 µA" k2l="At one reading a second" k3="2 × 2 mm" k3l="8-pin DFN package"} ## Features {style="lead-in"} - ±0.1 °C maximum error from −20 °C to 50 °C - ±0.3 °C maximum error from −40 °C to 125 °C - 16-bit result, with a resolution of 0.0078 °C - 1.4 µA at one reading a second, 0.1 µA in shutdown - Supply from 1.6 V to 5.5 V, 5.5 V-tolerant interface - I²C and SMBus, from 1 kHz to 1 MHz, with bus timeout - Eight bus addresses, selected by three pins - Alert output, in comparator or interrupt mode - 8-pin DFN, 2 × 2 mm, with an exposed pad (:ref{id="pinout" style="full"}) ## Applications - Cold-chain loggers for vaccines and fresh food - Wearable and home thermometry - Battery packs, chargers and power banks - Thermostats and building controls - Thermal protection for processors and power stages - Laboratory and medical instruments :::columnbreak ## Description The PX-7021 is a digital temperature sensor for coin-cell designs that must read within a tenth of a degree. A 16-bit converter reads an on-chip bandgap sensor, and every part is trimmed at two temperatures on the production line, so the board needs no calibration and the host no look-up table: one step of the result is 1/128 °C. Between conversions the sensor sleeps: at one reading a second it draws 1.4 µA on average. Two limit registers drive the open-drain :chip[ALERT] output, which can wake a sleeping host when the temperature leaves a window. :ref{id="circuit" style="full"} shows the typical circuit, with one decoupling capacitor and the bus pull-ups. With its address pins tied to ground the sensor answers at 48h. :chip[Preview]{style="preview"} Engineering samples of the DFN versions are available now. The limits in :ref{id="electrical" style="full"} are preliminary and may change before production release. :::pagebreak ## Specifications :::callout{type="ratings"} - Supply voltage, *V*~DD~ to GND: **−0.3 V to 6 V** - SDA, SCL and ALERT to GND: **−0.3 V to 6 V** - A0 to A2 to GND: **−0.3 V to *V*~DD~ + 0.3 V** - Current into any pin: **±10 mA** - Storage temperature: **−60 °C to 150 °C** - Electrostatic discharge, human-body model: **±2 kV** ::: Stress beyond these ratings can damage the device for good. Design to the operating conditions below. ### Recommended operating conditions Operate the sensor from 1.6 V to 5.5 V, at ambient temperatures from −40 °C to 125 °C, with less than 50 mV of ripple on the supply. The bus pull-ups may return to any supply up to 5.5 V, so a sensor run from 1.8 V can share a bus with 5 V parts without a level shifter. ### Electrical characteristics :ref{id="electrical" style="full"} lists the limits over the full supply and temperature range. Typical values are the mean of the characterization lots at 3.3 V and 25 °C; they are not guaranteed. Accuracy is tested on every part at 25 °C and 50 °C; values marked ² come from characterization only. ## Pin configuration and functions :ref{id="pinout" style="full"} shows the package from above. A dot on the top face marks pin 1, and the pins count counterclockwise from it. The exposed pad under the package is the sensor's thermal path to the board: solder it to a ground pour. :ref{id="pins" style="full"} describes each pin. ## Detailed description A host talks to the PX-7021 through eleven registers, listed with their reset values in :ref{id="registers" style="full"}. Two-byte registers are read and written most significant byte first. ### Temperature conversion A conversion takes 10.5 ms at 16 bits and 3.1 ms at 12 bits. In continuous mode the sensor starts a new conversion at the programmed rate, from one every four seconds to eight a second, and sleeps in between; in one-shot mode it converts once, stores the result and shuts down again. The result register always holds the last complete reading, so a read never catches a conversion halfway. The result is a signed 16-bit value in steps of 1/128 °C. Values above 7FFFh are negative: 1900h reads as 50 °C, and E700h as −50 °C. ### Serial interface The sensor is a target on an I²C or SMBus bus, at clock rates up to 1 MHz. Tie each of A0, A1 and A2 to ground or to the supply to choose one of eight addresses, from 48h to 4Fh. In SMBus mode a clock held low for more than 30 ms resets the interface, so a host that crashes in mid-transfer cannot lock the bus. The sensor also answers the I²C general-call reset command. ### Alert output The :chip[ALERT] pin compares every result with the limits in THIGH and TLOW. In comparator mode it stays asserted while the temperature is above THIGH and releases once it falls below TLOW, which gives a thermostat its hysteresis. In interrupt mode it asserts once per crossing, and releases when the host reads STATUS, or when the sensor answers a read of the SMBus alert response address. ## Packaging and ordering :ref{id="outline" style="full"} draws the package and its land pattern: the DFN-8 body is 2 mm square and 0.55 mm high, with a 0.5 mm pin pitch and a 0.8 × 1.5 mm exposed pad. :ref{id="ordering" style="full"} lists the versions and their order codes. The top of each part carries its number over a date code, YWWL: the year, the work week and the lot. Parts are rated at moisture sensitivity level 1, so they need no dry storage, and survive three reflow cycles at a peak of 260 °C. Reflow shifts the reading by less than 0.02 °C. ## Layout guidelines The sensor measures the copper under its exposed pad. For air temperature, place it at the edge of the board, away from regulators and processors, and cut slots in the board around it so that heat from the rest of the circuit reaches it slowly. For the temperature of a surface, do the opposite: a solid pour and a row of vias carry heat from the surface to the pad. Keep the decoupling capacitor on the same side of the board as the sensor and close to its supply pin: 2 mm at most. ## Revision history **Rev. 0.3**, September 2026: preliminary release, with limits from the first characterization lots. Adds the WLCSP-4 version and the SMBus timeout. **Rev. 0.2**, May 2026: advance information for early customers, with typical values only. **Rev. 0.1**, February 2026: product brief, with the target accuracy and supply current. :::paragraphs{style="colophon"} A work of fiction: Pyxis Microdevices and the PX-7021 are imaginary, and so are these figures; do not design with them. Set in Fira Sans, Fira Sans Condensed and Fira Mono (SIL Open Font License) · Text and drawings: original, licensed CC BY 4.0. :::`; // reworded so no unit starts a line (gotcha: nbsp-breaks) const electrical = String.raw`Parameter Symbol Conditions Value UnitAmostra em Markdown · 21 linhas · content.electrical.en.md
Min Typ Max Temperature sensor Accuracy^1^ *T*~ACC~ −20 °C to 50 °C −0.1 ±0.05 0.1 °C −40 °C to 125 °C −0.3 ±0.1 0.3 °C Resolution 16-bit result 0.0078 °C Repeatability^2^ 1 Hz, 100 readings ±0.008 °C Long-term drift^2^ 500 h at 125 °C 0.02 °C Power supply Supply voltage *V*~DD~ 1.6 3.3 5.5 V Average supply current^3^ *I*~DD~ 1 conversion per second 1.4 2.5 µA Supply current, converting *I*~CONV~ 120 175 µA Shutdown current *I*~SD~ bus idle 0.1 0.5 µA Power-on reset threshold *V*~POR~ *V*~DD~ rising 1.2 1.45 V Conversion Conversion time *t*~CONV~ 16-bit result 10.5 12 ms 12-bit result 3.1 3.6 ms Conversion rate *f*~CONV~ continuous mode 0.25 8 Hz Digital inputs and outputs High-level input voltage *V*~IH~ 0.7 *V*~DD~ V Low-level input voltage *V*~IL~ 0.3 *V*~DD~ V Low-level output voltage *V*~OL~ 3 mA sink 0.4 V Input leakage current *I*~IN~ −1 1 µA Pin capacitance *C*~IN~ 3 pF Serial interface Clock frequency *f*~SCL~ Fast-mode Plus 1 1000 kHz Bus timeout *t*~TIMEOUT~ SMBus mode 25 30 35 ms`; // the tables: TSV, pasted from a spreadsheet const pins = String.raw`Pin Name Type DescriptionAmostra em Markdown · 12 linhas · content.pins.en.md
**Power** 8 :chip[VDD] P Supply, 1.6 V to 5.5 V. Decouple with 100 nF within 2 mm of the pin. 4 :chip[GND] G Ground. EP :chip[EP] G Exposed pad, the thermal path to the board. Solder it to ground. **Serial interface** 1 :chip[SDA] I/O Serial data, open drain, 5.5 V-tolerant. 2 :chip[SCL] I Serial clock, Schmitt-trigger input. 3 :chip[ALERT] O Alert output, open drain. Leave it open when unused. **Address select** 5 :chip[A0] I Address bit 0. Tie to GND or *V*~DD~, never leave it floating. 6 :chip[A1] I Address bit 1. Tie to GND or *V*~DD~. 7 :chip[A2] I Address bit 2. Tie to GND or *V*~DD~.`; const registers = String.raw`Addr. Register Reset ContentsAmostra em Markdown · 15 linhas · content.registers.en.md
**Measurement** 00h :chip[TEMP] 8000h The last complete result, read only.\n• 8000h until the first result\n• Signed, in steps of 1/128 °C\n• At 12 bits, bits 3 to 0 read zero\n• Two bytes, most significant first 04h :chip[STATUS] 00h Flags, read only. Reading STATUS releases ALERT in interrupt mode.\n• Bit 7, BUSY: still converting\n• Bit 6, HIGH: a result crossed THIGH\n• Bit 5, LOW: a result crossed TLOW\n• Bit 4, TRIM: the trim check failed at power-on **Configuration** 01h :chip[CONFIG] 0000h Operating mode and alert behavior.\n• Bits 15 and 14, MODE: continuous, one-shot or shutdown\n• Bit 13, RES: a 12-bit result instead of 16 bits\n• Bit 12, AVG: each result averages eight readings\n• Bits 11 and 10, FAULTS: 1, 2, 4 or 6 results beyond a limit before ALERT asserts\n• Bit 9, POL: ALERT active high\n• Bit 8, INT: interrupt mode instead of comparator mode 05h :chip[RATE] 02h Conversion rate in continuous mode.\n• 00h to 05h: 0.25, 0.5, 1, 2, 4 or 8 conversions a second\n• Higher values read back as 05h 06h :chip[ONESHOT] 00h Any write starts one conversion.\n• Shuts down again afterwards\n• Ignored in continuous mode 07h :chip[OFFSET] 0000h Added to every result, in the format of TEMP.\n• From −8 °C to 8 °C\n• Cleared by a power-on reset **Limits** 02h :chip[TLOW] F600h Low limit, in the format of TEMP.\n• −20 °C after reset\n• Keep it below THIGH 03h :chip[THIGH] 3C00h High limit, in the format of TEMP.\n• 120 °C after reset\n• In comparator mode, ALERT releases below TLOW **Identification** 0Eh :chip[SERIAL] — A 48-bit number unique to each part, read only.\n• Read six bytes, from 0Eh FEh :chip[MAKER] 5058h Manufacturer, read only: PX in ASCII. FFh :chip[DEVICE] 7021h Device, read only.\n• Bits 15 to 4: the part number, 702h\n• Bits 3 to 0: the silicon revision`; const ordering = String.raw`Order code Package Packing StatusAmostra em Markdown · 4 linhas · content.ordering.en.md
**PX-7021-DFN-R** DFN-8, 2 × 2 mm Reel of 3000 :chip[Preview]{style="preview"} **PX-7021-DFN-T** DFN-8, 2 × 2 mm Cut tape, 250 :chip[Preview]{style="preview"} **PX-7021-CSP-R** WLCSP-4, 0.8 × 0.8 mm Reel of 5000 :chip[Planned]{style="planned"} **PX-7021-EVM** Evaluation board Box of 1 :chip[Available]{style="active"}`; // #region art: the logo, pinout and circuit at column width, the outline at page width, in mm const SCALE = 10; // px per mm of the drawings' intrinsic size: the engine keeps only the ratio // The outline is 48.3 mm tall so that the text above it ends on a whole grid line: the // closing-page lift (EF-94) then leaves it at the foot, level with the other pages. const LOGO = [24, 24], PINOUT = [87, 47], CIRCUIT = [87, 56], OUTLINE = [180, 48.3]; // mm // One size family, in mm at 1:1 (1 mm = 2.83 pt): names 7.4 pt, labels and dimensions 6.8 pt, // notes 6.2 pt, pin numbers 6 pt; all under the 9.3 pt text and the 9.5 pt captions. const TEXT = { name: 2.6, label: 2.4, note: 2.2, pin: 2.1 }; const svg = ([w, h], body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * SCALE}" ` + `height="${h * SCALE}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const label = (x, y, s, { anchor = 'start', color = 'ink', size = TEXT.label } = {}) => `<text x="${x}" y="${y}" font-size="${size}" font-family="${MONO}" text-anchor="${anchor}" ` + `fill="${palette[color]}">${s}</text>`; // Fira Mono 400, the label face const logo = svg(LOGO, `<circle cx="12" cy="12" r="12" fill="${palette.brand}"/><path fill="` + `${palette.paper}" d="M12 3 14 10 21 12 14 14 12 21 10 14 3 12 10 10Z"/>`); // a compass star function pinout() { // the DFN-8 from above: pins 1 to 4 down the left, 5 to 8 back up the right const [cx, cy] = [PINOUT[0] / 2, PINOUT[1] / 2]; const [bw, bh, pitch] = [36, 46, 9.4]; // body and pin pitch: a diagram, not to scale const [left, right] = [cx - bw / 2, cx + bw / 2]; const pins = ['SDA', 'SCL', 'ALERT', 'GND', 'A0', 'A1', 'A2', 'VDD'].map((name, i) => { const onLeft = i < 4; const y = cy + ((onLeft ? i : 7 - i) - 1.5) * pitch; return `<rect x="${(onLeft ? left : right) - 2.4}" y="${y - 1.5}" width="4.8" height="3" ` + `rx="0.5" fill="${palette.brand}"/>${onLeft ? label(left - 4.2, y + 0.95, `${name} ${i + 1}`, { anchor: 'end', size: TEXT.name }) : label(right + 4.2, y + 0.95, `${i + 1} ${name}`, { size: TEXT.name })}`; }); // The exposed pad is under the package: from above, a hidden outline, dashed. return svg(PINOUT, `<rect x="${left}" y="${cy - bh / 2}" width="${bw}" height="${bh}" rx="1.6" ` + `fill="${palette.paper}" stroke="${palette.ink}" stroke-width="0.45"/><rect x="${cx - 9}" ` + `y="${cy - 12.5}" width="18" height="25" rx="0.6" fill="none" stroke="${palette.brand}" ` + `stroke-width="0.35" stroke-dasharray="1.4 0.9"/><circle cx="${left + 4}" ` + `cy="${cy - bh / 2 + 4}" r="1.3" fill="${palette.ink}"/>${pins.join('')}` + label(cx, cy + 0.95, 'EP', { anchor: 'middle', color: 'brand', size: TEXT.name })); } function circuit() { // the typical application: one capacitor, three pull-ups, address 48h const wire = (d, w = 0.3) => `<path d="${d}" fill="none" stroke="${palette.ink}" ` + `stroke-width="${w}"/>`; const dots = (...xy) => xy.map(([x, y]) => `<circle cx="${x}" cy="${y}" r="0.6" ` + `fill="${palette.ink}"/>`).join(''); const box = (x, y, w, h, fill) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="0.8" ` + `fill="${palette[fill]}" stroke="${palette.ink}" stroke-width="0.35"/>`; const small = { size: TEXT.note }, pin = { size: TEXT.pin, color: 'muted', anchor: 'middle' }; const pullUps = [[59, 23], [63, 28], [67, 33]].map(([x, y]) => wire(`M${x} 6V10M${x} 15.5V${y}`) + `<rect x="${x - 0.8}" y="10" width="1.6" height="5.5" fill="${palette.paper}" ` + `stroke="${palette.ink}" stroke-width="0.3"/>${dots([x, 6], [x, y])}`); const lines = [['SDA', 'SDA', 23, 1], ['SCL', 'SCL', 28, 2], ['ALERT', 'INT', 33, 3]] .map(([from, to, y, n]) => wire(`M55 ${y}H71`) + label(53.6, y + 0.8, from, { ...small, anchor: 'end' }) + label(72.4, y + 0.8, to, small) + label(57, y - 0.7, n, pin)); const address = ['A0', 'A1', 'A2'].map((name, i) => wire(`M29 ${24 + 5 * i}H25`) + label(30.4, 24.8 + 5 * i, name, small) + label(27, 23.3 + 5 * i, 5 + i, pin)); const g = CIRCUIT[1] - 5; // the ground rail return svg(CIRCUIT, wire(`M6 6H78.5V17M6 ${g}H78.5V41M12 6V27.6M12 29.6V${g}M42 6V16M42 44V${g}` + `M25 24V${g}`) + wire('M9.4 27.6H14.6M9.4 29.6H14.6', 0.55) + dots([12, 6], [12, g], [42, 6], [42, g], [25, 29], [25, 34], [25, g]) + box(29, 16, 26, 28, 'tint') + box(71, 17, 15, 24, 'paper') + pullUps.join('') + lines.join('') + address.join('') + label(6, 4.2, 'VDD, 1.6 V to 5.5 V') + label(6, g + 3.6, 'GND') + label(8.4, 29.4, '100 nF', { ...small, color: 'muted', anchor: 'end' }) // left of the cap + label(68.8, 13.6, '4.7k', { ...small, color: 'muted' }) + label(29, 14.3, 'PX-7021', { size: TEXT.name, color: 'brand' }) + label(42, 19.8, 'VDD', { ...small, anchor: 'middle' }) + label(43.2, 14.4, 8, pin) + label(42, 42.2, 'GND', { ...small, anchor: 'middle' }) + label(43.2, 48.4, 4, pin) + label(27, g - 2.6, 'address 48h', { size: TEXT.pin, color: 'muted' }) + label(78.5, 38.6, 'MCU', { size: TEXT.name, color: 'brand', anchor: 'middle' })); } function outline() { // the DFN-8 at 15:1, four views in a row: top, bottom, side, land pattern const ink = (d, w = 0.15) => `<path d="${d}" fill="none" stroke="${palette.ink}" ` + `stroke-width="${w}"/>`; const fill = (d, color) => `<path d="${d}" fill="${palette[color]}"/>`; const rect = (x, y, w, h, color, extra = '') => `<rect x="${x}" y="${y}" width="${w}" ` + `height="${h}" fill="${palette[color]}"${extra}/>`; const body = (x, y, w, h) => rect(x, y, w, h, 'paper', ` rx="0.6" stroke="${palette.ink}" stroke-width="0.35"`); const dim = { anchor: 'middle' }; const caption = { size: TEXT.note, color: 'muted', anchor: 'middle' }; const hDim = (x1, x2, y, s) => ink(`M${x1} ${y}H${x2}`) + fill(`M${x1} ${y}l1.3 -0.45v0.9z` + `M${x2} ${y}l-1.3 -0.45v0.9z`, 'ink') + label((x1 + x2) / 2, y - 0.9, s, dim); const vDim = (x, y1, y2) => ink(`M${x} ${y1}V${y2}`) + fill(`M${x} ${y1}l-0.45 1.3h0.9z` + `M${x} ${y2}l-0.45 -1.3h0.9z`, 'ink'); const cy = 23, rows = [-11.25, -3.75, 3.75, 11.25].map((d) => cy + d); // 0.5 mm pitch const pads = (xs, w) => rows.map((y) => xs.map((x) => rect(x, y - 1.875, w, 3.75, 'brand')) .join('')).join(''); const [t, b, s, l] = [4, 50, 94, 140]; // the left edge of each view const top = body(t, 8, 30, 30) + `<circle cx="${t + 3.6}" cy="11.6" r="1" fill="${palette.ink}"/>` + label(t + 15, 24, '7021', { size: 3.2, anchor: 'middle' }) // the marking, not a label + label(t + 15, 29, 'YWWL', { ...caption, size: TEXT.label }) + ink(`M${t} 7.2V3.2M${t + 30} 7.2V3.2`) + hDim(t, t + 30, 4, '2.00') + label(t + 15, 45, 'TOP · MARKING', caption); const bottom = body(b, 8, 30, 30) + pads([b, b + 25.5], 4.5) + rect(b + 9, 11.75, 12, 22.5, 'tint', ` rx="0.4" stroke="${palette.brand}" stroke-width="0.3"`) + label(b + 15, 24, 'EP', dim) + ink(`M${b - 0.6} 11.75H${b - 3.7}M${b - 0.6} 19.25H${b - 3.7}`) + vDim(b - 2.8, 11.75, 19.25) + label(b - 4.2, 16.4, '0.50', { ...dim, anchor: 'end' }) + label(b + 31.6, 13, 1, caption) + label(b + 15, 45, 'BOTTOM · EP 0.80 × 1.50', caption); const side = body(s, cy - 4, 30, 8.25) + rect(s, cy + 3.7, 4.5, 0.55, 'brand') + rect(s + 25.5, cy + 3.7, 4.5, 0.55, 'brand') + ink(`M${s + 30.6} ${cy - 4}H${s + 34.3}M${s + 30.6} ${cy + 4.25}H${s + 34.3}`) + vDim(s + 33.5, cy - 4, cy + 4.25) + label(s + 35.2, cy + 1, '0.55', { ...dim, anchor: 'start' }) + label(s + 15, 45, 'SIDE', caption); const land = rect(l + 4.5, 8, 30, 30, 'paper', ` fill-opacity="0" stroke="${palette.muted}" ` + 'stroke-width="0.2" stroke-dasharray="1 0.8"') + pads([l, l + 28.5], 10.5) + rect(l + 13.5, cy - 11.25, 12, 22.5, 'brand') + [cy - 5.5, cy + 5.5].map((y) => `<circle cx="${l + 19.5}" cy="${y}" r="2.25" fill="${palette.paper}"/>`).join('') // vias + ink(`M${l} 9.3V3.2M${l + 39} 9.3V3.2`) + hDim(l, l + 39, 4, '2.60') + label(l + 19.5, 45, 'LAND PATTERN · 0.25 × 0.70', caption); return svg(OUTLINE, top + bottom + side + land); } // #endregion // #region resources: each drawing and table is a resource; the floats go where they are cited const svgResource = (id, caption, altText, [w, h], placement) => ({ id, typeId: 'figure', kind: 'svg', caption, altText, placement, createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * SCALE, height: h * SCALE } }); // fitted to its slot const table = (id, caption, model, { styleId, placement, note } = {}) => ({ id, typeId: 'table', kind: 'table', caption, note, placement, table: { model, styleId }, createdAt: 0, updatedAt: 0 }); // A float takes the first free slot after its first :ref; the logo is never cited, only drawn. const resources = [ svgResource('logo', '', 'Pyxis Microdevices', LOGO), svgResource('pinout', 'Pin configuration, 8-pin DFN, top view.', 'The package from above: ' + 'pins 1 to 4 down the left side, 5 to 8 up the right.', PINOUT), svgResource('circuit', 'Typical application, bus address 48h.', 'The sensor with a 100 nF ' + 'capacitor, address pins to ground, and SDA, SCL and ALERT pulled up to a host.', CIRCUIT), svgResource('outline', 'Package outline and land pattern, 8-pin DFN, in mm.', 'Top, bottom ' + 'and side views of the 2 × 2 mm body, and the land pattern with its two vias.', OUTLINE, { position: 'bottom', span: 'page' }), // a strip across the foot of a page table('electrical', 'Electrical characteristics, *V*~DD~ = 1.6 V to 5.5 V and *T*~A~ = −40 °C ' + 'to 125 °C unless noted', electricalTable(electrical), { styleId: 'electrical', placement: { position: 'top', span: 'page' }, note: 'Typical values at 3.3 V and 25 °C. ' + '^1^ Tested at 25 °C and 50 °C, the rest by characterization. ^2^ Characterized, not ' + 'tested in production. ^3^ One conversion a second, bus idle.' }), // No placement: these float, and only a floated table splits (gotcha: here-table-no-split). table('pins', 'Pin functions', groupedTable(pins, [9, 16, 10, 52]), { styleId: 'grouped', note: 'Types: P power, G ground, I input, O open-drain output, I/O open-drain input and ' + 'output.' }), table('registers', 'Register map', groupedTable(registers, [9, 17, 11, 50]), { styleId: 'grouped', note: 'Reset values apply at power-on and after a general-call reset.' }), table('ordering', 'Order codes', Object.assign(parseTSV(ordering), { headerRowCount: 1, columnWidths: [23, 29, 18, 17] }), { styleId: 'ordering', note: 'WLCSP-4: fixed address 48h, no ALERT output.' }), ]; // #endregion // #region drawing: the drawings' labels stay text, in the document's own label face // An SVG drawn as an image cannot use the page's fonts (gotcha: svg-no-webfonts): the PDF sets // its labels as real text in the faces it embeds; the canvas copy embeds the face itself. async function fontFace(family) { // the same TTF the PDF embeds, from the pdf kit block const ttf = await fontsourceProvider(family, 400, 'normal'); const base64 = btoa(Array.from(ttf, (b) => String.fromCharCode(b)).join('')); return `@font-face{font-family:'${family}';src:url(data:font/ttf;base64,${base64})}`; } async function loadDrawing(fileId, markup, face) { await loadSvg(fileId, markup); // registers the plain SVG and keeps its bytes for the PDF const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent( markup.replace(/<svg[^>]*>/, (tag) => `${tag}<style>${face}</style>`))}`; await img.decode(); registerResourceImage(fileId, img); // replaces the canvas copy only } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the layout uses, loaded before the build (gotcha: fonts-first) 'Fira Sans': ['400', '400i', '600', '600i'], // text; 600 is the bold 'Fira Sans Condensed': ['400', '400i', '600', '700'], // display: title, heads, captions 'Fira Mono': ['400'], // labels: pin names, codes, document number, the drawings' labels }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const allText = [markdown, electrical, pins, registers, ordering].join('\n'); // all it prints await loadFonts(FONTS, allText); await loadSvg('logo.svg', logo); const drawings = { pinout: pinout(), circuit: circuit(), outline: outline() }; // by resource id const face = await fontFace(MONO); // fetched once for the three drawings for (const [id, markup] of Object.entries(drawings)) await loadDrawing(`${id}.svg`, markup, face); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), allText); showPages(doc, { title: 'PX-7021 datasheet' }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // the same faces; the drawings stay vectors with real textKit · core, fonts, viewer, pdf, images: igual em todas as receitas · 316 linhas
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)
Variações
#Separar todas as linhas com fios cinza
Fios de meio ponto no cinza rule da paleta separam todas as linhas, com fundo ou sem; os brancos somem onde duas linhas sem fundo se encontram.
- { id: 'electrical', borderColor: col('paper'), borderWidth: pt(1.4), cellPadding: mm(1.1) },
+ { id: 'electrical', borderColor: col('rule'), borderWidth: pt(0.5), cellPadding: mm(1.1) },#Pôr as legendas das tabelas sobre uma barra
O estilo de legenda do tipo tabela pode pintar uma barra atrás da legenda, aqui na cor da tinta, com o rótulo no amarelo de advertência.
- ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
+ ...(type.id === 'table' && { captionStyle: { position: 'above', backgroundEnabled: true,
+ background: col('ink'), color: col('paper'), labelColor: col('hazard') } }) })),Erros comuns
Erro comum
Estilos de tabela nomeados não têm linhas zebradas
Um estilo de tabela tem um preenchimento para o cabeçalho e um único preenchimento para o corpo, sem linhas alternadas. Preencha uma linha sim, outra não, célula por célula (setCellBackground), com um tom ligado à paleta. Preenchimento de células →
Erro comum
Uma tabela 'here' nunca se divide
Só as tabelas flutuantes se dividem entre colunas e páginas; uma tabela posicionada como 'here' se move inteira. Deixe as tabelas longas flutuarem ou mantenha curtas as tabelas em linha. Tabelas que passam de página →
Erro comum
O texto dentro de um SVG <img> não pode usar fontes web
Um SVG é desenhado como imagem, e uma imagem não tem acesso às fontes web da página, então os rótulos dele caem em uma fonte do sistema. Converta o texto em contornos, incorpore um subconjunto @font-face no SVG ou passe os rótulos para a legenda. Figuras e tabelas como recursos →
Erro comum
Os arquivos do Fontsource cobrem letras, não símbolos
O kit incorpora o arquivo latin do Fontsource de cada fonte e, quando o texto usa esses caracteres, o arquivo latin-ext para č, ł, † e o arquivo greek para α, χ (em uma família que o tenha: Lora e Gelasio não têm), na tela e no PDF. Símbolos como →, ≈, ✓ e ★ não estão em nenhum desses arquivos e somem no PDF: desenhe-os, componha-os como matemática ou escolha uma fonte cujos arquivos os tenham. Fontes incorporadas ao PDF →
Erro comum
Qualquer objeto headings desativa a quebra de página do H1
Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →
Erro comum
Carregue todas as fontes antes do layout
O motor de layout mede o texto com as fontes que o navegador carregou e guarda as larguras em cache, então uma fonte que chega depois da primeira composição deixa quebras de linha erradas e um PDF que não corresponde mais à tela. Carregue antes todos os pesos e estilos e chame clearMeasurementCache() antes de recompor quando alguma chegar atrasada. Fontes antes da diagramação →
Erro comum
Um espaço não separável ainda quebra a linha
No postext 1.4.1, o algoritmo de quebra de linha trata U+00A0 como um espaço comum, então 0,08 %, 2,006 s ou seção 2 podem ficar em duas linhas. Junte os dois elementos (0,08%) ou reescreva a frase. Escapes e caracteres literais →
Erro comum
Uma paleta trocada não chega aos elementos de design nem à cor das referências
postext 1.4.1 aplica colorPalette aos estilos de texto (corpo, títulos, listas, legendas, tabelas, boxes), mas não aos elementos de cabeçalhos, rodapés, aberturas e páginas de parte, nem a bodyText.referenceColor: eles mantêm o hex escrito ao lado do seu paletteId. Se você trocar a paleta, para uma edição de tela escura ou para mudar as cores, reescreva cada cor vinculada a partir de colorPalette antes de compor. Paleta de cores semântica →
Erro comum
O excesso de texto de design é 'ellipsis-end' por padrão
Um elemento de texto de design que não cabe na sua largura termina em reticências por padrão. Use overflow: 'wrap' nos títulos que devem passar para mais linhas. Textos, fios e caixas nos designs de página →
Erro comum
A configuração fica em cache pela identidade: crie um objeto novo
O motor guarda em cache as configurações resolvidas pela identidade do objeto, então alterar uma configuração no próprio objeto e compor de novo reaproveita o resultado antigo. Crie um objeto novo a cada composição; por isso a configuração de uma receita é uma função, config(). Páginas em um canvas →
- A primeira parte de uma tabela dividida deixa o pé da coluna para o texto quando cabem três linhas ou mais embaixo dela, e sempre faz isso numa coluna que já tem um flutuante. Comece uma tabela longa numa coluna sem outro flutuante, como faz o mapa de registradores, e ajuste as linhas para que a primeira parte chegue ao pé da coluna.
- Os elementos de texto de design são centralizados por padrão. A linha fina tem largura fixa, então precisa de
align: 'left'para se alinhar sob o código da peça. - Na página de fechamento de um capítulo, o postext 1.4.1 sobe os flutuantes na largura da página posicionados abaixo do texto até uma linha do texto corrido abaixo dele, de modo que uma figura
bottomali fica sob a última linha de texto, e não no pé da página. O texto da página 4 foi redigido para encher as colunas, e o desenho do encapsulamento tem 48,3 mm de altura para que o texto acima dele termine numa linha da grade. Assim o desenho não tem espaço para subir e termina na mesma altura do pé das outras páginas.
Créditos
- Receita
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fontes
- Fira Sans (SIL OFL 1.1) · Fira Sans Condensed (SIL OFL 1.1) · Fira Mono (SIL OFL 1.1)
- Código
- MIT, como o Postext
Editar este texto ↗ (abre em uma nova aba)Pasta da receita no GitHub ↗ (abre em uma nova aba)


