本文へスキップ
レシピ番号95

レシピ集 · 第5章 · 本の構成

バンクーバー方式の医学論文

A4判2段組みの臨床試験報告。上付きの引用番号、番号付きのバンクーバー方式の文献一覧、PDFでリンクとして開くDOIを備えます。

このページの内容
ジャンル
論文・学術書
出力
Canvas · PDF
レベル
中級
Postext
Postext 1.12.1で検証済み
1.12.1以上が必要 · postext-pdf ≥ 1.12.1
ライセンス
2026年10月1日更新
コードMIT · 本文CC BY 4.0
  • 英語の見本:日本語版はまだありません
  • 仕上がり210 × 297 mm
  • 2段, 段間6 mm
  • PT Serif 9.3/12.8
  • Fira Sans
  • Fira Sans Condensed
  • 3ページ
  • レベル
  • Postext 1.12.1
  • 組版時間41 ms
  • コード179行

かんたんな説明

医学誌のページのように組んだ短い医学研究論文です。著者はコードで文献を引き、Postextは本文に小さな上付きの番号を印刷し、最後に番号付きの文献一覧を作り、各DOIをリンクにします。

できあがり

臨床医学誌の3ページです。パイロット試験を報告する原著論文を、ICMJEの雑誌と同じくA4判2段組みで組みます。1ページ目の上部をティールの誌名帯が横切り、その下に論文種別、3行の題、上付きの所属番号を付けた著者、淡い地色の上の構造化抄録が続きます。著者はBibTeXのキーで引用し、ページには医学系の編集者が求める形が現れます。ピリオドの後の小さな上付きの番号、6–8のようにまとめた連続する文献、そしてNLM形式(「Lancet 2016;387(10022):957–67」)で各文献のDOIで終わる番号付きの文献一覧です。PDFでは各番号が該当する文献へ跳び、各DOIが論文を開きます。試験は架空ですが、引用している11件の文献は実在します。

このレシピが答える質問

  • 医学雑誌のように、引用番号を上付きにし、DOIを有効なリンクにするには?
  • APA、IEEEなどの引用スタイルで、文献を引用し参考文献を作るには?

手短な答え

script.js · 29–47行コード全体で見る
// [@key] prints a raised number in order of first citation. English journals set it after
// the full stop, "control.[@ncdrisc2021]"; Spanish ones before it, "controlada[@ncdrisc2021].".
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
// elsevier-vancouver is the bundled NLM/Vancouver style (citation-sequence). Two edits bring
// its list to the NLM sample references: "1." instead of "[1]", and the issue after the
// volume, 2015;373(22):2103-16.
const vancouver = STYLES['elsevier-vancouver']
  .replace('<text variable="citation-number" prefix="[" suffix="]"/>',
    '<text variable="citation-number" suffix="."/>')
  .replace('<text variable="volume"/>',
    '<group><text variable="volume"/><text variable="issue" prefix="(" suffix=")"/></group>');
const citations = {
  style: 'custom', customStyle: vancouver,
  marker: 'superscript', collapseRanges: true, // raised 1 and 6–8, not [1] and [6–8]
  link: true, // each number jumps to its reference, in the PDF and on screen
  bibliography: { fontSize: em(0.86), lineHeight: pt(10.4), entrySpacing: pt(1.6),
    labelWidth: mm(2.85), // the width of "1. " at 8 pt: the turnovers line up with the text
    doi: 'link' }, // https://doi.org/… printed whole and clickable
};

材料

種類
PT Serif, Fira Sans, Fira Sans Condensed (SIL OFL 1.1)
素材
なし:図はすべてコードで描画

作り方

#1 · 上付きでリンク付きのバンクーバー方式

コードは前に掲げた手短な答えです。同梱のスタイルのうち、NLMの『Citing Medicine』に従っているのはelsevier-vancouverです(引用順、「Ettehad D, Emdin CA」の形の著者名、6名の後に「et al.」、略記した誌名)。そこでこのファイルを出発点にします。2つの文字列置換で、文献一覧をNLMの見本に合わせます。番号を「[1]」ではなく「1.」とし、号を巻の後に置きます。marker: 'superscript'は、スタイルが何を書くかにかかわらず引用を上付きの番号にし、doi: 'link'は各https://doi.org/のアドレスをリンクのまま保ちます。AMAスタイルも同梱されており、自分で番号を上付きにしますが、文献一覧の句読点とページ範囲の書き方がバンクーバー方式と異なります。

#2 · 2段にまたがる表題ブロック

script.js · 51–80行コード全体で見る
const text = (id, content, family, size, extra) => ({ kind: 'text', id, content, align: 'left',
  fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', ...extra });
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: 'auto' } }) });
const caps = { fontFamily: COND, fontWeight: 600, letterSpacing: pt(1.3),
  textTransform: 'uppercase' };
const titleBlock = { enabled: true,
  minHeight: mm(62), // the abstract and the text start under the note, on both columns
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('accent') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(15) } } },
    text('journal', t({ en: 'Almenara Medical Journal', es: 'Revista Médica de Almenara' }),
      COND, 9.5, { ...caps, color: col('paper'), placement: at('page', 'top-left', INNER, 6) }),
    text('issue', '2026 · 14(3) · 181–183', COND, 9.5, { ...caps, color: col('paper'),
      align: 'right', placement: at('page', 'top-right', -OUTER, 6, 60) }),
    text('kind', '{attr.kind}', COND, 9, { ...caps, color: col('accent'),
      placement: at('container', 'top-left', 0, 0, MEASURE) }),
    text('title', '{titleText}', SANS, 19.5, { fontWeight: 600, lineHeight: 1.16,
      placement: at('#kind', 'below', 0, 3, MEASURE - 20) }),
    text('authors', '{attr.authors}', SERIF, 10.5, { inlineMarks: true, // ^1^ → ¹
      placement: at('#title', 'below', 0, 4.5, MEASURE) }),
    text('affiliations', '{attr.affiliations}', SANS, 7.4, { inlineMarks: true,
      lineHeight: 1.35, color: col('muted'), placement: at('#authors', 'below', 0, 2, MEASURE) }),
    { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'),
      placement: at('#affiliations', 'below', 0, 3, MEASURE) },
    text('note', '{attr.note}', SANS, 7.4, { fontStyle: 'italic', color: col('muted'),
      placement: at('#rule', 'below', 0, 1.6, MEASURE) }),
  ] },
};

論文の題はただひとつのレベル1見出しで、スタイルはarticleです。このスタイルはページ幅にまたがり、見出しをデザインから描きます。論文種別、著者、所属、注記は見出しの属性から取ります。inlineMarksは属性中の^1^を上付きの所属番号にし、所属の中の\nで機関ごとに改行します。帯は仕上がりにアンカーしてあるので、紙の上端まで届きます。

#3 · 構造化抄録とキーポイントの囲み

script.js · 84–94行コード全体で見る
const box = (id, bold, more) => ({ id, border: { enabled: false }, snapToGrid: false,
  marginTop: pt(LEAD), marginBottom: pt(LEAD),
  titleStyle: { ...caps, fontSize: pt(8.5), color: col('accent') }, // run-in labels in bold
  body: { fontFamily: SANS, fontSize: pt(8.3), lineHeight: pt(11.2), textAlign: 'left',
    firstLineIndent: pt(0), paragraphSpacing: true, boldColor: col(bold) }, ...more });
const calloutStyles = [
  box('abstract', 'accent', { background: col('tint'), padding: mm(3.5), marginTop: pt(0) }),
  box('keypoints', 'ink', { backgroundEnabled: false, padding: { top: mm(2.5), right: mm(0),
    bottom: mm(1), left: mm(0) }, stripe: { enabled: true, side: 'top', width: pt(2.5),
    color: col('accent') } }),
];

2つの囲みは同じ文字スタイルを共有します。8.3ptのFira Sansで、ラベルは太字の追い込みです。要約的な内容に読者が期待する調子です。抄録には地色を敷き、ラベルをティールにします。キーポイントは2.5ptのストライプの下に置き、ラベルは墨色です。snapToGrid: falseはパディングを正確に保つので、抄録の囲みはテキストにぴったり沿います。

#4 · 段の頭に置く表1と図1

script.js · 298–317行コード全体で見る
const resources = () => [
  { id: 'tbl-baseline', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0,
    placement: { position: 'top' },
    caption: t({ en: 'Characteristics of the patients at baseline.',
      es: 'Características de los pacientes al inicio.' }),
    note: t({ en: 'Values are mean (SD) or number (%). BP, blood pressure, in mm Hg.',
      es: 'Valores en media (DE) o número (%). PA, presión arterial, en mm Hg.' }),
    table: { model: { headerRowCount: 1, columnWidths: [2.2, 1.1, 1.1],
      rows: parseTSV(baseline).rows.map((row, r) => row.map(({ content }, c) => ({ content,
        ...(r === 0 && { isHeader: true }), ...(c > 0 && { align: 'center' }) }))) } } },
  { id: 'fig-home', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    placement: { position: 'top' }, svg: { fileId: 'home.svg', width: 850, height: 520 },
    caption: t({ en: 'Mean home systolic pressure by week in the telemonitoring group, with its '
      + '95% confidence interval. The dashed line is the home target of 135\u00a0mm\u00a0Hg.',
    es: 'Presión sistólica domiciliaria media por semana en el grupo de telemonitorización, con '
      + 'su intervalo de confianza del 95\u00a0%. La línea discontinua es el objetivo de '
      + '135\u00a0mm\u00a0Hg.' }),
    altText: t({ en: 'A line falling from 158 mm Hg in week 1 to 135 mm Hg in week 12.',
      es: 'Una línea que baja de 158 mm Hg en la semana 1 a 135 mm Hg en la semana 12.' }) },
];

表は表計算ソフトが書き出すのと同じタブ区切りのテキストから読み込み、見出し行はティールで塗って白い太字のラベルを載せます。2つのリソースはどちらも、それを引用する段落の後で段の頭へフロートします。shortLabelにタイプの正式名を設定すると、:refは論文誌の書き方どおり「(Table 1)」と印刷します。題に付けたnumbered: falseで、番号は1.1ではなく1になります。

script.js · 17–21行コード全体で見る
const palette = { ink: '#1a1d21', accent: '#0d5c63', tint: '#e5eff0', rule: '#9fb4b6',
  muted: '#566065', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 095 · A medical article in Vancouver style ══════════════
// https://postext.dev/en/cookbook/medical-article-vancouver
// Code: MIT · Text: original (CC BY 4.0) · Chart: generated in code (CC BY 4.0)
// Fonts: PT Serif, Fira Sans, Fira Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.12.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerCitationEngine,
  registerResourceImage, defaultResourceTypes, parseTSV,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
import { createCiteprocEngine, STYLES, LOCALES } from 'https://esm.sh/postext-citeproc';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'medical-article-vancouver';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black text, one clinical teal for the journal's own marks
const palette = { ink: '#1a1d21', accent: '#0d5c63', tint: '#e5eff0', rule: '#9fb4b6',
  muted: '#566065', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [SERIF, SANS, COND] = ['PT Serif', 'Fira Sans', 'Fira Sans Condensed'];
const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [210, 297, 24, 22, 17, 17, 6];
const MEASURE = TRIM_W - INNER - OUTER;
const LEAD = 12.8; // pt: 9.3 pt type, as journals set two columns of A4

// #region answer: Vancouver numbers as superscripts, a "1." list and DOIs that are links
// [@key] prints a raised number in order of first citation. English journals set it after
// the full stop, "control.[@ncdrisc2021]"; Spanish ones before it, "controlada[@ncdrisc2021].".
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
// elsevier-vancouver is the bundled NLM/Vancouver style (citation-sequence). Two edits bring
// its list to the NLM sample references: "1." instead of "[1]", and the issue after the
// volume, 2015;373(22):2103-16.
const vancouver = STYLES['elsevier-vancouver']
  .replace('<text variable="citation-number" prefix="[" suffix="]"/>',
    '<text variable="citation-number" suffix="."/>')
  .replace('<text variable="volume"/>',
    '<group><text variable="volume"/><text variable="issue" prefix="(" suffix=")"/></group>');
const citations = {
  style: 'custom', customStyle: vancouver,
  marker: 'superscript', collapseRanges: true, // raised 1 and 6–8, not [1] and [6–8]
  link: true, // each number jumps to its reference, in the PDF and on screen
  bibliography: { fontSize: em(0.86), lineHeight: pt(10.4), entrySpacing: pt(1.6),
    labelWidth: mm(2.85), // the width of "1. " at 8 pt: the turnovers line up with the text
    doi: 'link' }, // https://doi.org/… printed whole and clickable
};
// #endregion

// #region title: the masthead band, article type, title, authors and affiliations
const text = (id, content, family, size, extra) => ({ kind: 'text', id, content, align: 'left',
  fontFamily: family, fontSize: pt(size), color: col('ink'), overflow: 'wrap', ...extra });
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: 'auto' } }) });
const caps = { fontFamily: COND, fontWeight: 600, letterSpacing: pt(1.3),
  textTransform: 'uppercase' };
const titleBlock = { enabled: true,
  minHeight: mm(62), // the abstract and the text start under the note, on both columns
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('accent') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(15) } } },
    text('journal', t({ en: 'Almenara Medical Journal', es: 'Revista Médica de Almenara' }),
      COND, 9.5, { ...caps, color: col('paper'), placement: at('page', 'top-left', INNER, 6) }),
    text('issue', '2026 · 14(3) · 181–183', COND, 9.5, { ...caps, color: col('paper'),
      align: 'right', placement: at('page', 'top-right', -OUTER, 6, 60) }),
    text('kind', '{attr.kind}', COND, 9, { ...caps, color: col('accent'),
      placement: at('container', 'top-left', 0, 0, MEASURE) }),
    text('title', '{titleText}', SANS, 19.5, { fontWeight: 600, lineHeight: 1.16,
      placement: at('#kind', 'below', 0, 3, MEASURE - 20) }),
    text('authors', '{attr.authors}', SERIF, 10.5, { inlineMarks: true, // ^1^ → ¹
      placement: at('#title', 'below', 0, 4.5, MEASURE) }),
    text('affiliations', '{attr.affiliations}', SANS, 7.4, { inlineMarks: true,
      lineHeight: 1.35, color: col('muted'), placement: at('#authors', 'below', 0, 2, MEASURE) }),
    { kind: 'rule', id: 'rule', thickness: pt(0.5), color: col('rule'),
      placement: at('#affiliations', 'below', 0, 3, MEASURE) },
    text('note', '{attr.note}', SANS, 7.4, { fontStyle: 'italic', color: col('muted'),
      placement: at('#rule', 'below', 0, 1.6, MEASURE) }),
  ] },
};
// #endregion

// #region abstract: the structured abstract in a teal tint, the key points under a teal stripe
const box = (id, bold, more) => ({ id, border: { enabled: false }, snapToGrid: false,
  marginTop: pt(LEAD), marginBottom: pt(LEAD),
  titleStyle: { ...caps, fontSize: pt(8.5), color: col('accent') }, // run-in labels in bold
  body: { fontFamily: SANS, fontSize: pt(8.3), lineHeight: pt(11.2), textAlign: 'left',
    firstLineIndent: pt(0), paragraphSpacing: true, boldColor: col(bold) }, ...more });
const calloutStyles = [
  box('abstract', 'accent', { background: col('tint'), padding: mm(3.5), marginTop: pt(0) }),
  box('keypoints', 'ink', { backgroundEnabled: false, padding: { top: mm(2.5), right: mm(0),
    bottom: mm(1), left: mm(0) }, stripe: { enabled: true, side: 'top', width: pt(2.5),
    color: col('accent') } }),
];
// #endregion

const head = (id, content, parity, edge, x, extra) => text(id, content, COND, 8, {
  parity, pages: 'body', fontWeight: 500, letterSpacing: pt(0.4), color: col('muted'),
  overflow: 'clip', placement: at('page', edge, x, 13, 100), ...extra });
const folio = { fontWeight: 600, color: col('accent') };
const right = { align: 'right' }; // anchored top-right, an element ends at its offset
const header = { elements: [
  head('v-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio),
  head('v-title', t({ en: 'Almenara Med J 2026;14(3)', es: 'Rev Med Almenara 2026;14(3)' }),
    'even', 'top-left', OUTER + 9),
  head('r-title', t({ en: 'Ortega Ramos et al. · Telemonitoring after severe hypertension',
    es: 'Ortega Ramos et al. · Telemonitorización tras una crisis hipertensiva' }),
  'odd', 'top-right', -OUTER - 9, right),
  head('r-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, { ...folio, ...right }),
] };
const footer = { elements: [head('drop-folio', '{pageNumber}', 'all', 'bottom-right', -OUTER,
  { ...folio, ...right, pages: 'opener', placement: at('page', 'bottom-right', -OUTER, -13, 10) }),
] };

const sans = (size, weight) => ({ fontFamily: SANS, fontSize: pt(size), fontWeight: weight });
const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-gb', es: 'es' }),
  // "(Table 1)" in the text, not "Tab. 1"; Table 1, not 1.1, as the title is numbered: false
  resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, shortLabel: type.name,
    ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
  colorPalette, citations, calloutStyles, header, footer,
  headingStyles: [
    // No header: [] here, it would hold for the whole section; pages: 'body' spares page 1
    { id: 'article', numbered: false, span: 'page', advancedDesign: titleBlock },
    { id: 'back', numbered: false, fontSize: pt(9.5), color: col('ink') },
  ],
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    pageNumbering: { startAt: 181 }, // the article opens on page 181 of the issue
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: { fontFamily: SERIF, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    referenceBold: false, textAlign: 'justify', firstLineIndent: mm(4),
    indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: SANS, color: col('accent'), fontWeight: 600, levels: [
    { level: 1, breakBefore: { enabled: true, parity: 'any' } }, // gotcha: headings-drop-h1-break
    { level: 2, ...sans(11.5, 600), lineHeight: pt(LEAD), marginTop: pt(LEAD),
      marginBottom: pt(0) }, // a line above, the text straight under it
    { level: 3, ...sans(9.3, 600), color: col('ink'), lineHeight: pt(LEAD),
      marginTop: pt(LEAD / 2), marginBottom: pt(0) },
  ] },
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('accent'), headerColor: col('paper'), headerBold: true,
    headerFontFamily: SANS, headerFontSize: pt(7.6), bodyFontFamily: SANS,
    bodyFontSize: pt(7.6), bodyColor: col('ink'), cellPadding: mm(1.2) },
  captionStyle: { fontFamily: SANS, fontSize: pt(7.8), color: col('ink'), labelBold: true,
    labelColor: col('accent'), gap: mm(2), note: { fontSize: pt(6.8), color: col('muted') } },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdownの見本 · 129行 · content.en.mdtitle: "Pharmacist-led home blood pressure telemonitoring after an emergency visit for severe hypertension" author: "Lucía Ortega Ramos, Daniel Achterberg, Marta Quiroga Gil and Samuel Okafor" --- # Pharmacist-led home blood pressure telemonitoring after an emergency visit for severe hypertension: a pilot randomised trial {style="article" kind="Original article" authors="Lucía Ortega Ramos^1^, Daniel Achterberg^2^, Marta Quiroga Gil^1^, Samuel Okafor^3^" affiliations="^1^ Department of Internal Medicine, Hospital General de Almenara\n^2^ Community Pharmacy Network, Almenara Health Area\n^3^ Primary Care Research Unit, Almenara Health Area" note="Illustrative article written for the Postext Cookbook: the trial, its authors and its data are invented. The works cited are real."} :::callout{type="abstract" title="Abstract"} **Background** Patients discharged from the emergency department after a visit for severe hypertension often wait weeks for a review, and many return with the same readings. **Methods** In this pilot trial, 120 adults seen for a systolic blood pressure of 180 mm Hg or more were randomised to home telemonitoring with medication titration by a community pharmacist, or to usual care. The primary outcome was office systolic pressure at 12 weeks. **Results** At 12 weeks, mean systolic pressure was 136.2 mm Hg with telemonitoring and 145.9 mm Hg with usual care (adjusted difference −9.4 mm Hg; 95% CI −13.8 to −5.0). Control below 140/90 mm Hg was reached by 63% and 37% of patients. **Conclusions** Telemonitoring led by pharmacists lowered blood pressure in the weeks after an emergency visit. A larger trial with cardiovascular outcomes is justified. **Keywords** hypertension; telemonitoring; pharmacists; emergency department; self-monitoring ::: ## Introduction High blood pressure is the leading modifiable cause of cardiovascular death. In 2019 about 1.28 billion adults aged 30 to 79 years had hypertension, and fewer than a quarter of them had it under control.[@ncdrisc2021] Each reduction of 10 mm Hg in systolic pressure lowers the risk of major cardiovascular events by about a fifth,[@ettehad2016] and a target below 120 mm Hg prevented more events than one below 140 mm Hg in patients at high risk.[@sprint2015] European guidelines ask for control within three months of starting treatment,[@williams2018] and American ones for a review every month until it is reached.[@whelton2018] Patients who come to the emergency department with severe hypertension and no acute organ damage are a group the guidelines say little about. Most are sent home with a prescription and a referral to their general practitioner, and the first review may be weeks away. Self-monitoring of blood pressure lowers it when it is combined with a plan for acting on the readings,[@tucker2017; @mcmanus2014; @mcmanus2018] and @margolis2013 showed that pharmacists who adjusted treatment from transmitted home readings improved control for a year in primary care. Whether the same model works in the weeks after an emergency visit has not been tested. We designed a pilot trial to estimate the effect of pharmacist-led telemonitoring on blood pressure at 12 weeks in this group, and to measure recruitment, retention and safety for a larger trial. :::callout{type="keypoints" title="Key points"} **What is already known** Self-monitoring lowers blood pressure when someone acts on the readings, and pharmacists who manage telemonitored readings improve control in primary care. **What this study adds** After an emergency visit for severe hypertension, telemonitoring with titration by a community pharmacist lowered office systolic pressure by 9.4 mm Hg more than usual care at 12 weeks. ::: ## Methods ### Design and participants This was a parallel-group, open-label pilot trial with 1:1 allocation, reported according to the CONSORT statement.[@schulz2010] We enrolled adults aged 18 to 80 years discharged from the emergency department of a district general hospital after a visit with a systolic pressure of 180 mm Hg or more, with no acute organ damage, and living within reach of a participating community pharmacy. Patients with secondary hypertension, an estimated glomerular filtration rate below 30 mL/min/1.73 m², pregnancy or a life expectancy under one year were excluded. ### Interventions Each patient in the telemonitoring group received a validated monitor for the upper arm, which sent every reading to a secure server. They measured twice in the morning and twice in the evening on three days a week. A community pharmacist reviewed the readings weekly and changed treatment by a written protocol agreed with the general practitioners of the area, aiming at a home mean below 135/85 mm Hg. Patients in the usual-care group were referred to their general practitioner, as is routine. ### Outcomes and analysis The primary outcome was the mean of the second and third of three office systolic readings at 12 weeks, taken by a nurse who did not know the allocation. Secondary outcomes were control below 140/90 mm Hg, return visits to the emergency department and adverse events. Data were collected in REDCap.[@harris2009] The difference between groups was estimated by linear regression adjusted for baseline systolic pressure. With 60 patients per group, the trial had 80% power to detect a difference of 8 mm Hg, assuming a standard deviation of 15 mm Hg and 10% loss to follow-up. ## Results Between January and June, 163 patients were screened and 120 were randomised; 57 in the telemonitoring group and 57 in the usual-care group attended the 12-week visit. The groups were similar at baseline (:ref{id="tbl-baseline"}). Patients in the telemonitoring group sent a median of 31 sets of readings, and the pharmacists made a median of two changes to treatment per patient. Home systolic pressure in the telemonitoring group fell most in the first four weeks, and by week 10 it was close to the target (:ref{id="fig-home"}). At 12 weeks, the mean office systolic pressure was 136.2 mm Hg with telemonitoring and 145.9 mm Hg with usual care, an adjusted difference of −9.4 mm Hg (95% CI −13.8 to −5.0). Control below 140/90 mm Hg was reached by 36 of 57 patients (63%) with telemonitoring and by 21 of 57 (37%) with usual care. Three patients in the telemonitoring group and nine in the usual-care group returned to the emergency department with raised blood pressure. Two patients in the telemonitoring group reported dizziness on standing after a dose increase, and both recovered after the dose was reduced. No serious adverse events were related to the intervention. ## Discussion In this pilot trial, telemonitoring with titration by a community pharmacist lowered office systolic pressure by about 9 mm Hg more than usual care in the 12 weeks after an emergency visit for severe hypertension. The size of the effect is close to the 10 mm Hg that meta-analysis associates with a fifth fewer cardiovascular events,[@ettehad2016] and it was reached within the three months that European guidelines allow.[@williams2018] The result agrees with trials of self-monitoring in primary care, where the benefit came from acting on the readings rather than from taking them.[@tucker2017] Pharmacists were well placed to act. They saw the readings every week, they knew the patients from collecting their prescriptions, and the protocol let them change treatment without waiting for an appointment, as the pharmacists did in the trial by @margolis2013 in Minnesota. The trial has limitations. It was small and open-label, it was run in one health area, and 12 weeks is too short to show an effect on events. The office readings were taken by a nurse blind to the allocation, which limits the bias that an open design invites. Fewer return visits to the emergency department in the telemonitoring group are encouraging, but the numbers are too small for a conclusion. A multicentre trial powered for return visits and cardiovascular events is the next step. The rates of recruitment and retention seen here, three in four patients screened and 95% retained, suggest that it is feasible. ## Article information {style="back"} **Funding** None. **Competing interests** None declared. **Note** Set in PT Serif, Fira Sans and Fira Sans Condensed (SIL OFL). Text: original, CC BY 4.0. The study, its authors, institutions and results are invented for this example; the references are to real published works. ## References {style="back"} :::bibliography{title=""} :::references{format=bibtex} @article{ncdrisc2021, author = {{NCD Risk Factor Collaboration (NCD-RisC)}}, title = {Worldwide trends in hypertension prevalence and progress in treatment and control from 1990 to 2019: a pooled analysis of 1201 population-representative studies with 104 million participants}, journal = {Lancet}, year = 2021, volume = 398, number = 10304, pages = {957--980}, doi = {10.1016/S0140-6736(21)01330-1}} @article{ettehad2016, author = {Ettehad, Dena and Emdin, Connor A. and Kiran, Amit and Anderson, Simon G. and Callender, Thomas and Emberson, Jonathan and Chalmers, John and Rodgers, Anthony and Rahimi, Kazem}, title = {Blood pressure lowering for prevention of cardiovascular disease and death: a systematic review and meta-analysis}, journal = {Lancet}, year = 2016, volume = 387, number = 10022, pages = {957--967}, doi = {10.1016/S0140-6736(15)01225-8}} @article{sprint2015, author = {{SPRINT Research Group}}, title = {A randomized trial of intensive versus standard blood-pressure control}, journal = {N Engl J Med}, year = 2015, volume = 373, number = 22, pages = {2103--2116}, doi = {10.1056/NEJMoa1511939}} @article{whelton2018, author = {Whelton, Paul K. and Carey, Robert M. and Aronow, Wilbert S. and Casey, Donald E. and Collins, Karen J. and Dennison Himmelfarb, Cheryl and DePalma, Sondra M.}, title = {2017 {ACC/AHA/AAPA/ABC/ACPM/AGS/APhA/ASH/ASPC/NMA/PCNA} guideline for the prevention, detection, evaluation, and management of high blood pressure in adults}, journal = {Hypertension}, year = 2018, volume = 71, number = 6, pages = {e13--e115}, doi = {10.1161/HYP.0000000000000065}} @article{williams2018, author = {Williams, Bryan and Mancia, Giuseppe and Spiering, Wilko and Agabiti Rosei, Enrico and Azizi, Michel and Burnier, Michel and Clement, Denis L.}, title = {2018 {ESC/ESH} guidelines for the management of arterial hypertension}, journal = {Eur Heart J}, year = 2018, volume = 39, number = 33, pages = {3021--3104}, doi = {10.1093/eurheartj/ehy339}} @article{tucker2017, author = {Tucker, Katherine L. and Sheppard, James P. and Stevens, Richard and Bosworth, Hayden B. and Bove, Alfred and Bray, Emma P. and Earle, Kenneth}, title = {Self-monitoring of blood pressure in hypertension: a systematic review and individual patient data meta-analysis}, journal = {PLoS Med}, year = 2017, volume = 14, number = 9, pages = {e1002389}, doi = {10.1371/journal.pmed.1002389}} @article{mcmanus2014, author = {McManus, Richard J. and Mant, Jonathan and Haque, M. Sayeed and Bray, Emma P. and Bryan, Stirling and Greenfield, Sheila M. and Jones, Miren I.}, title = {Effect of self-monitoring and medication self-titration on systolic blood pressure in hypertensive patients at high risk of cardiovascular disease: the {TASMIN-SR} randomized clinical trial}, journal = {JAMA}, year = 2014, volume = 312, number = 8, pages = {799--808}, doi = {10.1001/jama.2014.10057}} @article{mcmanus2018, author = {McManus, Richard J. and Mant, Jonathan and Franssen, Marloes and Nickless, Alecia and Schwartz, Claire and Hodgkinson, James and Bradburn, Peter}, title = {Efficacy of self-monitored blood pressure, with or without telemonitoring, for titration of antihypertensive medication ({TASMINH4}): an unmasked randomised controlled trial}, journal = {Lancet}, year = 2018, volume = 391, number = 10124, pages = {949--959}, doi = {10.1016/S0140-6736(18)30309-X}} @article{margolis2013, author = {Margolis, Karen L. and Asche, Stephen E. and Bergdall, Anna R. and Dehmer, Steven P. and Groen, Sarah E. and Kadrmas, Holly M. and Kerby, Tessa J.}, title = {Effect of home blood pressure telemonitoring and pharmacist management on blood pressure control: a cluster randomized clinical trial}, journal = {JAMA}, year = 2013, volume = 310, number = 1, pages = {46--56}, doi = {10.1001/jama.2013.6549}} @article{schulz2010, author = {Schulz, Kenneth F. and Altman, Douglas G. and Moher, David and {CONSORT Group}}, title = {{CONSORT} 2010 statement: updated guidelines for reporting parallel group randomised trials}, journal = {BMJ}, year = 2010, volume = 340, pages = {c332}, doi = {10.1136/bmj.c332}} @article{harris2009, author = {Harris, Paul A. and Taylor, Robert and Thielke, Robert and Payne, Jonathon and Gonzalez, Nathaniel and Conde, Jose G.}, title = {Research electronic data capture ({REDCap}): a metadata-driven methodology and workflow process for providing translational research informatics support}, journal = {J Biomed Inform}, year = 2009, volume = 42, number = 2, pages = {377--381}, doi = {10.1016/j.jbi.2008.08.010}} :::
`; const baseline = String.raw`Characteristic Telemonitoring \\ (n = 60) Usual care \\ (n = 60)
Markdownの見本 · 10行 · content.baseline.en.mdAge, years 58.7 (11.2) 60.1 (10.4) Women 27 (45) 29 (48) Systolic BP at the emergency visit 192.4 (10.8) 190.9 (9.6) Systolic BP at randomisation 161.3 (13.5) 160.2 (12.9) Diastolic BP at randomisation 94.8 (10.1) 93.6 (9.7) Diabetes 14 (23) 12 (20) Chronic kidney disease 6 (10) 8 (13) Antihypertensive drugs, number 1.4 (1.0) 1.5 (1.1) No treatment before the visit 19 (32) 17 (28) Home monitor before the trial 11 (18) 13 (22)
`; // TSV: characteristic, telemonitoring, usual care // #region resources: Table 1 from the TSV, Figure 1 drawn from the weekly means const resources = () => [ { id: 'tbl-baseline', typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, placement: { position: 'top' }, caption: t({ en: 'Characteristics of the patients at baseline.', es: 'Características de los pacientes al inicio.' }), note: t({ en: 'Values are mean (SD) or number (%). BP, blood pressure, in mm Hg.', es: 'Valores en media (DE) o número (%). PA, presión arterial, en mm Hg.' }), table: { model: { headerRowCount: 1, columnWidths: [2.2, 1.1, 1.1], rows: parseTSV(baseline).rows.map((row, r) => row.map(({ content }, c) => ({ content, ...(r === 0 && { isHeader: true }), ...(c > 0 && { align: 'center' }) }))) } } }, { id: 'fig-home', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, placement: { position: 'top' }, svg: { fileId: 'home.svg', width: 850, height: 520 }, caption: t({ en: 'Mean home systolic pressure by week in the telemonitoring group, with its ' + '95% confidence interval. The dashed line is the home target of 135\u00a0mm\u00a0Hg.', es: 'Presión sistólica domiciliaria media por semana en el grupo de telemonitorización, con ' + 'su intervalo de confianza del 95\u00a0%. La línea discontinua es el objetivo de ' + '135\u00a0mm\u00a0Hg.' }), altText: t({ en: 'A line falling from 158 mm Hg in week 1 to 135 mm Hg in week 12.', es: 'Una línea que baja de 158 mm Hg en la semana 1 a 135 mm Hg en la semana 12.' }) }, ]; // #endregion // #region art: the weekly chart, its labels set in Fira Sans carried inside the SVG const HOME = [158.4, 153.1, 149.6, 146.2, 143.8, 141.5, 139.9, 138.6, 137.4, 136.1, 135.3, 134.6]; const n2 = (v) => +v.toFixed(2); // An SVG drawn as an image cannot see the page's web fonts (gotcha: svg-no-webfonts), so the // chart carries its face inline, as a data URL of the Fontsource file. async function inlineFace(family, weight) { const id = family.toLowerCase().replace(/\s+/g, '-'); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${weight}` + '-normal.woff2'; const bytes = new Uint8Array(await (await fetch(url)).arrayBuffer()); let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return `<style>@font-face{font-family:F;src:url(data:font/woff2;base64,${btoa(bin)}) ` + `format('woff2')}text{font-family:F}</style>`; } function homeChart(face) { // 85 × 52 mm: the width of a column const [W, H, L, R, T, B] = [85, 52, 11, 3, 4, 42]; const x = (week) => L + ((week - 1) / 11) * (W - L - R); const y = (v) => B - ((v - 125) / 40) * (B - T); // 125 to 165 mm Hg const label = (tx, ty, s, anchor = 'middle') => `<text x="${n2(tx)}" y="${n2(ty)}" ` + `font-size="2.7" text-anchor="${anchor}" fill="${palette.muted}">${s}</text>`; let grid = ''; for (const v of [130, 140, 150, 160]) { grid += `<path d="M${L} ${n2(y(v))}H${W - R}" stroke="${palette.rule}" stroke-width="0.2"/>` + label(L - 1.6, y(v) + 0.9, v, 'end'); } for (let w = 1; w <= 12; w++) grid += label(x(w), B + 4, w); const half = (i) => 4.6 - i * 0.18; // the interval narrows as readings accumulate const upper = HOME.map((v, i) => `${n2(x(i + 1))} ${n2(y(v + half(i)))}`); const lower = HOME.map((v, i) => `${n2(x(i + 1))} ${n2(y(v - half(i)))}`).reverse(); const line = HOME.map((v, i) => `${n2(x(i + 1))} ${n2(y(v))}`).join('L'); const dots = HOME.map((v, i) => `<circle cx="${n2(x(i + 1))}" cy="${n2(y(v))}" r="0.75" ` + `fill="${palette.accent}"/>`).join(''); const dash = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17].map((k) => { const x0 = L + k * 4; return x0 + 2 > W - R ? '' : `M${x0} ${n2(y(135))}h2`; }).join(''); return `<svg xmlns="http://www.w3.org/2000/svg" width="850" height="520" ` + `viewBox="0 0 ${W} ${H}">${face}${grid}` + `<path d="M${upper.join('L')}L${lower.join('L')}Z" fill="${palette.tint}"/>` + `<path d="${dash}" stroke="${palette.ink}" stroke-width="0.3"/>` + `<path d="M${L} ${B}H${W - R}" stroke="${palette.ink}" stroke-width="0.35"/>` + `<path d="M${line}" fill="none" stroke="${palette.accent}" stroke-width="0.6"/>${dots}` + label(L - 1.6, T - 1.2, 'mm Hg', 'end') + label((L + W - R) / 2, H - 0.8, t({ en: 'Week', es: 'Semana' })) + '</svg>'; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'PT Serif': ['400', '400i', '700', '700i'], 'Fira Sans': ['400', '400i', '600'], 'Fira Sans Condensed': ['500', '600'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('home.svg', homeChart(await inlineFace(SANS, 400))); const content = { markdown, resources: resources() }; const doc = await buildWithFonts(() => buildDocument(content, config()), markdown); const title = t({ en: 'A medical article in Vancouver style', es: 'Un artículo médico en estilo Vancouver' }); showPages(doc, { title }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
キット · 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上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#AMAスタイルを使う

AMAスタイルは自分で番号を上付きにし、文献を「doi:10.1056/…」の形で並べます。編集とマーカーを外してください。

-  style: 'custom', customStyle: vancouver,
-  marker: 'superscript', collapseRanges: true, // raised 1 and 6–8, not [1] and [6–8]
+  style: 'american-medical-association',

#DOIをリンクなしで印刷する

印刷専用のPDFでは、アドレスを黒のただのテキストにします。

-    doi: 'link' }, // https://doi.org/… printed whole and clickable
+    doi: 'text' },

#角かっこの番号

バンクーバー方式の雑誌には、番号を行の高さで角かっこに入れて書くものもあります。

-  marker: 'superscript', collapseRanges: true, // raised 1 and 6–8, not [1] and [6–8]
+  marker: 'brackets', collapseRanges: true,

よくあるつまずき

つまずき

見出しスタイルのヘッダーは節全体に効く

タイトルのスタイルに書いたheader: { elements: [] }は、章扉だけでなく、次のスタイル付きの見出しまでの柱をすべて消します。最初のページだけで柱を消すには、ヘッダー要素にpages: 'body'を指定してください。 柱とノンブル →

つまずき

headingsオブジェクトを渡すとH1の改ページが消える

既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →

つまずき

SVGの<img>内のテキストはWebフォントを使えない

SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →

つまずき

Figure/TableはdefaultResourceTypes(locale)でローカライズする

設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。 「図」と「表」を文書の言語で →

つまずき

レイアウトの前にすべてのフォントを読み込む

レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →

つまずき

フロントマターの値はすべて引用符で囲む

YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください(title: "1984")。 文書のメタデータ →

つまずき

設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る

エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →

  • 本文が4と6のように番号の連続しない2つの文献を引用すると、postext 1.12.1は上付きの番号を「4, 6」と書き、カンマの後のスペースが両端そろえの行に合わせて伸びます。この論文では、そうした文献は別々の箇所で引用しています。
  • labelWidthが決めるのは2行目以降の字下げで、番号の列ではありません。1行目は番号とスペースの後から始まります。2.85mmにすると項目1から9のテキストはそろいますが、項目10と11は少し右から始まります。
  • 見出しスタイルのheaderは、その見出しが始める節全体に効きます。題にheader: { elements: [] }を付けると、次のスタイル付き見出しまでのすべてのページから柱が消えます。1ページ目に柱を出さないだけなら、柱にpages: 'body'を付ければ足ります。

クレジット

レシピ
Ignacio Ferro
本文
書き下ろしの文章, CC BY 4.0
フォント
PT Serif (SIL OFL 1.1) · Fira Sans (SIL OFL 1.1) · Fira Sans Condensed (SIL OFL 1.1)
SandboxPDF