跳到主要内容
食谱编号87

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

按APA 7引用的学位论文章节

一章博士论文:正文写[@key, p. 33],citeproc-js按APA 7排出引文,参考文献表取自章末的BibTeX块。

本页内容
输出
Canvas · PDF
难度
中级
Postext
已用Postext 1.12.0测试
需要≥ 1.12.0 · postext-pdf ≥ 1.12.0
许可证
更新于2026年10月1日
代码MIT · 文本CC BY 4.0
  • 英文样例:尚无中文版本
  • 成品尺寸210 × 297 mm
  • 1栏
  • Literata 11/16
  • Public Sans
  • 3页
  • 难度
  • Postext 1.12.0
  • 排版用时19 ms
  • 118行代码

简单来说

博士论文的一章。作者给每条文献起一个短代号写进正文,Postext按APA格式印出文中引文和章末的参考文献表。

成品一览

一篇教育学博士论文的第二章,文献综述:二十年来关于纸上阅读和屏幕阅读的研究说了什么。这一章用A4纸,左侧装订,正文是Literata,行距16 pt,章首压着一条大学蓝色的浅色色带。作者用Zotero管理文献,把导出的BibTeX贴进稿子;正文里每条文献只是一个代号。Postext把每条引文排成APA 7:作者、年份,给了页码就加页码;名字作为句子成分时写成"Delgado et al. (2018)";参考文献表按APA要求的顺序和格式生成。导师要改成Chicago时,只改样式这一行。

这道食谱解答

  • 怎样按APA、IEEE或其他引用样式引用文献并生成参考文献表?

简短回答

script.js · 第27–39行在完整代码中
// Citations are written [@key, p. 33] and formatted by citeproc-js in the chosen CSL
// style; the references come from the BibTeX block at the end of the chapter. Register
// the engine once, before the first build, then the style is one setting.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
  style: 'apa', // 'chicago-author-date', 'ieee', 'vancouver'… change nothing else
  link: true, // each citation jumps to its entry in the PDF and on screen
  bibliography: {
    // APA asks for a half-inch hanging indent and keeps the list in the text size.
    fontSize: em(1), hangingIndent: mm(12.7), entrySpacing: pt(4),
    doi: 'link', // printed whole, as APA wants, and clickable
  },
};

用料

类型
Literata, Public Sans(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 一个引擎,一种样式

代码就是上面的简短回答。Postext识别引文,措辞交给引用引擎,在第一次排版前注册一次即可;postext-citeproc封装了Zotero和Mendeley所用的citeproc-js,自带十六种CSL样式及其语言文件。[@mangen2013]得到"(Mangen et al., 2013)",不带方括号的@delgado2018把作者放进句子,[@ibanez2023, pp. 12–15]把页码范围带进括号。文档改成西班牙文时,同样的代号会改用西班牙文语言文件里的连接词和缩写,因为引擎跟随文档的语言。

#2 · 文献来自BibTeX块

本章最后一块是:::references{format=bibtex},里面贴着Zotero的导出,重音写作{\'a}。上面的:::bibliography把文献表放在References标题之下;不写它,Postext就把文献表加在文末。表里只列被引用的文献,按APA的规则排序,正文里每条引文都链接到对应条目。

#3 · 不编号的标题,用文字表达的节号引用

script.js · 第105–106行在完整代码中
  headingStyles: [{ id: 'references', numbered: false }],
  crossRefs: { section: t({ en: 'Section {n}', es: 'Sección {n}' }) },

参考文献标题使用references样式,留在目录里但不参与编号,所以文献表不会变成第2.4节。:ref{id="sec-speed"}按crossRefs的标签印出"Section 2.2"并链接到该标题;节号来自章标题上的startAt=2,与各级标题共用同一个计数器。

#4 · 章首放在色带里

script.js · 第43–68行在完整代码中
const BAND = 92; // mm from the trim's top
const opener = {
  enabled: true,
  minHeight: mm(BAND - 28 + 10), // the body starts 10 mm under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('tint') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'text', id: 'label', content: t({ en: 'Chapter', es: 'Capítulo' }),
      fontFamily: LABEL, fontSize: pt(9), fontWeight: 600, letterSpacing: pt(1.8),
      textTransform: 'uppercase',
      color: col('accent'), align: 'left',
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { x: mm(0), y: mm(2) } } },
    { kind: 'text', id: 'number', content: '{number}', fontFamily: TEXT, fontSize: pt(96),
      fontWeight: 300, lineHeight: 0.9, color: col('accent'), align: 'right',
      placement: { anchor: { to: 'container', edge: 'top-right' },
        offset: { x: mm(0), y: mm(-6) },
        size: { width: mm(40), height: 'auto' } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(24),
      lineHeight: 1.12, fontWeight: 600, color: col('ink'), align: 'left', overflow: 'wrap',
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { x: mm(0), y: mm(14) },
        size: { width: mm(108), height: 'auto' } } },
  ] },
};

色带是一个锚定在页面裁切边上的方框,正文从它下方10 mm处开始。章号用96 pt的Literata Light放在右侧,读起来像一个图形,把整行宽度留给标题。

script.js · 第16–21行在完整代码中
const palette = {
  ink: '#1b1b1f', accent: '#22406a', tint: '#e6ecf4', rule: '#b9bec8', muted: '#5d6370',
};
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º 087 · A thesis chapter cited in APA 7 ═══════════════════
// https://postext.dev/en/cookbook/apa-thesis-with-bibtex
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Literata, Public Sans (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 = 'apa-thesis-with-bibtex';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a thesis prints in black; the one colour is the university's
const palette = {
  ink: '#1b1b1f', accent: '#22406a', tint: '#e6ecf4', rule: '#b9bec8', muted: '#5d6370',
};
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 = 'Literata', LABEL = 'Public Sans';
const LEAD = 16; // pt: double spacing is a typewriter habit; 1.45 reads as well and fits more

// #region answer: the citation engine, APA 7 and how its references look
// Citations are written [@key, p. 33] and formatted by citeproc-js in the chosen CSL
// style; the references come from the BibTeX block at the end of the chapter. Register
// the engine once, before the first build, then the style is one setting.
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
  style: 'apa', // 'chicago-author-date', 'ieee', 'vancouver'… change nothing else
  link: true, // each citation jumps to its entry in the PDF and on screen
  bibliography: {
    // APA asks for a half-inch hanging indent and keeps the list in the text size.
    fontSize: em(1), hangingIndent: mm(12.7), entrySpacing: pt(4),
    doi: 'link', // printed whole, as APA wants, and clickable
  },
};
// #endregion

// #region opener: a pale band at the head of the page, the number large and light in it
const BAND = 92; // mm from the trim's top
const opener = {
  enabled: true,
  minHeight: mm(BAND - 28 + 10), // the body starts 10 mm under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('tint') },
      placement: { anchor: { to: 'page', edge: 'top-left' },
        size: { width: 'fill', height: mm(BAND) } } },
    { kind: 'text', id: 'label', content: t({ en: 'Chapter', es: 'Capítulo' }),
      fontFamily: LABEL, fontSize: pt(9), fontWeight: 600, letterSpacing: pt(1.8),
      textTransform: 'uppercase',
      color: col('accent'), align: 'left',
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { x: mm(0), y: mm(2) } } },
    { kind: 'text', id: 'number', content: '{number}', fontFamily: TEXT, fontSize: pt(96),
      fontWeight: 300, lineHeight: 0.9, color: col('accent'), align: 'right',
      placement: { anchor: { to: 'container', edge: 'top-right' },
        offset: { x: mm(0), y: mm(-6) },
        size: { width: mm(40), height: 'auto' } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: TEXT, fontSize: pt(24),
      lineHeight: 1.12, fontWeight: 600, color: col('ink'), align: 'left', overflow: 'wrap',
      placement: { anchor: { to: 'container', edge: 'top-left' },
        offset: { x: mm(0), y: mm(14) },
        size: { width: mm(108), height: 'auto' } } },
  ] },
};
// #endregion

const head = (id, content, edge, x) => ({
  kind: 'text', id, content, pages: 'body', fontFamily: LABEL, fontSize: pt(7.5),
  letterSpacing: pt(0.8), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(14) } },
});

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(297), dpi: 150,
    // one-sided, bound at the left
    margins: { top: mm(28), bottom: mm(28), left: mm(35), right: mm(25) },
  },
  layout: { layoutType: 'single' },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(11), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('accent'),
    textAlign: 'justify', firstLineIndent: mm(6), 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, numberingTemplate: '{1}', fontSize: pt(22),
        breakBefore: { enabled: true, parity: 'any' }, advancedDesign: opener },
      { level: 2, numberingTemplate: '{1}.{2}', numberSeparator: '  ', fontSize: pt(13),
        lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) },
    ],
  },
  // #region refs: the references heading goes unnumbered; section references read in words
  headingStyles: [{ id: 'references', numbered: false }],
  crossRefs: { section: t({ en: 'Section {n}', es: 'Sección {n}' }) },
  // #endregion
  header: { elements: [
    head('title', '{title}', 'top-left', 35), head('folio', '{pageNumber}', 'top-right', -25),
  ] },
  footer: { elements: [] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 69行 · content.en.mdtitle: "Reading on Paper and on Screens" author: "Marta Ibáñez Roca" --- # Reading on Paper and on Screens: The State of the Evidence {#ch-review startAt=2} The question this thesis takes up is older than the screens it is usually asked about. Long before the first e-reader, typographers measured how the size of a letter, the length of a line and the space between lines changed the speed and the comfort of reading [@tinker1963]. What changed with screens was the scale of the experiment: by the 2010s a large share of what students read for their courses reached them through a display, and the question of whether that mattered left the laboratory for the lecture hall [@baron2015]. This chapter reviews what is known. :ref{id="sec-comprehension"} gathers the studies of comprehension, :ref{id="sec-speed"} those of speed and line length, and :ref{id="sec-attention"} the arguments about attention that frame the rest of the thesis. The method of the study itself follows in Chapter 3. ## Comprehension {#sec-comprehension} The most cited early comparison asked Norwegian upper-secondary students to read two texts, one narrative and one expository, either on paper or as PDF files on a computer screen [@mangen2013]. Those who read on paper scored better on the comprehension test that followed. The difference was modest, but it pointed in the same direction as a growing number of studies, and it raised a question the authors could not settle: whether the advantage came from the medium, from the way readers navigated it, or from what they expected to do with it. Two meta-analyses later pooled the evidence. @delgado2018 combined 54 studies with more than 170,000 participants and found an advantage for paper in the comprehension of informational texts, larger when reading was done under time pressure and, unexpectedly, larger in the more recent studies than in the older ones. @clinton2019 reached a similar conclusion from a narrower set of experiments: reading from paper led to better comprehension, while the time spent reading did not differ between the media. Neither review found a reliable difference for narrative texts. A pilot study run for this thesis with forty first-year students found the same pattern [@ibanez2023, pp. 12–15], although its sample was too small to settle it. ## Speed and line length {#sec-speed} Speed is the measure readers notice first and researchers trust least. A reader can go faster by understanding less, and a gain in words per minute says nothing of what was retained [@rayner2016]. Studies that control for comprehension tell a more modest story than the claims of speed-reading courses: the eyes move in short jumps, take in a few letters on either side of the fixation, and cannot be trained to take in a whole line at once [@rayner2016]. Line length is the variable typographers have argued about longest. On screen, @dyson2001 measured speed and comprehension at several line lengths and found that the two do not always improve together, which warns against judging a layout by speed alone. Older work on print had already shown that the best length depends on the size of the type and the leading, not on a fixed count of characters [@tinker1963; see also @ibanez2023, sec. 2]. ## Attention and the reading brain {#sec-attention} The empirical studies leave room for a wider argument. @wolf2018 holds that the habits formed by reading on screens, quick and skimming, carry over to the reading of long texts and weaken the slower processes that comprehension of a demanding argument needs. @baron2015 reaches a similar view from surveys of students, many of whom reported that they concentrated better on paper even when they preferred screens for convenience. These are arguments rather than measurements, and this thesis treats them as hypotheses: Chapter 3 tests whether the advantage for paper reported in :ref{id="sec-comprehension"} holds when the screen layout is set with the care a printed page receives. ## References {style="references"} :::bibliography{title=""} :::references{format=bibtex} @book{tinker1963, author = {Tinker, Miles A.}, title = {Legibility of print}, publisher = {Iowa State University Press}, address = {Ames, IA}, year = 1963} @book{baron2015, author = {Baron, Naomi S.}, title = {Words onscreen: The fate of reading in a digital world}, publisher = {Oxford University Press}, address = {New York}, year = 2015} @article{mangen2013, author = {Mangen, Anne and Walgermo, Bente R. and Br{\o}nnick, Kolbj{\o}rn}, title = {Reading linear texts on paper versus computer screen: Effects on reading comprehension}, journal = {International Journal of Educational Research}, volume = 58, pages = {61--68}, year = 2013, doi = {10.1016/j.ijer.2012.12.002}} @article{delgado2018, author = {Delgado, Pablo and Vargas, Crist{\'o}bal and Ackerman, Rakefet and Salmer{\'o}n, Ladislao}, title = {Don't throw away your printed books: A meta-analysis on the effects of reading media on reading comprehension}, journal = {Educational Research Review}, volume = 25, pages = {23--38}, year = 2018, doi = {10.1016/j.edurev.2018.09.003}} @article{clinton2019, author = {Clinton, Virginia}, title = {Reading from paper compared to screens: A systematic review and meta-analysis}, journal = {Journal of Research in Reading}, volume = 42, number = 2, pages = {288--325}, year = 2019, doi = {10.1111/1467-9817.12269}} @article{dyson2001, author = {Dyson, Mary C. and Haselgrove, Mark}, title = {The influence of reading speed and line length on the effectiveness of reading from screen}, journal = {International Journal of Human-Computer Studies}, volume = 54, number = 4, pages = {585--612}, year = 2001, doi = {10.1006/ijhc.2001.0458}} @article{rayner2016, author = {Rayner, Keith and Schotter, Elizabeth R. and Masson, Michael E. J. and Potter, Mary C. and Treiman, Rebecca}, title = {So much to read, so little time: How do we read, and can speed reading help?}, journal = {Psychological Science in the Public Interest}, volume = 17, number = 1, pages = {4--34}, year = 2016, doi = {10.1177/1529100615623267}} @techreport{ibanez2023, author = {Ib{\'a}{\~n}ez Roca, Marta}, title = {Screen layouts and comprehension: A pilot study}, institution = {Reading Lab}, type = {Working paper}, number = 3, year = 2023} @book{wolf2018, author = {Wolf, Maryanne}, title = {Reader, come home: The reading brain in a digital world}, publisher = {Harper}, address = {New York}, year = 2018} :::
`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { Literata: ['300', '400', '400i', '600', '600i'], 'Public Sans': ['400', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); const title = t({ en: 'A thesis chapter in APA 7', es: 'Un capítulo de tesis en APA 7' }); 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上的食谱文件夹 ↗ (在新标签页中打开)

变化

#换一种样式

同一章改用Chicago著者-出版年制,作者名后不加逗号,年份紧跟在作者之后。

-  style: 'apa', // 'chicago-author-date', 'ieee', 'vancouver'… change nothing else
+  style: 'chicago-author-date',

#让文献表比正文小一号

不少大学允许参考文献表缩小一号、单倍行距。

-    fontSize: em(1), hangingIndent: mm(12.7), entrySpacing: pt(4),
+    fontSize: em(0.9), lineHeight: pt(13), hangingIndent: mm(12.7), entrySpacing: pt(3),

常见问题

易错点

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

  • APA要求文章和图书标题只大写首字母,而citeproc-js按数据原样印出标题。Zotero里的标题多半每个词首字母大写,所以要在Zotero或BibTeX里改好,再让它们上版。

致谢

文本
原创文字, CC BY 4.0
字体
Literata (SIL OFL 1.1) · Public Sans (SIL OFL 1.1)
沙盒PDF