En pocas palabras
Un ensayo famoso sobre por qué muchos resultados de investigación no se sostienen, compuesto en siete páginas. Un solo script hace la edición inglesa y una traducción al español, cada una con su separación silábica y su coma decimal.
Lo que vas a componer
Una separata de siete páginas del ensayo de John Ioannidis Why Most Published Research Findings Are False (PLoS Medicine, 2005), abreviado y compuesto en una página de 210 × 280 mm a dos columnas. Una banda azul marino abre la primera página, con las curvas de la propia fórmula del ensayo dibujadas tenues detrás del título. Las tres fórmulas van destacadas y numeradas; las tablas de 2×2 llevan una fórmula en cada celda, con α y β de verdad; la figura 1 se calcula con la ecuación (2), y la última columna de la tabla 4, con la misma ecuación. El mismo script compone una traducción al español con separación silábica española, coma decimal en tablas y figuras, comillas « » y rótulos en español. Cierran treinta y tres referencias al estilo Vancouver, rehechas en BibTeX a partir de la lista del artículo.
Esta receta responde a
- ¿Cómo publico un mismo artículo en dos idiomas desde un solo script, con la tipografía de cada idioma?
- ¿Cómo hago una tabla con filas de cabecera, celdas combinadas, anchos de columna y alineación por celda?
- ¿Cómo compongo matemáticas (en línea, destacadas, ecuaciones) y las mantengo vectoriales en el PDF?
- ¿Cómo vuelvo a componer con Postext un artículo de acceso abierto de arXiv o PubMed Central, con sus citas, sus figuras y su línea de licencia?
La respuesta corta
// The Cookbook composes the pen once per edition: content.<LANG>.md and each named slot
// replace the @content markers. What else follows the language is set here.
const edition = {
// Hyphenation patterns and the words the engine writes (Tabla, Continúa) follow the
// locale, an exact code (gotcha: hyphenation-locales).
locale: t({ en: 'en-us', es: 'es' }),
// Table 1 / Tabla 1, Figure 1 / Figura 1, counted through the essay; tables caption above.
resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, shortLabel: type.name,
numberingTemplate: '{n}', resetOn: 'never',
...(type.id === 'table' && { captionStyle: { position: 'above' } }) })),
// Vancouver numbers in brackets, [2–4]; the CSL locale writes the list's words (2nd ed.,
// 2.ª ed.). The titles of the works stay in English in both editions.
citations: { style: 'elsevier-vancouver', locale: t({ en: 'en-US', es: 'es-ES' }),
marker: 'brackets', collapseRanges: true,
bibliography: { fontSize: em(0.78), lineHeight: pt(9.4), entrySpacing: pt(0.8),
labelWidth: mm(6.4), doi: 'hide' } },
};
// The numbers the script writes (Table 4's PPV, the figure axes) take the edition's decimal
// sign, 0.85 or 0,85. Formulas keep their symbols; content.es.md writes 0{,}05 inside $…$
// so that TeX sets no space after the comma.
const number = (x, digits = 2) => x.toLocaleString(t({ en: 'en-US', es: 'es-ES' }),
{ minimumFractionDigits: digits, maximumFractionDigits: digits });
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
await initMathEngine(); // before the tables are built (gotcha: math-bundle)
Ingredientes
- Funciones
- MatemáticasTablas a partir de datosSeparación silábica e idioma del documentoEstilo de tablasFigura y Tabla en tu idiomaCitas en un estilo de citaBibliografía a partir de las referenciasFiguras y tablas como recursosCitas que colocan las figurasPies numeradosColocación de figurasEstilo de los piesAperturas diseñadasAtributos de títuloRecuadros a todo el anchoRecuadrosChips en líneaCabeceras y foliosExportación a PDF
- También usa
- Columnas dentro de un recuadroEstilos de títuloCabeceras según el tipo de páginaEstilos de párrafoFuentes incrustadas en el PDFTipos de recurso propiosSuperíndices y subíndicesCapítulos sin número
- Tipografía
- Gelasio, Sofia Sans Semi Condensed (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Un script, dos ediciones
El código es la respuesta corta de arriba. El Recetario compone el script una vez por idioma y pone content.en.md o content.es.md donde está la marca @content; cada ranura con nombre (content.corollaries.es.md, content.resources.es.md) sigue la misma regla, y una ranura sin archivo en español toma el inglés, que es como las dos ediciones comparten un único archivo BibTeX. Todo lo demás que cambia con el idioma pasa por LANG: locale elige los patrones de separación silábica ('es' es el código que separa el español), defaultResourceTypes(LANG) escribe Tabla y Figura, la configuración regional de CSL escribe las palabras de la bibliografía y toLocaleString da a los números que calcula el script su coma decimal. La traducción sigue la ortotipografía española: comillas « », 0,05 en el texto y 0{,}05 dentro de una fórmula, para que TeX no trate la coma como signo de puntuación y deje un espacio detrás. Para publicar tu propio artículo en dos idiomas, guarda un archivo por idioma para el texto y los pies, y reúne cualquier otra diferencia en un objeto como edition.
#2 · Fórmulas dentro de las celdas
// A cell, a caption or a note sets $…$ as the text does (postext ≥ 1.19): MathJax paths at
// the cell's 8 pt, on the baseline of its line and aligned with the cell, vector in the PDF.
// The faces' latin files have no α or β (gotcha: latin-subset); the formulas need none.
const [CELL_PT, PAD] = [8, 1]; // cell type (pt), cell padding (mm)
function tableModel(tsv, widths, headerRows, merges = [], right = []) {
let model = { ...parseTSV(tsv, { headerRows }), columnWidths: widths };
model.rows.forEach((_, row) => right.forEach((c) => {
model = setAlignment(model, { row, col: c }, 'right'); // the numbers, the PPV and their heads
}));
for (const range of merges) model = mergeCells(model, range); // gotcha: merged-cells-hiddenby
return model;
}
// "Research finding" over both header rows, "True relationship" over Yes, No and Total.
const twoByTwo = (tsv) => tableModel(tsv, [1.1, 1.6, 1.6, 2.5], 2, [
{ start: { row: 0, col: 0 }, end: { row: 1, col: 0 } },
{ start: { row: 0, col: 1 }, end: { row: 0, col: 3 } }]);
// Table 4's last column is computed from Eq. (2), α = 0.05: two significant figures, as in 2005.
const ppv = (power, R, u, alpha = 0.05) => (power * R + u * (1 - power) * R)
/ (R + alpha - (1 - power) * R + u - u * alpha + u * (1 - power) * R);
const read = (s) => Number(s.replace(',', '.'));
const odds = (s) => s.split(':').map((x) => Number(x.replace(/\D/g, ''))).reduce((a, b) => a / b);
const ppvRows = (tsv) => tsv.split('\n').map((line, r) => {
const [power, R, u, example, head] = line.split('\t');
const p = ppv(read(power), odds(R), read(u)); // 0.0010: as many decimals as 2 figures need
return [power, R, u, example, r ? number(p, 1 - Math.floor(Math.log10(p))) : head].join('\t');
}).join('\n');
Desde postext 1.19, una celda compone $…$ como el texto, así que una celda que en el TSV dice $c(1-\beta)R/(R+1)$ es una fórmula: trazados de MathJax al cuerpo de 8 pt de las celdas, sobre la línea de base de la línea de la celda, de modo que Sí, No y las fórmulas quedan en una misma línea, y contornos vectoriales en el PDF. Los archivos latin de Fontsource no traen letras griegas, y las fórmulas no las necesitan. Los pies y las notas también admiten matemáticas: el pie de la tabla 4 nombra la potencia como 1 − β y su nota supone α = 0,05, como en el artículo de 2005. setAlignment alinea a la derecha los números de la tabla 4, y mergeCells une Resultado de la investigación sobre las dos filas de cabecera y Relación verdadera sobre tres columnas, y escribe las celdas ocultas que necesita una celda combinada.
#3 · Pies y tablas desde la ranura, números desde la fórmula
const parsed = blocks.trim().split(/\n\s*\n/).map((block) => {
const fields = {};
const tsv = block.split('\n').filter((line) => {
const m = /^(id|caption|note|alt): (.*)$/.exec(line);
if (m) fields[m[1]] = m[2];
return !m;
}).join('\n');
return { ...fields, tsv };
});
const tables = parsed.filter((b) => b.tsv).map(({ id, caption, note, tsv }) => {
const model = id === 'tbl-ppv'
? tableModel(ppvRows(tsv), [0.8, 0.95, 0.75, 3.8, 1.05], 1, [], [0, 1, 2, 4])
: twoByTwo(tsv);
return { id, typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, caption, note,
table: { model }, placement: { position: 'auto', ...(id !== 'tbl-ppv' && { span: 'page' }) } };
});
const figures = parsed.filter((b) => !b.tsv).map(({ id, caption, note, alt }) => ({ id,
typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, caption, note, altText: alt,
placement: { position: 'auto', span: 'page' },
svg: { fileId: `${id}.svg`, width: FIG_W * 10, height: FIG_H * 10 } }));
const resources = [...tables, ...figures,
{ id: 'band-art', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
svg: { fileId: 'band-art.svg', width: 1400, height: BAND * 10 } }];
const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
headerBackground: col('navy'), headerColor: col('paper'), headerBold: true,
headerFontFamily: SANS, headerFontSize: pt(CELL_PT), bodyFontFamily: SANS,
bodyFontSize: pt(CELL_PT), bodyColor: col('ink'), cellPadding: mm(PAD) };
const captionStyle = { fontFamily: SANS, fontSize: pt(8.2), color: col('ink'), labelBold: true,
labelColor: col('accent'), gap: mm(2), note: { fontSize: pt(7), color: col('muted') } };
Las tablas, los pies, las notas y los textos alternativos están en content.resources.<lang>.md, un bloque por recurso, así que quien traduce solo toca archivos de texto y cada edición lleva solo su idioma. La columna de VPP de la tabla 4 no se teclea: ppv() la calcula con la potencia, R y u de cada fila, y los nueve valores coinciden con los impresos en 2005 en dos cifras significativas. Las tablas flotan con position: 'auto', así que la tabla 1 puede ocupar el pie de la página que la cita en lugar de amontonarse con los demás flotantes en la siguiente.
#4 · La figura 1, dibujada con la ecuación (2)
const R2 = (x) => Math.round(x * 100) / 100;
// An SVG drawn as a picture cannot use the page's web fonts (gotcha: svg-no-webfonts): the
// figures carry the label face inline under its own name, which the PDF asks the provider for.
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 `@font-face{font-family:'${family}';font-weight:${weight};`
+ `src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`;
}
const mix = (f) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette.navy.slice(i, i + 2), 16)
* (1 - f) + parseInt(palette.accent.slice(i, i + 2), 16) * f).toString(16).padStart(2, '0'))
.join('')}`; // navy for the first curve, vermilion for the last
const label = (x, y, s, size, extra = '', fill = palette.muted) => `<text x="${R2(x)}" `
+ `y="${R2(y)}" font-size="${size}" font-family="${SANS}" fill="${fill}" ${extra}>${s}</text>`;
const line = (points, stroke, width, extra = '') => `<path d="M${points.map(([x, y]) =>
`${R2(x)} ${R2(y)}`).join('L')}" fill="none" stroke="${stroke}" stroke-width="${width}" `
+ `${extra}/>`;
// Three panels, power 0.80, 0.50 and 0.20; PPV in % against R from 0 to 1, one curve per value.
function panels(face, curve, values, name, digits, dashed) {
const [pw, top, plotH, left] = [52, 12, 34, 8]; // panel width, plot top and height, y labels
let out = `<style>${face}</style>`;
[0.8, 0.5, 0.2].forEach((power, p) => {
const x0 = p * (pw + (FIG_W - 3 * pw) / 2) + left;
const X = (R) => x0 + R * (pw - left - 2);
const Y = (v) => top + plotH * (1 - v);
const trace = (f) => Array.from({ length: 101 }, (_, k) => [X(k / 100), Y(f(k / 100))]);
out += label(x0 - left, 4, `${'ABC'[p]} <tspan font-weight="400">${t({ en: 'Power',
es: 'Potencia' })} ${number(power)}</tspan>`, 3.4, '', palette.navy);
if (!p) out += label(x0 - left, top - 4, t({ en: 'PPV (%)', es: 'VPP (%)' }), 2.8);
for (const v of [0, 0.2, 0.4, 0.6, 0.8, 1]) {
out += line([[X(0), Y(v)], [X(1), Y(v)]], palette.rule, v ? 0.15 : 0.3)
+ label(X(0) - 1.4, Y(v) + 1, v * 100, 2.6, 'text-anchor="end"')
+ label(X(v), Y(0) + 3.6, number(v, v % 1 ? 1 : 0), 2.6, 'text-anchor="middle"');
}
if (dashed) out += line(trace((R) => dashed(power, R)), palette.muted, 0.35,
'stroke-dasharray="1 0.8"');
values.forEach((value, i) => {
out += line(trace((R) => curve(power, R, value)), mix(i / (values.length - 1)), 0.6);
});
out += label(X(0.5), Y(0) + 8, `${t({ en: 'Pre-study odds', es: 'Razón previa' })}, `
+ '<tspan font-style="italic">R</tspan>', 2.9, 'text-anchor="middle"');
});
const key = values.map((value, i) => line([[48 + i * 24, 57.8], [54 + i * 24, 57.8]],
mix(i / (values.length - 1)), 0.8) + label(56 + i * 24, 58.8, `<tspan font-style="italic">`
+ `${name}</tspan> = ${number(value, digits)}`, 2.9)).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" width="${FIG_W * 10}" height="${FIG_H * 10}" `
+ `viewBox="0 0 ${FIG_W} ${FIG_H}">${out}${key}</svg>`;
}
// Eq. (3): n independent studies of equal power, no bias.
const teams = (power, R, n, alpha = 0.05) => (R * (1 - (1 - power) ** n))
/ (R + 1 - (1 - alpha) ** n - R * (1 - power) ** n);
const bandArt = () => `<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="${BAND * 10}" `
+ `viewBox="0 0 140 ${BAND}">${Array.from({ length: 10 }, (_, i) => line(Array.from(
{ length: 81 },
(_, k) => [10 + k * 1.6, 92 - 76 * ppv(0.8, k / 80, i / 10)]), palette.mist, 0.5,
`stroke-opacity="${R2(0.5 - i * 0.04)}"`)).join('')}</svg>`;
Cada panel recorre R de 0 a 1 con la misma ppv() que rellena la tabla 4. Dibujar las curvas con la fórmula revela algo que la figura de 2005 esconde: sus curvas corresponden a u = 0; 0,05; 0,20 y 0,80, mientras que su leyenda dice 0,05; 0,20; 0,50 y 0,80. Esta versión dibuja los valores de la leyenda, añade en trazo discontinuo la curva sin sesgo y lo explica en la nota del pie. Los rótulos son texto en la letra de los rótulos: el SVG lleva la fuente como @font-face en línea para el lienzo, y el PDF los compone con la fuente que entrega el proveedor.
#5 · Una primera página y un recuadro para un ensayo
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left',
overflow: 'wrap', placement, ...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 = (size, fontWeight = 700) => ({ fontWeight, letterSpacing: pt(size * 0.18),
textTransform: 'uppercase' });
const BAND = 100; // mm from the top of the trim to the foot of the band
const titleBlock = { enabled: true, minHeight: mm(BAND + 12 - TOP), slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('navy') },
placement: { anchor: { to: 'bleed', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND + 3) } } }, // + the 3 mm bleed
{ kind: 'image', id: 'curves', resourceId: 'band-art', // Eq. (2), faint, for u = 0 to 0.9
placement: at('page', 'top-left', 128, 46, 76) }, // under the title, clear of the text
text('kicker', '{attr.kicker}', SANS, 8, 'mist', at('page', 'top-left', INNER, 20, 120),
caps(8)),
text('title', '{titleText}', SANS, 36, 'paper', at('#kicker', 'below', 0, 6, 150),
{ fontWeight: 800, lineHeight: 1.02 }),
text('author', '{attr.author}', SERIF, 13, 'paper', at('#title', 'below', 0, 8, 150)),
text('affiliation', '{attr.affiliation}', SANS, 8, 'mist', at('#author', 'below', 0, 1.6, 150),
{ lineHeight: 1.35 }),
text('source', '{attr.source}', SANS, 7.4, 'muted', at('page', 'top-left', INNER, BAND + 4,
MEASURE), { lineHeight: 1.35 }),
] } };
El título es el único de nivel 1; su estilo essay ocupa toda la página y dibuja la banda, el antetítulo, el autor, las filiaciones y la línea de la fuente a partir de los atributos del título, así que la edición española solo cambia palabras. El resumen es un recuadro a toda la página sobre un tinte. El recuadro 1, el ejemplo resuelto, flota al pie de una página a lo ancho de las dos columnas y compone su texto a dos columnas dentro del recuadro. Los corolarios conservan la forma del artículo, con el enunciado en negrita al comienzo del párrafo: :chip[Corolario 1]{style="corollary"} pone delante un rótulo pequeño en bermellón.
const calloutStyles = [
{ id: 'summary', span: 'page', background: col('tint'), marginTop: pt(0),
marginBottom: pt(LEAD), padding: { top: mm(4), right: mm(6), bottom: mm(4), left: mm(6) },
titleStyle: { fontFamily: SANS, fontSize: pt(8), ...caps(8), color: col('accent'),
gap: mm(1.5) },
body: { fontSize: pt(9.4), lineHeight: pt(13), firstLineIndent: pt(0) } },
{ id: 'box', span: 'page', placement: 'bottom', columnGap: mm(GUTTER),
backgroundEnabled: false, border: { enabled: false },
stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('navy') },
padding: { top: mm(2.5), right: mm(0), bottom: mm(1), left: mm(0) },
marginTop: pt(LEAD), marginBottom: pt(LEAD),
titleStyle: { fontFamily: SANS, fontSize: pt(9), fontWeight: 700, color: col('navy'),
gap: mm(1.2) },
body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(11.2), firstLineIndent: mm(3) } },
];
// :chip[Corollary 1]{style="corollary"}: a label in the sans and the accent, no frame.
const chipStyles = [{ id: 'corollary', fontFamily: SANS, fontSize: em(0.86), bold: true,
color: col('accent'), backgroundEnabled: false, borderWidth: pt(0), paddingX: em(0),
gap: em(0.35) }];
const paragraphStyles = [{ id: 'colophon', fontFamily: SANS, fontSize: pt(7.6),
lineHeight: pt(10.4), color: col('muted'), boldColor: col('ink'), textAlign: 'left',
firstLineIndent: pt(0), spaceBetween: pt(3) }];
La receta completa
// ═══ Postext Cookbook · Nº 141 · A metascience essay in English and Spanish ═══════ // https://postext.dev/en/cookbook/bilingual-metascience-essay // Code: MIT · Text: J. P. A. Ioannidis, PLoS Med 2005 (CC BY) · Figures: drawn in code (CC BY 4.0) // Fonts: Gelasio, Sofia Sans Semi Condensed (SIL OFL 1.1) · Needs postext ≥ 1.19.0 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, registerCitationEngine, defaultResourceTypes, initMathEngine, parseTSV, mergeCells, setAlignment, } from 'https://esm.sh/postext?bundle'; 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 = 'bilingual-metascience-essay'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: a navy for the furniture, one vermilion accent, every colour linked const palette = { ink: '#1c1d24', navy: '#22305a', accent: '#b8442a', tint: '#eceff5', rule: '#b4bccb', muted: '#5a6070', mist: '#c3cde6', paper: '#ffffff' }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.navy }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const [SERIF, SANS] = ['Gelasio', 'Sofia Sans Semi Condensed']; // mm: the 210 × 280 trim, head, foot, inner and outer margins, and the gutter const [TRIM_W, TRIM_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [210, 280, 22, 22, 19, 17, 6]; const MEASURE = TRIM_W - INNER - OUTER; // 174 mm across both columns const [BODY, LEAD] = [9.4, 13]; // pt const [FIG_W, FIG_H] = [MEASURE, 60]; // mm: Figures 1 and 2, across both columns // #region answer: one script, two editions: LANG picks the text, the language and the numbers // The Cookbook composes the pen once per edition: content.<LANG>.md and each named slot // replace the @content markers. What else follows the language is set here. const edition = { // Hyphenation patterns and the words the engine writes (Tabla, Continúa) follow the // locale, an exact code (gotcha: hyphenation-locales). locale: t({ en: 'en-us', es: 'es' }), // Table 1 / Tabla 1, Figure 1 / Figura 1, counted through the essay; tables caption above. resourceTypes: defaultResourceTypes(LANG).map((type) => ({ ...type, shortLabel: type.name, numberingTemplate: '{n}', resetOn: 'never', ...(type.id === 'table' && { captionStyle: { position: 'above' } }) })), // Vancouver numbers in brackets, [2–4]; the CSL locale writes the list's words (2nd ed., // 2.ª ed.). The titles of the works stay in English in both editions. citations: { style: 'elsevier-vancouver', locale: t({ en: 'en-US', es: 'es-ES' }), marker: 'brackets', collapseRanges: true, bibliography: { fontSize: em(0.78), lineHeight: pt(9.4), entrySpacing: pt(0.8), labelWidth: mm(6.4), doi: 'hide' } }, }; // The numbers the script writes (Table 4's PPV, the figure axes) take the edition's decimal // sign, 0.85 or 0,85. Formulas keep their symbols; content.es.md writes 0{,}05 inside $…$ // so that TeX sets no space after the comma. const number = (x, digits = 2) => x.toLocaleString(t({ en: 'en-US', es: 'es-ES' }), { minimumFractionDigits: digits, maximumFractionDigits: digits }); registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES })); await initMathEngine(); // before the tables are built (gotcha: math-bundle) // #endregion // #region title: the essay's first page: a navy band with the title, author and source const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), align: 'left', overflow: 'wrap', placement, ...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 = (size, fontWeight = 700) => ({ fontWeight, letterSpacing: pt(size * 0.18), textTransform: 'uppercase' }); const BAND = 100; // mm from the top of the trim to the foot of the band const titleBlock = { enabled: true, minHeight: mm(BAND + 12 - TOP), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('navy') }, placement: { anchor: { to: 'bleed', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND + 3) } } }, // + the 3 mm bleed { kind: 'image', id: 'curves', resourceId: 'band-art', // Eq. (2), faint, for u = 0 to 0.9 placement: at('page', 'top-left', 128, 46, 76) }, // under the title, clear of the text text('kicker', '{attr.kicker}', SANS, 8, 'mist', at('page', 'top-left', INNER, 20, 120), caps(8)), text('title', '{titleText}', SANS, 36, 'paper', at('#kicker', 'below', 0, 6, 150), { fontWeight: 800, lineHeight: 1.02 }), text('author', '{attr.author}', SERIF, 13, 'paper', at('#title', 'below', 0, 8, 150)), text('affiliation', '{attr.affiliation}', SANS, 8, 'mist', at('#author', 'below', 0, 1.6, 150), { lineHeight: 1.35 }), text('source', '{attr.source}', SANS, 7.4, 'muted', at('page', 'top-left', INNER, BAND + 4, MEASURE), { lineHeight: 1.35 }), ] } }; // #endregion // Running heads 13 mm from the trim; the first page has its folio at the foot instead. const head = (id, content, parity, edge, x, extra) => text(id, content, SANS, 7.5, 'muted', at('page', `top-${edge}`, x, 13), { overflow: 'clip', ...caps(7.5, 600), align: edge, parity, pages: 'body', ...extra }); const folio = { fontWeight: 800, color: col('accent'), letterSpacing: pt(0.4) }; const header = { elements: [ head('v-folio', '{pageNumber}', 'even', 'left', OUTER, folio), head('v-title', 'Ioannidis · PLoS Medicine 2005', 'even', 'left', OUTER + 9), head('r-title', t({ en: 'Why most published research findings are false', es: 'Por qué la mayoría de los resultados publicados son falsos' }), 'odd', 'right', -(OUTER + 9)), head('r-folio', '{pageNumber}', 'odd', 'right', -OUTER, folio), ] }; const footer = { elements: [text('drop-folio', '{pageNumber}', SANS, 7.5, 'accent', at('page', 'bottom-right', -OUTER, -12), { ...folio, align: 'right', pages: 'opener', overflow: 'clip' })] }; const config = () => ({ // a factory: the engine caches resolved configs per object ...edition, colorPalette, header, footer, page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, 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(BODY), 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 }, math: { marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2), keepWithLeadIn: true }, headings: { fontFamily: SANS, color: col('navy'), fontWeight: 700, levels: [ { level: 1, breakBefore: { enabled: true, parity: 'any' } }, // gotcha: headings-drop-h1-break { level: 2, fontSize: pt(12), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) }, ] }, headingStyles: [ { id: 'essay', numbered: false, span: 'page', advancedDesign: titleBlock }, { id: 'back', fontSize: pt(8), ...caps(8), color: col('accent'), marginBottom: pt(4) }, ], calloutStyles, chipStyles, paragraphStyles, tableStyle, captionStyle, }); // #region boxes: the summary across the page, Box 1 under a stripe, run-in corollary labels const calloutStyles = [ { id: 'summary', span: 'page', background: col('tint'), marginTop: pt(0), marginBottom: pt(LEAD), padding: { top: mm(4), right: mm(6), bottom: mm(4), left: mm(6) }, titleStyle: { fontFamily: SANS, fontSize: pt(8), ...caps(8), color: col('accent'), gap: mm(1.5) }, body: { fontSize: pt(9.4), lineHeight: pt(13), firstLineIndent: pt(0) } }, { id: 'box', span: 'page', placement: 'bottom', columnGap: mm(GUTTER), backgroundEnabled: false, border: { enabled: false }, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('navy') }, padding: { top: mm(2.5), right: mm(0), bottom: mm(1), left: mm(0) }, marginTop: pt(LEAD), marginBottom: pt(LEAD), titleStyle: { fontFamily: SANS, fontSize: pt(9), fontWeight: 700, color: col('navy'), gap: mm(1.2) }, body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(11.2), firstLineIndent: mm(3) } }, ]; // :chip[Corollary 1]{style="corollary"}: a label in the sans and the accent, no frame. const chipStyles = [{ id: 'corollary', fontFamily: SANS, fontSize: em(0.86), bold: true, color: col('accent'), backgroundEnabled: false, borderWidth: pt(0), paddingX: em(0), gap: em(0.35) }]; const paragraphStyles = [{ id: 'colophon', fontFamily: SANS, fontSize: pt(7.6), lineHeight: pt(10.4), color: col('muted'), boldColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(3) }]; // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = [ String.raw`---Muestra en Markdown · 33 líneas · content.es.md
title: "Por qué la mayoría de los resultados de investigación publicados son falsos" author: "John P. A. Ioannidis" --- # Por qué la mayoría de los resultados de investigación publicados son falsos {style="essay" kicker="Ensayo · Métodos de investigación" author="John P. A. Ioannidis" affiliation="Facultad de Medicina de la Universidad de Ioánina, Ioánina (Grecia)\nFacultad de Medicina de la Universidad Tufts, Boston, Massachusetts (Estados Unidos)" source="Traducción del inglés hecha para el Recetario de Postext · Publicado originalmente en PLoS Medicine 2(8): e124, 30 de agosto de 2005 · doi:10.1371/journal.pmed.0020124 · © 2005 John P. A. Ioannidis, licencia Creative Commons Attribution"} :::callout{type="summary" title="Resumen"} Crece la preocupación por que la mayoría de los resultados de investigación publicados hoy sean falsos. La probabilidad de que una afirmación científica sea cierta puede depender de la potencia del estudio y de su sesgo, del número de estudios que abordan la misma pregunta y, sobre todo, de la proporción entre relaciones verdaderas y relaciones inexistentes entre las que se investigan en cada campo. En este marco, un resultado tiene menos probabilidades de ser cierto cuando los estudios de un campo son más pequeños; cuando los tamaños del efecto son menores; cuando se prueban más relaciones y se seleccionan menos de antemano; cuando hay más flexibilidad en los diseños, las definiciones, las variables de resultado y los modos de análisis; cuando hay más intereses económicos o de otro tipo y más prejuicios, y cuando más equipos trabajan en un campo a la caza de la significación estadística. Las simulaciones muestran que, en la mayoría de los diseños y contextos, es más probable que una afirmación científica sea falsa que verdadera. Además, en muchos campos actuales, los resultados que se proclaman pueden ser a menudo una medida exacta del sesgo dominante. En este ensayo comento lo que estos problemas suponen para la forma de hacer e interpretar la investigación. ::: Los resultados de investigación publicados a veces quedan refutados por pruebas posteriores, con la confusión y la decepción consiguientes. La refutación y la controversia aparecen en todos los diseños, desde los ensayos clínicos y los estudios epidemiológicos tradicionales [@ioannidis2001a; @lawlor2004; @vandenbroucke2004] hasta la investigación molecular más moderna [@michiels2005; @ioannidis2001b]. Cada vez preocupa más que, en la investigación actual, los resultados falsos sean la mayoría, o incluso la inmensa mayoría, de las afirmaciones publicadas [@colhoun2003; @ioannidis2003; @ioannidis2005a]. Sin embargo, no debería sorprendernos. Se puede demostrar que la mayoría de los resultados que se proclaman son falsos. Aquí examino los factores principales que influyen en este problema y algunos corolarios que se derivan de ellos. ## Un modelo para los resultados falsos positivos Varios metodólogos han señalado [@sterne2001; @wacholder2004; @risch2000] que la alta tasa de no replicación (falta de confirmación) de los descubrimientos es consecuencia de una estrategia cómoda pero mal fundada: proclamar resultados concluyentes a partir de un único estudio valorado por su significación estadística formal, típicamente con un valor *p* inferior a 0,05. Como ya se ha demostrado, la probabilidad de que un resultado sea realmente cierto depende de la probabilidad previa de que lo sea (antes de hacer el estudio), de la potencia estadística del estudio y del nivel de significación estadística [@wacholder2004; @risch2000]. Pensemos en una tabla de 2 × 2 que compara los resultados de la investigación con el patrón de referencia de las relaciones verdaderas de un campo científico. En un campo de investigación pueden plantearse hipótesis verdaderas y falsas sobre la existencia de relaciones. Sea $R$ la razón entre el número de «relaciones verdaderas» y el de «relaciones inexistentes» entre las que se prueban en el campo. $R$ es propia de cada campo y puede variar mucho según que el campo busque relaciones muy verosímiles o persiga una sola relación verdadera, o unas pocas, entre los miles o millones de hipótesis que pueden plantearse. Para simplificar el cálculo, consideremos además campos acotados en los que o bien hay una sola relación verdadera (entre las muchas que pueden formularse) o bien la potencia para detectar cualquiera de las varias relaciones verdaderas es parecida. La probabilidad previa al estudio de que una relación sea verdadera es $R/(R+1)$. La probabilidad de que un estudio detecte una relación verdadera es la potencia, $1-\beta$ (uno menos la tasa de error de tipo II). La probabilidad de afirmar una relación cuando no existe ninguna es la tasa de error de tipo I, $\alpha$. Si en el campo se investigan $c$ relaciones, los valores esperados de la tabla de 2 × 2 son los de la :ref{id="tbl-truth"}. Una vez proclamado un resultado por haber alcanzado la significación estadística formal, la probabilidad posterior al estudio de que sea cierto es el valor predictivo positivo, VPP. El VPP es también la probabilidad complementaria de lo que Wacholder y colaboradores han llamado probabilidad de un informe falso positivo [@wacholder2004]. Según la tabla de 2 × 2, se obtiene $$\mathrm{VPP} = \frac{(1-\beta)R}{R-\beta R+\alpha}. \tag{1}$$ Por tanto, un resultado tiene más probabilidades de ser verdadero que falso si $(1-\beta)R > \alpha$. Como la inmensa mayoría de los investigadores trabaja con $\alpha = 0{,}05$, un resultado es más probablemente verdadero que falso si $(1-\beta)R > 0{,}05$. Se tiene menos en cuenta que el sesgo y la repetición de pruebas independientes por distintos equipos de todo el mundo pueden distorsionar aún más este panorama y reducir todavía más la probabilidad de que los resultados sean ciertos. Intentaremos modelizar estos dos factores con tablas de 2 × 2 semejantes. ## El sesgo Definamos primero el sesgo como la combinación de factores de diseño, de datos, de análisis y de presentación que tienden a producir resultados cuando no deberían producirse. Sea $u$ la proporción de análisis que no habrían sido «resultados», pero que acaban presentándose y comunicándose como tales por culpa del sesgo. El sesgo no debe confundirse con la variabilidad aleatoria, que hace que algunos resultados sean falsos por azar aunque el diseño, los datos, el análisis y la presentación del estudio sean perfectos. El sesgo puede consistir en manipular el análisis o la comunicación de los resultados. La comunicación selectiva o distorsionada es una forma típica de este sesgo. Podemos suponer que $u$ no depende de que exista o no una relación verdadera. No es una suposición descabellada, porque normalmente es imposible saber qué relaciones son de verdad ciertas. En presencia de sesgo (:ref{id="tbl-bias"}) se obtiene $$\mathrm{VPP} = \frac{[1-\beta]R+u\beta R}{R+\alpha-\beta R+u-u\alpha+u\beta R}, \tag{2}$$ y el VPP disminuye al aumentar $u$, salvo que $1-\beta \le \alpha$, es decir, $1-\beta \le 0{,}05$ en la mayoría de las situaciones. Así pues, cuanto mayor es el sesgo, más bajan las probabilidades de que un resultado sea verdadero. La :ref{id="fig-bias"} lo muestra para distintas potencias y distintas razones de probabilidades previas al estudio. ## Pruebas por varios equipos independientes Varios equipos independientes pueden estar abordando las mismas preguntas de investigación. Con la globalización de la investigación, es casi la norma que varios equipos, a menudo decenas, estudien preguntas iguales o parecidas. Es fácil estimar la probabilidad de que al menos uno de los estudios hechos sobre una misma pregunta proclame un resultado estadísticamente significativo. Para $n$ estudios independientes de igual potencia, la tabla de 2 × 2 es la de la :ref{id="tbl-teams"}: $$\mathrm{VPP} = \frac{R(1-\beta^n)}{R+1-[1-\alpha]^n-R\beta^n} \tag{3}$$ (sin tener en cuenta el sesgo). A medida que aumenta el número de estudios independientes, el VPP tiende a disminuir, salvo que $1-\beta < \alpha$, es decir, normalmente $1-\beta < 0{,}05$. La :ref{id="fig-teams"} lo muestra para distintas potencias y distintas razones previas. Para $n$ estudios de potencia distinta, el término $\beta^n$ se sustituye por el producto de los términos $\beta_i$ para $i = 1$ hasta $n$, pero las conclusiones son parecidas.`, // title, summary, the model, bias, several teams String.raw`## CorolariosMuestra en Markdown · 22 líneas · content.corollaries.es.md
El recuadro 1 presenta un ejemplo práctico. De las consideraciones anteriores pueden deducirse varios corolarios interesantes sobre la probabilidad de que un resultado de investigación sea realmente cierto. :::callout{type="box" title="Recuadro 1. Un ejemplo: ciencia con una razón previa muy baja"} :::columns{count=2} Supongamos que un equipo hace un estudio de asociación de genoma completo para averiguar si alguno de 100 000 polimorfismos genéticos se asocia con la predisposición a la esquizofrenia. Por lo que sabemos de la heredabilidad de la enfermedad, es razonable esperar que unos diez de los polimorfismos estudiados estén asociados de verdad con la esquizofrenia, con razones de posibilidades (*odds ratios*) parecidas, de alrededor de 1,3, y con una potencia bastante similar para detectar cualquiera de ellos. Entonces $R = 10/100\,000 = 10^{-4}$, y la probabilidad previa de que un polimorfismo cualquiera esté asociado con la esquizofrenia es también $R/(R+1) = 10^{-4}$. Supongamos además que el estudio tiene una potencia del 60 % para detectar una asociación con una razón de posibilidades de 1,3 con $\alpha = 0{,}05$. Puede estimarse entonces que, si se encuentra una asociación estadísticamente significativa con un valor *p* que apenas cruza el umbral de 0,05, la probabilidad posterior de que sea cierta se multiplica por unos 12 respecto a la previa, pero sigue siendo de solo $12 \times 10^{-4}$. Supongamos ahora que los investigadores manipulan el diseño, los análisis y la comunicación de los resultados para que más relaciones crucen el umbral de *p* = 0,05, aunque no lo habrían cruzado con un diseño y un análisis seguidos al pie de la letra y con una comunicación completa de los resultados, ceñida al plan original del estudio. Con un sesgo $u = 0{,}10$, la probabilidad posterior de que un resultado sea cierto es de solo $4{,}4 \times 10^{-4}$. Y aun sin ningún sesgo, cuando diez equipos independientes hacen experimentos parecidos en distintos lugares del mundo y uno de ellos encuentra una asociación formalmente significativa, la probabilidad de que el resultado sea cierto es de solo $1{,}5 \times 10^{-4}$, apenas mayor que la que teníamos antes de emprender toda esta investigación. ::: ::: :chip[Corolario 1]{style="corollary"} **Cuanto más pequeños son los estudios de un campo científico, menos probable es que sus resultados sean ciertos.** Un tamaño muestral pequeño supone una potencia menor y, en todas las funciones anteriores, el VPP de un resultado verdadero disminuye cuando la potencia baja hacia $1-\beta = 0{,}05$. Por tanto, en igualdad de condiciones, los resultados son más probablemente ciertos en los campos que hacen estudios grandes, como los ensayos controlados aleatorizados en cardiología (con varios miles de sujetos aleatorizados) [@yusuf1984], que en los campos de estudios pequeños, como la mayor parte de la investigación sobre predictores moleculares (con muestras cien veces menores) [@altman2000]. :chip[Corolario 2]{style="corollary"} **Cuanto menores son los tamaños del efecto en un campo científico, menos probable es que sus resultados sean ciertos.** La potencia depende también del tamaño del efecto. Así, los resultados son más probablemente ciertos en campos con efectos grandes, como el del tabaco sobre el cáncer o la enfermedad cardiovascular (riesgos relativos de 3 a 20), que en campos donde los efectos que se postulan son pequeños, como los factores de riesgo genéticos de las enfermedades poligénicas (riesgos relativos de 1,1 a 1,5) [@ioannidis2003]. La epidemiología moderna se ve cada vez más obligada a buscar efectos más pequeños [@taubes1995]. En consecuencia, cabe esperar que disminuya la proporción de resultados verdaderos. :chip[Corolario 3]{style="corollary"} **Cuantas más relaciones se prueban en un campo científico, y cuanto menos se seleccionan, menos probable es que sus resultados sean ciertos.** Como hemos visto, la probabilidad posterior de que un resultado sea cierto (el VPP) depende mucho de la razón de probabilidades previa ($R$). Por eso los resultados son más probablemente ciertos en los diseños confirmatorios, como los grandes ensayos controlados aleatorizados de fase III o sus metaanálisis, que en los experimentos que generan hipótesis. Los campos que se tienen por muy informativos y creativos por la riqueza de la información que reúnen y analizan, como las micromatrices y otras investigaciones de alto rendimiento orientadas al descubrimiento [@michiels2005; @ioannidis2005a; @golub1999], deberían tener un VPP bajísimo. :chip[Corolario 4]{style="corollary"} **Cuanta más flexibilidad hay en los diseños, las definiciones, las variables de resultado y los modos de análisis de un campo científico, menos probable es que sus resultados sean ciertos.** La flexibilidad aumenta la posibilidad de convertir en «positivos» resultados que serían «negativos», es decir, el sesgo, $u$. Para varios diseños, como los ensayos controlados aleatorizados [@moher2001; @ioannidis2004; @ich1999] o los metaanálisis [@moher1999; @stroup2000], se ha intentado normalizar cómo se hacen y cómo se comunican. Es probable que seguir unas normas comunes aumente la proporción de resultados verdaderos. Con todo, incluso en los diseños más rigurosos el sesgo parece un problema importante. Por ejemplo, hay pruebas sólidas de que la comunicación selectiva de las variables de resultado, con manipulación de las variables y de los análisis que se publican, es un problema frecuente incluso en los ensayos aleatorizados [@chan2004]. Acabar sin más con la publicación selectiva no haría desaparecer el problema. :chip[Corolario 5]{style="corollary"} **Cuantos más intereses económicos o de otro tipo, y más prejuicios, hay en un campo científico, menos probable es que sus resultados sean ciertos.** Los conflictos de intereses y los prejuicios pueden aumentar el sesgo, $u$. Los conflictos de intereses son muy frecuentes en la investigación biomédica [@krimsky1998] y suelen declararse de forma insuficiente y escasa [@krimsky1998; @papanikolaou2001]. Los prejuicios no tienen por qué tener raíces económicas. Los científicos de un campo pueden tener prejuicios solo por su fe en una teoría o por su apego a sus propios resultados. Investigadores de prestigio pueden impedir, mediante la revisión por pares, que aparezcan y se difundan resultados que refutan los suyos, y condenar así a su campo a perpetuar dogmas falsos. Las pruebas empíricas muestran que la opinión de los expertos es muy poco fiable [@antman1992]. :chip[Corolario 6]{style="corollary"} **Cuanto más candente es un campo científico (con más equipos trabajando en él), menos probable es que sus resultados sean ciertos.** Este corolario, aparentemente paradójico, se sigue de que, como se ha dicho, el VPP de los resultados aislados disminuye cuando muchos equipos trabajan en el mismo campo. Puede explicar por qué a veces vemos un gran entusiasmo seguido rápidamente de fuertes decepciones en los campos que atraen mucha atención. Se ha acuñado el término *fenómeno Proteo* para describir esta rápida alternancia de afirmaciones extremas y refutaciones en sentido opuesto [@ioannidis2005b]. Las pruebas empíricas indican que esta sucesión de extremos opuestos es muy frecuente en genética molecular [@ioannidis2005b].`, // Box 1 and the six corollaries String.raw`## En la mayoría de los diseños y de los campos, la mayoría de los resultados son falsosMuestra en Markdown · 34 líneas · content.close.es.md
En el marco descrito, es bastante difícil conseguir un VPP superior al 50 %. La :ref{id="tbl-ppv"} recoge los resultados de simulaciones hechas con las fórmulas anteriores, que tienen en cuenta la potencia, la razón entre relaciones verdaderas y no verdaderas y el sesgo, para varias situaciones propias de determinados diseños y contextos. Un resultado de un ensayo controlado aleatorizado bien hecho y con potencia suficiente, que parte de una probabilidad previa del 50 % de que la intervención sea eficaz, resulta cierto alrededor del 85 % de las veces. De un metaanálisis confirmatorio de ensayos aleatorizados de buena calidad cabe esperar un rendimiento bastante parecido: el sesgo posible probablemente aumenta, pero la potencia y las probabilidades previas son mayores que en un único ensayo. En cambio, un resultado metaanalítico procedente de estudios no concluyentes, en el que se agregan los datos para «corregir» la baja potencia de cada estudio, probablemente es falso si $R \le 1{:}3$. Los resultados de ensayos clínicos de fases tempranas con potencia insuficiente serían ciertos una de cada cuatro veces, o menos aún si hay sesgo. Los estudios epidemiológicos de carácter exploratorio salen todavía peor parados, sobre todo cuando les falta potencia, pero incluso los que tienen potencia suficiente pueden tener solo una probabilidad entre cinco de ser ciertos si $R = 1{:}10$. Por último, en la investigación orientada al descubrimiento con pruebas masivas, en la que las relaciones probadas superan mil veces a las verdaderas (por ejemplo, 30 000 genes estudiados, de los que quizá 30 sean los verdaderos responsables) [@ntzani2003; @ransohoff2004], el VPP de cada relación proclamada es bajísimo, incluso con una normalización considerable de los métodos de laboratorio y estadísticos, de las variables de resultado y de su comunicación para reducir al mínimo el sesgo. ## Los resultados que se proclaman pueden ser a menudo una medida exacta del sesgo dominante Como hemos visto, la mayor parte de la investigación biomédica actual se mueve en terrenos con una probabilidad muy baja, antes y después del estudio, de obtener resultados verdaderos. Supongamos que en un campo de investigación no hay ningún resultado verdadero que descubrir. En un «campo nulo» así, en ausencia de sesgo, lo ideal sería que todos los tamaños del efecto observados variaran por azar en torno al valor nulo. Lo que los resultados observados se aparten de lo esperable por azar sería, sin más, una medida pura del sesgo dominante. Por ejemplo, supongamos que ningún nutriente ni ningún patrón de alimentación influye de verdad en el riesgo de padecer un tumor determinado. Supongamos también que la literatura científica ha estudiado 60 nutrientes y afirma que todos ellos se relacionan con el riesgo de padecer ese tumor, con riesgos relativos de entre 1,2 y 1,4 al comparar el tercil superior de consumo con el inferior. En tal caso, los tamaños del efecto que se proclaman no miden otra cosa que el sesgo neto que ha intervenido en la producción de esa literatura. De hecho, son la estimación más exacta del sesgo neto. Se sigue incluso que, entre los «campos nulos», los que proclaman efectos más fuertes (a menudo acompañados de afirmaciones sobre su importancia médica o para la salud pública) son sencillamente los que han sufrido los peores sesgos. En los campos con un VPP muy bajo, las pocas relaciones verdaderas apenas alterarían este panorama general. Aunque algunas relaciones sean ciertas, la forma de la distribución de los efectos observados seguiría dando una medida clara de los sesgos que operan en el campo. Esta idea invierte por completo nuestra manera de ver los resultados científicos. Tradicionalmente, los investigadores han recibido con entusiasmo los efectos grandes y muy significativos, como señales de descubrimientos importantes. En la mayoría de los campos de la investigación moderna, unos efectos demasiado grandes y demasiado significativos pueden ser más bien señal de un sesgo grande. Deberían llevar a los investigadores a pensar con espíritu crítico qué puede haber fallado en sus datos, sus análisis y sus resultados. ## ¿Cómo podemos mejorar la situación? ¿Es inevitable que la mayoría de los resultados de investigación sean falsos, o podemos mejorar la situación? Un problema importante es que resulta imposible saber con una certeza del 100 % cuál es la verdad en cualquier pregunta de investigación. En este sentido, el patrón «oro» puro es inalcanzable. Aun así, hay varias maneras de mejorar la probabilidad posterior al estudio. Pueden ayudar unas pruebas con más potencia, como los estudios grandes o los metaanálisis con poco sesgo, porque se acercan más a ese patrón «oro» desconocido. Pero también los estudios grandes pueden tener sesgos, que hay que reconocer y evitar. Hay que tener presente, además, que los estudios enormes pueden encontrar con más facilidad una diferencia formalmente significativa para un efecto trivial que en realidad no se distingue de forma relevante del valor nulo [@lindley1957; @bartlett1957; @senn2001]. En segundo lugar, la mayoría de las preguntas las abordan muchos equipos, y es engañoso destacar los resultados significativos de un solo equipo. Lo que cuenta es el conjunto de las pruebas. También puede ayudar reducir el sesgo con mejores normas de investigación y frenar los prejuicios, aunque eso quizá exija un cambio de mentalidad científica difícil de lograr. En algunos diseños, como los ensayos aleatorizados, puede dar mejor resultado el registro previo de los estudios [@deangelis2004]. El registro sería un reto para la investigación que genera hipótesis. Dentro de cada campo puede ser más factible algún tipo de registro o de red de colecciones de datos o de investigadores que registrar todos y cada uno de los experimentos que generan hipótesis. En cualquier caso, aunque el registro de estudios no avance mucho en otros campos, los principios de elaborar un protocolo y atenerse a él podrían tomarse de los ensayos controlados aleatorizados mucho más de lo que se hace. Por último, en lugar de perseguir la significación estadística, deberíamos conocer mejor el intervalo de valores de $R$ (la razón de probabilidades previa al estudio) en el que se mueve la investigación [@wacholder2004]. Antes de hacer un experimento, los investigadores deberían considerar qué probabilidad creen tener de estar probando una relación verdadera y no una inexistente. A veces podrá comprobarse luego si los valores altos de $R$ que se suponían lo son de verdad. Como ya se ha dicho, siempre que sea éticamente aceptable, deberían hacerse estudios grandes y con un sesgo mínimo sobre resultados que se consideran relativamente establecidos, para ver con qué frecuencia se confirman. Sospecho que varios «clásicos» consagrados no superarán la prueba [@ioannidis2005c]. Con todo, la mayoría de los nuevos descubrimientos seguirán saliendo de investigaciones que generan hipótesis con razones previas bajas o muy bajas. Deberíamos reconocer entonces que la prueba de significación estadística en el informe de un único estudio solo ofrece una imagen parcial, porque no sabemos cuántas pruebas se han hecho fuera de ese informe y en el conjunto del campo. Pese a la abundante literatura estadística sobre la corrección por comparaciones múltiples [@hsueh2003], normalmente es imposible saber cuánto han rebuscado en los datos los autores, u otros equipos, antes de comunicar un resultado. Y aunque pudiera saberse, eso no nos diría nada sobre la razón previa. Por tanto, es inevitable hacer suposiciones aproximadas sobre cuántas relaciones cabe esperar que sean verdaderas entre las que se prueban en los campos y los diseños de que se trate. Aunque estas suposiciones serían bastante subjetivas, seguirían siendo muy útiles para interpretar las afirmaciones científicas y ponerlas en contexto. ## Información del artículo {style="back"} :::paragraphs{style="colophon"} **Conflicto de intereses.** El autor declara no tener ningún conflicto de intereses. **Abreviatura.** VPP, valor predictivo positivo. **Esta edición.** Traducción hecha para el Recetario de Postext de una versión abreviada de J. P. A. Ioannidis, «Why Most Published Research Findings Are False», *PLoS Medicine* 2(8): e124 (2005), doi:10.1371/journal.pmed.0020124; texto original en inglés con licencia Creative Commons Attribution. Se omiten algunas frases y párrafos, las fórmulas van numeradas, las tablas y las figuras se han redibujado y se ha corregido una errata. Compuesto en Gelasio y Sofia Sans Semi Condensed (SIL OFL); fórmulas con MathJax. ::: ## Referencias {style="back"} :::bibliography{title=""}`, // the last three sections and the back matter String.raw`:::references{format=bibtex}Muestra en Markdown · 98 líneas · content.refs.en.md
@article{ioannidis2001a, author = {Ioannidis, J. P. and Haidich, A. B. and Lau, J.}, title = {Any casualties in the clash of randomised and observational evidence?}, journal = {BMJ}, year = 2001, volume = 322, pages = {879--880}} @article{lawlor2004, author = {Lawlor, D. A. and Davey Smith, G. and Kundu, D. and Bruckdorfer, K. R. and Ebrahim, S.}, title = {Those confounded vitamins: what can we learn from the differences between observational versus randomised trial evidence?}, journal = {Lancet}, year = 2004, volume = 363, pages = {1724--1727}} @article{vandenbroucke2004, author = {Vandenbroucke, J. P.}, title = {When are observational studies as credible as randomised trials?}, journal = {Lancet}, year = 2004, volume = 363, pages = {1728--1731}} @article{michiels2005, author = {Michiels, S. and Koscielny, S. and Hill, C.}, title = {Prediction of cancer outcome with microarrays: a multiple random validation strategy}, journal = {Lancet}, year = 2005, volume = 365, pages = {488--492}} @article{ioannidis2001b, author = {Ioannidis, J. P. A. and Ntzani, E. E. and Trikalinos, T. A. and Contopoulos-Ioannidis, D. G.}, title = {Replication validity of genetic association studies}, journal = {Nat Genet}, year = 2001, volume = 29, pages = {306--309}} @article{colhoun2003, author = {Colhoun, H. M. and McKeigue, P. M. and Davey Smith, G.}, title = {Problems of reporting genetic associations with complex outcomes}, journal = {Lancet}, year = 2003, volume = 361, pages = {865--872}} @article{ioannidis2003, author = {Ioannidis, J. P.}, title = {Genetic associations: false or true?}, journal = {Trends Mol Med}, year = 2003, volume = 9, pages = {135--138}} @article{ioannidis2005a, author = {Ioannidis, J. P. A.}, title = {Microarrays and molecular research: noise discovery?}, journal = {Lancet}, year = 2005, volume = 365, pages = {454--455}} @article{sterne2001, author = {Sterne, J. A. and Davey Smith, G.}, title = {Sifting the evidence---what's wrong with significance tests?}, journal = {BMJ}, year = 2001, volume = 322, pages = {226--231}} @article{wacholder2004, author = {Wacholder, S. and Chanock, S. and Garcia-Closas, M. and El Ghormli, L. and Rothman, N.}, title = {Assessing the probability that a positive report is false: an approach for molecular epidemiology studies}, journal = {J Natl Cancer Inst}, year = 2004, volume = 96, pages = {434--442}} @article{risch2000, author = {Risch, N. J.}, title = {Searching for genetic determinants in the new millennium}, journal = {Nature}, year = 2000, volume = 405, pages = {847--856}} @article{yusuf1984, author = {Yusuf, S. and Collins, R. and Peto, R.}, title = {Why do we need some large, simple randomized trials?}, journal = {Stat Med}, year = 1984, volume = 3, pages = {409--422}} @article{altman2000, author = {Altman, D. G. and Royston, P.}, title = {What do we mean by validating a prognostic model?}, journal = {Stat Med}, year = 2000, volume = 19, pages = {453--473}} @article{taubes1995, author = {Taubes, G.}, title = {Epidemiology faces its limits}, journal = {Science}, year = 1995, volume = 269, pages = {164--169}} @article{golub1999, author = {Golub, T. R. and Slonim, D. K. and Tamayo, P. and Huard, C. and Gaasenbeek, M. and others}, title = {Molecular classification of cancer: class discovery and class prediction by gene expression monitoring}, journal = {Science}, year = 1999, volume = 286, pages = {531--537}} @article{moher2001, author = {Moher, D. and Schulz, K. F. and Altman, D. G.}, title = {The {CONSORT} statement: revised recommendations for improving the quality of reports of parallel-group randomised trials}, journal = {Lancet}, year = 2001, volume = 357, pages = {1191--1194}} @article{ioannidis2004, author = {Ioannidis, J. P. and Evans, S. J. and Gøtzsche, P. C. and O'Neill, R. T. and Altman, D. G. and others}, title = {Better reporting of harms in randomized trials: an extension of the {CONSORT} statement}, journal = {Ann Intern Med}, year = 2004, volume = 141, pages = {781--788}} @article{ich1999, author = {{International Conference on Harmonisation E9 Expert Working Group}}, title = {{{ICH}} {{Harmonised Tripartite Guideline}}. Statistical principles for clinical trials}, journal = {Stat Med}, year = 1999, volume = 18, pages = {1905--1942}} @article{moher1999, author = {Moher, D. and Cook, D. J. and Eastwood, S. and Olkin, I. and Rennie, D. and others}, title = {Improving the quality of reports of meta-analyses of randomised controlled trials: the {QUOROM} statement. {Quality of Reporting of Meta-analyses}}, journal = {Lancet}, year = 1999, volume = 354, pages = {1896--1900}} @article{stroup2000, author = {Stroup, D. F. and Berlin, J. A. and Morton, S. C. and Olkin, I. and Williamson, G. D. and others}, title = {Meta-analysis of observational studies in epidemiology: a proposal for reporting. {Meta-analysis Of Observational Studies in Epidemiology} ({MOOSE}) group}, journal = {JAMA}, year = 2000, volume = 283, pages = {2008--2012}} @article{chan2004, author = {Chan, A. W. and Hróbjartsson, A. and Haahr, M. T. and Gøtzsche, P. C. and Altman, D. G.}, title = {Empirical evidence for selective reporting of outcomes in randomized trials: comparison of protocols to published articles}, journal = {JAMA}, year = 2004, volume = 291, pages = {2457--2465}} @article{krimsky1998, author = {Krimsky, S. and Rothenberg, L. S. and Stott, P. and Kyle, G.}, title = {Scientific journals and their authors' financial interests: a pilot study}, journal = {Psychother Psychosom}, year = 1998, volume = 67, pages = {194--201}} @article{papanikolaou2001, author = {Papanikolaou, G. N. and Baltogianni, M. S. and Contopoulos-Ioannidis, D. G. and Haidich, A. B. and Giannakakis, I. A. and others}, title = {Reporting of conflicts of interest in guidelines of preventive and therapeutic interventions}, journal = {BMC Med Res Methodol}, year = 2001, volume = 1, pages = {3}} @article{antman1992, author = {Antman, E. M. and Lau, J. and Kupelnick, B. and Mosteller, F. and Chalmers, T. C.}, title = {A comparison of results of meta-analyses of randomized control trials and recommendations of clinical experts. {Treatments for myocardial infarction}}, journal = {JAMA}, year = 1992, volume = 268, pages = {240--248}} @article{ioannidis2005b, author = {Ioannidis, J. P. and Trikalinos, T. A.}, title = {Early extreme contradictory estimates may appear in published research: the {Proteus} phenomenon in molecular genetics research and randomized trials}, journal = {J Clin Epidemiol}, year = 2005, volume = 58, pages = {543--549}} @article{ntzani2003, author = {Ntzani, E. E. and Ioannidis, J. P.}, title = {Predictive ability of {DNA} microarrays for cancer outcomes and correlates: an empirical assessment}, journal = {Lancet}, year = 2003, volume = 362, pages = {1439--1444}} @article{ransohoff2004, author = {Ransohoff, D. F.}, title = {Rules of evidence for cancer molecular-marker discovery and validation}, journal = {Nat Rev Cancer}, year = 2004, volume = 4, pages = {309--314}} @article{lindley1957, author = {Lindley, D. V.}, title = {A statistical paradox}, journal = {Biometrika}, year = 1957, volume = 44, pages = {187--192}} @article{bartlett1957, author = {Bartlett, M. S.}, title = {A comment on {D. V. Lindley's} statistical paradox}, journal = {Biometrika}, year = 1957, volume = 44, pages = {533--534}} @article{senn2001, author = {Senn, S. J.}, title = {Two cheers for {P}-values?}, journal = {J Epidemiol Biostat}, year = 2001, volume = 6, pages = {193--204}} @article{deangelis2004, author = {De Angelis, C. and Drazen, J. M. and Frizelle, F. A. and Haug, C. and Hoey, J. and others}, title = {Clinical trial registration: a statement from the {International Committee of Medical Journal Editors}}, journal = {N Engl J Med}, year = 2004, volume = 351, pages = {1250--1251}} @article{ioannidis2005c, author = {Ioannidis, J. P. A.}, title = {Contradicted and initially stronger effects in highly cited clinical research}, journal = {JAMA}, year = 2005, volume = 294, pages = {218--228}} @article{hsueh2003, author = {Hsueh, H. M. and Chen, J. J. and Kodell, R. L.}, title = {Comparison of methods for estimating the number of true null hypotheses in multiplicity testing}, journal = {J Biopharm Stat}, year = 2003, volume = 13, pages = {675--689}} :::`, // the references as BibTeX, shared by both editions ].join('\n\n'); // Captions, notes and the tables as tab-separated text with TeX in the cells, one block each. const blocks = String.raw`id: tbl-truthMuestra en Markdown · 46 líneas · content.resources.es.md
caption: Resultados de la investigación y relaciones verdaderas Resultado de la investigación Relación verdadera Sí No Total Sí $c(1-\beta)R/(R+1)$ $c\alpha/(R+1)$ $c(R+\alpha-\beta R)/(R+1)$ No $c\beta R/(R+1)$ $c(1-\alpha)/(R+1)$ $c(1-\alpha+\beta R)/(R+1)$ Total $cR/(R+1)$ $c/(R+1)$ $c$ id: tbl-bias caption: Resultados de la investigación y relaciones verdaderas en presencia de sesgo Resultado de la investigación Relación verdadera Sí No Total Sí $(c[1-\beta]R+uc\beta R)/(R+1)$ $c\alpha+uc(1-\alpha)/(R+1)$ $c(R+\alpha-\beta R+u-u\alpha+u\beta R)/(R+1)$ No $(1-u)c\beta R/(R+1)$ $(1-u)c(1-\alpha)/(R+1)$ $c(1-u)(1-\alpha+\beta R)/(R+1)$ Total $cR/(R+1)$ $c/(R+1)$ $c$ id: tbl-teams caption: Resultados de la investigación y relaciones verdaderas cuando hay varios estudios Resultado de la investigación Relación verdadera Sí No Total Sí $cR(1-\beta^n)/(R+1)$ $c(1-[1-\alpha]^n)/(R+1)$ $c(R+1-[1-\alpha]^n-R\beta^n)/(R+1)$ No $cR\beta^n/(R+1)$ $c(1-\alpha)^n/(R+1)$ $c([1-\alpha]^n+R\beta^n)/(R+1)$ Total $cR/(R+1)$ $c/(R+1)$ $c$ id: tbl-ppv caption: VPP de los resultados de la investigación para varias combinaciones de potencia ($1-\beta$), razón entre relaciones verdaderas y no verdaderas ($R$) y sesgo ($u$) note: Los VPP (valores predictivos positivos) estimados suponen $\alpha = 0{,}05$ para un único estudio; aquí se calculan con la ecuación (2).\\ECA, ensayo controlado aleatorizado. $1-\beta$ $R$ $u$ Ejemplo práctico VPP 0,80 1:1 0,10 ECA con potencia adecuada, poco sesgo y una razón previa de 1:1 0,95 2:1 0,30 Metaanálisis confirmatorio de ECA de buena calidad 0,80 1:3 0,40 Metaanálisis de estudios pequeños no concluyentes 0,20 1:5 0,20 ECA de fase I/II con potencia insuficiente, pero bien hecho 0,20 1:5 0,80 ECA de fase I/II con potencia insuficiente y mal hecho 0,80 1:10 0,30 Estudio epidemiológico exploratorio con potencia adecuada 0,20 1:10 0,30 Estudio epidemiológico exploratorio con potencia insuficiente 0,20 1:1000 0,80 Investigación exploratoria orientada al descubrimiento, con pruebas masivas 0,20 1:1000 0,20 Como el anterior, pero con un sesgo más limitado (más normalizada) id: fig-bias caption: VPP (probabilidad de que un resultado de la investigación sea verdadero) en función de la razón de probabilidades previa al estudio para varios niveles de sesgo, $u$ note: Los paneles corresponden a una potencia de 0,80, 0,50 y 0,20. Dibujada en código con la ecuación (2) para los valores de $u$ de la leyenda de 2005, con la curva sin sesgo ($u = 0$) en trazo discontinuo; las curvas impresas en 2005 corresponden a $u = 0$; 0,05; 0,20 y 0,80. alt: Tres paneles de curvas crecientes: el VPP aumenta con la razón previa y baja cuando crece el sesgo. id: fig-teams caption: VPP (probabilidad de que un resultado de la investigación sea verdadero) en función de la razón de probabilidades previa al estudio para distintos números de estudios, $n$ note: Los paneles corresponden a una potencia de 0,80, 0,50 y 0,20. Dibujada en código con la ecuación (3). alt: Tres paneles de curvas crecientes: el VPP baja cuando más equipos estudian la misma pregunta.`; // #region tables: TSV in, a merged header, and the $…$ cells set as formulas by the engine // A cell, a caption or a note sets $…$ as the text does (postext ≥ 1.19): MathJax paths at // the cell's 8 pt, on the baseline of its line and aligned with the cell, vector in the PDF. // The faces' latin files have no α or β (gotcha: latin-subset); the formulas need none. const [CELL_PT, PAD] = [8, 1]; // cell type (pt), cell padding (mm) function tableModel(tsv, widths, headerRows, merges = [], right = []) { let model = { ...parseTSV(tsv, { headerRows }), columnWidths: widths }; model.rows.forEach((_, row) => right.forEach((c) => { model = setAlignment(model, { row, col: c }, 'right'); // the numbers, the PPV and their heads })); for (const range of merges) model = mergeCells(model, range); // gotcha: merged-cells-hiddenby return model; } // "Research finding" over both header rows, "True relationship" over Yes, No and Total. const twoByTwo = (tsv) => tableModel(tsv, [1.1, 1.6, 1.6, 2.5], 2, [ { start: { row: 0, col: 0 }, end: { row: 1, col: 0 } }, { start: { row: 0, col: 1 }, end: { row: 0, col: 3 } }]); // Table 4's last column is computed from Eq. (2), α = 0.05: two significant figures, as in 2005. const ppv = (power, R, u, alpha = 0.05) => (power * R + u * (1 - power) * R) / (R + alpha - (1 - power) * R + u - u * alpha + u * (1 - power) * R); const read = (s) => Number(s.replace(',', '.')); const odds = (s) => s.split(':').map((x) => Number(x.replace(/\D/g, ''))).reduce((a, b) => a / b); const ppvRows = (tsv) => tsv.split('\n').map((line, r) => { const [power, R, u, example, head] = line.split('\t'); const p = ppv(read(power), odds(R), read(u)); // 0.0010: as many decimals as 2 figures need return [power, R, u, example, r ? number(p, 1 - Math.floor(Math.log10(p))) : head].join('\t'); }).join('\n'); // #endregion // #region resources: each block of the slot is a table or a figure, placed where it is cited const parsed = blocks.trim().split(/\n\s*\n/).map((block) => { const fields = {}; const tsv = block.split('\n').filter((line) => { const m = /^(id|caption|note|alt): (.*)$/.exec(line); if (m) fields[m[1]] = m[2]; return !m; }).join('\n'); return { ...fields, tsv }; }); const tables = parsed.filter((b) => b.tsv).map(({ id, caption, note, tsv }) => { const model = id === 'tbl-ppv' ? tableModel(ppvRows(tsv), [0.8, 0.95, 0.75, 3.8, 1.05], 1, [], [0, 1, 2, 4]) : twoByTwo(tsv); return { id, typeId: 'table', kind: 'table', createdAt: 0, updatedAt: 0, caption, note, table: { model }, placement: { position: 'auto', ...(id !== 'tbl-ppv' && { span: 'page' }) } }; }); const figures = parsed.filter((b) => !b.tsv).map(({ id, caption, note, alt }) => ({ id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, caption, note, altText: alt, placement: { position: 'auto', span: 'page' }, svg: { fileId: `${id}.svg`, width: FIG_W * 10, height: FIG_H * 10 } })); const resources = [...tables, ...figures, { id: 'band-art', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'band-art.svg', width: 1400, height: BAND * 10 } }]; const tableStyle = { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackground: col('navy'), headerColor: col('paper'), headerBold: true, headerFontFamily: SANS, headerFontSize: pt(CELL_PT), bodyFontFamily: SANS, bodyFontSize: pt(CELL_PT), bodyColor: col('ink'), cellPadding: mm(PAD) }; const captionStyle = { fontFamily: SANS, fontSize: pt(8.2), color: col('ink'), labelBold: true, labelColor: col('accent'), gap: mm(2), note: { fontSize: pt(7), color: col('muted') } }; // #endregion // #region art: Figures 1 and 2 drawn from Eqs. (2) and (3), the band's curves from Eq. (2) const R2 = (x) => Math.round(x * 100) / 100; // An SVG drawn as a picture cannot use the page's web fonts (gotcha: svg-no-webfonts): the // figures carry the label face inline under its own name, which the PDF asks the provider for. 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 `@font-face{font-family:'${family}';font-weight:${weight};` + `src:url(data:font/woff2;base64,${btoa(bin)}) format('woff2')}`; } const mix = (f) => `#${[1, 3, 5].map((i) => Math.round(parseInt(palette.navy.slice(i, i + 2), 16) * (1 - f) + parseInt(palette.accent.slice(i, i + 2), 16) * f).toString(16).padStart(2, '0')) .join('')}`; // navy for the first curve, vermilion for the last const label = (x, y, s, size, extra = '', fill = palette.muted) => `<text x="${R2(x)}" ` + `y="${R2(y)}" font-size="${size}" font-family="${SANS}" fill="${fill}" ${extra}>${s}</text>`; const line = (points, stroke, width, extra = '') => `<path d="M${points.map(([x, y]) => `${R2(x)} ${R2(y)}`).join('L')}" fill="none" stroke="${stroke}" stroke-width="${width}" ` + `${extra}/>`; // Three panels, power 0.80, 0.50 and 0.20; PPV in % against R from 0 to 1, one curve per value. function panels(face, curve, values, name, digits, dashed) { const [pw, top, plotH, left] = [52, 12, 34, 8]; // panel width, plot top and height, y labels let out = `<style>${face}</style>`; [0.8, 0.5, 0.2].forEach((power, p) => { const x0 = p * (pw + (FIG_W - 3 * pw) / 2) + left; const X = (R) => x0 + R * (pw - left - 2); const Y = (v) => top + plotH * (1 - v); const trace = (f) => Array.from({ length: 101 }, (_, k) => [X(k / 100), Y(f(k / 100))]); out += label(x0 - left, 4, `${'ABC'[p]} <tspan font-weight="400">${t({ en: 'Power', es: 'Potencia' })} ${number(power)}</tspan>`, 3.4, '', palette.navy); if (!p) out += label(x0 - left, top - 4, t({ en: 'PPV (%)', es: 'VPP (%)' }), 2.8); for (const v of [0, 0.2, 0.4, 0.6, 0.8, 1]) { out += line([[X(0), Y(v)], [X(1), Y(v)]], palette.rule, v ? 0.15 : 0.3) + label(X(0) - 1.4, Y(v) + 1, v * 100, 2.6, 'text-anchor="end"') + label(X(v), Y(0) + 3.6, number(v, v % 1 ? 1 : 0), 2.6, 'text-anchor="middle"'); } if (dashed) out += line(trace((R) => dashed(power, R)), palette.muted, 0.35, 'stroke-dasharray="1 0.8"'); values.forEach((value, i) => { out += line(trace((R) => curve(power, R, value)), mix(i / (values.length - 1)), 0.6); }); out += label(X(0.5), Y(0) + 8, `${t({ en: 'Pre-study odds', es: 'Razón previa' })}, ` + '<tspan font-style="italic">R</tspan>', 2.9, 'text-anchor="middle"'); }); const key = values.map((value, i) => line([[48 + i * 24, 57.8], [54 + i * 24, 57.8]], mix(i / (values.length - 1)), 0.8) + label(56 + i * 24, 58.8, `<tspan font-style="italic">` + `${name}</tspan> = ${number(value, digits)}`, 2.9)).join(''); return `<svg xmlns="http://www.w3.org/2000/svg" width="${FIG_W * 10}" height="${FIG_H * 10}" ` + `viewBox="0 0 ${FIG_W} ${FIG_H}">${out}${key}</svg>`; } // Eq. (3): n independent studies of equal power, no bias. const teams = (power, R, n, alpha = 0.05) => (R * (1 - (1 - power) ** n)) / (R + 1 - (1 - alpha) ** n - R * (1 - power) ** n); const bandArt = () => `<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="${BAND * 10}" ` + `viewBox="0 0 140 ${BAND}">${Array.from({ length: 10 }, (_, i) => line(Array.from( { length: 81 }, (_, k) => [10 + k * 1.6, 92 - 76 * ppv(0.8, k / 80, i / 10)]), palette.mist, 0.5, `stroke-opacity="${R2(0.5 - i * 0.04)}"`)).join('')}</svg>`; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages paint, loaded before the first build (gotcha: fonts-first) Gelasio: ['400', '400i', '600', '700', '700i'], 'Sofia Sans Semi Condensed': ['400', '400i', '600', '700', '700i', '800'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown + blocks); const face = await inlineFace(SANS, 400); // one face: the figures set no bold await loadSvg('fig-bias.svg', panels(face, ppv, [0.05, 0.2, 0.5, 0.8], 'u', 2, (power, R) => ppv(power, R, 0))); await loadSvg('fig-teams.svg', panels(face, teams, [1, 5, 10, 50], 'n', 0)); await loadSvg('band-art.svg', bandArt()); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'A metascience essay in English and Spanish', es: 'Un ensayo de metaciencia en inglés y en español' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`); // text in the Fontsource faces; formulas and figures as vector pathsKit · core, fonts, viewer, pdf, images: igual en todas las recetas · 310 líneas
// ─── 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 ───────────────────────────────────────────────────────────────────────
El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)
Variantes
#Vuela los números de cita
Las revistas médicas suelen poner los números de Vancouver volados.
- marker: 'brackets', collapseRanges: true,
+ marker: 'superscript', collapseRanges: true,#Imprime los DOI
El BibTeX de esta receta no guarda los DOI, para no pasar del tamaño del script. Consérvalos en tu archivo e imprímelos como enlaces.
- labelWidth: mm(6.4), doi: 'hide' } },
+ labelWidth: mm(6.4), doi: 'link' } },#Compón las tablas en una columna
Una tabla de 2×2 con fórmulas más cortas cabe en una columna: quítale el span: 'page', que la tabla 4 no lleva. En Artículo a dos columnas con ecuaciones numeradas hay una tabla a una columna.
Errores frecuentes
Error frecuente
Las matemáticas necesitan postext?bundle e initMathEngine()
Las fórmulas cargadas desde https://esm.sh/postext se pintan como cajas grises sin ningún error. Importa todos los símbolos desde https://esm.sh/postext?bundle, sin mezclar nunca las dos URL, y espera a initMathEngine() antes de la primera composición. Matemáticas →
Error frecuente
Los archivos latin de Fontsource solo traen glifos del rango latino
El proveedor del PDF incrusta los archivos latin de Fontsource, que cubren el español y las lenguas de Europa occidental pero no →, ≈, ✓, ★, el griego ni las letras de Europa central; esos glifos faltan en el PDF. Mantén el texto del PDF dentro del rango latin. Fuentes incrustadas en el PDF →
Error frecuente
Solo 8 idiomas tienen separación silábica, con el código exacto
La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →
Error frecuente
Traduce Figura y Tabla con defaultResourceTypes(locale)
El locale de la configuración fija la separación silábica, no los pies: sin resourceTypes, los tipos de serie dicen Figure y Table en inglés. Pasa resourceTypes: defaultResourceTypes('es') para el español; para cualquier otro idioma, escribe tú los nombres en resourceTypes. Figura y Tabla en tu idioma →
Error frecuente
El texto dentro de un SVG <img> no puede usar fuentes web
Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →
Error frecuente
Un $ suelto abre matemáticas: escribe \$
El signo de dólar abre matemáticas en línea, así que un precio como $40 empieza una fórmula. Escribe \$40. Escapes y caracteres literales →
Error frecuente
Cualquier objeto headings desactiva el salto de página del H1
Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →
Error frecuente
Carga todas las fuentes antes de componer
La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →
Error frecuente
Entrecomilla cada valor del frontmatter
YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984". Metadatos del documento →
- Donde la lista de 2005 se queda en cinco autores y et al., el BibTeX termina la lista en
and others, yelsevier-vancouverescribe et al. en su lugar (desde postext 1.19; las versiones anteriores imprimían un coautor llamado others). - Las páginas no coinciden entre ediciones: el texto español es más largo y la tabla 4 cae una página después. La captura publica las páginas 1, 2, 3, 5, 6 y 7 de las dos, para que las imágenes no pasen del límite de peso y la tabla 4 se vea en ambas.
Créditos
- Receta
- Ignacio Ferro
- Texto
- «Why Most Published Research Findings Are False», PLoS Medicine 2(8): e124 (2005), doi:10.1371/journal.pmed.0020124, en acceso abierto con la licencia Creative Commons Attribution; abreviado, con las tablas y las figuras redibujadas; traducción al español hecha para el Recetario · John P. A. Ioannidis · CC BY
- Fuentes
- Gelasio (SIL OFL 1.1) · Sofia Sans Semi Condensed (SIL OFL 1.1)
- Código
- MIT, como Postext
Editar este texto ↗ (se abre en una nueva pestaña)Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)


