本文へスキップ
レシピ番号96

レシピ集 · 第5章 · 本の構成

MLA形式の引用文献一覧を付けた人文学の論文

文学論文の[@key, 48]形式の引用を、MLA第9版の著者名とページによる参照に変えます。ブロック引用と、CSL-YAMLのデータから作るWorks Citedの一覧も入ります。

このページの内容
ジャンル
論文・学術書
出力
Canvas · PDF
レベル
中級
Postext
Postext 1.12.1で検証済み
1.12.1以上が必要 · postext-pdf ≥ 1.12.1
ライセンス
2026年10月1日更新
コードMIT · 本文CC BY 4.0

1ページ(全4ページ)

  • 英語の見本:日本語版はまだありません
  • 仕上がり150 × 230 mm
  • 1段
  • Spectral 10.6/14.4
  • Spectral SC
  • 4ページ
  • レベル
  • Postext 1.12.1
  • 組版時間13 ms
  • コード142行

かんたんな説明

キーツの詩を論じた論文を、文学研究誌が刷るとおりに組んだ例です。著者は出典ごとに短いコードを書き、Postextが著者名とページを括弧に入れて印刷し、最後にMLA形式で引用文献の一覧を付けます。

できあがり

文学の季刊誌に載る論文です。キーツの『ギリシャの壺に寄せるオード』の最後の2行を誰が語っているのかを論じた4ページで、150 × 230 mmの誌面にSpectralで組み、節見出しと柱はSpectral SCです。章扉は黒い釉薬の色の帯と、その下の赤い粘土色の細帯に収まります。アッティカの壺の2色です。引用はMLA第9版に従います。文学部や美術史学科の多くが使う形式で、括弧に著者名とページを入れ、文中で著者に触れていればページだけ、同じ著者の作品が2つあれば短縮題を添え、Works Citedの一覧は独立したページに置きます。引用はどれもテキスト中のキーで、形式は設定1つで決まります。

このレシピが答える質問

  • APA、IEEEなどの引用スタイルで、文献を引用し参考文献を作るには?

手短な答え

script.js · 32–44行コード全体で見る
// The text cites with [@keats-letters, 48] → (Keats, Letters 48): MLA prints the author
// and the page, and adds a short title when two works share an author. [-@eliot1932, 230]
// drops the name the sentence already gives → (230). The list is alphabetical, and a
// second work by the same author opens with a dash (MLA's ---) instead of the name.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
  style: 'modern-language-association', // MLA 9th edition, author-page
  link: true, // a citation jumps to its entry in Works Cited
  bibliography: {
    // MLA's half-inch hanging indent, scaled to a 108 mm measure.
    fontSize: em(0.94), lineHeight: pt(13.4), hangingIndent: em(2.2), entrySpacing: pt(3),
  },
};

材料

種類
Spectral, Spectral SC (SIL OFL 1.1)
素材
なし:図はすべてコードで描画

作り方

#1 · 形式はMLA、データはCSL

コードは上の手短な答えにあります。引用エンジンは一度だけ登録し、citations.styleで同梱のMLA形式を指定します。citeproc-jsがそれを使って各引用と一覧を書きます。文献は論文の末尾の:::references{format=csl-yaml}ブロックに置きます。ここでBibTeXではなくCSL-YAMLを使うのは、MLAではキーツの2作品に短縮題が必要なのに、postext 1.12.1のBibTeXリーダーがshorttitle項目を捨ててしまうからです。CSLでの項目名はtitle-shortです。

#2 · 3種類の引用

本文が[@jack1967]で作品を引くと「(Jack)」と印刷されます。論文がその本を全体として参照しているからです。ページを付けた[@keats-letters, 41]は「(Keats, Letters 41)」と印刷されます。キーツの作品を2つ引いているので、citeprocが両者を区別するために短縮題を加えます。文中ですでに批評家の名前を出しているときは、[-@eliot1932, 230]が名前を省いて「(230)」と印刷します。オードを一度行番号付きで引いたら、以降の引用はMLAが認めるとおり行番号だけを付け、ふつうの括弧で手で打ちます。

#3 · 出典をピリオドの後に置くブロック引用

script.js · 48–52行コード全体で見る
// More than four lines of prose go in a Markdown blockquote: in ink, indented, with no
// first-line indent. Its citation follows the final full stop, as MLA asks.
const blockquote = {
  color: col('ink'), italic: false, indent: em(2), firstLineIndent: em(0),
};

ネガティブ・ケイパビリティについての書簡は散文で4行を超えるので、Markdownのブロック引用として本文から離して組みます。正体、本文の色で、2 em下げ、1行目の字下げはありません。MLAはブロック引用の出典を最後のピリオドの後に置くので、原稿も…fact and reason. [@keats-letters, 48]のように同じにします。上下の:::space{lines=0.5}が引用を前後の段落から離します。

#4 · Works Citedを独立したページに

script.js · 119行コード全体で見る
  headingStyles: [{ id: 'works-cited', breakBefore: { enabled: true, parity: 'any' } }],

見出しには改ページするスタイルを与え、:::bibliography{title=""}がその下に、2つ目の題を付けずに一覧を置きます。一覧はアルファベット順でぶら下げインデントを付け、キーツの2つ目の作品は、MLAが同じ著者の繰り返しに求めるとおり、名前の代わりに3 em幅のダッシュで始まります。柱を出さないこのページと章扉では、地側にノンブルを刷ります。

#5 · 壺の2色で組む章扉

script.js · 56–81行コード全体で見る
const BAND = 116; // mm from the trim's top
const opener = {
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 4), // the text starts a line under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('glaze') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'box', id: 'foot', style: { backgroundColor: col('clay') },
      placement: { ...at('page', 'top-left', 0, BAND - 3), size: { width: 'fill',
        height: mm(3) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9), letterSpacing: pt(1.6), color: col('slip'), align: 'left',
      placement: at('container', 'top-left', 0, 6) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 300,
      italic: true, fontSize: pt(56), lineHeight: 0.95, color: col('paper'), align: 'left',
      overflow: 'wrap', placement: { ...at('container', 'top-left', -1, 30),
        size: { width: mm(110), height: 'auto' } } },
    { kind: 'text', id: 'sub', content: '{attr.sub}', fontFamily: TEXT, fontWeight: 300,
      fontSize: pt(13), lineHeight: 1.25, color: col('paper'), align: 'left', overflow: 'wrap',
      placement: { ...at('container', 'top-left', 0, 66),
        size: { width: mm(96), height: 'auto' } } },
    { kind: 'text', id: 'byline', content: '{attr.byline}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(1.2), color: col('slip'), align: 'left',
      placement: at('container', 'top-left', 0, 82) },
  ] },
};
script.js · 16–23行コード全体で見る
const palette = {
  ink: '#1f1a17', glaze: '#231c19', clay: '#9a3d22', slip: '#e3a27e', rule: '#c9bdb0',
  muted: '#6b6159', paper: '#fffdf9',
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.clay })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

帯の深さは116 mmで、キッカー、Spectral Lightイタリック56 ptの題、副題、署名を収めます。どれも見出しの属性から取ります。粘土色の細帯は、帯の下端から3 mm上に固定した2つ目の箱です。

レシピの全体

Sandbox
// ═══ Postext Cookbook · Nº 096 · A humanities essay with MLA works cited ══════════
// https://postext.dev/en/cookbook/mla-humanities-essay
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Spectral, Spectral SC (SIL OFL 1.1) · Needs postext ≥ 1.12.1
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 = 'mla-humanities-essay';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: the black glaze and the red clay of an Attic vase
const palette = {
  ink: '#1f1a17', glaze: '#231c19', clay: '#9a3d22', slip: '#e3a27e', rule: '#c9bdb0',
  muted: '#6b6159', paper: '#fffdf9',
};
// A design element paints the hex written beside its paletteId (gotcha: palette-skips-designs).
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.clay })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Spectral', LABEL = 'Spectral SC';
const TRIM = { width: 150, height: 230 }; // a literary quarterly
const MARGIN = { top: 22, bottom: 22, inner: 19, outer: 21 };
const LEAD = 14.4; // pt
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: MLA 9 author-page citations and a Works Cited list
// The text cites with [@keats-letters, 48] → (Keats, Letters 48): MLA prints the author
// and the page, and adds a short title when two works share an author. [-@eliot1932, 230]
// drops the name the sentence already gives → (230). The list is alphabetical, and a
// second work by the same author opens with a dash (MLA's ---) instead of the name.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
  style: 'modern-language-association', // MLA 9th edition, author-page
  link: true, // a citation jumps to its entry in Works Cited
  bibliography: {
    // MLA's half-inch hanging indent, scaled to a 108 mm measure.
    fontSize: em(0.94), lineHeight: pt(13.4), hangingIndent: em(2.2), entrySpacing: pt(3),
  },
};
// #endregion

// #region quotation: a block quotation, set in and upright
// More than four lines of prose go in a Markdown blockquote: in ink, indented, with no
// first-line indent. Its citation follows the final full stop, as MLA asks.
const blockquote = {
  color: col('ink'), italic: false, indent: em(2), firstLineIndent: em(0),
};
// #endregion

// #region opener: the title in a band of black glaze with a red clay foot
const BAND = 116; // mm from the trim's top
const opener = {
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 4), // the text starts a line under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('glaze') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'box', id: 'foot', style: { backgroundColor: col('clay') },
      placement: { ...at('page', 'top-left', 0, BAND - 3), size: { width: 'fill',
        height: mm(3) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9), letterSpacing: pt(1.6), color: col('slip'), align: 'left',
      placement: at('container', 'top-left', 0, 6) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 300,
      italic: true, fontSize: pt(56), lineHeight: 0.95, color: col('paper'), align: 'left',
      overflow: 'wrap', placement: { ...at('container', 'top-left', -1, 30),
        size: { width: mm(110), height: 'auto' } } },
    { kind: 'text', id: 'sub', content: '{attr.sub}', fontFamily: TEXT, fontWeight: 300,
      fontSize: pt(13), lineHeight: 1.25, color: col('paper'), align: 'left', overflow: 'wrap',
      placement: { ...at('container', 'top-left', 0, 66),
        size: { width: mm(96), height: 'auto' } } },
    { kind: 'text', id: 'byline', content: '{attr.byline}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9.5), letterSpacing: pt(1.2), color: col('slip'), align: 'left',
      placement: at('container', 'top-left', 0, 82) },
  ] },
};
// #endregion

const head = (id, content, parity, edge, x, align) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontWeight: 500,
  fontSize: pt(8.5), letterSpacing: pt(1), color: col('muted'), align,
  placement: at('page', edge, x, 12),
});

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  colorPalette,
  citations,
  page: {
    sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(10.6), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true, blockquote,
  },
  headings: {
    fontFamily: LABEL, fontWeight: 600, color: col('clay'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, fontFamily: TEXT, fontSize: pt(56), marginBottom: pt(0),
        breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
      { level: 2, fontSize: pt(11.5), letterSpacing: pt(0.9), lineHeight: pt(LEAD),
        marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) },
    ],
  },
  // #region works-cited: MLA starts the list on a page of its own
  headingStyles: [{ id: 'works-cited', breakBefore: { enabled: true, parity: 'any' } }],
  // #endregion
  paragraphStyles: [
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD * 2) },
  ],
  header: { elements: [
    head('verso-folio', '{pageNumber}', 'even', 'top-left', MARGIN.outer, 'left'),
    head('verso-author', '{attr.byline}', 'even', 'top-left', MARGIN.outer + 8, 'left'),
    head('recto-title', '{title}', 'odd', 'top-right', -(MARGIN.outer + 8), 'right'),
    head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, 'right'),
  ] },
  footer: { elements: [ // a drop folio where the running heads stand down
    { ...head('drop-folio', '{pageNumber}', 'all', 'top', 0, 'center'),
      pages: 'opener', placement: at('container', 'top', 0, 9) },
  ] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdownの見本 · 95行 · content.en.mdtitle: "Cold Pastoral" author: "Helena Ward" --- # Cold Pastoral {#essay kicker="Essays · Romantic Poetry" sub="On the voice in the last two lines of Keats’s “Ode on a Grecian Urn”" byline="Helena Ward"} No Greek urn answers to the one Keats describes. Readers who went looking for it in the British Museum found parts of several: the heifer led to sacrifice on the Parthenon frieze, which Keats saw among the Elgin Marbles in the spring of 1817, and the Bacchic dancers of the Townley Vase, with those of the Borghese Vase in the Louvre, known in London through engravings [@jack1967]. The nearest thing to a single source is a sheet of paper. Keats traced an engraving of the Sosibios Vase, a marble krater signed by an Athenian sculptor of the first century BCE, from Henry Moses’s book of antique vases, and the tracing survives [@mcdermott1948]. The urn of the poem is a composite, assembled from marbles and from prints of marbles, and at the end it speaks. Keats wrote the ode in May 1819. It was first printed in January 1820 in the *Annals of the Fine Arts*, a London magazine about painting and sculpture, and that summer he collected it in his third and last book of poems. The two printings differ in one mark of punctuation, and much of what has been written about the poem since turns on that mark. ## The questions The ode opens by naming its object three times, as a “still unravish’d bride of quietness,” a “foster-child of silence and slow time” and a “Sylvan historian” [@keats-ode, lines 1–3]. The names are a courtesy: the speaker wants the historian to tell its tale. “What men or gods are these? What maidens loth? / What mad pursuit? What struggle to escape?” (8–9). The urn does not answer, and for three stanzas the speaker answers for it, praising the “unheard” melodies of the painted pipes over heard ones (11–12) and a lover who will never reach the girl but will always love her. The fourth stanza turns from the dancers to a procession, a priest leading a heifer “lowing at the skies” (33), and then to a town that is not on the urn at all, emptied of its people by the sacrifice and left silent “for evermore” (38). Here the questions stop being answerable even in principle. The town exists only because the procession has to have come from somewhere. ## The punctuation of the last lines In the last stanza the speaker gives up questioning. The urn “dost tease us out of thought / As doth eternity: Cold Pastoral!” (44–45), and it will outlast this generation as “a friend to man, to whom thou say’st, / ‘Beauty is truth, truth beauty,’—that is all / Ye know on earth, and all ye need to know” (48–50). In the 1820 volume the inverted commas close after “beauty,” so the urn says five words and someone else says the rest. The *Annals* text has no inverted commas, and neither have the transcripts made by Keats’s friends [@stillinger1974]. An editor has to choose, and the choice decides who says “that is all”: the urn to mankind, the poet to his reader, or the poet to the figures on the urn. The lines have had hostile readers. T. S. Eliot found the couplet “a serious blemish on a beautiful poem” [-@eliot1932, 230], a statement that was either untrue or one he could not understand. Cleanth Brooks answered that the words are “a speech ‘in character’” [-@brooks1947, 165], fitted to the urn that utters them, and set them beside Edgar’s “Ripeness is all” in *King Lear*. His reading needs the 1820 punctuation: if the urn speaks only the five words inside the commas, the motto is the urn’s, and the comment after the dash belongs to the poet who has listened to it. ## The letters Keats had written about beauty and truth before he wrote about the urn. “What the Imagination seizes as Beauty must be truth—whether it existed before or not,” he told Benjamin Bailey in November 1817 [@keats-letters, 41]. A month later, walking home from the Christmas pantomime, he named the quality he thought a poet most needed: :::space{lines=0.5} > I had not a dispute, but a disquisition, with Dilke upon various subjects; several things dove-tailed in my mind, and at once it struck me what quality went to form a Man of Achievement, especially in Literature, and which Shakspeare possessed so enormously—I mean Negative Capability, that is, when a man is capable of being in uncertainties, mysteries, doubts, without any irritable reaching after fact and reason. [@keats-letters, 48] :::space{lines=0.5} Read with the letter beside it, the couplet is less a doctrine than a refusal. The urn has answered none of the speaker’s questions about who the figures are or where the town stands. What it offers instead is a sentence that cannot be checked, and the poet’s comment accepts that this is all the urn will give. The speaker who began by demanding a tale ends content with half knowledge, which is the state the letter asks a poet to bear. ## Works Cited {style="works-cited"} :::bibliography{title=""} :::paragraphs{style="colophon"} Set in Spectral and Spectral SC (SIL OFL). Text: original essay, CC BY 4.0; quotations from Keats’s 1820 volume and Colvin’s edition of the letters. ::: :::references{format=csl-yaml} - id: keats-ode type: chapter author: [{family: Keats, given: John}] title: Ode on a Grecian Urn title-short: Ode container-title: "Lamia, Isabella, The Eve of St. Agnes, and Other Poems" publisher: Taylor and Hessey issued: 1820 page: 113-116 - id: keats-letters type: book author: [{family: Keats, given: John}] editor: [{family: Colvin, given: Sidney}] title: Letters of John Keats to His Family and Friends title-short: Letters publisher: Macmillan issued: 1925 - id: jack1967 type: book author: [{family: Jack, given: Ian}] title: Keats and the Mirror of Art publisher: Clarendon Press issued: 1967 - id: mcdermott1948 type: article-journal author: [{family: McDermott, given: William C.}] title: Keats and Sosibios container-title: The Classical Journal volume: 44 issue: 1 page: 33-34 issued: 1948 - id: stillinger1974 type: book author: [{family: Stillinger, given: Jack}] title: "The Texts of Keats’s Poems" publisher: Harvard UP issued: 1974 - id: eliot1932 type: chapter author: [{family: Eliot, given: T. S.}] title: Dante container-title: "Selected Essays, 1917–1932" publisher: Faber and Faber issued: 1932 - id: brooks1947 type: book author: [{family: Brooks, given: Cleanth}] title: "The Well Wrought Urn: Studies in the Structure of Poetry" publisher: Reynal and Hitchcock issued: 1947 :::
`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). const FONTS = { Spectral: ['300', '300i', '400', '400i', '600', '600i'], 'Spectral SC': ['400', '500', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); const title = t({ en: 'A humanities essay with MLA works cited', es: 'Un ensayo de humanidades con obras citadas en MLA' }); 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上のレシピのフォルダー ↗ (新しいタブで開きます)

アレンジ

#一覧を論文に続けて組む

Works Citedを改ページせずに最後の段落の後に組む雑誌なら、見出しスタイルから改ページを外します。

-  headingStyles: [{ id: 'works-cited', breakBefore: { enabled: true, parity: 'any' } }],
+  headingStyles: [{ id: 'works-cited', breakBefore: { enabled: false } }],

#シカゴ・スタイルの著者・年形式で引く

同じ論文をシカゴ・スタイルにすると、「(Brooks 1947, 165)」のように著者名の後に年が入り、一覧もそれに合わせます。

-  style: 'modern-language-association', // MLA 9th edition, author-page
+  style: 'chicago-author-date',

よくあるつまずき

つまずき

headingsオブジェクトを渡すとH1の改ページが消える

既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →

つまずき

レイアウトの前にすべてのフォントを読み込む

レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →

つまずき

パレットを差し替えても、デザイン要素と参照色は変わらない

postext 1.4.1はcolorPaletteをテキストのスタイル(本文、見出し、リスト、キャプション、表、囲み)には反映しますが、ヘッダー、フッター、章扉、部扉の要素と、bodyText.referenceColorには反映しません。これらはpaletteIdの横に書いた16進の色のままです。画面用のダーク版や色替えのためにパレットを差し替えるときは、ビルドの前に、リンクしたすべての色をcolorPaletteから書き直してください。 セマンティックカラーパレット →

つまずき

ハイフネーションできるのは8つのロケールだけで、コードは完全一致

ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。 ハイフネーションと文書の言語 →

つまずき

設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る

エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →

  • ページのない地の文での引用、@stillinger1974 recordsは、postext 1.12.1では名前の後に空白が2つ入って印刷されます。この論文のように節の終わりで括弧に入れて引くか、ページを付けてください。
  • citeproc-jsは題をデータのとおりに印刷します。MLAは英語の題にタイトルケースを使うので、文献にもそのとおりに書いてください。

クレジット

レシピ
Ignacio Ferro
本文
フォント
Spectral (SIL OFL 1.1) · Spectral SC (SIL OFL 1.1)
SandboxPDF