Saltar al contingut principal
Recepta número 87

Receptari · Capítol 5 · Estructura del llibre

Un capítol de tesi citat en APA 7

Un capítol de tesi doctoral amb citacions [@clau, p. 33] que surten en APA 7 gràcies a citeproc-js, amb la llista de referències presa d'un bloc BibTeX.

En aquesta pàgina
Sortida
Canvas · PDF
Nivell
Intermedi
Postext
Provada amb Postext 1.12.0
Requereix ≥ 1.12.0 · postext-pdf ≥ 1.12.0
Llicència
Actualitzada el 1 d’oct. del 2026
Codi MIT · Text CC BY 4.0
  • Mostra en castellà: encara no hi ha edició en català
  • Format 210 × 297 mm
  • 1 columna
  • Literata 11/16
  • Public Sans
  • 3 pàgines
  • Nivell
  • Postext 1.12.0
  • Compost en 26 ms
  • 118 línies de codi

En poques paraules

Un capítol d'una tesi doctoral. L'autora escriu una clau curta per a cada font; Postext imprimeix les citacions i la llista de referències del final en estil APA.

Què compondràs

El segon capítol d'una tesi doctoral en educació, l'estat de la qüestió: el que diuen vint anys d'estudis sobre llegir en paper i en pantalla. El capítol va en A4 enquadernat a l'esquerra, en Literata amb 16 pt d'interlineat, sota una banda pàl·lida en el blau de la universitat. L'autora desa les fonts a Zotero i enganxa l'exportació en BibTeX; al text, cada font és una clau. Postext compon cada citació en APA 7, amb l'autor, l'any i la pàgina quan n'hi ha, escriu «Delgado et al. (2018)» quan el nom forma part de la frase i munta la llista de referències en l'ordre i la forma que demana l'estil. Si el director de tesi demana Chicago, l'estil és l'única línia que canvia.

Aquesta recepta respon a

  • Com cito obres i componc la bibliografia en APA, IEEE o un altre estil de cita?

La resposta curta

script.js · línies 27–39al codi complet
// 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
  },
};

Ingredients

Tipografia
Literata, Public Sans (SIL OFL 1.1)
Recursos
Cap: totes les imatges es dibuixen en codi

Elaboració

#1 · Un motor, un estil

El codi és la resposta curta de més amunt. Postext llegeix les citacions i en deixa la redacció a un motor de citacions que es registra una vegada abans de la primera composició; postext-citeproc embolcalla citeproc-js, el motor de Zotero i Mendeley, amb setze estils CSL i els seus fitxers de llengua. [@mangen2013] dona «(Mangen et al., 2013)», @delgado2018 sense claudàtors posa els autors dins de la frase i [@ibanez2023, pp. 12–15] porta l'interval de pàgines al parèntesi. Amb el document en castellà, les mateixes claus prenen els nexes i les abreviatures del fitxer de llengua castellana, perquè el motor segueix la llengua del document.

#2 · Les referències surten d'un bloc BibTeX

L'últim bloc del capítol és :::references{format=bibtex}, amb l'exportació de Zotero enganxada a dins i els accents escrits com {\'a}. :::bibliography, a sobre, col·loca la llista sota el títol Referencias; sense aquest bloc, Postext afegeix la llista al final. Només hi apareixen les obres citades, ordenades com ordena APA, i cada citació del text enllaça amb la seva entrada.

#3 · Un títol sense número i referències amb paraules

script.js · línies 105–106al codi complet
  headingStyles: [{ id: 'references', numbered: false }],
  crossRefs: { section: t({ en: 'Section {n}', es: 'Sección {n}' }) },

El títol de les referències porta l'estil references, que el deixa a l'índex però fora de la numeració, així que la llista no es converteix en la secció 2.4. :ref{id="sec-speed"} imprimeix «Sección 2.2» amb l'etiqueta de crossRefs i enllaça amb el títol; els números surten de startAt=2 al títol del capítol, el mateix comptador que fan servir els títols.

#4 · L'obertura va en una banda

script.js · línies 43–68al codi complet
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 és una caixa ancorada al tall de la pàgina, i el text comença 10 mm per sota. El número va en Literata Light de 96 pt a la dreta, on es llegeix com una forma i deixa al títol tota la seva amplada.

script.js · línies 16–21al codi complet
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 recepta completa

Sandbox
// ═══ 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`---
Mostra en Markdown · 69 línies · content.es.mdtitle: "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 a totes les receptes · 275 línies// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

Variants

#Canvia l'estil

El mateix capítol en Chicago autor-any treu la coma després del nom i posa l'any darrere dels autors.

-  style: 'apa', // 'chicago-author-date', 'ieee', 'vancouver'… change nothing else
+  style: 'chicago-author-date',

#Deixa la llista més petita que el text

Moltes universitats admeten la llista de referències un cos per sota i a espai senzill.

-    fontSize: em(1), hangingIndent: mm(12.7), entrySpacing: pt(4),
+    fontSize: em(0.9), lineHeight: pt(13), hangingIndent: mm(12.7), entrySpacing: pt(3),

Errors freqüents

Error freqüent

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

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

Error freqüent

Carrega totes les fonts abans de compondre

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

Error freqüent

Posa entre cometes cada valor del frontmatter

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

Error freqüent

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

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

  • APA escriu els títols d'articles i llibres amb majúscula només inicial, i citeproc-js imprimeix els títols tal com vénen a les dades. Zotero desa gairebé tots els títols amb majúscula a cada paraula, així que corregeix-los a Zotero o al BibTeX abans que arribin a la pàgina.

Crèdits

Text
Text original, CC BY 4.0
Fonts
Literata (SIL OFL 1.1) · Public Sans (SIL OFL 1.1)
SandboxPDF