Aller au contenu principal
Recette numéro 88

Recettes · Chapitre 5 · Structure du livre

Un article de conférence sur deux colonnes, style IEEE

Article d’atelier sur deux colonnes : bloc de titre sur toute la page, citations IEEE avec plages [2]–[4], renvois « Section II » et liste numérotée compacte.

Sur cette page
  • Exemple en anglais : pas encore d’édition française
  • Format 215,9 × 279,4 mm
  • 2 colonnes, Gouttière de 6,3 mm
  • STIX Two Text 10/12
  • Schibsted Grotesk
  • 2 pages
  • Niveau
  • Postext 1.31.0
  • Mis en page en 328 ms
  • 127 lignes de code

En bref

Un court article de recherche pour une conférence, sur deux colonnes. L’auteur écrit un code par source ; Postext numérote les citations entre crochets, renvoie aux sections par numéro et page et imprime la liste numérotée des références.

Ce que vous allez composer

Un article de deux pages pour un petit atelier d’ingénierie documentaire, composé comme les actes de conférence de l’IEEE : format US Letter, deux colonnes de trois pouces et demi, un caractère de type Times en 10 pt, des sections numérotées I, II, III en capitales centrées. Le titre, les trois blocs d’auteurs, le résumé et les mots-clés courent sur les deux colonnes, sous une bande pâle dans le bleu de l’atelier, seule couleur des pages. Les auteurs citent avec des clés BibTeX ; Postext imprime [1] dans l’ordre de première citation des ouvrages, réunit trois ouvrages consécutifs en [2]–[4], écrit « Knuth and Plass [1] » quand les noms font partie de la phrase et construit la liste numérotée de la fin en 8 pt. Les renvois aux sections impriment « Section IV, on p. 2 » et restent justes quand le texte bouge.

Cette recette répond à

  • Comment citer des ouvrages et construire la bibliographie en APA, en IEEE ou dans un autre style de citation ?
  • Comment renvoyer à une section et à la page où elle se trouve, et garder les deux justes quand le livre change ?

La réponse courte

script.js · lignes 32–46dans le code complet
// [@key] prints [1] in order of first citation, @key in the sentence "Knuth and Plass [1]".
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
// The bundled IEEE style lists [2], [3], [4]; the IEEE editorial guide writes [2]–[4].
// One attribute on the CSL <citation> element makes citeproc-js join the run.
const ieee = STYLES.ieee.replace('<citation>', '<citation collapse="citation-number">');
const citations = {
  style: 'custom', customStyle: ieee, // or style: 'ieee' for the file as it ships
  link: true,
  bibliography: {
    fontSize: em(0.8), lineHeight: pt(9.4), // 8 pt on 9.4 pt, two sizes under the text
    labelWidth: mm(6), // the [n] column: wide enough for [10], the turnovers align after it
    entrySpacing: pt(1.2),
    doi: 'text', // printed, not linked: the PDF stays black
  },
};

Ingrédients

Typographie
STIX Two Text, Schibsted Grotesk (SIL OFL 1.1)
Ressources
Aucune : chaque image est dessinée en code

Préparation

#1 · Le style IEEE, avec ses plages

Le code est dans la réponse courte plus haut. Enregistré une seule fois, postext-citeproc met en forme chaque [@key] avec citeproc-js et le style CSL de l’IEEE, en numérotant les ouvrages dans l’ordre de leur première citation. Le fichier de style fourni avec le moteur liste trois ouvrages consécutifs sous la forme [2], [3], [4] ; le guide éditorial de l’IEEE les réunit en [2]–[4]. Ajouter collapse="citation-number" à l’élément <citation> du style et passer le résultat comme customStyle suffit pour que citeproc-js le fasse. labelWidth donne aux numéros de la liste une colonne à eux, de sorte que la deuxième ligne d’une entrée commence sous le premier mot et non sous le crochet.

#2 · Un bloc de titre sur les deux colonnes

script.js · lignes 50–72dans le code complet
const text = (id, content, size, extra) => ({ kind: 'text', id, content, fontFamily: SERIF,
  fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap', ...extra });
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: 'auto' } }) });
const AUTHOR_W = MEASURE / 3;
const titleBlock = {
  enabled: true,
  minHeight: mm(64), // venue line, two lines of title and five of author block
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('tint') }, // from the trim's top
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(TOP + 66) } } },
    text('venue', '{attr.venue}', 7.5, { fontFamily: SANS, fontWeight: 600, color: col('accent'),
      letterSpacing: pt(1.2), textTransform: 'uppercase',
      placement: at('container', 'top-left', 0, 0, MEASURE) }),
    { kind: 'rule', id: 'venue-rule', thickness: pt(0.5), color: col('rule'),
      placement: at('#venue', 'below', 0, 2, MEASURE) },
    text('title', '{titleText}', 23, { lineHeight: 1.1, placement: at('#venue', 'below', 0, 7,
      MEASURE) }),
    ...['a1', 'a2', 'a3'].map((id, i) => text(id, `{attr.${id}}`, 9.5, { lineHeight: 1.25,
      placement: at('#title', 'below', i * AUTHOR_W, 6, AUTHOR_W) })),
  ] },
};

Le titre est l’unique titre de niveau 1 du document, avec le style paper. Ce style s’étend sur toute la page et dessine le titre à partir de sa maquette : la ligne de la manifestation, le titre et trois blocs d’auteurs larges d’un tiers de la justification, chacun lu dans un attribut du titre où \n force les retours à la ligne. La bande est une boîte ancrée à la coupe, si bien qu’elle atteint le bord supérieur du papier.

#3 · Résumé et mots-clés en gras

script.js · lignes 76–82dans le code complet
const calloutStyles = [{ id: 'abstract', span: 'page', backgroundEnabled: false,
  border: { enabled: false }, stripe: { enabled: true, side: 'top', width: pt(0.5),
    color: col('rule') },
  padding: { top: mm(3), right: mm(14), bottom: mm(1), left: mm(14) },
  marginTop: pt(0), marginBottom: pt(LEAD),
  body: { fontSize: pt(9), lineHeight: pt(11), fontWeight: 700, firstLineIndent: pt(0),
    textAlign: 'justify', paragraphSpacing: true } }];

Un encadré sur toute la largeur de la page porte le résumé et les mots-clés. Son corps est en 9 pt gras, comme chez l’IEEE, et ***Abstract*—** dans le texte donne l’étiquette en italique avec son tiret cadratin. Le filet fin du haut est le liseré de l’encadré, et le remplissage réduit la justification à une centaine de caractères.

#4 · Sections numérotées et renvois

script.js · lignes 86–97dans le code complet
const levels = [
  { level: 1, breakBefore: { enabled: true, parity: 'any' } }, // any page: no forced recto
  { level: 2, numberingTemplate: '{2:I}.', numberSeparator: ' ', fontSize: pt(9),
    lineHeight: pt(LEAD), fontWeight: 400, letterSpacing: em(0.06), textTransform: 'uppercase',
    marginTop: pt(LEAD), marginBottom: pt(0) }, // a line above, none below: at a column's head
  // the margin above drops and the text still starts on the next grid line
];
const headingStyles = [
  { id: 'paper', numbered: false, span: 'page', advancedDesign: titleBlock },
  { id: 'back', numbered: false }, // Acknowledgment and References: no number
];
const crossRefs = { section: t({ en: 'Section {n}', es: 'sección {n}' }) };

{2:I}. numérote les sections I., II., III. Un renvoi imprime le numéro sans son point : :ref{id="sec:results"} donne « Section IV » à partir du modèle crossRefs, et :ref{id="sec:results" style=page} donne « p. 2 ». Les deux se calculent sur les pages composées, ils suivent donc le texte si l’on ajoute un paragraphe ou si une section se déplace. Les titres « Acknowledgment » et « References » prennent le style back et restent sans numéro.

script.js · lignes 16–22dans le code complet
const palette = {
  ink: '#16181d', accent: '#1d4a7a', tint: '#e9eef5', rule: '#aeb6c2', muted: '#5a606b',
  paper: '#ffffff',
};
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' } }));

La recette complète

Sandbox
// ═══ Postext Cookbook · Nº 088 · A two-column conference paper in IEEE style ═══════
// https://postext.dev/en/cookbook/ieee-conference-paper
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: STIX Two Text, Schibsted Grotesk (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerCitationEngine,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
import { createCiteprocEngine, STYLES, LOCALES } from 'https://esm.sh/postext-citeproc';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'ieee-conference-paper';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: black type on white, with one blue for the workshop's own marks
const palette = {
  ink: '#16181d', accent: '#1d4a7a', tint: '#e9eef5', rule: '#aeb6c2', muted: '#5a606b',
  paper: '#ffffff',
};
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' } }));
// #endregion
const [SERIF, SANS] = ['STIX Two Text', 'Schibsted Grotesk'];
// The IEEE conference template in mm: US letter, 0.75 in head, 1 in foot, 0.625 in sides,
// two columns of 3.5 in with 0.25 in between.
const [TRIM_W, TRIM_H, TOP, BOTTOM, SIDE, GUTTER] = [215.9, 279.4, 19, 25.4, 15.9, 6.35];
const MEASURE = TRIM_W - 2 * SIDE;
const LEAD = 12; // pt: 10 pt type on 12 pt, as the template sets it

// #region answer: IEEE numbers, collapsed ranges and a references list with a label column
// [@key] prints [1] in order of first citation, @key in the sentence "Knuth and Plass [1]".
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
// The bundled IEEE style lists [2], [3], [4]; the IEEE editorial guide writes [2]–[4].
// One attribute on the CSL <citation> element makes citeproc-js join the run.
const ieee = STYLES.ieee.replace('<citation>', '<citation collapse="citation-number">');
const citations = {
  style: 'custom', customStyle: ieee, // or style: 'ieee' for the file as it ships
  link: true,
  bibliography: {
    fontSize: em(0.8), lineHeight: pt(9.4), // 8 pt on 9.4 pt, two sizes under the text
    labelWidth: mm(6), // the [n] column: wide enough for [10], the turnovers align after it
    entrySpacing: pt(1.2),
    doi: 'text', // printed, not linked: the PDF stays black
  },
};
// #endregion

// #region title: the title block across both columns, three authors side by side
const text = (id, content, size, extra) => ({ kind: 'text', id, content, fontFamily: SERIF,
  fontSize: pt(size), color: col('ink'), align: 'center', overflow: 'wrap', ...extra });
const at = (to, edge, x, y, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(width && { size: { width: mm(width), height: 'auto' } }) });
const AUTHOR_W = MEASURE / 3;
const titleBlock = {
  enabled: true,
  minHeight: mm(64), // venue line, two lines of title and five of author block
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('tint') }, // from the trim's top
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(TOP + 66) } } },
    text('venue', '{attr.venue}', 7.5, { fontFamily: SANS, fontWeight: 600, color: col('accent'),
      letterSpacing: pt(1.2), textTransform: 'uppercase',
      placement: at('container', 'top-left', 0, 0, MEASURE) }),
    { kind: 'rule', id: 'venue-rule', thickness: pt(0.5), color: col('rule'),
      placement: at('#venue', 'below', 0, 2, MEASURE) },
    text('title', '{titleText}', 23, { lineHeight: 1.1, placement: at('#venue', 'below', 0, 7,
      MEASURE) }),
    ...['a1', 'a2', 'a3'].map((id, i) => text(id, `{attr.${id}}`, 9.5, { lineHeight: 1.25,
      placement: at('#title', 'below', i * AUTHOR_W, 6, AUTHOR_W) })),
  ] },
};
// #endregion

// #region abstract: abstract and index terms in bold 9 pt, between two hairlines
const calloutStyles = [{ id: 'abstract', span: 'page', backgroundEnabled: false,
  border: { enabled: false }, stripe: { enabled: true, side: 'top', width: pt(0.5),
    color: col('rule') },
  padding: { top: mm(3), right: mm(14), bottom: mm(1), left: mm(14) },
  marginTop: pt(0), marginBottom: pt(LEAD),
  body: { fontSize: pt(9), lineHeight: pt(11), fontWeight: 700, firstLineIndent: pt(0),
    textAlign: 'justify', paragraphSpacing: true } }];
// #endregion

// #region sections: I. INTRODUCTION, centred capitals; references print "Section II"
const levels = [
  { level: 1, breakBefore: { enabled: true, parity: 'any' } }, // any page: no forced recto
  { level: 2, numberingTemplate: '{2:I}.', numberSeparator: ' ', fontSize: pt(9),
    lineHeight: pt(LEAD), fontWeight: 400, letterSpacing: em(0.06), textTransform: 'uppercase',
    marginTop: pt(LEAD), marginBottom: pt(0) }, // a line above, none below: at a column's head
  // the margin above drops and the text still starts on the next grid line
];
const headingStyles = [
  { id: 'paper', numbered: false, span: 'page', advancedDesign: titleBlock },
  { id: 'back', numbered: false }, // Acknowledgment and References: no number
];
const crossRefs = { section: t({ en: 'Section {n}', es: 'sección {n}' }) };
// #endregion

const footer = { elements: [
  { kind: 'text', id: 'folio', content: '{pageNumber}', fontFamily: SANS, fontSize: pt(7.5),
    color: col('muted'), align: 'center',
    placement: { anchor: { to: 'page', edge: 'bottom-left' }, offset: { x: mm(SIDE), y: mm(-13) },
      size: { width: mm(MEASURE) } } },
] };

const config = () => ({
  locale: t({ en: 'en-us', es: 'es' }),
  colorPalette, citations, crossRefs, calloutStyles, headingStyles,
  page: { sizePreset: 'custom', width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(SIDE), right: mm(SIDE) } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: {
    fontFamily: SERIF, fontSize: pt(10), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    referenceBold: false, // "Section II" sits in the text like the citations
    textAlign: 'justify', firstLineIndent: mm(3.5), indentAfterHeading: true,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: { fontFamily: SERIF, color: col('ink'), textAlign: 'center', levels },
  header: { elements: [] },
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Exemple Markdown · 91 lignes · content.en.mdtitle: "Optimal Line Breaking in the Browser" author: "Irene Valcárcel, Tomás Brandt and Aiko Nwosu" --- # Optimal Line Breaking in the Browser: \\ What It Costs and When It Pays {style="paper" venue="DocWeb ’26 · Workshop on Document Engineering for the Web" a1="Irene Valcárcel\nDept. of Computer Science\nUniversidad de Almenara\nAlmenara, Spain\nivalcarcel@almenara.example" a2="Tomás Brandt\nTypesetting Group\nNorthgate College\nDunmore, United Kingdom\ntbrandt@northgate.example" a3="Aiko Nwosu\nSchool of Design\nHarrow Hill Institute\nHarrow Hill, Canada\nanwosu@harrowhill.example"} :::callout{type="abstract"} ***Abstract*—**Browsers break justified text one line at a time, and the loose lines this leaves are the main complaint against justified text on screen. The total-fit method used by TeX chooses the breaks of a whole paragraph at once and avoids most of them, but it is thought too slow for a page that reflows on every resize. We set a corpus of 2,400 paragraphs in four languages at six column widths with both methods, in a script that runs in the browser, and measured the time per paragraph and the spacing of every line. Total fit took 0.21 ms per paragraph on a mid-range laptop, 3.4 times the first-fit time, and cut the share of lines whose spaces stretch past one and a half times their natural width from 11.8% to 1.9%. The gain is largest in narrow columns and in German. We conclude that the cost is affordable for text that is laid out once per resize, and give a rule for when first fit is good enough. ***Index Terms*—**line breaking, justification, hyphenation, typesetting, web browsers, performance ::: ## Introduction {#sec:intro} A paragraph that a browser justifies is broken one line at a time. Each line takes as many words as fit, and the space left over is shared out between them. The method is fast and predictable, and it is the reason justified text on the web has a reputation for rivers and loose lines: a line followed by a long word must take that word's room as space, and nothing earlier in the paragraph can help it. Printers have had a better method for forty years. @knuthplass1981 treat the paragraph as a whole. Every possible break is a node in a graph, every line a weighted edge, and the breaks are those of the path with the least total penalty, so that a slightly tight line early on can save a very loose one later. Liang's hyphenation patterns, Plass's work on page breaking and TeX itself came out of the same project at Stanford [@liang1983; @plass1981; @knuth1984], and the method is still the reference against which other line breakers are judged. It has not reached the browser. The usual reason given is speed: a page that reflows whenever its window changes size cannot afford a search over every paragraph. We test that reason. :ref{id="sec:related"} places the question among earlier work, :ref{id="sec:method"} describes the corpus and the timing harness, and :ref{id="sec:results"}, on :ref{id="sec:results" style=page}, gives the measurements. :ref{id="sec:discussion"} turns them into a rule a page designer can apply. ## Related work {#sec:related} The total-fit algorithm was described in full by @knuthplass1981, with the box, glue and penalty model that later implementations kept. The search is quadratic in the worst case, but a feasible break can only lie within a line's width of the one before it, and the active list of candidate breaks stays short in practice. The authors report times for a mainframe of the day; we know of no measurement on a modern browser engine. Hyphenation is the other half of the problem. The patterns of @liang1983 find most of the permissible breaks of an English word from a table of a few thousand entries, and they are the basis of the hyphenation dictionaries that browsers and word processors still ship. A total-fit breaker that cannot hyphenate loses most of its advantage in narrow columns, which is why we measure the two together. Typographic practice sets the target. @bringhurst2004 asks for a measure of 45 to 75 characters and treats word spaces that open past their natural width as the first sign of a badly set paragraph. Studies of reading suggest why: the eye moves in saccades of seven to nine characters, and an irregular texture changes where it lands [@rayner1998]. On screen, @dyson2001 found that line length affects reading speed and comprehension differently, which warns against judging a layout by one number alone. We report the spacing of the lines rather than a reading measure, and leave the second to future work. ## Method {#sec:method} The corpus has 2,400 paragraphs, 600 in each of English, Spanish, German and French, drawn from public-domain novels and essays. Paragraphs shorter than four lines at the widest measure were left out, since a paragraph that short gives a line breaker little to choose from. Each paragraph was set at six column widths, from 30 to 80 characters of the text face, with two line breakers: first fit, which takes the longest line that fits, and total fit as described in [-@knuthplass1981]. Both used the same Liang patterns for each language, the same glue (a space of a third of an em that may stretch by half and shrink by a third) and the same text face, measured once per word with the canvas text API. The script that does it runs in the browser, with no server. For each setting we recorded the time to break the paragraph, excluding the measurement of words, which both methods share, and the stretch ratio of every line but the last. A line whose ratio passes 1.5 is counted as loose. The timings come from a laptop with a mid-range processor of 2023, in the current stable version of three browsers, each paragraph timed fifty times after a warm-up of ten. ## Results {#sec:results} Total fit took 0.21 ms per paragraph on average, against 0.062 ms for first fit, a ratio of 3.4. The ratio grew with the width of the column, from 2.6 at 30 characters to 4.1 at 80, since a wider line admits more candidate breaks. The slowest paragraph, a German one of 31 lines at 80 characters, took 1.9 ms. A long article of 120 paragraphs is broken in about 25 ms, well inside the time a browser gives itself to answer a resize. The spacing improved in every language and at every width. Over the whole corpus the share of loose lines fell from 11.8% to 1.9%. In columns of 30 to 40 characters, the measure of a two-column page on a phone, it fell from 27% to 4.6%. German gained the most, from 16.3% to 2.2%, because its long compounds leave first fit with the hardest choices; English gained the least, from 8.9% to 1.6%. The number of hyphenated lines rose by a fifth with total fit, which accepts a hyphen where it saves a loose line further down. At 70 characters and more, first fit left fewer than 4% of its lines loose in every language. At that measure the difference between the methods is hard to see on the page, and a reader shown both settings of the same paragraph side by side could rarely tell which was which. ## Discussion {#sec:discussion} The cost of total fit is a few tenths of a millisecond per paragraph, and the text of an ordinary page is broken in less time than the browser spends painting it. For text that is laid out once and then read, as in an article, a book chapter or a paper like this one, the cost is no argument against it. Live editing is a different case: there only the paragraph being edited needs breaking again, and the time for one paragraph is small. The results also give a rule for when first fit is enough. In a single column of 70 characters or more, a reader will rarely meet a loose line with either method, and a designer who cannot choose the line breaker loses little. In narrow columns, and in languages with long words, the difference is large and visible, and total fit with hyphenation is the method to ask for. This matches the advice of the printers [@bringhurst2004, chap. 2], who allow a narrow column to go ragged rather than set it justified without care. ## Conclusion We measured the cost of breaking paragraphs as TeX does in a browser and found it small: 0.21 ms per paragraph, 3.4 times the cost of the browser's own method, for six times fewer loose lines. The case against total fit on screen rests on speed, and on present hardware speed no longer supports it. ## Acknowledgment {style="back"} The authors thank the readers of the DocWeb ’26 committee for their comments on the draft. Set in STIX Two Text and Schibsted Grotesk (SIL OFL). Text: original, CC BY 4.0. The authors, institutions and measurements are invented for this example. ## References {style="back"} :::bibliography{title=""} :::references{format=bibtex} @article{knuthplass1981, author = {Knuth, Donald E. and Plass, Michael F.}, title = {Breaking paragraphs into lines}, journal = {Software: Practice and Experience}, volume = 11, number = 11, pages = {1119--1184}, year = 1981, doi = {10.1002/spe.4380111102}} @phdthesis{liang1983, author = {Liang, Franklin Mark}, title = {Word Hy-phen-a-tion by Com-put-er}, school = {Stanford Univ.}, address = {Stanford, CA}, year = 1983} @phdthesis{plass1981, author = {Plass, Michael Frederick}, title = {Optimal Pagination Techniques for Automatic Typesetting Systems}, school = {Stanford Univ.}, address = {Stanford, CA}, year = 1981} @book{knuth1984, author = {Knuth, Donald E.}, title = {The {TeX}book}, publisher = {Addison-Wesley}, address = {Reading, MA}, year = 1984} @book{bringhurst2004, author = {Bringhurst, Robert}, title = {The Elements of Typographic Style}, edition = {3rd}, publisher = {Hartley \& Marks}, address = {Point Roberts, WA}, year = 2004} @article{rayner1998, author = {Rayner, Keith}, title = {Eye movements in reading and information processing: 20 years of research}, journal = {Psychological Bulletin}, volume = 124, number = 3, pages = {372--422}, year = 1998, doi = {10.1037/0033-2909.124.3.372}} @article{dyson2001, author = {Dyson, Mary C. and Haselgrove, Mark}, title = {The influence of reading speed and line length on the effectiveness of reading from screen}, journal = {International Journal of Human-Computer Studies}, volume = 54, number = 4, pages = {585--612}, year = 2001, doi = {10.1006/ijhc.2001.0458}} :::
`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'STIX Two Text': ['400', '400i', '700', '700i'], 'Schibsted Grotesk': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const doc = await buildDocumentWithFonts({ markdown }, config(), kitFonts(FONTS)); const title = t({ en: 'An IEEE conference paper', es: 'Una ponencia en estilo IEEE' }); showPages(doc, { title }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf : identique dans toutes les recettes · 269 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 · pdf v3 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType, with their range and * place: the nearest weight the family ships, upright with no italic; * latin, then what its letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const files = await Promise.all(['latin', ...kitSubsetsFor(text, meta)].map(async (subset, order) => { const res = await fetch(`${await fontsourceAt(id)}files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return Object.assign(await decompressWoff2(new Uint8Array(await res.arrayBuffer())), { unicodeRange: kitRanges()[subset], order }); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── /Kit ───────────────────────────────────────────────────────────────────────

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

#Garder le style tel qu’il est livré

Sans la modification, trois ouvrages consécutifs s’impriment un par un, ce que préfèrent certaines revues.

-  style: 'custom', customStyle: ieee, // or style: 'ieee' for the file as it ships
+  style: 'ieee',

#Écrire les plages dans une seule paire de crochets

Certains styles numériques impriment plutôt [2–4]. Le marqueur brackets écrit lui-même les numéros et réunit ceux qui se suivent.

-  style: 'custom', customStyle: ieee, // or style: 'ieee' for the file as it ships
+  style: 'ieee', marker: 'brackets', collapseRanges: true,

Erreurs fréquentes

  • Quand les deux colonnes de la dernière page ne finissent pas à la même hauteur, regardez ce qui se trouve à l’endroit de la coupe : le moteur ne laisse pas un titre de section au pied d’une colonne loin de son texte, et renonce plutôt à la coupe. Une phrase de plus ou de moins dans les dernières sections fait passer la coupe au-delà du titre.
  • Les commandes BibTeX comme {\TeX} ne sont pas développées. Écrivez The {TeX}book ; les accolades conservent les capitales telles quelles.

Crédits

Texte
Texte original, CC BY 4.0
Polices
STIX Two Text (SIL OFL 1.1) · Schibsted Grotesk (SIL OFL 1.1)
SandboxPDF