En pocas palabras
Un ensayo sobre cómo las ciudades británicas adoptaron la hora de Londres. Cada fuente que cita la autora se convierte en una nota numerada al pie de la página, y la lista de obras va al final.
Lo que vas a componer
Una separata de la serie de historia de la técnica de una revista: un ensayo de unas 1.100 palabras sobre cómo las ciudades británicas dejaron su propio mediodía por el de Londres entre 1840 y 1880. La página mide 156 × 234 mm, con márgenes en espejo, en Crimson Pro de 11/14 pt, y una banda verde ferrocarril cubre la mitad superior de la primera. La autora escribe cada fuente como una clave, [@howse1980, 87], y Postext compone cada una como una nota numerada al pie en el estilo de notas y bibliografía de Chicago. La primera nota de una obra da autor, título y editorial; las siguientes, la forma breve. Las notas propias de la autora toman su número de la misma serie, y la bibliografía cierra el ensayo.
Esta receta responde a
- ¿Cómo pongo las citas en notas al pie, al estilo de Chicago, con forma breve en las siguientes?
- ¿Cómo cito obras y compongo la bibliografía en APA, IEEE u otro estilo de cita?
La respuesta corta
// The text cites with [@howse1980, 87]; a note style turns every citation into a
// footnote. The first note for a work gives it in full, later ones in the short form
// (author, short title, page), and the bibliography lists the works by author.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
style: 'chicago-notes-bibliography', // CMOS 18th edition, notes and bibliography
notes: 'footnote', // each citation in a note at the foot of the page
link: true, // a note's citation jumps to its bibliography entry
bibliography: { fontSize: em(0.92), lineHeight: pt(13), hangingIndent: em(1.8),
entrySpacing: pt(3) },
};
// The citation notes share the numbering and the look of the author's own [^notes].
const footnotes = {
placement: 'column', numbering: 'chapter',
fontSize: pt(8.5), lineHeight: pt(10.75), spaceBetween: pt(2),
textAlign: 'left', // ragged: the gap after a note's number stays the same in every note
separator: { width: 0.3, lineWidth: pt(0.5), color: col('rule') },
};
Ingredientes
- Funciones
- Citas en notasCitas en un estilo de citaBibliografía a partir de las referenciasNotas al pieAperturas diseñadasAtributos de títuloCabeceras y foliosMárgenes simétricosEstilos de párrafoPaleta de color semánticaExportación a PDF
- También usa
- Referencias cruzadasColor del papelCabeceras según el tipo de páginaFuentes incrustadas en el PDF
- Tipografía
- Crimson Pro, IBM Plex Sans Condensed (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Una nota para cada cita
El código es la respuesta corta de arriba. chicago-notes-bibliography es un estilo de notas: una cita deja en el texto un número volado y sus palabras pasan a una nota al pie, numerada junto con las [^notas] de la autora; notes: 'footnote' lo dice de forma explícita. citeproc-js lleva la cuenta de las obras ya citadas, así que la nota 3 dice Derek Howse, Greenwich Time and the Discovery of the Longitude (Oxford University Press, 1980), 87 y la nota 4, que vuelve al mismo libro, Howse, Greenwich Time and the Discovery of the Longitude, 90–91. El estilo incluido es la 18.ª edición del Manual de Chicago, que abandona ibid. y repite la forma breve, de modo que las notas 11 y 12 la imprimen dos veces.
#2 · Las notas de la autora también citan
Una cita dentro de una [^nota] se queda en esa nota y se compone dentro de la línea, completa si es la primera vez que aparece la obra: la nota 1 es la frase de la autora seguida de la referencia completa de Schivelbusch. Toda cita termina con el punto del estilo, así que una nota que acaba en una cita no lleva punto propio en el Markdown.
#3 · Prefijos, sufijos y localizadores
[véase @simmons1991, 346–47; y @bartky2000, cap. 9] pone véase delante de la primera obra, separa las dos con punto y coma y conserva el localizador de cada una. Un número solo es una página; cap. y chap. nombran un capítulo en cualquiera de los dos idiomas. El texto que sigue al localizador es un sufijo: [@howse1980, 82–84, sobre los relojes de las diligencias] imprime la frase detrás de las páginas.
#4 · La apertura va en una banda
const BAND = 122; // mm from the trim's top
const opener = {
enabled: true,
minHeight: mm(BAND - MARGIN.top + 9), // the text starts 9 mm under the band
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('green') },
placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 600,
fontSize: pt(7.5), letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('tint'),
align: 'left', placement: at('container', 'top-left', 0, 4) },
{ kind: 'text', id: 'years', content: '{attr.years}', fontFamily: TEXT, fontWeight: 300,
fontSize: pt(54), lineHeight: 1, color: col('tint'), align: 'left',
placement: at('container', 'top-left', -1, 16) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600,
fontSize: pt(25), lineHeight: 1.08, color: col('paper'), align: 'left', overflow: 'wrap',
placement: { ...at('container', 'top-left', 0, 47),
size: { width: mm(112), height: 'auto' } } },
{ kind: 'text', id: 'byline', content: '{attr.byline}', fontFamily: LABEL, fontWeight: 600,
fontSize: pt(9), letterSpacing: pt(0.6), color: col('tint'), align: 'left',
placement: at('container', 'top-left', 0, 92) },
] },
};
La banda baja 122 mm desde el corte y el texto empieza 9 mm por debajo. Las fechas salen de un atributo del título, years="1840–1880", en Crimson Pro Light de 54 pt, para que la tarjeta muestre una cifra tan grande como un título; el atributo de la firma alimenta además la cabecera de las páginas pares.
const palette = {
ink: '#211d1a', green: '#24493d', tint: '#dfe8e2', rule: '#b7b0a4', muted: '#6a645b',
paper: '#fbf9f4',
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.green })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
La receta completa
// ═══ Postext Cookbook · Nº 089 · A history essay with Chicago notes ═══════════════ // https://postext.dev/en/cookbook/history-essay-chicago-notes // Code: MIT · Text: original (CC BY 4.0) · Pictures: none // Fonts: Crimson Pro, IBM Plex Sans Condensed (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 = 'history-essay-chicago-notes'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: a railway green for the opener band, ink and warm paper for the rest const palette = { ink: '#211d1a', green: '#24493d', tint: '#dfe8e2', rule: '#b7b0a4', muted: '#6a645b', paper: '#fbf9f4', }; // A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.green }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const TEXT = 'Crimson Pro', LABEL = 'IBM Plex Sans Condensed'; const TRIM = { width: 156, height: 234 }; // a journal offprint, trimmed like a trade book const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 17 }; const LEAD = 14; // pt const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // #region answer: Chicago notes and bibliography, one note per citation // The text cites with [@howse1980, 87]; a note style turns every citation into a // footnote. The first note for a work gives it in full, later ones in the short form // (author, short title, page), and the bibliography lists the works by author. registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES })); const citations = { style: 'chicago-notes-bibliography', // CMOS 18th edition, notes and bibliography notes: 'footnote', // each citation in a note at the foot of the page link: true, // a note's citation jumps to its bibliography entry bibliography: { fontSize: em(0.92), lineHeight: pt(13), hangingIndent: em(1.8), entrySpacing: pt(3) }, }; // The citation notes share the numbering and the look of the author's own [^notes]. const footnotes = { placement: 'column', numbering: 'chapter', fontSize: pt(8.5), lineHeight: pt(10.75), spaceBetween: pt(2), textAlign: 'left', // ragged: the gap after a note's number stays the same in every note separator: { width: 0.3, lineWidth: pt(0.5), color: col('rule') }, }; // #endregion // #region opener: the green band holds the kicker, the dates and the title const BAND = 122; // mm from the trim's top const opener = { enabled: true, minHeight: mm(BAND - MARGIN.top + 9), // the text starts 9 mm under the band slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('green') }, placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } }, { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('tint'), align: 'left', placement: at('container', 'top-left', 0, 4) }, { kind: 'text', id: 'years', content: '{attr.years}', fontFamily: TEXT, fontWeight: 300, fontSize: pt(54), lineHeight: 1, color: col('tint'), align: 'left', placement: at('container', 'top-left', -1, 16) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600, fontSize: pt(25), lineHeight: 1.08, color: col('paper'), align: 'left', overflow: 'wrap', placement: { ...at('container', 'top-left', 0, 47), size: { width: mm(112), height: 'auto' } } }, { kind: 'text', id: 'byline', content: '{attr.byline}', fontFamily: LABEL, fontWeight: 600, fontSize: pt(9), letterSpacing: pt(0.6), color: col('tint'), align: 'left', placement: at('container', 'top-left', 0, 92) }, ] }, }; // #endregion const head = (id, content, parity, edge, x, align) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.1), textTransform: 'uppercase', color: col('muted'), align, placement: at('page', edge, x, 12), }); const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) colorPalette, citations, footnotes, page: { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true }, }, layout: { layoutType: 'single' }, bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(5), indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true, }, headings: { fontFamily: TEXT, fontWeight: 600, color: col('ink'), levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, fontSize: pt(25), breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('green'), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) }, ], }, paragraphStyles: [ { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], header: { elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, 'left'), head('verso-author', '{attr.byline}', 'even', 'top-left', MARGIN.outer + 8, 'left'), head('recto-title', '{title}', 'odd', 'top-right', -(MARGIN.outer + 8), 'right'), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, 'right'), ] }, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 104 líneas · content.es.md
title: "La hora del ferrocarril" author: "Clara Whitcombe" --- # La hora del ferrocarril: cómo las ciudades británicas adoptaron la hora de Londres {#essay kicker="Ensayos de historia de la técnica · n.º 14" years="1840–1880" byline="Clara Whitcombe"} En el otoño de 1840, un viajero que salía de Paddington en el Great Western Railway llevaba consigo dos horas. El horario que guardaba en el bolsillo daba las horas de Londres; el reloj de la iglesia de Bath o de Bristol, al llegar, daba las del lugar, unos minutos atrasadas. Bristol está algo más de dos grados y medio al oeste de Greenwich, y su mediodía, el instante en que el sol cruza el meridiano local, llegaba unos diez minutos después que el de Londres. Durante siglos la diferencia no había importado. Una diligencia que tardaba un día en cubrir el trayecto perdía más de diez minutos por un caballo cojo. Un tren que lo cubría en cuatro horas, no.[^frame] Este ensayo sigue los cuarenta años en que se resolvió esa diferencia, desde los primeros horarios del Great Western hasta la ley que en 1880 hizo de la hora de Greenwich la hora legal de Gran Bretaña. La historia suele contarse como una victoria de la ingeniería sobre la costumbre. Se entiende mejor como una negociación, en la que compañías ferroviarias, compañías de telégrafos, ayuntamientos y tribunales resolvieron la cuestión cada uno a su manera, y no siempre en el mismo sentido. [^frame]: Sobre el efecto del ferrocarril en la percepción del tiempo y la distancia, véase [@schivelbusch1986, cap. 3] ## Las compañías deciden Los primeros ferrocarriles marcaban la marcha de sus trenes por los relojes de sus propias estaciones. En una línea de pocas millas aquello no causaba problemas, y los primeros horarios no decían a qué hora se referían. La dificultad llegó con las grandes líneas troncales de finales de la década de 1830, que unían ciudades cuyos relojes discrepaban en un cuarto de hora.[@simmons1991, 345] El Great Western imprimió la hora de Londres en sus horarios a partir de noviembre de 1840 y pidió a sus estaciones que la siguieran.[@howse1980, 87] En 1847 la Railway Clearing House, el organismo a través del cual las compañías liquidaban sus cuentas por el tráfico compartido, recomendó a todos sus miembros adoptar la hora de Greenwich desde el 1 de diciembre. A finales de 1848 casi todos lo habían hecho.[@howse1980, 90–91] La decisión fue comercial antes que científica. Una compañía que mantenía una sola hora en toda su línea necesitaba un solo horario y un solo reglamento para su personal, y podía publicar los enlaces con otras líneas sin una tabla de correcciones. Se eligió Greenwich porque en Londres tenían sus oficinas las compañías y el correo sus diligencias, no porque allí estuviera el observatorio.[^post] [^post]: El correo enviaba a sus guardas con relojes puestos en la hora de Londres desde la década de 1790; véase [@howse1980, 82–84, sobre los relojes de las diligencias] ## El hilo lleva la hora Al principio la hora viajaba en el reloj de un guarda del primer tren, puesto en Paddington, y los jefes de estación ajustaban los suyos por él. El telégrafo eléctrico cambió el medio. Desde 1852 el Observatorio Real enviaba cada día una señal por los hilos de la Electric Telegraph Company, que la transmitía a las estaciones y oficinas de correos de su red.[@standage1998, 59] La señal ponía la hora de Greenwich al alcance de cualquier lugar al que llegaran los hilos; no obligaba a nadie a seguirla.[@schivelbusch1986, 43–44] ## Las ciudades dudan La mayoría de las ciudades de las líneas principales cedieron en pocos años, porque un reloj que no coincidía con el de la estación hacía perder trenes. Algunas resistieron. En 1852 el reloj de la Lonja de Granos de Bristol recibió una segunda aguja de minutos, una para Bristol y otra para Londres, y conserva las dos.[@simmons1991, 346] En Exeter, adonde el ferrocarril llegó en 1844, el reloj de la catedral siguió marcando la hora de la ciudad durante algunos años después de que cambiara el de la estación.[^exeter] [^exeter]: Sobre las ciudades británicas, y sobre la misma disputa en las ciudades estadounidenses treinta años después, véase [@simmons1991, 346–47; y @bartky2000, cap. 9] La hora media local seguía al sol, y el sol aún ordenaba la jornada de campesinos, pescadores y de cualquiera cuyo trabajo dependiera de la luz del día. Una hora fijada en Londres era para ellos una imposición. El historiador de la disciplina fabril ha sostenido que el reloj se había convertido ya en el siglo XVIII en un medio de controlar el trabajo;[@thompson1967, 80–82] la disputa por la hora del ferrocarril extendió ese argumento a ciudades enteras, a las que se pedía aceptar una hora fijada en otra parte. ## Los tribunales se resisten Las leyes que mencionaban una hora se referían a la del lugar, y esa hora era la hora media local. En 1858 un juicio en Dorchester empezó a las diez según el reloj del tribunal, que seguía la hora de Londres, y se dictó sentencia contra un demandado que llegó a las diez según la hora de la ciudad, unos minutos más tarde. En la apelación, los jueces sostuvieron que la hora fijada era la local, y la sentencia se anuló.[@howse1980, 113] La Statutes (Definition of Time) Act de 1880 cerró la brecha. Declaraba que toda expresión de tiempo en una ley, una escritura u otro instrumento jurídico significaba, salvo indicación en contrario, la hora media de Greenwich en Gran Bretaña y la de Dublín en Irlanda.[@howse1980, 114] ## Un acuerdo, no una conquista Vista desde 1840, la hora del ferrocarril era uno de varios arreglos posibles. Las compañías podrían haber mantenido las horas locales e impreso tablas de correcciones, como hicieron algunos ferrocarriles estadounidenses hasta 1883,[@bartky2000, 140–45] y el sistema internacional de husos que siguió a la conferencia de Washington de 1884 tardó medio siglo en llegar a la mayor parte del mundo.[@ogle2015, cap. 1] En Gran Bretaña las distancias eran cortas, las compañías eran pocas y podían ponerse de acuerdo en una sola cámara de compensación, y el telégrafo llegó a las estaciones al mismo tiempo que los trenes. Esas condiciones, más que ningún principio, explican la rapidez con que un país de mediodías locales pasó a guardar una sola hora.[@ogle2015, 25; véase también @schivelbusch1986, cap. 3] ## Bibliografía :::bibliography{title=""} :::paragraphs{style="colophon"} Ensayo de muestra para el Recetario de Postext (CC BY 4.0). Obras citadas reales, páginas ilustrativas. Crimson Pro e IBM Plex Sans Condensed (SIL OFL). ::: :::references{format=csl-yaml} - id: schivelbusch1986 type: book author: [{family: Schivelbusch, given: Wolfgang}] title: "The Railway Journey: The Industrialization of Time and Space in the 19th Century" publisher: University of California Press publisher-place: Berkeley issued: 1986 - id: thompson1967 type: article-journal author: [{family: Thompson, given: E. P.}] title: "Time, Work-Discipline, and Industrial Capitalism" container-title: Past & Present issue: 38 page: 56-97 issued: 1967 - id: howse1980 type: book author: [{family: Howse, given: Derek}] title: Greenwich Time and the Discovery of the Longitude publisher: Oxford University Press publisher-place: Oxford issued: 1980 - id: simmons1991 type: book author: [{family: Simmons, given: Jack}] title: The Victorian Railway publisher: Thames and Hudson publisher-place: London issued: 1991 - id: standage1998 type: book author: [{family: Standage, given: Tom}] title: The Victorian Internet publisher: Walker publisher-place: New York issued: 1998 - id: bartky2000 type: book author: [{family: Bartky, given: Ian R.}] title: "Selling the True Time: Nineteenth-Century Timekeeping in America" publisher: Stanford University Press publisher-place: Stanford, CA issued: 2000 - id: ogle2015 type: book author: [{family: Ogle, given: Vanessa}] title: The Global Transformation of Time, 1870–1950 publisher: Harvard University Press publisher-place: Cambridge, MA issued: 2015 :::`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). const FONTS = { 'Crimson Pro': ['300', '400', '400i', '600', '600i'], 'IBM Plex Sans Condensed': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); const title = t({ en: 'A history essay with Chicago notes', es: 'Un ensayo de historia con notas de Chicago' }); 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
#Lleva las notas al final
Algunas revistas reúnen las notas tras el ensayo; las citas conservan sus formas completa y breve.
- placement: 'column', numbering: 'chapter',
+ placement: 'chapterEnd', numbering: 'chapter',#Vuelve a escribir ibid.
La 17.ª edición del estilo de Chicago imprime ibid. en la nota que repite la obra anterior. Pega el archivo .csl de ese estilo, del repositorio de estilos de Zotero, en una cadena y úsalo como estilo propio.
- style: 'chicago-notes-bibliography', // CMOS 18th edition, notes and bibliography
+ style: 'custom', customStyle: CHICAGO_17TH_CSL, // the XML of the .csl fileErrores 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
Una paleta cambiada no llega a los elementos de diseño ni al color de las remisiones
postext 1.4.1 aplica colorPalette a los estilos de texto (cuerpo, títulos, listas, pies, tablas, recuadros), pero no a los elementos de cabeceras, pies de página, aperturas y portadillas, ni a bodyText.referenceColor: conservan el hex escrito junto a su paletteId. Si cambias la paleta, para una edición de pantalla oscura o para recolorear, reescribe cada color enlazado a partir de colorPalette antes de componer. Paleta de color semántica →
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
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 →
- No cierres con punto una nota que acaba en una cita: el estilo ya pone el suyo, y Postext no funde los dos, así que la nota terminaría con dos puntos.
- En un estilo de notas,
@howse1980sin corchetes deja en la frase solo el número de la nota, Como ¹ dice, sin el nombre del autor. Escribe tú el nombre y cita entre corchetes:Como dice Howse,[@howse1980].
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fuentes
- Crimson Pro (SIL OFL 1.1) · IBM Plex Sans 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)


