Lo que vas a componer
El currículum de Irene Salcedo, diseñadora editorial ficticia afincada en Madrid, en una sola hoja A4. Una banda ciruela de 66 mm de ancho recorre el borde izquierdo de arriba abajo. En lo alto, una pila de libros encuadernados en tela, dibujada en código, queda a la altura de la línea base del nombre. Debajo van el contacto, las habilidades y herramientas en píldoras, los idiomas, la formación, la docencia y sus aficiones. El nombre, en Hedvig Letters Serif de 46 pt, encabeza una columna de texto de 118 mm con un perfil de cinco líneas, tres puestos y los libros que ha diseñado. Cada puesto lleva el cargo a la izquierda y las fechas a la derecha en la misma línea base, con la empresa en cursiva debajo. El primer título de la barra comparte línea base con la profesión, bajo el nombre.
Esta receta responde a
- ¿Cómo compongo en columna y media, con una columna de texto ancha y una lateral estrecha?
- ¿Cómo añado una marca de agua, un fondo de color o una imagen decorativa en todas las páginas?
- ¿Cómo hago chips en línea: teclas, etiquetas, bancos de palabras para ejercicios?
La respuesta corta
const layout = {
layoutType: 'oneAndHalf',
sideColumnPercent: (SIDE / CONTENT) * 100, // 57 of the 185 mm between the margins
sideColumnSide: 'left',
sideColumnRole: 'floats', // no body text: only boxes fenced with span="side"
gutterWidth: mm(GUTTER), // the text column keeps 185 − 57 − 10 = 118 mm
};
// A side box stands where the text has reached at its fence, so the Markdown opens with it,
// before the name, and it starts at the head of the column (gotcha: side-box-starts-at-fence):
// :::callout{type="sidebar" span="side"}
// :::callout{type="section" title="Contact"} … ::: ← the sections nest inside it
// …
// :::space{lines=2.76} ← last: runs the band to the column's foot; a fifth of a line
// ::: more and the whole box moves to page 2
// # Irene Salcedo {role="Book designer and art director"}
const sidebar = { id: 'sidebar', background: col('band'),
// The first title stands on the role's line. The text starts 10 mm from the trim, the
// left margin plus 1 mm, and stops 10 mm short of the band's right edge.
padding: { top: mm(ROLE_Y), right: mm(10), bottom: pt(0), left: mm(1) },
body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(12.4), color: col('paper'),
boldColor: col('paper'), paragraphSpacing: false } };
Ingredientes
- Funciones
- Columna al margen para flotantesColumna y mediaNotas al margenRecuadros anidadosRecuadrosEspacio vertical explícitoChips en líneaTextos, filetes y cajas en los diseños de páginaImágenes en los diseños de páginaAnclaje de elementos de diseñoNiveles de títuloAtributos de títuloListas de viñetas y de comprobaciónEstilos de párrafoPaleta de color semánticaPáginas en un canvas
- Tipografía
- Hedvig Letters Serif, Hanken Grotesk (SIL OFL 1.1)
- Recursos
- La pila de libros en la cabeza de la barra lateral, dibujada en código con la paleta de la página (Ignacio Ferro, CC BY 4.0)
Elaboración
#1 · Anida las secciones en un solo recuadro lateral
// The sections nest in one side box. As separate side boxes they would stand at least a line
// of paper apart (gotcha: side-boxes-line-apart). A nested box ignores span and flows inside
// its parent (gotcha: nested-callout-limits).
const section = (id, lineHeight) => ({ id, backgroundEnabled: false, // the band shows through
padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
marginTop: mm(10),
titleStyle: { ...caps('glow'), gap: mm(2.4) },
body: { ...sidebar.body, lineHeight: pt(lineHeight) } });
const chipStyles = [{ id: 'skill', fontFamily: SANS, fontSize: pt(7.6), bold: true,
background: col('chip'), color: col('paper'), borderWidth: pt(0),
borderRadius: em(1), // past half the chip's height: a pill
paddingX: em(0.6), paddingY: em(0.22), gap: em(0.3) }];
Dentro de la columna, la banda es el recuadro de la respuesta corta. Se abre antes del nombre, y por eso span="side" lo coloca en la cabeza de la columna solo para flotantes. Las secciones van anidadas en él como recuadros sin fondo propio, de modo que el ciruela corre sin cortes de Contacto a Fuera del trabajo. Compuestas como recuadros laterales sueltos, quedan separadas por franjas de papel de 4,7 a 9,1 mm, aun con marginBottom a 0. Las píldoras miden unos 3,9 mm de alto. Habilidades y Herramientas usan un estilo propio, chips, cuyo interlineado de 16 pt deja 1,8 mm entre filas; con los 12,4 pt de las demás secciones, quedarían a medio milímetro unas de otras (estilos de chip).
#2 · Pinta los márgenes alrededor de la columna
// Header elements are painted after the text, over it, so these stay in the margins, where
// no text runs (gotcha: header-paints-over-text). They repeat on every page.
const BAND_W = LEFT + SIDE; // mm from the trim edge to the band's right edge
const SEAM = 0.5; // mm of overlap with the box, so no hairline of paper shows between them
const REACH = 1; // mm the foot box climbs above the column's foot, over the end of the box
const bandBox = (id, x, y, w, h) => ({ kind: 'box', id, style: { backgroundColor: col('band') },
placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } });
const header = { elements: [
bandBox('head', 0, 0, BAND_W, TOP + SEAM), // above the column: the top margin
bandBox('edge', 0, 0, LEFT + SEAM, TRIM_H), // beside it: the left margin, top to bottom
bandBox('foot', 0, TRIM_H - BOTTOM - REACH, BAND_W, BOTTOM + REACH), // below it
// Painted last, over the box's top padding, where no text runs. The bottom of the stack
// sits on the name's baseline.
{ kind: 'image', id: 'books', resourceId: 'books', placement: {
...at('page', 'top-left', LEFT + 1, TOP + BASELINE - BOOKS.h),
size: { width: mm(BOOKS.w), height: mm(BOOKS.h) } } },
] };
Como la columna lateral queda dentro de los márgenes, el recuadro se detiene a 9 mm del borde izquierdo y a 18 mm del superior. Tres cajas de la cabecera llenan el margen superior sobre la columna, el izquierdo de arriba abajo y el inferior bajo la columna. Los elementos de la cabecera se pintan después del texto y encima de él; por eso la receta los deja en los márgenes, donde no hay texto. Además se repiten en todas las páginas, y una segunda página también llevaría la banda (encabezados y pies). Las cajas de arriba y de la izquierda montan 0,5 mm sobre el recuadro, y la del pie sube 1 mm por encima del pie de la columna, sobre el final del recuadro, para que no asome un hilo de papel en las juntas. El :::space que cierra el recuadro lo alarga hasta 278,0 mm, 0,8 mm por encima del pie de la columna, con 2,76 líneas en inglés y 1,76 en español.
#3 · Compón el nombre en la cabeza de la columna de texto
const nameplate = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'name', content: '{titleText}', fontFamily: SERIF,
fontSize: pt(NAME.size), lineHeight: 1, color: col('ink'),
placement: at('container', 'top-left') },
{ kind: 'text', id: 'role', content: '{attr.role}', ...caps('accent'),
placement: at('#name', 'below', 0, NAME.gap) },
] } };
const nameLevel = { level: 1, // span stays 'column': the name heads the text column only
// No break before the name: the sidebar box is already on the page, and a break would
// move the name and the whole text column to page 2. 1.4.1 drops the H1 break anyway once
// `headings` is set (gotcha: headings-drop-h1-break); the explicit value keeps the page
// whole once that default returns.
breakBefore: { enabled: false },
marginBottom: pt(LEAD), // one grid line of air under the role
advancedDesign: nameplate };
El H1 conserva el span: 'column' por defecto. Con 'page' se compone como apertura sobre las dos columnas; al ir detrás del recuadro lateral, pasa a la página 2, y allí la columna lateral empieza debajo del nombre, a 45,9 mm. breakBefore está desactivado porque el recuadro lateral ya está en la página: con salto, el nombre y toda la columna de texto pasan a la página 2. El marginBottom del nivel, una línea de la rejilla, baja el perfil de 41,3 a 45,9 mm. El relleno superior del recuadro lateral es ROLE_Y, la altura del nombre más el aire que lo separa de la profesión, así que el primer título de la barra y la profesión comparten línea base, a 39,3 mm del borde superior.
#4 · Pon las fechas en la línea del cargo
const JOB = 9.8; // pt: the job title, the dates and the employer share the size and leading
const jobText = (id, content, look) => ({ kind: 'text', id, content, fontFamily: SANS,
fontSize: pt(JOB), lineHeight: LEAD / JOB, // a multiple (gotcha: design-lineheight-multiple)
align: 'left', ...look }); // without align, design text is centred in its box
const job = { enabled: true, slot: { elements: [
jobText('title', '{titleText}', { fontWeight: 700, color: col('ink'), overflow: 'wrap',
placement: { ...at('container', 'top-left'), size: { width: mm(84) } } }), // clear of dates
jobText('dates', '{attr.dates}', { color: col('muted'),
placement: at('container', 'top-right') }), // the title's top and size, so its baseline
jobText('org', '{attr.org}', { italic: true, color: col('muted'),
placement: at('#title', 'below') }),
] } };
const jobLevel = { level: 3, fontSize: pt(JOB), lineHeight: pt(LEAD), marginTop: pt(9),
marginBottom: pt(0), advancedDesign: job };
// The section head takes 20 pt under 19.6 pt of space, three grid lines, so a job head
// starts the same 9 pt under a grid line after a section head as after a list. The 9 pt
// below it merge with a job head's 9 pt above. Under Selected books they push the list to
// the next grid line, as the default 7 pt would; with 0 it would start one line higher.
const sectionLevel = { level: 2, fontSize: pt(14), lineHeight: pt(20),
marginTop: pt(3 * LEAD - 20), marginBottom: pt(9), advancedDesign: sectionHead };
Cada puesto es un H3 cuyos atributos llevan la empresa y las fechas: ### Directora de arte {org="Ediciones del Albardín, Madrid" dates="2021 – actualidad"} (atributos de encabezado). El cargo y las fechas se anclan a la cabeza del mismo contenedor, cada uno en una esquina, con el mismo cuerpo e interlineado, y por eso comparten línea base. El cargo tiene un ancho máximo de 84 mm y admite varias líneas: uno largo salta a la segunda antes de alcanzar las fechas, y la empresa baja con él. Cada título de sección ocupa, con el espacio de encima, tres líneas justas de la rejilla, y así todos los puestos empiezan 9 pt por debajo de una línea, vengan de un título de sección o de una lista.
La receta completa
// ═══ Postext Cookbook · Nº 057 · One-page CV with a sidebar ═══════════════════════ // https://postext.dev/en/cookbook/cv-with-sidebar // Code: MIT · Text: original (CC BY 4.0) · Drawing: made in code (CC BY 4.0) // Fonts: Hedvig Letters Serif, Hanken Grotesk (SIL OFL 1.1) · Needs postext ≥ 1.4.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'cv-with-sidebar'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#231b22', // text: a plum-tinted near-black band: '#3a2235', // the sidebar accent: '#8f3b62', // on paper: the role and the section heads (7.1:1) glow: '#f1b98f', // on the band: the sidebar titles (8.3:1) chip: '#744d6c', // on the band: the skill chips rule: '#d9cdd5', // the hairlines after the section heads muted: '#6c5f69', // dates, employers, the colophon (6.0:1) paper: '#ffffff', // the page, and the text on the band (14.4:1) }; // 1.4.1 design elements paint the hex and ignore the paletteId (gotcha: palette-skips-designs). const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); const [SERIF, SANS] = ['Hedvig Letters Serif', 'Hanken Grotesk']; const PT = 25.4 / 72; // mm in a point const [TRIM_W, TRIM_H] = [210, 297]; // mm: A4, printed on one side, so nothing is mirrored const [TOP, LEFT, RIGHT] = [18, 9, 16]; // mm const LEAD = 13.2; // pt: the body leading const BOTTOM = TRIM_H - TOP - 56 * LEAD * PT; // mm: the text block holds 56 lines const CONTENT = TRIM_W - LEFT - RIGHT; // 185 mm between the margins const [SIDE, GUTTER] = [57, 10]; // mm: the sidebar column, and the white after it const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const LABEL = 7.8; // pt: the role and the sidebar titles, tracked capitals const caps = (colour) => ({ fontFamily: SANS, fontSize: pt(LABEL), fontWeight: 700, textTransform: 'uppercase', letterSpacing: pt(LABEL * 0.18), color: col(colour) }); const NAME = { size: 46, gap: 2.6 }; // pt and mm: the name, and the room under it const ROLE_Y = NAME.size * PT + NAME.gap; // mm under the top margin: the role's line // mm under the top margin: the name's baseline. Hedvig Letters Serif, set solid, puts it 0.795 // of the size down the line (measured on the page); another face needs its own ratio. const BASELINE = 0.795 * NAME.size * PT; const BOOKS = { w: 46, h: 18 }; // mm: the drawing at the head of the band // #region answer: a float-only column on the left, filled by one box const layout = { layoutType: 'oneAndHalf', sideColumnPercent: (SIDE / CONTENT) * 100, // 57 of the 185 mm between the margins sideColumnSide: 'left', sideColumnRole: 'floats', // no body text: only boxes fenced with span="side" gutterWidth: mm(GUTTER), // the text column keeps 185 − 57 − 10 = 118 mm }; // A side box stands where the text has reached at its fence, so the Markdown opens with it, // before the name, and it starts at the head of the column (gotcha: side-box-starts-at-fence): // :::callout{type="sidebar" span="side"} // :::callout{type="section" title="Contact"} … ::: ← the sections nest inside it // … // :::space{lines=2.76} ← last: runs the band to the column's foot; a fifth of a line // ::: more and the whole box moves to page 2 // # Irene Salcedo {role="Book designer and art director"} const sidebar = { id: 'sidebar', background: col('band'), // The first title stands on the role's line. The text starts 10 mm from the trim, the // left margin plus 1 mm, and stops 10 mm short of the band's right edge. padding: { top: mm(ROLE_Y), right: mm(10), bottom: pt(0), left: mm(1) }, body: { fontFamily: SANS, fontSize: pt(8.4), lineHeight: pt(12.4), color: col('paper'), boldColor: col('paper'), paragraphSpacing: false } }; // #endregion // #region margins: header boxes paint the band into the margins around the column // Header elements are painted after the text, over it, so these stay in the margins, where // no text runs (gotcha: header-paints-over-text). They repeat on every page. const BAND_W = LEFT + SIDE; // mm from the trim edge to the band's right edge const SEAM = 0.5; // mm of overlap with the box, so no hairline of paper shows between them const REACH = 1; // mm the foot box climbs above the column's foot, over the end of the box const bandBox = (id, x, y, w, h) => ({ kind: 'box', id, style: { backgroundColor: col('band') }, placement: { ...at('page', 'top-left', x, y), size: { width: mm(w), height: mm(h) } } }); const header = { elements: [ bandBox('head', 0, 0, BAND_W, TOP + SEAM), // above the column: the top margin bandBox('edge', 0, 0, LEFT + SEAM, TRIM_H), // beside it: the left margin, top to bottom bandBox('foot', 0, TRIM_H - BOTTOM - REACH, BAND_W, BOTTOM + REACH), // below it // Painted last, over the box's top padding, where no text runs. The bottom of the stack // sits on the name's baseline. { kind: 'image', id: 'books', resourceId: 'books', placement: { ...at('page', 'top-left', LEFT + 1, TOP + BASELINE - BOOKS.h), size: { width: mm(BOOKS.w), height: mm(BOOKS.h) } } }, ] }; // #endregion // #region sections: boxes nested in the band, one per section; chips for the skills // The sections nest in one side box. As separate side boxes they would stand at least a line // of paper apart (gotcha: side-boxes-line-apart). A nested box ignores span and flows inside // its parent (gotcha: nested-callout-limits). const section = (id, lineHeight) => ({ id, backgroundEnabled: false, // the band shows through padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginTop: mm(10), titleStyle: { ...caps('glow'), gap: mm(2.4) }, body: { ...sidebar.body, lineHeight: pt(lineHeight) } }); const chipStyles = [{ id: 'skill', fontFamily: SANS, fontSize: pt(7.6), bold: true, background: col('chip'), color: col('paper'), borderWidth: pt(0), borderRadius: em(1), // past half the chip's height: a pill paddingX: em(0.6), paddingY: em(0.22), gap: em(0.3) }]; // #endregion // #region name: the H1 sets the name and the role at the head of the text column const nameplate = { enabled: true, slot: { elements: [ { kind: 'text', id: 'name', content: '{titleText}', fontFamily: SERIF, fontSize: pt(NAME.size), lineHeight: 1, color: col('ink'), placement: at('container', 'top-left') }, { kind: 'text', id: 'role', content: '{attr.role}', ...caps('accent'), placement: at('#name', 'below', 0, NAME.gap) }, ] } }; const nameLevel = { level: 1, // span stays 'column': the name heads the text column only // No break before the name: the sidebar box is already on the page, and a break would // move the name and the whole text column to page 2. 1.4.1 drops the H1 break anyway once // `headings` is set (gotcha: headings-drop-h1-break); the explicit value keeps the page // whole once that default returns. breakBefore: { enabled: false }, marginBottom: pt(LEAD), // one grid line of air under the role advancedDesign: nameplate }; // #endregion const sectionHead = { enabled: true, slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SERIF, fontSize: pt(14), lineHeight: 20 / 14, color: col('accent'), placement: at('container', 'top-left') }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.5), color: col('rule'), // 3 mm after the title, level with the middle of its lower case, on to the column's edge placement: { ...at('#title', 'right-of', 3, 3.9), size: { width: 'fill' } } }, ] } }; // #region jobs: each job heading sets the title left and the dates flush right const JOB = 9.8; // pt: the job title, the dates and the employer share the size and leading const jobText = (id, content, look) => ({ kind: 'text', id, content, fontFamily: SANS, fontSize: pt(JOB), lineHeight: LEAD / JOB, // a multiple (gotcha: design-lineheight-multiple) align: 'left', ...look }); // without align, design text is centred in its box const job = { enabled: true, slot: { elements: [ jobText('title', '{titleText}', { fontWeight: 700, color: col('ink'), overflow: 'wrap', placement: { ...at('container', 'top-left'), size: { width: mm(84) } } }), // clear of dates jobText('dates', '{attr.dates}', { color: col('muted'), placement: at('container', 'top-right') }), // the title's top and size, so its baseline jobText('org', '{attr.org}', { italic: true, color: col('muted'), placement: at('#title', 'below') }), ] } }; const jobLevel = { level: 3, fontSize: pt(JOB), lineHeight: pt(LEAD), marginTop: pt(9), marginBottom: pt(0), advancedDesign: job }; // The section head takes 20 pt under 19.6 pt of space, three grid lines, so a job head // starts the same 9 pt under a grid line after a section head as after a list. The 9 pt // below it merge with a job head's 9 pt above. Under Selected books they push the list to // the next grid line, as the default 7 pt would; with 0 it would start one line higher. const sectionLevel = { level: 2, fontSize: pt(14), lineHeight: pt(20), marginTop: pt(3 * LEAD - 20), marginBottom: pt(9), advancedDesign: sectionHead }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object colorPalette, layout, chipStyles, header, footer: { elements: [] }, page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(LEFT), right: mm(RIGHT) } }, bodyText: { fontFamily: SANS, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'), italicColor: col('ink'), // the titles in Selected books referenceColor: col('ink'), // no :ref yet; one added later prints in ink, not default blue textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // Each level draws its own design, but 1.4.1 still measures the hidden heading text in this // face. Without it the default is Open Sans, and the page would have to load that face too. headings: { fontFamily: SANS, levels: [nameLevel, sectionLevel, jobLevel] }, unorderedLists: { bulletChar: '–', color: col('ink'), fontWeight: 400, itemSpacing: pt(0), marginTop: pt(0), marginBottom: pt(0) }, paragraphStyles: [ { id: 'lead', fontSize: pt(11), lineHeight: pt(16) }, { id: 'colophon', fontSize: pt(7), lineHeight: pt(9.6), color: col('muted'), marginTop: pt(LEAD) }, ], calloutStyles: [sidebar, section('section', 12.4), section('chips', 16)], }); // #region art: a stack of cloth-bound books, drawn in the palette function books() { // BOOKS.w × BOOKS.h mm, in tenths of a millimetre; the bottom book first const lilac = mix(palette.band, palette.paper, 0.55); const stack = [ // [left edge, length, thickness, cloth, label] [0, 430, 40, palette.glow, palette.paper], [22, 380, 32, palette.paper, palette.accent], [6, 400, 36, palette.accent, palette.glow], [40, 330, 30, lilac, palette.paper], [18, 300, 34, mix(palette.glow, palette.paper, 0.45), palette.accent], ]; let y = BOOKS.h * 10; let art = ''; for (const [x, length, thick, cloth, label] of stack) { y -= thick; const [top, h] = [y + 3, thick - 3]; // a dark hairline above each book art += `<rect x="${x}" y="${top}" width="${length}" height="${h}" rx="3" fill="${cloth}"/>` + `<rect x="${x + 14}" y="${top}" width="5" height="${h}" fill="${label}"/>` // bands + `<rect x="${x + length - 19}" y="${top}" width="5" height="${h}" fill="${label}"/>` + `<rect x="${x + length * 0.34}" y="${top + h * 0.28}" width="${length * 0.32}" ` + `height="${h * 0.44}" rx="2" fill="${label}"/>`; // the title label } return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${BOOKS.w * 10} ${BOOKS.h * 10}" ` + `width="${BOOKS.w * 10}" height="${BOOKS.h * 10}">${art}</svg>`; } function mix(a, b, k) { // a blend of two palette colours, k of the way from a to b const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); const [p, q] = [rgb(a), rgb(b)]; return `#${p.map((v, i) => Math.round(v + (q[i] - v) * k).toString(16).padStart(2, '0')) .join('')}`; } // #endregion // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Muestra en Markdown · 89 líneas · content.es.md
title: "Irene Salcedo · Currículum" author: "Irene Salcedo" --- :::callout{type="sidebar" span="side"} :::callout{type="section" title="Contacto"} Madrid irene@salcedo.example salcedo.example/libros ::: :::callout{type="chips" title="Habilidades"} :chip[Tipografía editorial]{style="skill"} :chip[Cubiertas]{style="skill"} :chip[Diseño de colecciones]{style="skill"} :chip[EPUB 3]{style="skill"} :chip[Retículas y estilos]{style="skill"} :chip[Maquetación]{style="skill"} :chip[Preimpresión]{style="skill"} :chip[Pruebas de color]{style="skill"} :chip[PDF accesible]{style="skill"} :chip[Dirección de arte]{style="skill"} ::: :::callout{type="chips" title="Herramientas"} :chip[InDesign]{style="skill"} :chip[Illustrator]{style="skill"} :chip[Photoshop]{style="skill"} :chip[Glyphs]{style="skill"} :chip[Python]{style="skill"} ::: :::callout{type="section" title="Idiomas"} **Español** · nativo **Catalán** · C2 **Inglés** · C1, lengua de trabajo **Italiano** · B2 ::: :::callout{type="section" title="Formación"} **Máster en Diseño del Libro** Escuela del Libro del Turia, Valencia, 2011–2012 :::space{lines=0.5} **Título Superior de Diseño Gráfico** Escuela de Diseño Almenara, Castellón, 2007–2011 ::: :::callout{type="section" title="Docencia"} Profesora invitada de tipografía editorial en la Escuela del Libro del Turia, cursos de primavera de 2022 a 2024 ::: :::callout{type="section" title="Fuera del trabajo"} Imprimo con tipos móviles en un taller compartido de Lavapiés y recorro a pie los senderos de la sierra de Guadarrama. ::: :::space{lines=1.76} ::: # Irene Salcedo {role="Diseñadora editorial y directora de arte"} :::paragraphs{style="lead"} Diseñadora editorial con catorce años de oficio en edición literaria y académica, los cinco últimos como directora de arte de una casa con tres sellos. Cuido colecciones y cubiertas tanto como la página y redacto las especificaciones para las imprentas. Busco un puesto de directora de arte en una editorial literaria o de libro ilustrado. ::: ## Experiencia ### Directora de arte {org="Ediciones del Albardín, Madrid" dates="2021 – actualidad"} - Fijo y mantengo el libro de estilo de los tres sellos: 58 novedades en 2025, del bolsillo a la no ficción ilustrada. - Rediseñé la colección de bolsillo en un formato de 110 × 178 mm. La nueva retícula da 32 líneas por página en lugar de 29, y el catálogo de 2024 necesitó un 9 % menos de páginas que el de 2023 con el mismo número de títulos. - Dirijo a dos diseñadores y un maquetador, y encargo cada temporada el trabajo de ilustradores y fotógrafos. - Pasé las ediciones impresa y digital a un único archivo de origen, y el libro electrónico sale ahora la misma semana que la tapa dura. ### Diseñadora editorial sénior {org="Casa Albero, Barcelona" dates="2016 – 2021"} - Diseñé interiores y cubiertas de unos 40 títulos al año de narrativa literaria y ensayo, en castellano y en catalán. - Redacté las normas de composición del catálogo en catalán: separación silábica, la ele geminada, comillas y rayas de diálogo. - Diseñé los doce volúmenes de la poesía reunida de Elvira Montcada, con las notas compuestas en el margen. ### Diseñadora y maquetadora {org="Imprenta Quirós, Valencia" dates="2012 – 2016"} - Compuse ediciones críticas con hasta tres niveles de notas, y sus bibliografías e índices, para dos editoriales universitarias. - Preparé archivos para impresión offset: imposición, pruebas de color y especificaciones de papel y encuadernación. ## Libros seleccionados - *Los años del salitre*, de Marta Ibarra. Albardín, 2023. Cubierta, interior y un pliego de mapas a dos tintas. - *Cartas desde Menorca*, de Miquel Truyols. Casa Albero, 2019. Edición bilingüe en páginas enfrentadas. - *Obra reunida*, de Elvira Montcada. Casa Albero, 2018–2020. Doce volúmenes en un estuche de tela. :::paragraphs{style="colophon"} Compuesto en Hedvig Letters Serif y Hanken Grotesk. Texto: CC BY 4.0. :::`; // content.<lang>.md, inlined by the Cookbook // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses, loaded before the first build (gotcha: fonts-first). const FONTS = { 'Hedvig Letters Serif': ['400'], 'Hanken Grotesk': ['400', '400i', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await loadSvg('books.svg', books()); const resources = [{ id: 'books', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'books.svg', width: BOOKS.w * 10, height: BOOKS.h * 10 }, altText: t({ en: 'A stack of five cloth-bound books', es: 'Una pila de cinco libros encuadernados en tela' }) }]; const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'One-page CV with a sidebar', es: 'Currículum de una página con barra lateral' }) });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
#Cambia el color de la banda
Cada color se escribe una sola vez, en palette, y de ahí lo toman las cajas de la banda, el recuadro lateral, los chips, la profesión, los títulos y el dibujo. Con estas tres líneas, la banda, el acento y los chips pasan a verde azulado.
- band: '#3a2235', // the sidebar
+ band: '#1d3b3a', // the sidebar
- accent: '#8f3b62', // on paper: the role and the section heads (7.1:1)
+ accent: '#2b5f5b', // on paper: the role and the section heads (7.3:1)
- chip: '#744d6c', // on the band: the skill chips
+ chip: '#456d69', // on the band: the skill chips#Lleva figuras a la misma columna
El libro de texto con columna al margen pone figuras, pies laterales y glosas en una columna solo para flotantes en el borde exterior de cada página.
Errores frecuentes
Error frecuente
Un recuadro lateral empieza a la altura del bloque que sigue a su valla
En postext 1.4.1, un recuadro con span: 'side' se coloca en la columna lateral a la altura a la que ha llegado el texto en su valla, en la siguiente línea de la rejilla y debajo de los recuadros que ya haya. Pon la valla de una glosa justo antes del párrafo que explica: si va después, la glosa empieza junto al párrafo siguiente. Un recuadro que pasaría del pie de la columna sube hasta que su pie coincide con el de la columna, si el recuadro de encima le deja sitio; si aun así no cabe, espera a la columna lateral de la página siguiente. Notas al margen →
Error frecuente
Los recuadros laterales apilados en el canal dejan una línea de papel entre ellos
En postext 1.4.1, un recuadro con span: 'side' deja debajo al menos una línea de texto antes de lo siguiente que haya en el canal, redondeada a la rejilla base, diga lo que diga su marginBottom. Por eso los recuadros con el mismo fondo nunca se tocan: apilados, se leen como tarjetas sueltas. Para un panel continuo, abre un solo recuadro lateral y anida dentro las secciones. Notas al margen →
Error frecuente
:::space se descarta al principio de un recuadro o columna
:::space se descarta al principio de una columna, de un recuadro o de un grupo :::columns, aunque sea el único contenido de la caja, así que una caja de respuesta hecha solo de espacio se queda sin altura. Abre la caja con un título o una línea de enunciado y añade después el espacio. Espacio vertical explícito →
Error frecuente
Los elementos de cabecera y pie se pintan encima del texto
Los elementos de cabecera y pie se pintan sobre la página y el área de texto no les deja sitio. Mantenlos dentro de los márgenes, que son los que les reservan el espacio. Cabeceras y folios →
Error frecuente
Un recuadro anidado ignora span, placement y snapToGrid
Un recuadro anidado en otro ignora su span, placement, snapToGrid y floatBarrier: siempre se compone dentro del recuadro que lo contiene, con el ancho interior de este. Recuadros anidados →
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 lineHeight de un texto de diseño es un múltiplo, nunca una medida
En una ranura de diseño, el lineHeight de un elemento de texto multiplica su cuerpo (lineHeight: 1.05). En postext 1.4.1 una medida como pt(15) no da error: la altura de la apertura sale NaN, el espacio que reserva, minHeight incluido, se pierde sin aviso y el texto se superpone al título. Textos, filetes y cajas en los diseños de página →
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
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
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 →
- Ajusta el
:::spacea la página. En la edición española, 1,76 líneas terminan el recuadro 0,8 mm por encima del pie de la columna. Con 1,96 líneas pasa del pie en 0,1 mm y la barra entera se va a la página 2; con 1,66 se abre una franja de papel entre el recuadro y la caja del pie. - Una caja del diseño del nombre no sirve para pintar la banda. Si termina justo encima del pie de la columna, el título reserva 265 mm, más que los 260,8 mm de la columna, y el texto pasa a la página 2; si llega hasta el pie de la hoja, el título pierde la altura que reservaba y el perfil sube a 30,3 mm y se monta sobre la profesión.
Créditos
- Receta
- Ignacio Ferro
- Texto
- Texto original, CC BY 4.0
- Imágenes
- La pila de libros en la cabeza de la barra lateral, dibujada en código con la paleta de la página · Ignacio Ferro · CC BY 4.0
- Fuentes
- Hedvig Letters Serif (SIL OFL 1.1) · Hanken Grotesk (SIL OFL 1.1)
- Código
- MIT, como Postext


