En poques paraules
Un assaig sobre un poema de Keats, tal com l'imprimeix una revista literària. L'autora escriu una clau curta per a cada font; Postext imprimeix l'autor i la pàgina entre parèntesis i la llista d'obres citades al final, en estil MLA.
Què compondràs
Un assaig d'una revista literària trimestral: quatre pàgines sobre qui diu els dos últims versos de l'«Ode on a Grecian Urn» de Keats. La pàgina fa 150 × 230 mm i va en Spectral, amb els títols de secció i les capçaleres en Spectral SC; l'obertura ocupa una banda del negre del vernís sobre una franja d'argila vermella, els dos colors d'un vas àtic. Les cites segueixen MLA 9, l'estil de gairebé tots els departaments de literatura i d'història de l'art: autor i pàgina entre parèntesis, només la pàgina quan la frase ja anomena l'autor, un títol abreujat quan dues obres són del mateix autor i la llista d'obres citades en pàgina a part. Cada cita és una clau en el text i l'estil és un sol paràmetre de configuració.
Aquesta recepta respon a
- Com cito obres i componc la bibliografia en APA, IEEE o un altre estil de cita?
La resposta curta
// The text cites with [@keats-letters, 48] → (Keats, Letters 48): MLA prints the author
// and the page, and adds a short title when two works share an author. [-@eliot1932, 230]
// drops the name the sentence already gives → (230). The list is alphabetical, and a
// second work by the same author opens with a dash (MLA's ---) instead of the name.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
style: 'modern-language-association', // MLA 9th edition, author-page
link: true, // a citation jumps to its entry in Works Cited
bibliography: {
// MLA's half-inch hanging indent, scaled to a 108 mm measure.
fontSize: em(0.94), lineHeight: pt(13.4), hangingIndent: em(2.2), entrySpacing: pt(3),
},
};
Ingredients
- Funcions
- Cites en un estil de citaBibliografia a partir de les referènciesObertures dissenyadesAtributs de títolEstils de títolCapçaleres i folisMarges simètricsEstils de paràgrafPaleta de color semànticaExportació a PDF
- També fa servir
- Referències creuadesColor del paperCapçaleres segons el tipus de pàginaFonts incrustades al PDFEspai vertical explícit
- Tipografia
- Spectral, Spectral SC (SIL OFL 1.1)
- Recursos
- Cap: totes les imatges es dibuixen en codi
Elaboració
#1 · L'estil és MLA; les dades, CSL
El codi és la resposta curta de més amunt. El motor de cites es registra una vegada i citations.style anomena l'estil MLA inclòs; citeproc-js hi redacta cada cita i la llista. Les referències van en un bloc :::references{format=csl-yaml} al final de l'assaig. Aquí es fa servir CSL-YAML i no BibTeX perquè MLA necessita un títol abreujat per a les dues obres de Keats, i el lector de BibTeX de postext 1.12.1 descarta el camp shorttitle; en CSL el camp es diu title-short.
#2 · Tres maneres de citar
El text cita una obra amb [@jack1967], que imprimeix «(Jack)» perquè l'assaig es refereix al llibre sencer. Amb pàgina, [@keats-letters, 41] imprimeix «(Keats, Letters 41)»: se citen dues obres de Keats, així que citeproc hi afegeix el títol abreujat per distingir-les. Quan la frase ja anomena el crític, [-@eliot1932, 230] omet el nom i imprimeix «(230)». Un cop citada l'oda amb els seus números de vers, les cites següents porten només els números, escrits entre parèntesis normals, com permet MLA.
#3 · Una cita en bloc amb la referència després del punt
// More than four lines of prose go in a Markdown blockquote: in ink, indented, with no
// first-line indent. Its citation follows the final full stop, as MLA asks.
const blockquote = {
color: col('ink'), italic: false, indent: em(2), firstLineIndent: em(0),
};
La carta sobre la Negative Capability passa de quatre línies de prosa, així que va a part com a cita en bloc de Markdown: en rodona, en el color del text, sagnada 2 em i sense sagnat de primera línia. MLA posa la referència d'una cita en bloc darrere del punt final, i l'original fa el mateix: …fact and reason. [@keats-letters, 48]. Un :::space{lines=0.5} a sobre i un altre a sota la separen dels paràgrafs que l'envolten.
#4 · Les obres citades, en pàgina a part
headingStyles: [{ id: 'works-cited', breakBefore: { enabled: true, parity: 'any' } }],
El títol porta un estil que salta a pàgina nova, i :::bibliography{title=""} col·loca la llista a sota sense un segon títol. La llista va en ordre alfabètic amb sagnat francès, i la segona obra de Keats comença amb una ratlla triple en lloc del nom, com demana MLA per als autors repetits. Un foli al peu apareix en aquesta pàgina i a l'obertura, on no hi ha capçaleres.
#5 · L'obertura en els dos colors d'un vas
const BAND = 116; // mm from the trim's top
const opener = {
enabled: true,
minHeight: mm(BAND - MARGIN.top + 4), // the text starts a line under the band
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('glaze') },
placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
{ kind: 'box', id: 'foot', style: { backgroundColor: col('clay') },
placement: { ...at('page', 'top-left', 0, BAND - 3), size: { width: 'fill',
height: mm(3) } } },
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 600,
fontSize: pt(9), letterSpacing: pt(1.6), color: col('slip'), align: 'left',
placement: at('container', 'top-left', 0, 6) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 300,
italic: true, fontSize: pt(56), lineHeight: 0.95, color: col('paper'), align: 'left',
overflow: 'wrap', placement: { ...at('container', 'top-left', -1, 30),
size: { width: mm(110), height: 'auto' } } },
{ kind: 'text', id: 'sub', content: '{attr.sub}', fontFamily: TEXT, fontWeight: 300,
fontSize: pt(13), lineHeight: 1.25, color: col('paper'), align: 'left', overflow: 'wrap',
placement: { ...at('container', 'top-left', 0, 66),
size: { width: mm(96), height: 'auto' } } },
{ kind: 'text', id: 'byline', content: '{attr.byline}', fontFamily: LABEL, fontWeight: 600,
fontSize: pt(9.5), letterSpacing: pt(1.2), color: col('slip'), align: 'left',
placement: at('container', 'top-left', 0, 82) },
] },
};
const palette = {
ink: '#1f1a17', glaze: '#231c19', clay: '#9a3d22', slip: '#e3a27e', rule: '#c9bdb0',
muted: '#6b6159', paper: '#fffdf9',
};
// 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.clay })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
La banda fa 116 mm i porta l'avantítol, el títol de 56 pt en Spectral Light cursiva, el subtítol i la signatura, tots presos d'atributs del títol. La franja d'argila és una segona caixa ancorada 3 mm per sobre del peu de la banda.
La recepta completa
// ═══ Postext Cookbook · Nº 096 · A humanities essay with MLA works cited ══════════ // https://postext.dev/en/cookbook/mla-humanities-essay // Code: MIT · Text: original (CC BY 4.0) · Pictures: none // Fonts: Spectral, Spectral SC (SIL OFL 1.1) · Needs postext ≥ 1.12.1 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 = 'mla-humanities-essay'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: the black glaze and the red clay of an Attic vase const palette = { ink: '#1f1a17', glaze: '#231c19', clay: '#9a3d22', slip: '#e3a27e', rule: '#c9bdb0', muted: '#6b6159', paper: '#fffdf9', }; // 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.clay }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const TEXT = 'Spectral', LABEL = 'Spectral SC'; const TRIM = { width: 150, height: 230 }; // a literary quarterly const MARGIN = { top: 22, bottom: 22, inner: 19, outer: 21 }; const LEAD = 14.4; // pt const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); // #region answer: MLA 9 author-page citations and a Works Cited list // The text cites with [@keats-letters, 48] → (Keats, Letters 48): MLA prints the author // and the page, and adds a short title when two works share an author. [-@eliot1932, 230] // drops the name the sentence already gives → (230). The list is alphabetical, and a // second work by the same author opens with a dash (MLA's ---) instead of the name. registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES })); const citations = { style: 'modern-language-association', // MLA 9th edition, author-page link: true, // a citation jumps to its entry in Works Cited bibliography: { // MLA's half-inch hanging indent, scaled to a 108 mm measure. fontSize: em(0.94), lineHeight: pt(13.4), hangingIndent: em(2.2), entrySpacing: pt(3), }, }; // #endregion // #region quotation: a block quotation, set in and upright // More than four lines of prose go in a Markdown blockquote: in ink, indented, with no // first-line indent. Its citation follows the final full stop, as MLA asks. const blockquote = { color: col('ink'), italic: false, indent: em(2), firstLineIndent: em(0), }; // #endregion // #region opener: the title in a band of black glaze with a red clay foot const BAND = 116; // mm from the trim's top const opener = { enabled: true, minHeight: mm(BAND - MARGIN.top + 4), // the text starts a line under the band slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('glaze') }, placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } }, { kind: 'box', id: 'foot', style: { backgroundColor: col('clay') }, placement: { ...at('page', 'top-left', 0, BAND - 3), size: { width: 'fill', height: mm(3) } } }, { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 600, fontSize: pt(9), letterSpacing: pt(1.6), color: col('slip'), align: 'left', placement: at('container', 'top-left', 0, 6) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 300, italic: true, fontSize: pt(56), lineHeight: 0.95, color: col('paper'), align: 'left', overflow: 'wrap', placement: { ...at('container', 'top-left', -1, 30), size: { width: mm(110), height: 'auto' } } }, { kind: 'text', id: 'sub', content: '{attr.sub}', fontFamily: TEXT, fontWeight: 300, fontSize: pt(13), lineHeight: 1.25, color: col('paper'), align: 'left', overflow: 'wrap', placement: { ...at('container', 'top-left', 0, 66), size: { width: mm(96), height: 'auto' } } }, { kind: 'text', id: 'byline', content: '{attr.byline}', fontFamily: LABEL, fontWeight: 600, fontSize: pt(9.5), letterSpacing: pt(1.2), color: col('slip'), align: 'left', placement: at('container', 'top-left', 0, 82) }, ] }, }; // #endregion const head = (id, content, parity, edge, x, align) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontWeight: 500, fontSize: pt(8.5), letterSpacing: pt(1), 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, 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(10.6), 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, blockquote, }, headings: { fontFamily: LABEL, fontWeight: 600, color: col('clay'), levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, fontFamily: TEXT, fontSize: pt(56), marginBottom: pt(0), breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener }, { level: 2, fontSize: pt(11.5), letterSpacing: pt(0.9), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) }, ], }, // #region works-cited: MLA starts the list on a page of its own headingStyles: [{ id: 'works-cited', breakBefore: { enabled: true, parity: 'any' } }], // #endregion paragraphStyles: [ { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD * 2) }, ], 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: [ // a drop folio where the running heads stand down { ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, 'center'), pages: 'opener', placement: at('container', 'top', 0, 9) }, ] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Mostra en Markdown · 95 línies · content.es.md
title: "Fría pastoral" author: "Helena Ward" --- # Fría pastoral {#essay kicker="Ensayos · Poesía romántica" sub="Sobre la voz de los dos últimos versos de la «Ode on a Grecian Urn» de Keats" byline="Helena Ward"} Ninguna urna griega se corresponde con la que describe Keats. Quienes fueron a buscarla al British Museum encontraron trozos de varias: la novilla que llevan al sacrificio en el friso del Partenón, que Keats vio entre los mármoles de Elgin en la primavera de 1817, y los danzantes báquicos del vaso Townley, junto con los del vaso Borghese del Louvre, que en Londres se conocía por grabados [@jack1967]. Lo más parecido a una fuente única es una hoja de papel. Keats calcó un grabado del vaso Sosibios, una crátera de mármol firmada por un escultor ateniense del siglo I a. C., a partir del libro de vasos antiguos de Henry Moses, y el calco se conserva [@mcdermott1948]. La urna del poema es una suma hecha con mármoles y con estampas de mármoles, y al final habla. Keats escribió la oda en mayo de 1819. Se imprimió por primera vez en enero de 1820 en los *Annals of the Fine Arts*, una revista londinense de pintura y escultura, y ese verano el poeta la recogió en su tercer y último libro de poemas. Las dos versiones difieren en un signo de puntuación, y buena parte de lo que se ha escrito después sobre el poema gira en torno a ese signo. ## Las preguntas La oda empieza nombrando tres veces a su objeto: «still unravish’d bride of quietness», «foster-child of silence and slow time» y «Sylvan historian» [@keats-ode, lines 1–3]. Los nombres son una cortesía, porque el hablante quiere que la historiadora le cuente su historia. «What men or gods are these? What maidens loth? / What mad pursuit? What struggle to escape?» (8–9). La urna no contesta, y durante tres estrofas el hablante contesta por ella: prefiere las melodías «unheard» de las flautas pintadas a las que se oyen (11–12) y celebra a un amante que nunca alcanzará a la muchacha pero siempre la amará. La cuarta estrofa pasa de los danzantes a una procesión, un sacerdote que conduce una novilla «lowing at the skies» (33), y después a un pueblo que ni siquiera está en la urna, vaciado de su gente por el sacrificio y condenado al silencio «for evermore» (38). Aquí las preguntas dejan de tener respuesta posible. El pueblo existe solo porque la procesión tiene que haber salido de algún sitio. ## La puntuación de los últimos versos En la última estrofa el hablante deja de preguntar. La urna «dost tease us out of thought / As doth eternity: Cold Pastoral!» (44–45) y sobrevivirá a esta generación como «a friend to man, to whom thou say’st, / “Beauty is truth, truth beauty,”—that is all / Ye know on earth, and all ye need to know» (48–50). En el libro de 1820 las comillas se cierran después de «beauty», de modo que la urna dice cinco palabras y el resto lo dice otro. El texto de los *Annals* no lleva comillas, y tampoco las llevan las copias que hicieron los amigos de Keats [@stillinger1974]. Un editor tiene que elegir, y la elección decide quién dice «that is all»: la urna a la humanidad, el poeta a su lector o el poeta a las figuras de la urna. Los versos han tenido lectores hostiles. T. S. Eliot vio en ellos «a serious blemish on a beautiful poem» [-@eliot1932, 230], una afirmación falsa o que él no alcanzaba a entender. Cleanth Brooks le respondió que las palabras son «a speech ‘in character’» [-@brooks1947, 165], ajustadas a la urna que las pronuncia, y las comparó con el «Ripeness is all» de Edgar en *King Lear*. Su lectura necesita la puntuación de 1820: si la urna dice solo las cinco palabras entre comillas, el lema es de la urna, y el comentario que sigue a la raya pertenece al poeta que la ha escuchado. ## Las cartas Keats había escrito sobre la belleza y la verdad antes de escribir sobre la urna. «What the Imagination seizes as Beauty must be truth—whether it existed before or not», le dijo a Benjamin Bailey en noviembre de 1817 [@keats-letters, 41]. Un mes después, de vuelta de la pantomima de Navidad, dio nombre a la cualidad que a su juicio más necesitaba un poeta: :::space{lines=0.5} > I had not a dispute, but a disquisition, with Dilke upon various subjects; several things dove-tailed in my mind, and at once it struck me what quality went to form a Man of Achievement, especially in Literature, and which Shakspeare possessed so enormously—I mean Negative Capability, that is, when a man is capable of being in uncertainties, mysteries, doubts, without any irritable reaching after fact and reason. [@keats-letters, 48] :::space{lines=0.5} Leído junto a la carta, el dístico final es menos una doctrina que una renuncia. La urna no ha respondido a ninguna de las preguntas del hablante sobre quiénes son las figuras o dónde está el pueblo. Lo que ofrece a cambio es una frase que no se puede comprobar, y el comentario del poeta acepta que eso es todo lo que la urna va a dar. El hablante que empezó exigiendo una historia termina conforme con saber a medias, que es el estado que la carta pide al poeta que soporte. ## Obras citadas {style="works-cited"} :::bibliography{title=""} :::paragraphs{style="colophon"} Compuesto en Spectral y Spectral SC (SIL OFL). Texto: ensayo original, CC BY 4.0; citas del libro de Keats de 1820 y de la edición de sus cartas de Colvin. ::: :::references{format=csl-yaml} - id: keats-ode type: chapter author: [{family: Keats, given: John}] title: Ode on a Grecian Urn title-short: Ode container-title: "Lamia, Isabella, The Eve of St. Agnes, and Other Poems" publisher: Taylor and Hessey issued: 1820 page: 113-116 - id: keats-letters type: book author: [{family: Keats, given: John}] editor: [{family: Colvin, given: Sidney}] title: Letters of John Keats to His Family and Friends title-short: Letters publisher: Macmillan issued: 1925 - id: jack1967 type: book author: [{family: Jack, given: Ian}] title: Keats and the Mirror of Art publisher: Clarendon Press issued: 1967 - id: mcdermott1948 type: article-journal author: [{family: McDermott, given: William C.}] title: Keats and Sosibios container-title: The Classical Journal volume: 44 issue: 1 page: 33-34 issued: 1948 - id: stillinger1974 type: book author: [{family: Stillinger, given: Jack}] title: "The Texts of Keats’s Poems" publisher: Harvard UP issued: 1974 - id: eliot1932 type: chapter author: [{family: Eliot, given: T. S.}] title: Dante container-title: "Selected Essays, 1917–1932" publisher: Faber and Faber issued: 1932 - id: brooks1947 type: book author: [{family: Brooks, given: Cleanth}] title: "The Well Wrought Urn: Studies in the Structure of Poetry" publisher: Reynal and Hitchcock issued: 1947 :::`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). const FONTS = { Spectral: ['300', '300i', '400', '400i', '600', '600i'], 'Spectral SC': ['400', '500', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); const title = t({ en: 'A humanities essay with MLA works cited', es: 'Un ensayo de humanidades con obras citadas en MLA' }); 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
#Posa la llista a continuació de l'assaig
Una revista que compon les obres citades després de l'últim paràgraf, sense pàgina nova, treu el salt de l'estil del títol.
- headingStyles: [{ id: 'works-cited', breakBefore: { enabled: true, parity: 'any' } }],
+ headingStyles: [{ id: 'works-cited', breakBefore: { enabled: false } }],#Cita en Chicago autor-any
El mateix assaig en Chicago posa l'any després de l'autor, «(Brooks 1947, 165)», i la llista s'hi adapta.
- style: 'modern-language-association', // MLA 9th edition, author-page
+ style: 'chicago-author-date',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
Una paleta canviada no arriba als elements de disseny ni al color de les remissions
postext 1.4.1 aplica colorPalette als estils de text (cos, títols, llistes, peus, taules, requadres), però no als elements de capçaleres, peus de pàgina, obertures i portadelles, ni a bodyText.referenceColor: conserven l'hex escrit al costat del seu paletteId. Si canvies la paleta, per a una edició de pantalla fosca o per recolorejar, reescriu cada color enllaçat a partir de colorPalette abans de compondre. Paleta de color semàntica →
Error freqüent
Només 8 llengües tenen partició de mots, amb el codi exacte
La partició de mots existeix per a en-us, es, fr, de, it, pt, ca i nl, amb el codi exacte: 'es-ES' o qualsevol altra llengua passa sense avís a l'anglès americà. Partició de mots i llengua 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 →
- Una cita narrativa sense pàgina,
@stillinger1974 registra, imprimeix el nom amb un segon espai darrere en postext 1.12.1. Cita aquesta obra entre claudàtors al final de la frase, com fa aquest assaig, o dona'n una pàgina. - citeproc-js imprimeix els títols tal com vénen a les dades. MLA escriu els títols anglesos amb majúscula a cada paraula principal, així que escriu-los així a les referències.
Crèdits
- Recepta
- Ignacio Ferro
- Text
- Quotations from “Ode on a Grecian Urn” (1820) · John Keats · domini públic
- Quotations from two letters of 1817, in Letters of John Keats to His Family and Friends, ed. Sidney Colvin (1925) · John Keats · domini públic
- Fonts
- Spectral (SIL OFL 1.1) · Spectral SC (SIL OFL 1.1)
- Codi
- MIT, com Postext
Editar aquest text ↗ (s'obre en una pestanya nova)Carpeta de la recepta a GitHub ↗ (s'obre en una pestanya nova)


