かんたんな説明
生物の教科書の1章で、注意や実験メモなど6種類の囲みがあります。種類ごとに色と記号が決まっているので、生徒はひと目で見分けられます。
できあがり
高校の生物の教科書『Living Matter』の第2章を、210 × 280 mmのページに組みます。章はページの天の角で断ち切られた細胞の図の下で始まり、細胞の核に章番号が入ります。その後に3色6種類の囲みが続き、それぞれが独自の目印を持つので、生徒は読む前に目標と注意を見分けられます。マゼンタは、的のアイコンを入れた帯に載せる学習目標と、枠の外にチェックの入ったタイルを置く確認問題に使います。緑は、電球のバッジを頭に載せた細い罫を添える学習のヒントと、3つの安全のピクトグラムを付けた実験の囲みに使います。注意は黄色で、外側の角に注意のバッジを掛けます。BOX 2.1は、黒いタブとフラスコの下にラベンダーの地色を敷きます。太字の用語はその囲みの色になり、BOX 2.1ではマゼンタです。
このレシピが答える質問
- アイコン、色付きの帯、角丸を備えた注記・ヒント・警告の囲みを作るには?
- 番号付きのタブ(「BOX 1-1」)、角のアイコン、罫線付きの余白のアイコンを加えるには?
- 本文や囲みの中で、キーワード(太字やイタリック)に色を付けるには?
手短な答え
// box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms.
const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size
const calloutStyles = [
// The first style is also the one a missing or misspelt type falls back to.
box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }),
background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') },
icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it
box('feature', 'band', { background: col('mist'),
titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) },
// Printed only where the fence has label="…": the tab, the flask beside it, a rule to them.
label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'),
position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge
icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) },
rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }),
box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4),
border: { enabled: true, color: col('warn'), width: pt(0.75) },
// The badge hangs half past the outer corner: sides of half badge + GAP align the title.
padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2),
left: mm(BADGE / 2 + GAP) },
icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
]; // straight into config(), followed by the 'more' styles
材料
- 機能
- 囲み番号付きの囲みのタブ囲みのアイコンと角のバッジ囲みの横のマーカー段囲みの中の段組み段抜きの囲みフロートする囲み太字・イタリックとその色セマンティックカラーパレットデザインした章扉段抜きの章見出し帯ページデザインの画像見出しの属性タイトル内の改行柱とノンブル見開きの余白リソースとしての図と表図を配置する参照図の配置キャンバス上のページ
- 種類
- Noto Serif, Lexend, Barlow Semi Condensed (SIL OFL 1.1)
- 素材
- なし:図はすべてコードで描画
作り方
#1 · 囲みの種類ごとに色を決める
const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs
band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check
tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work
warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type
mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
// The engine's defaults link to 'main-color': point it at the band, so nothing prints blue.
const colorPalette = Object.entries({ ...palette, 'main-color': palette.band })
.map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
6種類の囲みが3つの色相を分け合います。マゼンタは目標と確認問題、緑は学習のヒントと実験、黄色は注意です。BOX 2.1はラベンダーの地色で、キーワードにはマゼンタを使います。各色相には地色用の淡いティントがあり、色の設定はすべてパレットの項目にリンクしているので、色相を変えるときは値をひとつ書き換えるだけです。この大きさの文字には4.5:1のコントラストが必要です。ラベンダーの上のマゼンタは4.9:1ありますが、注意の地色の上の黄色は2.2:1しかありません。そこで注意の囲みは、枠とバッジに黄色を使い、タイトルとキーワードは5.2:1の暗い黄色warnInkで組みます。
#2 · それ以外はすべて共有する
const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2),
padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) },
titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default
textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) },
body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'),
boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true },
lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers
...device }); // the kind's title, fill and device: its spread wins over the base
// An SVG or bitmap resource, fitted into a square of that size (width × size with `width`).
const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more });
box()には、すべての囲みが共有する設定をまとめます。マージン、パディング、タイトルと本文の文字、リストの間隔です。引数のhueは、タイトル、箇条書きの記号と番号、太字の部分に色を付けます。body.boldColorにより、目標の囲みではfluid mosaic modelがマゼンタに、注意の囲みではcell wallが黄褐色になります。本文中の太字は墨のままです(囲みの中の文字組み)。calloutStylesを宣言すると、エンジンが標準で持つ唯一のスタイル、灰色の囲みnoteが置き換えられます。typeがないか綴りを誤ったフェンスは最初のスタイルになるので、どの章にもある目標をリストの先頭に置きます。
#3 · 帯、タブ、バッジ
コードは前出の手短な答えにあります。目標の帯は幅7.5 mm、的のアイコンは5 mmなので、アイコンは左右に余裕を残して色の中に収まります。タブのoffsetをheightと同じにすると、タブは囲みの上辺に立ち、タイトルにかかりません。'top-left'はこの偶数ページの外側の角です。フラスコはタブの後に続き、罫はフラスコから上辺に沿って反対の角まで伸びます。position: 'corner'は注意のバッジを幅の半分だけ辺の外に掛け、cornerSide: 'outer'はバッジを小口側、つまりこの偶数ページでは左に送ります。その側では、パディングがバッジの半分とタイトルの間隔をすでに覆っていない限り、エンジンがタイトルを字下げしてバッジを避けます。そこで注意の囲みは両側にBADGE / 2 + GAPのパディングを取り、タイトルを本文にそろえます。borderRadiusを持つのはこの囲みだけです。帯のある囲みでは帯が別の四角い棒として塗られるので、その側の角は四角いまま残るからです(囲みスタイル)。
#4 · さらに3つの目印
const BULB = 6.4; // the study tip's badge in mm: the text clears it
const moreStyles = [
box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }),
backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') },
// A stripe narrower than its icon: the badge sits on it at the top and hides it there.
padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) },
icon: icon('bulb', BULB) }),
box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading
icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image
box('check', 'band', { background: col('tintBand'),
// Outside the frame: [marker][rule][gap][box], the rule as tall as the box.
marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3),
rule: { enabled: true, color: col('band'), width: pt(0.75) } } }),
];
学習のヒントの帯は幅2.5 ptで、6.4 mmの電球のバッジに対して細い罫として刷られ、その上端にバッジが載ります。左のパディングをバッジの半分に2 mmを足した値にして、本文を電球から離します。icon.widthは絵を正方形ではなく横長の枠に収めます。これで1枚の24.5 mmの画像として描いた3つの安全のピクトグラムが、実験の囲みのアイコン列になり、本文に対して中央に置かれます。確認問題の目印は枠の外にあり、目印、罫、間隔、囲みの順に並びます。0.75 ptの罫に載せた四角いタイルが、丸いバッジを持つ学習のヒントとの違いを示します。フェンスにspan="page" placement="bottom"を加えて、確認問題を29ページの地にフロートとして配置します。
#5 · アイコンはリソース
const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg',
svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra });
const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref
const resources = [
// Uncited, so never placed as figures: the box styles and the opener use them by id.
...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)),
svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000),
svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({
en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its '
+ 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide
es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su '
+ 'glucosa, colesterol gris, azúcares ámbar.' }),
altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }),
svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({
en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the '
+ 'hybrid just after fusion and the same hybrid 40 minutes later.',
es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos '
+ 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }),
altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }),
svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({
en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the '
+ 'net flow of water.',
es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas '
+ 'indican el flujo neto de agua.' }),
altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }),
];
すべてのアイコン、タブの横のフラスコ、章扉の細胞は、コンテンツと一緒に宣言してファイルidで登録したSVGのリソースです。スタイルはリソースidでそれらを指定し、本文からは引用しないので、どれも番号付きの図にはなりません。✓や☞のようなグリフのほうが短く書けますが、ページが読み込むFontsourceのlatinファイルにはこれらがありません。Canvasはシステムフォントから借りて描き、PDFでは消えてしまいます。図に文字を入れていないのも、画像として描くSVGはページのフォントを使えないためです。代わりにキャプションで色を説明しています。
#6 · 細胞で章を始める
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
overflow: 'wrap', align: 'left', ...extra }); // gotcha: overflow-ellipsis-default
const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' },
offset: { y: mm(y) }, size: { width: mm(width) } });
const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' });
const opener = { enabled: true, minHeight: mm(66), slot: { elements: [
{ kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page',
edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } },
text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page',
edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } },
{ fontWeight: 800, lineHeight: 1, align: 'right' }),
text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container',
edge: 'top-left' }, offset: { y: mm(3) } },
{ fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }),
text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112),
{ fontWeight: 700, lineHeight: 1.04 }),
text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98),
{ italic: true, lineHeight: 1.42 }),
] } };
細胞は、ページの右上の角に固定して角の外まで押し出した画像要素です。そのため見えるのはおもに左下の4分の1で、核に数字が入ります。どちらもminHeightが章扉のために確保する66 mmの中に収まります。キッカーとリードは見出しの行# Cells and \\ Membranes {unit="The cell" lead="…"}から取り、\\は章扉でだけタイトルを改行します。
レシピの全体
// ═══ Postext Cookbook · Nº 008 · A family of textbook boxes ═══════════════════════ // https://postext.dev/en/cookbook/textbook-box-family // Code: MIT · Text: original (CC BY 4.0) · Drawings and icons: generated in code (CC BY 4.0) // Fonts: Noto Serif, Lexend, Barlow Semi Condensed (SIL OFL 1.1) · Needs postext ≥ 1.4.1 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-box-family'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: three hues for six kinds of box; everything links to an entry const palette = { ink: '#1f2430', paper: '#ffffff', // text; type on stripes and tabs band: '#ab2e78', tintBand: '#fae3ef', // the chapter's magenta: opener, objectives, self-check tip: '#25734a', tintTip: '#e2f0e7', // study tips and lab work warn: '#e39422', tintWarn: '#fcefd8', warnInk: '#94540a', // frame and badge; tint; its type mist: '#e8e3f0', rule: '#dccfd8', muted: '#675e66' }; // feature box; lines in drawings; heads const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id }); // The engine's defaults link to 'main-color': point it at the band, so nothing prints blue. const colorPalette = Object.entries({ ...palette, 'main-color': palette.band }) .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })); // #endregion // Label face (box text and titles, tabs, captions, heads); leading in pt; outer margin in mm const LABEL = 'Barlow Semi Condensed', LEAD = 13.4, OUTER = 16, GAP = 1.5; // GAP: title gap, mm // #region base: what every box shares; each kind brings its colour and its one device const box = (id, hue, device) => ({ id, marginTop: pt(LEAD), marginBottom: pt(LEAD / 2), padding: { top: mm(2.8), right: mm(3.4), bottom: mm(3.2), left: mm(3.4) }, titleStyle: { fontFamily: LABEL, fontSize: pt(8.5), color: col(hue), // bold by default textTransform: 'uppercase', letterSpacing: pt(1.4), gap: mm(GAP) }, body: { fontFamily: LABEL, fontSize: pt(9.6), lineHeight: pt(12.6), color: col('ink'), boldColor: col(hue), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true }, lists: { color: col(hue), gap: mm(2.2), itemSpacing: pt(2.4) }, // bullets and numbers ...device }); // the kind's title, fill and device: its spread wins over the base // An SVG or bitmap resource, fitted into a square of that size (width × size with `width`). const icon = (id, size, more) => ({ kind: 'resource', resourceId: id, size: mm(size), ...more }); // #endregion // #region answer: three devices: an icon on a wide stripe, a numbered tab, an outer badge // box(id, hue, device) is the shared base: the hue colours the title, bullets and key terms. const TAB = 5, BADGE = 7.4; // in mm: the tab's height (and offset), the warning badge's size const calloutStyles = [ // The first style is also the one a missing or misspelt type falls back to. box('objectives', 'band', { title: t({ en: 'In this chapter', es: 'En este capítulo' }), background: col('tintBand'), stripe: { enabled: true, width: mm(7.5), color: col('band') }, icon: icon('target', 5) }), // on a side stripe (left by default) the icon is centred on it box('feature', 'band', { background: col('mist'), titleStyle: { fontFamily: 'Lexend', fontSize: pt(10.5), color: col('ink'), gap: mm(1.6) }, // Printed only where the fence has label="…": the tab, the flask beside it, a rule to them. label: { fontFamily: LABEL, fontSize: pt(8), color: col('paper'), background: col('ink'), position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default) height: mm(TAB), offset: mm(TAB), paddingX: mm(2.3), // offset = height: on the top edge icon: { resourceId: 'flask', width: mm(4.4), gap: mm(1.2) }, rule: { enabled: true, color: col('ink'), width: pt(1.2) } } }), box('warning', 'warnInk', { background: col('tintWarn'), borderRadius: mm(1.4), border: { enabled: true, color: col('warn'), width: pt(0.75) }, // The badge hangs half past the outer corner: sides of half badge + GAP align the title. padding: { top: mm(2.8), right: mm(BADGE / 2 + GAP), bottom: mm(3.2), left: mm(BADGE / 2 + GAP) }, icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }), ]; // straight into config(), followed by the 'more' styles // #endregion // #region more: a badge threaded on a thin rule, a strip of pictograms, a marker outside const BULB = 6.4; // the study tip's badge in mm: the text clears it const moreStyles = [ box('tip', 'tip', { title: t({ en: 'Study tip', es: 'Para recordar' }), backgroundEnabled: false, stripe: { enabled: true, width: pt(2.5), color: col('tip') }, // A stripe narrower than its icon: the badge sits on it at the top and hides it there. padding: { top: mm(0.6), right: mm(0), bottom: mm(0.6), left: mm(BULB / 2 + 2) }, icon: icon('bulb', BULB) }), box('safety', 'tip', { background: col('tintTip'), marginTop: pt(4), // under its heading icon: icon('safety', 7, { width: mm(24.5), align: 'center' }) }), // 3 pictograms, 1 image box('check', 'band', { background: col('tintBand'), // Outside the frame: [marker][rule][gap][box], the rule as tall as the box. marker: { kind: 'resource', resourceId: 'check', size: mm(7), align: 'top', gap: mm(3), rule: { enabled: true, color: col('band'), width: pt(0.75) } } }), ]; // #endregion // #region opener: a cell cut by the corner of the page, the chapter number inside it const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id, content, fontFamily: family, fontSize: pt(size), color: col(color), placement, overflow: 'wrap', align: 'left', ...extra }); // gotcha: overflow-ellipsis-default const below = (id, y, width) => ({ anchor: { to: `#${id}`, edge: 'below' }, offset: { y: mm(y) }, size: { width: mm(width) } }); const chapter = t({ en: 'Chapter {chapterNumber}', es: 'Capítulo {chapterNumber}' }); const opener = { enabled: true, minHeight: mm(66), slot: { elements: [ { kind: 'image', id: 'cell', resourceId: 'cell', placement: { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(58), y: mm(-71) }, size: { width: mm(150) } } }, text('numeral', '{chapterNumber}', 'Lexend', 118, 'paper', { anchor: { to: 'page', edge: 'top-right' }, offset: { x: mm(-OUTER), y: mm(14) } }, { fontWeight: 800, lineHeight: 1, align: 'right' }), text('kicker', `${chapter} · {attr.unit}`, LABEL, 9, 'band', { anchor: { to: 'container', edge: 'top-left' }, offset: { y: mm(3) } }, { fontWeight: 700, letterSpacing: pt(1.8), textTransform: 'uppercase' }), text('title', '{titleText}', 'Lexend', 34, 'ink', below('kicker', 2.5, 112), { fontWeight: 700, lineHeight: 1.04 }), text('lead', '{attr.lead}', 'Noto Serif', 10.5, 'ink', below('title', 5, 98), { italic: true, lineHeight: 1.42 }), ] } }; // #endregion // Heads 12.8 mm below the trim (the larger folio 0.4 mm higher), titles 8.5 mm in from folios. const head = (id, content, parity, edge, x, extra, y = 12.8) => ({ kind: 'text', id, content, parity, pages: 'body', fontFamily: LABEL, fontSize: pt(7.8), fontWeight: 600, letterSpacing: pt(1.3), textTransform: 'uppercase', color: col('muted'), ...extra, placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } } }); const folio = { fontFamily: 'Lexend', fontSize: pt(9), fontWeight: 700, color: col('band') }; const header = { elements: [ // folios on the fore-edge, never on the opener head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER, folio, 12.4), head('verso-title', '{title}', 'even', 'top-left', OUTER + 8.5), head('recto-title', `${chapter} · {chapterTitle}`, 'odd', 'top-right', -(OUTER + 8.5)), head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER, folio, 12.4), ] }; // the opener's folio drops to the foot: const footer = { elements: [{ ...head('drop', '{pageNumber}', 'all', 'bottom', 0, folio, -12), pages: 'opener' }] }; const config = () => ({ // a factory, never a shared object (gotcha: config-cache-identity) locale: t({ en: 'en-us', es: 'es' }), // exact codes only (gotcha: hyphenation-locales) resourceTypes: defaultResourceTypes(LANG), // "Figura" (gotcha: resource-types-locale) colorPalette, header, footer, page: { width: mm(210), height: mm(280), dpi: 150, margins: { top: mm(24), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true } }, // left is the inner margin layout: { layoutType: 'double', gutterWidth: mm(8) }, bodyText: { fontFamily: 'Noto Serif', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('band'), textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false }, // hyphens: default headings: { fontFamily: 'Lexend', color: col('band'), levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' }, marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener }, { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), numberingTemplate: '{1}.{2}', marginTop: pt(LEAD * 1.4), marginBottom: pt(LEAD * 0.6) }, // 3 lines, close to its text { level: 3, fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('tip'), marginTop: pt(LEAD), marginBottom: pt(0) }, // a line clear of the text, on its box ] }, // In 1.4.1 a box's lists.color reaches its numbers only if it differs from these bullets'. unorderedLists: { color: col('ink'), marginTop: pt(0), marginBottom: pt(0) }, orderedLists: { fontFamily: 'Lexend', color: col('tip'), marginTop: pt(0), marginBottom: pt(0) }, calloutStyles: [...calloutStyles, ...moreStyles], captionStyle: { fontFamily: LABEL, fontSize: pt(8.8), color: col('ink'), labelColor: col('band'), gap: mm(1.8) }, paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.6), lineHeight: pt(10), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdownの見本 · 87行 · content.en.md
title: "Living Matter" subtitle: "Biology for upper secondary school" --- # Cells and \\ Membranes {unit="The cell" lead="Every living cell is wrapped in a film ten thousand times thinner than a sheet of paper. Oxygen passes through it freely; salts and sugars need a protein to cross."} A cell is a crowded, watery workshop. Its enzymes need salts, sugars and building blocks close at hand, and its waste has to leave before it builds up. The **plasma membrane**, a layer about eight nanometres thick, separates that workshop from the world outside. In this chapter you will see what the membrane is made of and why it behaves more like a liquid than a wall. Some substances cross it at no cost to the cell; others cross only when the cell spends energy. :::callout{type="objectives"} - Use the **fluid mosaic model** to describe the plasma membrane. - Explain how **diffusion** and **osmosis** move substances without energy. - Predict how a cell changes in a hypertonic, an isotonic and a hypotonic solution. - Compare **active transport** with passive transport. ::: ## A boundary with doors Membranes are built mostly from **phospholipids**. Each molecule has a head that is attracted to water and two fatty tails that avoid it. In water, phospholipids arrange themselves so that the tails are hidden: they form a double layer, the **lipid bilayer**, with the heads facing the watery fluid on both sides and the tails meeting in the middle (:ref{id="mosaic"}). Because its core is oily, the bilayer lets small, neutral molecules such as oxygen and carbon dioxide slip straight across. Ions and larger polar molecules, such as glucose, are turned away. Water, although polar, is small enough to trickle through slowly on its own, and many cells speed it up with water channels, the aquaporins. Letting some substances through and not others is called **selective permeability**. It lets a cell keep an inside that differs from its surroundings, and it is why ions and sugars cross through proteins set in the membrane. :::callout{type="tip"} Read the name of the model as a description. **Fluid** means that the lipids drift sideways and swap places with their neighbours millions of times a second, and **mosaic** means that proteins are set into the layer like tiles in a floor. ::: ## The fluid mosaic In 1972 S. Jonathan Singer and Garth Nicolson proposed the model that is still used today. The bilayer is the fluid base, and proteins float in it: some span the whole membrane, others rest on one of its faces. **Channel proteins** form water-filled pores for particular ions, and **carrier proteins** change shape to move a molecule from one side to the other. Chains of sugars fixed to proteins and lipids face the outside of the cell and work as identity tags that other cells recognise. Cholesterol, tucked between the phospholipid tails, acts as a buffer. When it is warm, cholesterol restrains the tails and keeps the membrane from becoming runny; when it is cold, it keeps them from packing tightly and turning stiff. Living things tune their membranes to their temperature: bacteria grown in the cold build lipids with more kinks in their tails, which keeps the layer fluid, and many plants do the same as winter comes, as do the fish that live in the icy seas around Antarctica. :::callout{type="feature" label="BOX 2.1" title="Watching proteins drift" span="page"} :::columns{count=2 breaks="2"} ::resource{id="fusion"} In 1970 Larry Frye and Michael Edidin fused a mouse cell with a human cell. They had tagged the mouse membrane proteins with a green fluorescent dye and the human ones with a red dye. At first each colour kept to its own half of the hybrid cell. Forty minutes later, at 37 °C, the colours were completely mixed (:ref{id="fusion"}). Kept cold, the hybrids mixed far more slowly. The proteins were **drifting** on their own through a fluid layer that the cold made stiffer. ::: ::: :::callout{type="warning" title="Membrane or wall?"} Plants, fungi and most bacteria also have a **cell wall** outside the membrane. The wall gives the cell its shape and keeps it from bursting, but it lets almost everything through. Only the membrane is selectively permeable. ::: The lipids and many of the proteins in a membrane keep moving, yet the layer holds together, because wherever a lipid drifts, its tails stay hidden from the water. For the same reason, a small hole in a bilayer closes up again on its own. ## Crossing the membrane Particles in a solution never stop moving. **Diffusion** is their net movement from where they are more concentrated to where they are less, until they are evenly spread. It needs no energy from the cell, because the particles’ own motion does the work: oxygen diffuses into a cell that keeps using it up, and carbon dioxide diffuses out. **Osmosis** is the diffusion of water across a selectively permeable membrane. If the solution outside a cell holds more dissolved particles than the cytoplasm, it is **hypertonic**, and the cell loses water and shrinks; if it holds fewer, it is **hypotonic**, and water floods in; if the two are equal, it is **isotonic**, and the cell keeps its size (:ref{id="osmosis"}). Red blood cells in pure water swell until they burst. Plant cells do not burst: the wall holds them firm, which is why a watered plant stands upright and a dry one wilts. Diffusion is quick over short distances and slow over long ones. An oxygen molecule crosses a cell ten micrometres across in about a fortieth of a second, yet it would need some seven hours to diffuse one centimetre through water. That is one reason why cells stay small, and why large animals need lungs, gills and blood to carry oxygen the rest of the way. Transport down the gradient through a channel or a carrier protein is called **facilitated diffusion**, and it is still passive: the protein opens a way, but the cell spends no energy on it. To move a substance uphill, from low concentration to high, a cell has to spend energy, usually as ATP, and that is **active transport**. The sodium–potassium pump in your nerve cells pushes three sodium ions out and two potassium ions in for every ATP it uses, and pumps like it take a large share of a resting cell’s energy. :::callout{type="check" title="Check yourself" span="page" placement="bottom"} :::columns{count=2 breaks="4"} 1. Why can oxygen cross the bilayer but glucose cannot? 2. What does **fluid** mean in the fluid mosaic model? 3. A slice of cucumber is left in salty water. Use **osmosis** to predict what happens to its cells. 4. Why does the sodium–potassium pump count as **active transport** and not as diffusion? 5. Could a cell with a rigid membrane use **endocytosis** to take in a bacterium? Explain. ::: ::: ## Moving in bulk Some cargo is far too large for any channel or carrier. A white blood cell swallows a whole bacterium by wrapping its membrane around it and pinching off a bubble, a **vesicle**, into the cytoplasm: this is **endocytosis**. Cells drink the same way: in pinocytosis the membrane folds in around a droplet of the fluid outside, with whatever is dissolved in it. The reverse, **exocytosis**, carries material out. Vesicles filled with hormones, digestive enzymes or the mucus that lines your airways fuse with the plasma membrane and empty their contents outside. Most signals that a nerve cell passes to the next leave it the same way: vesicles release messenger molecules, the neurotransmitters, into the narrow gap between the two cells. Both processes cost energy, and both work only because the bilayer is fluid enough to break and reseal without leaking. Membranes do not stop at the surface of the cell. Inside it, the same bilayer wraps the nucleus, the mitochondria and a maze of inner compartments, each keeping its own chemistry apart from the rest. In Chapter 3 you will follow a protein that the cell exports, from the ribosome that makes it, through the endoplasmic reticulum and the Golgi apparatus, to the vesicle that releases it outside. ### Try it: osmosis in a potato :::callout{type="safety" title="Lab safety"} Wear safety goggles and gloves, and always cut away from your fingers. ::: 1. Cut two potato strips of the same size, about five centimetres long, and weigh them. 2. Leave one in tap water and the other in strongly salted water for thirty minutes. 3. Dry both strips, weigh them again and bend them. 4. Explain the change in mass and in stiffness with what you know about osmosis. :::paragraphs{style="colophon"} **Living Matter** is a textbook invented for the Postext Cookbook. Text and drawings: original, CC BY 4.0. Set in Noto Serif, Lexend and Barlow Semi Condensed (SIL Open Font License). :::`; // content.<lang>.md, inlined by the Cookbook // #region icons: icons are resources too: declared with the content, registered by file id const svgResource = (id, width, height, extra) => ({ id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height }, createdAt: 0, updatedAt: 0, ...extra }); const pageTop = { position: 'top', span: 'page' }; // a 'top' float opens the page after its :ref const resources = [ // Uncited, so never placed as figures: the box styles and the opener use them by id. ...['target', 'bulb', 'caution', 'check'].map((id) => svgResource(id, 240, 240)), svgResource('flask', 200, 240), svgResource('safety', 760, 240), svgResource('cell', 2000, 2000), svgResource('mosaic', 3480, 1240, { placement: pageTop, caption: t({ en: 'The fluid mosaic: magenta phospholipids, green proteins, an amber carrier with its ' + 'glucose, grey cholesterol, amber sugars.', // one line: the page is wide es: 'El mosaico fluido: fosfolípidos magenta, proteínas verdes, transportadora ámbar con su ' + 'glucosa, colesterol gris, azúcares ámbar.' }), altText: t({ en: 'A cell membrane in section', es: 'Una membrana celular en sección' }) }), svgResource('fusion', 1800, 500, { placement: { position: 'here' }, caption: t({ en: 'Mouse proteins in green, human proteins in magenta (the red dye): the two cells, the ' + 'hybrid just after fusion and the same hybrid 40 minutes later.', es: 'En verde, las proteínas de ratón; en magenta, las humanas (el colorante rojo): las dos ' + 'células, el híbrido recién fusionado y el mismo híbrido 40 minutos después.' }), altText: t({ en: 'Two cells fusing into a hybrid', es: 'Dos células que se fusionan' }) }), svgResource('osmosis', 3480, 860, { placement: pageTop, caption: t({ en: 'Red blood cells in a hypertonic, an isotonic and a hypotonic solution. Arrows show the ' + 'net flow of water.', es: 'Glóbulos rojos en una disolución hipertónica, una isotónica y una hipotónica. Las flechas ' + 'indican el flujo neto de agua.' }), altText: t({ en: 'Blood cells in three solutions', es: 'Glóbulos rojos en tres medios' }) }), ]; // #endregion // #region art: the icons and the drawings, in the palette's colours (seeded) // No words in them: an SVG drawn as an image cannot use web fonts (gotcha: svg-no-webfonts). function rng(seed) { // Mulberry32: the same drawing on every run return () => { seed = (seed + 0x6d2b79f5) | 0; let x = Math.imul(seed ^ (seed >>> 15), 1 | seed); x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; return ((x ^ (x >>> 14)) >>> 0) / 4294967296; }; } const n = (v) => +v.toFixed(2); const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w * 10}" ` + `height="${h * 10}" viewBox="0 0 ${w} ${h}">${body}</svg>`; const dot = (x, y, r, fill, extra = '') => `<circle cx="${n(x)}" cy="${n(y)}" r="${n(r)}" ` + `fill="${fill}"${extra}/>`; const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" ` + `stroke-width="${width}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`; const shape = (d, fill, extra = '') => `<path d="${d}" fill="${fill}"${extra}/>`; const capsule = (x, y, w, h, fill, turn = 0, extra = '') => `<rect x="${n(x - w / 2)}" ` + `y="${n(y - h / 2)}" width="${n(w)}" height="${n(h)}" rx="${n(Math.min(w, h) / 2)}" ` + `fill="${fill}" transform="rotate(${n(turn)} ${n(x)} ${n(y)})"${extra}/>`; const arrow = (x, y, len, turn, fill) => shape(`M${x} ${y - 0.9}h${len - 4}v-1.9l4 2.8-4 2.8` + `v-1.9H${x}Z`, fill, ` transform="rotate(${turn} ${x} ${y})"`); // a path, never a marker function target() { // white rings on the magenta stripe return svg(24, 24, `<g fill="none" stroke="${palette.paper}" stroke-width="2.3">` + `<circle cx="12" cy="12" r="9.8"/><circle cx="12" cy="12" r="5.3"/></g>` + dot(12, 12, 1.9, palette.paper)); } function bulb() { return svg(24, 24, dot(12, 12, 12, palette.tip) + shape('M12 4.4a5.6 5.6 0 0 0-3.3 10.1c.7.5 1 ' + '1.1 1 1.9v.6h4.6v-.6c0-.8.3-1.4 1-1.9A5.6 5.6 0 0 0 12 4.4Z', palette.paper) + capsule(12, 18.6, 4.6, 1.3, palette.paper) + capsule(12, 20.3, 3, 1.2, palette.paper)); } function flask() { const body = 'M8 2v7L2.4 19.4A2.1 2.1 0 0 0 4.3 22.5h11.4a2.1 2.1 0 0 0 1.9-3.1L12 9V2'; return svg(20, 24, shape(`${body}Z`, palette.paper) + shape('M5.3 14h9.4l3 5.4a1.1 1.1 0 0 1-1 ' + '1.6H3.3a1.1 1.1 0 0 1-1-1.6Z', palette.band) + dot(8.6, 17.4, 1.1, palette.paper) + dot(11.8, 18.7, 0.8, palette.paper) + line(body, palette.ink, 1.7) + line('M6.4 2h7.2', palette.ink, 1.7)); } function caution() { // ink on amber: white on amber would fail contrast return svg(24, 24, dot(12, 12, 11, palette.warn, ` stroke="${palette.paper}" stroke-width="2"`) + capsule(12, 10, 2.8, 9.2, palette.ink) + dot(12, 17.6, 1.7, palette.ink)); } function check() { // a ticked tile: square where the study tip's badge is round return svg(24, 24, `<rect width="24" height="24" rx="5.5" fill="${palette.band}"/>` + line('M6.3 12.6l3.8 3.8 7.6-8', palette.paper, 2.9)); } function safety() { // goggles, a glove and a blade: three mandatory-action discs const goggles = `<g fill="none" stroke="${palette.paper}" stroke-width="1.5">` + '<rect x="4.3" y="9" width="6.6" height="5.6" rx="2.4"/>' + '<rect x="13.1" y="9" width="6.6" height="5.6" rx="2.4"/></g>' + line('M10.9 11.4q1.1-1 2.2 0M2.6 11.6h1.7M19.7 11.6h1.7', palette.paper, 1.4); const glove = shape('M32.4 20.5v-6.6l-2.3-2.7a1.2 1.2 0 0 1 1.8-1.6l1.4 1.5V6.2a1.1 1.1 0 0 1 ' + '2.2 0v5.2V4.9a1.1 1.1 0 0 1 2.2 0v6.5V5.5a1.1 1.1 0 0 1 2.2 0v6.2V7a1.1 1.1 0 0 1 2.2 0v8.6' + 'l-1.2 4.9Z', palette.paper); const blade = `<g transform="rotate(-45 64 12)">${capsule(64, 16.3, 3.6, 9, palette.paper)}` + shape('M62.2 11.4V6.3L64 3.2l1.8 3.1v5.1Z', palette.paper) + '</g>'; return svg(76, 24, [12, 38, 64].map((x) => dot(x, 12, 11.4, palette.ink)).join('') + goggles + glove + blade); } function blob(cx, cy, rx, ry, r, fill, extra = '', square = 2.6) { // a soft, uneven shape const pts = Array.from({ length: 16 }, (_, i) => { const [c, sn] = [Math.cos((i * Math.PI) / 8), Math.sin((i * Math.PI) / 8)]; const k = 1 + (r() - 0.5) * 0.12; const f = (v) => Math.sign(v) * Math.abs(v) ** (2 / square); // squarer than an ellipse return [cx + f(c) * rx * k, cy + f(sn) * ry * k]; }); const mid = (a, b) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2].map(n); const d = pts.map((p, i) => `Q${p.map(n)} ${mid(p, pts[(i + 1) % 16])}`).join(''); return shape(`M${mid(pts[15], pts[0])}${d}Z`, fill, extra); } const shine = (cx, cy, rx, ry, r) => blob(cx - rx * 0.28, cy - ry * 0.34, rx * 0.46, ry * 0.4, r, palette.paper, ' fill-opacity=".22"'); const pale = ` stroke="${palette.tip}" stroke-width="1.6"`; // a light green protein, outlined const sugars = (x, y, r, count = 5) => { // a chain of sugars, branched once const sugar = (sx, sy) => dot(sx, sy, 2.1, palette.warn, ` stroke="${palette.paper}" stroke-width=".7"`); let out = ''; for (let k = 0; k < count; k++, y -= 4.3, x += (r() - 0.5) * 4) { out += sugar(x, y) + (k === 2 ? sugar(x + 4.3, y - 1.6) : ''); } return out; }; function cell() { // a cell drawn as a disc of bilayer: mostly its lower-left quarter shows let out = dot(100, 100, 71, palette.band); for (let a = 0; a < 360; a += 3.05) { const [c, s] = [Math.cos((a * Math.PI) / 180), Math.sin((a * Math.PI) / 180)]; const at = (d) => [100 + c * d, 100 + s * d]; const tail = (d0, d1) => line(`M${at(d0).map(n)}L${at(d1).map(n)}`, palette.rule, 0.8); out += tail(90.5, 84.8) + tail(75.8, 81.2) + dot(...at(92.6), 2.6, palette.band) + dot(...at(73.6), 2.5, palette.paper); } [106, 133, 157, 184].forEach((a, k) => { // proteins across the visible arc const rad = (a * Math.PI) / 180; const fill = k % 2 ? palette.warn : palette.tip; out += capsule(100 + Math.cos(rad) * 83, 100 + Math.sin(rad) * 83, 26, 7, fill, a); }); return svg(200, 200, out); } function mosaic() { // the membrane in section, 174 × 62 mm; the outside of the cell on top const r = rng(5); const W = 348; const yc = (x) => 70 + 3.4 * Math.sin(x / 44); const proteins = [[56, 34, palette.tip, 'channel'], [148, 38, palette.warn, 'carrier'], [234, 27, palette.tip], [306, 32, palette.tintTip, 'pale']]; const clear = (x) => proteins.every(([px, w]) => Math.abs(x - px) > w / 2 + 1.8); let out = `<rect width="${W}" height="70" fill="${palette.tintBand}"/>` + `<rect y="70" width="${W}" height="54" fill="${palette.mist}"/>`; for (let x = 4; x < W; x += 5.9) { // phospholipids: heads out, two tails in if (!clear(x)) continue; for (const s of [-1, 1]) { const y = yc(x) + s * 13; for (const dx of [-1, 1]) { out += line(`M${n(x + dx)} ${n(y - s * 2.4)}l${n(r() - 0.5)} ${-s * 4}` + `l${n(r() - 0.5)} ${-s * 4.6}`, palette.band, 0.75, ' stroke-opacity=".4"'); } out += dot(x, y, 2.75, palette.band); if (r() < 0.13 && clear(x + 3)) { // cholesterol among the tails: the only grey rods out += capsule(x + 3, y - s * 7.4, 2.1, 7.2, palette.ink, 0, ' fill-opacity=".62"'); } } } out += sugars(196, yc(196) - 16.5, r, 3); // a glycolipid for (const [px, w, fill, kind] of proteins) { // proteins across the bilayer const y = yc(px); const halves = kind === 'channel' ? [px - w / 4 - 1.3, px + w / 4 + 1.3] : [px]; for (const x of halves) { const rx = kind === 'channel' ? w / 4 : w / 2; out += blob(x, y, rx, 21, r, fill, kind === 'pale' ? pale : '') + shine(x, y, rx, 21, r); } if (kind === 'channel') { out += dot(px, y - 7, 2, palette.warnInk) + dot(px, y + 4, 2, palette.warnInk); } if (kind === 'carrier') { // a glucose held in the carrier's open mouth out += shape(`M${px - 7} ${y - 24}L${px} ${y - 12}L${px + 7} ${y - 24}Z`, palette.tintBand) + shape(`M${px - 3.6} ${y - 19.5}l1.8-3.1h3.6l1.8 3.1-1.8 3.1h-3.6Z`, palette.paper, ` stroke="${palette.ink}" stroke-width=".8"`); } else out += sugars(px + (r() - 0.5) * 5, y - 22.5, r); } out += blob(100, yc(100) + 22.5, 11, 5.5, r, palette.tintTip, pale) // proteins on one face only + blob(270, yc(270) - 22, 8.5, 5, r, palette.tip); for (let x = 6; x < W; x += 3.2) { // the cytoskeleton under the membrane out += dot(x, 111 + 2.2 * Math.sin(x / 7), 1.4, palette.muted, ' fill-opacity=".45"'); } for (let k = 0; k < 9; k++) { // oxygen outside the cell const [x, y] = [14 + r() * 320, 8 + r() * 26]; for (const dx of [0, 2.6]) out += dot(x + dx, y, 1.5, palette.ink, ' fill-opacity=".5"'); } return svg(W, 124, out); } function fusion() { // two cells, the hybrid just after fusion, the hybrid 40 minutes later const r = rng(3); const cellAt = (cx, rx, fill, nuclei, pick) => { let out = `<ellipse cx="${cx}" cy="25" rx="${rx}" ry="${Math.min(rx, 15)}" fill="${fill}" ` + `stroke="${palette.ink}" stroke-width=".8"/>` + nuclei.map((x) => dot(x, 25, 4.2, palette.rule)).join(''); for (let a = 0; a < 360; a += 12) { const rad = (a * Math.PI) / 180; out += dot(cx + Math.cos(rad) * rx, 25 + Math.sin(rad) * Math.min(rx, 15), 1.7, pick(Math.cos(rad), r())); } return out; }; return svg(180, 50, cellAt(16, 13, palette.tintTip, [16], () => palette.tip) + cellAt(45, 13, palette.tintBand, [45], () => palette.band) + arrow(65, 25, 12, 0, palette.muted) + cellAt(104, 22, palette.paper, [96, 112], (c) => (c < 0 ? palette.tip : palette.band)) + arrow(132, 25, 12, 0, palette.muted) + cellAt(163, 15.5, palette.paper, [158, 168], (c, k) => (k < 0.5 ? palette.tip : palette.band))); } function osmosis() { // three solutions: water leaves, stays even, floods in const r = rng(9); const panel = (x, solutes, body, flow) => { let out = `<rect x="${x}" width="108" height="86" rx="4" fill="${palette.mist}"/>`; for (let k = 0; k < solutes; k++) { const [px, py] = [x + 5 + r() * 98, 5 + r() * 76]; if (Math.hypot(px - x - 54, py - 43) > 30) out += dot(px, py, 1.5, palette.muted); } for (let k = 0; k < 6; k++) { // arrows: the net flow of water, out (+1) or in (-1) const rad = (k * Math.PI) / 3 + 0.5; if (flow === 0 && k % 3) continue; const d = flow < 0 || (flow === 0 && k) ? 40 : 27; out += arrow(x + 54 + Math.cos(rad) * d, 43 + Math.sin(rad) * d, 10, (rad * 180) / Math.PI + (d > 30 ? 180 : 0), palette.tip); } return out + body; }; const at = (k, d) => [54 + Math.cos((k * Math.PI) / 11) * d, 43 + Math.sin((k * Math.PI) / 11) * d].map(n); const crenated = `M${at(0, 13)}${Array.from({ length: 22 }, (_, k) => (k % 2 ? '' : `Q${at(k + 1, 19)} ${at(k + 2, 13)}`)).join('')}Z`; // bumps: control points outside const disc = (x, rr) => dot(x + 54, 43, rr, palette.band) + dot(x + 54, 43, rr * 0.45, palette.tintBand); return svg(348, 86, panel(0, 70, shape(crenated, palette.band), 1) + panel(120, 34, disc(120, 17), 0) + panel(240, 8, disc(240, 23), -1)); } const drawings = { target, bulb, flask, caution, safety, check, cell, mosaic, fusion, osmosis }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Noto Serif': ['400', '400i', '700'], Lexend: ['700', '800'], // text, display, 'Barlow Semi Condensed': ['400', '600', '700'] }; // labels (gotcha: fonts-first) // ─── 4 · Build & show ─────────────────────────────────────────────────────── await Promise.all([loadFonts(FONTS, markdown), ...Object.entries(drawings).map(([id, draw]) => loadSvg(`${id}.svg`, draw()))]); // Folio 27 is odd like page 1, always a recto: parity follows the page (gotcha: parity-page1-recto) const continuation = { pageNumbering: { startAt: 27 }, headings: { h1: 1, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } }; // the next # is chapter 2 const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: t({ en: 'Living Matter, chapter 2', es: 'Materia viva, capítulo 2' }) });キット · 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上のレシピのフォルダー ↗ (新しいタブで開きます)
アレンジ
#囲みを両段にまたがらせる
フェンスにspan="page"を付けると、どの種類の囲みも両段にまたがって組まれます。BOX 2.1はさらに:::columnsのグループを入れ子にして、図を本文の横に置いています。
-:::callout{type="warning" title="Membrane or wall?"}
+:::callout{type="warning" title="Membrane or wall?" span="page"}#バッジをのど側に掛ける
'inner'はバッジをのど側、つまり奇数ページでは左、偶数ページでは右に移します。囲みの外に掛かる半分のために、その側に場所を空けておいてください。
- icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'outer' }) }),
+ icon: icon('caution', BADGE, { position: 'corner', cornerSide: 'inner' }) }),#タブを右の角に立てる
既定値の'top-right'はラベル全体を左右反転させます。タブは右の角に、フラスコはその左に移り、罫は左から伸びます。
- position: 'top-left', // this verso's outer corner, above the badge ('top-right' by default)
+ position: 'top-right',よくあるつまずき
つまずき
headingsオブジェクトを渡すとH1の改ページが消える
既定ではH1は奇数ページへ改ページします(always-odd)。ところがheadingsオブジェクトを渡すと中身にかかわらずこの既定がリセットされ、章は改ページせずに続けて組まれ、span: 'page'も効かなくなります。どの設定でもheadings.levels[0].breakBefore: { enabled: true, parity }を書き直してください。 奇数ページから始まる章 →
つまずき
行末不ぞろいのテキストでは、太字や:refと隣の約物の間で改行されることがある
postext 1.4.1では、両端そろえでないテキスト(囲みの本文、左そろえの段落)は、太字やイタリックの部分、あるいは:refと、それに接する約物の間で改行されることがあります。ピリオドが次の行の頭に来たり、参照の前の「(」が前の行の末尾に残ったりします。両端そろえのテキストではそこで改行されません。すべての版の囲みを読み、そうなった文は、その部分が行の途中に来るように書き換えてください。 太字・イタリックとその色 →
つまずき
ノーブレークスペースでも改行される
postext 1.4.1の改行処理はU+00A0を通常のスペースとして扱うため、0.08 %、2.006 s、Section 2などが2行に分かれることがあります。間を詰める(0.08%)か、文を書き換えてください。 エスケープとそのままの文字 →
つまずき
Fontsourceのlatinファイルにはラテン文字以外のグリフがない
PDFプロバイダーが埋め込むのはFontsourceのlatinファイルです。スペイン語や西欧語のテキストは収まりますが、→、≈、✓、★、ギリシャ文字、中欧語の文字は含まれず、PDFではそれらのグリフが欠けます。PDFのテキストはlatinの範囲に収めてください。 PDFに埋め込むフォント →
つまずき
SVGの<img>内のテキストはWebフォントを使えない
SVGは画像として描かれ、画像はページのWebフォントにアクセスできないため、ラベルはシステムの書体にフォールバックします。テキストをアウトライン化するか、SVGに@font-faceのサブセットを埋め込むか、ラベルをキャプションに移してください。 リソースとしての図と表 →
つまずき
'top'のフロートは参照したページには入らない
フロートが自分の参照より上に置かれることはありません。そのため、Nページで参照したページ幅の'top'フロートはN+1ページの頭に置かれます。もっと前で参照するか、参照したページの下端に入れる位置'auto'または'bottom'を使ってください。 図の配置 →
つまずき
:::columnsは囲みの中でしか効かず、分割されない
:::columnsは囲みの外では無視されます。また、囲みが分割されても、columnsグループの内部で切れることはありません。breaks属性は子ブロックを数え、入れ子の囲みは1つと数えます。 囲みの中の段組み →
つまずき
Figure/TableはdefaultResourceTypes(locale)でローカライズする
設定のlocaleが決めるのはハイフネーションで、キャプションではありません。resourceTypesがなければ、組み込みの種類は英語でFigureとTableと表示されます。スペイン語ならresourceTypes: defaultResourceTypes('es')を渡し、それ以外の言語ではresourceTypesに名前を自分で書いてください。 「図」と「表」を文書の言語で →
つまずき
ハイフネーションできるのは8つのロケールだけで、コードは完全一致
ハイフネーションが用意されているのはen-us、es、fr、de、it、pt、ca、nlで、コードは完全一致で照合されます。'es-ES'やほかの言語は、何の知らせもなくアメリカ英語にフォールバックします。 ハイフネーションと文書の言語 →
つまずき
レイアウトの前にすべてのフォントを読み込む
レイアウトはブラウザーが読み込んだフォントで文字を計測し、その幅をキャッシュします。最初のビルドのあとに届いたフォントがあると改行位置が狂い、PDFも画面と一致しなくなります。すべてのウェイトとスタイルを先に読み込み、遅れて届いたときは再ビルドの前にclearMeasurementCache()を呼んでください。 レイアウト前のフォント読み込み →
つまずき
デザインのテキストのoverflowの既定値は'ellipsis-end'
幅に収まらないデザインのテキスト要素は、既定では省略記号で終わります。複数行に折り返したいタイトルにはoverflow: 'wrap'を設定してください。 ページデザインのテキスト・罫・ボックス →
つまずき
1ページは奇数ページ。ページは物理的な番号で計画する
1ページは右ページで、2ページが最初の偶数ページです。見開きは物理的なページ番号で計画してください。偶数ページの章扉は、その次の奇数ページと向かい合います。 改ページと改段 →
つまずき
設定はオブジェクトの同一性でキャッシュされる。毎回新しいオブジェクトを作る
エンジンは解決済みの設定をオブジェクトの同一性でキャッシュします。そのため、設定をその場で書き換えて再ビルドすると前の結果が再利用されます。ビルドのたびに新しいオブジェクトを作ってください。レシピの設定がファクトリー関数config()になっているのはこのためです。 キャンバス上のページ →
レイアウト警告 · unknownCalloutType
不明な囲みの種類
原因. :::calloutが、calloutStylesに定義されていないtypeを指定しているため、何の知らせもなく最初のスタイルが使われます。
対処. スタイルを定義するか、type属性を修正してください。 ドキュメント →
- タブが刷られるのは、フェンスに
label="BOX 2.1"があり、スタイルにlabelオブジェクトがある場合だけです。labelオブジェクトのないスタイルはこの属性を無視します。 - Postextは囲みに番号を振りません。ラベルはフェンスに書いた文字列そのものなので、BOX 2.1、BOX 2.2と番号を一つひとつ手で打ちます。
- コーナーバッジは囲みの辺から幅の半分だけはみ出し、ページの外側では余白に掛かります。同じページのもう一方の段では段間に掛かるので、バッジ付きの囲みは外側の段に置くか、場所を空けておいてください。
- 囲みが箇条書きの記号と番号の色を変えるのは、その
lists.colorがunorderedLists.colorと異なる場合だけです。両方が同じマゼンタだと、囲みの中の番号付きリストは代わりにorderedLists.colorの色になります。この文書が自身の箇条書きの記号を墨で組んでいるのはそのためです。
クレジット
- 本文
- 書き下ろしの文章, CC BY 4.0
- フォント
- Noto Serif (SIL OFL 1.1) · Lexend (SIL OFL 1.1) · Barlow Semi Condensed (SIL OFL 1.1)


