简单来说
大学研讨课的阅读书目。老师把Zotero导出的文件贴进稿子,Postext排出文中引文,书目里还收了正文没提到的书。
成品一览
一门为期六周的科学史与科学社会学研讨课的阅读书目,开本210 × 280 mm,分两栏排。第一页上半部是一块深绿色色带,课程名用50 pt的Spectral Light;下面是任课教师逐周的阅读说明。参考文献是这门课的Zotero文献集,导出为BibTeX后一行不改贴进文档:Zotero生成的引用键、保护大写字母的花括号、{\"o}式的重音、month = dec,以及file、keywords、urldate字段。正文引用了其中八种,另外五种是研讨课不讨论的背景读物,通过nocite进入书目。
这道食谱解答
- 怎样使用从Zotero导出的BibTeX,并列出正文未引用的文献?
- 怎样按APA、IEEE或其他引用样式引用文献并生成参考文献表?
简短回答
// The works come from a :::references{format=bibtex} block: the Zotero export pasted as it
// is. The text cites some of them; `nocite` in the front matter adds the rest by key, and
// `nocite: "@*"` would add the whole export. citeproc-js writes both in the style named.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
style: 'chicago-author-date', // '(Gieryn 1983)', 'Shapin and Schaffer (1985)'
link: true, // a citation jumps to its entry
bibliography: {
// includeUncited: true is the same as nocite: "@*", set from the design side.
fontSize: em(0.94), lineHeight: pt(12.4), hangingIndent: mm(5), entrySpacing: pt(3.5),
doi: 'link', // Zotero's doi field prints as https://doi.org/…; its url field is dropped
},
};
用料
- 类型
- Spectral, IBM Plex Sans Condensed(SIL OFL 1.1)
- 素材
- 无:所有图片都用代码绘制
做法
#1 · 样式只写一次
代码就是上面的简短回答。引用引擎在第一次排版前注册,样式只是一项设置:Chicago著者-出版年制把[@shapin_pump_1984]排成"(Shapin 1984)",把句中的@shapin_leviathan_1985排成"Shapin and Schaffer (1985)"。引用键是Zotero用第一作者、标题第一个实词和年份生成的,所以gieryn_boundary-work_1983里带连字符;Postext把键内部的连字符和下划线都当作键的一部分。
#2 · 导出内容按Zotero写的原样粘贴
文档最后一块是导出内容,每种文献一条,像Zotero那样用制表符缩进:
@article{gieryn_boundary-work_1983,
title = {Boundary-{Work} and the {Demarcation} of {Science} from {Non}-{Science}: …},
url = {https://www.jstor.org/stable/2095325},
doi = {10.2307/2095325},
urldate = {2026-08-21},
journal = {American Sociological Review},
author = {Gieryn, Thomas F.},
month = dec,
year = {1983},
keywords = {demarcation, week 4},
pages = {781--795},
file = {JSTOR Full Text PDF:/Users/hps214/Zotero/storage/2HJ8M6PA/Gieryn - 1983 - ….pdf:application/pdf},
}读取器去掉保护大写的花括号,把{\"o}和{\'e}合成ö和é,把--变成短破折号,并把不带括号的month = dec读作月份名。urldate成为访问日期,language成为文献的语种;file、keywords、abstract和shorttitle被跳过,硬盘上的路径不会印到页面上。
#3 · 收入正文没有引用的文献
在前置元数据里按键列出:
---
title: "Science as a Social Practice"
nocite: "@fleck_genesis_1979, @merton_sociology_1973, @latour_science_1987, @rheinberger_toward_1997, @lopez_pinero_ciencia_1979"
---书目收入全部被引文献和这五种,按Chicago的要求一起按作者排序,未引用的条目没有任何标记。nocite: "@*"列出整个导出内容;配置里的bibliography.includeUncited: true效果相同,适合总要印出整个文献库的设计。
#4 · 卡片上的一块绿色
const BAND = 132; // mm from the trim's top to the band's foot
const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' };
const opener = {
enabled: true,
minHeight: mm(BAND - TOP + 9), // the columns start 9 mm under the band
slot: { elements: [
{ kind: 'box', id: 'band', style: { backgroundColor: col('accent') },
placement: { anchor: { to: 'page', edge: 'top-left' },
size: { width: 'fill', height: mm(BAND) } } },
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: LABEL,
fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(1.7), textTransform: 'uppercase',
placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(14) } } },
{ kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: TEXT,
fontSize: pt(50), fontWeight: 300, lineHeight: 1.02,
placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(7) },
size: { width: mm(150), height: 'auto' } } },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('tint'),
placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(7) },
size: { width: mm(18) } } },
{ kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: TEXT,
italic: true, fontSize: pt(13), lineHeight: 1.3, color: col('tint'),
placement: { anchor: { to: '#rule', edge: 'below' }, offset: { y: mm(6) },
size: { width: mm(128), height: 'auto' } } },
] },
};
色带是锚定在裁切边上的方框,深132 mm,两栏正文从它下方9 mm处开始。标题、短线和导语用below锚点串在一起,课程名更长时会把导语往下推,而不会叠在一起。
const palette = {
ink: '#1c211f', accent: '#2d5b48', tint: '#dfe9e2', rule: '#c3cdc6', muted: '#5c6862',
paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
完整食谱
// ═══ Postext Cookbook · Nº 097 · A reading list from a Zotero BibTeX export ═══════ // https://postext.dev/en/cookbook/zotero-bibtex-nocite // Code: MIT · Text: original (CC BY 4.0) · Pictures: none // Fonts: Spectral, IBM Plex Sans Condensed (SIL OFL 1.1) · Needs postext ≥ 1.12.1 import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerCitationEngine, } from 'https://esm.sh/postext'; import { createCiteprocEngine, STYLES, LOCALES } from 'https://esm.sh/postext-citeproc'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'zotero-bibtex-nocite'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: a department green, one tint, everything else in ink const palette = { ink: '#1c211f', accent: '#2d5b48', tint: '#dfe9e2', rule: '#c3cdc6', muted: '#5c6862', paper: '#ffffff', }; const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = Object.entries({ ...palette, 'main-color': palette.accent }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion const [TEXT, LABEL] = ['Spectral', 'IBM Plex Sans Condensed']; const LEAD = 13.4; // pt const [TOP, OUTER] = [24, 18]; // mm // #region answer: Chicago author-date, and a list that holds more than the text cites // The works come from a :::references{format=bibtex} block: the Zotero export pasted as it // is. The text cites some of them; `nocite` in the front matter adds the rest by key, and // `nocite: "@*"` would add the whole export. citeproc-js writes both in the style named. registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES })); const citations = { style: 'chicago-author-date', // '(Gieryn 1983)', 'Shapin and Schaffer (1985)' link: true, // a citation jumps to its entry bibliography: { // includeUncited: true is the same as nocite: "@*", set from the design side. fontSize: em(0.94), lineHeight: pt(12.4), hangingIndent: mm(5), entrySpacing: pt(3.5), doi: 'link', // Zotero's doi field prints as https://doi.org/…; its url field is dropped }, }; // #endregion // #region opener: a green field over the top half, the course in white on it const BAND = 132; // mm from the trim's top to the band's foot const onBand = { color: col('paper'), align: 'left', overflow: 'wrap' }; const opener = { enabled: true, minHeight: mm(BAND - TOP + 9), // the columns start 9 mm under the band slot: { elements: [ { kind: 'box', id: 'band', style: { backgroundColor: col('accent') }, placement: { anchor: { to: 'page', edge: 'top-left' }, size: { width: 'fill', height: mm(BAND) } } }, { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...onBand, fontFamily: LABEL, fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(1.7), textTransform: 'uppercase', placement: { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(14) } } }, { kind: 'text', id: 'title', content: '{titleText}', ...onBand, fontFamily: TEXT, fontSize: pt(50), fontWeight: 300, lineHeight: 1.02, placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(7) }, size: { width: mm(150), height: 'auto' } } }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('tint'), placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(7) }, size: { width: mm(18) } } }, { kind: 'text', id: 'lead', content: '{attr.lead}', ...onBand, fontFamily: TEXT, italic: true, fontSize: pt(13), lineHeight: 1.3, color: col('tint'), placement: { anchor: { to: '#rule', edge: 'below' }, offset: { y: mm(6) }, size: { width: mm(128), height: 'auto' } } }, ] }, }; // #endregion const head = (id, content, parity, edge, x, extra = {}) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5), fontWeight: 600, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('muted'), placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(13) } }, ...extra, }); const folio = { color: col('accent') }; const listName = t({ en: 'HPS 214 · Reading list', es: 'HPS 214 · Lecturas' }); const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-us', es: 'es' }), colorPalette, citations, page: { sizePreset: 'custom', width: mm(210), height: mm(280), dpi: 150, margins: { top: mm(TOP), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true }, }, layout: { layoutType: 'double', gutterWidth: mm(7) }, bodyText: { fontFamily: TEXT, fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('accent'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true, }, headings: { fontFamily: TEXT, color: col('ink'), fontWeight: 600, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontFamily: LABEL, fontSize: pt(10), lineHeight: pt(LEAD), fontWeight: 600, color: col('accent'), marginTop: pt(LEAD), marginBottom: pt(0), }, ], }, 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', OUTER, folio), head('verso-title', '{title}', 'even', 'top-left', OUTER + 8), head('recto-title', listName, 'odd', 'top-right', -(OUTER + 8)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio), ] }, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 204行 · content.en.md
title: "Science as a Social Practice" nocite: "@fleck_genesis_1979, @merton_sociology_1973, @latour_science_1987, @rheinberger_toward_1997, @lopez_pinero_ciencia_1979" --- # Science as a Social Practice {kicker="HPS 214 · Seminar reading list · Autumn term 2026" lead="Six weeks on how scientific knowledge is made, tested and trusted, from the paradigm to the laboratory notebook."} This seminar reads the history and sociology of science as one subject. Its question is how a claim about nature becomes a fact that people who never saw the experiment are willing to rely on. The answers we read come from historians of the seventeenth century, from sociologists who sat in laboratories for two years, and from philosophers who looked at what scientists actually do rather than at what their textbooks say they do. Each week has one set reading, which everyone reads before the seminar, and one or two companion texts for the person introducing the discussion. The set readings are available in the course reader; the others are in the library’s reserve collection. ## Week 1 · Paradigms and normal science We start with the book that made the subject fashionable. Read the first five sections and the Postscript of @kuhn_structure_1970, which Kuhn added for the second edition to answer the charge that “paradigm” meant too many things at once. His preface credits a monograph by Ludwik Fleck, written in 1935 and nearly forgotten until Kuhn mentioned it; it is in the list at the end, and the student introducing the week should read its second chapter. ## Week 2 · Witnessing an experiment The set reading is the first two chapters of @shapin_leviathan_1985, on Robert Boyle’s air-pump and the dispute with Thomas Hobbes over whether an experiment could settle anything. The companion text is the shorter article in which Shapin first described Boyle’s “literary technology”, the way a report written in plain, circumstantial prose turned its readers into witnesses [@shapin_pump_1984]. ## Week 3 · Inside the laboratory Bruno Latour spent two years at the Salk Institute as an observer of a neuroendocrinology laboratory. Read the second and third chapters of @latour_laboratory_1979 and follow how a statement loses its qualifications (“it seems that”, “X has claimed”) as it travels from one paper to the next until it is printed without any. ## Week 4 · Where science ends The demarcation problem asks what separates science from what is not science. Philosophers looked for a criterion; @gieryn_boundary-work_1983 looked at the people who draw the line in practice, and at what they gain by drawing it where they do. Bring an example from the news of the last month. ## Week 5 · Objectivity has a history Read the article first [@daston_image_1992], then the first chapter and the chapter on “trained judgment” in the book it grew into [@daston_objectivity_2007]. The argument is that objectivity, as a virtue of the scientist, appeared in the nineteenth century, and that atlases of skeletons, snowflakes and clouds show it arriving. ## Week 6 · Whom to believe The last week returns to the seventeenth century with a broader question: how did gentlemen-philosophers decide whose reports to accept? Read the first chapter of @shapin_social_1994 and come ready to compare its answer with how you decide whom to believe when you read a paper outside your field. ## Further reading The list below is the seminar’s Zotero collection, exported as BibTeX and set as it came out of Zotero. It includes every work cited above and five that the seminar does not discuss but that you will meet in the essays you read: Fleck’s monograph, Merton’s collected essays on the norms of science, Latour’s later handbook, Rheinberger’s study of experimental systems, and a history of Spanish science in the sixteenth and seventeenth centuries for those who read Spanish. :::bibliography{title=""} :::paragraphs{style="colophon"} Set in Spectral and IBM Plex Sans Condensed (SIL OFL) · Reading list and notes: original, CC BY 4.0 · The works are real; the Zotero file paths and access dates are illustrative. ::: :::references{format=bibtex} @book{kuhn_structure_1970, address = {Chicago}, edition = {2}, title = {The {Structure} of {Scientific} {Revolutions}}, abstract = {Normal science, anomaly and crisis, and the paradigm changes that end them, with the Postscript of 1969.}, language = {en}, publisher = {University of Chicago Press}, author = {Kuhn, Thomas S.}, year = {1970}, keywords = {paradigms, week 1}, file = {Kuhn - 1970 - The Structure of Scientific Revolutions.pdf:/Users/hps214/Zotero/storage/7QW2KD9N/Kuhn - 1970 - The Structure of Scientific Revolutions.pdf:application/pdf}, } @book{fleck_genesis_1979, address = {Chicago}, title = {Genesis and {Development} of a {Scientific} {Fact}}, language = {en}, publisher = {University of Chicago Press}, author = {Fleck, Ludwik}, editor = {Trenn, Thaddeus J. and Merton, Robert K.}, translator = {Bradley, Fred and Trenn, Thaddeus J.}, year = {1979}, keywords = {thought collectives, week 1}, } @book{shapin_leviathan_1985, address = {Princeton, NJ}, title = {Leviathan and the {Air}-{Pump}: {Hobbes}, {Boyle}, and the {Experimental} {Life}}, shorttitle = {Leviathan and the {Air}-{Pump}}, language = {en}, publisher = {Princeton University Press}, author = {Shapin, Steven and Schaffer, Simon}, year = {1985}, keywords = {experiment, week 2}, } @article{shapin_pump_1984, title = {Pump and {Circumstance}: {Robert} {Boyle}'s {Literary} {Technology}}, volume = {14}, shorttitle = {Pump and {Circumstance}}, url = {https://doi.org/10.1177/030631284014004001}, doi = {10.1177/030631284014004001}, language = {en}, number = {4}, urldate = {2026-08-21}, journal = {Social Studies of Science}, author = {Shapin, Steven}, month = nov, year = {1984}, keywords = {literary technology, week 2}, pages = {481--520}, file = {Shapin - 1984 - Pump and Circumstance.pdf:/Users/hps214/Zotero/storage/V3NC8RTE/Shapin - 1984 - Pump and Circumstance.pdf:application/pdf}, } @book{latour_laboratory_1979, address = {Beverly Hills}, title = {Laboratory {Life}: {The} {Social} {Construction} of {Scientific} {Facts}}, shorttitle = {Laboratory {Life}}, language = {en}, publisher = {Sage}, author = {Latour, Bruno and Woolgar, Steve}, year = {1979}, keywords = {ethnography, week 3}, } @book{latour_science_1987, address = {Cambridge, MA}, title = {Science in {Action}: {How} to {Follow} {Scientists} and {Engineers} through {Society}}, shorttitle = {Science in {Action}}, language = {en}, publisher = {Harvard University Press}, author = {Latour, Bruno}, year = {1987}, } @book{rheinberger_toward_1997, address = {Stanford, CA}, title = {Toward a {History} of {Epistemic} {Things}: {Synthesizing} {Proteins} in the {Test} {Tube}}, language = {en}, publisher = {Stanford University Press}, author = {Rheinberger, Hans-J{\"o}rg}, year = {1997}, keywords = {experimental systems}, } @article{gieryn_boundary-work_1983, title = {Boundary-{Work} and the {Demarcation} of {Science} from {Non}-{Science}: {Strains} and {Interests} in {Professional} {Ideologies} of {Scientists}}, volume = {48}, shorttitle = {Boundary-{Work} and the {Demarcation} of {Science} from {Non}-{Science}}, url = {https://www.jstor.org/stable/2095325}, doi = {10.2307/2095325}, language = {en}, number = {6}, urldate = {2026-08-21}, journal = {American Sociological Review}, author = {Gieryn, Thomas F.}, month = dec, year = {1983}, keywords = {demarcation, week 4}, pages = {781--795}, file = {JSTOR Full Text PDF:/Users/hps214/Zotero/storage/2HJ8M6PA/Gieryn - 1983 - Boundary-Work and the Demarcation of Science from Non-Science.pdf:application/pdf}, } @article{daston_image_1992, title = {The {Image} of {Objectivity}}, url = {https://www.jstor.org/stable/2928741}, doi = {10.2307/2928741}, language = {en}, number = {40}, urldate = {2026-08-22}, journal = {Representations}, author = {Daston, Lorraine and Galison, Peter}, year = {1992}, keywords = {objectivity, week 5}, pages = {81--128}, file = {JSTOR Full Text PDF:/Users/hps214/Zotero/storage/F9XK4C2B/Daston and Galison - 1992 - The Image of Objectivity.pdf:application/pdf}, } @book{daston_objectivity_2007, address = {New York}, title = {Objectivity}, language = {en}, publisher = {Zone Books}, author = {Daston, Lorraine and Galison, Peter}, year = {2007}, keywords = {objectivity, week 5}, } @book{shapin_social_1994, address = {Chicago}, series = {Science and {Its} {Conceptual} {Foundations}}, title = {A {Social} {History} of {Truth}: {Civility} and {Science} in {Seventeenth}-{Century} {England}}, language = {en}, publisher = {University of Chicago Press}, author = {Shapin, Steven}, year = {1994}, keywords = {trust, week 6}, } @book{merton_sociology_1973, address = {Chicago}, title = {The {Sociology} of {Science}: {Theoretical} and {Empirical} {Investigations}}, language = {en}, publisher = {University of Chicago Press}, author = {Merton, Robert K.}, editor = {Storer, Norman W.}, year = {1973}, } @book{lopez_pinero_ciencia_1979, address = {Barcelona}, title = {Ciencia y t{\'e}cnica en la sociedad espa{\~n}ola de los siglos {XVI} y {XVII}}, language = {es}, publisher = {Labor}, author = {L{\'o}pez Pi{\~n}ero, Jos{\'e} Mar{\'i}a}, year = {1979}, } :::`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Spectral: ['300', '300i', '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); showPages(doc, { title: t({ en: 'A reading list from Zotero', es: 'Lecturas desde Zotero' }) });工具包 · core, fonts, viewer:每道食谱都相同 · 235行
// ─── 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 ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗ (在新标签页中打开)
变化
#列出整个Zotero文献集
导出内容本身就是书目时,不必写键:块里的每一条都会印出,无论是否被引用。
-nocite: "@fleck_genesis_1979, @merton_sociology_1973, @latour_science_1987, …"
+nocite: "@*"#换一种样式
同样的键不做其他改动,就能得到Harvard(Cite Them Right)书目。
- style: 'chicago-author-date', // '(Gieryn 1983)', 'Shapin and Schaffer (1985)'
+ style: 'harvard-cite-them-right',常见问题
易错点
nocite中找不到的键会被悄悄丢弃
被引用却没有文献数据的键会触发unknownCitationKey警告,但nocite中对不上任何条目的键会被直接跳过,没有警告。从导出文件里复制键,并数一数列表中的条目。 由参考文献数据生成的文献表 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
易错点
frontmatter的每个值都加引号
YAML会把title: 1984读成数字,把日期读成Date对象;非字符串的值在占位符中打印为空,PDF也会没有标题。每个值都加引号:title: "1984"。 文档元数据 →
易错点
配置按对象身份缓存:每次新建一个对象
引擎按对象身份缓存解析后的配置,所以就地修改配置再构建,会复用旧的结果。每次构建都新建一个对象,这也是食谱的配置写成工厂函数config()的原因。 在Canvas上绘制页面 →
nocite里的键如果对不上任何条目,会被直接丢掉,没有警告。键要从导出内容里复制,不要手打,并核对页面上的条目数。- Zotero按数据生成键:在Zotero里改了作者名或标题,下次导出时键可能变化,所有引用它的地方都会失效。动笔之前先把键固定下来:Zotero会读取条目“其他”(Extra)字段里的
Citation Key: …一行。 - Zotero默认以UTF-8导出,重音字符原样保留。本例的块是用Western编码选项导出的,所以写成
{\"o};Postext两种都能读。 - Chicago第18版不印出版地,所以
address字段不出现;有DOI的文章印DOI,而不印JSTOR的url。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- Spectral (SIL OFL 1.1) · IBM Plex Sans Condensed (SIL OFL 1.1)


