成品一览
Lever and Lens是一本物理入门教材,页面为210 × 275 mm,这里排的是它的第4章。正文排在一个宽栏中,从不进入外侧页边距里一条53 mm宽的边栏。章号是一个绿色的4,在边栏中与标题平齐,学习目标排在它下方。光路图从边栏顶部开始依次堆叠,关键术语放在薄荷绿的术语注中,位于定义它们的段落旁边。留在正文栏中的图排在深色或浅色底板上,题注放在边栏中,与图的底边平齐。只有棱镜面板横跨两栏。页边距左右镜像,所以边栏总在切口一侧:右页在右,左页在左。
这道食谱解答
- 怎样做一栏半版式,一个宽的正文栏加一个窄的侧栏?
- 怎样把边注或旁注排在它所解释的段落旁边?
- 怎样加一幅带编号题注的图,并在正文里引用它(“见图3.2”)?
- 怎样控制图的位置:页面顶部、横跨两栏、就放在这里,还是放在边栏里?
- 怎样把题注放在图的旁边,或放在表上方的题注条上?
- 怎样给标题编号(1、1.1、1.1.1),并给每一级设置不同样式?
简短回答
const layout = {
layoutType: 'oneAndHalf', // a wide main column and a narrow side column
sideColumnPercent: 30, // of the 176 mm content width: a 52.8 mm channel
sideColumnRole: 'floats', // no body text: side figures, side captions and side boxes only
sideColumnSide: 'outer', // right on a recto, left on a verso (the margins are mirrored)
gutterWidth: mm(7), // the text column keeps the rest: 176 − 52.8 − 7 = 116 mm
};
// A figure placed with span 'side' stacks in the channel from the head of the page that cites it.
// The stack ignores the opener's numeral: on a first page, cite side figures after the objectives.
const side = { span: 'side' };
// A figure left in the text column (the default span) sets its caption in the channel beside
// it; page-wide floats ignore captionSide and keep theirs underneath.
const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id !== 'figure' ? type
: { ...type, defaultPlacement: { captionSide: true } })); // gotcha: resource-types-locale
// A box fenced :::callout{type="term" span="side"} leaves the flow and lands in the channel at the
// height the text has reached. Fence each gloss after a paragraph, never straight after a heading
// (gotcha: side-box-after-heading).
用料
做法
#1 · 把边栏交给浮动体
这一步的代码见上文的简短回答。在一栏半版式中,sideColumnPercent: 30把176 mm内容宽度的30 %(52.8 mm)分给侧栏,正文栏得到减去7 mm栏间距后余下的部分(116 mm,约为9.3 pt Merriweather的75个字符)。sideColumnRole: 'floats'让正文不进入侧栏,sideColumnSide: 'outer'把侧栏放在外侧,镜像的页边距会让它逐页换边。图用span: 'side'放进边栏。围栏上带有span="side"的框会离开正文流,在正文排到的高度进入边栏,所以每条术语注都与其围栏后面的那一块平齐开始。
#2 · 让引用决定每幅图的位置
const drawings = new Map(); // fileId → SVG markup, registered before the build
const figure = (id, { width, height, markup }, placement) => {
drawings.set(`${id}.svg`, markup);
return { id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, caption: t(captions[id]),
altText: t(captions[id]), // read aloud in HTML and tagged PDF; the canvas does not use it
svg: { fileId: `${id}.svg`, width, height }, ...(placement && { placement }) };
};
const resources = [ // no placement: a main-column float, its caption in the channel
figure('burning-glass', burningGlass()),
figure('refraction', refraction(), side),
figure('critical-angle', criticalAngle(), side),
figure('fibre', fibre()),
figure('prism', prism(), { span: 'page', position: 'top' }), // across text column and channel
figure('principal-rays', principalRays()),
figure('diverging', diverging(), side),
];
对一幅图的第一次:ref给它编号,并按它的放置方式放置。span: 'side'的图在引用它的那一页从边栏顶部开始堆叠,不论引用在页面多靠下;如果边栏剩下的部分太短,它就移到下一页。图4.2和图4.3都在第88页被引用,在该页边栏中依次向下堆叠,临界角术语注夹在两者之间。棱镜是一个在第88页被引用的通栏top浮动体,而浮动体从不排在引用之前,所以它在第89页开头横跨两栏。图4.1、4.4和4.6留在正文栏中,从图类型的defaultPlacement取得captionSide。它们占据栏底的位置,所以每条题注都与图的底边平齐;如果放在栏顶位置,题注会与图的顶边对齐。
#3 · 在边栏中开启本章
// Every element counts toward the opener's depth, the page-anchored numeral too (gotcha:
// opener-reserves-anchored). minHeight fixes that depth at nine lines of the grid, room for a
// one-line title, the rule and a four-line standfirst (41.2 mm), so the text starts on the same
// line in every such chapter, however short its standfirst and even with a smaller numeral. Kicker
// and numeral (40.6 mm) reach the ninth line too; a deeper opener grows past it, line by line.
const [KICKER, NUMERAL] = [8, 104]; // pt
const opener = {
enabled: true,
minHeight: pt(LEAD * 9), // 42.9 mm: the text starts on the eleventh line, after marginBottom
slot: {
elements: [
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: SANS, fontWeight: 800,
fontSize: pt(32), lineHeight: 1.05, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill' } } },
{ kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('accent'),
placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(4) },
size: { width: 'fill' } } },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true,
fontSize: pt(10.5), lineHeight: 1.45, color: col('ink'), align: 'left', overflow: 'wrap',
placement: { anchor: { to: '#rule', edge: 'below' }, offset: { y: mm(3.5) },
size: { width: 'fill' } } },
// The kicker hangs from the page's top-right corner, not from the heading: the channel lies
// outside the heading's column, and on the right only on a recto (so chapters open on one).
// The numeral hangs from the kicker.
{ kind: 'text', id: 'kicker', content: t({ en: 'Chapter', es: 'Capítulo' }), ...label,
fontSize: pt(KICKER), align: 'left', placement: { anchor: { to: 'page', edge: 'top-right' },
offset: { x: mm(-OUTER), y: mm(TOP) }, size: { width: mm(CHANNEL) } } },
{ kind: 'text', id: 'numeral', content: '{chapterNumber}', fontFamily: SANS, fontWeight: 800,
fontSize: pt(NUMERAL), lineHeight: 1, color: col('accent'), align: 'left',
placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(0.5) },
size: { width: mm(CHANNEL) } } },
],
},
};
一级标题留在正文栏中,由一个设计槽依次排出标题文字、细线和导语;眉题挂在页面右上角,数字挂在眉题下,两者都与边栏同宽。minHeight把章首固定为九条网格线(42.9 mm),足够放一行标题、细线和四行导语,所以较短的导语也不会把正文往上拉。眉题和104 pt的数字结束在上页边距以下40.6 mm处,在这九行之内,所以也不会把正文往下推。只有在右页上边栏才在右侧,所以H1分页到奇数页。学习目标框排在第一段之后,而不是紧跟标题,并且在它之前不引用任何边栏图(见常见问题)。
#4 · 让页码留在边栏一侧
const [HEAD_Y, FOOT_Y, HEAD_GAP] = [12.5, -12, 9]; // mm from the top and bottom trim; folio to head
// A text on the physical page: edge picks the corner, x and y are its offsets in mm.
const head = ({ edge, x, y = HEAD_Y, ...text }) => ({
kind: 'text', pages: 'body', ...label, color: col('muted'), ...text,
placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const folio = { content: '{pageNumber}', fontSize: pt(8.5), letterSpacing: pt(0),
color: col('accent') };
const verso = { parity: 'even', edge: 'top-left' }; // x counts in from the left edge
const recto = { parity: 'odd', edge: 'top-right' }; // x counts back from the right edge
const header = { elements: [
head({ id: 'verso-folio', ...verso, ...folio, x: OUTER }),
head({ id: 'verso-title', ...verso, content: '{title}', x: OUTER + HEAD_GAP }),
head({ id: 'recto-title', ...recto, x: -(OUTER + HEAD_GAP),
content: t({ en: 'Chapter {chapterNumber} · {chapterTitle}',
es: 'Capítulo {chapterNumber} · {chapterTitle}' }) }),
head({ id: 'recto-folio', ...recto, ...folio, x: -OUTER }),
] };
// A chapter's first page, always a recto, carries a drop folio at the foot of the channel instead.
const footer = { elements: [
head({ id: 'drop-folio', ...recto, ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER,
y: FOOT_Y }),
] };
每个元素都锚定在物理页面上,并用parity筛选,所以在跨页两侧,页码和书眉都与边栏在同一边。pages: 'body'让它们不出现在章首页上,章首页的页码改为印在边栏底部。
#5 · 从第4章开始这本书
const continuation = { pageNumbering: { startAt: 87 }, // odd, like page 1: a recto
headings: { h1: 3, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 4
const doc = await buildWithFonts(
() => buildDocument({ markdown, resources, continuation }, config()), markdown);
showPages(doc, { title: t({ en: 'Textbook with a margin column',
es: 'Libro de texto con columna al margen' }) });
这些页面是一本更长的书的第4章。续排设置把章计数器设为3,所以{chapterNumber}印出4,二级标题的numberingTemplate: '{1}.{2}'把各节编为4.1到4.3;图从4.1编到4.7。## Questions {style="plain"}使用一个带numbered: false的headingStyles条目,所以这个标题没有编号。页码从87开始,因为第一页是右页,而右页的页码是奇数。
#6 · 颜色只命名一次
const palette = {
ink: '#1a222d', // text, and the dark panels of figures 4.1, 4.4 and 4.5
accent: '#17774f', // the only accent colour: numerals, folios, section headings, labels
ray: '#f2a516', // light rays in every diagram
glass: '#cfe6dd', // glass in the diagrams
tint: '#edf5f1', // the key-term glosses and the light plate of a construction diagram
muted: '#5b6863', // running heads, the normals in the diagrams, the colophon
paper: '#ffffff',
};
// A colour carries its id and its hex: 1.4.1 paints design elements and referenceColor from the
// hex alone (gotcha: palette-skips-designs), so retint by editing `palette`, not colorPalette.
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
// The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
{ id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } },
];
配置中的每种颜色都链接到一个调色板条目,同时带有它的十六进制值,由col()从同一个对象中复制。Postext 1.4.1用这个十六进制值绘制设计元素和参考颜色,而不是用调色板条目(见常见问题)。要给本章换色,编辑palette即可:光路图读取的是同一个对象,所以其中的玻璃、光线、深色底板和浅色底板会随数字、标签和术语注一起改变。main-color指向强调色,所以配置未设置的文本样式默认颜色都印成绿色,而不是引擎的蓝色。
完整食谱
// ═══ Postext Cookbook · Nº 001 · Textbook with a margin column ═══════════════════ // https://postext.dev/en/cookbook/textbook-margin-column // Code: MIT · Text: original (CC BY 4.0) · Diagrams: generated in code (CC BY 4.0) // Fonts: Merriweather, Merriweather Sans (SIL OFL 1.1) · Needs postext ≥ 1.4.1 // A chapter of a physics textbook in the column-and-a-half layout: the body text keeps to // the main column, and the outer margin is a channel for diagrams, captions and glosses. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, defaultResourceTypes, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'textbook-margin-column'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: semantic colours, each linked by id and written out in hex const palette = { ink: '#1a222d', // text, and the dark panels of figures 4.1, 4.4 and 4.5 accent: '#17774f', // the only accent colour: numerals, folios, section headings, labels ray: '#f2a516', // light rays in every diagram glass: '#cfe6dd', // glass in the diagrams tint: '#edf5f1', // the key-term glosses and the light plate of a construction diagram muted: '#5b6863', // running heads, the normals in the diagrams, the colophon paper: '#ffffff', }; // A colour carries its id and its hex: 1.4.1 paints design elements and referenceColor from the // hex alone (gotcha: palette-skips-designs), so retint by editing `palette`, not colorPalette. const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); const colorPalette = [ ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })), // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue. { id: 'main-color', name: 'accent (defaults)', value: { hex: palette.accent, model: 'hex' } }, ]; // #endregion // The page in mm, named once: the channel, the opener and the running heads derive from it. const [TRIM_W, TRIM_H] = [210, 275]; const [TOP, BOTTOM, INNER, OUTER] = [24, 22, 20, 14]; // inner and outer swap on a verso const LEAD = 13.5; // body leading in pt const [SERIF, SANS] = ['Merriweather', 'Merriweather Sans']; // #region answer: a float-only channel on the outer edge, and what goes into it const layout = { layoutType: 'oneAndHalf', // a wide main column and a narrow side column sideColumnPercent: 30, // of the 176 mm content width: a 52.8 mm channel sideColumnRole: 'floats', // no body text: side figures, side captions and side boxes only sideColumnSide: 'outer', // right on a recto, left on a verso (the margins are mirrored) gutterWidth: mm(7), // the text column keeps the rest: 176 − 52.8 − 7 = 116 mm }; // A figure placed with span 'side' stacks in the channel from the head of the page that cites it. // The stack ignores the opener's numeral: on a first page, cite side figures after the objectives. const side = { span: 'side' }; // A figure left in the text column (the default span) sets its caption in the channel beside // it; page-wide floats ignore captionSide and keep theirs underneath. const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id !== 'figure' ? type : { ...type, defaultPlacement: { captionSide: true } })); // gotcha: resource-types-locale // A box fenced :::callout{type="term" span="side"} leaves the flow and lands in the channel at the // height the text has reached. Fence each gloss after a paragraph, never straight after a heading // (gotcha: side-box-after-heading). // #endregion // The channel's width, the measure of everything the opener and the heads set in it: 52.8 mm. const CHANNEL = ((TRIM_W - INNER - OUTER) * layout.sideColumnPercent) / 100; // The channel's own type, and the two boxes that stand in it. const label = { fontFamily: SANS, fontSize: pt(7.5), fontWeight: 700, letterSpacing: pt(1.2), textTransform: 'uppercase', color: col('accent') }; // A box's text takes the body's ink for text, bold and italic; only face, size and setting change. const note = { fontFamily: SANS, fontSize: pt(8), lineHeight: pt(11.25), textAlign: 'left', firstLineIndent: pt(0) }; const calloutStyles = [ { id: 'panel', backgroundEnabled: false, // objectives and key ideas; each fence names its title padding: { top: mm(2.6), right: pt(0), bottom: pt(0), left: pt(0) }, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') }, titleStyle: { ...label, gap: mm(2) }, body: note, marginTop: pt(0), marginBottom: pt(LEAD), lists: { color: col('accent'), indent: mm(3), itemSpacing: pt(3) } }, { id: 'term', title: t({ en: 'Key term', es: 'Término clave' }), background: col('tint'), padding: { top: mm(2.6), right: mm(3), bottom: mm(3), left: mm(3) }, titleStyle: { ...label, gap: mm(1.2) }, body: note, marginTop: pt(0), marginBottom: pt(LEAD) }, ]; // #region opener: the title in the main column, the chapter number standing in the channel // Every element counts toward the opener's depth, the page-anchored numeral too (gotcha: // opener-reserves-anchored). minHeight fixes that depth at nine lines of the grid, room for a // one-line title, the rule and a four-line standfirst (41.2 mm), so the text starts on the same // line in every such chapter, however short its standfirst and even with a smaller numeral. Kicker // and numeral (40.6 mm) reach the ninth line too; a deeper opener grows past it, line by line. const [KICKER, NUMERAL] = [8, 104]; // pt const opener = { enabled: true, minHeight: pt(LEAD * 9), // 42.9 mm: the text starts on the eleventh line, after marginBottom slot: { elements: [ { kind: 'text', id: 'title', content: '{titleText}', fontFamily: SANS, fontWeight: 800, fontSize: pt(32), lineHeight: 1.05, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: 'container', edge: 'top-left' }, size: { width: 'fill' } } }, { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('accent'), placement: { anchor: { to: '#title', edge: 'below' }, offset: { y: mm(4) }, size: { width: 'fill' } } }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: SERIF, italic: true, fontSize: pt(10.5), lineHeight: 1.45, color: col('ink'), align: 'left', overflow: 'wrap', placement: { anchor: { to: '#rule', edge: 'below' }, offset: { y: mm(3.5) }, size: { width: 'fill' } } }, // The kicker hangs from the page's top-right corner, not from the heading: the channel lies // outside the heading's column, and on the right only on a recto (so chapters open on one). // The numeral hangs from the kicker. { kind: 'text', id: 'kicker', content: t({ en: 'Chapter', es: 'Capítulo' }), ...label, fontSize: pt(KICKER), align: 'left', placement: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(TOP) }, size: { width: mm(CHANNEL) } } }, { kind: 'text', id: 'numeral', content: '{chapterNumber}', fontFamily: SANS, fontWeight: 800, fontSize: pt(NUMERAL), lineHeight: 1, color: col('accent'), align: 'left', placement: { anchor: { to: '#kicker', edge: 'below' }, offset: { y: mm(0.5) }, size: { width: mm(CHANNEL) } } }, ], }, }; // #endregion // #region heads: book title on the verso, chapter on the recto, folios on the outer edge const [HEAD_Y, FOOT_Y, HEAD_GAP] = [12.5, -12, 9]; // mm from the top and bottom trim; folio to head // A text on the physical page: edge picks the corner, x and y are its offsets in mm. const head = ({ edge, x, y = HEAD_Y, ...text }) => ({ kind: 'text', pages: 'body', ...label, color: col('muted'), ...text, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }, }); const folio = { content: '{pageNumber}', fontSize: pt(8.5), letterSpacing: pt(0), color: col('accent') }; const verso = { parity: 'even', edge: 'top-left' }; // x counts in from the left edge const recto = { parity: 'odd', edge: 'top-right' }; // x counts back from the right edge const header = { elements: [ head({ id: 'verso-folio', ...verso, ...folio, x: OUTER }), head({ id: 'verso-title', ...verso, content: '{title}', x: OUTER + HEAD_GAP }), head({ id: 'recto-title', ...recto, x: -(OUTER + HEAD_GAP), content: t({ en: 'Chapter {chapterNumber} · {chapterTitle}', es: 'Capítulo {chapterNumber} · {chapterTitle}' }) }), head({ id: 'recto-folio', ...recto, ...folio, x: -OUTER }), ] }; // A chapter's first page, always a recto, carries a drop folio at the foot of the channel instead. const footer = { elements: [ head({ id: 'drop-folio', ...recto, ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER, y: FOOT_Y }), ] }; // #endregion const config = () => ({ // a factory: a fresh object per build (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales) resourceTypes, colorPalette, page: { width: mm(TRIM_W), height: mm(TRIM_H), dpi: 150, margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, // left: recto's inner layout, bodyText: { // hyphenation, optimal line breaking and widow control are on by default fontFamily: SERIF, fontWeight: 300, fontSize: pt(9.3), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('accent'), referenceBold: false, textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, headings: { fontFamily: SANS, color: col('ink'), fontWeight: 800, levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). // 'odd': kicker, numeral and drop folio sit at the right edge, the outer one only on a recto. // The heading stays in the main column; its design draws it. { level: 1, breakBefore: { enabled: true, parity: 'odd' }, marginBottom: pt(LEAD), advancedDesign: opener }, { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}', color: col('accent'), marginTop: pt(LEAD * 1.5), marginBottom: pt(0) }, ], }, headingStyles: [{ id: 'plain', numbered: false }], // ## Questions {style="plain"} orderedLists: { numberFormat: 'arabic', // the default, written out: 'decimal' prints 'undefined' fontFamily: SANS, fontWeight: 800, color: col('accent'), marginTop: pt(0), marginBottom: pt(0) }, calloutStyles, captionStyle: { fontFamily: SANS, fontSize: pt(7.6), labelColor: col('accent'), gap: mm(2) }, paragraphStyles: [{ id: 'aside', firstLineIndent: pt(0), marginTop: pt(LEAD * 0.5) }, { id: 'colophon', fontFamily: SANS, fontSize: pt(6.8), lineHeight: pt(9), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 87行 · content.en.md
title: "Lever and Lens" subtitle: "An Introductory Course" --- # Light and Lenses {lead="Light changes direction where it passes from air into glass or water, by an amount set by the two materials and the angle at which it arrives. Lenses use that change to form images, and a prism shows that it differs slightly from colour to colour."} Hold a magnifying glass in sunshine and you can gather the light of the Sun into a spot bright enough to scorch paper (:ref{id="burning-glass" case="lower"}). :::callout{type="panel" span="side" title="In this chapter"} - Explain refraction in terms of a change in the speed of light. - Use the refractive index and Snell’s law to predict how far a ray bends. - Trace the principal rays through converging and diverging lenses. - Describe dispersion and explain how a prism makes a spectrum. ::: The lens gathers all the light that falls on it into a few square millimetres. Each ray changes direction at the two curved surfaces, and the curves are ground so that all the rays arrive at the same point. That change of direction is called *refraction*, and every lens depends on it: the camera in your phone, a pair of reading glasses, the microscope in your school laboratory and the eye itself, where the cornea and the lens focus an image on the retina. To understand any of them you need two ideas. The first is that light travels more slowly in glass or water than in air. The second is that a beam that meets a surface at an angle crosses it one edge at a time. :::callout{type="term" span="side"} **Refractive index** *n*: the speed of light in a vacuum divided by its speed in the material. It has no units. Water 1.33, crown glass 1.52, diamond 2.42. ::: ## Refraction In a vacuum light travels at almost exactly 300,000 kilometres per second. In air it is only a fraction slower, but in water it covers about 225,000 km each second, and in ordinary glass about 200,000 km. The ratio of the speed in a vacuum to the speed in a material is that material’s *refractive index*, written *n*: the more slowly light travels in a material, the higher its index. A line of marchers shows why a change of speed makes light change direction. Suppose the line crosses at an angle from a paved square onto a muddy field. Those at one end of it reach the mud first and slow down, while those at the other end are still walking at full speed, so the whole line swings round and heads off in a new direction. A beam of light does the same as it enters glass and bends *towards the normal*, the line drawn at right angles to the surface (:ref{id="refraction" case="lower"}). Leaving a parallel-sided block, it speeds up again and bends back by the same amount. How far the ray bends depends on the two refractive indices and on the angle at which it arrives. The rule was found by the Dutch mathematician Willebrord Snell in 1621 and is known as *Snell’s law*: *n*~1~ sin *i* = *n*~2~ sin *r*, where *i* is the angle of incidence and *r* the angle of refraction, both measured from the normal. A ray that strikes glass at 40° from the normal is refracted so that sin *r* = sin 40° ÷ 1.52 = 0.42, an angle of 25°, so the ray has turned 15° towards the normal. A ray that arrives along the normal, at an angle of 0°, does not bend at all. :::callout{type="term" span="side"} **Critical angle** *c*: the angle of incidence inside the denser material above which no light gets out: sin *c* = 1 ÷ *n*. About 41° for crown glass, 49° for water. ::: Reverse the ray, so that it passes from glass into air, and it bends away from the normal. As the angle inside the glass grows, the ray leaving the surface swings closer and closer to the surface itself, until at the *critical angle* it skims along it. Beyond that angle no light escapes: all of it is reflected back into the glass (:ref{id="critical-angle" case="lower"}). This *total internal reflection* returns more light than the best mirror. Total internal reflection carries telephone calls and internet traffic across the oceans. An optical fibre is a thread of very pure glass, thinner than a hair, inside a sleeve of glass with a slightly lower refractive index. Light sent into one end strikes the boundary at more than the critical angle each time, so it zigzags along the fibre for tens of kilometres with almost no loss (:ref{id="fibre" case="lower"}). The same effect makes a cut diamond sparkle: its critical angle is only 24°, so light that enters the stone is reflected round inside it several times before it finds a way out. ## Dispersion So far we have treated the refractive index as a single number, but it depends on colour. In glass, violet light travels a little more slowly than red light, so it is refracted a little more: the index of crown glass is 1.51 for red light and 1.53 for violet. The difference is small, but a prism makes it visible (:ref{id="prism" case="lower"}). Its two faces are tilted towards each other, so the bending at the second face adds to the bending at the first instead of undoing it, and each colour leaves at its own angle. In 1666 Isaac Newton let a beam of sunlight into a darkened room through a hole in a shutter and passed it through a prism. He saw a band of colour on the far wall, from red to violet, and he showed that a second prism, turned the other way, gathered the colours back into white. He concluded that white light is a mixture of every colour and that the prism only sorts them. This spreading of light into its colours is called *dispersion*, and the band of colour it produces is a *spectrum*. :::callout{type="term" span="side"} **Dispersion**: the spreading of white light into its colours, because the refractive index of a material is slightly different for each colour. ::: A rainbow is sunlight dispersed by raindrops. Each falling drop refracts the light as it enters, reflects it once from the back of the drop and refracts it again on the way out. Red light leaves at about 42° from the direction of the sunlight and violet at about 40°, so every drop sends one colour to your eye and the drops together draw an arc. :::callout{type="term" span="side"} **Focal length** *f*: the distance from the centre of a lens to its principal focus. The power of the lens in dioptres is 1 ÷ *f*, with *f* in metres. ::: ## Lenses A lens is a piece of glass or plastic whose curved faces bend light towards its axis or away from it. A *converging* lens is thicker in the middle than at the edges. Rays that arrive parallel to its axis are bent towards the axis and meet at a point behind the lens, the *principal focus* F (:ref{id="principal-rays" case="lower"}). The distance from the centre of the lens to F is the *focal length* *f*. A fatter, more strongly curved lens bends light more and has a shorter focal length. To find where a lens forms an image, draw three rays from the tip of the object, chosen because their paths are known in advance. A ray parallel to the axis leaves through the focus. A ray through the centre of the lens goes straight on. A ray through the focus in front of the lens leaves parallel to the axis. The image of the tip forms where they cross, and any two of them are enough to find it. When the object is more than two focal lengths from a converging lens, as in a camera, the image is *real*, *inverted* and smaller than the object. Real means that light from the object reaches the image itself, so it can be caught on a screen or a sensor. Move the object closer and the image grows and moves away from the lens. Bring it inside the focal length and the rays leaving the lens no longer meet at all. They spread out, and your eye traces them back to a larger, upright, *virtual* image on the same side as the object. That is how a magnifying glass works. A *diverging* lens is thinner in the middle than at the edges, and it spreads parallel rays apart as if they came from a focus in front of the lens (:ref{id="diverging" case="lower"}), so the image it forms is always virtual, upright and smaller than the object. Short-sighted eyes focus light in front of the retina, and a diverging lens in a pair of glasses moves the image back onto the retina. Long-sighted eyes need the opposite, a converging lens. Chapter 5 follows light into the eye, where the cornea does most of the focusing and the lens adjusts it for near or distant objects. It then turns to the microscope and the telescope, which extend what the eye can see. :::callout{type="panel" span="side" title="Key ideas"} - Light travels more slowly in glass and water than in air; the refractive index *n* measures how much. - A ray entering a denser material bends towards the normal, as Snell’s law describes. - Past the critical angle, light inside glass or water is totally reflected. - A converging lens forms real or virtual images; a diverging lens forms only virtual ones. ::: ## Questions {style="plain"} 1. A ray of light passes from air into water (*n* = 1.33) at 50° from the normal. Find the angle of refraction. 2. A straw standing in a glass of water seems to bend at the water’s surface. Use a ray diagram to explain why. 3. Why does a cut diamond sparkle? Find its critical angle (*n* = 2.42). 4. A reading lens has a power of +2.5 dioptres. What is its focal length? 5. An object sits just inside the focal length of a converging lens. Draw the principal rays and describe the image. :::paragraphs{style="aside"} *Answers to the numerical questions are at the back of the book.* ::: :::paragraphs{style="colophon"} Set in Merriweather and Merriweather Sans (SIL OFL 1.1) · Text and diagrams: original, CC BY 4.0 :::`; // content.<lang>.md, inlined by the Cookbook // Captions carry the labels the diagrams leave out (gotcha: svg-no-webfonts). const captions = { 'burning-glass': { en: 'A burning glass. A converging lens bends parallel rays of sunlight so that they all ' + 'meet at one point, the focus, where a card begins to scorch.', es: 'Una lupa al sol. Una lente convergente desvía los rayos paralelos de luz para que ' + 'coincidan en un punto, el foco, donde una cartulina empieza a quemarse.' }, 'refraction': { en: 'Entering glass, a ray bends towards the normal (dashed): the angle of refraction (green) ' + 'is less than the angle of incidence (amber).', es: 'Al entrar en el vidrio, el rayo se acerca a la normal (a trazos): el ángulo de refracción ' + '(verde) es menor que el de incidencia (ámbar).' }, 'critical-angle': { en: 'Rays aimed at the centre of a semicircular block. At 25° the ray escapes, bent away ' + 'from the normal; at 58°, past the critical angle, all of it is reflected.', es: 'Rayos dirigidos al centro de un bloque semicircular. A 25° el rayo sale, alejándose de ' + 'la normal; a 58°, pasado el ángulo límite, se refleja por completo.' }, 'fibre': { en: 'An optical fibre. Light meets the wall of the core at more than the critical angle, ' + 'so it is totally reflected each time and cannot leak out.', es: 'Una fibra óptica. La luz incide en la pared del núcleo con un ángulo mayor que el límite, ' + 'así que se refleja por completo cada vez y no puede escaparse por el camino.' }, 'prism': { en: 'Dispersion. The prism bends every colour towards its base, red least and violet most ' + '(the spread is exaggerated).', es: 'Dispersión. El prisma desvía todos los colores hacia su base: el rojo, menos, y el ' + 'violeta, más (la separación está exagerada).' }, 'principal-rays': { en: 'The three principal rays from the tip of an object beyond 2F meet at the tip of a real, ' + 'inverted, smaller image (green). Dots mark the foci F; open circles, the points 2F.', es: 'Los tres rayos principales que parten de la punta de un objeto situado más allá de 2F se ' + 'cortan en la punta de una imagen real, invertida y menor (verde). Los puntos marcan los ' + 'focos F, y los círculos, los puntos 2F.' }, 'diverging': { en: 'A diverging lens. Parallel rays leave as if they came from the focus in front of the ' + 'lens (dashed lines), so the image is virtual.', es: 'Una lente divergente. Los rayos paralelos salen como si vinieran del foco situado delante ' + 'de la lente (líneas a trazos), así que la imagen es virtual.' }, }; // #region art: seven diagrams drawn in code: amber rays, green glass, no text const f1 = (n) => Math.round(n * 10) / 10; const pts = (list) => list.map(([x, y]) => `${f1(x)} ${f1(y)}`).join('L'); const svg = (width, height, body) => ({ width, height, markup: '<svg ' + `xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` + `viewBox="0 0 ${width} ${height}">${body}</svg>` }); const stroke = (list, color, width, extra = '') => `<path d="M${pts(list)}" fill="none" ` + `stroke="${color}" stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"` + `${extra}/>`; const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const deg = (a) => (a * Math.PI) / 180; // degrees to radians // An arrowhead is a path, never a <marker> (gotcha: svg-no-marker-filters). const tip = ([x, y], [dx, dy], color, s = 12) => { const l = Math.hypot(dx, dy); const [u, v] = [dx / l, dy / l]; return shape(`M${pts([[x + u * s, y + v * s], [x - v * s * 0.5, y + u * s * 0.5], [x + v * s * 0.5, y - u * s * 0.5]])}Z`, color); }; // A ray through its points, with an arrowhead halfway along the first segment. const ray = (list, color = palette.ray, width = 3, at = 0.5) => { const [[x0, y0], [x1, y1]] = list; return stroke(list, color, width) + tip([x0 + (x1 - x0) * at, y0 + (y1 - y0) * at], [x1 - x0, y1 - y0], color, width * 4); }; const dot = (x, y, r, fill, extra = '') => `<circle cx="${f1(x)}" cy="${f1(y)}" r="${r}" ` + `fill="${fill}"${extra}/>`; const lens = (x, top, bottom, bulge, fill, line, width = 2.5, extra = '') => { const mid = (top + bottom) / 2; return shape(`M${x} ${top}Q${x + bulge} ${mid} ${x} ${bottom}Q${x - bulge} ${mid} ${x} ${top}Z`, fill, ` stroke="${line}" stroke-width="${width}"${extra}`); }; // 4.1 · A burning glass on a dark panel: the Sun, seven parallel rays, the focus on a card. function burningGlass() { const [W, H, LX, FX, AX] = [1162, 540, 470, 900, 270]; const ys = [120, 170, 220, 270, 320, 370, 420]; const cone = `M${LX} ${ys[0]}L${FX} ${AX}L${LX} ${ys[6]}Z`; return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.ink}"/>` + shape(cone, palette.ray, ' fill-opacity=".1"') + dot(-60, AX, 200, palette.ray) + dot(-60, AX, 150, palette.paper, ' fill-opacity=".2"') + ys.map((y) => ray([[200, y], [LX, y], [FX, AX]], palette.ray, 3, 0.55)).join('') + lens(LX, 60, 480, 80, palette.glass, palette.paper, 3, ' fill-opacity=".3"') + [34, 22, 13].map((r, i) => dot(FX, AX, r, palette.ray, ` fill-opacity="${0.12 + i * 0.14}"`)) .join('') + dot(FX, AX, 6, palette.paper) + shape(`M${FX + 2} 150H${FX + 12}V390H${FX + 2}Z`, palette.paper, ' fill-opacity=".85"')); } // 4.2 · Refraction at an air-glass boundary: the ray bends towards the normal. function refraction() { const [W, H, X, Y, L] = [528, 360, 250, 172, 250]; const [si, ci] = [Math.sin(deg(50)), Math.cos(deg(50))]; const sr = si / 1.52; const cr = Math.sqrt(1 - sr * sr); const wedge = (dy, ux, uy, color) => shape(`M${X} ${Y}L${X} ${Y + dy}A80 80 0 0 0 ` + `${f1(X + ux * 80)} ${f1(Y + uy * 80)}Z`, color, ' fill-opacity=".45"'); return svg(W, H, shape(`M0 ${Y}H${W}V${H}H0Z`, palette.glass) + stroke([[0, Y], [W, Y]], palette.ink, 2.5) + stroke([[X, 14], [X, H - 14]], palette.muted, 2, ' stroke-dasharray="10 8"') + wedge(-80, -si, -ci, palette.ray) + wedge(80, sr, cr, palette.accent) + ray([[X - si * L, Y - ci * L], [X, Y], [X + sr * 205, Y + cr * 205]])); } // 4.3 · A semicircular block: a shallow ray escapes, a steep one is totally reflected. function criticalAngle() { const [W, H, X, Y, R] = [528, 372, 264, 110, 250]; const inside = (a, len) => [X - Math.sin(deg(a)) * len, Y + Math.cos(deg(a)) * len]; const out = Math.asin(1.52 * Math.sin(deg(25))); return svg(W, H, shape(`M${X - R} ${Y}A${R} ${R} 0 0 0 ${X + R} ${Y}Z`, palette.glass, ` stroke="${palette.ink}" stroke-width="2.5"`) + stroke([[X, 10], [X, Y + R - 10]], palette.muted, 2, ' stroke-dasharray="10 8"') + ray([inside(25, R - 8), [X, Y], [X + Math.sin(out) * 150, Y - Math.cos(out) * 150]]) + ray([inside(58, R - 8), [X, Y], [X + Math.sin(deg(58)) * (R - 8), Y + Math.cos(deg(58)) * (R - 8)]], palette.accent, 3, 0.45)); } // 4.4 · An optical fibre on a dark panel: light zigzags along the core, reflected at each wall. function fibre() { const [W, H, CORE_TOP, CORE_BOT, END] = [1162, 360, 140, 220, 1080]; const zig = [[20, 96], [70, 180]]; // from the source into the core, then wall to wall for (let x = 145, i = 0; x < END; x += 150, i++) zig.push([x, i % 2 ? CORE_TOP : CORE_BOT]); const [lx, ly] = zig.at(-1); const exit = [END, ly + ((ly === CORE_BOT ? CORE_TOP : CORE_BOT) - ly) * ((END - lx) / 150)]; const glow = ([x, y], radii) => radii.map((r, i) => dot(x, y, r, palette.ray, ` fill-opacity="${0.2 + (i * 0.6) / radii.length}"`)).join(''); return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.ink}"/>` + shape(`M70 100H${END}V260H70Z`, palette.glass, ' fill-opacity=".14"') // the cladding + shape(`M70 ${CORE_TOP}H${END}V${CORE_BOT}H70Z`, palette.glass, ' fill-opacity=".3"') + [100, 260].map((y) => stroke([[70, y], [END, y]], palette.paper, 2, ' stroke-opacity=".35"')) .join('') + [-70, 0, 70].map((dy) => stroke([exit, [W, exit[1] + dy]], palette.ray, 3, ' stroke-opacity=".8"')).join('') + glow(exit, [26, 15]) + glow(zig[0], [30, 18, 9]) + ray([...zig, exit], palette.ray, 3.5, 0.5) + zig.slice(2, 6).map((p, i) => tip([(p[0] + zig[i + 3][0]) / 2, (p[1] + zig[i + 3][1]) / 2], [zig[i + 3][0] - p[0], zig[i + 3][1] - p[1]], palette.ray, 14)).join('')); } // 4.5 · Dispersion on a dark panel: a white beam crosses a prism at minimum deviation, and each // colour leaves bent towards the base, red least and violet most (the spread is exaggerated). function prism() { const [W, H, SX, BEAM] = [1760, 720, 1690, 8]; // the panel, the screen's x, half the beam const hues = ['#e5484d', '#f0892a', '#f5cf3a', '#58b86b', '#3b8fd0', '#4f5ab8', '#7c4fb8']; const [A, B, C] = [[800, 75], [580, 485], [1020, 485]]; // apex, base left, base right const along = (p, d, t) => [p[0] + d[0] * t, p[1] + d[1] * t]; const into = (q, r) => { // the unit normal of the face q→r that points into the glass const l = Math.hypot(r[0] - q[0], r[1] - q[1]); return [(q[1] - r[1]) / l, (r[0] - q[0]) / l]; }; // Snell's law with vectors: m is the face normal against the ray, eta = n before ÷ n after. const refract = (d, m, eta) => { const c = -(d[0] * m[0] + d[1] * m[1]); return along([eta * d[0], eta * d[1]], m, eta * c - Math.sqrt(1 - eta * eta * (1 - c * c))); }; const meet = (p, d, [q, r]) => { // where the ray from p along d crosses the line q–r const [ex, ey] = [r[0] - q[0], r[1] - q[1]]; return along(p, d, ((q[0] - p[0]) * ey - (q[1] - p[1]) * ex) / (d[0] * ey - d[1] * ex)); }; // At minimum deviation the beam crosses the glass parallel to the base: it rises to the first // face at half the deviation of the middle colour (n = 1.52), and every colour falls after. const half = Math.atan2(C[0] - A[0], C[1] - A[1]); // half the apex angle const lift = Math.asin(1.52 * Math.sin(half)) - half; const d0 = [Math.cos(lift), -Math.sin(lift)]; const across = [Math.sin(lift), Math.cos(lift)]; // square to the beam, downwards const mid = along(A, [B[0] - A[0], B[1] - A[1]], 0.5); // the beam meets the first face halfway const slit = along(mid, d0, (70 - mid[0]) / d0[0]); const edge = (s) => along(slit, across, s * BEAM); // s = -1: the beam's upper edge; 1: lower const [top, bottom] = [meet(edge(-1), d0, [B, A]), meet(edge(1), d0, [B, A])]; // The seven bands' eight edges, red (0) to violet (7), each refracted with its own index. const edges = Array.from({ length: 8 }, (_, k) => { const n = 1.46 + k * 0.02; const p = along(top, [bottom[0] - top[0], bottom[1] - top[1]], k / 7); const inside = refract(d0, into(A, B), 1 / n); const out = meet(p, inside, [A, C]); return [p, out, meet(out, refract(inside, into(A, C), n), [[SX, 0], [SX, H]])]; }); const ys = edges.map(([, , hit]) => hit[1]); const jaw = (from, to) => shape(`M${pts([edge(from), edge(to), along(edge(to), d0, -30), along(edge(from), d0, -30)])}Z`, palette.muted); return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.ink}"/>` + jaw(-1.3, -7.5) + jaw(1.3, 7.5) // the slit + shape(`M${pts([edge(-1), top, bottom, edge(1)])}Z`, palette.paper, ' fill-opacity=".95"') + shape(`M${pts([top, edges[0][1], edges[7][1], bottom])}Z`, palette.paper, ' fill-opacity=".45"') + hues.map((hue, i) => shape(`M${pts([edges[i][1], edges[i][2], edges[i + 1][2], edges[i + 1][1]])}Z`, hue, ' fill-opacity=".85"')).join('') + shape(`M${pts([A, B, C])}Z`, palette.glass, ` fill-opacity=".16" stroke="${palette.paper}" ` + 'stroke-opacity=".75" stroke-width="3" stroke-linejoin="round"') + shape(`M${pts([A, [A[0] + 40, B[1]], C])}Z`, palette.paper, ' fill-opacity=".07"') // a facet + shape(`M${SX} ${f1(Math.min(...ys) - 10)}H${SX + 16}V${f1(Math.max(...ys) + 10)}H${SX}Z`, palette.paper, ' fill-opacity=".25"') // the screen + tip(along(slit, d0, 260), d0, palette.ink, 16)); } // 4.6 · The three principal rays of a converging lens meet at the tip of a real image. function principalRays() { const [W, H, AX, LX, F] = [1162, 470, 235, 581, 200]; const [ox, oy] = [121, 95]; // the object's tip, beyond 2F const v = 1 / (1 / F - 1 / (LX - ox)); // the lens formula gives the image distance const [ix, iy] = [LX + v, AX + (AX - oy) * (v / (LX - ox))]; const along = (p, q, x) => [x, p[1] + ((q[1] - p[1]) * (x - p[0])) / (q[0] - p[0])]; const hit = along([ox, oy], [LX - F, AX], LX); // where the ray through F meets the lens const arrow = (x, y, color) => stroke([[x, AX], [x, y + Math.sign(AX - y) * 18]], color, 5) + tip([x, y + Math.sign(AX - y) * 20], [0, y - AX], color, 20); return svg(W, H, `<rect width="${W}" height="${H}" fill="${palette.tint}"/>` // a light plate + stroke([[0, AX], [W, AX]], palette.muted, 1.5) + lens(LX, 30, 440, 70, palette.glass, palette.ink) + [LX - 2 * F, LX + 2 * F].map((x) => dot(x, AX, 6, palette.paper, ` stroke="${palette.ink}" stroke-width="2.5"`)).join('') + [LX - F, LX + F].map((x) => dot(x, AX, 7, palette.ink)).join('') + ray([[ox, oy], [LX, oy], along([LX, oy], [LX + F, AX], 1110)], palette.ray, 3, 0.45) + ray([[ox, oy], along([ox, oy], [LX, AX], 1110)], palette.ray, 3, 0.28) + ray([[ox, oy], hit, [1110, hit[1]]], palette.ray, 3, 0.6) // through F, then parallel + arrow(ox, oy, palette.ink) + arrow(ix, iy, palette.accent) + dot(ix, iy, 7, palette.ray)); } // 4.7 · A diverging lens spreads parallel rays as if they came from the focus in front of it. function diverging() { const [W, H, AX, LX, F, OUT] = [528, 380, 190, 300, 150, 185]; // A ray leaves the lens along the line from the virtual focus, and every one runs OUT px. const away = (y) => { const l = Math.hypot(F, y - AX); return [LX + (F * OUT) / l, y + ((y - AX) * OUT) / l]; }; return svg(W, H, stroke([[0, AX], [W, AX]], palette.muted, 1.5) + shape(`M${LX - 26} 40H${LX + 26}Q${LX + 4} ${AX} ${LX + 26} 340H${LX - 26}Q${LX - 4} ${AX} ` + `${LX - 26} 40Z`, palette.glass, ` stroke="${palette.ink}" stroke-width="2.5"`) + dot(LX - F, AX, 7, palette.ink) + [105, 150, 230, 275].map((y) => stroke([[LX - F, AX], [LX, y]], palette.muted, 1.5, ' stroke-dasharray="8 7"') + ray([[20, y], [LX, y], away(y)], palette.ray, 3, 0.55)).join('') + ray([[20, AX], [LX + OUT, AX]], palette.ray, 3, 0.3)); } // #endregion // #region figures: where each diagram goes, set by its placement and its first citation const drawings = new Map(); // fileId → SVG markup, registered before the build const figure = (id, { width, height, markup }, placement) => { drawings.set(`${id}.svg`, markup); return { id, typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, caption: t(captions[id]), altText: t(captions[id]), // read aloud in HTML and tagged PDF; the canvas does not use it svg: { fileId: `${id}.svg`, width, height }, ...(placement && { placement }) }; }; const resources = [ // no placement: a main-column float, its caption in the channel figure('burning-glass', burningGlass()), figure('refraction', refraction(), side), figure('critical-angle', criticalAngle(), side), figure('fibre', fibre()), figure('prism', prism(), { span: 'page', position: 'top' }), // across text column and channel figure('principal-rays', principalRays()), figure('diverging', diverging(), side), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Merriweather: ['300', '300i', '400i', '700'], 'Merriweather Sans': ['300', '300i', '700', '800'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all([...drawings].map(([fileId, markup]) => loadSvg(fileId, markup))); // #region build: chapter 4 of a longer book, so the counters start where chapter 3 ended const continuation = { pageNumbering: { startAt: 87 }, // odd, like page 1: a recto headings: { h1: 3, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 4 const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Textbook with a margin column', es: 'Libro de texto con columna al margen' }) }); // #endregion工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行
// ─── 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 · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#把题注留在图下方
使用图类型的普通默认设置时,图4.1、4.4和4.6的题注会占用正文栏的行,边栏只放光路图和术语注。
-const resourceTypes = defaultResourceTypes(LANG).map((type) => (type.id !== 'figure' ? type
- : { ...type, defaultPlacement: { captionSide: true } })); // 易错点:resource-types-locale
+const resourceTypes = defaultResourceTypes(LANG); // 易错点:resource-types-locale#让边栏在每一页都位于右侧
对于在屏幕上一次看一页的文档,可以取消页边距镜像,并把左页的页码和书眉移到右边缘;这样各章可以从任一页开始。
- sideColumnSide: 'outer', // 右页在右,左页在左(页边距是镜像的)
+ sideColumnSide: 'right', // 页边距不再镜像后,'outer'本来也就是这个意思
- bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: true } }, // left:右页的内侧
+ bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER), mirror: false } },
- { level: 1, breakBefore: { enabled: true, parity: 'odd' }, marginBottom: pt(LEAD),
+ { level: 1, breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(LEAD),
-const verso = { parity: 'even', edge: 'top-left' }; // x从左边缘向内计算
+const verso = { parity: 'even', edge: 'top-right' };
- head({ id: 'verso-folio', ...verso, ...folio, x: OUTER }),
- head({ id: 'verso-title', ...verso, content: '{title}', x: OUTER + HEAD_GAP }),
+ head({ id: 'verso-folio', ...verso, ...folio, x: -OUTER }),
+ head({ id: 'verso-title', ...verso, content: '{title}', x: -(OUTER + HEAD_GAP) }),
- head({ id: 'drop-folio', ...recto, ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER,
+ head({ id: 'drop-folio', ...folio, pages: 'opener', edge: 'bottom-right', x: -OUTER,#在色带下开启本章
出血色带上的章首页让一条色带从页面顶部出血,并在上面排标题和168 pt的章号。
常见问题
易错点
标题后的侧栏框会让下一段缩进
在postext 1.4.1中,把span: 'side'的框围在标题和它的第一段之间,即使设了indentAfterHeading: false,这一段也会首行缩进:框离开了正文流,但它的块仍被算作标题之后的那个块。把框围在第一段之后。 旁注 →
易错点
'top'浮动体不会出现在引用它的那一页
浮动体不会排在自己的引用之前,所以在第N页引用的整页宽'top'浮动体会出现在第N+1页的顶部。把引用提前,或者使用position 'auto'或'bottom',它们可以占用引用页的底部。 图的放置 →
易错点
章首页预留的高度一直到最低的页面锚定元素
高级设计的章首页会预留到其最低元素为止的高度,标题下方锚定在页面或出血上的元素也算在内,所以页脚处的装饰会把正文推到下一页。把这类装饰放在标题上方,移到页眉或页脚槽位,或者用minHeight设定预留高度。 设计过的章首页 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
用defaultResourceTypes(locale)本地化Figure/Table
配置的locale决定断词,不决定题注:没有resourceTypes时,内置类型用英文写作Figure和Table。西班牙语传入resourceTypes: defaultResourceTypes('es');其他语言请在resourceTypes中自己写出名称。 用你的语言显示“图”和“表” →
易错点
不换行空格仍然会断行
在postext 1.4.1中,断行器把U+00A0当作普通空格,所以0.08 %、2.006 s或Section 2可能被拆到两行。把两部分连写(0.08%),或者改写句子。 转义与字面字符 →
易错点
SVG <img>中的文字不能使用网络字体
SVG作为图像绘制,而图像无法使用页面的网络字体,所以其中的标签会退回系统字体。把文字转成轮廓,在SVG中嵌入@font-face子集,或者把标签移到题注里。 作为资源的图和表 →
易错点
SVG插图中不要用<marker>或滤镜(会退回位图)
SVG图只有在不含<marker>、滤镜和蒙版时,才能在PDF中保持矢量;否则会退回位图,而且层层嵌套的滤镜可能让它在Chrome中变成空白。箭头用路径来画。 作为资源的图和表 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
- 图和术语注按正文排到它们的顺序在边栏中依次堆叠,从不并排。临界角术语注的围栏写在斯涅尔定律那一段之后、引用图4.3的那一段之前,所以在第88页上它堆叠在图4.2和图4.3之间,位于引入这个术语的段落旁边。如果它的围栏写在那次引用之后,就会落在图4.3下方。
- 在一章的第一页,只在学习目标框之后引用边栏图。侧栏图从边栏顶部开始堆叠,不会给锚定在那里的眉题和数字让位,所以在第一段引用的图会盖在它们上面。
致谢
- 文本
- 原创文字, CC BY 4.0
- 字体
- Merriweather (SIL OFL 1.1) · Merriweather Sans (SIL OFL 1.1)


