Saltar al contenido principal
Receta número 73

Recetario · Capítulo 5 · Estructura del libro

Un índice onomástico y un índice de materias

Las marcas con index="names" van al índice de personas y las demás al de materias; :::index imprime cada uno a dos columnas y la maquetación fija las páginas.

En esta página
Salida
Canvas · PDF
Postext
Probada con Postext 1.7.0
Requiere ≥ 1.7.0 · postext-pdf ≥ 1.7.0
Licencia
Actualizada el 28 sept 2026
Código MIT · Texto CC BY 4.0

pp. 6–7 de 7

  • Formato 155 × 235 mm
  • 1 columna
  • Crimson Pro 10,6/13,6
  • Bodoni Moda
  • Libre Franklin
  • 7 páginas
  • Nivel
  • Postext 1.7.0
  • Compuesto en 68 ms
  • 143 líneas de código

Lo que vas a componer

El capítulo cuarto de Letras de metal, una monografía sobre tipos de imprenta de 155 × 235 mm: una columna de Crimson Pro, títulos en Bodoni cursiva y un solo color de acento, rojo ladrillo. El ensayo va de la textura de Gutenberg a los modernos de Bodoni, pasando por Jenson, Aldo y Griffo, Garamond y Plantin, Caslon y Baskerville, los puntos de Fournier y los tipos españoles que tallaron Pradell y Gil e imprimió Ibarra. Termina como terminan estos libros, con dos índices en páginas enfrentadas: impresores, grabadores y mecenas en la par; tipos, útiles y libros en la impar. Los dos salen del mismo texto marcado, y cada número de página es la página donde cayó la marca.

Esta receta responde a

  • ¿Cómo imprimo dos índices, uno onomástico y otro de materias, a partir del mismo texto marcado?
  • ¿Cómo hago un índice analítico cuyos números de página cambien solos cuando el texto se mueve?

La respuesta corta

script.js · líneas 35–57en el código completo
// Marks in the Markdown. A person goes to the names index, inverted:
//   :index[Aldus Manutius]{term="Manutius, Aldus" index="names" main}
// A subject goes to the main index, with levels split by "!" and a sort key when the
// printed form would file in the wrong place:
//   :index[textura]{term="Blackletter!textura" main}
//   :index{term="42-line Bible" sort="Forty-two-line Bible"}
//   :index{term="Cursive" see="Italic"}
// Printed at the end, each under its own heading:
//   # Index of Names {style="index"}    then    :::index{index="names"}
//   # Index of Subjects {style="index"} then    :::index
const indexHeading = {
  id: 'index',
  numbered: false, // no chapter number, and the next chapter is still chapter 5
  breakBefore: { enabled: true, parity: 'any' },
  layout: { layoutType: 'double', gutterWidth: mm(7) }, // the section after the heading
};
const index = {
  fontSize: pt(8.6), lineHeight: pt(11.2), // one leading for entries and letter heads
  indent: em(1), turnoverIndent: em(2),
  rangeFormat: 'chicago', // 212–14, as a monograph prints it
  main: { bold: true }, // the page of the principal discussion
  groups: { fontFamily: 'Bodoni Moda', fontSize: pt(11), fontWeight: 600, color: col('brick') },
};

Ingredientes

Tipografía
Crimson Pro, Bodoni Moda, Libre Franklin (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Marca cada persona para el índice onomástico y cada materia para el principal

La configuración es la respuesta corta de arriba; lo demás está en el Markdown. Una marca con index="names" va al índice que imprime :::index{index="names"}, y todas las demás al índice principal, el que imprime :::index sin atributos. Así, :index[Aldo Manucio]{term="Manucio, Aldo" index="names" main} imprime el nombre en el texto y anota una página en negrita bajo Manucio en el onomástico, mientras que :index[cursiva]{term="Cursiva" main}, dos líneas más abajo, anota la misma página bajo Cursiva en el de materias. Una persona que además es materia del capítulo puede llevar las dos marcas.

#2 · Invierte los nombres y ordena por la letra donde buscará el lector

Los nombres van con el apellido delante: term="Garamond, Claude", term="Dall’Aglio, Margherita". La coma forma parte del término, de modo que la ordenación pone Didot, Firmin antes de Didot, François-Ambroise, como haría un indizador. Cuando la forma impresa caería en la letra equivocada, sort da la clave: la edición inglesa marca term="42-line Bible" sort="Forty-two-line Bible" para que la Biblia quede bajo la F y no abra un grupo 0–9. Las subentradas (term="Fundiciones!Plantin-Moretus"), las páginas principales en negrita (main), range="start" y range="end" alrededor del apartado de Bodoni y las remisiones (see="Cursiva", seealso="Matrices") hacen el resto. Un destino con niveles se imprime con dos puntos: Humana, letra. Véase Letra romana: veneciana.

#3 · Compón una sola vez; el documento fija sus propios números

script.js · líneas 219–222en el código completo
// A pass finds the page of every mark and prints both indexes with those numbers. When the
// printed index moves a mark (an index set before the text would), the layout runs again,
// until no page number changes (in a buildBundle book, the chapter with :::index gets them all).
const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown);

El índice imprime números de página que solo existen después de maquetar, e imprimirlo ocupa páginas. buildDocument() lo resuelve: maqueta el capítulo, lee la página de cada marca, imprime los dos índices y vuelve a maquetar cada vez que un número cambia. Aquí los índices van detrás del texto, así que la segunda pasada confirma la primera; un índice colocado antes del texto desplazaría todas las páginas siguientes, y las pasadas seguirían hasta coincidir. Añade un párrafo al apartado de Bodoni y el índice onomástico dará a Whittingham su nueva página sin que toques nada.

#4 · Da a cada índice una apertura sobre dos columnas

script.js · líneas 61–75en el código completo
const opener = (sink, size) => ({
  enabled: true,
  minHeight: pt(LEAD * sink), // the text starts on the same grid line in every chapter
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(7.8),
      letterSpacing: pt(1.6), color: col('brick'), align: 'left',
      placement: at('container', 'top-left', 0, 10) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Bodoni Moda', italic: true,
      fontSize: pt(size), lineHeight: 1.08, color: col('ink'), align: 'left',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('#kicker', 'below', 0, 3), size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('brick'),
      placement: { ...at('#title', 'below', 0, 4.5), size: { width: mm(16) } } },
  ] },
});

El estilo de título de la respuesta corta hace tres cosas por # Índice onomástico {style="index"}: abre página (parity: 'any', para que los dos índices se sigan sin una página en blanco), compone a dos columnas separadas 7 mm la sección que empieza tras él y, con numbered: false, no cuenta como capítulo. El estilo reutiliza la apertura del capítulo con un hundido de seis líneas y un título de 20 pt, y las letras de cada grupo repiten su Bodoni en el color de acento, con la altura de una línea del índice para que las columnas no pierdan el paso.

#5 · Pon el nombre del índice en la página impar

script.js · líneas 79–96en el código completo
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers
  ...label, fontSize: pt(7.2), letterSpacing: pt(1.3), color: col('muted'),
  placement: at('page', edge, x, 12.5), ...extra,
});
const folio = { color: col('brick'), fontWeight: 700 };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer,
    { ...folio, align: 'left' }),
  head('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 8, { align: 'left' }),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 8),
    { align: 'right' }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer,
    { ...folio, align: 'right' }),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'bottom', 0,
  { ...folio, align: 'center', placement: at('page', 'bottom', 0, -12) }), pages: 'opener' }] };

La cabecera de la página impar imprime {chapterTitle}, y el título de un índice es de primer nivel como el de cualquier capítulo, así que las páginas que lo siguen dicen Índice de materias arriba. pages: 'body' quita las cabeceras de las dos aperturas de índice, que llevan un folio al pie.

La receta completa

Sandbox
// ═══ Postext Cookbook · Nº 073 · An index of names and an index of subjects ═══════
// https://postext.dev/en/cookbook/names-and-subjects-indexes
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Crimson Pro, Bodoni Moda, Libre Franklin (SIL OFL 1.1) · Needs postext ≥ 1.7.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache } from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'names-and-subjects-indexes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a warm black, one brick red for kickers, letter heads and folios
const palette = {
  ink: '#211d19', // text: a warm near-black
  brick: '#8e3b24', // the one accent
  muted: '#6d645a', // running heads, colophon
  rule: '#c8bba9', // hairlines
  paper: '#fcfaf5',
};
// 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).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to main-color: point it at the accent, never the default blue.
  { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.brick, model: 'hex' } },
];
// #endregion
const TRIM = { width: 155, height: 235 }; // a scholarly monograph
const MARGIN = { top: 22, bottom: 24, inner: 19, outer: 22 }; // mirrored: 114 mm measure
const LEAD = 13.6; // body leading in pt
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const label = { fontFamily: 'Libre Franklin', fontWeight: 600, textTransform: 'uppercase' };

// #region answer: two indexes from one text, each under a two-column heading style
// Marks in the Markdown. A person goes to the names index, inverted:
//   :index[Aldus Manutius]{term="Manutius, Aldus" index="names" main}
// A subject goes to the main index, with levels split by "!" and a sort key when the
// printed form would file in the wrong place:
//   :index[textura]{term="Blackletter!textura" main}
//   :index{term="42-line Bible" sort="Forty-two-line Bible"}
//   :index{term="Cursive" see="Italic"}
// Printed at the end, each under its own heading:
//   # Index of Names {style="index"}    then    :::index{index="names"}
//   # Index of Subjects {style="index"} then    :::index
const indexHeading = {
  id: 'index',
  numbered: false, // no chapter number, and the next chapter is still chapter 5
  breakBefore: { enabled: true, parity: 'any' },
  layout: { layoutType: 'double', gutterWidth: mm(7) }, // the section after the heading
};
const index = {
  fontSize: pt(8.6), lineHeight: pt(11.2), // one leading for entries and letter heads
  indent: em(1), turnoverIndent: em(2),
  rangeFormat: 'chicago', // 212–14, as a monograph prints it
  main: { bold: true }, // the page of the principal discussion
  groups: { fontFamily: 'Bodoni Moda', fontSize: pt(11), fontWeight: 600, color: col('brick') },
};
// #endregion

// #region opener: kicker, a Bodoni italic title and a short rule, sunk to the same line
const opener = (sink, size) => ({
  enabled: true,
  minHeight: pt(LEAD * sink), // the text starts on the same grid line in every chapter
  slot: { elements: [
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(7.8),
      letterSpacing: pt(1.6), color: col('brick'), align: 'left',
      placement: at('container', 'top-left', 0, 10) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Bodoni Moda', italic: true,
      fontSize: pt(size), lineHeight: 1.08, color: col('ink'), align: 'left',
      overflow: 'wrap', // design text ends in an ellipsis by default
      placement: { ...at('#kicker', 'below', 0, 3), size: { width: 'fill', height: 'auto' } } },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.75), color: col('brick'),
      placement: { ...at('#title', 'below', 0, 4.5), size: { width: mm(16) } } },
  ] },
});
// #endregion

// #region running-heads: book title on the verso, chapter or index on the recto
const head = (id, content, parity, edge, x, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', // never on openers
  ...label, fontSize: pt(7.2), letterSpacing: pt(1.3), color: col('muted'),
  placement: at('page', edge, x, 12.5), ...extra,
});
const folio = { color: col('brick'), fontWeight: 700 };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer,
    { ...folio, align: 'left' }),
  head('verso-title', '{title}', 'even', 'top-left', MARGIN.outer + 8, { align: 'left' }),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(MARGIN.outer + 8),
    { align: 'right' }),
  head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer,
    { ...folio, align: 'right' }),
] };
// Openers carry a drop folio instead, centred under the text block.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', 'bottom', 0,
  { ...folio, align: 'center', placement: at('page', 'bottom', 0, -12) }), pages: 'opener' }] };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  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 } }, // left = inner
  layout: { layoutType: 'single' },
  bodyText: { fontFamily: 'Crimson Pro', fontSize: pt(10.6), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true },
  headings: { fontFamily: 'Bodoni Moda', fontWeight: 400, color: col('ink'), levels: [
    // Restated on purpose: any headings object drops the H1 break
    // (gotcha: headings-drop-h1-break).
    { level: 1, advancedDesign: opener(9, 26), marginBottom: pt(0),
      breakBefore: { enabled: true, parity: 'odd' } },
    { level: 2, fontSize: pt(12), lineHeight: pt(LEAD), italic: true, fontWeight: 400,
      color: col('brick'), marginTop: pt(LEAD), marginBottom: pt(LEAD * 0.5) },
  ] },
  // The index heading: a shallower opener over the two columns of entries.
  headingStyles: [{ ...indexHeading, span: 'page', advancedDesign: opener(6, 20),
    marginBottom: pt(0) }],
  index,
  paragraphStyles: [
    { id: 'colophon', ...label, textTransform: 'none', fontWeight: 400, fontSize: pt(7),
      lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
      marginTop: pt(LEAD * 2) },
  ],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 71 líneas · content.es.mdtitle: "Letras de metal" subtitle: "Breve historia de los tipos de imprenta" --- # De la textura a Bodoni {kicker="Capítulo cuarto"} Los primeros tipos europeos copiaron la letra en la que más confiaban los lectores. Cuando :index[Johannes Gutenberg]{term="Gutenberg, Johannes" index="names" main} terminó en Maguncia su Biblia, hacia 1455,:index{term="Biblia de 42 líneas" main} la compuso en :index[textura]{term="Letra gótica!textura" main}, la gótica alta y estrecha de los libros de coro alemanes, de trazos quebrados y pies en rombo. La página quería pasar por un manuscrito, y lo consiguió: los rubricadores pintaban a mano las iniciales rojas, y algunos ejemplares se tiraron en vitela. La novedad no se veía. Cada letra se había tallado en acero como :index[punzón]{term="Punzones" main}:index{term="Punzones" seealso="Matrices"}, se había hundido en una barra de cobre, más blando, para obtener la :index[matriz]{term="Matrices"}, y se había fundido en el :index[molde de mano]{term="Molde de mano"} con una aleación de plomo, estaño y antimonio.:index{term="Metal de imprenta"} Un solo punzón daba miles de letras idénticas, y toda la cadena, del punzón a la página, duró casi cuatrocientos años con pocos cambios. Los socios de Gutenberg vieron el negocio antes que él. :index[Johann Fust]{term="Fust, Johann" index="names"} se quedó con el taller tras un pleito en 1455 y, con :index[Peter Schoeffer]{term="Schoeffer, Peter" index="names"}, imprimió el Salterio de Maguncia de 1457, el primer libro con colofón de impresor y fecha. Cuando Maguncia fue saqueada en 1462 sus oficiales se dispersaron, y veinte años después había prensas en más de cien ciudades. ## La romana veneciana Los lectores italianos no querían letras alemanas. Los humanistas de Florencia habían recuperado la minúscula carolina para copiar a los clásicos, una letra redonda y abierta que creían antigua, y los impresores que bajaron al sur los siguieron. La mejor de las primeras romanas la talló en Venecia :index[Nicolas Jenson]{term="Jenson, Nicolas" index="names" main}, un francés formado en la ceca de Tours. Su Eusebio de 1470 está compuesto en una :index[redonda]{term="Letra romana!veneciana" main} de color parejo y mayúsculas anchas y bien espaciadas, y desde entonces ha servido de modelo a todas las recuperaciones. Veinticinco años después :index[Aldo Manucio]{term="Manucio, Aldo" index="names" main} abrió su imprenta en la misma ciudad con un programa de erudito: primero los textos griegos, luego los autores latinos en ediciones que un estudiante pudiera llevar consigo. Su grabador de punzones era el boloñés :index[Francesco Griffo]{term="Griffo, Francesco" index="names" main}. Para el breve diálogo *De Aetna* del joven :index[Pietro Bembo]{term="Bembo, Pietro" index="names"}, impreso en 1496,:index{term="*De Aetna*"} Griffo talló una romana más ligera y regular que la de Jenson, con las mayúsculas más bajas que los trazos ascendentes, la proporción que los tipos de libro conservan hasta hoy.:index{term="Letra romana!aldina"} En 1501 Aldo publicó un :index[Virgilio]{term="Virgilio" index="names"} en octavo compuesto entero en una letra nueva: la :index[cursiva]{term="Cursiva" main} de Griffo, dibujada sobre la escritura inclinada de la cancillería papal. Ahorraba espacio, lo que convenía a un libro de bolsillo, y parecía docta. El Senado de Venecia concedió a Aldo un privilegio sobre ella,:index{term="Privilegios"} los falsificadores de Lyon la copiaron antes de un año, y Aldo y Griffo riñeron por el mérito hasta que Griffo se marchó de Venecia. Durante un siglo la cursiva fue letra de texto por derecho propio. Solo más tarde los impresores la emparejaron con una redonda y la reservaron para el énfasis, los títulos y las palabras extranjeras, el oficio que aún tiene. :index{term="Itálica" see="Cursiva"} :index{term="Bastardilla" see="Cursiva"} ## París y Amberes La romana se hizo francesa en la década de 1530. El impresor parisino :index[Robert Estienne]{term="Estienne, Robert" index="names"} y su círculo querían letras aldinas, y quien mejor las talló fue :index[Claude Garamond]{term="Garamond, Claude" index="names" main}, grabador de punzones que trabajaba sobre todo para prensas ajenas. Sus romanas afinan el modelo de Griffo: los trazos finos son más finos, los remates más agudos, las mayúsculas más ligeras frente a la caja baja. Entre 1540 y 1550 talló además los *grecs du roi*,:index{term="Letra griega"} tres cuerpos de griego para Francisco I, calcados de la mano del copista cretense Ángel Vergecio. A la muerte de Garamond, en 1561, se vendieron sus punzones, y muchos acabaron en Amberes. :index[Christophe Plantin]{term="Plantin, Christophe" index="names" main}, encuadernador nacido en Turena, había levantado allí la mayor imprenta de Europa, y compraba letra como un banquero compra deuda. Su casa guardaba punzones y matrices de Garamond, de :index[Robert Granjon]{term="Granjon, Robert" index="names" main} y de Guillaume Le Bé, y los necesitó todos para la Biblia Políglota de 1568-1572, que dirigió Benito Arias Montano.:index{term="Arias Montano, Benito" index="names"} Granjon fue el mejor tallista de cursivas del siglo; en Lyon, en 1557, grabó además la *civilité*, una letra de secretario francesa que esperaba convertir en tipo nacional. No lo consiguió, pero sus cursivas sí triunfaron. La casa siguió en manos de los Moretus hasta que la ciudad de Amberes la compró en 1876, y allí siguen sus punzones, sus matrices y sus libros de cuentas: la fundición de su época que ha llegado más completa hasta nosotros.:index{term="Fundiciones!Plantin-Moretus"} ## El siglo inglés Inglaterra imprimió con letra holandesa hasta el siglo XVIII, cuando un grabador de llaves de arcabuz abrió una fundición en Londres. :index[William Caslon]{term="Caslon, William" index="names" main} publicó su hoja de muestras en 1734,:index{term="Muestrarios!Caslon, 1734"} y su romana, recia y algo irregular, se convirtió en la letra corriente del mundo de habla inglesa. No pedía explicaciones ni llamaba la atención, y por eso los impresores siguen echando mano de ella. :index[John Baskerville]{term="Baskerville, John" index="names" main}, maestro de caligrafía de Birmingham enriquecido con la loza lacada, llegó tarde a la imprenta y cambió algo más que las letras. Para su Virgilio de 1757 hizo tallar un tipo propio a John Handy, de eje más vertical y más contraste que el de Caslon, y lo imprimió sobre papel avitelado,:index{term="Papel avitelado"} con una tinta más negra de receta propia, y satinó los pliegos entre planchas de cobre calientes. A los impresores ingleses las páginas les parecieron demasiado brillantes para leerlas. Los lectores del continente las admiraron, y nació una letra :index[de transición]{term="Letra de transición"} entre la antigua y la moderna. ## Medir la letra Hasta mediados del siglo XVIII cada fundición llamaba a sus cuerpos como quería: *atanasia*, *lectura*, *entredós*, *breviario*. Un cuerpo era una costumbre de la casa, no una medida, y el impresor que compraba letra a dos fundiciones no podía componerlas juntas. :index[Pierre-Simon Fournier]{term="Fournier, Pierre-Simon" index="names" main}, el hijo menor de una familia parisina de fundidores, publicó en 1737 una tabla de proporciones que dividía la pulgada en setenta y dos :index[puntos]{term="Punto tipográfico" main range="start"} y daba a cada cuerpo un número de puntos. La expuso por extenso en su *Manuel typographique* de 1764-1766.:index{term="*Manuel typographique*"} :index[François-Ambroise Didot]{term="Didot, François-Ambroise" index="names"} ató el punto al pie de rey hacia 1783, lo que lo hizo algo mayor; el punto Didot se extendió por el continente, y los fundidores de Estados Unidos y Gran Bretaña no fijaron su propio punto hasta 1886 y 1898.:index{term="Punto tipográfico" range="end"} ## Los tipos españoles A comienzos del siglo XVIII los impresores españoles compraban la letra en Holanda y en Francia, y la Corona lo tenía por una vergüenza en el reino que había impreso la Políglota Complutense. :index[Carlos III]{term="Carlos III, rey de España" index="names"} y sus ministros se propusieron cambiarlo. La Real Biblioteca de Madrid abrió fundición propia,:index{term="Fundiciones!Real Biblioteca, Madrid" main} y la Corona pensionó a los grabadores de punzones que quisieran trabajar en España. Dos de ellos venían de oficios muy distintos. :index[Eudald Pradell]{term="Pradell, Eudald" index="names" main}, hijo de un armero de Ripoll, aprendió a trabajar el acero en la fragua de su padre y talló sus primeros punzones en Barcelona. Llamado a Madrid en 1764 con una pensión real, grabó romanas y cursivas de dibujo abierto y generoso que los impresores madrileños adoptaron enseguida. :index[Jerónimo Antonio Gil]{term="Gil, Jerónimo Antonio" index="names" main} venía del otro extremo del oficio: era grabador de la Real Casa de la Moneda y medallista, y talló para la Real Biblioteca una serie de romanas, cursivas y griegas que la Biblioteca mostró en su muestrario de 1787.:index{term="Muestrarios!Real Biblioteca, 1787"} Gil no llegó a verlo. En 1778 partió a México como grabador mayor de la Casa de Moneda, y allí fundó la escuela que se convertiría en la Academia de San Carlos. El impresor que dio fama a estos tipos fue :index[Joaquín Ibarra]{term="Ibarra, Joaquín" index="names" main}, nacido en Zaragoza en 1725 e impresor de cámara del Rey desde 1779. Su Salustio de 1772, traducido por el infante don Gabriel,:index{term="Gabriel de Borbón, infante" index="names"} fue elogiado en París y en Parma por la calidad de la tirada. Para la Real Academia Española imprimió en 1780 el *Quijote* en cuatro volúmenes en cuarto,:index{term="*Quijote*, edición de la Academia" main}:index{term="Cervantes, Miguel de" index="names"} con los tipos de Gil, láminas de los mejores grabadores de la corte y papel hecho para la ocasión en Cataluña. La tinta, negra y pareja, era del propio Ibarra; la página, de márgenes generosos y sin más adorno que las láminas, es la réplica española de las de Baskerville. ## Los modernos de Bodoni El último paso lo dio un impresor de una corte pequeña.:index{term="Letra moderna" range="start"} :index[Giambattista Bodoni]{term="Bodoni, Giambattista" index="names" main}, nacido en Saluzzo en 1740, aprendió a tallar punzones en la imprenta de Propaganda Fide, en Roma, donde limpió y ordenó sus tipos orientales. En 1768 el duque Fernando lo llamó a Parma para dirigir la imprenta ducal, y allí se quedó el resto de su vida. Sus primeros tipos siguen a Fournier; en la década de 1790 tallaba ya letras de otra especie, con remates finísimos, eje estrictamente vertical y un contraste entre gruesos y finos que solo el papel liso y una tirada cuidadosa podían sostener. Son las letras :index[modernas]{term="Letra moderna" main}. En París, :index[Firmin Didot]{term="Didot, Firmin" index="names"} llegaba a las mismas conclusiones al mismo tiempo, y las dos casas se vigilaban de cerca.:index{term="Didona" see="Letra moderna"} Bodoni imprimió pocos libros que alguien leyera y muchos que todos admiraban: Homero en griego, Horacio, Tasso, folio tras folio para coleccionistas y reyes. La obra de su vida fue el :index[*Manuale tipografico*]{term="*Manuale tipografico*" main}, que su viuda, :index[Margherita Dall’Aglio]{term="Dall’Aglio, Margherita" index="names"}, publicó en 1818, cinco años después de su muerte: dos volúmenes de romanas y cursivas en más de un centenar de cuerpos y cortes, con griego, hebreo y otros alfabetos, y un prefacio que dice cómo debe ser una letra. La letra moderna conquistó el continente y después Inglaterra, donde hacia 1820 apenas quedaba impresor que compusiera libros en letra antigua.:index{term="Letra moderna" range="end"} ## Nombres para las familias Las palabras que ha usado este capítulo, :index[antigua]{term="Letra antigua" main}, de transición y moderna, las pusieron en circulación los impresores del siglo XIX, que necesitaban ordenar sus muestrarios, y no son precisas. En 1860 una antigua era cualquier letra tallada sobre el modelo de Caslon, y una moderna, cualquier cosa posterior a Bodoni, por torpe que fuera. La antigua volvió en la década de 1840, cuando :index[Charles Whittingham]{term="Whittingham, Charles" index="names"}, de la Chiswick Press, recuperó los tipos de Caslon para un libro compuesto a la manera del siglo XVII, y desde entonces las dos convivieron en todos los muestrarios. En 1921 el fundidor francés :index[Francis Thibaudeau]{term="Thibaudeau, Francis" index="names"} propuso clasificar los tipos solo por sus remates: triangulares, filiformes, cuadrados o ninguno.:index{term="Clasificación de los tipos!Thibaudeau, 1921"} Era fácil de aplicar y ciega a la historia. :index[Maximilien Vox]{term="Vox, Maximilien" index="names"}, ilustrador y editor, fue más lejos en 1954 con nueve familias cuyos nombres unen a los maestros de cada estilo: *humanas* para los venecianos que siguieron a Jenson, *garaldas* para Garamond y Aldo, *reales* para las letras de Fournier y Baskerville, *didonas* para Didot y Bodoni.:index{term="Clasificación de los tipos!Vox, 1954" main} La Association Typographique Internationale adoptó una versión en 1962, y la British Standards Institution la siguió en 1967. :index{term="Garalda" see="Letra antigua"} :index{term="Humana, letra" see="Letra romana!veneciana"} Estas etiquetas dejan fuera tanto como ordenan. Las romanas de Gil para la Real Biblioteca son *reales* según el manual y españolas según cualquier otra medida; las cursivas de Pradell deben tanto a Granjon como a cualquier letra tallada en su propio siglo. La disputa entre las dos ideas de la letra, la que se escribe con pluma y la que se dibuja con compás y regla, es más antigua que todos estos nombres y sigue sin resolverse. :::paragraphs{style="colophon"} *Letras de metal*, capítulo cuarto. Compuesto en Crimson Pro, con Bodoni Moda en los títulos y Libre Franklin en los rótulos (SIL Open Font License). Texto escrito para el Recetario de Postext, CC BY 4.0. ::: # Índice onomástico {style="index" kicker="Impresores, grabadores, mecenas"} :::index{index="names"} # Índice de materias {style="index" kicker="Tipos, útiles y libros"} :::index
`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). const FONTS = { 'Crimson Pro': ['400', '400i', '600', '700'], 'Bodoni Moda': ['400', '400i', '600'], 'Libre Franklin': ['400', '400i', '600', '700'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); // #region build: one buildDocument, laid out again until the index's page numbers settle // A pass finds the page of every mark and prints both indexes with those numbers. When the // printed index moves a mark (an index set before the text would), the layout runs again, // until no page number changes (in a buildBundle book, the chapter with :::index gets them all). const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); // #endregion showPages(doc, { title: t({ en: 'Two indexes', es: 'Dos índices' }) }); 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

#Imprime un único índice

Quita index="names" de las marcas de personas y el segundo título: todas las marcas van entonces al índice principal, nombres y materias en un solo alfabeto.

-:index[Aldo Manucio]{term="Manucio, Aldo" index="names" main}
+:index[Aldo Manucio]{term="Manucio, Aldo" main}

#Quita las letras de grupo

Un onomástico tan corto se lee bien sin ellas. groups.enabled: false las quita y deja una línea de espacio entre letras; el ajuste vale para los dos índices.

-  groups: { fontFamily: 'Bodoni Moda', fontSize: pt(11), fontWeight: 600, color: col('brick') },
+  groups: { enabled: false },

Errores frecuentes

Error frecuente

Cualquier objeto headings desactiva el salto de página del H1

Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →

Error frecuente

Carga todas las fuentes antes de componer

La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →

Error frecuente

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

El desbordamiento del texto de diseño es 'ellipsis-end' por defecto

Un elemento de texto de diseño que no cabe en su ancho termina en puntos suspensivos por defecto. Pon overflow: 'wrap' en los títulos que deban pasar a más líneas. Textos, filetes y cajas en los diseños de página →

Error frecuente

Solo 8 idiomas tienen separación silábica, con el código exacto

La separación silábica existe para en-us, es, fr, de, it, pt, ca y nl, con el código exacto: 'es-ES' o cualquier otro idioma pasa sin aviso al inglés americano. Separación silábica e idioma del documento →

Error frecuente

Una configuración se cachea por identidad: crea un objeto nuevo

El motor guarda en caché las configuraciones resueltas según la identidad del objeto, así que modificar el mismo objeto y volver a componer reutiliza el resultado anterior. Crea un objeto nuevo en cada composición: por eso la configuración de una receta es una función, config(). Páginas en un canvas →

  • La configuración index es común: los dos índices comparten letra, interlineado y letras de grupo.
  • Una marca en un pie de figura o en una celda de tabla se imprime tal cual. Marca a la persona en el párrafo que cita la figura.

Créditos

Texto
  • «De la textura a Bodoni», ensayo sobre la historia de los tipos de imprenta escrito para esta receta, en inglés y en español · Ignacio Ferro · CC BY 4.0
Fuentes
Crimson Pro (SIL OFL 1.1) · Bodoni Moda (SIL OFL 1.1) · Libre Franklin (SIL OFL 1.1)
SandboxPDF