En bref
Une affiche pour des conférences d’astronomie avant une éclipse de Soleil. Elle montre comment placer chaque élément en mesurant depuis le bord de la page ou depuis un autre élément.
Ce que vous allez composer
L’affiche d’un club d’astronomie imaginaire pour quatre conférences du jeudi avant l’éclipse totale de Soleil du 2 août 2027. Un ciel outremer remplit les deux tiers supérieurs d’une feuille A3. À droite, une Lune noire couvre un Soleil citron coupé par le bord de coupe, et une couronne dessinée en code s’étend derrière les deux. Le titre, The Shadow Returns en Funnel Display de 120 pt, se tient sur son chapeau au pied du ciel. Sous l’horizon, quatre dates bleues coiffent les conférences, et les petits caractères courent sur deux colonnes au-dessus d’un bandeau bleu. Chaque élément s’accroche au fond perdu, à la page, à son emplacement ou à un autre élément, et ses décalages se comptent à partir de là. La deuxième planche reprend l’affiche avec un cadre rose autour de la boîte de chaque élément et autour des conteneurs d’en-tête et de pied de page, et une étiquette sur treize éléments qui imprime le placement de chacun.
Cette recette répond à
- Comment placer des éléments de maquette par rapport à la page, au fond perdu ou les uns aux autres plutôt que par coordonnées ?
- Comment ajouter un filigrane, une teinte de fond ou une image décorative sur chaque page ?
La réponse courte
const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
const INSET = BLEED + MARGIN; // from the bleed's edge to the text's edge
const AIR = 14; // mm between the standfirst and the horizon
const poster = () => ({ enabled: true, slot: { elements: [ // array order is paint order
// 'bleed' and 'page' are frames of the sheet; 'container' would be this heading's own box.
{ kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, // no width: fills the bleed
placement: at('bleed', 'top-left', 0, 0, { height: mm(BLEED + SKY) }) },
...eclipse(), // the Sun hangs from the trim's corner, the Moon and the corona from the Sun
// The headline stands on the horizon and grows upwards: 'above' puts an element's
// bottom-left corner on the top-left corner of its anchor, so a longer title lifts the kicker.
text('deck', '{attr.deck}', FACE.deck, { ...at('#sky', 'align-bottom', INSET, -AIR),
size: { maxWidth: mm(153) } }), // shrink-wraps its lines, but wraps at 153 mm
text('title', '{titleText}', FACE.title, at('#deck', 'above', 0, -9)), // breaks at the \\
text('kicker', '{attr.kicker}', FACE.kicker, at('#title', 'above', 0, -7)),
...programme(), // four columns chained off the horizon
] } }); // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: poster() }
// {titleText} is the heading's text; each {attr.*} is written on its line in content.md.
Ingrédients
- Fonctionnalités
- Ancrer les éléments de maquetteTexte, filets et boîtes dans les maquettes de pageImages dans les maquettes de pageOuvertures composéesBandeau de chapitre sur toute la largeurBandeaux à fond perdu et ongletsStyles de titreTitres courants par sectionAttributs de titreSauts de ligne dans les titresPalette de couleurs sémantiqueStyles de paragrapheFigures et tableaux comme ressourcesPages sur un canvas
- Utilise aussi
- Fond perdu et traits de coupe
- Typographie
- Funnel Display, Funnel Sans, Martian Mono (SIL OFL 1.1)
- Ressources
- Aucune : chaque image est dessinée en code
Préparation
#1 · Fixez deux éléments à la feuille et accrochez-y le reste
Le code est dans la réponse courte plus haut. anchor.to y nomme ce à quoi un élément s’accroche : 'bleed' et 'page' sont des cadres de la feuille (la même boîte tant que page.cutLines est désactivé), 'container' est la boîte de l’emplacement lui-même et '#id' un autre élément (placement des éléments). Une boîte sans largeur s’étend jusqu’au bord opposé de son cadre : le ciel remplit donc le fond perdu d’un côté à l’autre. Un élément placé contre le ciel se mesure depuis le bord gauche du ciel, qui est celui du fond perdu : le chapeau prend donc INSET, 18 mm, pour x. Le titre se construit vers le haut à partir du bord inférieur du ciel. 'align-bottom' place le pied de la boîte du chapeau AIR, 14 mm, au-dessus de ce bord, et 'above' pose le pied de chaque élément sur le haut de celui qui est dessous : un titre qui prend plus de lignes soulève le surtitre au lieu de recouvrir le chapeau. Sous la maquette, les petits caractères commencent sur la première ligne de la grille de 16 pt située au moins 3,2 mm sous l’élément le plus bas, la ligne des intervenants ; ces 3,2 mm sont la marge inférieure par défaut du titre.
#2 · Accrochez la Lune et la couronne au Soleil
const SUN = 150; // mm across; the Moon is drawn the same size
const CORONA = 300; // mm, the square picture of the corona
const HALO = (CORONA - SUN) / 2; // how far the corona reaches past the Sun on every side
const BITE = { x: 2, y: 2.5 }; // mm the Moon sits off the Sun: a sliver is left at upper left
const disc = (id, colour, placement) => ({ kind: 'box', id, placement: { ...placement,
size: square(SUN) }, style: { backgroundColor: col(colour), borderRadius: mm(SUN / 2) } });
const eclipse = () => [
{ kind: 'image', id: 'corona', resourceId: 'corona', // listed before the discs: painted behind
placement: at('#sun', 'align-top', -HALO, -HALO, square(CORONA)) },
disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
disc('moon', 'ink', at('#sun', 'align-top', BITE.x, BITE.y)),
];
Le Soleil est le seul élément fixé à la page, la boîte de coupe. Mesuré depuis le coin 'top-right', un x positif dépasse le bord droit : 30 mm du disque sont donc coupés. La Lune et la couronne s’accrochent au Soleil avec 'align-top' : déplacer le Soleil déplace les deux. Leurs décalages se comptent depuis le coin supérieur gauche du Soleil : la Lune se place 2 mm à droite et 2,5 mm plus bas, ce qui laisse un mince croissant de Soleil en haut à gauche, et la couronne commence HALO, 75 mm, plus haut et plus à gauche, ce qui centre son carré de 300 mm sur le disque de 150 mm. La couronne est un élément image qui prend la taille de sa boîte (éléments image) et vient avant les disques dans la liste : elle est donc peinte derrière eux.
#3 · Chaînez les soirées en colonnes
const GUT = 7; // mm between the evenings, and between the two columns of text under them
const COL = (A3.width - 2 * MARGIN - 3 * GUT) / 4; // four columns across the text width: 60 mm
const DROP = 12; // mm from the horizon to the dates
const programme = () => [1, 2, 3, 4].flatMap((n) => [
text(`day${n}`, `{attr.d${n}}`, FACE.day, n === 1
? at('#sky', 'below', INSET, DROP, wide(COL)) // the first date hangs from the horizon
: at(`#day${n - 1}`, 'right-of', GUT, 0, wide(COL))), // the others from the one before
text(`talk${n}`, `{attr.t${n}}`, FACE.talk, at(`#day${n}`, 'below', 0, 3, wide(COL))),
text(`who${n}`, `{attr.s${n}}`, FACE.who, at(`#talk${n}`, 'below', 0, 2.5, wide(COL))),
]);
Chaque date, conférence et ligne d’intervenant a une size.width fixe de COL, et 'right-of' plus GUT place la date suivante une colonne plus loin. Sans largeur, une date se resserre sur ses chiffres (le « 8 » fait 17 mm de large) : le « 15 » commencerait 7 mm après, et chaque conférence courrait sur une seule ligne jusque dans la colonne suivante. COL divise la largeur du texte en quatre avec la même gouttière de 7 mm que les deux colonnes de petits caractères du dessous, ce qui place la deuxième colonne de petits caractères sous le 22. Chaque conférence s’accroche 'below' sa date et chaque ligne d’intervenant sous sa conférence : une conférence qui passerait sur une troisième ligne repousserait son propre intervenant et aucun autre.
#4 · Mettez le bandeau dans le pied de page, sur chaque page
const MARK = 10; // mm, the club's mark
const PAD = (FOOT - MARK) / 2; // centres the mark in the bottom margin
const markTall = { height: mm(MARK) }; // as tall as the mark: the line is centred on it
const footer = { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('sky') }, // the bottom margin, bled
placement: at('bleed', 'bottom-left', 0, 0, { height: mm(BLEED + FOOT) }) },
// The container runs from the text down to the trim: 'bottom-*' counts up, 'top-*' down.
{ kind: 'image', id: 'mark', resourceId: 'mark',
placement: at('container', 'bottom-left', 0, -PAD, square(MARK)) },
text('club', '{attr.club}', FACE.foot, at('#mark', 'right-of', 3, 0, markTall)),
text('free', '{attr.free}', FACE.foot, at('container', 'top-right', 0, PAD, markTall)),
] };
Tout ce qui doit s’imprimer sur chaque page, comme ce bandeau, va dans l’emplacement d’en-tête ou de pied de page, et un style de titre qui a son propre en-tête ou pied de page remplace cet emplacement sur les pages de sa section. Le pied de page du style des tracés répète ces quatre éléments avec leurs cadres et leurs étiquettes : c’est pourquoi le bandeau et l’emblème s’impriment sur les deux planches. Le conteneur du pied de page est la marge inférieure, du texte jusqu’au bord de coupe : 'bottom-left' compte vers le haut à partir du bord de coupe et 'top-right' vers le bas à partir du texte, et le −10 de l’emblème comme le +10 de la ligne « Free entry » tombent sur la même bande de 10 mm au milieu du bandeau (en-têtes et pieds de page). Le pied de page est peint après le texte et ne lui prend pas de place : FOOT règle donc à la fois la marge inférieure et la hauteur du bandeau, et le bandeau reste à distance des lignes au-dessus.
#5 · Tracez les lignes de construction à partir de la même configuration
const GUIDE = { borderColor: col('guide'), borderWidth: pt(1) };
const signed = ({ value }) => `${value < 0 ? '−' : '+'}${Math.abs(value)}`; // all offsets in mm
const describe = ({ id, placement: { anchor: { to, edge }, offset = {} } }) => [`#${id}`,
`${edge} ${to}`, // the two words of anchor: { to, edge }
['x', 'y'].filter((k) => offset[k]?.value).map((k) => `${k} ${signed(offset[k])}`).join(' '),
].filter(Boolean).join(' · ');
// A border never changes an element's size (padding does), so each frame traces its box.
// An image or a rounded box gets a square frame of its size, hung from its top-left corner.
const frame = (el) => (el.kind === 'text' ? [{ ...el, box: { ...el.box, ...GUIDE } }]
: el.kind === 'box' && !el.style.borderRadius ? [{ ...el, style: { ...el.style, ...GUIDE } }]
: [el, { kind: 'box', id: `${el.id}-box`, style: GUIDE,
placement: at(`#${el.id}`, 'align-top', 0, 0, el.placement.size) }]);
const tag = (el, [edge, x = 0, y = 0, note]) => text(`${el.id}-tag`, note ?? describe(el),
face('Martian Mono', 8.5, 500, 'ink', { box: { backgroundColor: col('guide'),
padding: { top: pt(1.6), bottom: pt(1.2), left: pt(3), right: pt(3) } } }),
at(`#${el.id}`, edge, x, y));
const guides = (elements, tags) => [...elements.flatMap(frame),
...elements.filter((el) => tags[el.id]).map((el) => tag(el, tags[el.id]))];
const container = (id) => ({ kind: 'box', id, style: GUIDE, // the slot's own box, drawn
placement: at('container', 'top-left', 0, 0, { width: 'fill', height: 'fill' }) });
const TAGS = { // where each tag sits against its element: [edge, x, y, text]
sky: ['align-bottom', INSET, -3], corona: ['align-bottom', 90, -2], sun: ['above'],
moon: ['below', 20, 2], kicker: ['above'], title: ['above'], deck: ['above'],
day1: ['above', 0, -6], day2: ['above'], who1: ['below'],
mark: ['above', 0, -2], club: ['above', 69, -2], free: ['above', -30, -2],
header: ['align-bottom', 0, -2, t({ en: 'header container', es: 'contenedor de la cabecera' })],
footer: ['align-bottom', 0, -2, t({ en: 'footer container', es: 'contenedor del pie' })],
};
guides() parcourt les éléments de l’affiche. Un texte reçoit une bordure sur sa boîte et une boîte carrée une bordure sur son style ; ni l’une ni l’autre ne déplace quoi que ce soit, car seul le remplissage agrandit une boîte. Une image ou un disque rond reçoit une boîte carrée de sa propre taille, accrochée à son coin supérieur gauche : le coin du Soleil auquel s’accrochent la Lune et la couronne est donc dessiné lui aussi. Chaque étiquette est un texte accroché à son élément, écrit par describe() à partir du placement de cet élément : une étiquette ne peut pas contredire la configuration qu’elle décrit. {style="guides"} sur le deuxième titre fait entrer cette maquette et un en-tête et un pied de page qui lui sont propres (styles de titre). Une étiquette sans largeur ne dispose que de la place entre son ancre et le bord droit du conteneur : c’est pourquoi l’étiquette de #free commence 30 mm à gauche de son texte. pageIndexOffset: 1, dans la continuation du contenu, fait de l’affiche un verso : la visionneuse place les deux planches côte à côte.
La recette complète
// ═══ Postext Cookbook · Nº 029 · Anchoring cheat sheet: a poster built from chained elements ═══ // https://postext.dev/en/cookbook/anchoring-cheat-sheet // Code: MIT · Text: original (CC BY 4.0) · Drawings: generated in code (CC BY 4.0) // Fonts: Funnel Display, Funnel Sans, Martian Mono (SIL OFL 1.1) · Needs postext ≥ 1.25.0 // An A3 poster whose elements hang from the bleed, the page, their slot or one another, never // from coordinates; the second sheet is the same poster with each element framed and tagged. import { buildDocumentWithFonts, renderPageToCanvas, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'anchoring-cheat-sheet'; // ─── 1 · Design ───────────────────────────────────────────────────────────── const palette = { ink: '#0b0b0c', // text, and the Moon sky: '#1b3bff', // the sky, the foot band and the dates sun: '#ffe53b', // the Sun and the kicker paper: '#ffffff', // type on the sky muted: '#5c5f66', // speakers and the colophon guide: '#ff2d9b', // frames and tags on the construction sheet }; // col(id): a colour linked to its palette entry, so a swapped palette recolours the sheet. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), { id: 'main-color', name: 'sky (defaults)', value: { hex: palette.sky, model: 'hex' } }, ]; const A3 = { width: 297, height: 420 }; // mm, the trim const MARGIN = 18; // mm at the top and sides: the body's edges, and the header container's const FOOT = 30; // mm, the bottom margin: the footer container and its band const BLEED = 0; // mm; 3 for the printer, which switches on page.cutLines below // at(): a placement. Offsets are distances from the anchor point, never page coordinates. const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); const wide = (w) => ({ width: mm(w) }); // fixed, so a text is as wide as its column const square = (d) => ({ width: mm(d), height: mm(d) }); // Design text is centred by default, and in a footer it is cut with '…': these wrap, set left. const text = (id, content, face, placement) => ({ kind: 'text', id, content, overflow: 'wrap', align: 'left', ...face, placement }); const face = (fontFamily, size, fontWeight, colour, more) => ({ fontFamily, fontSize: pt(size), fontWeight, color: col(colour), ...more }); const caps = (tracking) => ({ letterSpacing: pt(tracking), textTransform: 'uppercase' }); const FACE = { // lineHeight as a multiple of each size kicker: face('Martian Mono', 12, 500, 'sun', caps(1.8)), title: face('Funnel Display', 120, 800, 'paper', { lineHeight: 0.86 }), deck: face('Funnel Sans', 22, 400, 'paper', { lineHeight: 1.22 }), day: face('Funnel Display', 84, 800, 'sky', { lineHeight: 0.9 }), talk: face('Funnel Sans', 19, 600, 'ink', { lineHeight: 1.14 }), who: face('Funnel Sans', 12.5, 400, 'muted', { lineHeight: 1.3, italic: true }), foot: face('Martian Mono', 9, 500, 'paper', caps(0.9)), }; // #region answer: two elements pinned to frames of the sheet, every other one to an element const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge const INSET = BLEED + MARGIN; // from the bleed's edge to the text's edge const AIR = 14; // mm between the standfirst and the horizon const poster = () => ({ enabled: true, slot: { elements: [ // array order is paint order // 'bleed' and 'page' are frames of the sheet; 'container' would be this heading's own box. { kind: 'box', id: 'sky', style: { backgroundColor: col('sky') }, // no width: fills the bleed placement: at('bleed', 'top-left', 0, 0, { height: mm(BLEED + SKY) }) }, ...eclipse(), // the Sun hangs from the trim's corner, the Moon and the corona from the Sun // The headline stands on the horizon and grows upwards: 'above' puts an element's // bottom-left corner on the top-left corner of its anchor, so a longer title lifts the kicker. text('deck', '{attr.deck}', FACE.deck, { ...at('#sky', 'align-bottom', INSET, -AIR), size: { maxWidth: mm(153) } }), // shrink-wraps its lines, but wraps at 153 mm text('title', '{titleText}', FACE.title, at('#deck', 'above', 0, -9)), // breaks at the \\ text('kicker', '{attr.kicker}', FACE.kicker, at('#title', 'above', 0, -7)), ...programme(), // four columns chained off the horizon ] } }); // hook-up: headings.levels[0] = { span: 'page', breakBefore, advancedDesign: poster() } // {titleText} is the heading's text; each {attr.*} is written on its line in content.md. // #endregion // #region eclipse: the Sun pinned to the trim's corner, the Moon and the corona to the Sun const SUN = 150; // mm across; the Moon is drawn the same size const CORONA = 300; // mm, the square picture of the corona const HALO = (CORONA - SUN) / 2; // how far the corona reaches past the Sun on every side const BITE = { x: 2, y: 2.5 }; // mm the Moon sits off the Sun: a sliver is left at upper left const disc = (id, colour, placement) => ({ kind: 'box', id, placement: { ...placement, size: square(SUN) }, style: { backgroundColor: col(colour), borderRadius: mm(SUN / 2) } }); const eclipse = () => [ { kind: 'image', id: 'corona', resourceId: 'corona', // listed before the discs: painted behind placement: at('#sun', 'align-top', -HALO, -HALO, square(CORONA)) }, disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge disc('moon', 'ink', at('#sun', 'align-top', BITE.x, BITE.y)), ]; // #endregion // #region programme: four evenings in fixed-width columns, chained right-of and below const GUT = 7; // mm between the evenings, and between the two columns of text under them const COL = (A3.width - 2 * MARGIN - 3 * GUT) / 4; // four columns across the text width: 60 mm const DROP = 12; // mm from the horizon to the dates const programme = () => [1, 2, 3, 4].flatMap((n) => [ text(`day${n}`, `{attr.d${n}}`, FACE.day, n === 1 ? at('#sky', 'below', INSET, DROP, wide(COL)) // the first date hangs from the horizon : at(`#day${n - 1}`, 'right-of', GUT, 0, wide(COL))), // the others from the one before text(`talk${n}`, `{attr.t${n}}`, FACE.talk, at(`#day${n}`, 'below', 0, 3, wide(COL))), text(`who${n}`, `{attr.s${n}}`, FACE.who, at(`#talk${n}`, 'below', 0, 2.5, wide(COL))), ]); // #endregion // #region footer: a band and the club's mark in the footer slot, which prints on every page const MARK = 10; // mm, the club's mark const PAD = (FOOT - MARK) / 2; // centres the mark in the bottom margin const markTall = { height: mm(MARK) }; // as tall as the mark: the line is centred on it const footer = { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('sky') }, // the bottom margin, bled placement: at('bleed', 'bottom-left', 0, 0, { height: mm(BLEED + FOOT) }) }, // The container runs from the text down to the trim: 'bottom-*' counts up, 'top-*' down. { kind: 'image', id: 'mark', resourceId: 'mark', placement: at('container', 'bottom-left', 0, -PAD, square(MARK)) }, text('club', '{attr.club}', FACE.foot, at('#mark', 'right-of', 3, 0, markTall)), text('free', '{attr.free}', FACE.foot, at('container', 'top-right', 0, PAD, markTall)), ] }; // #endregion // #region guides: the same slots, each element framed and tagged with its own placement const GUIDE = { borderColor: col('guide'), borderWidth: pt(1) }; const signed = ({ value }) => `${value < 0 ? '−' : '+'}${Math.abs(value)}`; // all offsets in mm const describe = ({ id, placement: { anchor: { to, edge }, offset = {} } }) => [`#${id}`, `${edge} ${to}`, // the two words of anchor: { to, edge } ['x', 'y'].filter((k) => offset[k]?.value).map((k) => `${k} ${signed(offset[k])}`).join(' '), ].filter(Boolean).join(' · '); // A border never changes an element's size (padding does), so each frame traces its box. // An image or a rounded box gets a square frame of its size, hung from its top-left corner. const frame = (el) => (el.kind === 'text' ? [{ ...el, box: { ...el.box, ...GUIDE } }] : el.kind === 'box' && !el.style.borderRadius ? [{ ...el, style: { ...el.style, ...GUIDE } }] : [el, { kind: 'box', id: `${el.id}-box`, style: GUIDE, placement: at(`#${el.id}`, 'align-top', 0, 0, el.placement.size) }]); const tag = (el, [edge, x = 0, y = 0, note]) => text(`${el.id}-tag`, note ?? describe(el), face('Martian Mono', 8.5, 500, 'ink', { box: { backgroundColor: col('guide'), padding: { top: pt(1.6), bottom: pt(1.2), left: pt(3), right: pt(3) } } }), at(`#${el.id}`, edge, x, y)); const guides = (elements, tags) => [...elements.flatMap(frame), ...elements.filter((el) => tags[el.id]).map((el) => tag(el, tags[el.id]))]; const container = (id) => ({ kind: 'box', id, style: GUIDE, // the slot's own box, drawn placement: at('container', 'top-left', 0, 0, { width: 'fill', height: 'fill' }) }); const TAGS = { // where each tag sits against its element: [edge, x, y, text] sky: ['align-bottom', INSET, -3], corona: ['align-bottom', 90, -2], sun: ['above'], moon: ['below', 20, 2], kicker: ['above'], title: ['above'], deck: ['above'], day1: ['above', 0, -6], day2: ['above'], who1: ['below'], mark: ['above', 0, -2], club: ['above', 69, -2], free: ['above', -30, -2], header: ['align-bottom', 0, -2, t({ en: 'header container', es: 'contenedor de la cabecera' })], footer: ['align-bottom', 0, -2, t({ en: 'footer container', es: 'contenedor del pie' })], }; // #endregion const LEAD = 16; // body leading in pt const config = () => ({ colorPalette, locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle page: { width: mm(A3.width), height: mm(A3.height), dpi: 150, // 150 dpi is for the screen cutLines: { enabled: BLEED > 0, bleed: mm(BLEED) }, margins: { top: mm(MARGIN), bottom: mm(FOOT), left: mm(MARGIN), right: mm(MARGIN) } }, layout: { layoutType: 'double', gutterWidth: mm(GUT) }, // the text columns under the evenings bodyText: { fontFamily: 'Funnel Sans', fontSize: pt(12), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, // The hidden title is still measured, so it needs a face FONTS loads. headings: { fontFamily: 'Funnel Display', fontWeight: 800, levels: [{ level: 1, span: 'page', advancedDesign: poster(), breakBefore: { enabled: true, parity: 'any' } }] }, // each poster on a new sheet // {style="guides"}: the same design framed and tagged, and a header and footer of its own. headingStyles: [{ id: 'guides', advancedDesign: { enabled: true, slot: { elements: guides(poster().slot.elements, TAGS) } }, header: { elements: guides([container('header')], TAGS) }, footer: { elements: guides([...footer.elements, container('footer')], TAGS) } }], paragraphStyles: [{ id: 'colophon', fontFamily: 'Martian Mono', fontSize: pt(8), lineHeight: pt(LEAD * 0.75), color: col('muted') }], header: { elements: [] }, // the poster has none; the guides page draws the empty container footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`# The\\Shadow\\Returns {kicker="April 2027 · Thursdays at 19:30" deck="Four talks to get ready for the total eclipse of 2 August 2027, when the Moon’s shadow crosses the Strait of Gibraltar." d1="8" t1="Why the Moon fits the Sun" s1="Marta Iribarren, astronomer" d2="15" t2="Where the shadow will fall" s2="Óscar Beltrán, meteorologist" d3="22" t3="What the corona tells us" s3="Lucía Ferrándiz, physicist" d4="29" t4="Looking at the Sun without harm" s4="Tomás Rey, optometrist" club="Umbra Circle · amateur astronomers since 1987" free="Free entry · no booking"}Exemple Markdown · 14 lignes · content.en.md
Every lecture begins at 19:30 and lasts about an hour, questions included. The Umbra Circle hall is at 14 Lighthouse Road; the doors open at 19:00, entry is free and there is no booking, so the 120 seats go to whoever comes first. On 29 April every listener takes home a pair of eclipse glasses made to ISO 12312-2: wear them through each partial phase, and take them off only while the Sun is completely covered. On eclipse morning the Circle sets up filtered telescopes on the harbour wall. # The\\Shadow\\Returns {style="guides" kicker="April 2027 · Thursdays at 19:30" deck="Four talks to get ready for the total eclipse of 2 August 2027, when the Moon’s shadow crosses the Strait of Gibraltar." d1="8" t1="Why the Moon fits the Sun" s1="Marta Iribarren, astronomer" d2="15" t2="Where the shadow will fall" s2="Óscar Beltrán, meteorologist" d3="22" t3="What the corona tells us" s3="Lucía Ferrándiz, physicist" d4="29" t4="Looking at the Sun without harm" s4="Tomás Rey, optometrist" club="Umbra Circle · amateur astronomers since 1987" free="Free entry · no booking"} **How to read this sheet.** Each pink frame is an element’s box as laid out, and a tag gives an element’s id, the edge it is pinned by, what it is pinned to and any offset in millimetres. Above the footer only two elements hang from the sheet itself: the sky from the bleed and the Sun from the page, which is the trim. Every other one hangs from an element, so a longer title lifts the kicker, and the four evenings move with the horizon. The frames in the top and bottom margins are the header and footer containers. The footer container runs from the text down to the trim, so its bottom anchors count up from the trim and its top anchors down from the text. :::paragraphs{style="colophon"} Set in Funnel Display, Funnel Sans and Martian Mono (SIL OFL) · Text and drawings: original, CC BY 4.0 · The Umbra Circle and its speakers are fictional. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the corona and the club's mark, drawn in code, and their resources function mulberry32(seed) { // a seeded generator: the same corona on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function coronaSvg() { // a glow, then 14 streamers, 70 polar plumes and 160 fine rays const rand = mulberry32(20270802); const R = (500 * SUN) / CORONA; // the Sun's radius in the 1000-unit square const TILT = Math.PI / 4; // the Sun's equator: long streamers go up-left, clear of the kicker const TYPE = [2.2, 3.05]; // radians from the centre where the type is: rays there stop at 9 mm const p = (r, a) => `${(500 + r * Math.cos(a)).toFixed(1)} ${(500 + r * Math.sin(a)).toFixed(1)}`; const ray = (a, full, half, opacity) => { // a petal from the limb, tapered to a point const turn = ((a % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); const length = turn > TYPE[0] && turn < TYPE[1] ? Math.min(full, R * 0.12) : full; const bend = (rand() - 0.5) * 0.06; return `<path d="M${p(R * 0.97, a - half)}C${p(R + length * 0.35, a - half * 1.1)} ` + `${p(R + length * 0.7, a + bend - half * 0.25)} ${p(R + length, a + bend)}` + `C${p(R + length * 0.7, a + bend + half * 0.25)} ${p(R + length * 0.35, a + half * 1.1)} ` + `${p(R * 0.97, a + half)}Z" fill="${palette.paper}" fill-opacity="${opacity.toFixed(3)}"/>`; }; const glow = Array.from({ length: 28 }, (_, i) => `<circle cx="500" cy="500" ` + `r="${(R * (1.01 + i * 0.022)).toFixed(1)}" fill="${palette.paper}" fill-opacity="0.022"/>`) .reverse().join(''); const out = []; for (let i = 0; i < 14; i++) { // helmet streamers, two fans across the equator const a = TILT + (i % 2) * Math.PI + (rand() - 0.5) * 1.1; out.push(ray(a, R * (0.55 + 0.45 * rand()), 0.1 + 0.14 * rand(), 0.06 + 0.06 * rand())); } for (let i = 0; i < 70; i++) { // polar plumes, shorter and thinner than the streamers const a = TILT + Math.PI / 2 + (i % 2) * Math.PI + (rand() - 0.5) * 1.3; out.push(ray(a, R * (0.18 + 0.3 * rand()), 0.008 + 0.012 * rand(), 0.1 + 0.12 * rand())); } for (let i = 0; i < 160; i++) { // fine rays all round const a = (i / 160) * 2 * Math.PI + (rand() - 0.5) * 0.04; out.push(ray(a, R * (0.12 + 0.3 * rand()), 0.006 + 0.014 * rand(), 0.05 + 0.07 * rand())); } return `<svg xmlns="http://www.w3.org/2000/svg" width="1500" height="1500" ` + `viewBox="0 0 1000 1000">${glow}${out.join('')}</svg>`; } function markSvg() { // the Umbra Circle's mark: a ring round an eclipsed Sun return `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 100 100">` + `<circle cx="50" cy="50" r="45" fill="none" stroke="${palette.paper}" stroke-width="6"/>` + `<circle cx="50" cy="50" r="27" fill="${palette.sun}"/>` + `<circle cx="56" cy="45" r="27" fill="${palette.sky}"/></svg>`; } const svg = (id, size, altText) => ({ id, typeId: 'figure', kind: 'svg', altText, svg: { fileId: `${id}.svg`, width: size, height: size }, createdAt: 0, updatedAt: 0 }); // Image elements draw resources. Nothing cites them, so neither is placed or numbered as a figure. const resources = [ svg('corona', 1500, t({ en: 'The solar corona round the eclipsed Sun', es: 'La corona solar alrededor del Sol eclipsado' })), svg('mark', 200, t({ en: 'The Umbra Circle’s mark', es: 'El emblema del Círculo Umbra' })), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the pages paint, loaded before the first build. const FONTS = { 'Funnel Display': ['800'], 'Funnel Sans': ['400', '400i', '600', '700'], 'Martian Mono': ['400', '500'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadSvg('corona.svg', coronaSvg()); await loadSvg('mark.svg', markSvg()); // pageIndexOffset 1 makes the poster a verso, so the viewer sets the two sheets side by side. const content = { markdown, resources, continuation: { pageIndexOffset: 1 } }; const doc = await buildDocumentWithFonts(content, config(), kitFonts(FONTS)); showPages(doc, { title: t({ en: 'Anchoring cheat sheet', es: 'Chuleta de anclajes' }) });Kit · core, fonts, viewer, images : identique dans toutes les recettes · 270 lignes
// ─── 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 v3 ── the same in every recipe · postext.dev/cookbook // Postext loads the faces a config asks for before layout (prepareFonts, // buildDocumentWithFonts) and asks the kit for the files: Fontsource's, the // files the PDF embeds too. /** The options of buildDocumentWithFonts / prepareFonts / withLoadedFonts: * the Fontsource resolver, and the faces of FONTS loaded with the ones the * config names. FONTS = { 'Family Name': ['400', '400i', '700'] }. */ function kitFonts(FONTS = {}) { const faces = Object.entries(FONTS).flatMap(([family, specs]) => specs.map((spec) => ({ family, weight: parseInt(spec, 10), style: spec.endsWith('i') ? 'italic' : 'normal' }))); return { resolve: fontsourceResolver, faces }; } /** A face's Fontsource files, in a fixed order: latin, then latin-ext and greek * when `text` needs them and the family ships them (kitSubsetsFor), each with * its unicode-range. Null for a weight or slant Fontsource does not ship. */ async function fontsourceResolver(family, weight, style, { text = '' } = {}) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); if (meta && !(meta.weights.includes(weight) && meta.styles.includes(style))) return null; kitStatus('Loading fonts…'); const at = await fontsourceAt(id); return ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ source: `${at}files/${id}-${subset}-${weight}-${style}.woff2`, unicodeRange: kitRanges()[subset], })); } /** The unicode-range of each Fontsource file the kit loads (a function: the * kit is inlined after the pen's code, which runs before a const would be set). */ function kitRanges() { return { 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', greek: 'U+0370-03FF', }; } /** For the cjk, arabic and comics blocks, which add their own files: the * faces of `faces` = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * The files are added once all have loaded, in the order asked (latin, then * latin-ext, then greek), whatever order they arrive in: the last one added * wins a character two files hold. Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = kitRanges(); const jobs = []; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const at = fontsourceAt(id); const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `${await at}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().catch(() => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } const ready = await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return ready.filter((face) => face && document.fonts.add(face)).length; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** A package's folder at the one version @5 names now (#766). */ function fontsourceAt(id, u = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@`) { return (fontsourceAt.c ??= {})[id] ??= fetch(u + '5/package.json').then((r) => r.json()) .then((p) => p.version || 5, () => 5).then((v) => `${u}${v}/`); } /** The family's Fontsource metadata (weights, styles, subsets), 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 v2 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. Other options go to renderPageToCanvas. */ function showPages(docs, { title, width = 460, ...render } = {}) { 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, { ...render, 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 v3 ── 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 as a vector image, its fonts inside (inlineSvgFonts, ≥ 1.25). * `options` go to registerResourceImage: { singleInk: true } tints it on the canvas. */ async function loadSvg(fileId, svg, options) { if (typeof inlineSvgFonts === 'function') svg = await inlineSvgFonts(svg, svgFaces); const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img, options); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** A face's Fontsource files: nearest weight; latin + what the SVG sets. */ async function svgFaces(family, weight, style, { codePoints = [] } = {}) { const [id, m] = [fontsourceId(family), await fontsourceMeta(family)]; const w = m?.weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)) ?? weight; const s = m?.styles.includes(style) === false ? 'normal' : style; return Promise.all(['latin', ...kitSubsetsFor(String.fromCodePoint(...codePoints), m)].map(async (x) => new Uint8Array( await (await fetch(`${await fontsourceAt(id)}files/${id}-${x}-${w}-${s}.woff2`)).arrayBuffer()))); } /** 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 ───────────────────────────────────────────────────────────────────────
Le script.js composé s’exécute tel quel : collez-le dans le script de module de n’importe quelle page, ou ouvrez la recette sur CodePen. Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)
Variantes
#Ajoutez 3 mm de fond perdu et des traits de coupe
Trois millimètres de BLEED activent les traits de coupe et repoussent le ciel et le bandeau de pied de 3 mm dans le fond perdu auquel ils s’accrochent, tandis qu’INSET garde le texte sur la marge ; le Soleil, la Lune et la couronne s’accrochent à la page, et tout ce qu’une page peint est rogné au fond perdu : le Soleil s’arrête 3 mm au-delà du bord de coupe et la zone de repères ne contient que les traits de coupe.
-const BLEED = 0; // mm; 3 for the printer, which switches on page.cutLines below
+const BLEED = 3; // mm; 3 for the printer, which switches on page.cutLines below#Remontez l’horizon
Retirer 20 mm à SKY remonte le bord du ciel, et avec lui le chapeau, le titre, le surtitre et les douze textes du programme, tandis que les petits caractères commencent sur une ligne de grille plus haute ; le Soleil s’accroche à la page, si bien que la deuxième modification le remonte des mêmes 20 mm, faute de quoi le « w » de Shadow toucherait le croissant.
-const SKY = 272; // mm from the trim's top edge to the horizon, the sky's lower edge
+const SKY = 252; // mm from the trim's top edge to the horizon, the sky's lower edge
- disc('sun', 'sun', at('page', 'top-right', 30, 25)), // 30 mm of it past the right edge
+ disc('sun', 'sun', at('page', 'top-right', 30, 5)), // 30 mm of it past the right edgeErreurs fréquentes
Erreur fréquente
Les éléments d’en-tête et de pied de page se peignent par-dessus le texte
Les éléments d’en-tête et de pied de page sont peints par-dessus la page et la zone de texte ne leur fait pas de place. Gardez-les dans les marges, qui sont ce qui leur réserve de l’espace. Titres courants et folios →
Erreur fréquente
Valeurs d’attribut : pas de { ni de } ; une valeur avec " entre apostrophes simples
Une valeur d’attribut s’arrête à l’accolade fermante : elle ne peut donc contenir ni { ni }. Une valeur qui contient un guillemet double se met entre apostrophes simples ; un signe dollar ne pose pas de problème. Attributs de titre →
Vérification du Sandbox · designCyclicAnchor
Référence d’ancre cyclique
Pourquoi. Des éléments de maquette s’ancrent les uns aux autres en boucle : aucun d’eux ne peut être placé.
Correction. Ancrez un élément de la chaîne au conteneur, à la page ou au fond perdu. Docs →
Vérification du Sandbox · designDanglingAnchor
Référence d’ancre orpheline
Pourquoi. Un élément de maquette s’ancre à un #id qu’aucun élément de l’emplacement ne porte.
Correction. Corrigez l’identifiant, ou ajoutez l’élément auquel il renvoie. Docs →
- Un pen ne reçoit aucun rapport sur une ancre circulaire ou orpheline. Le panneau Vérifications du Sandbox les signale dans l’en-tête, le pied de page, les pages de partie et les niveaux de titre, mais pas dans les styles de titre comme
guides. Un élément fixé à un#idqui n’existe pas est placé contre le conteneur : un bord d’élément comme'below'se replie sur le coin supérieur gauche du conteneur, décalage compris, et un bord de conteneur comme'top-right'sur ce coin-là. Dans une boucle, un élément se replie de la même façon et les autres s’enchaînent à partir de lui. 'align-left'et'align-top'aboutissent au même point : tous deux posent le coin supérieur gauche de l’élément sur le coin supérieur gauche de son ancre.- Une bordure est peinte centrée sur le bord de la boîte : chaque cadre rose de 1 pt déborde donc de 0,5 pt hors de la boîte qu’il trace, bien que la documentation de la configuration dise que les traits sont peints à l’intérieur.
Crédits
- Recette
- Ignacio Ferro
- Texte
- Texte original, CC BY 4.0
- Polices
- Funnel Display (SIL OFL 1.1) · Funnel Sans (SIL OFL 1.1) · Martian Mono (SIL OFL 1.1)
Modifier ce texte ↗ (s’ouvre dans un nouvel onglet)Dossier de la recette sur GitHub ↗ (s’ouvre dans un nouvel onglet)


