跳到主要内容
食谱编号10

排版食谱 · 第8章 · 表

数据手册:由数据生成表格,合并表头

表格以TSV粘贴,用parseTSV解析,再用mergeCells、setAlignment和setCellBackground整理;寄存器表跨页时自动拆分。

第2–3页,共4页

  • 英文样例:尚无中文版本
  • 成品尺寸216 × 279 mm
  • 2栏, 栏间距6 mm
  • Fira Sans 9.3/13
  • Fira Mono
  • Fira Sans Condensed
  • 4页
  • 难度
  • Postext 1.4.1
  • 排版用时34 ms
  • 268行代码

成品一览

这是虚构厂商Pyxis Microdevices出品的温度传感器PX-7021的四页数据手册。内容大多是表格:表头里满是合并单元格,寄存器表比一栏还长。首页以一条紫色色带开头,上面有型号和三项关键指标。第2页有一张通栏表格,两行表头把Value放在Min、Typ和Max之上;参数组在第一列向下合并,参数隔一个染一次灰色。寄存器表在第3页页脚断开,到第4页接续并重复表头,旁边是订货型号,下方是封装图。这些表格都是以TSV粘贴的电子表格数据,因为Postext不解析Markdown表格。

这道食谱解答

  • 怎样做一个有表头行、合并单元格、栏宽和逐格对齐的表?
  • 怎样把长表跨页拆分,并重复表头、加上“续表”标记?
  • 怎样在一份文档里给几张表设不同的样式(填色、斑马纹单元格、圆角边框)?
  • 怎样让示意图在PDF里保持清晰、文字可选中(SVG、印刷母版)?
  • 怎样用代码(资源)而不是Markdown的![]()添加图片和表格?

简短回答

script.js · 第31–69行在完整代码中
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;
}

用料

类型
Fira Sans, Fira Sans Condensed, Fira Mono(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 先粘贴数据,再整理表格

代码见上文的简短回答。在postext 1.4.1中,parseTSV不设置headerRowCount,所以两行表头要手动声明;这样表格拆分时,两行都会在每一部分重复。columnWidths的权重加起来是180,即版心的毫米宽度,所以每个权重就是该列在页面上的宽度。mergeCells让每个被覆盖的单元格留在原位并标为hiddenBy,因此没有列会错位。setAlignment逐个单元格生效,省略垂直对齐时会把它清掉,所以大多数调用都要再传一次'middle'。灰色按参数交替,而不是按行交替,这样Accuracy的两行保持同一底色;按行交替会让第二行变成白色。

#2 · 每个模型都成为资源

script.js · 第492–521行在完整代码中
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.' }),
];

每张表格和每幅图都是带题注的资源,表格还加上一条注释和一个命名样式。题注和注释支持斜体、下标和上标:电气特性表在题注中用*V*~DD~写出测试条件,在注释中用^1^给脚注编号。浮动体放在首次:ref之后的第一个空位。表1在第1页被引用,放置在页面顶部通栏,所以它排在第2页开头。外形图放置在页面底部通栏,其余表格保持默认放置方式,即一栏宽的浮动体。放置为here的表格从不拆分。

#3 · 让长表格自行拆分

script.js · 第73–87行在完整代码中
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
}

引脚表和寄存器表都由groupedTable生成。只填了第一个单元格的行会横跨全表合并,并作为组标题染成紫色。TSV单元格不能含换行,所以数据里写\n,由函数把它换成换行;以•开头的行随后排为列表项。短代码用code行内标签样式,它没有底色、边框和左右内边距,用0.9 em的Fira Mono排。浮动表格放不进分给它的空栏时,引擎会在行与行之间把它切开,把余下部分排到下一个空位,放在重复的表头下,并在题注后加(continued)。continuesMarker那一行“Continued on the next page”放在第一部分下方,注释则留到最后一部分(高于页面的表格)。

#4 · 一套统一样式,每张表一个变体

script.js · 第191–205行在完整代码中
  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存放所有表格共有的设置。tableStyles中的每一项只设置其表格要改的部分,资源用table.styleId选用其中一项。电气特性表把灰色细线换成1.4 pt的白线,把底色切开,并显示出每处合并的范围。订货型号表加了圆角外框,并使用Fira Sans Condensed,使四列能放进一栏。

#5 · 在PDF中保持为文本的标签

script.js · 第525–539行在完整代码中
// 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
}

SVG以图像形式进入Canvas,而图像不能使用页面的网页字体,所以引脚图、电路图和封装外形图的标签会退回到系统字体。PDF使用原始标记,把每个<text>用它嵌入的字体排成可选中的文本。为了Canvas,每幅图再注册一次,内嵌Fira Mono字体文件;这个文件通过工具包pdf块的字体加载器只获取一次。

#6 · 章首、书眉和页脚

script.js · 第96–147行在完整代码中
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)),
] };

型号是一级标题,它的设计槽画出章首页:一个延伸到出血的紫色框、标志,以及从标题属性读取的眉题、导语和三项关键指标。图像元素既能用在章首页,也能用在页眉页脚设计槽中,所以页脚以一个指向标志资源的图像元素开头。书眉的各元素带有pages: 'body',因此首页上一个都不印。

完整食谱

沙盒
// ═══ 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`---
Markdown样例 · 107行 · content.en.mdtitle: "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 Unit
Markdown样例 · 21行 · 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 Description
Markdown样例 · 12行 · 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 Contents
Markdown样例 · 15行 · 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 Status
Markdown样例 · 4行 · 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 text
工具包 · core, fonts, viewer, pdf, images:每道食谱都相同 · 310行// ─── 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 v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. 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', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; 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` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs 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; its * bold, italic and bold-italic variants are listed whether or not used. */ 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' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true 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; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), 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 ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ 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 v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { 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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show 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 ───────────────────────────────────────────────────────────────────────

组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗

变化

#每一行都用灰线分隔

用调色板中灰色rule颜色的0.5 pt细线分隔每一行,不论有无底色;白线在两行无底色的行相接处会看不见。

-    { 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) },

#把表格题注排在色条上

表格类型的题注样式可以在题注后面画一条色条,这里用墨色,标签用警示黄。

-    ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
+    ...(type.id === 'table' && { captionStyle: { position: 'above', backgroundEnabled: true,
+      background: col('ink'), color: col('paper'), labelColor: col('hazard') } }) })),

常见问题

易错点

合并单元格需要hiddenBy占位:使用mergeCells

单元格按其在行数组中的位置排布,所以合并单元格延伸到的地方需要标记hiddenBy的占位单元格;像HTML那样省略它们,后面的每一列都会错位。用mergeCells来合并。 由数据生成的表 →

易错点

命名表格样式没有斑马纹

表格样式只有一种表头底色和一种表体底色,没有隔行变色。用与调色板关联的浅色,逐个单元格给每隔一行填色(setCellBackground)。 单元格填色 →

易错点

'here'表格从不拆分

只有浮动的表才会跨栏、跨页拆分;放在'here'的表整体移动。让长表浮动,或者让行内的表保持简短。 跨页的表 →

易错点

SVG <img>中的文字不能使用网络字体

SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →

易错点

Fontsource的latin文件不含拉丁字母以外的字形

PDF字体提供函数嵌入的是Fontsource的latin文件,它们覆盖西班牙语和西欧文字,但不包括→、≈、✓、★、希腊字母或中欧字母;这些字形在PDF中会缺失。PDF中的文字要保持在latin范围内。 嵌入PDF的字体 →

易错点

传入任何headings对象都会关掉H1换页

默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

易错点

不换行空格仍然会断行

在postext 1.4.1中,断行器把U+00A0当作普通空格,所以0.08 %、2.006 s或Section 2可能被拆到两行。把两部分连写(0.08%),或者改写句子。 转义与字面字符 →

易错点

替换调色板时,设计元素和引用颜色不会跟着变

postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →

易错点

设计文本的overflow默认为'ellipsis-end'

宽度放不下的设计文本元素默认以省略号结尾。需要折成多行的标题,设置overflow: 'wrap'。 页面设计中的文字、线条和框 →

易错点

配置按对象身份缓存:每次新建一个对象

引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →

  • 拆分表格的第一部分下方如果还能放下三行或更多,就会把所在栏的底部留给正文;在已有浮动体的栏里,它总是这样做。长表格应从一个没有其他浮动体的栏开始,就像寄存器表那样,并调整行数,让第一部分排到栏底。
  • 设计文本元素默认居中。导语有固定宽度,所以需要align: 'left'才能与型号左对齐。
  • 在一章的最后一页,postext 1.4.1会把排在正文下方的通栏浮动体上移到正文下方一个正文行处,所以那里的bottom图会紧贴最后一行正文,而不是在页面底部。第4页的文字经过调整以填满各栏,外形图画成48.3 mm高,让它上方的正文结束在一条网格线上。这样外形图没有上移的空间,底边与其他页的页脚平齐。

致谢

文本
原创文字, CC BY 4.0
字体
Fira Sans (SIL OFL 1.1) · Fira Sans Condensed (SIL OFL 1.1) · Fira Mono (SIL OFL 1.1)
沙盒PDF