跳到主要内容
食谱编号99

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

用OSCOLA格式引注的法律评论

一篇四页的判例评论:正文写[@robinson2018, para 21],引文按OSCOLA排成脚注,判例名用斜体,带中立引注、ibid和(n 1)回指。

本页内容
输出
Canvas · PDF
难度
中级
Postext
已用Postext 1.12.1测试
需要≥ 1.12.1 · postext-pdf ≥ 1.12.1
许可证
更新于2026年10月1日
代码MIT · 文本CC BY 4.0
  • 英文样例:尚无中文版本
  • 成品尺寸162 × 240 mm
  • 1栏
  • Libre Baskerville 9.4/13.6
  • Libre Caslon Display
  • Libre Franklin
  • 4页
  • 难度
  • Postext 1.12.1
  • 排版用时47 ms
  • 158行代码

简单来说

一篇评论英国警察案件判决的短文。文中依据的每个判例、法规和文章都在页脚的编号注释中注明,用的是英国法学院通行的格式。

成品一览

一份法学期刊判例评论栏目的抽印本:四页,评论英国最高法院2018年的Robinson v Chief Constable of West Yorkshire Police案,讨论警察何时负有注意义务。页面162 × 240 mm,左右镜像,正文Libre Baskerville 9.4/13.6 pt,首页是一条酒红色色带,案名用60 pt斜体。作者把每个出处写成带定位的代号[@caparo1990, 617–18],Postext按英国法学院通行的OSCOLA格式排出23条脚注:判例名斜体,法规正体,AC、HL、edn等缩写后不加句点,紧接上一条注的同一出处写ibid,隔开之后写Robinson (n 1) [55]。所引判例和法规都是真实的,作者和期刊是虚构的。

这道食谱解答

  • 怎样按英国法律写作的OSCOLA样式在脚注中引用判例和法规?
  • 怎样按芝加哥注释体把引用排成脚注,并让后续引用用简略形式?

简短回答

script.js · 第50–68行在完整代码中
// The text cites with [@robinson2018, para 21]; OSCOLA is a note style, so each citation
// becomes a footnote: the case in full the first time, "ibid" when the note before cites
// the same authority, "Robinson (n 1) [55]" after that. Case names come out italic,
// statutes roman, and no abbreviation takes a full stop (AC, HL, UKSC, edn, n, s).
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
  style: 'custom', customStyle: OSCOLA, // the bundled 'oscola', patched above
  locale: 'en-GB', // OSCOLA's words stay English in the Spanish edition too
  notes: 'footnote',
  link: false, // a case comment has no bibliography to link to
  bibliography: { auto: false }, // OSCOLA articles cite in the notes only
};
// The citation notes share the numbering and the look of the author's own [^notes].
const footnotes = {
  placement: 'column', numbering: 'chapter',
  fontSize: pt(7.8), lineHeight: pt(10.2), spaceBetween: pt(2.2),
  textAlign: 'left', // ragged: long report citations would open wide gaps if justified
  separator: { width: 0.25, lineWidth: pt(0.5), color: col('rule') },
};

用料

类型
Libre Baskerville, Libre Caslon Display, Libre Franklin(SIL OFL 1.1)
素材
无:所有图片都用代码绘制

做法

#1 · 每个出处一条OSCOLA注

代码就是上面的简短回答。OSCOLA是注释体样式:[@robinson2018, para 21]在句号后留下一个上标数字,内容进入页脚。注1给出完整引注Robinson v Chief Constable of West Yorkshire Police [2018] UKSC 4, [2018] AC 736;紧接着再次引用的注2是ibid [21];中间隔了其他判例的注8是Robinson (n 1) [26–27]。法学期刊文章只在注释中引用,所以bibliography.auto: false不在末页排参考文献表。

#2 · 判例是数据,照判例汇编的写法录入

判例是type: legal_case的CSL条目:案名放title,法院放authority,汇编名放container-title,起始页放page。当年份用来确定卷册时(如Appeal Cases),OSCOLA把汇编年份放在方括号里,所以本例把这个年份连同卷号写进volume:volume: "[1990] 2"得到*[1990] 2 AC 605 (HL)。2001年以后的判决还有中立引注:authority: UKSC、number: "4"和issued: 2018得到[2018] UKSC 4*,后面再接汇编引注。法规是type: legislation,年份写在标题里、不设issued,免得年份印两次;[@hra1998, § 6]引作s 6。

#3 · 给内置样式打两个补丁

script.js · 第32–46行在完整代码中
// The bundled oscola.csl writes a paragraph pinpoint in brackets only in a case's first
// note: a later note printed "Robinson (n 1) para 55" and "ibid 27", and a repeated
// statute "ibid 7". These two replacements give "Robinson (n 1) [55]", "ibid [27]" and
// "ibid s 7", as OSCOLA prints them.
const LOCATOR = `<macro name="label-locator"><choose>
  <if type="legal_case" locator="paragraph">
    <text variable="locator" prefix="[" suffix="]"/></if>
  <else-if locator="page"><text variable="locator"/></else-if>
  <else><group delimiter=" "><label form="short" strip-periods="true" variable="locator"/>
    <text variable="locator"/></group></else>
</choose></macro>`;
const OSCOLA = STYLES.oscola
  .replace(/<macro name="label-locator">[\s\S]*?<\/macro>/, LOCATOR)
  .replace(/(<if position="ibid-with-locator">[\s\S]*?)<text variable="locator"\/>/,
    '$1<text macro="label-locator"/>');

内置的oscola.csl只在判例的第一条注里给段落定位加方括号,之后的注印成para 55和ibid 27,再次引用同一法规时也丢了s。补丁在排版前替换样式XML中的一个宏和一行,再作为自定义样式传入,正文和文献数据都不用改。

#4 · 色带上的案名

script.js · 第72–96行在完整代码中
const BAND = 124; // mm from the trim's top
const big = (id, content, family, size, color, y, extra = {}) => ({
  kind: 'text', id, content, fontFamily: family, fontSize: pt(size), lineHeight: 1.08, color,
  align: 'left', overflow: 'wrap', ...extra,
  placement: { ...at('container', 'top-left', 0, y), size: { width: mm(118), height: 'auto' } },
});
const opener = {
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 8), // the text starts 8 mm under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('claret') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    big('kicker', '{attr.kicker}', LABEL, 7.4, col('tint'), 2,
      { fontWeight: 600, letterSpacing: pt(1.4), textTransform: 'uppercase' }),
    big('case', '{attr.case}', TEXT, 60, col('paper'), 9, { italic: true }),
    big('parties', '{attr.parties}', TEXT, 12.5, col('tint'), 33, { italic: true }),
    big('citation', '{attr.citation}', TEXT, 10.5, col('tint'), 40),
    { kind: 'box', id: 'hairline', style: { backgroundColor: col('tint') },
      placement: { ...at('container', 'top-left', 0, 51),
        size: { width: mm(30), height: pt(0.6) } } },
    big('title', '{titleText}', DISPLAY, 27, col('paper'), 57),
    big('byline', '{attr.byline}', LABEL, 8.5, col('tint'), 82,
      { fontWeight: 500, letterSpacing: pt(0.5) }),
  ] },
};

色带从裁切边向下124 mm。案名、当事人和引注都是标题属性,同一栏目的另一篇评论只需改Markdown。Robinson用60 pt的Libre Baskerville斜体,因为OSCOLA要求案名斜体;下面的引注保持正体,与注释中一致。

script.js · 第16–23行在完整代码中
const palette = {
  ink: '#1f1b1a', claret: '#5e1b26', tint: '#ecdcd8', rule: '#bfb4ad', muted: '#6b625d',
  paper: '#fcfaf6',
};
// 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.claret })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 099 · A legal opinion cited in OSCOLA ══════════════════
// https://postext.dev/en/cookbook/legal-opinion-oscola
// Code: MIT · Text: original (CC BY 4.0) · Pictures: none
// Fonts: Libre Baskerville, Libre Caslon Display, Libre Franklin (OFL 1.1) · Needs postext ≥ 1.12.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerCitationEngine,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';
import { createCiteprocEngine, STYLES, LOCALES } from 'https://esm.sh/postext-citeproc';

const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es')
const RECIPE = 'legal-opinion-oscola';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: a law-report claret for the band, ink and cream paper for the rest
const palette = {
  ink: '#1f1b1a', claret: '#5e1b26', tint: '#ecdcd8', rule: '#bfb4ad', muted: '#6b625d',
  paper: '#fcfaf6',
};
// 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.claret })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const TEXT = 'Libre Baskerville', DISPLAY = 'Libre Caslon Display', LABEL = 'Libre Franklin';
const TRIM = { width: 162, height: 240 }; // a law-journal offprint
const MARGIN = { top: 23, bottom: 22, inner: 19, outer: 21 };
const LEAD = 13.6; // pt
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });

// #region oscola: two patches to the bundled OSCOLA style, applied before the build
// The bundled oscola.csl writes a paragraph pinpoint in brackets only in a case's first
// note: a later note printed "Robinson (n 1) para 55" and "ibid 27", and a repeated
// statute "ibid 7". These two replacements give "Robinson (n 1) [55]", "ibid [27]" and
// "ibid s 7", as OSCOLA prints them.
const LOCATOR = `<macro name="label-locator"><choose>
  <if type="legal_case" locator="paragraph">
    <text variable="locator" prefix="[" suffix="]"/></if>
  <else-if locator="page"><text variable="locator"/></else-if>
  <else><group delimiter=" "><label form="short" strip-periods="true" variable="locator"/>
    <text variable="locator"/></group></else>
</choose></macro>`;
const OSCOLA = STYLES.oscola
  .replace(/<macro name="label-locator">[\s\S]*?<\/macro>/, LOCATOR)
  .replace(/(<if position="ibid-with-locator">[\s\S]*?)<text variable="locator"\/>/,
    '$1<text macro="label-locator"/>');
// #endregion

// #region answer: OSCOLA footnotes for cases, statutes and articles
// The text cites with [@robinson2018, para 21]; OSCOLA is a note style, so each citation
// becomes a footnote: the case in full the first time, "ibid" when the note before cites
// the same authority, "Robinson (n 1) [55]" after that. Case names come out italic,
// statutes roman, and no abbreviation takes a full stop (AC, HL, UKSC, edn, n, s).
registerCitationEngine(createCiteprocEngine({ styles: STYLES, locales: LOCALES }));
const citations = {
  style: 'custom', customStyle: OSCOLA, // the bundled 'oscola', patched above
  locale: 'en-GB', // OSCOLA's words stay English in the Spanish edition too
  notes: 'footnote',
  link: false, // a case comment has no bibliography to link to
  bibliography: { auto: false }, // OSCOLA articles cite in the notes only
};
// The citation notes share the numbering and the look of the author's own [^notes].
const footnotes = {
  placement: 'column', numbering: 'chapter',
  fontSize: pt(7.8), lineHeight: pt(10.2), spaceBetween: pt(2.2),
  textAlign: 'left', // ragged: long report citations would open wide gaps if justified
  separator: { width: 0.25, lineWidth: pt(0.5), color: col('rule') },
};
// #endregion

// #region opener: the claret band holds the case name, its citation and the title
const BAND = 124; // mm from the trim's top
const big = (id, content, family, size, color, y, extra = {}) => ({
  kind: 'text', id, content, fontFamily: family, fontSize: pt(size), lineHeight: 1.08, color,
  align: 'left', overflow: 'wrap', ...extra,
  placement: { ...at('container', 'top-left', 0, y), size: { width: mm(118), height: 'auto' } },
});
const opener = {
  enabled: true,
  minHeight: mm(BAND - MARGIN.top + 8), // the text starts 8 mm under the band
  slot: { elements: [
    { kind: 'box', id: 'band', style: { backgroundColor: col('claret') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(BAND) } } },
    big('kicker', '{attr.kicker}', LABEL, 7.4, col('tint'), 2,
      { fontWeight: 600, letterSpacing: pt(1.4), textTransform: 'uppercase' }),
    big('case', '{attr.case}', TEXT, 60, col('paper'), 9, { italic: true }),
    big('parties', '{attr.parties}', TEXT, 12.5, col('tint'), 33, { italic: true }),
    big('citation', '{attr.citation}', TEXT, 10.5, col('tint'), 40),
    { kind: 'box', id: 'hairline', style: { backgroundColor: col('tint') },
      placement: { ...at('container', 'top-left', 0, 51),
        size: { width: mm(30), height: pt(0.6) } } },
    big('title', '{titleText}', DISPLAY, 27, col('paper'), 57),
    big('byline', '{attr.byline}', LABEL, 8.5, col('tint'), 82,
      { fontWeight: 500, letterSpacing: pt(0.5) }),
  ] },
};
// #endregion

const head = (id, content, parity, edge, x, align) => ({
  kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontWeight: 500,
  fontSize: pt(7.2), letterSpacing: pt(1), textTransform: 'uppercase', color: col('muted'),
  align, placement: at('page', edge, x, 13),
});
const journal = t({ en: 'Notes on Obligations', es: 'Cuadernos de Obligaciones' });

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(9.4), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: mm(4.5), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: TEXT, fontWeight: 400, color: col('ink'),
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, fontSize: pt(27), breakBefore: { enabled: true, parity: 'any' },
        advancedDesign: opener },
      { level: 2, italic: true, fontSize: pt(12), color: col('claret'),
        lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(LEAD / 2) },
    ],
  },
  paragraphStyles: [
    { id: 'colophon', fontFamily: LABEL, fontSize: pt(6.8), lineHeight: pt(9.2),
      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-journal', journal, 'even', 'top-left', MARGIN.outer + 8, 'left'),
    head('recto-title', '{title}', 'odd', 'top-right', -(MARGIN.outer + 8), 'right'),
    head('recto-folio', '{pageNumber}', 'odd', 'top-right', -MARGIN.outer, 'right'),
  ] },
  footer: { elements: [ // a drop folio on the opener page only
    { kind: 'text', id: 'drop-folio', content: '{pageNumber}', pages: 'opener',
      fontFamily: LABEL, fontWeight: 500, fontSize: pt(7.2), color: col('muted'),
      align: 'center', placement: at('page', 'bottom', 0, -11) },
  ] },
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 192行 · content.en.mdtitle: "Acts, Omissions and the Police" author: "Harriet Okafor" --- # Acts, Omissions and the Police {#comment kicker="Notes on Obligations · Case Comment" case="Robinson" parties="v Chief Constable of West Yorkshire Police" citation="[2018] UKSC 4, [2018] AC 736" byline="Harriet Okafor"} On a July afternoon in 2008 two detectives tried to arrest a man suspected of dealing drugs on a shopping street in the centre of Huddersfield. He resisted, and the three men fell against Mrs Robinson, a woman of 76 who was walking past, and knocked her to the ground. The trial judge found that the officers had been careless in attempting the arrest where and when they did, but held that the police owed no duty of care to a passer-by hurt in the course of their work against crime. The Court of Appeal agreed. The Supreme Court did not.[@robinson2018] Lord Reed, with whom Lady Hale and Lord Hodge agreed, began by correcting a reading of the law that had gone uncorrected for almost thirty years.[@robinson2018, para 21] This comment argues that the case matters less for what it decided about the police than for the method it restored. A court asked whether a duty of care exists should look first for an established category and reason from it by analogy; it should weigh questions of policy only when the case is new. ## There is no “Caparo test” The modern law begins with Lord Atkin’s statement that a person must take reasonable care to avoid acts or omissions which he can reasonably foresee would be likely to injure his neighbour.[@donoghue1932, 580] In the 1970s the House of Lords tried to turn that statement into a general formula. Lord Wilberforce proposed two stages: a relationship of proximity in which harm was foreseeable, and then any considerations that ought to negative or limit the duty.[@anns1978, 751–52] The formula expanded liability faster than the courts could defend, and *Anns* was overruled in 1990.[@murphy1991] In the same year Lord Bridge described the ingredients of a duty as foreseeability of damage, a relationship of proximity, and a situation in which the court considers it fair, just and reasonable to impose a duty.[@caparo1990, 617–18] Many judges went on to apply it as a three-stage test to every claim, including claims for physical injury caused by a positive act. Lord Bridge had said the opposite. He doubted that any single test could settle every case and preferred that the law develop novel categories incrementally.[^brennan] [^brennan]: Lord Bridge adopted the words of Brennan J in the High Court of Australia; see [@caparo1990, 618; @sutherland1985, 481] *Robinson* restores that reading. Where a case falls within an established category, the court applies the law as settled; the question of what is fair, just and reasonable arises only where the court is asked to go further.[@robinson2018, para 26–27] Physical injury caused by a careless positive act has been an established category since *Donoghue*. The officers’ decision to make the arrest in a crowded street was such an act, the risk to people nearby was foreseeable, and nothing more was needed to found a duty.[@robinson2018, para 74] ## Acts and omissions The common law does not, in general, impose a duty to protect another from harm caused by a third party or by nature.[@smith1987, 271] Lord Hoffmann gave the reason in political terms: it is less of an invasion of a person’s freedom to require him to take care when he chooses to act than to require him to act at all, and a duty to rescue invites the question why it should fall on one bystander rather than another.[@stovin1996, 943–44] The rule has exceptions: where the defendant has assumed responsibility, controls the source of the danger, or has created it. Read in this light, the earlier police cases were cases about omissions. In *Hill* the mother of the last victim of Peter Sutcliffe sued the force that had failed to catch him. The House of Lords held that no duty was owed to her daughter, who was one of an indeterminate public, and added reasons of policy about the effect of liability on police work.[@hill1989, 63] For thirty years *Hill* was cited as if it gave the police a general immunity. Lord Reed treated the policy reasons as a supplement to the ordinary rule: the police, like anyone else, owe no duty to prevent harm by a third party, unless one of the exceptions applies; but they owe the ordinary duty not to injure people by what they themselves do.[@robinson2018, para 55] The distinction had already decided *Michael*, where a call to the emergency services was wrongly graded and the caller was killed before officers arrived. The majority held that the police had assumed no responsibility for her safety and so owed her no duty in negligence.[@michael2015, para 97–100] Academic opinion had divided on the point. One view holds that a public body set up to protect people should bear a narrow duty where it knows of a specific threat to a specific person;[^peripheral] the other, that the omissions rule applies to public authorities as it applies to private persons, and that any wider duty is for Parliament.[@nolan2011] [^peripheral]: [@tofaris2016, 130–32; cf @stapleton1995, 304–06, on the deterrent value of claims against parties who are not the primary wrongdoer] ## The statutory route Section 6(1) of the Human Rights Act 1998 makes it unlawful for a public authority to act in a way that is incompatible with a Convention right,[@hra1998, § 6] and a victim may bring proceedings against it under section 7.[@hra1998, § 7] In *Michael* the claim under article 2 of the Convention was allowed to go to trial even though the claim in negligence failed.[@michael2015, para 139] In *DSD* the Supreme Court held the police liable under article 3 for serious failures in investigating a series of sexual assaults by a taxi driver.[@dsd2018, para 24] The concern behind *Hill*, that liability would push the police into defensive practices, has a statutory answer of its own. When a court decides what steps a defendant should have taken to meet the standard of care, it may consider whether requiring those steps might prevent a desirable activity from being undertaken.[@compensation2006, § 1] That is a question of breach, not of duty. The officers in *Robinson* were not expected to stop arresting suspects, only to choose the moment with some regard for the people on the pavement. ## Comment A year later the Supreme Court applied the same method to a local authority that had failed to rehouse a family harassed by their neighbours, and found no duty, because the council had neither created the danger nor assumed responsibility for the children’s safety.[@poole2019, para 64–65] The two decisions point in different directions on their facts and rest on the same reasoning. Two difficulties remain. The line between acts and omissions is easy to state and hard to draw: an officer who opens a door and lets a violent man in has acted, but one who fails to lock it has not, and the difference may be small to the person on the other side. And the exceptions do much of the work. An assumption of responsibility can be found in a promise, a practice or the nature of a service, and the cases since *Robinson* show that the courts have not agreed how readily to find one.[^generally] The case does not resolve either difficulty; it puts them where they belong, in the analysis of established categories, and not in an open question about what is fair. [^generally]: For the exceptions and the cases on each, see generally [@winfield2020] :::paragraphs{style="colophon"} Written for the Postext Cookbook (CC BY 4.0). Author and series are fictional; this is not legal advice. The authorities are real; some pinpoints are illustrative. Set in Libre Baskerville, Libre Caslon Display and Libre Franklin (SIL OFL). ::: :::references{format=csl-yaml} - id: donoghue1932 type: legal_case title: Donoghue v Stevenson title-short: Donoghue volume: "[1932]" container-title: AC page: "562" authority: HL - id: anns1978 type: legal_case title: Anns v Merton London Borough Council title-short: Anns volume: "[1978]" container-title: AC page: "728" authority: HL - id: murphy1991 type: legal_case title: Murphy v Brentwood District Council title-short: Murphy volume: "[1991] 1" container-title: AC page: "398" authority: HL - id: caparo1990 type: legal_case title: Caparo Industries plc v Dickman title-short: Caparo volume: "[1990] 2" container-title: AC page: "605" authority: HL - id: sutherland1985 type: legal_case title: Sutherland Shire Council v Heyman title-short: Sutherland volume: "157" container-title: CLR page: "424" authority: HCA issued: 1985 - id: smith1987 type: legal_case title: Smith v Littlewoods Organisation Ltd title-short: Smith volume: "[1987]" container-title: AC page: "241" authority: HL - id: stovin1996 type: legal_case title: Stovin v Wise title-short: Stovin volume: "[1996]" container-title: AC page: "923" authority: HL - id: hill1989 type: legal_case title: Hill v Chief Constable of West Yorkshire title-short: Hill volume: "[1989]" container-title: AC page: "53" authority: HL - id: michael2015 type: legal_case title: Michael v Chief Constable of South Wales Police title-short: Michael authority: UKSC number: "2" issued: 2015 volume: "[2015]" container-title: AC page: "1732" - id: robinson2018 type: legal_case title: Robinson v Chief Constable of West Yorkshire Police title-short: Robinson authority: UKSC number: "4" issued: 2018 volume: "[2018]" container-title: AC page: "736" - id: dsd2018 type: legal_case title: Commissioner of Police of the Metropolis v DSD title-short: DSD authority: UKSC number: "11" issued: 2018 volume: "[2019]" container-title: AC page: "196" - id: poole2019 type: legal_case title: N v Poole Borough Council title-short: Poole authority: UKSC number: "25" issued: 2019 volume: "[2020]" container-title: AC page: "780" - id: hra1998 type: legislation title: Human Rights Act 1998 - id: compensation2006 type: legislation title: Compensation Act 2006 - id: stapleton1995 type: article-journal author: [{family: Stapleton, given: Jane}] title: "Duty of Care: Peripheral Parties and Alternative Opportunities for Deterrence" container-title: LQR volume: "111" page: "301" issued: 1995 - id: nolan2011 type: article-journal author: [{family: Nolan, given: Donal}] title: The Liability of Public Authorities for Failing to Confer Benefits container-title: LQR volume: "127" page: "260" issued: 2011 - id: tofaris2016 type: article-journal author: [{family: Tofaris, given: Stelios}, {family: Steel, given: Sandy}] title: Negligence Liability for Omissions and the Police container-title: CLJ volume: "75" page: "128" issued: 2016 - id: winfield2020 type: book author: [{family: Goudkamp, given: James}, {family: Nolan, given: Donal}] title: Winfield and Jolowicz on Tort edition: 20 publisher: Sweet & Maxwell issued: 2020 :::
`; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face the design uses (gotcha: fonts-first). const FONTS = { 'Libre Baskerville': ['400', '400i', '700'], 'Libre Caslon Display': ['400'], 'Libre Franklin': ['400', '500', '600'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); const title = t({ en: 'A legal opinion cited in OSCOLA', es: 'Un dictamen jurídico citado en OSCOLA' }); 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上的食谱文件夹 ↗ (在新标签页中打开)

变化

#使用未修改的样式

要与未打补丁的结果对比,改用内置样式名。之后各注中的段落定位会印成para 55。

-  style: 'custom', customStyle: OSCOLA, // the bundled 'oscola', patched above
+  style: 'oscola',

#把注释放到文末

有些期刊把注释集中在正文之后。引文保留完整和简略两种形式以及编号。

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

常见问题

易错点

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

  • 卷号只写数字(volume: "2")时,年份印在圆括号里,成为*(1990) 2 AC*;OSCOLA只对跨年连续编卷的汇编这样写,如注7中的Commonwealth Law Reports。Appeal Cases和Weekly Law Reports要把方括号年份写进volume。
  • 定位前的s表示页(德语Seite),不表示条:请写§ 6。6(1)这样的款项不会被识别为定位,请在句中写明,引注只引到条。
  • OSCOLA的期刊文章不附参考文献表,样式也不区分判例表和法规表。需要这些表的论文用:::bibliography只能得到一份排序列表:先列学术文献,判例和法规混排在后。

致谢

文本
原创文字, CC BY 4.0
字体
Libre Baskerville (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Libre Franklin (SIL OFL 1.1)
沙盒PDF