En pocas palabras
Un capítulo de una tesis doctoral. La autora escribe una clave corta para cada fuente; Postext imprime las citas y la lista de referencias del final en estilo APA.
Lo que vas a componer
El segundo capítulo de una tesis doctoral en educación, el estado de la cuestión: lo que dicen veinte años de estudios sobre leer en papel y en pantalla. El capítulo va en A4 encuadernado a la izquierda, en Literata con 16 pt de interlineado, bajo una banda pálida en el azul de la universidad. La autora guarda las fuentes en Zotero y pega la exportación en BibTeX; en el texto, cada fuente es una clave. Postext compone cada cita en APA 7, con el autor, el año y la página cuando la hay, escribe «Delgado et al. (2018)» cuando el nombre forma parte de la frase y monta la lista de referencias en el orden y la forma que pide el estilo. Si el director de tesis pide Chicago, el estilo es la única línea que cambia.
Esta receta responde a
- ¿Cómo cito obras y compongo la bibliografía en APA, IEEE u otro estilo de cita?
La respuesta corta
// Citations are written [@key, p. 33] and formatted by citeproc-js in the chosen CSL
// style; the references come from the BibTeX block at the end of the chapter. Register
// the engine once, before the first build, then the style is one setting.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
style: 'apa', // 'chicago-author-date', 'ieee', 'vancouver'… change nothing else
link: true, // each citation jumps to its entry in the PDF and on screen
bibliography: {
// APA asks for a half-inch hanging indent and keeps the list in the text size.
fontSize: em(1), hangingIndent: mm(12.7), entrySpacing: pt(4),
doi: 'link', // printed whole, as APA wants, and clickable
},
};
Ingredientes
- Funciones
- Citas en un estilo de citaBibliografía a partir de las referenciasReferencias cruzadasTítulos numeradosEstilos de títuloCapítulos sin númeroAperturas diseñadasCabeceras y foliosPaleta de color semánticaExportación a PDF
- También usa
- Citas que colocan las figurasAtributos de títuloCabeceras según el tipo de páginaFuentes incrustadas en el PDF
- Tipografía
- Literata, Public Sans (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Un motor, un estilo
El código es la respuesta corta de arriba. Postext lee las citas y deja su redacción a un motor de citas que se registra una vez antes de la primera composición; postext-citeproc envuelve citeproc-js, el motor de Zotero y Mendeley, con dieciséis estilos CSL y sus archivos de idioma. [@mangen2013] da «(Mangen et al., 2013)», @delgado2018 sin corchetes pone los autores dentro de la frase y [@ibanez2023, pp. 12–15] lleva el intervalo de páginas al paréntesis. Con el documento en español, las mismas claves toman los nexos y las abreviaturas del archivo de idioma español, porque el motor sigue el idioma del documento.
#2 · Las referencias salen de un bloque BibTeX
El último bloque del capítulo es :::references{format=bibtex}, con la exportación de Zotero pegada dentro y los acentos escritos como {\'a}. :::bibliography, encima, coloca la lista bajo el título Referencias; sin él, Postext añade la lista al final. Solo aparecen las obras citadas, ordenadas como ordena APA, y cada cita del texto enlaza con su entrada.
#3 · Un título sin número y referencias con palabras
headingStyles: [{ id: 'references', numbered: false }],
crossRefs: { section: t({ en: 'Section {n}', es: 'Sección {n}' }) },
El título de las referencias lleva el estilo references, que lo deja en el índice pero fuera de la numeración, así que la lista no se convierte en la sección 2.4. :ref{id="sec-speed"} imprime «Sección 2.2» con la etiqueta de crossRefs y enlaza con el título; los números salen de startAt=2 en el título del capítulo, el mismo contador que usan los títulos.
#4 · La apertura va en una banda
const BAND = 92; // mm from the trim's top
const opener = {
enabled: true,
minHeight: mm(BAND - 28 + 10), // the body starts 10 mm under the band
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('tint') },
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
{ kind: 'text', id: 'label', content: t({ en: 'Chapter', es: 'Capítulo' }),
fontFamily: LABEL, fontSize: pt(9), fontWeight: 600, letterSpacing: pt(1.8),
textTransform: 'uppercase',
color: col('accent'), align: 'left',
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(0), y: mm(2) } } },
{ kind: 'text', id: 'number', content: '{number}', fontFamily: TEXT, fontSize: pt(96),
fontWeight: 300, lineHeight: 0.9, color: col('accent'), align: 'right',
placement: { anchor: { to: 'container', edge: 'top-right' },
offset: { x: mm(0), y: mm(-6) },
size: { width: mm(40), height: 'auto' } } },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(24),
lineHeight: 1.12, fontWeight: 600, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { x: mm(0), y: mm(14) },
size: { width: mm(108), height: 'auto' } } },
] },
};
La banda es una caja anclada al corte de la página, y el texto empieza 10 mm por debajo. El número va en Literata Light de 96 pt a la derecha, donde se lee como una forma y deja al título toda su medida.
const palette = {
ink: '#1b1b1f', accent: '#22406a', tint: '#e6ecf4', rule: '#b9bec8', muted: '#5d6370',
};
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 receta completa
// ═══ Postext Cookbook · Nº 087 · A thesis chapter cited in APA 7 ═══════════════════ // https://postext.dev/en/cookbook/apa-thesis-with-bibtex // Code: MIT · Text: original (CC BY 4.0) · Pictures: none // Fonts: Literata, Public Sans (SIL OFL 1.1) · Needs postext ≥ 1.12.0 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerCitationEngine, } 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 = 'apa-thesis-with-bibtex'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: a thesis prints in black; the one colour is the university's const palette = { ink: '#1b1b1f', accent: '#22406a', tint: '#e6ecf4', rule: '#b9bec8', muted: '#5d6370', }; 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 TEXT = 'Literata', LABEL = 'Public Sans'; const LEAD = 16; // pt: double spacing is a typewriter habit; 1.45 reads as well and fits more // #region answer: the citation engine, APA 7 and how its references look // Citations are written [@key, p. 33] and formatted by citeproc-js in the chosen CSL // style; the references come from the BibTeX block at the end of the chapter. Register // the engine once, before the first build, then the style is one setting. registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES })); const citations = { style: 'apa', // 'chicago-author-date', 'ieee', 'vancouver'… change nothing else link: true, // each citation jumps to its entry in the PDF and on screen bibliography: { // APA asks for a half-inch hanging indent and keeps the list in the text size. fontSize: em(1), hangingIndent: mm(12.7), entrySpacing: pt(4), doi: 'link', // printed whole, as APA wants, and clickable }, }; // #endregion // #region opener: a pale band at the head of the page, the number large and light in it const BAND = 92; // mm from the trim's top const opener = { enabled: true, minHeight: mm(BAND - 28 + 10), // the body starts 10 mm under the band slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('tint') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, { kind: 'text', id: 'label', content: t({ en: 'Chapter', es: 'Capítulo' }), fontFamily: LABEL, fontSize: pt(9), fontWeight: 600, letterSpacing: pt(1.8), textTransform: 'uppercase', color: col('accent'), align: 'left', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(0), y: mm(2) } } }, { kind: 'text', id: 'number', content: '{number}', fontFamily: TEXT, fontSize: pt(96), fontWeight: 300, lineHeight: 0.9, color: col('accent'), align: 'right', placement: { anchor: { to: 'container', edge: 'top-right' }, offset: { x: mm(0), y: mm(-6) }, size: { width: mm(40), height: 'auto' } } }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(24), lineHeight: 1.12, fontWeight: 600, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { x: mm(0), y: mm(14) }, size: { width: mm(108), height: 'auto' } } }, ] }, }; // #endregion const head = (id, content, edge, x) => ({ kind: 'text', id, content, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(14) } }, }); const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), colorPalette, citations, page: { sizePreset: 'custom', width: mm(210), height: mm(297), dpi: 150, // one-sided, bound at the left margins: { top: mm(28), bottom: mm(28), left: mm(35), right: mm(25) }, }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('accent'), textAlign: 'justify', firstLineIndent: mm(6), indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true, }, headings: { fontFamily: TEXT, color: col('ink'), fontWeight: 600, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, numberingTemplate: '{1}', fontSize: pt(22), breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, numberingTemplate: '{1}.{2}', numberSeparator: ' ', fontSize: pt(13), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) }, ], }, // #region refs: the references heading goes unnumbered; section references read in words headingStyles: [{ id: 'references', numbered: false }], crossRefs: { section: t({ en: 'Section {n}', es: 'Sección {n}' }) }, // #endregion header: { elements: [ head('title', '{title}', 'top-left', 35), head('folio', '{pageNumber}', 'top-right', -25), ] }, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 69 líneas · content.es.md
title: "Leer en papel y en pantalla" author: "Marta Ibáñez Roca" --- # Leer en papel y en pantalla: el estado de la cuestión {#ch-review startAt=2} La pregunta que aborda esta tesis es más antigua que las pantallas a las que suele referirse. Mucho antes del primer lector electrónico, los tipógrafos midieron cómo el tamaño de la letra, la longitud de la línea y el espacio entre líneas cambiaban la velocidad y la comodidad de la lectura [@tinker1963]. Lo que cambió con las pantallas fue la escala del experimento: en la década de 2010, buena parte de lo que los estudiantes leían para sus asignaturas les llegaba a través de un dispositivo, y la pregunta de si eso importaba salió del laboratorio y entró en el aula [@baron2015]. Este capítulo repasa lo que se sabe. :ref{id="sec-comprehension"} reúne los estudios sobre comprensión, :ref{id="sec-speed"} los de velocidad y longitud de línea, y :ref{id="sec-attention"} los argumentos sobre la atención que enmarcan el resto de la tesis. El método del estudio propio se expone en el capítulo 3. ## Comprensión {#sec-comprehension} La comparación temprana más citada pidió a estudiantes noruegos de secundaria superior que leyeran dos textos, uno narrativo y otro expositivo, en papel o como archivos PDF en la pantalla de un ordenador [@mangen2013]. Quienes leyeron en papel obtuvieron mejores resultados en la prueba de comprensión posterior. La diferencia era modesta, pero apuntaba en la misma dirección que un número creciente de estudios, y planteaba una pregunta que los autores no pudieron resolver: si la ventaja venía del soporte, de la manera de moverse por él o de lo que los lectores esperaban hacer con él. Dos metaanálisis reunieron después las pruebas. @delgado2018 combinaron 54 estudios con más de 170 000 participantes y encontraron una ventaja del papel en la comprensión de textos informativos, mayor cuando se leía con límite de tiempo y, de forma inesperada, mayor en los estudios recientes que en los antiguos. @clinton2019 llegó a una conclusión parecida a partir de un conjunto más reducido de experimentos: leer en papel daba mejor comprensión, mientras que el tiempo de lectura no variaba entre soportes. Ninguna de las dos revisiones encontró una diferencia fiable en los textos narrativos. Un estudio piloto realizado para esta tesis con cuarenta estudiantes de primer curso encontró el mismo patrón [@ibanez2023, pp. 12–15], aunque su muestra era demasiado pequeña para zanjar la cuestión. ## Velocidad y longitud de línea {#sec-speed} La velocidad es la medida que el lector nota primero y en la que el investigador menos confía. Se puede leer más deprisa entendiendo menos, y ganar palabras por minuto no dice nada de lo que se retiene [@rayner2016]. Los estudios que controlan la comprensión cuentan una historia más modesta que las promesas de los cursos de lectura rápida: los ojos avanzan a saltos cortos, captan unas pocas letras a cada lado de la fijación y no se les puede entrenar para abarcar una línea entera de una vez [@rayner2016]. La longitud de línea es la variable sobre la que más tiempo llevan discutiendo los tipógrafos. En pantalla, @dyson2001 midieron velocidad y comprensión con distintas longitudes de línea y encontraron que ambas no siempre mejoran a la vez, lo que aconseja no juzgar una composición solo por la velocidad. Los trabajos anteriores sobre el impreso ya habían mostrado que la mejor longitud depende del cuerpo de la letra y del interlineado, no de un número fijo de caracteres [@tinker1963; véase también @ibanez2023, sec. 2]. ## Atención y cerebro lector {#sec-attention} Los estudios empíricos dejan sitio para un argumento más amplio. @wolf2018 sostiene que los hábitos que forma la lectura en pantalla, rápida y en diagonal, se trasladan a la lectura de textos largos y debilitan los procesos lentos que exige comprender un argumento difícil. @baron2015 llega a una idea parecida a partir de encuestas a estudiantes, muchos de los cuales decían concentrarse mejor en papel aunque prefirieran la pantalla por comodidad. Son argumentos, no mediciones, y esta tesis los trata como hipótesis: el capítulo 3 comprueba si la ventaja del papel descrita en :ref{id="sec-comprehension"} se mantiene cuando la página en pantalla se compone con el cuidado que recibe una página impresa. ## Referencias {style="references"} :::bibliography{title=""} :::references{format=bibtex} @book{tinker1963, author = {Tinker, Miles A.}, title = {Legibility of print}, publisher = {Iowa State University Press}, address = {Ames, IA}, year = 1963} @book{baron2015, author = {Baron, Naomi S.}, title = {Words onscreen: The fate of reading in a digital world}, publisher = {Oxford University Press}, address = {New York}, year = 2015} @article{mangen2013, author = {Mangen, Anne and Walgermo, Bente R. and Br{\o}nnick, Kolbj{\o}rn}, title = {Reading linear texts on paper versus computer screen: Effects on reading comprehension}, journal = {International Journal of Educational Research}, volume = 58, pages = {61--68}, year = 2013, doi = {10.1016/j.ijer.2012.12.002}} @article{delgado2018, author = {Delgado, Pablo and Vargas, Crist{\'o}bal and Ackerman, Rakefet and Salmer{\'o}n, Ladislao}, title = {Don't throw away your printed books: A meta-analysis on the effects of reading media on reading comprehension}, journal = {Educational Research Review}, volume = 25, pages = {23--38}, year = 2018, doi = {10.1016/j.edurev.2018.09.003}} @article{clinton2019, author = {Clinton, Virginia}, title = {Reading from paper compared to screens: A systematic review and meta-analysis}, journal = {Journal of Research in Reading}, volume = 42, number = 2, pages = {288--325}, year = 2019, doi = {10.1111/1467-9817.12269}} @article{dyson2001, author = {Dyson, Mary C. and Haselgrove, Mark}, title = {The influence of reading speed and line length on the effectiveness of reading from screen}, journal = {International Journal of Human-Computer Studies}, volume = 54, number = 4, pages = {585--612}, year = 2001, doi = {10.1006/ijhc.2001.0458}} @article{rayner2016, author = {Rayner, Keith and Schotter, Elizabeth R. and Masson, Michael E. J. and Potter, Mary C. and Treiman, Rebecca}, title = {So much to read, so little time: How do we read, and can speed reading help?}, journal = {Psychological Science in the Public Interest}, volume = 17, number = 1, pages = {4--34}, year = 2016, doi = {10.1177/1529100615623267}} @techreport{ibanez2023, author = {Ib{\'a}{\~n}ez Roca, Marta}, title = {Screen layouts and comprehension: A pilot study}, institution = {Reading Lab}, type = {Working paper}, number = 3, year = 2023} @book{wolf2018, author = {Wolf, Maryanne}, title = {Reader, come home: The reading brain in a digital world}, publisher = {Harper}, address = {New York}, year = 2018} :::`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Literata: ['300', '400', '400i', '600', '600i'], 'Public Sans': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); const title = t({ en: 'A thesis chapter in APA 7', es: 'Un capítulo de tesis en APA 7' }); showPages(doc, { title }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf: igual en todas las recetas · 275 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 ───────────────────────────────────────────────────────────────────────
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
#Cambia el estilo
El mismo capítulo en Chicago autor-año quita la coma tras el nombre y pone el año detrás de los autores.
- style: 'apa', // 'chicago-author-date', 'ieee', 'vancouver'… change nothing else
+ style: 'chicago-author-date',#Deja la lista más pequeña que el texto
Muchas universidades admiten la lista de referencias un cuerpo por debajo y a espacio sencillo.
- fontSize: em(1), hangingIndent: mm(12.7), entrySpacing: pt(4),
+ fontSize: em(0.9), lineHeight: pt(13), hangingIndent: mm(12.7), entrySpacing: pt(3),Errores frecuentes
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 →
Error frecuente
Una configuración se cachea por identidad: crea un objeto nuevo
El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →
- APA escribe los títulos de artículos y libros con mayúscula solo inicial, y citeproc-js imprime los títulos tal como vienen en los datos. Zotero guarda casi todos los títulos con mayúsculas en cada palabra, así que corrígelos en Zotero o en el BibTeX antes de que lleguen a la página.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fuentes
- Literata (SIL OFL 1.1) · Public Sans (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)


