Saltar al contingut principal
Recepta número 95

Receptari · Capítol 5 · Estructura del llibre

Un article mèdic en estil Vancouver

Un assaig clínic en A4 a dues columnes: números de cita volats, una bibliografia numerada a la manera de Vancouver i DOI que s'obren com a enllaços al PDF.

En aquesta pàgina
Sortida
Canvas · PDF
Nivell
Intermedi
Postext
Provada amb Postext 1.12.1
Requereix ≥ 1.12.1 · postext-pdf ≥ 1.12.1
Llicència
Actualitzada el 1 d’oct. del 2026
Codi MIT · Text CC BY 4.0
  • Mostra en castellà: encara no hi ha edició en català
  • Format 210 × 297 mm
  • 2 columnes, espai entre columnes de 6 mm
  • PT Serif 9,3/12,8
  • Fira Sans
  • Fira Sans Condensed
  • 3 pàgines
  • Nivell
  • Postext 1.12.1
  • Compost en 42 ms
  • 179 línies de codi

En poques paraules

Un article breu de recerca mèdica, compost com en una revista. Els autors citen amb codis; Postext imprimeix números petits volats i munta al final la llista numerada de referències, amb cada DOI com a enllaç.

Què compondràs

Tres pàgines d'una revista clínica: un article original que presenta un assaig pilot, compost en A4 a dues columnes tal com l'imprimeixen les revistes que segueixen les normes de l'ICMJE. Una banda verd blavós travessa el capdamunt de la primera pàgina sobre el tipus d'article, un títol de quatre línies, els autors amb els seus números de filiació volats i un resum estructurat sobre un tint pàl·lid. Els autors citen amb claus de BibTeX, i les pàgines mostren el que espera un editor mèdic: números petits volats, obres consecutives agrupades com a 6–8 i una bibliografia numerada en la forma de la NLM, Lancet 2016;387(10022):957-67, que tanca cada entrada amb el seu DOI. Al PDF cada número salta a la seva referència i cada DOI obre l'article. L'assaig és inventat; les onze obres que cita són reals.

Aquesta recepta respon a

  • Com poso els números de cita en superíndex i faig que els DOI siguin enllaços, com a les revistes mèdiques?
  • Com cito obres i componc la bibliografia en APA, IEEE o un altre estil de cita?

La resposta curta

script.js · línies 29–47al codi complet
// [@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
};

Ingredients

Tipografia
PT Serif, Fira Sans, Fira Sans Condensed (SIL OFL 1.1)
Recursos
Cap: totes les imatges es dibuixen en codi

Elaboració

#1 · Vancouver, volat i enllaçat

El codi és la resposta curta de més amunt. Dels estils que porta el motor, elsevier-vancouver és el que segueix el Citing Medicine de la NLM (ordre de cita, autors com Ettehad D, Emdin CA, sis noms i després et al., títols de revista abreujats), així que és el fitxer del qual convé partir. Dues substitucions de text acosten la seva llista als exemples de la NLM: el número es llegeix 1. i no [1], i el fascicle va després del volum. marker: 'superscript' compon les cites com a números volats escrigui el que escrigui l'estil, i doi: 'link' manté cada adreça https://doi.org/ com a enllaç. En anglès la crida va després del punt; en castellà va al davant, com demana l'ortografia acadèmica, i n'hi ha prou d'escriure [@clave] abans del punt. L'estil AMA també ve inclòs i vola els seus números tot sol, però la seva llista difereix de la de Vancouver en la puntuació i en els intervals de pàgines.

#2 · Un bloc de títol a dues columnes

script.js · línies 51–80al codi complet
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) }),
  ] },
};

El títol de l'article és l'únic títol de nivell 1, amb l'estil article, que ocupa tota la pàgina i el dibuixa amb el seu disseny. El tipus d'article, els autors, les filiacions i la nota surten d'atributs del títol. inlineMarks converteix el ^1^ de l'atribut en un número de filiació volat, i el \n de les filiacions obre una línia per a cada centre. La banda està ancorada al tall, així que arriba fins a la vora superior del paper.

#3 · Un resum estructurat i un requadre de punts clau

script.js · línies 84–94al codi complet
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') } }),
];

Els dos requadres comparteixen un estil de text: Fira Sans a 8,3 pt amb etiquetes en negreta al començament del paràgraf, la veu que el lector espera en el material de resum. El resum porta un tint i les etiquetes en verd blavós; els punts clau van sota un filet de 2,5 pt amb les etiquetes en tinta. snapToGrid: false respecta el farciment exacte, de manera que el requadre del resum s'ajusta al text.

#4 · La Taula 1 i la Figura 1 al capdamunt d'una columna

script.js · línies 298–317al codi complet
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.' }) },
];

La taula es llegeix d'un text separat per tabuladors, tal com l'exporta un full de càlcul, i la seva fila de capçalera va emplenada en verd blavós amb les etiquetes en blanc i negreta. Els dos recursos floten al capdamunt d'una columna després del paràgraf que els cita. Amb shortLabel igual al nom complet del tipus, :ref imprimeix (Taula 1), com escriuen les revistes, i el numbered: false del títol deixa el compte en 1 i no en 1.1.

script.js · línies 17–21al codi complet
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' } }));

La recepta completa

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

const LANG = 'es'; // @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`---
Mostra en Markdown · 129 línies · content.es.mdtitle: "Telemonitorización domiciliaria de la presión arterial dirigida por farmacéuticos tras una visita a urgencias por hipertensión grave" author: "Lucía Ortega Ramos, Daniel Achterberg, Marta Quiroga Gil y Samuel Okafor" --- # Telemonitorización domiciliaria de la presión arterial dirigida por farmacéuticos tras una visita a urgencias por hipertensión grave: ensayo piloto aleatorizado {style="article" kind="Original" authors="Lucía Ortega Ramos^1^, Daniel Achterberg^2^, Marta Quiroga Gil^1^, Samuel Okafor^3^" affiliations="^1^ Servicio de Medicina Interna, Hospital General de Almenara\n^2^ Red de Farmacias Comunitarias, Área de Salud de Almenara\n^3^ Unidad de Investigación en Atención Primaria, Área de Salud de Almenara" note="Artículo ilustrativo escrito para el Recetario de Postext: el ensayo, sus autores y sus datos son inventados. Las obras citadas son reales."} :::callout{type="abstract" title="Resumen"} **Introducción** Los pacientes dados de alta en urgencias tras una visita por hipertensión grave suelen esperar semanas hasta la revisión, y muchos vuelven con las mismas cifras. **Métodos** En este ensayo piloto, 120 adultos atendidos con una presión sistólica de 180 mm Hg o más se asignaron al azar a telemonitorización domiciliaria con ajuste del tratamiento por un farmacéutico comunitario o a la atención habitual. La variable principal fue la presión sistólica en consulta a las 12 semanas. **Resultados** A las 12 semanas, la presión sistólica media fue de 136,2 mm Hg con telemonitorización y de 145,9 mm Hg con atención habitual (diferencia ajustada −9,4 mm Hg; IC del 95 %, −13,8 a −5,0). El 63 % y el 37 % de los pacientes, respectivamente, quedaron por debajo de 140/90 mm Hg. **Conclusiones** La telemonitorización dirigida por farmacéuticos redujo la presión arterial en las semanas siguientes a una visita a urgencias. Está justificado un ensayo mayor con variables cardiovasculares. **Palabras clave** hipertensión; telemonitorización; farmacéuticos; servicio de urgencias; automedida ::: ## Introducción La hipertensión arterial es la primera causa modificable de muerte cardiovascular. En 2019 la padecían unos 1280 millones de adultos de 30 a 79 años, y menos de una cuarta parte la tenía controlada[@ncdrisc2021]. Cada descenso de 10 mm Hg en la presión sistólica reduce alrededor de una quinta parte el riesgo de episodios cardiovasculares mayores[@ettehad2016], y en pacientes de alto riesgo un objetivo inferior a 120 mm Hg evitó más episodios que uno inferior a 140 mm Hg[@sprint2015]. Las guías europeas piden alcanzar el control en los tres meses siguientes al inicio del tratamiento[@williams2018], y las estadounidenses, una revisión mensual hasta lograrlo[@whelton2018]. Los pacientes que llegan a urgencias con hipertensión grave y sin daño agudo de órgano diana son un grupo del que las guías apenas se ocupan. La mayoría vuelve a casa con una receta y un volante para su médico de familia, y la primera revisión puede tardar semanas. La automedida de la presión arterial la reduce cuando se combina con un plan para actuar según las cifras[@tucker2017; @mcmanus2014; @mcmanus2018], y @margolis2013 demostraron que los farmacéuticos que ajustaban el tratamiento a partir de las lecturas transmitidas desde casa mejoraban el control durante un año en atención primaria. Nadie ha comprobado si el mismo modelo sirve en las semanas que siguen a una visita a urgencias. Diseñamos un ensayo piloto para estimar el efecto de la telemonitorización dirigida por farmacéuticos sobre la presión arterial a las 12 semanas en este grupo, y para medir el reclutamiento, la permanencia y la seguridad de cara a un ensayo mayor. :::callout{type="keypoints" title="Puntos clave"} **Qué se sabe** La automedida reduce la presión arterial cuando alguien actúa según las lecturas, y los farmacéuticos que gestionan lecturas telemonitorizadas mejoran el control en atención primaria. **Qué aporta este estudio** Tras una visita a urgencias por hipertensión grave, la telemonitorización con ajuste del tratamiento por un farmacéutico comunitario redujo la presión sistólica en consulta 9,4 mm Hg más que la atención habitual a las 12 semanas. ::: ## Métodos ### Diseño y participantes Ensayo piloto de grupos paralelos, abierto, con asignación 1:1, comunicado según la declaración CONSORT[@schulz2010]. Incluimos adultos de 18 a 80 años dados de alta en el servicio de urgencias de un hospital general de distrito tras una visita con una presión sistólica de 180 mm Hg o más, sin daño agudo de órgano diana, que vivieran cerca de una farmacia participante. Se excluyó a los pacientes con hipertensión secundaria, filtrado glomerular estimado inferior a 30 mL/min/1,73 m², embarazo o una esperanza de vida menor de un año. ### Intervenciones Cada paciente del grupo de telemonitorización recibió un monitor validado de brazo que enviaba cada lectura a un servidor seguro. Medían dos veces por la mañana y dos por la tarde, tres días a la semana. Un farmacéutico comunitario revisaba las lecturas cada semana y modificaba el tratamiento según un protocolo escrito, pactado con los médicos de familia del área, con el objetivo de una media domiciliaria inferior a 135/85 mm Hg. Los pacientes del grupo de atención habitual se remitieron a su médico de familia, como es costumbre. ### Variables y análisis La variable principal fue la media de la segunda y la tercera de tres lecturas sistólicas en consulta a las 12 semanas, tomadas por una enfermera que desconocía la asignación. Las variables secundarias fueron el control por debajo de 140/90 mm Hg, las nuevas visitas a urgencias y los efectos adversos. Los datos se recogieron en REDCap[@harris2009]. La diferencia entre grupos se estimó por regresión lineal ajustada por la presión sistólica basal. Con 60 pacientes por grupo, el ensayo tenía una potencia del 80 % para detectar una diferencia de 8 mm Hg, con una desviación típica supuesta de 15 mm Hg y un 10 % de pérdidas. ## Resultados Entre enero y junio se evaluó a 163 pacientes y se aleatorizó a 120; acudieron a la visita de las 12 semanas 57 del grupo de telemonitorización y 57 del de atención habitual. Los grupos eran similares al inicio (:ref{id="tbl-baseline"}). Los pacientes telemonitorizados enviaron una mediana de 31 series de lecturas, y los farmacéuticos hicieron una mediana de dos cambios de tratamiento por paciente. La presión sistólica domiciliaria del grupo de telemonitorización bajó sobre todo en las cuatro primeras semanas, y en la semana 10 ya estaba cerca del objetivo (:ref{id="fig-home"}). A las 12 semanas, la presión sistólica media en consulta fue de 136,2 mm Hg con telemonitorización y de 145,9 mm Hg con atención habitual, una diferencia ajustada de −9,4 mm Hg (IC del 95 %, −13,8 a −5,0). Quedaron por debajo de 140/90 mm Hg 36 de 57 pacientes (63 %) con telemonitorización y 21 de 57 (37 %) con atención habitual. Tres pacientes del grupo de telemonitorización y nueve del de atención habitual volvieron a urgencias por presión arterial elevada. Dos pacientes telemonitorizados refirieron mareo al ponerse de pie tras un aumento de dosis, y ambos se recuperaron al reducirla. Ningún efecto adverso grave se relacionó con la intervención. ## Discusión En este ensayo piloto, la telemonitorización con ajuste del tratamiento por un farmacéutico comunitario redujo la presión sistólica en consulta unos 9 mm Hg más que la atención habitual en las 12 semanas siguientes a una visita a urgencias por hipertensión grave. El efecto se acerca a los 10 mm Hg que los metaanálisis asocian con una quinta parte menos de episodios cardiovasculares[@ettehad2016], y se alcanzó dentro de los tres meses que conceden las guías europeas[@williams2018]. El resultado coincide con los ensayos de automedida en atención primaria, en los que el beneficio vino de actuar según las lecturas y no de tomarlas[@tucker2017]. Los farmacéuticos estaban en buena posición para actuar: veían las lecturas cada semana, conocían a los pacientes porque recogían sus recetas, y el protocolo les permitía cambiar el tratamiento sin esperar a una cita, como hicieron los farmacéuticos del ensayo de @margolis2013 en Minnesota. El ensayo tiene limitaciones. Era pequeño y abierto, se hizo en una sola área de salud, y 12 semanas son pocas para mostrar un efecto sobre los episodios. Las lecturas en consulta las tomó una enfermera ciega a la asignación, lo que reduce el sesgo propio de un diseño abierto. Que hubiera menos visitas a urgencias en el grupo telemonitorizado es alentador, pero son cifras demasiado pequeñas como para sacar conclusiones. El paso siguiente es un ensayo multicéntrico con potencia para detectar diferencias en las visitas a urgencias y en los episodios cardiovasculares. Las tasas de reclutamiento y de permanencia observadas, tres de cada cuatro pacientes evaluados y un 95 % de seguimiento, indican que es factible. ## Información del artículo {style="back"} **Financiación** Ninguna. **Conflicto de intereses** Ninguno. **Nota** Compuesto en PT Serif, Fira Sans y Fira Sans Condensed (SIL OFL). Texto: original, CC BY 4.0. El estudio, sus autores, sus centros y sus resultados son inventados para este ejemplo; la bibliografía remite a obras publicadas reales. ## Bibliografía {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`Característica Intervención \\ (n = 60) Atención habitual \\ (n = 60)
Mostra en Markdown · 10 línies · content.baseline.es.mdEdad, años 58,7 (11,2) 60,1 (10,4) Mujeres 27 (45) 29 (48) PA sistólica en urgencias 192,4 (10,8) 190,9 (9,6) PA sistólica en la aleatorización 161,3 (13,5) 160,2 (12,9) PA diastólica en la aleatorización 94,8 (10,1) 93,6 (9,7) Diabetes 14 (23) 12 (20) Enfermedad renal crónica 6 (10) 8 (13) Fármacos antihipertensivos, número 1,4 (1,0) 1,5 (1,1) Sin tratamiento antes de la visita 19 (32) 17 (28) Monitor domiciliario previo 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`);
Kit · core, fonts, viewer, pdf, images: igual a totes les receptes · 310 línies// ─── 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 ───────────────────────────────────────────────────────────────────────

L'script.js compost funciona tal com és: enganxa'l com a script de mòdul en qualsevol pàgina o obre la recepta a CodePen. Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)

Variants

#Fes servir l'estil AMA

L'estil AMA vola els seus números tot sol i escriu doi:10.1056/… a la llista; treu les substitucions i el marcador.

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

#Imprimeix els DOI sense enllaç

Per a un PDF que només s'imprimirà, deixa les adreces com a text negre.

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

#Números entre claudàtors

Algunes revistes Vancouver escriuen els números a la línia, entre claudàtors.

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

Errors freqüents

Error freqüent

La capçalera d'un estil de títol regeix tota la seva secció

header: { elements: [] } a l'estil del títol treu les capçaleres fins al títol següent amb estil, no només a l'obertura. Per deixar lliure només la primera pàgina, dona als elements de la capçalera pages: 'body'. Capçaleres i folis →

Error freqüent

Qualsevol objecte headings desactiva el salt de pàgina de l'H1

Per defecte un H1 salta a una pàgina senar (always-odd), però qualsevol objecte headings anul·la aquest valor, de manera que els capítols van seguits i span: 'page' no fa res. Torna a declarar headings.levels[0].breakBefore: { enabled: true, parity } a cada configuració. Capítols que obren en pàgina senar →

Error freqüent

El text dins d'un SVG <img> no pot fer servir fonts web

Un SVG es dibuixa com a imatge, i una imatge no té accés a les fonts web de la pàgina, de manera que els seus rètols surten amb una font del sistema. Converteix el text en traçats, incrusta un subconjunt @font-face a l'SVG o porta els rètols al peu. Figures i taules com a recursos →

Error freqüent

Tradueix Figura i Taula amb defaultResourceTypes(locale)

El locale de la configuració fixa la partició de mots, no els peus: sense resourceTypes, els tipus de sèrie diuen Figure i Table en anglès. Passa resourceTypes: defaultResourceTypes('es') per al castellà; per a qualsevol altra llengua, escriu tu mateix els noms a resourceTypes. Figura i Taula en la teva llengua →

Error freqüent

Carrega totes les fonts abans de compondre

La composició mesura el text amb les fonts que el navegador ha carregat i en desa les amplades, així que una font que arriba després de la primera composició deixa talls de línia erronis i un PDF que ja no coincideix amb la pantalla. Carrega abans tots els pesos i estils, i crida clearMeasurementCache() abans de recompondre si alguna arriba tard. Fonts abans de compondre →

Error freqüent

Posa entre cometes cada valor del frontmatter

YAML llegeix title: 1984 com un nombre i una data com un objecte Date, i els valors que no són cadenes s'imprimeixen buits als marcadors i deixen el PDF sense títol. Posa entre cometes cada valor: title: "1984". Metadades del document →

Error freqüent

Una configuració es desa a la memòria cau per identitat: crea un objecte nou

El motor desa a la memòria cau les configuracions resoltes segons la identitat de l'objecte, de manera que modificar el mateix objecte i tornar a compondre reutilitza el resultat anterior. Crea un objecte nou a cada composició: per això la configuració d'una recepta és una funció, config(). Pàgines en un canvas →

  • Quan el text cita alhora dues obres amb números no consecutius, com el 4 i el 6, postext 1.12.1 escriu els números volats com a 4, 6, i l'espai després de la coma s'estira amb la línia justificada. Aquest article cita aquestes obres en llocs diferents.
  • labelWidth fixa el sagnat de les línies següents de cada entrada, no una columna per als números: la primera línia comença després del número i un espai. Amb 2,85 mm el text de les entrades 1 a 9 queda alineat; les entrades 10 i 11 comencen una mica més a la dreta.
  • La header d'un estil de títol regeix tota la secció que aquest títol obre, així que header: { elements: [] } al títol de l'article trauria les capçaleres de totes les pàgines fins al títol amb estil següent. Per deixar lliure la primera pàgina n'hi ha prou amb pages: 'body' a les capçaleres.

Crèdits

Text
Text original, CC BY 4.0
Fonts
PT Serif (SIL OFL 1.1) · Fira Sans (SIL OFL 1.1) · Fira Sans Condensed (SIL OFL 1.1)
SandboxPDF