跳到主要内容
食谱编号89

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

用芝加哥注释体排历史论文

一篇四页的历史论文:正文写[@key, 87],引文按芝加哥注释体排成页脚注,首次完整、再次简略,文末附参考书目。

本页内容
输出
Canvas · PDF
难度
中级
Postext
已用Postext 1.12.0测试
需要≥ 1.12.0 · postext-pdf ≥ 1.12.0
许可证
更新于2026年10月1日
代码MIT · 文本CC BY 4.0
  • 英文样例:尚无中文版本
  • 成品尺寸156 × 234 mm
  • 1栏
  • Crimson Pro 11/14
  • IBM Plex Sans Condensed
  • 4页
  • 难度
  • Postext 1.12.0
  • 排版用时14 ms
  • 132行代码

简单来说

一篇讲英国各城镇如何改用伦敦时间的论文。作者引用的每一处文献都变成页脚的编号注释,书目列在文末。

成品一览

一份期刊技术史丛书的抽印本:约1,100词的论文,讲1840至1880年间英国各城镇如何放弃本地正午、改用伦敦时间。页面156 × 234 mm,左右镜像,正文Crimson Pro 11/14 pt,首页上半部是一条铁路绿色带。作者把每条文献写成代号[@howse1980, 87],Postext按芝加哥注释-书目体把每处引用排成页脚的编号注释。某部著作第一次出现时注出作者、书名和出版社,之后都用简略形式。作者自己的说明性注释与引文注释共用一套编号,参考书目放在全文最后。

这道食谱解答

  • 怎样按芝加哥注释体把引用排成脚注,并让后续引用用简略形式?
  • 怎样按APA、IEEE或其他引用样式引用文献并生成参考文献表?

简短回答

script.js · 第32–49行在完整代码中
// The text cites with [@howse1980, 87]; a note style turns every citation into a
// footnote. The first note for a work gives it in full, later ones in the short form
// (author, short title, page), and the bibliography lists the works by author.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
  style: 'chicago-notes-bibliography', // CMOS 18th edition, notes and bibliography
  notes: 'footnote', // each citation in a note at the foot of the page
  link: true, // a note's citation jumps to its bibliography entry
  bibliography: { fontSize: em(0.92), lineHeight: pt(13), hangingIndent: em(1.8),
    entrySpacing: pt(3) },
};
// The citation notes share the numbering and the look of the author's own [^notes].
const footnotes = {
  placement: 'column', numbering: 'chapter',
  fontSize: pt(8.5), lineHeight: pt(10.75), spaceBetween: pt(2),
  textAlign: 'left', // ragged: the gap after a note's number stays the same in every note
  separator: { width: 0.3, lineWidth: pt(0.5), color: col('rule') },
};

用料

类型
Crimson Pro, IBM Plex Sans Condensed(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 每处引用一条注

代码就是上面的简短回答。chicago-notes-bibliography是注释体样式:正文里的引用只留下一个上标数字,内容进入页脚注释,与作者自己的[^注]统一编号;notes: 'footnote'把这一点写明。citeproc-js记录哪些著作已经引用过,所以注3是Derek Howse, Greenwich Time and the Discovery of the Longitude (Oxford University Press, 1980), 87,再次引用同一本书的注4是Howse, Greenwich Time and the Discovery of the Longitude, 90–91。内置样式是《芝加哥手册》第18版,它不再用Ibid.,而是重复简略形式,所以注11和注12都印简略形式。

#2 · 作者注里也能引用

写在[^注]里的引用留在该注之内、随文排出;如果是该著作第一次出现,就排完整形式:注1是作者的一句话加上Schivelbusch的完整出处。每条引用都以样式自带的句点结尾,所以以引用收尾的注释在Markdown里不要再加句点。

#3 · 前缀、后缀与定位

[see @simmons1991, 346–47; and @bartky2000, chap. 9]把see放在第一部著作前,两部之间用分号隔开,各自保留页码或章节。单独的数字表示页码;chap.和cap.在两种语言里都表示章。定位之后的文字是后缀:[@howse1980, 82–84, on the mail-coach timepieces]把这句话印在页码之后。

#4 · 章首放在色带里

script.js · 第53–74行在完整代码中
const BAND = 122; // mm from the trim's top
const opener = {
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 9), // the text starts 9 mm under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('green') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(7.5), letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('tint'),
      align: 'left', placement: at('container', 'top-left', 0, 4) },
    { kind: 'text', id: 'years', content: '{attr.years}', fontFamily: TEXT, fontWeight: 300,
      fontSize: pt(54), lineHeight: 1, color: col('tint'), align: 'left',
      placement: at('container', 'top-left', -1, 16) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(25), lineHeight: 1.08, color: col('paper'), align: 'left', overflow: 'wrap',
      placement: { ...at('container', 'top-left', 0, 47),
        size: { width: mm(112), height: 'auto' } } },
    { kind: 'text', id: 'byline', content: '{attr.byline}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9), letterSpacing: pt(0.6), color: col('tint'), align: 'left',
      placement: at('container', 'top-left', 0, 92) },
  ] },
};

色带从裁切边向下122 mm,正文从它下方9 mm处开始。年份取自标题属性years="1840–1880",用54 pt的Crimson Pro Light排出,卡片上的数字与标题一样醒目;署名属性同时供偶数页的书眉使用。

script.js · 第16–23行在完整代码中
const palette = {
  ink: '#211d1a', green: '#24493d', tint: '#dfe8e2', rule: '#b7b0a4', muted: '#6a645b',
  paper: '#fbf9f4',
};
// 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.green })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 089 · A history essay with Chicago notes ═══════════════
// https://postext.dev/en/cookbook/history-essay-chicago-notes
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Crimson Pro, IBM Plex Sans Condensed (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 = 'history-essay-chicago-notes';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a railway green for the opener band, ink and warm paper for the rest
const palette = {
  ink: '#211d1a', green: '#24493d', tint: '#dfe8e2', rule: '#b7b0a4', muted: '#6a645b',
  paper: '#fbf9f4',
};
// 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.green })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Crimson Pro', LABEL = 'IBM Plex Sans Condensed';
const TRIM = { width: 156, height: 234 }; // a journal offprint, trimmed like a trade book
const MARGIN = { top: 22, bottom: 22, inner: 20, outer: 17 };
const LEAD = 14; // pt
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region answer: Chicago notes and bibliography, one note per citation
// The text cites with [@howse1980, 87]; a note style turns every citation into a
// footnote. The first note for a work gives it in full, later ones in the short form
// (author, short title, page), and the bibliography lists the works by author.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
  style: 'chicago-notes-bibliography', // CMOS 18th edition, notes and bibliography
  notes: 'footnote', // each citation in a note at the foot of the page
  link: true, // a note's citation jumps to its bibliography entry
  bibliography: { fontSize: em(0.92), lineHeight: pt(13), hangingIndent: em(1.8),
    entrySpacing: pt(3) },
};
// The citation notes share the numbering and the look of the author's own [^notes].
const footnotes = {
  placement: 'column', numbering: 'chapter',
  fontSize: pt(8.5), lineHeight: pt(10.75), spaceBetween: pt(2),
  textAlign: 'left', // ragged: the gap after a note's number stays the same in every note
  separator: { width: 0.3, lineWidth: pt(0.5), color: col('rule') },
};
// #endregion

// #region opener: the green band holds the kicker, the dates and the title
const BAND = 122; // mm from the trim's top
const opener = {
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 9), // the text starts 9 mm under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('green') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(7.5), letterSpacing: pt(1.4), textTransform: 'uppercase', color: col('tint'),
      align: 'left', placement: at('container', 'top-left', 0, 4) },
    { kind: 'text', id: 'years', content: '{attr.years}', fontFamily: TEXT, fontWeight: 300,
      fontSize: pt(54), lineHeight: 1, color: col('tint'), align: 'left',
      placement: at('container', 'top-left', -1, 16) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontWeight: 600,
      fontSize: pt(25), lineHeight: 1.08, color: col('paper'), align: 'left', overflow: 'wrap',
      placement: { ...at('container', 'top-left', 0, 47),
        size: { width: mm(112), height: 'auto' } } },
    { kind: 'text', id: 'byline', content: '{attr.byline}', fontFamily: LABEL, fontWeight: 600,
      fontSize: pt(9), letterSpacing: pt(0.6), color: col('tint'), align: 'left',
      placement: at('container', 'top-left', 0, 92) },
  ] },
};
// #endregion

const head = (id, content, parity, edge, x, align) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontWeight: 600,
  fontSize: pt(7.5), letterSpacing: pt(1.1), textTransform: 'uppercase', 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,
  footnotes,
  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(11), 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,
  },
  headings: {
    fontFamily: TEXT, fontWeight: 600, color: col('ink'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, fontSize: pt(25), breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: opener },
      { level: 2, fontFamily: LABEL, fontWeight: 600, fontSize: pt(8.5), letterSpacing: pt(1.3),
        textTransform: 'uppercase', color: col('green'), lineHeight: pt(LEAD),
        marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) },
    ],
  },
  paragraphStyles: [
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(7), lineHeight: pt(9.5),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  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: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 106行 · content.en.mdtitle: "Railway Time" author: "Clara Whitcombe" --- # Railway Time: How British Towns Came to Keep London's Hour {#essay kicker="Essays in the History of Technology · No. 14" years="1840–1880" byline="Clara Whitcombe"} In the autumn of 1840 a traveller who left Paddington on the Great Western Railway carried two times with him. The timetable in his pocket gave the hours of London; the church clock at Bath or Bristol, when he arrived, gave the hours of the place, a few minutes behind. Bristol stood a little over two and a half degrees west of Greenwich, and its noon, the moment the sun crossed the local meridian, came about ten minutes after London's. For centuries the difference had not mattered. A stagecoach that took a day to cover the distance lost more than ten minutes to a lame horse. A train that covered it in four hours could not.[^frame] This essay follows the forty years in which that difference was settled, from the Great Western's first timetables to the act of Parliament that made Greenwich time the legal time of Great Britain in 1880. The story has usually been told as a triumph of engineering over custom. It reads better as a negotiation, in which railway companies, telegraph companies, town councils and courts each settled the question for their own purposes, and not always in the same direction. [^frame]: For the railway's effect on the sense of time and distance, see [@schivelbusch1986, chap. 3] ## The companies decide The first railways ran their trains by the clocks of their own stations. On a line of a few miles this caused no trouble, and the early timetables did not say which time they meant. The difficulty came with the long trunk lines of the late 1830s, which joined towns whose clocks disagreed by a quarter of an hour.[@simmons1991, 345] The Great Western printed London time in its timetables from November 1840 and asked its station clocks to keep it.[@howse1980, 87] In 1847 the Railway Clearing House, the body through which the companies settled their accounts for through traffic, recommended that every member adopt Greenwich time from the first of December. By the end of 1848 nearly all of them had.[@howse1980, 90–91] The decision was commercial before it was scientific. A company that kept one time along its line needed one timetable and one set of instructions for its staff, and it could publish connections with other lines without a table of corrections. Greenwich was chosen because London was where the companies had their offices and the Post Office its mail coaches, not because the observatory stood there.[^post] [^post]: The Post Office had sent its guards out with watches set to London time since the 1790s; see [@howse1980, 82–84, on the mail-coach timepieces] ## The wire carries the hour A time that every station is meant to keep has to reach every station. At first it travelled by hand: a guard on the morning down train carried a watch set at Paddington, and the stationmasters along the line set their clocks from it. The electric telegraph changed the means. From 1852 the Royal Observatory sent a signal each day along the wires of the Electric Telegraph Company, and the company passed it on to the railway stations and post offices on its network.[@standage1998, 59] A clock in Manchester or Edinburgh could now be checked against Greenwich within a second or two of the event. The signal made Greenwich time available everywhere the wires went; it did not oblige anyone to keep it.[@schivelbusch1986, 43–44] ## The towns hesitate Most towns on a main line gave way within a few years, since a clock that disagreed with the station made people miss trains. Some held out. In 1852 the clock on the Corn Exchange at Bristol was given a second minute hand, one for Bristol and one for London, and it still has both.[@simmons1991, 346] At Exeter, which the railway reached in 1844, the cathedral clock went on keeping the time of the city for some years after the station clock had changed.[^exeter] [^exeter]: On the British towns, and on the same quarrel in American towns thirty years later, see [@simmons1991, 346–47; and @bartky2000, chap. 9] The holdouts were not cranks. Local mean time followed the sun, and the sun still ordered the working day of farmers, fishermen and anyone whose hours depended on daylight. An hour fixed in London was an imposition on them. The historian of factory discipline has argued that the clock had already become a means of controlling labour by the eighteenth century;[@thompson1967, 80–82] the dispute over railway time extended that argument to whole towns, which were asked to accept an hour set elsewhere. ## The courts decline The law was slower than the towns. Statutes that named a time meant the time of the place, and that time was local mean time. In 1858 a trial at Dorchester began at ten by the court's clock, which kept London time, and judgment was given against a defendant who arrived at ten by the time of the town, some minutes later. On appeal the judges held that the hour meant was the local one, and the judgment was set aside.[@howse1980, 113] For twenty more years a contract, a polling station or a public house could be bound by one time while the trains ran by another. The Statutes (Definition of Time) Act of 1880 closed the gap. It declared that any expression of time in an act of Parliament, deed or other legal instrument meant, unless stated otherwise, Greenwich mean time in Great Britain and Dublin mean time in Ireland.[@howse1980, 114] ## A settlement, not a conquest Seen from 1840, railway time was one of several possible arrangements. The companies might have kept local times and printed tables of corrections, as some American railroads did until 1883,[@bartky2000, 140–45] and the international system of zones that followed the Washington conference of 1884 took half a century to reach most of the world.[@ogle2015, chap. 1] In Britain the distances were short, the companies were few enough to agree through a single clearing house, and the telegraph reached the stations as the trains did. Those conditions, more than any principle, account for the speed with which a country of local noons came to keep one hour.[@ogle2015, 25; see also @schivelbusch1986, chap. 3] ## Bibliography :::bibliography{title=""} :::paragraphs{style="colophon"} A sample essay written for the Postext Cookbook (CC BY 4.0). The cited works are real; the page numbers in the notes are illustrative. Set in Crimson Pro and IBM Plex Sans Condensed (SIL OFL). ::: :::references{format=csl-yaml} - id: schivelbusch1986 type: book author: [{family: Schivelbusch, given: Wolfgang}] title: "The Railway Journey: The Industrialization of Time and Space in the 19th Century" publisher: University of California Press publisher-place: Berkeley issued: 1986 - id: thompson1967 type: article-journal author: [{family: Thompson, given: E. P.}] title: "Time, Work-Discipline, and Industrial Capitalism" container-title: Past & Present issue: 38 page: 56-97 issued: 1967 - id: howse1980 type: book author: [{family: Howse, given: Derek}] title: Greenwich Time and the Discovery of the Longitude publisher: Oxford University Press publisher-place: Oxford issued: 1980 - id: simmons1991 type: book author: [{family: Simmons, given: Jack}] title: The Victorian Railway publisher: Thames and Hudson publisher-place: London issued: 1991 - id: standage1998 type: book author: [{family: Standage, given: Tom}] title: The Victorian Internet publisher: Walker publisher-place: New York issued: 1998 - id: bartky2000 type: book author: [{family: Bartky, given: Ian R.}] title: "Selling the True Time: Nineteenth-Century Timekeeping in America" publisher: Stanford University Press publisher-place: Stanford, CA issued: 2000 - id: ogle2015 type: book author: [{family: Ogle, given: Vanessa}] title: The Global Transformation of Time, 1870–1950 publisher: Harvard University Press publisher-place: Cambridge, MA issued: 2015 :::
`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). const FONTS = { 'Crimson Pro': ['300', '400', '400i', '600', '600i'], 'IBM Plex Sans Condensed': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); const title = t({ en: 'A history essay with Chicago notes', es: 'Un ensayo de historia con notas de Chicago' }); 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上的食谱文件夹 ↗ (在新标签页中打开)

变化

#把注释放到文末

有些期刊把注释集中在论文之后;引文仍保留完整和简略两种形式。

-  placement: 'column', numbering: 'chapter',
+  placement: 'chapterEnd', numbering: 'chapter',

#恢复Ibid.

芝加哥格式第17版在重复上一条著作时印Ibid.。从Zotero样式库取来该样式的.csl文件,把内容放进一个字符串,作为自定义样式使用。

-  style: 'chicago-notes-bibliography', // CMOS 18th edition, notes and bibliography
+  style: 'custom', customStyle: CHICAGO_17TH_CSL, // the XML of the .csl file

常见问题

易错点

传入任何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上绘制页面 →

  • 以引用结尾的注释不要再加句点:样式已经加了一个,Postext不会把两个合并,注释末尾就会出现两个句点。
  • 在注释体样式里,不带方括号的@howse1980只在句中留下注号,变成As ¹ says,没有作者名。请自己写出作者名,再用方括号引用:As Howse says,[@howse1980]。

致谢

文本
原创文字, CC BY 4.0
字体
Crimson Pro (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1)
沙盒PDF