Lo que vas a componer
Las siete últimas páginas de una tesis doctoral sobre la lectura en pantalla y en papel, en formato B5 y en blanco y negro. El capítulo 6, el apéndice A, el glosario, la bibliografía y el índice alfabético abren bajo la misma banda negra de 62 mm, con el título calado en blanco. El capítulo lleva su numeral en la banda; el apéndice, su letra; y las secciones finales, el antetítulo BACK MATTER y una nota breve en cursiva. El capítulo va a una columna justificada. El glosario y el índice pasan a dos columnas en bandera y la bibliografía vuelve a una, con las líneas de continuación de cada entrada sangradas. El pen lee los números de página del índice en las páginas compuestas y agrupa los seguidos en tramos como 171–73. Los números en cursiva remiten a una tabla, y los de negrita, al glosario.
Esta receta responde a
- ¿Cómo compongo una bibliografía o un glosario (sangría francesa, cuerpo menor)?
- ¿Cómo evito que los títulos, las negritas y las viñetas salgan en azul?
- ¿Cómo numero los títulos (1, 1.1, 1.1.1) y doy a cada nivel un estilo distinto?
- ¿Cómo pongo cabeceras: el título del libro en la página izquierda, el del capítulo en la derecha y el folio por fuera?
- ¿Cómo fuerzo un salto de página o de columna, y hago que cada capítulo empiece en página impar?
La respuesta corta
// '# Glossary {style="glossary"}' in the Markdown picks a style. Each style starts a page
// of either parity, the appendix a recto (a style that sets no break inherits its level's
// 'odd': gotcha style-inherits-break), stays out of the chapter count (numbered: false, so
// its band has no numeral) and brings its own running heads; the glossary and the index
// set their pages in two columns until the next '#'. config() takes both lists below.
const twoColumns = { layoutType: 'double', gutterWidth: mm(6) };
const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
const headingStyles = () => [
backMatter('appendix', { breakBefore: { enabled: true, parity: 'odd' }, // {letter="A"}
header: appendixHeads, advancedDesign: opener('Appendix', '{attr.letter}') }),
backMatter('glossary', { layout: twoColumns }),
backMatter('references'),
backMatter('index', { layout: twoColumns }),
];
// One paragraph per entry, in :::paragraphs{style="…"}: the turnover lines hang, so the
// first word of every entry stands clear at the left. Ragged, as APA asks of references,
// and so never hyphenated (gotcha: ragged-no-hyphenation).
const entries = (id, size, lead, hang, extra) => ({ id, fontSize: pt(size),
lineHeight: pt(lead), textAlign: 'left', hangingIndent: em(hang), ...extra });
const paragraphStyles = () => [
entries('term', 9.3, 12.4, 1), // the glossary: a bold term, then its definition
entries('reference', 9.3, 12.4, 1.5, { spaceBetween: pt(2.4) }),
entries('entry', 9, 11.6, 2), // the index, written by writeIndex()
];
Ingredientes
- Funciones
- Geometría por secciónBibliografías y glosariosCapítulos sin númeroCabeceras por secciónEstilos de títuloTítulos numeradosCapítulos que abren en página imparBanda de capítulo a todo el anchoAperturas diseñadasAtributos de títuloCabeceras según el tipo de páginaEstilos de párrafoTipos de recurso propiosCitas que colocan las figurasTablas a partir de datosEstilo de los piesPaleta de color semánticaNegrita, cursiva y sus coloresEquilibrado de columnasExportación a PDF
- También usa
- RecuadrosFigura y Tabla en tu idiomaSalir de la rejilla a propósitoFuentes incrustadas en el PDF
- Tipografía
- Libertinus Serif, Libertinus Serif Display, Libertinus Sans (SIL OFL 1.1)
- Recursos
- Ninguno: todas las imágenes se dibujan en código
Elaboración
#1 · Un estilo de título para cada parte del final
El código es la respuesta corta de arriba. # Glossary {style="glossary" note="…"} abre una sección que llega hasta el siguiente título de nivel 1, y sus páginas toman la disposición, las cabeceras y la apertura del estilo (estilos de encabezado), de modo que el glosario y el índice pasan a dos columnas y la bibliografía, cuyo estilo no fija disposición, vuelve a la columna única del documento. numbered: false deja estos títulos fuera de la cuenta de capítulos (sin él, en la banda del glosario saldría un 8), y cada estilo declara su salto de página, porque el que no lo declara hereda el 'odd' del capítulo y deja páginas pares en blanco. Las entradas del glosario y de la bibliografía son párrafos de un bloque :::paragraphs{style="…"} en cuerpo 9,3 sobre 12,4 pt, frente al 11 sobre 14,6 del texto, con las líneas de continuación sangradas 1 em en el glosario y 1,5 em en la bibliografía (estilos de párrafo).
#2 · Una sola banda para todas las aperturas
const SINK = 8; // lines reserved, 41.2 mm: 3.2 mm more than the band, and text on the grid
// Design text sets each baseline 0.8 of its line under the line's top, and a line is 1.2 × the
// size unless lineHeight says otherwise. In mm, a line's part above its baseline and below it:
const PT = 25.4 / 72;
const above = (size, lineHeight = 1.2) => 0.8 * size * lineHeight * PT;
const below = (size, lineHeight = 1.2) => 0.2 * size * lineHeight * PT;
const KICKER = 4.3, TITLE = BAND - TOP - 9.5; // mm under the text block's top: two baselines
// A bottom-aligned box that ends below() under a baseline sets its last line on it. The
// numeral's line is 0.72 of its size: a line taller than its box would hang from its top.
const text = (id, content, family, size, lineHeight, baseline, edge, w, extra) => ({
kind: 'text', id, content, fontFamily: family, fontSize: pt(size), lineHeight,
color: col('paper'), overflow: 'wrap', align: edge.endsWith('right') ? 'right' : 'left',
verticalAlign: 'bottom', ...extra, placement: { anchor: { to: 'container', edge },
size: { width: mm(w), height: mm(baseline + below(size, lineHeight)) } } });
// The mark: '{number}', empty on an unnumbered heading, or the appendix's '{attr.letter}'.
const opener = (label, mark = '{number}') => ({ enabled: true, minHeight: pt(SINK * LEAD),
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: {
anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } },
text('label', label, LABEL, 8, 1.2, KICKER, 'top-left', 80,
{ fontWeight: 700, letterSpacing: pt(1.6), textTransform: 'uppercase' }),
text('title', '{titleText}', DISPLAY, 34, 1.04, TITLE, 'top-left', 84),
text('mark', mark, DISPLAY, 118, 0.72, TITLE, 'top-right', 34),
text('note', '{attr.note}', TEXT, 8.6, 1.3, TITLE, 'top-right', 44, { italic: true }),
] } });
La marca es {number}, que queda vacío en un título sin numerar, así que el mismo diseño sirve para el capítulo, para el apéndice, que pasa {attr.letter} como marca, y para las secciones finales, que imprimen su {attr.note} donde iría el numeral. minHeight reserva ocho líneas de 14,6 pt, 41,2 mm desde la cabeza de la mancha, que dejan 3,2 mm libres bajo la banda y mantienen el texto en su rejilla. En los textos de diseño, la línea base cae a 0,8 de la altura de línea, de modo que una caja alineada abajo cuyo pie queda a below() de una línea base asienta en ella su última línea. Así, el título, el numeral de 118 pt y la última línea de la nota se apoyan en la línea base del título, a 52,5 mm del corte. La línea del numeral mide 0,72 veces su cuerpo porque en la 1.4.1 una línea más alta que su caja ignora verticalAlign: 'bottom'.
#3 · La letra del apéndice se pone a mano
// In 1.4.1 a heading style cannot change the numbering: the appendix is unnumbered, and its
// letter feeds the band (see answer), the running head and a table type that counts A.1.
const appendixHeads = heads('Appendix {attr.letter}. {chapterTitle}');
const appendixTables = { ...defaultResourceTypes(LANG).find((type) => type.id === 'table'),
id: 'table-a', numberingTemplate: 'A.{n}' }; // a copy of 'table'
En postext 1.4.1 un estilo de título no puede cambiar la numeración de su nivel, así que el apéndice va sin numerar y su letra se escribe como atributo: # Interview guide {style="appendix" letter="A"}. La banda y la cabecera del apéndice la toman de ahí, y un tipo de recurso propio numera sus tablas A.1, A.2, etc.; con el tipo por defecto, la tabla sería la 6.2, porque un título sin numerar deja el contador de capítulos en 6. Los títulos del capítulo se numeran con las plantillas de cada nivel: '{1}.{2}' imprime 6.1, y continuation.headings.h1: 5 hace de este el sexto capítulo.
#4 · Las cabeceras, hacia el margen exterior
const HEAD = 17.5, GAP = 9; // mm: the heads' baseline under the trim; the folio to the words
// Each text is placed by its top, above() over HEAD: the folio and the capitals share a baseline.
const head = (id, content, parity, edge, x, size = 7.5, extra) => ({ kind: 'text', id, content,
parity, pages: 'body', fontFamily: LABEL, fontSize: pt(size), fontWeight: 700,
letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('ink'), ...extra, placement: {
anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD - above(size)) } } });
const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0) };
const heads = (recto) => ({ elements: [
head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, 9.5, folio),
head('verso', '{title}', 'even', 'top-left', OUTER + GAP),
head('recto', recto, 'odd', 'top-right', -(OUTER + GAP)),
head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, 9.5, folio),
// The header's container spans the text block, so one rule serves both pages.
{ kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', thickness: pt(0.5),
color: col('rule'), placement: { anchor: { to: 'container', edge: 'top-left' },
offset: { y: mm(HEAD + 2) }, size: { width: 'fill' } } },
] });
const chapterHeads = heads('Chapter {chapterNumber}. {chapterTitle}');
const sectionHeads = heads('{chapterTitle}'); // 'Glossary', 'References', 'Index'
// Openers drop the folio to the foot, centred under the text block, its baseline 12 mm below.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}',
pages: 'opener', ...folio, fontSize: pt(9.5), color: col('ink'), align: 'center',
placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(12 - above(9.5)) } } }] };
Los cuatro textos se anclan a la página y se filtran por parity, lo que mantiene el folio en el borde exterior de las dos páginas; cada uno se coloca por su borde superior, a above() por encima de una línea base situada a 17,5 mm del corte, así que el folio de 9,5 pt y las mayúsculas de 7,5 pt quedan en la misma línea. La par lleva el título de la tesis ({title}, del frontmatter) y la impar el de la sección ({chapterTitle}), como INDEX en la página 177. pages: 'body' las deja fuera de las aperturas, donde el folio pasa al pie. La impar del capítulo diría Chapter 6. Conclusion y la del apéndice Appendix A. Interview guide, pero en esta muestra solo el índice sigue en una página impar después de su apertura.
#5 · Los números del índice salen de las páginas
// The Markdown lists the entries in :::paragraphs{style="index-terms"}, one a line, as 'term:
// pattern' (a regular expression, in any case, from a word's start); two spaces: a sub-entry.
const TERMS = /^:::paragraphs\{style="index-terms"\}\n([\s\S]*?)\n:::$/m;
const KIND = { glossary: 'term', references: 'skip', index: 'skip' }; // other sections: 'text'
// Every searched line in one string (of the glossary, only the bold terms), with the page and
// kind of each character. A line ending in '-' runs into the next without it, a hard hyphen too
// ('meta-' + 'analyses' reads 'metaanalyses'), so every hyphen in a pattern is optional.
function pagesText(doc) {
let text = ''; const at = [];
const termOf = (line) => line.segments.filter((s) => s.bold).map((s) => s.text).join('');
const read = (lines, page, kind) => (lines ?? []).forEach((line) => {
const words = kind === 'term' ? termOf(line) : line.text; // the glossary: the term defined
const part = words.endsWith('-') ? words.slice(0, -1) : `${words} `; // 'expos-' + 'itory'
text += part;
at.push(...Array(part.length).fill({ page, kind }));
});
let kind = 'text';
for (const block of doc.blocks) {
if (block.headingLevel === 1) kind = KIND[block.headingStyleId] ?? 'text'; // a new section
if (kind !== 'skip') read(block.lines, doc.pages[block.pageIndex], kind);
}
for (const page of doc.pages) { // tables float: they hang on their page, not in doc.blocks
page.floats?.flatMap((float) => float.resourceBlock?.table?.cells ?? [])
.forEach((cell) => read(cell.lines, page, 'table'));
}
return { text, at };
}
// 'look-backs, *171*, 172, **174**': runs of text pages join (171–72, Chicago's short form); a
// page that names the term only in a table is set in italics, the glossary's page in bold.
const MARK = { text: '', table: '*', term: '**' };
function locators(pattern, { text, at }) {
const pages = new Map(); // page number → 'text', 'table' or 'term'
const start = new RegExp(`(?<![\\p{L}\\p{N}])(?:${pattern.replaceAll('-', '-?')})`, 'giu');
for (const m of text.matchAll(start)) { // from the start of a word: 'índice' too
const { page: { pageNumberValue: n }, kind } = at[m.index];
if (pages.get(n) !== 'text') pages.set(n, kind); // the text outranks a table on its page
}
const runs = [];
for (const [n, kind] of [...pages].sort(([a], [b]) => a - b)) {
const run = runs.at(-1);
if (run?.kind === 'text' && kind === 'text' && n === run.to + 1) run.to = n;
else runs.push({ from: n, to: n, kind });
}
return runs.map(({ from, to, kind }) => {
const last = from % 100 && Math.trunc(from / 100) === Math.trunc(to / 100) ? to % 100 : to;
return `${MARK[kind]}${from === to ? from : `${from}–${last}`}${MARK[kind]}`;
}).join(', ');
}
// Sorts the entries, heads each letter and adds the numbers (none on the first pass).
function writeIndex(markdown, found) {
const tree = [];
for (const line of TERMS.exec(markdown)[1].split('\n').filter((row) => row.trim())) {
const [, indent, term, pattern] = /^( *)(.+?): (.+)$/.exec(line);
(indent ? tree.at(-1).subs : tree).push({ term, pattern, subs: [] });
}
const byTerm = (a, b) => a.term.localeCompare(b.term, LANG);
const entry = ({ term, pattern }, lead = '') => {
const pages = found && locators(pattern, found);
if (found && !pages) console.warn(`Index: no page mentions “${term}”`);
return `${lead}${term}${pages ? `, ${pages}` : ''}`;
};
const out = []; let letter = '';
for (const main of tree.sort(byTerm)) {
const initial = main.term.normalize('NFD')[0].toUpperCase(); // 'Á' files under A
if (initial !== letter) out.push(`### ${(letter = initial)}`); // an H3 among the entries
// Two en spaces behind a zero-width space indent a sub-entry (gotcha: latin-subset): the
// font lacks an em space, which a plain PDF line sets at no width; a bare space is trimmed.
out.push(entry(main), ...main.subs.sort(byTerm).map((sub) => entry(sub, '\u200B\u2002\u2002')));
}
return markdown.replace(TERMS, () => `:::paragraphs{style="entry"}\n${out.join('\n\n')}\n:::`);
}
Postext no genera índices alfabéticos, pero el documento compuesto guarda cada línea con su texto y su página. El pen compone una vez con las entradas sin números, une las líneas en una sola cadena (del glosario, solo los términos en negrita; nada de la bibliografía ni del índice), busca en ella el patrón de cada entrada y vuelve a componer con los números escritos; el índice es la última sección y abre página, así que nada de lo que va antes se mueve entre las dos composiciones. Las celdas de tabla, leídas de page.floats, dan números en cursiva, y el glosario, en negrita, como avisa la nota del índice. Una línea que acaba en guion se une a la siguiente sin él, y cada guion de un patrón es opcional, porque una línea justificada que corta meta-analyses por su propio guion se marca como partida, igual que la que corta expository tras expos-.
#6 · Todos los valores por defecto, en negro
const palette = { ink: '#000000', band: '#000000', rule: '#000000', paper: '#ffffff' };
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// Bold, italic and list markers default to 'main-color': pointed at the ink, they print black.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
La negrita, la cursiva y las viñetas y números de las listas siguen por defecto la entrada main-color de la paleta, así que, si le das el color de la tinta, salen en negro y no en azul (paleta de colores). En la 1.4.1 la paleta no llega a bodyText.referenceColor, y por eso la configuración lo repite: sin esa línea, Table 6.1 sale en el texto en azul #295AA3. referenceBold: false pone la referencia en redonda, como las citas autor-año que la rodean.
La receta completa
// ═══ Postext Cookbook · Nº 031 · Thesis back matter: appendix, glossary and index ═══ // https://postext.dev/en/cookbook/thesis-back-matter // Code: MIT · Text: original (CC BY 4.0) · Pictures: none // Fonts: Libertinus Serif, Serif Display and Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, defaultResourceTypes, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the language of the sample document ('en') const RECIPE = 'thesis-back-matter'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: one ink; every colour is black or white, each under its own name const palette = { ink: '#000000', band: '#000000', rule: '#000000', paper: '#ffffff' }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // Bold, italic and list markers default to 'main-color': pointed at the ink, they print black. const colorPalette = Object.entries({ ...palette, 'main-color': palette.ink }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const TEXT = 'Libertinus Serif', DISPLAY = 'Libertinus Serif Display', LABEL = 'Libertinus Sans'; const TOP = 24, INNER = 25, OUTER = 31; // mm: a 120 mm measure, about 70 characters at 11 pt const LEAD = 14.6; // pt: the body's leading, the grid every page is set on const BAND = 62; // mm from the trim's top: the black band at the head of every opener // #region answer: back matter as unnumbered heading styles, entries in hanging indents // '# Glossary {style="glossary"}' in the Markdown picks a style. Each style starts a page // of either parity, the appendix a recto (a style that sets no break inherits its level's // 'odd': gotcha style-inherits-break), stays out of the chapter count (numbered: false, so // its band has no numeral) and brings its own running heads; the glossary and the index // set their pages in two columns until the next '#'. config() takes both lists below. const twoColumns = { layoutType: 'double', gutterWidth: mm(6) }; const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra }); const headingStyles = () => [ backMatter('appendix', { breakBefore: { enabled: true, parity: 'odd' }, // {letter="A"} header: appendixHeads, advancedDesign: opener('Appendix', '{attr.letter}') }), backMatter('glossary', { layout: twoColumns }), backMatter('references'), backMatter('index', { layout: twoColumns }), ]; // One paragraph per entry, in :::paragraphs{style="…"}: the turnover lines hang, so the // first word of every entry stands clear at the left. Ragged, as APA asks of references, // and so never hyphenated (gotcha: ragged-no-hyphenation). const entries = (id, size, lead, hang, extra) => ({ id, fontSize: pt(size), lineHeight: pt(lead), textAlign: 'left', hangingIndent: em(hang), ...extra }); const paragraphStyles = () => [ entries('term', 9.3, 12.4, 1), // the glossary: a bold term, then its definition entries('reference', 9.3, 12.4, 1.5, { spaceBetween: pt(2.4) }), entries('entry', 9, 11.6, 2), // the index, written by writeIndex() ]; // #endregion // #region opener: a black band across the head of the page, the title reversed out of it const SINK = 8; // lines reserved, 41.2 mm: 3.2 mm more than the band, and text on the grid // Design text sets each baseline 0.8 of its line under the line's top, and a line is 1.2 × the // size unless lineHeight says otherwise. In mm, a line's part above its baseline and below it: const PT = 25.4 / 72; const above = (size, lineHeight = 1.2) => 0.8 * size * lineHeight * PT; const below = (size, lineHeight = 1.2) => 0.2 * size * lineHeight * PT; const KICKER = 4.3, TITLE = BAND - TOP - 9.5; // mm under the text block's top: two baselines // A bottom-aligned box that ends below() under a baseline sets its last line on it. The // numeral's line is 0.72 of its size: a line taller than its box would hang from its top. const text = (id, content, family, size, lineHeight, baseline, edge, w, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), lineHeight, color: col('paper'), overflow: 'wrap', align: edge.endsWith('right') ? 'right' : 'left', verticalAlign: 'bottom', ...extra, placement: { anchor: { to: 'container', edge }, size: { width: mm(w), height: mm(baseline + below(size, lineHeight)) } } }); // The mark: '{number}', empty on an unnumbered heading, or the appendix's '{attr.letter}'. const opener = (label, mark = '{number}') => ({ enabled: true, minHeight: pt(SINK * LEAD), slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('band') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, text('label', label, LABEL, 8, 1.2, KICKER, 'top-left', 80, { fontWeight: 700, letterSpacing: pt(1.6), textTransform: 'uppercase' }), text('title', '{titleText}', DISPLAY, 34, 1.04, TITLE, 'top-left', 84), text('mark', mark, DISPLAY, 118, 0.72, TITLE, 'top-right', 34), text('note', '{attr.note}', TEXT, 8.6, 1.3, TITLE, 'top-right', 44, { italic: true }), ] } }); // #endregion // #region running-heads: the thesis on the verso, the section on the recto, a hairline under const HEAD = 17.5, GAP = 9; // mm: the heads' baseline under the trim; the folio to the words // Each text is placed by its top, above() over HEAD: the folio and the capitals share a baseline. const head = (id, content, parity, edge, x, size = 7.5, extra) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(size), fontWeight: 700, letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('ink'), ...extra, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(HEAD - above(size)) } } }); const folio = { fontFamily: TEXT, fontWeight: 400, letterSpacing: pt(0) }; const heads = (recto) => ({ elements: [ head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, 9.5, folio), head('verso', '{title}', 'even', 'top-left', OUTER + GAP), head('recto', recto, 'odd', 'top-right', -(OUTER + GAP)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, 9.5, folio), // The header's container spans the text block, so one rule serves both pages. { kind: 'rule', id: 'hairline', pages: 'body', direction: 'horizontal', thickness: pt(0.5), color: col('rule'), placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(HEAD + 2) }, size: { width: 'fill' } } }, ] }); const chapterHeads = heads('Chapter {chapterNumber}. {chapterTitle}'); const sectionHeads = heads('{chapterTitle}'); // 'Glossary', 'References', 'Index' // Openers drop the folio to the foot, centred under the text block, its baseline 12 mm below. const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener', ...folio, fontSize: pt(9.5), color: col('ink'), align: 'center', placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(12 - above(9.5)) } } }] }; // #endregion // #region appendix: the letter comes from the heading, '# Interview guide {letter="A"}' // In 1.4.1 a heading style cannot change the numbering: the appendix is unnumbered, and its // letter feeds the band (see answer), the running head and a table type that counts A.1. const appendixHeads = heads('Appendix {attr.letter}. {chapterTitle}'); const appendixTables = { ...defaultResourceTypes(LANG).find((type) => type.id === 'table'), id: 'table-a', numberingTemplate: 'A.{n}' }; // a copy of 'table' // #endregion const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) colorPalette, header: chapterHeads, footer, layout: { layoutType: 'single' }, page: { sizePreset: 'custom', width: mm(176), height: mm(250), dpi: 150, // B5 margins: { top: mm(TOP), bottom: mm(24), left: mm(INNER), right: mm(OUTER), mirror: true } }, bodyText: { fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'), // 'Table 6.1' in roman and in ink, outside the palette's reach (gotcha: palette-skips-designs) referenceColor: col('ink'), referenceBold: false, firstLineIndent: mm(4.5), indentAfterHeading: false, minWordSpacing: 0.8, maxWordSpacing: 1.8 }, // Exact heading margins (snapToGrid: false) keep the index's letters close to their entries, // and no lines are added above them; the chapter's heads measure whole grid lines. headings: { fontFamily: DISPLAY, fontWeight: 400, color: col('ink'), snapToGrid: false, balancing: { maxLinesPerHeading: 0 }, levels: [ // The H1 break restated (gotcha: headings-drop-h1-break). span: 'page' (the styles inherit // it) sets the band above the columns: inside a column, its top would be clipped. { level: 1, numberingTemplate: '{1}', span: 'page', marginBottom: pt(0), advancedDesign: opener('Chapter'), breakBefore: { enabled: true, parity: 'odd' } }, { level: 2, numberingTemplate: '{1}.{2}', fontSize: pt(14), lineHeight: pt(LEAD), marginTop: pt(LEAD * 1.5), marginBottom: pt(LEAD / 2) }, // three lines in all // The index's letters carry their space in their own line, so both columns start level. { level: 3, fontSize: pt(13), lineHeight: pt(21), marginTop: pt(0), marginBottom: pt(0) }, ] }, headingStyles: headingStyles(), paragraphStyles: paragraphStyles(), orderedLists: { marginTop: pt(LEAD / 2), marginBottom: pt(LEAD / 2) }, unorderedLists: { bulletChar: '–' }, resourceTypes: [...defaultResourceTypes(LANG), appendixTables], // tables 6.1… and A.1… // Captions in the text face, as APA sets a table's number and title. captionStyle: { fontSize: pt(9), position: 'above', gap: pt(4), note: { fontSize: pt(8) } }, // Rules only and a bold header: filled header cells show seams between the columns. tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5), headerBackgroundEnabled: false, headerFontSize: pt(9.5), bodyFontSize: pt(9.5), cellPadding: mm(1) }, calloutStyles: [{ id: 'colophon', span: 'page', marginTop: pt(LEAD), backgroundEnabled: false, stripe: { enabled: true, side: 'top', width: pt(0.5), color: col('rule') }, padding: { top: mm(2.5), right: mm(0), bottom: mm(0), left: mm(0) }, body: { fontSize: pt(8), lineHeight: pt(10.5), firstLineIndent: pt(0), textAlign: 'left' } }], }); // #region index: the index's page numbers, read off the laid-out pages // The Markdown lists the entries in :::paragraphs{style="index-terms"}, one a line, as 'term: // pattern' (a regular expression, in any case, from a word's start); two spaces: a sub-entry. const TERMS = /^:::paragraphs\{style="index-terms"\}\n([\s\S]*?)\n:::$/m; const KIND = { glossary: 'term', references: 'skip', index: 'skip' }; // other sections: 'text' // Every searched line in one string (of the glossary, only the bold terms), with the page and // kind of each character. A line ending in '-' runs into the next without it, a hard hyphen too // ('meta-' + 'analyses' reads 'metaanalyses'), so every hyphen in a pattern is optional. function pagesText(doc) { let text = ''; const at = []; const termOf = (line) => line.segments.filter((s) => s.bold).map((s) => s.text).join(''); const read = (lines, page, kind) => (lines ?? []).forEach((line) => { const words = kind === 'term' ? termOf(line) : line.text; // the glossary: the term defined const part = words.endsWith('-') ? words.slice(0, -1) : `${words} `; // 'expos-' + 'itory' text += part; at.push(...Array(part.length).fill({ page, kind })); }); let kind = 'text'; for (const block of doc.blocks) { if (block.headingLevel === 1) kind = KIND[block.headingStyleId] ?? 'text'; // a new section if (kind !== 'skip') read(block.lines, doc.pages[block.pageIndex], kind); } for (const page of doc.pages) { // tables float: they hang on their page, not in doc.blocks page.floats?.flatMap((float) => float.resourceBlock?.table?.cells ?? []) .forEach((cell) => read(cell.lines, page, 'table')); } return { text, at }; } // 'look-backs, *171*, 172, **174**': runs of text pages join (171–72, Chicago's short form); a // page that names the term only in a table is set in italics, the glossary's page in bold. const MARK = { text: '', table: '*', term: '**' }; function locators(pattern, { text, at }) { const pages = new Map(); // page number → 'text', 'table' or 'term' const start = new RegExp(`(?<![\\p{L}\\p{N}])(?:${pattern.replaceAll('-', '-?')})`, 'giu'); for (const m of text.matchAll(start)) { // from the start of a word: 'índice' too const { page: { pageNumberValue: n }, kind } = at[m.index]; if (pages.get(n) !== 'text') pages.set(n, kind); // the text outranks a table on its page } const runs = []; for (const [n, kind] of [...pages].sort(([a], [b]) => a - b)) { const run = runs.at(-1); if (run?.kind === 'text' && kind === 'text' && n === run.to + 1) run.to = n; else runs.push({ from: n, to: n, kind }); } return runs.map(({ from, to, kind }) => { const last = from % 100 && Math.trunc(from / 100) === Math.trunc(to / 100) ? to % 100 : to; return `${MARK[kind]}${from === to ? from : `${from}–${last}`}${MARK[kind]}`; }).join(', '); } // Sorts the entries, heads each letter and adds the numbers (none on the first pass). function writeIndex(markdown, found) { const tree = []; for (const line of TERMS.exec(markdown)[1].split('\n').filter((row) => row.trim())) { const [, indent, term, pattern] = /^( *)(.+?): (.+)$/.exec(line); (indent ? tree.at(-1).subs : tree).push({ term, pattern, subs: [] }); } const byTerm = (a, b) => a.term.localeCompare(b.term, LANG); const entry = ({ term, pattern }, lead = '') => { const pages = found && locators(pattern, found); if (found && !pages) console.warn(`Index: no page mentions “${term}”`); return `${lead}${term}${pages ? `, ${pages}` : ''}`; }; const out = []; let letter = ''; for (const main of tree.sort(byTerm)) { const initial = main.term.normalize('NFD')[0].toUpperCase(); // 'Á' files under A if (initial !== letter) out.push(`### ${(letter = initial)}`); // an H3 among the entries // Two en spaces behind a zero-width space indent a sub-entry (gotcha: latin-subset): the // font lacks an em space, which a plain PDF line sets at no width; a bare space is trimmed. out.push(entry(main), ...main.subs.sort(byTerm).map((sub) => entry(sub, '\u200B\u2002\u2002'))); } return markdown.replace(TERMS, () => `:::paragraphs{style="entry"}\n${out.join('\n\n')}\n:::`); } // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 194 líneas · content.en.md
title: "Reading on Screens and Paper" subtitle: "A Mixed-Methods Study of Comprehension, Confidence and Navigation" author: "Ines Varley" --- # Conclusion This thesis set out to test whether it matters if a long text is read on paper or on a screen. Chapters 3 to 5 reported a within-subjects experiment with forty-eight undergraduates and interviews with sixteen of them, combined in the convergent design described by Creswell and Plano Clark (2018). This chapter brings the two strands together and sets out what they mean for teaching and for the design of reading software. ## What the study found :ref{id="findings" style="full"} summarises the results. On literal questions, answerable from a single sentence, the medium made no difference. On inferential questions, which required connecting ideas across paragraphs, paper readers scored higher. The difference points the same way as the meta-analyses of Delgado et al. (2018) and Clinton (2019), which found the paper advantage in expository rather than narrative texts. Calibration showed the larger difference. Screen readers predicted higher scores than paper readers and obtained lower ones, so the gap between confidence and accuracy was nearly three times as wide. The result repeats the overconfidence that Ackerman and Goldsmith (2011) found in students who read on screen and set their own study time. Such readers stop once they judge a text understood, so overconfidence cuts their study short. Paper readers also turned back almost twice as often as screen readers scrolled back, most often just before an inferential question. In the interviews, eleven of the sixteen participants remembered where on a page an idea had been (“top left, next to the diagram”), and three gave up looking for a passage on the tablet because “it could have been anywhere”. Liu (2005) described a drift towards browsing and keyword spotting on screen; these readers went through the whole text but had fewer landmarks to return to. ## Implications for teaching and design For short texts and factual questions, screens serve as well as paper. For long expository texts that students must understand rather than search, paper remains the safer choice. Where it is not available, students should test their understanding instead of trusting their sense of it: in the pilot sessions, a short self-test after reading halved the overconfidence on screen. Readers also used the fixed position of text on a page as a map, one of the uses of paper that Sellen and Harper (2002) observed in offices. Reading applications that keep a stable page and show the reader’s place in the whole text may restore some of that map. ## Limitations and further work The participants were students at one university who read English fluently, and the medium matters more for some readers, texts and tasks than it does for others (Singer & Alexander, 2017). The texts were expository and about 1,800 words long, and the screen condition used a single tablet. A replication with a larger sample, several devices and the eye-movement recording reviewed by Rayner (1998) would show where on the page the two media part company. Huey (1908) thought that a complete analysis of what we do when we read would be almost the acme of a psychologist’s achievements. The experiments reported here add a small part to that analysis; the replication proposed above could measure how far readers rely on the position of a passage on the page when they look back. # Interview guide {style="appendix" letter="A"} The interviews took place within a week of each participant’s second session. They were audio-recorded, transcribed in full and analysed thematically following Braun and Clarke (2006); :ref{id="session-plan" style="full"} gives their timing. The questions were asked in this order, and a prompt only when the participant had not already covered its point. 1. Tell me about the last long text you read for a course. - Where did you read it, and on paper or on a screen? 2. Which of the texts in this study do you remember best, and why? 3. When you wanted to check an earlier passage, what did you do? - How did you know where to look? 4. How sure were you of your answers? What made you more or less sure? 5. Did reading on the tablet feel different from reading on paper? 6. Some students say they read more carefully on paper. Do you? 7. What would the ideal way to read a long text for study be like? # Glossary {style="glossary" note="Words in italics are defined under entries of their own."} :::paragraphs{style="term"} **calibration** The agreement between a reader’s confidence in having understood a text and the accuracy of that understanding, measured here as the difference between predicted and actual scores. **comprehension, inferential** Understanding that requires the reader to connect information from different parts of a text or to add knowledge the text does not state. **comprehension, literal** Understanding of what a single sentence or passage states directly. **confidence judgement** A reader’s estimate, made after reading and before seeing the questions, of how many answers will be correct. **convergent design** A mixed-methods design in which quantitative and qualitative data are collected in the same period, analysed separately and then compared. **expository text** A text written to explain or inform, such as a textbook chapter or a report, as opposed to a narrative text. **fixation** A pause of the eyes, typically about a quarter of a second, during which the reader takes in text; fixations alternate with *saccades*. **look-back** Any return to an earlier part of a text during reading: turning back a page, scrolling up or following a link to a previous section. **metacomprehension** A reader’s knowledge and monitoring of their own understanding of a text; *calibration* is one of its measures. **navigation** The movements a reader makes through a text as a whole, as distinct from the movements of the eyes along a line. **overconfidence** Positive *calibration* bias: predicting a higher score than the one actually obtained. **saccade** A rapid movement of the eyes from one *fixation* to the next, during which little or no text is taken in. **screen inferiority effect** The finding that comprehension of the same text is lower on screen than on paper, most consistently for *expository texts* read under time pressure. **self-regulated study** Reading in which the reader, not the experimenter, decides how long to spend on a text. **spatial memory for text** Memory of where on a page or in a document a piece of information appeared, used as a cue for *look-backs*. **thematic analysis** A method for identifying, analysing and reporting patterns of meaning across qualitative data such as interview transcripts. **within-subjects design** An experimental design in which every participant takes part in every condition, here reading on both paper and screen. ::: # References {style="references" note="Every work cited in the thesis, set in APA style (7th edition)."} :::paragraphs{style="reference"} Ackerman, R., & Goldsmith, M. (2011). Metacognitive regulation of text learning: On screen versus on paper. *Journal of Experimental Psychology: Applied, 17*(1), 18–32. Baron, N. S. (2015). *Words onscreen: The fate of reading in a digital world.* Oxford University Press. Braun, V., & Clarke, V. (2006). Using thematic analysis in psychology. *Qualitative Research in Psychology, 3*(2), 77–101. Clinton, V. (2019). Reading from paper compared to screens: A systematic review and meta-analysis. *Journal of Research in Reading, 42*(2), 288–325. Creswell, J. W., & Plano Clark, V. L. (2018). *Designing and conducting mixed methods research* (3rd ed.). SAGE. Delgado, P., Vargas, C., Ackerman, R., & Salmerón, L. (2018). Don’t throw away your printed books: A meta-analysis on the effects of reading media on reading comprehension. *Educational Research Review, 25*, 23–38. Dillon, A. (1992). Reading from paper versus screens: A critical review of the empirical literature. *Ergonomics, 35*(10), 1297–1326. Huey, E. B. (1908). *The psychology and pedagogy of reading.* Macmillan. Liu, Z. (2005). Reading behavior in the digital environment: Changes in reading behavior over the past ten years. *Journal of Documentation, 61*(6), 700–712. Mangen, A., Walgermo, B. R., & Brønnick, K. (2013). Reading linear texts on paper versus computer screen: Effects on reading comprehension. *International Journal of Educational Research, 58*, 61–68. Noyes, J. M., & Garland, K. J. (2008). Computer- vs. paper-based tasks: Are they equivalent? *Ergonomics, 51*(9), 1352–1375. Paterson, D. G., & Tinker, M. A. (1940). *How to make type readable.* Harper & Brothers. Rayner, K. (1998). Eye movements in reading and information processing: 20 years of research. *Psychological Bulletin, 124*(3), 372–422. Sellen, A. J., & Harper, R. H. R. (2002). *The myth of the paperless office.* MIT Press. Singer, L. M., & Alexander, P. A. (2017). Reading on paper and digitally: What the past decades of empirical research reveal. *Review of Educational Research, 87*(6), 1007–1041. Tinker, M. A. (1963). *Legibility of print.* Iowa State University Press. Wolf, M. (2018). *Reader, come home: The reading brain in a digital world.* Harper. ::: # Index {style="index" note="Bold numbers refer to the glossary, italic numbers to tables."} :::paragraphs{style="index-terms"} accuracy: accura Ackerman, Rakefet: Ackerman Alexander, Patricia A.: Alexander Braun, Virginia: Braun browsing: brows calibration: calibrat bias in: calibration bias|confidence and accuracy on screen: screen readers predicted|overconfidence on screen Clarke, Victoria: Clarke Clinton, Virginia: Clinton comprehension: comprehen|understand inferential: inferential literal: literal confidence: confiden|how sure judgements of: confidence judgement|predicted convergent design: convergent courses, reading for: course Creswell, John W.: Creswell debriefing: debrief Delgado, Pablo: Delgado design of reading software: design of reading|reading applications|ideal design expository text: expository eye movements: eye-movement|eyes fixation: fixation Goldsmith, Morris: Goldsmith Harper, Richard H. R.: Harper Huey, Edmund Burke: Huey interviews: interview prompts in: prompt questions asked: questions were asked|Tell me about recording of: recorded|recorder timing of: timing|lasted keyword spotting: keyword landmarks: landmark|as a map limitations: limitation|limits Liu, Ziming: Liu look-backs: look-back|turned back|scrolled back meta-analyses: meta-analys memory: remember metacomprehension: metacomprehension narrative text: narrative navigation: navigat offices, paper in: offices overconfidence: overconfiden paper advantage: paper advantage|safer choice participants: participant pilot sessions: pilot Plano Clark, Vicki L.: Plano Clark Rayner, Keith: Rayner recall: recall replication: replicat saccade: saccade screen inferiority effect: screen inferiority scrolling: scroll self-regulated study: self-regulated|set their own study self-test: self-test Sellen, Abigail J.: Sellen Singer, Lauren M.: Singer spatial memory: spatial memory|where on a page tablet: tablet teaching: teach texts, length of: 1,800 words|long text thematic analysis: thematic transcription: transcri undergraduates: undergraduate university, single: one university within-subjects design: within-subjects ::: :::callout{type="colophon"} Set in Libertinus Serif, Libertinus Serif Display and Libertinus Sans (SIL Open Font License). Text: original, CC BY 4.0. The thesis, its author, its participants and its results are fictional; the works in the references are real. :::`; // content.<lang>.md, inlined by the Cookbook // A table from rows of 'cell|cell|cell'; aligns has a letter a column, l or r. const table = (id, typeId, caption, note, widths, aligns, rows) => ({ id, typeId, kind: 'table', caption, note, createdAt: 0, updatedAt: 0, table: { model: { headerRowCount: 1, columnWidths: widths, rows: rows.map((row, r) => row.split('|').map((content, c) => ({ content, isHeader: r === 0, align: aligns[c] === 'r' ? 'right' : 'left' }))) } } }); const resources = [ table('findings', 'table', 'Main results by medium', 'Means for 48 participants. Bias is the predicted minus the actual score.', [5, 1.4, 1.4], 'lrr', ['Measure|Paper|Screen', 'Literal comprehension (of 10)|7.8|7.7', 'Inferential comprehension (of 10)|6.4|5.6', 'Predicted score (%)|75|78', 'Actual score (%)|71|66.5', 'Calibration bias (points)|+4.0|+11.5', 'Look-backs per text|5.8|3.1']), table('session-plan', 'table-a', 'Timing of an interview session', undefined, [1, 6, 1.4], 'llr', ['Part|Content|Minutes', '1|Welcome, consent and a check of the recorder|3', '2|Free recall of the two study texts|5', '3|Questions 1–4: reading habits, look-backs and confidence|12', '4|Questions 5–7: the two media and an ideal design|12', '5|Debrief|3']), ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Libertinus Serif': ['400', '400i', '700'], 'Libertinus Serif Display': ['400'], 'Libertinus Sans': ['700'] }; // every face the pages use, loaded first (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── // The thesis's sixth and last chapter opens on page 171, a recto. const continuation = { pageIndexOffset: 170, pageNumbering: { startAt: 171 }, headings: { h1: 5 } }; const build = (source) => buildDocument({ markdown: source, resources, continuation }, config()); await loadFonts(FONTS, markdown); // Two passes: the first lays the index out without numbers, the second writes them in. The // index is the last section and opens a page, so no page before it moves between passes. const draft = await buildWithFonts(() => build(writeIndex(markdown, null)), markdown); const doc = build(writeIndex(markdown, pagesText(draft))); showPages(doc, { title: 'Reading on Screens and Paper: the back matter' }); 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 ↗
Variantes
#Abre cada sección en página impar
Las tesis que se encuadernan para una biblioteca suelen abrir cada sección en página impar. El glosario, la bibliografía y el índice pasan entonces a las páginas 175, 177 y 179, cada una tras una par en blanco, y los números en negrita del índice apuntan a la 175.
-const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
- parity: 'any' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });
+const backMatter = (id, extra) => ({ id, numbered: false, breakBefore: { enabled: true,
+ parity: 'odd' }, advancedDesign: opener('Back matter'), header: sectionHeads, ...extra });#Compón también el principio de la tesis
Los preliminares, numerados en romanos antes de la página 1, están en Preliminares con folios romanos y luego la página 1.
Errores frecuentes
Error frecuente
Un estilo de título hereda el salto de página de su nivel
Una entrada de headingStyles toma de su nivel de título todo lo que no fija, también breakBefore. Un índice o un colofón con estilo sobre un H1 tras un :::pagebreak hereda la paridad 'odd' y cae detrás de una página en blanco. Dale a ese estilo breakBefore: { enabled: false }. Estilos de título →
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
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
En el texto en bandera no hay separación silábica
La separación silábica solo se aplica al texto justificado; el texto en bandera corta entre palabras, así que una columna estrecha en bandera queda muy desigual. Justifica el pasaje o ensancha la medida. Separación silábica e idioma del documento →
Error frecuente
Los archivos latin de Fontsource solo traen glifos del rango latino
El proveedor del PDF incrusta los archivos latin de Fontsource, que cubren el español y las lenguas de Europa occidental pero no →, ≈, ✓, ★, el griego ni las letras de Europa central; esos glifos faltan en el PDF. Mantén el texto del PDF dentro del rango latin. Fuentes incrustadas en el PDF →
Error frecuente
El PDF pide todos los pesos y estilos de cada familia
renderToPdf pide al proveedor de fuentes la negrita, la cursiva y la negrita cursiva de cada familia que un bloque podría usar, aunque nunca se imprima, y un solo rechazo detiene la exportación. El proveedor debe ajustarse al peso más cercano que tenga la familia y volver a la redonda cuando no haya cursiva. Fuentes incrustadas en el PDF →
Error frecuente
La página 1 es impar: planifica con números físicos
La página 1 queda a la derecha y la 2 es la primera página par, así que planifica los pliegos con números de página físicos: una apertura en página par queda frente a la impar que la sigue. Saltos de página y de columna →
Error frecuente
Entrecomilla cada valor del frontmatter
YAML lee title: 1984 como un número y una fecha como un objeto Date, y los valores que no son cadenas se imprimen vacíos en los marcadores y dejan el PDF sin título. Entrecomilla cada valor: title: "1984". Metadatos del documento →
Error frecuente
Una configuración se cachea por identidad: crea un objeto nuevo
El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →
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 →
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Fuentes
- Libertinus Serif (SIL OFL 1.1) · Libertinus Serif Display (SIL OFL 1.1) · Libertinus Sans (SIL OFL 1.1)
- Código
- MIT, como Postext


