Lo que vas a componer
Las siete primeras páginas de Lisboa a pie, guía de bolsillo de 120 × 200 mm. La cubierta es una pared de azulejos azules y blancos con el título en una placa enmarcada como un rótulo de calle lisboeta, y la página 2 presenta los tres paseos con un plano de sus rutas. Cada paseo tiene un color, terracota para Alfama y amarillo para la Baixa, y una pestaña en el corte lleva ese color y su número a la misma altura en cada página del paseo. Con el paseo cambian también la banda de la apertura, las paradas, la cabecera de la página impar y el tono del recuadro «No te lo pierdas». La página par en blanco que empuja el paseo 2 a una impar va pintada de su amarillo y lleva su número. El texto es Albert Sans en bandera; los nombres, DM Serif Display, y los rótulos, Asap Condensed.
Esta receta responde a
- ¿Cómo oculto las cabeceras en las aperturas y las páginas en blanco, o pinto una página par en blanco con el color de la parte?
- ¿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 añado una pestaña numerada («RECUADRO 1-1»), un icono de esquina o un icono al margen con un filete?
- ¿Cómo añado muestras de color como leyenda en el texto, los pies o las notas de tabla?
La respuesta corta
// '# Alfama {style="alfama"}' opens a section that runs to the next level-1 heading. Its
// palette gives band, bandInk, onBand and wash the walk's values on the section's pages,
// in the page furniture and in the text alike.
const walkStyles = Object.keys(WALKS).map((id) => ({ id, palette: WALKS[id] }));
// The tab has one size and one place on every page, hanging from the foot of the opener
// band; only its colour and its number change from walk to walk.
const TAB = { y: BAND, width: 7, height: 24 }; // mm
const tab = (parity, edge) => text(`tab-${parity}`, '{chapterNumber}', {
...label, fontSize: pt(11), fontWeight: 700, color: col('onBand'), align: 'center',
verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('band') },
parity, pages: 'all', // the walk's first page is an 'opener' page: the tab goes there too
}, { ...at('bleed', edge, 0, TAB.y), ...size(mm(TAB.width), mm(TAB.height)) });
const tabs = [tab('odd', 'top-right'), tab('even', 'top-left')]; // on the fore-edge
// The blank verso that pushes a walk onto a recto belongs to that walk (gotcha:
// section-last-wins), so the field takes the next walk's colour and its numeral the number.
const blankPage = [
box('field', { backgroundColor: col('band') },
{ ...at('bleed', 'top-left', 0, 0), ...size('fill', 'fill') }),
tileImage('field-tiles', 'tiles-page', 'top-left', 'fill', 'auto'),
text('field-number', '{chapterNumber}', { ...display, fontSize: pt(220), lineHeight: 1,
color: col('onBand'), overflow: 'clip' }, at('page', 'bottom-left', MARGIN.outer, -24)),
].map((el) => ({ ...el, pages: 'blank' }));
Ingredientes
- Funciones
- Bandas a sangre y pestañasColores por parteCabeceras según el tipo de páginaCabeceras por secciónEstilos de títuloAperturas diseñadasAtributos de títuloTítulos numeradosCapítulos que abren en página imparCabeceras y foliosPaleta de color semánticaChips en líneaIconos y distintivos de esquinaMuestras de colorFiguras justo aquíTipos de recurso propiosImágenes en los diseños de páginaTextos, filetes y cajas en los diseños de páginaCubiertas, portadas y colofonesPáginas en un canvas
- También usa
- RecuadrosEquilibrado de columnasBanda de capítulo a todo el anchoColor del papelSaltos de página y de columnaEstilos de párrafoFiguras y tablas como recursosSaltos de línea en los títulosCapítulos sin número
- Tipografía
- Albert Sans, DM Serif Display, Asap Condensed (SIL OFL 1.1)
- Recursos
- Los azulejos, el plano de las rutas y sus pines, dibujados en código (Ignacio Ferro, CC BY 4.0)
Elaboración
#1 · Nombra los colores que cambia un paseo
const palette = {
ink: '#1b2430', // text: a blue-black
paper: '#fffdf8', // the page
band: '#1f4e9c', // the section colour: tile blue until a walk replaces it
bandInk: '#1d4a94', // the section colour for type on paper
onBand: '#ffffff', // type set on the band
wash: '#e8eef8', // the section's box tint
muted: '#5e6875', // running heads, notes, chip outlines
};
// Each walk's values for those four. Baixa yellow is too light for white type, so its
// onBand is the ink. The text is recoloured by value, so the four base values must differ.
const WALKS = {
alfama: { band: '#b9533a', bandInk: '#a1432b', onBand: '#ffffff', wash: '#f7e7e0' },
baixa: { band: '#e2b23d', bandInk: '#7d5a0f', onBand: '#1b2430', wash: '#fbf0d5' },
belem: { band: '#2b7a78', bandInk: '#236866', onBand: '#ffffff', wash: '#e1eeed' },
};
const NAMES = { alfama: 'Alfama', baixa: 'Baixa', belem: 'Belém' };
// A walk's palette follows the paletteId; the hex is written out too, since the document
// palette never reaches design elements (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id] ?? WALKS[id].band, model: 'hex', paletteId: id });
const entry = (id, hex) => ({ id, name: id, value: { hex, model: 'hex' } });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => entry(id, hex)),
...Object.entries(WALKS).map(([id, w]) => entry(id, w.band)), // for :swatch{color="alfama"}
entry('main-color', palette.ink), // the engine's defaults link here: nothing prints blue
];
La palette de un estilo de título sustituye entradas por su id en las páginas de su sección (estilos de encabezado), así que todo color que deba seguir al paseo se enlaza a una de cuatro entradas: band para los campos de color, la pestaña y los círculos de las paradas; bandInk para el texto sobre papel; onBand para el texto sobre un campo, y wash para el recuadro. El blanco sobre el amarillo de la Baixa da un contraste de 2:1, así que el onBand de ese paseo es la tinta, con 8:1. Los elementos de diseño siguen el id, mientras que el texto se recolorea por valor: un color igual al hex base de una entrada toma el valor del paseo, y por eso los cuatro valores base son distintos. El título del recuadro en la página 4 y la línea que te manda al paseo 2 en la página 5 son texto en bandInk, y los dos salen en terracota. col() escribe el hex junto al id porque en 1.4.1 la paleta del documento no llega nunca a los elementos de diseño.
#2 · Deja las cabeceras en las páginas de texto
const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, {
...label, fontSize: pt(7.8), letterSpacing: pt(1.3), color: col('muted'), overflow: 'clip',
...style }, placement), parity, pages: 'body' }); // never on openers or blank pages
const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('ink') };
// Anchored to the page: from its top-right corner a negative x runs inwards
// (gotcha: negative-offsets).
const heads = [
head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio),
head('verso-title', '{title}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)),
head('recto-title', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber} · {chapterTitle}`, 'odd',
at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { color: col('bandInk') }),
head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), folio),
];
// Array order is paint order: the blank page's field comes last and covers the tab there.
const header = { elements: [...heads, ...tabs, ...blankPage] };
// A walk's first page has no running head: its folio drops to the foot.
const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all',
at('container', 'top', 0, 7), folio), pages: 'opener', align: 'center' }] };
// Page 2 comes before Walk 1, where {chapterNumber} is empty, so its header has no tabs. With the
// level's break off (gotcha: style-inherits-break) and in the column, it is a 'body' page.
const introStyle = { id: 'intro', numbered: false, header: { elements: heads },
span: 'column', breakBefore: { enabled: false }, advancedDesign: { enabled: false },
fontSize: pt(24), lineHeight: pt(2 * LEAD), marginBottom: pt(LEAD / 2) };
Tras la composición, cada página recibe una función (elementos de texto). La primera página de un paseo es opener, porque su título fuerza un salto de página; la página de relleno antes de una impar es blank, y las demás son body. Las cabeceras llevan pages: 'body', así que no salen en las aperturas de las páginas 3 y 7 ni en la página en blanco 6, y el folio al pie lleva 'opener'. La página par imprime el {title} del frontmatter, y la impar, Paseo {chapterNumber} · {chapterTitle} en bandInk, terracota en la página 5. Las pestañas de la respuesta corta llevan 'all'. El campo de la página en blanco va después en la lista, y como el orden de la lista es el orden de pintado, tapa la pestaña en la página 6. La introducción de la página 2 va antes del paseo 1 y allí {chapterNumber} está vacío, así que su estilo lleva un header propio con las cuatro cabeceras y sin pestañas. Sin él, la página 2 imprimiría una pestaña azul vacía.
#3 · Abre cada paseo bajo su banda y numera sus paradas
const walkLevel = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // a recto
span: 'page', // kept in the column, the band would be cut at the top margin, 18 mm down
// The band ends 30 mm into the text area; a floor of 8 grid lines, with nothing added
// under it, starts the text 7.3 mm below the band.
marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: pt(8 * LEAD), slot: {
elements: [
box('band', { backgroundColor: col('band') },
{ ...at('bleed', 'top-left', 0, 0), ...size('fill', mm(BAND)) }),
tileImage('tiles', 'tiles-band', 'top-right', 'auto', mm(BAND)),
text('kicker', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber}`, { ...label,
fontSize: pt(9), letterSpacing: pt(2), color: col('onBand') },
at('page', 'top-left', MARGIN.inner, 17)), // walks open on rectos: inner is left
text('title', '{titleText}', { ...display, fontSize: pt(34), lineHeight: 1,
color: col('onBand') }, { ...at('#kicker', 'below', 0, 1), ...size(mm(70), 'auto') }),
text('route', '{attr.route}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10),
color: col('onBand') }, { ...at('#title', 'below', 0, 1.5), ...size(mm(70), 'auto') }),
] } } };
const STOP = 5.4; // mm: the diameter of a stop's circle
const stopLevel = { level: 3, numberingTemplate: '{3}', // restarts at every walk
// A grid line above a stop and no margin under it, so the circle can sit 3.2 mm down its
// two grid lines: about 8.3 mm of white above a stop and 2.1 under it, 6.4 between paragraphs.
marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: { elements: [
text('number', '{number}', { ...label, fontSize: pt(9), fontWeight: 700, align: 'center',
verticalAlign: 'middle', color: col('onBand'), overflow: 'clip',
box: { backgroundColor: col('band'), borderRadius: mm(STOP / 2) } },
{ ...at('container', 'top-left', 0, 3.2), ...size(mm(STOP), mm(STOP)) }),
text('name', '{titleText}', { ...display, fontSize: pt(13.5), lineHeight: 1.1,
color: col('ink') }, { ...at('#number', 'right-of', 2.4, 0.2), ...size('fill', 'auto') }),
] } } };
La banda se ancla a la sangre, que aquí es el corte, y entra 30 mm en el área de texto. Para eso el nivel lleva span: 'page', porque en 1.4.1 un diseño que se queda en la columna se recorta en el margen superior y la banda empezaría 18 mm más abajo. El diseño termina con la banda, y minHeight reserva ocho líneas de la rejilla base, 37,3 mm; la apertura ocupa la mayor de las dos alturas, así que en las páginas 3 y 7 el texto empieza 7,3 mm bajo la banda. Cada parada es un título de nivel 3 cuyo diseño pone {number} en un círculo de 5,4 mm relleno de band. La plantilla {3} vuelve a empezar en cada paseo, porque un título de nivel 1 reinicia los contadores inferiores, y los pines del plano llevan los mismos números. Una parada lleva una línea de rejilla de margen encima y ninguna debajo, y su círculo baja 3,2 mm dentro de las dos líneas que ocupa el título. Quedan unos 8,3 mm de blanco sobre el círculo y 2,1 mm bajo él; entre párrafos hay 6,4 mm.
#4 · Cuelga un azulejo del recuadro y deja los chips en tinta
const calloutStyles = [{ id: 'dontmiss', title: t({ en: 'Don’t miss', es: 'No te lo pierdas' }),
background: col('wash'), // one device: the walk's tint
// The tile hangs half off the box's outer side, flush with its top: the left side on a verso.
// With 6.5 mm of padding on both sides the title stays in line with the box's text there too.
// The tile's colours are drawn into the SVG, so it stays tile blue in every walk.
padding: { top: mm(3.4), right: mm(6.5), bottom: mm(3.4), left: mm(6.5) },
icon: { kind: 'resource', resourceId: 'tile', size: mm(10), position: 'corner',
cornerSide: 'outer' },
titleStyle: { ...label, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.5),
color: col('bandInk') },
body: { fontSize: pt(9), lineHeight: pt(12.4), color: col('ink') } }];
// A walk's palette does not reach chips: one linked to 'band' would stay tile blue in every
// walk, so the facts are ink on paper (gotcha: section-palette-skips-chips).
const chipStyles = [{ id: 'fact', background: col('paper'), borderColor: col('muted'),
borderWidth: pt(0.6), borderRadius: mm(0.8), paddingX: mm(1.4), paddingY: mm(0.5),
fontFamily: 'Asap Condensed', fontSize: em(0.92), color: col('ink'), bold: true }];
El recuadro solo lleva el tono claro del paseo, sin filete ni borde, y un azulejo dibujado en código en su esquina (estilos de aviso). cornerSide: 'outer' cuelga el azulejo medio fuera del lado exterior del recuadro, a ras de su borde superior; en la página 4, par, es el lado izquierdo, donde empieza el título. Con 6,5 mm de relleno a los dos lados, el título libra el azulejo y se alinea con el texto del recuadro en cualquiera de las dos páginas. La paleta del paseo cambia el tono y el título, pero el azulejo sigue azul, porque sus colores van dibujados en el SVG. La paleta de un paseo no llega a los chips en 1.4.1: enlazados a band, los datos del paseo seguirían en azul de azulejo en los dos paseos. Por eso los chips van en tinta sobre papel, con un contorno gris en muted, iguales en todos los paseos.
#5 · Haz la leyenda del plano con muestras de color
const map = svg('routes', MAP.width, MAP.height, { placement: { position: 'here' }, ...t({ en: {
caption: 'The three walks. The numbered pins are the stops in the text.',
note: ':swatch{color="alfama"} Walk 1, Alfama · :swatch{color="baixa"} Walk 2, Baixa · '
+ ':swatch{color="belem"} Walk 3, Belém, 6 km west (inset)',
altText: 'Map of central Lisbon north of the river: the yellow route runs from the '
+ 'waterfront up the Baixa grid to Rossio; the terracotta route climbs east from the '
+ 'cathedral through Alfama. An inset shows the teal Belém route.',
}, es: {
caption: 'Los tres paseos. Los pines numerados son las paradas del texto.',
note: ':swatch{color="alfama"} Paseo 1, Alfama · :swatch{color="baixa"} Paseo 2, Baixa · '
+ ':swatch{color="belem"} Paseo 3, Belém, 6 km al oeste (en el detalle)',
altText: 'Plano del centro de Lisboa al norte del río: la ruta amarilla sube desde el '
+ 'muelle por la cuadrícula de la Baixa hasta el Rossio; la terracota sube hacia el este '
+ 'desde la catedral por Alfama. En un detalle, la ruta verde azulada de Belém.',
} }) });
El plano es un recurso SVG que va donde está ::resource{id="routes"}, con un tipo de recurso propio para que su pie diga «Plano 1». En la nota, tres muestras de color hacen de leyenda de las rutas: :swatch{color="alfama"} y las de baixa y belem. Una muestra de color acepta el id de una entrada de la paleta (formato en línea). La paleta recibe de WALKS una entrada por paseo, y el dibujo toma los colores de las rutas del mismo objeto, así que la leyenda coincide con las rutas y con las pestañas. Los números de los pines se dibujan con trazos, porque un SVG dibujado como imagen no puede usar las fuentes web de la página.
La receta completa
// ═══ Postext Cookbook · Nº 047 · Walking guide with thumb tabs ═══════════════════ // https://postext.dev/en/cookbook/walking-guide-thumb-tabs // Code: MIT · Text: original (CC BY 4.0) · Map and tiles: drawn in code (CC BY 4.0) // Fonts: Albert Sans, DM Serif Display, Asap Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A pocket guide to Lisbon. Each walk is a heading style that sets four colours, and the tab, // the stop numbers, the box's tint and title and the blank page before the walk take them. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'walking-guide-thumb-tabs'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: 'band' and three partners are the entries a walk overrides const palette = { ink: '#1b2430', // text: a blue-black paper: '#fffdf8', // the page band: '#1f4e9c', // the section colour: tile blue until a walk replaces it bandInk: '#1d4a94', // the section colour for type on paper onBand: '#ffffff', // type set on the band wash: '#e8eef8', // the section's box tint muted: '#5e6875', // running heads, notes, chip outlines }; // Each walk's values for those four. Baixa yellow is too light for white type, so its // onBand is the ink. The text is recoloured by value, so the four base values must differ. const WALKS = { alfama: { band: '#b9533a', bandInk: '#a1432b', onBand: '#ffffff', wash: '#f7e7e0' }, baixa: { band: '#e2b23d', bandInk: '#7d5a0f', onBand: '#1b2430', wash: '#fbf0d5' }, belem: { band: '#2b7a78', bandInk: '#236866', onBand: '#ffffff', wash: '#e1eeed' }, }; const NAMES = { alfama: 'Alfama', baixa: 'Baixa', belem: 'Belém' }; // A walk's palette follows the paletteId; the hex is written out too, since the document // palette never reaches design elements (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id] ?? WALKS[id].band, model: 'hex', paletteId: id }); const entry = (id, hex) => ({ id, name: id, value: { hex, model: 'hex' } }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => entry(id, hex)), ...Object.entries(WALKS).map(([id, w]) => entry(id, w.band)), // for :swatch{color="alfama"} entry('main-color', palette.ink), // the engine's defaults link here: nothing prints blue ]; // #endregion const TRIM = { width: 120, height: 200 }; // a pocket guide // Mirrored, and tighter than a book's at top and foot: a pocket guide keeps 164 mm of text. const MARGIN = { top: 18, bottom: 18, inner: 14, outer: 16 }; const MEASURE = TRIM.width - MARGIN.inner - MARGIN.outer; // 90 mm, about 55 characters const LEAD = 13.2; // body leading in pt: the baseline grid const BAND = 48; // mm: a walk opener's colour band, from the top edge const HEAD = { y: 10, gap: 7 }; // running heads from the top edge; folio to title (mm) const label = { fontFamily: 'Asap Condensed', fontWeight: 600, textTransform: 'uppercase' }; const display = { fontFamily: 'DM Serif Display', fontWeight: 400 }; const at = (to, edge, x, y) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const size = (width, height) => ({ size: { width, height } }); const text = (id, content, style, placement) => ({ kind: 'text', id, content, overflow: 'wrap', align: 'left', ...style, placement }); const box = (id, style, placement) => ({ kind: 'box', id, style, placement }); const tileImage = (id, resourceId, edge, width, height) => ({ kind: 'image', id, resourceId, placement: { ...at('bleed', edge, 0, 0), ...size(width, height) } }); // #region answer: one tab and one blank-page field, coloured by the walk they belong to // '# Alfama {style="alfama"}' opens a section that runs to the next level-1 heading. Its // palette gives band, bandInk, onBand and wash the walk's values on the section's pages, // in the page furniture and in the text alike. const walkStyles = Object.keys(WALKS).map((id) => ({ id, palette: WALKS[id] })); // The tab has one size and one place on every page, hanging from the foot of the opener // band; only its colour and its number change from walk to walk. const TAB = { y: BAND, width: 7, height: 24 }; // mm const tab = (parity, edge) => text(`tab-${parity}`, '{chapterNumber}', { ...label, fontSize: pt(11), fontWeight: 700, color: col('onBand'), align: 'center', verticalAlign: 'middle', overflow: 'clip', box: { backgroundColor: col('band') }, parity, pages: 'all', // the walk's first page is an 'opener' page: the tab goes there too }, { ...at('bleed', edge, 0, TAB.y), ...size(mm(TAB.width), mm(TAB.height)) }); const tabs = [tab('odd', 'top-right'), tab('even', 'top-left')]; // on the fore-edge // The blank verso that pushes a walk onto a recto belongs to that walk (gotcha: // section-last-wins), so the field takes the next walk's colour and its numeral the number. const blankPage = [ box('field', { backgroundColor: col('band') }, { ...at('bleed', 'top-left', 0, 0), ...size('fill', 'fill') }), tileImage('field-tiles', 'tiles-page', 'top-left', 'fill', 'auto'), text('field-number', '{chapterNumber}', { ...display, fontSize: pt(220), lineHeight: 1, color: col('onBand'), overflow: 'clip' }, at('page', 'bottom-left', MARGIN.outer, -24)), ].map((el) => ({ ...el, pages: 'blank' })); // #endregion // #region running-heads: book title on the verso, walk on the recto, folios outside const head = (id, content, parity, placement, style = {}) => ({ ...text(id, content, { ...label, fontSize: pt(7.8), letterSpacing: pt(1.3), color: col('muted'), overflow: 'clip', ...style }, placement), parity, pages: 'body' }); // never on openers or blank pages const folio = { fontSize: pt(8.5), fontWeight: 700, color: col('ink') }; // Anchored to the page: from its top-right corner a negative x runs inwards // (gotcha: negative-offsets). const heads = [ head('verso-folio', '{pageNumber}', 'even', at('page', 'top-left', MARGIN.outer, HEAD.y), folio), head('verso-title', '{title}', 'even', at('page', 'top-left', MARGIN.outer + HEAD.gap, HEAD.y)), head('recto-title', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber} · {chapterTitle}`, 'odd', at('page', 'top-right', -(MARGIN.outer + HEAD.gap), HEAD.y), { color: col('bandInk') }), head('recto-folio', '{pageNumber}', 'odd', at('page', 'top-right', -MARGIN.outer, HEAD.y), folio), ]; // Array order is paint order: the blank page's field comes last and covers the tab there. const header = { elements: [...heads, ...tabs, ...blankPage] }; // A walk's first page has no running head: its folio drops to the foot. const footer = { elements: [{ ...head('drop-folio', '{pageNumber}', 'all', at('container', 'top', 0, 7), folio), pages: 'opener', align: 'center' }] }; // Page 2 comes before Walk 1, where {chapterNumber} is empty, so its header has no tabs. With the // level's break off (gotcha: style-inherits-break) and in the column, it is a 'body' page. const introStyle = { id: 'intro', numbered: false, header: { elements: heads }, span: 'column', breakBefore: { enabled: false }, advancedDesign: { enabled: false }, fontSize: pt(24), lineHeight: pt(2 * LEAD), marginBottom: pt(LEAD / 2) }; // #endregion // #region levels: a walk opens under a band in its colour; its stops are numbered circles const walkLevel = { level: 1, breakBefore: { enabled: true, parity: 'odd' }, // a recto span: 'page', // kept in the column, the band would be cut at the top margin, 18 mm down // The band ends 30 mm into the text area; a floor of 8 grid lines, with nothing added // under it, starts the text 7.3 mm below the band. marginBottom: pt(0), advancedDesign: { enabled: true, minHeight: pt(8 * LEAD), slot: { elements: [ box('band', { backgroundColor: col('band') }, { ...at('bleed', 'top-left', 0, 0), ...size('fill', mm(BAND)) }), tileImage('tiles', 'tiles-band', 'top-right', 'auto', mm(BAND)), text('kicker', `${t({ en: 'Walk', es: 'Paseo' })} {chapterNumber}`, { ...label, fontSize: pt(9), letterSpacing: pt(2), color: col('onBand') }, at('page', 'top-left', MARGIN.inner, 17)), // walks open on rectos: inner is left text('title', '{titleText}', { ...display, fontSize: pt(34), lineHeight: 1, color: col('onBand') }, { ...at('#kicker', 'below', 0, 1), ...size(mm(70), 'auto') }), text('route', '{attr.route}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10), color: col('onBand') }, { ...at('#title', 'below', 0, 1.5), ...size(mm(70), 'auto') }), ] } } }; const STOP = 5.4; // mm: the diameter of a stop's circle const stopLevel = { level: 3, numberingTemplate: '{3}', // restarts at every walk // A grid line above a stop and no margin under it, so the circle can sit 3.2 mm down its // two grid lines: about 8.3 mm of white above a stop and 2.1 under it, 6.4 between paragraphs. marginTop: pt(LEAD), marginBottom: pt(0), advancedDesign: { enabled: true, slot: { elements: [ text('number', '{number}', { ...label, fontSize: pt(9), fontWeight: 700, align: 'center', verticalAlign: 'middle', color: col('onBand'), overflow: 'clip', box: { backgroundColor: col('band'), borderRadius: mm(STOP / 2) } }, { ...at('container', 'top-left', 0, 3.2), ...size(mm(STOP), mm(STOP)) }), text('name', '{titleText}', { ...display, fontSize: pt(13.5), lineHeight: 1.1, color: col('ink') }, { ...at('#number', 'right-of', 2.4, 0.2), ...size('fill', 'auto') }), ] } } }; // #endregion // The cover: a wall of tiles, the title on a plaque like a Lisbon street sign, the walks' keys. const plaque = size(mm(94), 'auto'); // the width of the texts on the plaque const rim = (id, x, y, w, h, style) => box(id, { borderColor: col('band'), ...style }, { ...at('page', 'top-left', x, y), ...size(mm(w), mm(h)) }); const cover = { enabled: true, slot: { elements: [ tileImage('wall', 'tiles-cover', 'top-left', 'fill', 'auto'), rim('plaque', 13, 88, 94, 62, { backgroundColor: col('paper'), borderWidth: pt(1.6) }), rim('frame', 15.2, 90.2, 89.6, 57.6, { borderWidth: pt(0.5) }), // the inner frame line text('kicker', t({ en: 'A pocket guide', es: 'Guía de bolsillo' }), { ...label, align: 'center', fontSize: pt(8.5), letterSpacing: pt(2), color: col('bandInk') }, // centred text sits left { ...at('page', 'top-left', 13 + 0.35, 96), ...plaque }), // by half its tracking: 1 pt, 0.35 mm text('title', '{titleText}', { ...display, fontSize: pt(46), lineHeight: 0.98, align: 'center', color: col('band') }, { ...at('#kicker', 'below', 0, 2), ...plaque }), text('subtitle', '{subtitle}', { fontFamily: 'Albert Sans', italic: true, fontSize: pt(10.5), color: col('ink'), align: 'center' }, { ...at('#title', 'below', 0, 3), ...plaque }), ...Object.keys(WALKS).flatMap((id, i) => [text(`key-${id}`, String(i + 1), { ...label, fontSize: pt(10), fontWeight: 700, align: 'center', verticalAlign: 'middle', overflow: 'clip', color: col(WALKS[id].onBand === palette.ink ? 'ink' : 'onBand'), box: { backgroundColor: col(id) } }, { ...at('page', 'top-left', 17 + i * 30, 166), ...size(mm(7), mm(7)) }), text(`name-${id}`, NAMES[id], { ...display, fontSize: pt(13), color: col('ink') }, at(`#key-${id}`, 'right-of', 2.2, 0.4))]), ] } }; // #region box-and-chips: a tile on the box's outer corner; chips that stay in ink const calloutStyles = [{ id: 'dontmiss', title: t({ en: 'Don’t miss', es: 'No te lo pierdas' }), background: col('wash'), // one device: the walk's tint // The tile hangs half off the box's outer side, flush with its top: the left side on a verso. // With 6.5 mm of padding on both sides the title stays in line with the box's text there too. // The tile's colours are drawn into the SVG, so it stays tile blue in every walk. padding: { top: mm(3.4), right: mm(6.5), bottom: mm(3.4), left: mm(6.5) }, icon: { kind: 'resource', resourceId: 'tile', size: mm(10), position: 'corner', cornerSide: 'outer' }, titleStyle: { ...label, fontSize: pt(8.5), fontWeight: 700, letterSpacing: pt(1.5), color: col('bandInk') }, body: { fontSize: pt(9), lineHeight: pt(12.4), color: col('ink') } }]; // A walk's palette does not reach chips: one linked to 'band' would stay tile blue in every // walk, so the facts are ink on paper (gotcha: section-palette-skips-chips). const chipStyles = [{ id: 'fact', background: col('paper'), borderColor: col('muted'), borderWidth: pt(0.6), borderRadius: mm(0.8), paddingX: mm(1.4), paddingY: mm(0.5), fontFamily: 'Asap Condensed', fontSize: em(0.92), color: col('ink'), bold: true }]; // #endregion const MAP_WORD = t({ en: 'Map', es: 'Plano' }); // the caption reads 'Map 1' const config = () => ({ // a factory: the engine caches resolved configs per object 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 } }, layout: { layoutType: 'single' }, // Ragged text for short lines of directions. It is never hyphenated (gotcha: // ragged-no-hyphenation) and never checked for runts (gotcha: ragged-runts). bodyText: { fontFamily: 'Albert Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'left', firstLineIndent: mm(0), paragraphSpacing: true }, // No lines added above the stops to fill a page: a guide's pages may end short. headings: { ...display, color: col('ink'), balancing: { enabled: false }, // Any headings object drops the level-1 break: it is stated in walkLevel // (gotcha: headings-drop-h1-break). levels: [walkLevel, stopLevel] }, headingStyles: [ { id: 'cover', numbered: false, advancedDesign: cover, header: { elements: [] }, footer: { elements: [] } }, introStyle, ...walkStyles, ], resourceTypes: [{ id: 'map', name: MAP_WORD, shortLabel: MAP_WORD, captionPrefix: MAP_WORD, numberingTemplate: '{n}', resetOn: 'never', counterFormat: 'decimal' }], captionStyle: { fontFamily: 'Asap Condensed', fontSize: pt(8.6), color: col('ink'), labelColor: col('bandInk'), gap: mm(2), note: { fontSize: pt(8), color: col('muted') } }, calloutStyles, chipStyles, paragraphStyles: [{ id: 'colophon', fontFamily: 'Asap Condensed', fontSize: pt(7.8), lineHeight: pt(10.4), color: col('muted') }, // then the line that ends a walk, in its colour { id: 'onward', fontFamily: 'Asap Condensed', fontSize: pt(10), color: col('bandInk'), boldColor: col('bandInk'), marginTop: pt(2 * LEAD) }], header, footer, }); // #region art: the tiles, the map and its pins, drawn in code and seeded: every run the same let seed = 1755; // Mulberry32, a tiny seeded PRNG: never Math.random() in a recipe const rand = () => { let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; const n = (v) => v.toFixed(2); const sheet = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const fill = (c, o = 1) => `fill="${c}"${o < 1 ? ` fill-opacity="${n(o)}"` : ''}`; const line = (c, w, o = 1) => `fill="none" stroke="${c}" stroke-width="${n(w)}" ` + `stroke-linecap="round" stroke-linejoin="round"${o < 1 ? ` stroke-opacity="${n(o)}"` : ''}`; const shape = (pts, paint) => `<path d="M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')} Z" ` + `${paint}/>`; const put = (x, y, turn, body) => `<g transform="translate(${n(x)} ${n(y)}) rotate(${turn})">` + `${body}</g>`; // A petal pointing up from the origin: `len` long, `wid` at its widest. const petal = (len, wid) => `M0 0C${n(wid)} ${n(-len * 0.3)} ${n(wid * 0.7)} ${n(-len * 0.8)} ` + `0 ${n(-len)}C${n(-wid * 0.7)} ${n(-len * 0.8)} ${n(-wid)} ${n(-len * 0.3)} 0 0Z`; const flower = (x, y, [axis, axisW, diag = 0, diagW = 0], paint) => [0, 45, 90, 135, 180, 225, 270, 315].filter((turn) => diag || turn % 90 === 0).map((turn) => put(x, y, turn, `<path d="${turn % 90 ? petal(diag, diagW) : petal(axis, axisW)}" ${paint}/>`)).join(''); // A pattern of s-mm tiles, as on a Lisbon façade: a circle round every corner where four // tiles meet, and a flower in the diamond each tile keeps between the circles. `glaze` // paints the tiles; without it only the motif is drawn, to lay over a colour. function tiles(w, h, s, ink, { glaze, tint, o = 1, x0 = 0, y0 = 0 } = {}) { const [cols, rows] = [Math.ceil((w - x0) / s), Math.ceil((h - y0) / s)]; let [back, front] = [glaze ? `<rect width="${w}" height="${h}" ${fill(glaze)}/>` : '', '']; for (let r = -1; r <= rows; r++) { for (let c = -1; c <= cols; c++) { const [x, y] = [x0 + c * s, y0 + r * s]; back += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(s * 0.47)}" ` + `${fill(tint ?? ink, glaze ? 1 : o * 0.35)}/>`; front += `<circle cx="${n(x)}" cy="${n(y)}" r="${n(s * 0.47)}" ${line(ink, s * 0.035, o)}/>` + (glaze ? flower(x, y, [s * 0.2, s * 0.06], fill(ink, o)) : '') // glazed tiles only + flower(x + s / 2, y + s / 2, [s * 0.36, s * 0.075, s * 0.17, s * 0.05], fill(ink, o)) + `<circle cx="${n(x + s / 2)}" cy="${n(y + s / 2)}" r="${n(s * 0.06)}" ` + `${fill(glaze ?? ink, o)}/>`; } } return back + front; } const TILE = 15; // mm: one tile on the cover and on the bands const BLUE = { tint: '#dde6f3', glaze: '#f8f5ee' }; function coverTiles() { const [w, h] = [TRIM.width, 112]; let g = tiles(w, h, TILE, palette.band, { ...BLUE, y0: -4 }); // The joints, through the flowers where four tiles meet. for (let k = 1; k < 8; k++) { g += `<path d="M${k * TILE} 0V${h}M0 ${k * TILE - 4}H${w}" ${line('#a9a292', 0.2, 0.6)}/>`; } // At the fore-edge one tile is missing and the plaster shows, in the shadow of the tiles // round it; the tile beside it has lost a jagged strip along their joint. const [x, y] = [7 * TILE, TILE - 4]; const edge = [[x, y], [x - 2.6, y + 1.8], [x - 1.4, y + 4.6], [x - 3.4, y + 7.4], [x - 1.8, y + 10.2], [x - 2.4, y + 12.6], [x, y + 13.4], [x, y + TILE]]; const hole = (d) => [[x + TILE, y + d], ...edge.map(([a, b]) => [a + d, Math.max(b, y + d)]), [x + TILE, y + TILE]]; g += shape(hole(0), fill('#a89c83')) + shape(hole(0.8), fill('#d6ccb8')); for (let i = 0; i < 16; i++) { g += `<circle cx="${n(x - 2 + rand() * (TILE + 2))}" cy="${n(y + rand() * TILE)}" ` + `r="${n(0.12 + rand() * 0.3)}" ${fill('#b3a78f', 0.8)}/>`; } return sheet(w, h, g); } // The white motif laid over a walk's band, and over the page before a walk. const bandTiles = () => sheet(3 * TILE, BAND, tiles(3 * TILE, BAND, TILE, '#ffffff', { o: 0.3, y0: BAND - 3 * TILE })); const pageTiles = () => sheet(TRIM.width, TRIM.height, tiles(TRIM.width, TRIM.height, TILE, '#ffffff', { o: 0.2, y0: BAND - 3 * TILE })); // The corner badge of the 'Don't miss' box: one tile, framed. const tileIcon = () => sheet(TILE, TILE, tiles(TILE, TILE, TILE, palette.band, BLUE) + `<rect x="0.35" y="0.35" width="${TILE - 0.7}" height="${TILE - 0.7}" ` + `${line(palette.band, 0.7)}/>`); // Digits drawn as strokes in a 6 × 10 box: an SVG drawn as an image cannot use the page's // web fonts (gotcha: svg-no-webfonts). const GLYPH = { 0: 'M3 0C0.6 0 0 2.6 0 5S0.6 10 3 10 6 7.4 6 5 5.4 0 3 0Z', 1: 'M1.2 2.2L3.6 0V10', 2: 'M0.5 2.4C0.8 0.7 2 0 3.2 0C4.8 0 5.8 1.1 5.8 2.7C5.8 4.8 3.8 6 0.4 10H6', 3: 'M0.5 1.3C1.2 0.4 2.2 0 3.2 0C4.8 0 5.8 1 5.8 2.5C5.8 4 4.5 4.8 2.6 4.8C4.6 4.8 6 5.8 6 7.4' + 'C6 9.1 4.8 10 3 10C1.9 10 0.9 9.6 0.2 8.6', 4: 'M4.4 10V0L0 7H6.2', 5: 'M5.6 0H1.2L0.7 4.6C1.4 4 2.3 3.8 3.1 3.8C4.9 3.8 6 5 6 6.9S4.8 10 3 10C1.9 10 0.9 9.6' + ' 0.3 8.8', N: 'M0 10V0L6 10V0', B: 'M0 10V0H3.4C4.9 0 5.6 1 5.6 2.4S4.9 4.8 3.4 4.8H0M3.4 4.8C5.1 4.8 6 5.8 6 7.4' + 'S5.1 10 3.4 10H0', E: 'M5.6 0H0V10H5.8M0 4.8H4.4', 'É': 'M5.6 0H0V10H5.8M0 4.8H4.4M2.4 -1.6L3.8 -3.2', L: 'M0 0V10H5.6', M: 'M0 10V0L3 6.4L6 0V10', m: 'M0 10V4.6M0 5.6C0 4.6 0.8 4 1.6 4S3 4.6 3 5.6V10M3 5.6C3 4.6 3.7 4 4.5 4S6 4.6 6 5.6V10', ' ': '', }; const glyphs = (str, x, y, h, c, w) => [...str].map((ch, i) => `<path transform="translate(` + `${n(x + i * h * 0.8)} ${n(y)}) scale(${n(h / 10)})" d="${GLYPH[ch]}" ` + `${line(c, w / (h / 10))}/>`).join(''); // A pin: a disc in the walk's colour with its stop number, on a paper ring. const pin = ([x, y], num, w) => `<circle cx="${n(x)}" cy="${n(y)}" r="2.4" ${fill(palette.paper)}/>` + `<circle cx="${n(x)}" cy="${n(y)}" r="2" ${fill(w.band)}/>` + glyphs(String(num), x - 0.95, y - 1.3, 2.6, w.onBand, 0.42); const route = (pts, w) => { const d = `M${pts.map(([x, y]) => `${n(x)} ${n(y)}`).join(' L')}`; return `<path d="${d}" ${line(palette.paper, 1.9)}/><path d="${d}" ${line(w.band, 1.05)}/>`; }; // Central Lisbon, north up, at 1:20 000 (1 km = 50 mm): the Baixa's grid on the line of // Rua Augusta, Alfama's lanes round the castle hill, the river. Drawn on a 60 mm sheet // and cropped to 56 mm, 3 mm off the top. The routes measure 0.77 km and 1.38 km. const MAP = { width: MEASURE, height: 56 }; const SHORE = [[0, 57], [12, 56.5], [22, 55], [32, 51.5], [44, 46.5], [54, 41.5], [64, 35.5], [74, 29], [84, 23], [MAP.width, 20]]; const shoreY = (x) => { const i = Math.max(1, SHORE.findIndex(([sx]) => sx >= x)); const [[x0, y0], [x1, y1]] = [SHORE[i - 1], SHORE[i]]; return y0 + ((x - x0) / (x1 - x0)) * (y1 - y0); }; // The Baixa frame: u runs up Rua Augusta from its arch, v runs east across it. const ARCH = [21.7, 43.8]; const baixa = (u, v) => [ARCH[0] - 0.292 * u + 0.955 * v, ARCH[1] - 0.955 * u - 0.292 * v]; const inBaixa = ([x, y]) => { const [dx, dy] = [x - ARCH[0], y - ARCH[1]]; const [u, v] = [-0.292 * dx - 0.955 * dy, 0.955 * dx - 0.292 * dy]; return u > -10.5 && u < 44 && Math.abs(v) < 10.2; }; const STOPS = { baixa: [baixa(-4.8, 0), baixa(0.9, 0), baixa(32.2, -2.75)], alfama: [[35.2, 36.6], [49.4, 27.8], [51.6, 23], [65.6, 6.4], [78.6, 11.8]], belem: [[76.5, 44.2], [78.6, 53], [59.8, 54.9]], }; function mapArt() { const [w, h] = [MAP.width, 60]; const [block, water, green] = ['#e5dccb', '#d4e1ee', '#d3dcc3']; const paint = fill(block); let g = `<rect width="${w}" height="${h}" ${fill(palette.paper)}/>` + shape([...SHORE, [w, h], [0, h]], fill(water)); for (let i = 0; i < 44; i++) { // ripples on the river const x = rand() * w; const y = shoreY(x) + 1.6 + rand() * (h - shoreY(x)); g += `<path d="M${n(x)} ${n(y)} q0.9 -0.45 1.8 0" ${line('#b3c7da', 0.22)}/>`; } // The Baixa: blocks between parallel streets, Rua Augusta (v = 0) up the middle. for (const v0 of [-9.15, -5.95, -2.75, 0.45, 3.65, 6.85]) { for (let u0 = 2.2; u0 < 44; u0 += 2.7) { if (u0 > 25.5 && u0 < 37.5 && v0 > -6 && v0 < 0) continue; // Rossio g += shape([baixa(u0, v0), baixa(u0, v0 + 2.3), baixa(u0 + 2, v0 + 2.3), baixa(u0 + 2, v0)], paint); } } // Praça do Comércio: arcades on three sides, the river on the fourth. for (const [u0, u1, v0, v1] of [[-8.8, 0.3, -8.4, -6.3], [-8.8, 0.3, 6.3, 8.4], [-0.4, 1.5, -8.4, -0.55], [-0.4, 1.5, 0.55, 8.4]]) { g += shape([baixa(u0, v0), baixa(u0, v1), baixa(u1, v1), baixa(u1, v0)], paint); } // Everywhere else, the older town: small, uneven blocks. const castle = [[30, 12], [34, 9.5], [40, 11], [41, 16], [36, 18.5], [30.5, 17]]; for (let y = 1; y < h; y += 2.9) { for (let x = 0.5; x < w; x += 3) { const [bw, bh] = [1.3 + rand() * 1.3, 1.2 + rand() * 1.2]; const [cx, cy] = [x + rand() * 0.6, y + rand() * 0.6]; if (cy + bh + 0.8 > shoreY(cx + bw / 2) || inBaixa([cx + bw / 2, cy + bh / 2])) continue; if (Math.hypot(cx - 35.5, cy - 14) < 7) continue; // the castle's hill const j = () => (rand() - 0.5) * 0.5; g += shape([[cx + j(), cy + j()], [cx + bw + j(), cy + j()], [cx + bw + j(), cy + bh + j()], [cx + j(), cy + bh + j()]], paint); } } // São Jorge castle: its walls and towers among the pines. for (let i = 0; i < 26; i++) { const [a, d] = [rand() * Math.PI * 2, 5 + rand() * 1.8]; g += `<circle cx="${n(35.5 + Math.cos(a) * d)}" cy="${n(14 + Math.sin(a) * d * 0.8)}" ` + `r="${n(0.5 + rand() * 0.4)}" ${fill(green)}/>`; } g += shape(castle, `${fill('#ddd3bf')} stroke="#a89c83" stroke-width="0.35"`) + castle.map(([x, y]) => `<rect x="${n(x - 0.6)}" y="${n(y - 0.6)}" width="1.2" height="1.2" ` + `${fill('#a89c83')}/>`).join(''); // The two walks in town. const [a, b, c] = [WALKS.alfama, WALKS.baixa, WALKS.belem]; const [se, luzia, sol, vicente, pantheon] = STOPS.alfama; g += route([STOPS.baixa[0], baixa(0, 0), baixa(27, 0), baixa(29, -2.75), STOPS.baixa[2]], b) + route([se, [38.4, 37.8], [41.4, 36.6], [42.4, 33.4], [45.2, 32.8], [46.6, 30.2], [48.8, 30.4], luzia, sol, [55.2, 21.8], [55.8, 18.4], [59.6, 17.4], [61.6, 15.4], [60.6, 12.6], [62.8, 11.6], [63.4, 8.6], vicente, [69.8, 4.8], [74.2, 5.4], [77.2, 7.4], [77.4, 9.2], pantheon], a) + STOPS.baixa.map((p, i) => pin(p, i + 1, b)).join('') + STOPS.alfama.map((p, i) => pin(p, i + 1, a)).join(''); // Belém, 6 km west, in an inset on the river at 1:45 000: the monastery, its gardens, the // monument on the waterfront and the tower in the water. const inset = 'x="57" y="40.5" width="31" height="18"'; g += `<rect ${inset} ${fill(palette.paper)}/>` + `<rect x="57" y="53.2" width="31" height="5.3" ${fill(water)}/>` + `<rect x="71.5" y="42.4" width="12.5" height="3.2" ${fill(block)}/>` + `<rect x="74" y="47" width="8.5" height="3.6" ${fill(green)}/>` + `<path d="M57 51.6 H88" fill="none" stroke="#ffffff" stroke-width="0.8"/>` // square ends + `<path d="M59 55.6 L59.8 53.2" ${line(block, 0.6)}/><rect x="58.3" y="55" width="1.6" ` + `height="1.6" ${fill(block)}/>` + glyphs('BELÉM', 59, 42.9, 2.2, palette.ink, 0.3) + route([STOPS.belem[0], [77.6, 48.8], STOPS.belem[1], [70, 52.5], [63, 52.7], STOPS.belem[2]], c) + STOPS.belem.map((p, i) => pin(p, i + 1, c)).join('') // The frame goes on last, over the road and the river that run to its edges. + `<rect ${inset} fill="none" stroke="${palette.muted}" stroke-width="0.35"/>`; // North, and a scale bar of 200 m. g += `<path d="M5 4 L6.4 9 L5 8.2 L3.6 9 Z" ${fill(palette.ink)}/>` + glyphs('N', 3.95, 10.2, 2.2, palette.ink, 0.3) + `<path d="M3 56.8 V57.8 H13 V56.8" ${line(palette.ink, 0.3)}/>` + glyphs('200 m', 14.4, 56, 2, palette.ink, 0.28); return sheet(w, MAP.height, `<g transform="translate(0 -3)">${g}</g>`); } const drawings = () => ({ 'tiles-cover': coverTiles(), 'tiles-band': bandTiles(), 'tiles-page': pageTiles(), tile: tileIcon(), routes: mapArt(), }); // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 84 líneas · content.es.md
title: "Lisboa a pie" subtitle: "Tres paseos por la ciudad vieja" author: "Postext Cookbook" --- # Lisboa \\ a pie {style="cover"} :::pagebreak # Antes de salir {style="intro"} Lisboa sube desde el Tajo por siete colinas empinadas, y en Alfama muchas calles son escaleras. En los tres paseos de esta guía, las cuestas son cortas. Cada uno empieza en una parada de tranvía o de metro, dura entre 45 minutos y dos horas con tiempo para mirar y solo pasa por lugares públicos. Lleva calzado que agarre: el empedrado de caliza está gastado y resbala cuando llueve. Cada paseo tiene su color, el de la pestaña del borde de sus páginas: terracota para Alfama, amarillo para la Baixa, verde azulado para Belém. ::resource{id="routes"} :::paragraphs{style="colophon"} Compuesto en Albert Sans, DM Serif Display y Asap Condensed (SIL Open Font License). Texto, plano y azulejos: CC BY 4.0. ::: # Alfama {style="alfama" route="De la catedral al Panteón"} :chip[1,4 km]{style="fact"} :chip[1 h 30]{style="fact"} :chip[Cuesta al principio]{style="fact"} :chip[Tranvía 28]{style="fact"} Alfama es el barrio más antiguo de Lisboa y el que mejor resistió el terremoto de 1755, así que sus callejas siguen el trazado medieval. El paseo sube desde la catedral hasta dos miradores y luego sigue la cresta hacia el este hasta São Vicente y la cúpula del Panteón. Lo más empinado son los primeros 400 metros. ### Sé de Lisboa Las obras de la catedral empezaron en 1147, el año en que Afonso Henriques tomó la ciudad a los musulmanes, sobre el solar de la mezquita mayor. Con sus dos torres almenadas, parece tanto una fortaleza como una iglesia. Los terremotos de 1344 y 1755 derribaron partes del edificio, y una restauración del siglo XX le quitó los añadidos posteriores para devolverle la fachada románica. Ponte en la acera de enfrente para ver el rosetón sobre la puerta y espera a que el tranvía 28 doble chirriando la esquina de la Rua Augusto Rosa. ### Miradouro de Santa Luzia Sigue las vías del tranvía cuesta arriba unos 400 metros. La terraza junto a la pequeña iglesia de Santa Luzia mira por encima de los tejados de Alfama hasta el río, con la cúpula del Panteón y las torres de São Vicente en la cresta, a la izquierda. Una pérgola da sombra a los bancos, y en verano la cubren las buganvillas. :::callout{type="dontmiss"} Los dos paneles de azulejos de la pared sur de la iglesia. Uno muestra la Praça do Comércio antes de 1755, con el palacio real junto al agua; el otro, a los soldados cristianos asaltando el castillo en 1147. El paseo 2 empieza en la plaza del primer panel. ::: ### Largo das Portas do Sol Unos pasos más allá, esta plaza toma el nombre de la Puerta del Sol, una de las puertas de la muralla musulmana. La estatua es la de São Vicente, patrón de Lisboa, con una barca y dos cuervos. Cuenta la tradición que unos cuervos velaron su cuerpo cuando lo trajeron por mar en 1173; la barca y las aves siguen hoy en el escudo de la ciudad. La vista es más amplia que en Santa Luzia: Alfama entera baja a tus pies hasta los muelles y, en los días claros, se ve la otra orilla del estuario. ### São Vicente de Fora Sigue las vías del tranvía hacia el este por la cresta, unos 500 metros. La iglesia blanca que tienes delante es San Vicente de Fuera, llamada así por el monasterio que se fundó aquí en el siglo XII, extramuros. El edificio actual se levantó entre 1582 y 1629. Dos torres enmarcan una fachada sobria. Los claustros están revestidos de azulejos del siglo XVIII, entre ellos una serie de paneles con las fábulas de La Fontaine, y el monasterio guarda las tumbas de los reyes de la casa de Braganza, que reinaron en Portugal de 1640 a 1910. ### Panteão Nacional Baja por el Campo de Santa Clara, donde la Feira da Ladra, el rastro, llena la plaza los martes y los sábados, hasta la iglesia de Santa Engrácia y su cúpula. Las obras empezaron en 1682 y la cúpula no se terminó hasta 1966; por eso los portugueses todavía llaman *obras de Santa Engrácia* a un trabajo que nunca se acaba. Aquí reposan la fadista Amália Rodrigues y varios presidentes y escritores. Una escalera interior sube hasta una terraza que rodea la cúpula, con el río a un lado y los tejados de Alfama al otro. Desde el Panteón, la estación de Santa Apolónia queda a cinco minutos cuesta abajo. :::paragraphs{style="onward"} **Paseo 2 · Baixa.** Empieza en la Praça do Comércio, algo más de un kilómetro al oeste siguiendo el río. ::: # Baixa {style="baixa" route="Del río al Rossio"} :chip[0,8 km]{style="fact"} :chip[45 min]{style="fact"} :chip[Llano]{style="fact"} :chip[Metro Terreiro do Paço]{style="fact"} Un terremoto, un maremoto y cinco días de incendios destruyeron la ciudad baja el 1 de noviembre de 1755. El marqués de Pombal la reconstruyó en cuadrícula, con casas armadas en madera contra el siguiente temblor. ### Praça do Comércio Hasta 1755 fue el Terreiro do Paço, la explanada del palacio real, y muchos lisboetas la siguen llamando así. Tres lados tienen soportales; en el cuarto, las escaleras de mármol del Cais das Colunas bajan hasta el río. ### Arco da Rua Augusta El arco del lado norte se terminó en 1873: en lo alto, la Gloria corona al Genio y al Valor, sobre Vasco da Gama y el marqués de Pombal. Detrás, la Rua Augusta va hacia el norte entre la Rua do Ouro y la Rua da Prata, las calles de los orfebres y los plateros. ### Rossio La Rua Augusta termina en la Praça Dom Pedro IV, que todo el mundo llama Rossio. Las ondas blancas y negras de su empedrado se colocaron en la década de 1840.`; // content.<lang>.md, inlined by the Cookbook // Every drawing is an SVG resource, sized in mm at 10 px per mm (as sheet() draws them). const svg = (id, w, h, more) => ({ id, typeId: 'map', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: `${id}.svg`, width: w * 10, height: h * 10 }, ...more }); // #region map: the map in the text, keyed by swatches of the walks' colours in its note const map = svg('routes', MAP.width, MAP.height, { placement: { position: 'here' }, ...t({ en: { caption: 'The three walks. The numbered pins are the stops in the text.', note: ':swatch{color="alfama"} Walk 1, Alfama · :swatch{color="baixa"} Walk 2, Baixa · ' + ':swatch{color="belem"} Walk 3, Belém, 6 km west (inset)', altText: 'Map of central Lisbon north of the river: the yellow route runs from the ' + 'waterfront up the Baixa grid to Rossio; the terracotta route climbs east from the ' + 'cathedral through Alfama. An inset shows the teal Belém route.', }, es: { caption: 'Los tres paseos. Los pines numerados son las paradas del texto.', note: ':swatch{color="alfama"} Paseo 1, Alfama · :swatch{color="baixa"} Paseo 2, Baixa · ' + ':swatch{color="belem"} Paseo 3, Belém, 6 km al oeste (en el detalle)', altText: 'Plano del centro de Lisboa al norte del río: la ruta amarilla sube desde el ' + 'muelle por la cuadrícula de la Baixa hasta el Rossio; la terracota sube hacia el este ' + 'desde la catedral por Alfama. En un detalle, la ruta verde azulada de Belém.', } }) }); // #endregion // The other drawings are used by the designs, never placed in the text. const resources = [map, svg('tiles-cover', TRIM.width, 112), svg('tiles-band', 3 * TILE, BAND), svg('tiles-page', TRIM.width, TRIM.height), svg('tile', TILE, TILE)]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Albert Sans': ['400', '400i'], // text, the route under a walk's name 'DM Serif Display': ['400'], // walk names, stops, the cover 'Asap Condensed': ['600', '700'], // labels, chips, tabs, folios, captions }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); for (const [id, art] of Object.entries(drawings())) await loadSvg(`${id}.svg`, art); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Lisbon on Foot', es: 'Lisboa a pie' }) });Kit · core, fonts, viewer, images: igual en todas las recetas · 270 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /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
#Deja sin pintar la página par en blanco
Sin el campo, la página 6 conserva su pestaña: pages: 'all' la imprime también en la página en blanco, en el amarillo del paseo 2 y con un 2.
-const header = { elements: [...heads, ...tabs, ...blankPage] };
+const header = { elements: [...heads, ...tabs] };#Añade el tercer paseo
WALKS ya tiene los colores de Belém y walkStyles su estilo, así que basta un título. El paseo empieza en la página 9, y la 8, que queda en blanco, va pintada de verde azulado con un 3 blanco.
+# Belém {style="belem" route="Del monasterio a la torre"}
+
+El tranvía 15 va de la Praça da Figueira a Belém junto al río.Errores frecuentes
Error frecuente
Una página toma la cabecera de la última sección que empieza en ella
Cuando dos estilos de título empiezan en la misma página, esta toma la cabecera, el pie y la paleta del último. Una página en blanco de separación pertenece al capítulo anterior, y el relleno de paridad al siguiente. Cabeceras por sección →
Error frecuente
La paleta de una sección o de una parte no recolorea los chips
En postext 1.4.1 la paleta de un estilo de título o de una parte recolorea en sus páginas el texto, los recuadros y los elementos de diseño, pero un chip conserva los colores de su entrada de chipStyles. Un chip enlazado a una entrada que la sección cambia muestra el valor del documento en todas las páginas. Pon los chips en colores que ninguna sección cambie, como tinta sobre papel, o da a cada sección su propio estilo de chip. Chips en línea →
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
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
Los desplazamientos negativos relativos al contenedor no pintan nada
El texto de diseño de ancho automático se limita a su contenedor, así que un desplazamiento negativo respecto al contenedor lo saca fuera y no se pinta nada. Ancla esos elementos a la página o a la sangre con desplazamientos explícitos en mm, o dales un ancho fijo. Anclaje de elementos de diseño →
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
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
En el texto en bandera no se evitan las líneas cortas
optimalLineBreaking, avoidRunts, runtPenalty y runtMinCharacters actúan sobre el algoritmo de Knuth–Plass, que postext 1.4.1 solo aplica al texto justificado. Un párrafo en bandera se corta línea a línea y puede terminar en una sola palabra corta, digan lo que digan esos ajustes. Revisa las últimas líneas del texto en bandera y reescribe el párrafo que acabe en una línea corta. Viudas, huérfanas y líneas cortas →
Error frecuente
El texto dentro de un SVG <img> no puede usar fuentes web
Un SVG se dibuja como imagen, y una imagen no tiene acceso a las fuentes web de la página, así que sus rótulos salen con una fuente del sistema. Convierte el texto en trazados, incrusta un subconjunto @font-face en el SVG o lleva los rótulos al pie. Figuras y tablas como recursos →
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 →
- La página 6 existe porque el paseo 1 termina en la página 5, impar. Un paseo que termina en página par no deja página en blanco, y entonces el color del paseo siguiente solo se ve en sus propias páginas.
- Antes del primer paseo numerado,
{chapterNumber}está vacío, así que un elemento que lo imprima, como la pestaña, necesita allí un estilo que lo deje fuera.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Imágenes
- Los azulejos, el plano de las rutas y sus pines, dibujados en código · Ignacio Ferro · CC BY 4.0
- Fuentes
- Albert Sans (SIL OFL 1.1) · DM Serif Display (SIL OFL 1.1) · Asap Condensed (SIL OFL 1.1)
- Código
- MIT, como Postext


