Kurz gesagt
Ein kurzer medizinischer Forschungsartikel, wie eine Zeitschriftenseite gesetzt. Die Autoren zitieren mit Kürzeln; Postext druckt kleine hochgestellte Zahlen in den Text und baut am Ende das nummerierte Verzeichnis, jede DOI als Link.
Was Sie setzen
Drei Seiten einer klinischen Fachzeitschrift: ein Originalartikel über eine Pilotstudie, auf A4 in zwei Spalten gesetzt, wie die ICMJE-Zeitschriften sie drucken. Ein türkisblauer Zeitschriftenkopf läuft über den oberen Rand der ersten Seite, über dem Artikeltyp, einem dreizeiligen Titel, den Autoren mit hochgestellten Affiliationsnummern und einer strukturierten Zusammenfassung auf blasser Tönung. Die Autoren zitieren mit BibTeX-Schlüsseln, und die Seiten zeigen, was eine medizinische Redaktion erwartet: kleine hochgestellte Zahlen nach dem Punkt, aufeinanderfolgende Werke verbunden als 6–8, zwei andere eng gesetzt als 6,9, und ein nummeriertes Literaturverzeichnis in der Form der NLM, Lancet 2016;387(10022):957–67, jeweils abgeschlossen mit der DOI des Werks. Im PDF springt jede Nummer zu ihrer Literaturangabe, und jede DOI öffnet den Artikel. Die Studie ist erfunden; die elf Werke, die sie zitiert, gibt es wirklich.
Dieses Rezept beantwortet
- Wie setze ich Zitatnummern hochgestellt und mache DOIs zu aktiven Links, wie medizinische Fachzeitschriften es tun?
- Wie zitiere ich Werke und erstelle das Literaturverzeichnis nach APA, IEEE oder einem anderen Zitierstil?
Die kurze Antwort
// [@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(5.5), labelAlign: 'right', // a column for the numbers: 9. and 10. end
// together, and every entry's text starts at one x, first line and turnovers alike
doi: 'link' }, // https://doi.org/… printed whole and clickable
};
Zutaten
- Funktionen
- Zitate in einem ZitierstilLiteraturverzeichnis aus den QuellenHoch- und TiefstellungÜberschriftenformateÜberschriftenattributeGestaltete AuftakteKästenKolumnentitel und SeitenzahlenTabellenformatFormat der BildunterschriftPlatzierung von AbbildungenSemantische FarbpalettePDF-Export
- Nutzt außerdem
- Verweise, die Abbildungen platzierenAbbildung und Tabelle in Ihrer SpracheDas Raster bewusst verlassenKopfzeilen nach SeitenrolleIm PDF eingebettete SchriftenEigene RessourcentypenAbbildungen und Tabellen als RessourcenRömisch paginierte TiteleiTabellen aus DatenKapitel ohne Nummer
- Schrift
- PT Serif, Fira Sans, Fira Sans Condensed (SIL OFL 1.1)
- Dateien
- Keine: Jedes Bild wird im Code gezeichnet
Zubereitung
#1 · Vancouver, hochgestellt und verlinkt
Der Code steht oben in der kurzen Antwort. Unter den mitgelieferten Stilen folgt elsevier-vancouver dem Citing Medicine der NLM (Reihenfolge der Zitate, Autoren als Ettehad D, Emdin CA, sechs Namen und dann et al., abgekürzte Zeitschriftentitel), es ist also die Datei, von der man ausgeht. Zwei Zeichenkettenersetzungen bringen sein Verzeichnis auf die Musterangaben der NLM: Die Nummer lautet 1. statt [1], und das Heft folgt auf den Jahrgang. marker: 'superscript' setzt die Zitate als hochgestellte Zahlen, gleich was der Stil schreibt, und doi: 'link' behält jede Adresse https://doi.org/ als Link. Zwei auseinanderliegende Werke stehen eng, 6,9: Ein Leerzeichen nach dem Komma würde sich in einer Blocksatzzeile dehnen. Im Verzeichnis gibt labelWidth den Nummern eine eigene Spalte, und labelAlign: 'right' lässt 9. und 10. bündig enden, sodass der Text jedes Eintrags an derselben x-Position beginnt, in seiner ersten Zeile wie in den Folgezeilen. Der AMA-Stil ist ebenfalls mitgeliefert und stellt seine Zahlen von selbst hoch, sein Verzeichnis unterscheidet sich aber von Vancouver in Interpunktion und Seitenbereichen.
#2 · Ein Titelblock über beide Spalten
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) }),
] },
};
Der Artikeltitel ist die einzige Überschrift der Ebene 1, mit dem Stil article, der sich über die Seite erstreckt und den Titel aus seiner Gestaltung zeichnet. Artikeltyp, Autoren, Affiliationen und Hinweis kommen aus Attributen der Überschrift. inlineMarks macht aus ^1^ im Attribut eine hochgestellte Affiliationsnummer, und das \n in den Affiliationen beginnt für jede Einrichtung eine Zeile. Das Band ist am Endformat verankert und reicht deshalb bis zur Oberkante des Papiers.
#3 · Eine strukturierte Zusammenfassung und ein Kasten mit Kernaussagen
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') } }),
];
Beide Kästen teilen sich einen Textstil: Fira Sans in 8,3 pt mit fetten eingezogenen Bezeichnungen, der Ton, den Leser von zusammenfassenden Teilen erwarten. Die Zusammenfassung trägt eine Tönung und ihre Bezeichnungen in Türkisblau; die Kernaussagen stehen unter einem 2,5 pt starken Streifen, die Bezeichnungen in Textfarbe. snapToGrid: false hält den Innenabstand genau, sodass der Kasten der Zusammenfassung eng an seinem Text anliegt.
#4 · Tabelle 1 und Abbildung 1 am Kopf einer Spalte
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.' }) },
];
Die Tabelle wird aus tabulatorgetrenntem Text gelesen, wie ihn eine Tabellenkalkulation exportiert, und ihre Kopfzeile ist türkisblau gefüllt, mit weißen fetten Bezeichnungen. Beide Ressourcen gleiten an den Kopf einer Spalte nach dem Absatz, der sie zitiert. Ist shortLabel auf den vollen Namen des Typs gesetzt, druckt :ref (Table 1), wie Fachzeitschriften es schreiben, und das numbered: false am Titel hält die Zählung bei 1 statt 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' } }));
Das ganze Rezept
// ═══ 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.25.0 import { buildDocumentWithFonts, renderPageToCanvas, registerCitationEngine, registerResourceImage, defaultResourceTypes, parseTSV, inlineSvgFonts, } 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(5.5), labelAlign: 'right', // a column for the numbers: 9. and 10. end // together, and every entry's text starts at one x, first line and turnovers alike 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 = () => ({ 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' } }, // the article opens a page { 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-Beispiel · 129 Zeilen · 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; @margolis2013] 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-Beispiel · 10 Zeilen · 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 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); // The labels' family, named on the root element: loadSvg embeds its face. const LABELS = `font-family="${SANS}"`; 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 loadSvg('home.svg', homeChart(LABELS)); const content = { markdown, resources: resources() }; const doc = await buildDocumentWithFonts(content, config(), kitFonts(FONTS)); 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`);Kit · core, fonts, viewer, pdf, images: in jedem Rezept gleich · 315 Zeilen
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)
Varianten
#Den AMA-Stil verwenden
Der AMA-Stil stellt seine Zahlen selbst hoch und führt Werke mit doi:10.1056/… auf; lassen Sie die Änderungen und die Marke weg.
- style: 'custom', customStyle: vancouver,
- marker: 'superscript', collapseRanges: true, // raised 1 and 6–8, not [1] and [6–8]
+ style: 'american-medical-association',#Die DOIs ohne Links drucken
Für ein reines Druck-PDF bleiben die Adressen schlichter schwarzer Text.
- doi: 'link' }, // https://doi.org/… printed whole and clickable
+ doi: 'text' },#Nummern in eckigen Klammern
Manche Vancouver-Zeitschriften setzen die Nummern auf die Zeile, in eckige Klammern.
- marker: 'superscript', collapseRanges: true, // raised 1 and 6–8, not [1] and [6–8]
+ marker: 'brackets', collapseRanges: true,Häufige Fehler
Häufiger Fehler
Die Kopfzeile eines Überschriftenstils gilt für seinen ganzen Abschnitt
header: { elements: [] } im Stil eines Titels entfernt die Kolumnentitel bis zur nächsten Überschrift mit Stil, nicht nur auf dem Auftakt. Um sie nur von der ersten Seite fernzuhalten, geben Sie den Elementen der Kopfzeile pages: 'body'. Kolumnentitel und Seitenzahlen →
- Der
headereines Überschriftenstils gilt für den ganzen Abschnitt, den diese Überschrift eröffnet;header: { elements: [] }am Titel würde also die Kolumnentitel von jeder Seite bis zur nächsten Überschrift mit Stil entfernen.pages: 'body'an den Kolumnentiteln genügt, um sie von der ersten Seite fernzuhalten.
Nachweise
- Rezept
- Ignacio Ferro
- Text
- Eigener Text, CC BY 4.0
- Schriften
- PT Serif (SIL OFL 1.1) · Fira Sans (SIL OFL 1.1) · Fira Sans Condensed (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet in neuem Tab)Ordner des Rezepts auf GitHub ↗ (öffnet in neuem Tab)


