かんたんな説明
学会向けの短い研究論文を2段組みで組みます。著者は文献ごとにコードを書き、Postextは引用に角かっこの番号を振り、節を番号とページで参照し、最後に番号付きの文献一覧を印刷します。
できあがり
文書工学の小さなワークショップに出す2ページの論文を、IEEEの会議録と同じ組み方で組みます。USレター判、幅3.5インチの2段組み、10ptのTimes系の書体、I、II、IIIと番号を振った中央そろえの大文字の節見出しです。題、3つの著者ブロック、要旨、キーワードは、ワークショップの青で塗った淡い帯の下で2段にまたがって組みます。この青がページで唯一の色です。著者はBibTeXのキーで引用し、Postextは文献を最初に引用された順に[1]と番号を振り、連続する3つの文献を[2]–[4]にまとめ、名前が文の一部になるときは「Knuth and Plass [1]」と書き、最後の番号付き文献一覧を8ptで作ります。節への参照は「Section IV, on p. 2」と印刷され、本文が動いても正しいまま保たれます。
このレシピが答える質問
- APA、IEEEなどの引用スタイルで、文献を引用し参考文献を作るには?
- 節とそのページを参照し、本が変わっても両方を正しく保つには?
手短な答え
// [@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
},
};
材料
- 種類
- STIX Two Text, Schibsted Grotesk (SIL OFL 1.1)
- 素材
- なし:図はすべてコードで描画
作り方
#1 · 範囲をまとめるIEEEスタイル
コードは前に掲げた手短な答えです。一度登録すれば、postext-citeprocがすべての[@key]をciteproc-jsとIEEEのCSLスタイルで整形し、文献に最初に引用された順で番号を振ります。エンジンに同梱のスタイルファイルは、連続する3つの文献を[2]、[3]、[4]と並べますが、IEEEの編集ガイドは[2]–[4]とまとめます。スタイルの<citation>要素にcollapse="citation-number"を加え、その結果をcustomStyleとして渡すと、citeproc-jsがまとめてくれます。labelWidthは一覧の番号に専用の列を与えるので、項目の2行目は角かっこの下ではなく最初の語の下から始まります。
#2 · 2段にまたがる表題ブロック
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) })),
] },
};
題は文書でただひとつのレベル1見出しで、スタイルはpaperです。このスタイルはページ幅にまたがり、見出しをデザインから描きます。会議名の行、題、行長の3分の1の幅を持つ3つの著者ブロックで、どれも見出しの属性から読み込み、属性中の\nで改行します。帯は仕上がりにアンカーした箱なので、紙の上端まで届きます。
#3 · 太字の要旨とキーワード
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 } }];
ページ幅にまたがる囲みに要旨とキーワードを入れます。本文はIEEEの組み方どおり9ptの太字で、テキスト中の***Abstract*—**がイタリックのラベルとエムダッシュを作ります。上の細罫は囲みのストライプで、パディングが行長を約100字に狭めます。
#4 · 番号付きの節とその参照
const levels = [
{ level: 1, breakBefore: { enabled: true, parity: 'any' } }, // gotcha: headings-drop-h1-break
{ 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}.で節にI.、II.、III.と番号を振ります。参照では番号の後のピリオドを省くので、:ref{id="sec:results"}はcrossRefsのテンプレートから「Section IV」となり、:ref{id="sec:results" style=page}は「p. 2」となります。どちらも組み上がったページから求めるので、段落が加わったり節が移ったりしても本文に追従します。謝辞(Acknowledgment)と文献(References)の見出しはbackスタイルを使い、番号を付けません。
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' } }));
レシピの全体
// ═══ 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.12.0 import { buildDocument, renderPageToCanvas, clearMeasurementCache, 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' } }, // gotcha: headings-drop-h1-break { 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 = () => ({ // a factory: the engine caches resolved configs per object 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`---Markdownの見本 · 91行 · content.en.md
title: "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 University}, address = {Stanford, CA}, year = 1983} @phdthesis{plass1981, author = {Plass, Michael Frederick}, title = {Optimal Pagination Techniques for Automatic Typesetting Systems}, school = {Stanford University}, 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 ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); const title = t({ en: 'An IEEE conference paper', es: 'Una ponencia en estilo IEEE' }); showPages(doc, { title }); offerPdf(() => renderToPdf(doc, { fontProvider: fontsourceProvider }), `${RECIPE}.pdf`);キット · core, fonts, viewer, pdf:全レシピ共通 · 275行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
組み立てたscript.jsはそのまま動きます。任意のページのモジュールスクリプトに貼り付けるか、レシピをCodePenで開いてください。 GitHub上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#同梱のスタイルをそのまま使う
この編集をしなければ、連続する3つの文献は1つずつ印刷されます。こちらを好む論文誌もあります。
- style: 'custom', customStyle: ieee, // or style: 'ieee' for the file as it ships
+ style: 'ieee',#範囲を1組の角かっこの中に書く
番号式のスタイルには[2–4]と印刷するものもあります。bracketsマーカーは番号を自分で書き、連続する番号をまとめます。
- style: 'custom', customStyle: ieee, // or style: 'ieee' for the file as it ships
+ style: 'ieee', marker: 'brackets', collapseRanges: true,よくあるつまずき
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
つまずき
フロントマターの値はすべて引用符で囲む
YAMLはtitle: 1984を数値として、日付をDateオブジェクトとして読みます。文字列でない値はプレースホルダーに空で出力され、PDFにもタイトルが付きません。値はすべて引用符で囲んでください(title: "1984")。 文書のメタデータ →
つまずき
設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る
エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →
- 最終ページの2つの段の終わりがそろわないときは、段を切るはずの位置に何があるかを見てください。エンジンは節見出しを本文から離して段の下端に残すことはせず、その場合は段の切れ目のほうをあきらめます。最後のほうの節で1文増やすか減らすと、切れ目は見出しの先へ移ります。
{\TeX}のようなBibTeXのコマンドは展開されません。The {TeX}bookと書いてください。波かっこによって大文字がそのまま保たれます。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- フォント
- STIX Two Text (SIL OFL 1.1) · Schibsted Grotesk (SIL OFL 1.1)


