跳到主要内容
食谱编号96

排版食谱 · 第5章 · 图书结构

按MLA格式引用的人文学科论文

一篇文学论文:正文写[@key, 48],按MLA 9排成著者-页码引文,有一段独立引文,引用文献表取自CSL-YAML数据。

本页内容
输出
Canvas · PDF
难度
中级
Postext
已用Postext 1.12.1测试
需要≥ 1.12.1 · postext-pdf ≥ 1.12.1
许可证
更新于2026年10月1日
代码MIT · 文本CC BY 4.0
  • 英文样例:尚无中文版本
  • 成品尺寸150 × 230 mm
  • 1栏
  • Spectral 10.6/14.4
  • Spectral SC
  • 4页
  • 难度
  • Postext 1.12.1
  • 排版用时13 ms
  • 142行代码

简单来说

一篇谈济慈诗作的论文,按文学期刊的样子排印。作者给每条文献起一个短代号写进正文,Postext按MLA格式在括号里印出作者和页码,并在文末列出引用文献。

成品一览

一份文学季刊里的论文:四页,讨论济慈《希腊古瓮颂》("Ode on a Grecian Urn")最后两行诗由谁说出。版面是150 × 230 mm的期刊页,正文用Spectral,节标题和书眉用Spectral SC;篇首放在一条黑釉色的色带里,下面压一道陶土红,取自阿提卡陶瓶的两种颜色。引文遵循MLA第9版,这是大多数文学和艺术史院系使用的格式:括号里写作者和页码;句中已提到作者时只写页码;同一作者有两部作品时加简短标题;引用文献表单独起页。正文里每条引文只是一个代号,样式只是一项设置。

这道食谱解答

  • 怎样按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}块里。这里用CSL-YAML而不用BibTeX,是因为MLA要给济慈的两部作品加简短标题,而postext 1.12.1的BibTeX读取器会丢掉shorttitle字段;CSL里对应的字段是title-short。

#2 · 三种引用方式

正文用[@jack1967]引用整部著作,印出"(Jack)"。带页码的[@keats-letters, 41]印出"(Keats, Letters 41)":文中引用了济慈的两部作品,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),
};

关于"消极能力"(Negative Capability)的那封信超过四行散文,所以用Markdown的引文块单独成段:正体,正文颜色,左缩进2 em,首行不缩进。MLA规定独立引文的出处放在末尾句号之后,原稿也这样写:…fact and reason. [@keats-letters, 48]。引文上下各加一个:::space{lines=0.5},与前后段落隔开。

#4 · 引用文献表单独起页

script.js · 第119行在完整代码中
  headingStyles: [{ id: 'works-cited', breakBefore: { enabled: true, parity: 'any' } }],

这个标题使用一个另起新页的样式,:::bibliography{title=""}把文献表放在它下面,不再重复标题。文献表按字母排序,悬挂缩进;济慈的第二部作品以三连划线代替姓名,这是MLA对同一作者重复出现的要求。这一页和篇首页没有书眉,页码改印在页脚。

#5 · 用陶瓶的两种颜色排篇首

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,里面是栏目名、56 pt的Spectral Light斜体标题、副标题和署名,内容都取自标题属性。陶土红细条是第二个方框,锚定在色带底边以上3 mm处。

完整食谱

沙盒
// ═══ 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上的食谱文件夹 ↗ (在新标签页中打开)

变化

#文献表紧接正文排

有的期刊把引用文献接在最后一段之后,不另起页,只要去掉标题样式里的分页。

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

#改用Chicago著者-出版年制

同一篇论文改用Chicago时,作者后面跟年份,写作"(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旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →

易错点

只有8种语言区域能断词,且须代码完全一致

断词支持en-us、es、fr、de、it、pt、ca和nl,须完全匹配:'es-ES'或其他任何语言都会悄悄退回美式英语。 断词与文档语言 →

易错点

配置按对象身份缓存:每次新建一个对象

引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →

  • 不带页码的叙述式引文,如@stillinger1974 records,在postext 1.12.1里会在姓名后多出一个空格。可以像本文那样,把这类引文放在分句末尾的方括号里,或者给出页码。
  • citeproc-js按数据原样印出标题。MLA要求英文标题实词首字母大写,所以在文献数据里就要这样写。

致谢

文本
  • Quotations from “Ode on a Grecian Urn” (1820) · John Keats · 公有领域
  • Quotations from two letters of 1817, in Letters of John Keats to His Family and Friends, ed. Sidney Colvin (1925) · John Keats · 公有领域
字体
Spectral (SIL OFL 1.1) · Spectral SC (SIL OFL 1.1)
沙盒PDF