简单来说
一篇医学研究短文,按期刊页面排版。作者用代号引用文献;Postext在正文里印出小号上标数字,并在文末生成编号的参考文献表,每个DOI都是链接。
成品一览
一本临床期刊的三页:报告一项试点试验的原创论文,按遵循ICMJE规范的期刊那样排在A4纸上,分两栏。第一页顶部横贯一条深青色刊头,下面依次是文章类型、三行标题、带上标单位序号的作者,以及浅色底纹上的结构式摘要。作者用BibTeX代号引用,页面呈现医学编辑期望的样子:句号后的小号上标数字,连续文献合并为6–8,以及NLM格式的编号参考文献表,如Lancet 2016;387(10022):957–67,每条末尾是该文献的DOI。在PDF中,每个序号都跳到对应的参考文献,每个DOI都能打开原文。试验是虚构的;所引的十一篇文献都真实存在。
这道食谱解答
- 怎样像医学期刊那样把引用序号排成上标,并让DOI成为可点击的链接?
- 怎样按APA、IEEE或其他引用样式引用文献并生成参考文献表?
简短回答
// [@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 · 温哥华格式,上标并带链接
代码就是上面的简短回答。引擎自带的样式中,elsevier-vancouver遵循NLM的Citing Medicine(按引用顺序编号,作者写作Ettehad D, Emdin CA,列六位后加et al.,期刊名用缩写),所以从这个文件出发。两处字符串替换让它的文献表符合NLM的示例:序号写作1.而不是[1],期号跟在卷号后面。marker: 'superscript'不论样式怎么写,都把引用排成上标数字;doi: 'link'让每个https://doi.org/地址保持为链接。英文的上标放在句号之后;西班牙文按学院正字法放在句号之前,只要把[@key]写在句号前即可。AMA样式也随引擎提供,它自己就把序号排成上标,但文献表的标点和页码区间与温哥华格式不同。
#2 · 横跨两栏的标题区
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) }),
] },
};
文章标题是唯一的一级标题,使用article样式,该样式通栏,用自己的设计绘制标题。文章类型、作者、单位和注释都来自标题属性。inlineMarks把属性里的^1^变成上标的单位序号,单位列表中的\n让每个机构另起一行。色带锚定在裁切边上,所以一直延伸到纸张上边缘。
#3 · 结构式摘要和要点框
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') } }),
];
两个框共用一种文字样式:8.3 pt的Fira Sans,段首是粗体的小标签,这是读者在摘要类内容中习惯的字体。摘要框带底纹,标签为深青色;要点框位于2.5 pt的色条下,标签用正文墨色。snapToGrid: false保留精确的内边距,让摘要框紧贴文字。
#4 · 栏顶的表1和图1
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.' }) },
];
表格从制表符分隔的文本读入,就像电子表格导出的那样,表头一行填深青色,标签为白色粗体。两个资源都浮动到引用它们的段落之后的栏顶。把shortLabel设为类型的全名后,:ref会像期刊那样印出*(Table 1)*;标题上的numbered: false让编号保持为1,而不是1.1。
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' } }));
完整食谱
// ═══ 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.md
title: "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.md
Age, 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>中的文字不能使用网络字体
SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →
易错点
用defaultResourceTypes(locale)本地化Figure/Table
配置的locale决定断词,不决定题注:没有resourceTypes时,内置类型用英文写作Figure和Table。西班牙语传入resourceTypes: defaultResourceTypes('es');其他语言请在resourceTypes中自己写出名称。 用你的语言显示“图”和“表” →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
易错点
frontmatter的每个值都加引号
YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
- 同时引用两篇序号不连续的文献(如4和6)时,postext 1.12.1把上标写成4, 6,逗号后的空格会随两端对齐的行一起拉宽。本文把这类文献分开引用。
labelWidth设定的是条目转行的缩进,而不是序号所占的一栏:第一行从序号和一个空格之后开始。取2.85 mm时,第1至9条的正文对齐;第10、11条的第一行稍微靠右。- 标题样式的
header对该标题开启的整个节都有效,所以在文章标题上写header: { elements: [] },会让直到下一个带样式的标题之前的所有页面都没有页眉。要让第一页不出现页眉,在页眉元素上用pages: 'body'就够了。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- PT Serif (SIL OFL 1.1) · Fira Sans (SIL OFL 1.1) · Fira Sans Condensed (SIL OFL 1.1)


