باختصار
تحقيق سفر من ست صفحات في مجلة شهرية، منضّد في ثلاثة أعمدة ضيقة. يبيّن كيف تمتد الصور واقتباس كبير على عمودين أو على عرض الصفحة كله، وكيف يوضع صندوق نصائح عملية في عمود واحد.
ما الذي ستنضده
ست صفحات من عدد نوفمبر من Meridian، مجلة سفر شهرية، على صفحة 225 × 297 مم. يُفتتح التحقيق بصفحتين متقابلتين: صورة واحدة لملّاحات عند الشروق تمتد عليهما، والعنوان بالأبيض على الماء في الصفحة الزوجية، وتحت الصورة في الفردية الفقرة الأولى بحرفها الاستهلالي والموجز والتوقيع. ثم يجري النص في ثلاثة أعمدة مضبوطة عرض كل منها 61.7 مم. تأخذ الصور عمودين أو عرض الصفحة كله أو عمودًا واحدًا؛ ويقع اقتباس بارز على عمودين بين الفقرات؛ ويفتتح صندوق ملوّن فيه خريطة الجزيرة الصفحة الختامية. ويُختم النص بمربع أزرق صغير.
تجيب هذه الوصفة عن
- هل يستطيع Postext تنضيد ثلاثة أعمدة نصية أو أكثر، أو التفاف النص حول صورة؟
- كيف أتحكم في موضع الشكل: أعلى الصفحة، أو بعرض العمودين، أو هنا تمامًا، أو في الهامش؟
- كيف أجعل إطارًا يطفو إلى أعلى الصفحة أو أسفلها بينما يواصل النص تدفقه؟
- كيف أضيف إلى صفحة الافتتاح سطر المؤلف أو مقدمة تمهيدية أو فقرة أولى بحرف استهلالي؟
الجواب المختصر
const GUTTER = 5; // mm between the columns
const WIDTH = TRIM.width - MARGIN.inner - MARGIN.outer; // the text block, 195 mm
const COLUMN = (WIDTH - 2 * GUTTER) / 3; // 61.7 mm: about 40 characters of 9.6 pt text
const layout = { layoutType: 'multiple', columnCount: 3, gutterWidth: mm(GUTTER) };
// A float takes `columns` adjacent columns and the gutters between them: the head (or foot)
// of the first run of columns that are still empty, on its page or the next. As many columns
// as the page has is a page-wide float, like span: 'page'.
const across = {
raker: { position: 'top', columns: 2 }, // 128.3 mm wide, at the head of two columns
pans: { position: 'top', span: 'page' }, // 195 mm, the whole text block
flor: { position: 'bottom' }, // 61.7 mm: one column, the default
sieve: { position: 'top', columns: 2 }, // cited in the first column: heads the other two
quay: { position: 'top', columns: 2 }, // two columns again, on the closing page
};
// A floated box takes `columns` the same way: the style sets it, a fence may override it
// with :::callout{type="pull" columns="3"}. A box set in the flow ('here') keeps to its column.
const pull = { id: 'pull', placement: 'top', columns: 2, backgroundEnabled: false,
stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('sea') }, // one device
padding: { top: mm(3), right: pt(0), bottom: mm(1), left: pt(0) },
body: { fontFamily: 'Fraunces', fontSize: pt(19), lineHeight: pt(2 * LEAD - 3),
italic: true, textAlign: 'left', hyphenation: false, color: col('sea'),
firstLineIndent: pt(0) } };
const visit = { id: 'visit', placement: 'top', // one column, at the head of the next free one
background: col('tint'), padding: { top: mm(4), right: mm(4), bottom: mm(4), left: mm(4) },
titleStyle: { ...label, fontWeight: 700, fontSize: pt(8.5), letterSpacing: pt(1.4),
color: col('sea'), gap: mm(2) },
body: { fontFamily: 'Barlow Condensed', fontWeight: 500, fontSize: pt(9.6),
lineHeight: pt(12.4), textAlign: 'left', hyphenation: false, boldColor: col('sea'),
paragraphSpacing: true, firstLineIndent: pt(0) } };
المكونات
- الميزات
- ثلاثة أعمدة أو أكثرعناصر عائمة عبر عدة أعمدةإطارات عائمةإطارات التنبيهموضع الأشكالصفحات افتتاح مصمَّمةالصور في تصاميم الصفحةالحروف الاستهلالية في صفحات الافتتاحسمات العنوانأنماط العناوينالترويسات وأرقام الصفحاتالترويسات بحسب دور الصفحةنمط التعليقسطور المصدر والحقوقالشارات داخل السطرأنواع موارد مخصّصةالأشكال والجداول بوصفها مواردلوحة ألوان دلاليةهوامش متناظرة
- الخطوط
- Source Serif 4, Fraunces, Barlow Condensed (SIL OFL 1.1)
- الأصول
flor-1200.jpgpans-1840.jpgquay-960.jpgraker-1500.jpgsieve-1500.jpgspread-left-1200.jpgspread-right-1200.jpg- Sunrise on the salt pans (the opening spread, in two halves) (Generated With Diffusion Models, CC BY 4.0)
- Sunrise on the salt pans, the recto's half (Generated With Diffusion Models, CC BY 4.0)
- A rake drawing up wet salt (Generated With Diffusion Models, CC BY 4.0)
- The salt flats from above (Generated With Diffusion Models, CC BY 4.0)
- A salt maker with a basket of flor de sal (Generated With Diffusion Models, CC BY 4.0)
- Skimming flor de sal with a sieve (Generated With Diffusion Models, CC BY 4.0)
- Salt sacks on a harbour quay at dusk (Generated With Diffusion Models, CC BY 4.0)
- The sidebar's map of Marisal, drawn in code (Ignacio Ferro, MIT)
طريقة التحضير
#1 · قسّم الصفحة إلى ثلاثة أعمدة، ودع العناصر العائمة تأخذ عمودًا أو عمودين أو ثلاثة
الشيفرة في الجواب المختصر أعلاه. يقسم layoutType: 'multiple' مع columnCount: 3 صندوق النص البالغ 195 مم إلى ثلاثة أعمدة متساوية؛ ومع فاصل 5 مم يبلغ عرض كل عمود 61.7 مم، أي نحو 40 حرفًا من Source Serif بحجم 9.6 نقطة. يأخذ placement.columns في الصورة هذا العدد من الأعمدة المتجاورة والفواصل التي بينها: صورة المجرفة عرضها 128.3 مم، وصورة الملّاحات، مع span: 'page'، تأخذ 195 مم كاملة. ويفعل نمط الاقتباس الشيء نفسه للصندوق: columns: 2 في نمط له placement: 'top' يرفعه إلى رأس عمودين فارغين، ويجري النص من حوله. تناسب شبكة الأعمدة الثلاثة تحقيقًا كثير الصور، لأن كل صورة يمكن أن تأخذ عمودًا أو عمودين أو ثلاثة. أما النص الطويل القليل الصور فيُقرأ أفضل في عمودين أعرض.
#2 · افتتح بصفحتين متقابلتين وصورة واحدة تعبرهما
const PHOTO = 165; // mm: how far down the recto the right half of the photograph bleeds
// The verso is its own heading style: the left half of the picture fills the page, and the
// kicker and headline are reversed out of the dark water at its foot. The picture reserves
// nothing, so nothing is pushed off the page; the recto's heading opens the next page.
const spread = { id: 'spread', span: 'page', advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'photo', resourceId: 'spread-left', reserve: false,
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } },
{ kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(10),
letterSpacing: pt(2), color: col('paper'), // on a sea-blue tab: the water is too bright
box: { backgroundColor: col('sea'), padding: { top: mm(1.2), right: mm(2.4),
bottom: mm(1.2), left: mm(2.4) } },
placement: at('page', 'top-left', 20, 188) },
{ kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Fraunces',
fontWeight: 600, fontSize: pt(66), lineHeight: 0.96, // a multiple of the size
color: col('paper'), align: 'left', overflow: 'wrap', // gotcha: overflow-ellipsis-default
placement: at('#kicker', 'below', 0, 3, 150) },
] } } };
// The recto: the right half of the picture, cut to PHOTO mm, then the lead with its drop cap
// in the first column and the standfirst (the heading's own text) across the other two.
const air = { padding: { bottom: mm(6) } }; // empty padding counts: the columns start lower
const deck = { id: 'deck', span: 'page', advancedDesign: { enabled: true, slot: { elements: [
{ kind: 'image', id: 'photo', resourceId: 'spread-right', // cropped to 225 × 165 mm
placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(PHOTO) } } },
{ kind: 'text', id: 'credit', content: '{attr.credit}', ...label, fontWeight: 500,
fontSize: pt(6.8), letterSpacing: pt(0.5), color: col('muted'), align: 'right',
placement: at('container', 'top-right', 0, PHOTO - MARGIN.top + 2) },
{ kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'Source Serif 4',
fontSize: pt(9.6), lineHeight: LEAD / 9.6, color: col('ink'), align: 'justify',
hyphenate: true, box: air, placement: at('container', 'top-left', 0, PHOTO - 10, COLUMN),
dropCap: { lines: 4, fontFamily: 'Fraunces', fontWeight: 600, color: col('sea'),
gap: mm(1.6) } },
{ kind: 'text', id: 'standfirst', content: '{titleText}', fontFamily: 'Fraunces', italic: true,
fontSize: pt(16.5), lineHeight: 1.24, color: col('ink'), align: 'left', overflow: 'wrap',
placement: at('container', 'top-left', COLUMN + GUTTER, PHOTO - 11, 2 * COLUMN + GUTTER) },
{ kind: 'text', id: 'byline', content: '{attr.byline}', ...label, fontSize: pt(8.5),
letterSpacing: pt(1.5), color: col('sea'), box: air,
placement: at('#standfirst', 'below', 0, 4) },
] } } };
ينتمي كل نصف من الصورة إلى نمط عنوان له span: 'page'، والعنوان الممتد على الصفحة يفتح دائمًا صفحة جديدة، فتأتي صفحة العنوان وصفحة الموجز متتاليتين. يملأ النصف الأيسر الصفحة الزوجية ولا يحجز ارتفاعًا (reserve: false)، فيمكن أن يقع العنوان على الماء في أسفلها. ويُقص النصف الأيمن إلى 165 مم، وتتدلّى الفقرة الأولى والموجز والتوقيع من الحاوية تحته. الفقرة الأولى عنصر نص تصميمي فيه dropCap من أربعة أسطر بعرض عمود واحد؛ وحشوتها السفلية الفارغة تُحسب في ارتفاع الافتتاحية، فتبدأ الأعمدة تحتها بـ 6 مم. يجعل continuation.pageIndexOffset: 83 الصفحة 84 زوجية، فيتقابل نصفا الصورة.
#3 · صرّح بالصور، واستشهد بكل منها حيث يجب أن تقع
// No figure numbers: the pictures get a type with an empty caption prefix and template.
const photoType = { id: 'photo', name: 'Photograph', shortLabel: 'photo', captionPrefix: '',
numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' };
const CREDIT = 'Photograph generated with diffusion models';
// Pixels at the page's 150 dpi (gotcha: bitmap-print-size): each shrinks to its frame.
const photo = (id, [w, h], altText, caption) => ({ id, typeId: 'photo',
kind: 'bitmap', createdAt: 0, updatedAt: 0, altText,
bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
note: CREDIT, ...(caption && { caption, placement: across[id] }) });
const resources = [ // the two halves of the spread are drawn by the headings, never cited
photo('spread-left', [1200, 1584], 'Salt pans at sunrise, the sky mirrored in still brine.'),
photo('spread-right', [1200, 880], 'A salt raker at work below a row of white salt heaps.'),
photo('raker', [1500, 1125], 'A wooden rake pushing a ridge of wet salt crystals.',
'Raking the crystallisers at dawn. The salt rolls up in a ridge; a rake that digs in '
+ 'tears the clay floor and greys the harvest.'),
photo('pans', [1840, 1150], 'Rectangular salt pans in pink, ochre and grey by the sea.',
'The Alvarra flats from the lighthouse hill. The pink basins are nearly ready: an alga '
+ 'colours the brine once it holds more than 200 grams of salt a litre.'),
photo('flor', [1200, 1488], 'An older woman in a straw hat holding a basket of salt.',
'Amélia Sousa with an afternoon’s flor de sal, about four kilos from two pans.'),
photo('sieve', [1500, 1125], 'Hands skimming a crust of salt crystals with a round sieve.',
'Skimming the flower: the sieve works the downwind edge of the pan, where the breeze has '
+ 'drifted the crust.'),
photo('quay', [960, 1200], 'Sacks of salt on a harbour quay beside a blue boat at dusk.',
'Porto Velho, the last week of September: fifty-kilo sacks wait for the mainland boat.'),
{ id: 'map', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below
svg: { fileId: 'map.svg', width: MAP.width * 10, height: MAP.height * 10 },
placement: { position: 'here' }, // in the sidebar, under its title
caption: 'The Alvarra pans (pink) lie on the eastern flats, 6 km from Porto Velho (the '
+ 'squares) by the coast road. The lighthouse hill is the triangle; the ferry, dashed.',
altText: 'A map of a long, low island with salt pans at its eastern end and a harbour '
+ 'town at the west.' },
];
يذهب العنصر العائم إلى أول رأس (أو أسفل) فارغ لأعمدته بعد السطر الذي يستشهد به، فتحدّد أسطر ::resource الصفحات. يُستشهد بصورة الغربال في أعلى العمود الأول من الصفحة الخامسة، والعمودان الثاني والثالث ما زالا فارغين، فتتصدّرهما في الصفحة نفسها. ويُستشهد بصورة الميناء في آخر تلك الصفحة، فتتصدّر عمودين من الصفحة التالية. صور النوع الواحد لا يتجاوز بعضها بعضًا، فتبقى الاستشهادات بترتيب التحقيق. وتُقاس الصور النقطية بدقة الصفحة البالغة 150 نقطة في البوصة: 1500 بكسل تعطي 254 مم وتصغر إلى 128.3 مم لعمودين.
#4 · ترويسات وأرقام صفحات خارج صفحتي الافتتاح
const head = { ...label, fontSize: pt(7.5), letterSpacing: pt(1.3), color: col('muted') };
const folio = { fontFamily: 'Barlow Condensed', fontWeight: 700, fontSize: pt(9.5),
color: col('ink') };
const pin = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
// The label is 2 pt smaller, so it sits 0.68 mm lower in its box: one shared baseline.
const DROP = (0.8 * 1.2 * 2 * 25.4) / 72;
const header = { elements: sides.flatMap(([parity, edge, s]) => [
{ kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio, align: edge,
placement: pin(`top-${edge}`, s * MARGIN.outer, 12) },
{ kind: 'text', id: `head-${parity}`, ...head, align: edge,
content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
placement: pin(`top-${edge}`, s * (MARGIN.outer + 9), 12 + DROP) },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // none on the spread
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', ...folio,
align: 'right', parity: 'odd', pages: 'opener', // the recto of the spread: a folio at the foot
placement: pin('bottom-right', -MARGIN.outer, -12) }] };
تُرسم عناصر الترويسة فوق الصفحة ولا تحجز مكانًا، فيُثبَّت رقم الصفحة والعنوان القصير على الصفحة في الهامش العلوي. يُبعدهما pages: 'body' عن صفحتي الافتتاح، وهما صفحتا افتتاح لأن كلًا منهما تبدأ بعنوان ممتد على الصفحة. وتحمل الصفحة الفردية من الافتتاح رقمها في الأسفل عبر pages: 'opener' وparity: 'odd'؛ أما الزوجية، وهي صورة كلها، فلا تحمل رقمًا.
#5 · ارسم خريطة الصندوق الجانبي بالشيفرة
function mulberry32(seed) {
return () => {
seed = (seed + 0x6d2b79f5) | 0;
let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}
function islandMap() { // in mm: a long, low island lying south-west to north-east
const rand = mulberry32(135);
const f = (id, a = 1) => `fill="${palette[id]}"${a < 1 ? ` fill-opacity="${a}"` : ''}`;
const [cx, cy, rx, ry, turn] = [28, 27, 23, 9.5, -0.5]; // centre, half-axes, radians
const place = (u, v) => [cx + u * Math.cos(turn) - v * Math.sin(turn),
cy + u * Math.sin(turn) + v * Math.cos(turn)]; // island frame to map
const coast = Array.from({ length: 48 }, (_, i) => {
const a = (i / 48) * 2 * Math.PI;
const k = 0.9 + rand() * 0.16; // a ragged shore
return place(rx * k * Math.cos(a), ry * k * Math.sin(a)).map((n) => n.toFixed(2)).join(' ');
});
const pans = []; // a grid of basins on the eastern flats, inside the shore
for (let u = 6; u < 19; u += 2.4) {
for (let v = -5; v < 5; v += 1.9) {
if ((u / rx) ** 2 + (v / ry) ** 2 > 0.62) continue;
const [x, y] = place(u, v);
pans.push(`<rect x="${(x - 1).toFixed(2)}" y="${(y - 0.7).toFixed(2)}" width="2" `
+ `height="1.4" transform="rotate(${(turn * 180) / Math.PI} ${x.toFixed(2)} `
+ `${y.toFixed(2)})" ${f(rand() < 0.7 ? 'salt' : 'rule')}/>`);
}
}
const [tx, ty] = place(-18, 3); // Porto Velho, on the south-west shore
const town = [[0, 0], [1.3, 0.2], [0.4, 1.2], [1.7, 1.3], [-0.9, 0.9]].map(([dx, dy]) =>
`<rect x="${(tx + dx).toFixed(2)}" y="${(ty + dy).toFixed(2)}" width="0.9" height="0.9" `
+ `${f('ink')}/>`).join('');
const [lx, ly] = place(2, -6.5); // the lighthouse hill, on the north shore
const ferry = `M${tx} ${ty + 1.5}C${tx - 6} ${ty + 6} 6 46 1.5 48`; // to the mainland, south-west
return `<svg xmlns="http://www.w3.org/2000/svg" width="${MAP.width * 10}" `
+ `height="${MAP.height * 10}" viewBox="0 0 ${MAP.width} ${MAP.height}">`
+ `<rect width="${MAP.width}" height="${MAP.height}" ${f('sea', 0.16)}/>`
+ `<path d="M${coast.join('L')}Z" ${f('paper')} stroke="${palette.muted}" `
+ 'stroke-width="0.25"/>' + pans.join('') + town
+ `<path d="M${lx - 1.4} ${ly + 1.1}L${lx} ${ly - 1.3}L${lx + 1.4} ${ly + 1.1}Z" ${f('ink')}/>`
+ `<path d="${ferry}" fill="none" stroke="${palette.ink}" stroke-width="0.3" `
+ 'stroke-dasharray="1 0.8"/>'
+ `<path d="M0.4 46.4L1.5 48L2.9 47Z" ${f('ink')}/>` // the arrowhead, a path (no marker)
+ '</svg>';
}
الخريطة مورد SVG له placement: 'here'، يُستشهد به داخل سياج الصندوق، فتقع تحت عنوانه وبعرضه. ليس فيها كتابة: النص داخل SVG لا يستطيع استعمال خطوط الويب في الصفحة، فيسمّي التعليق الأشكال بدلًا منها. ورأس السهم على خط العبّارة مسار مرسوم، لأن <marker> لا يبقى عند التصدير إلى PDF.
الوصفة كاملة
// ═══ Postext Cookbook · Nº 135 · Magazine feature on a three-column grid ═════════ // https://postext.dev/en/cookbook/magazine-three-column-feature // Code: MIT · Text: original (CC BY 4.0) · Photographs: generated with diffusion models // Fonts: Source Serif 4, Fraunces, Barlow Condensed (SIL OFL 1.1) · Needs postext ≥ 1.18.0 // A travel feature from a monthly magazine, pages 84–89 of the issue. The story opens on a // spread, a photograph bled across both pages, then runs on in three columns: pictures across // two of them, one across the page and one in a single column, a pull quote floated across // two columns, a sidebar in one, running heads, folios and an end mark. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'en'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'magazine-three-column-feature'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: an Atlantic blue for the one accent, a salt tint for the sidebar const palette = { ink: '#1c2024', // text: a blue-black sea: '#1d5470', // the accent: kickers, crossheads, the quote, the drop cap tint: '#edf1f2', // the sidebar's ground rule: '#c5ced3', // the map's resting pans muted: '#5c656d', // running heads, credits, the colophon paper: '#ffffff', // the page, and the headline reversed out of the photograph salt: '#dfa79c', // the pans on the sidebar's map, the pink of ripe brine }; 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: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } }, ]; // #endregion const TRIM = { width: 225, height: 297 }; // a magazine trim, in mm const MARGIN = { top: 22, bottom: 20, inner: 16, outer: 14 }; // mirrored const LEAD = 14; // body leading in pt: the baseline grid const MAP = { width: 54, height: 50 }; // mm: the sidebar's map, as wide as its measure const label = { fontFamily: 'Barlow Condensed', fontWeight: 600, textTransform: 'uppercase', align: 'left' }; // design text is centred by default const at = (to, edge, x = 0, y = 0, width) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) }); // #region answer: three equal columns, and floats that take one, two or all three of them const GUTTER = 5; // mm between the columns const WIDTH = TRIM.width - MARGIN.inner - MARGIN.outer; // the text block, 195 mm const COLUMN = (WIDTH - 2 * GUTTER) / 3; // 61.7 mm: about 40 characters of 9.6 pt text const layout = { layoutType: 'multiple', columnCount: 3, gutterWidth: mm(GUTTER) }; // A float takes `columns` adjacent columns and the gutters between them: the head (or foot) // of the first run of columns that are still empty, on its page or the next. As many columns // as the page has is a page-wide float, like span: 'page'. const across = { raker: { position: 'top', columns: 2 }, // 128.3 mm wide, at the head of two columns pans: { position: 'top', span: 'page' }, // 195 mm, the whole text block flor: { position: 'bottom' }, // 61.7 mm: one column, the default sieve: { position: 'top', columns: 2 }, // cited in the first column: heads the other two quay: { position: 'top', columns: 2 }, // two columns again, on the closing page }; // A floated box takes `columns` the same way: the style sets it, a fence may override it // with :::callout{type="pull" columns="3"}. A box set in the flow ('here') keeps to its column. const pull = { id: 'pull', placement: 'top', columns: 2, backgroundEnabled: false, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('sea') }, // one device padding: { top: mm(3), right: pt(0), bottom: mm(1), left: pt(0) }, body: { fontFamily: 'Fraunces', fontSize: pt(19), lineHeight: pt(2 * LEAD - 3), italic: true, textAlign: 'left', hyphenation: false, color: col('sea'), firstLineIndent: pt(0) } }; const visit = { id: 'visit', placement: 'top', // one column, at the head of the next free one background: col('tint'), padding: { top: mm(4), right: mm(4), bottom: mm(4), left: mm(4) }, titleStyle: { ...label, fontWeight: 700, fontSize: pt(8.5), letterSpacing: pt(1.4), color: col('sea'), gap: mm(2) }, body: { fontFamily: 'Barlow Condensed', fontWeight: 500, fontSize: pt(9.6), lineHeight: pt(12.4), textAlign: 'left', hyphenation: false, boldColor: col('sea'), paragraphSpacing: true, firstLineIndent: pt(0) } }; // #endregion // #region spread: one photograph across the opening spread, the headline on the verso const PHOTO = 165; // mm: how far down the recto the right half of the photograph bleeds // The verso is its own heading style: the left half of the picture fills the page, and the // kicker and headline are reversed out of the dark water at its foot. The picture reserves // nothing, so nothing is pushed off the page; the recto's heading opens the next page. const spread = { id: 'spread', span: 'page', advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'photo', resourceId: 'spread-left', reserve: false, placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: 'fill' } } }, { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...label, fontSize: pt(10), letterSpacing: pt(2), color: col('paper'), // on a sea-blue tab: the water is too bright box: { backgroundColor: col('sea'), padding: { top: mm(1.2), right: mm(2.4), bottom: mm(1.2), left: mm(2.4) } }, placement: at('page', 'top-left', 20, 188) }, { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Fraunces', fontWeight: 600, fontSize: pt(66), lineHeight: 0.96, // a multiple of the size color: col('paper'), align: 'left', overflow: 'wrap', // gotcha: overflow-ellipsis-default placement: at('#kicker', 'below', 0, 3, 150) }, ] } } }; // The recto: the right half of the picture, cut to PHOTO mm, then the lead with its drop cap // in the first column and the standfirst (the heading's own text) across the other two. const air = { padding: { bottom: mm(6) } }; // empty padding counts: the columns start lower const deck = { id: 'deck', span: 'page', advancedDesign: { enabled: true, slot: { elements: [ { kind: 'image', id: 'photo', resourceId: 'spread-right', // cropped to 225 × 165 mm placement: { ...at('bleed', 'top-left'), size: { width: 'fill', height: mm(PHOTO) } } }, { kind: 'text', id: 'credit', content: '{attr.credit}', ...label, fontWeight: 500, fontSize: pt(6.8), letterSpacing: pt(0.5), color: col('muted'), align: 'right', placement: at('container', 'top-right', 0, PHOTO - MARGIN.top + 2) }, { kind: 'text', id: 'lead', content: '{attr.lead}', fontFamily: 'Source Serif 4', fontSize: pt(9.6), lineHeight: LEAD / 9.6, color: col('ink'), align: 'justify', hyphenate: true, box: air, placement: at('container', 'top-left', 0, PHOTO - 10, COLUMN), dropCap: { lines: 4, fontFamily: 'Fraunces', fontWeight: 600, color: col('sea'), gap: mm(1.6) } }, { kind: 'text', id: 'standfirst', content: '{titleText}', fontFamily: 'Fraunces', italic: true, fontSize: pt(16.5), lineHeight: 1.24, color: col('ink'), align: 'left', overflow: 'wrap', placement: at('container', 'top-left', COLUMN + GUTTER, PHOTO - 11, 2 * COLUMN + GUTTER) }, { kind: 'text', id: 'byline', content: '{attr.byline}', ...label, fontSize: pt(8.5), letterSpacing: pt(1.5), color: col('sea'), box: air, placement: at('#standfirst', 'below', 0, 4) }, ] } } }; // #endregion // #region heads: the magazine and the issue on the verso, the story on the recto const head = { ...label, fontSize: pt(7.5), letterSpacing: pt(1.3), color: col('muted') }; const folio = { fontFamily: 'Barlow Condensed', fontWeight: 700, fontSize: pt(9.5), color: col('ink') }; const pin = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } }); const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left // The label is 2 pt smaller, so it sits 0.68 mm lower in its box: one shared baseline. const DROP = (0.8 * 1.2 * 2 * 25.4) / 72; const header = { elements: sides.flatMap(([parity, edge, s]) => [ { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio, align: edge, placement: pin(`top-${edge}`, s * MARGIN.outer, 12) }, { kind: 'text', id: `head-${parity}`, ...head, align: edge, content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}', placement: pin(`top-${edge}`, s * (MARGIN.outer + 9), 12 + DROP) }, ].map((element) => ({ ...element, parity, pages: 'body' }))) }; // none on the spread const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', ...folio, align: 'right', parity: 'odd', pages: 'opener', // the recto of the spread: a folio at the foot placement: pin('bottom-right', -MARGIN.outer, -12) }] }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object locale: 'en-us', // an exact hyphenation code (gotcha: hyphenation-locales) colorPalette, resourceTypes: [photoType], page: { width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left is the inner side layout, bodyText: { fontFamily: 'Source Serif 4', fontSize: pt(9.6), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(3), indentAfterHeading: false, // A 40-character measure: a line no break can set within twice the normal space takes up // to 0.015 em of tracking for the rest instead of spreading its spaces wider still. minWordSpacing: 0.7, maxJustifyTracking: 15 }, headings: { fontFamily: 'Fraunces', fontWeight: 600, color: col('sea'), levels: [ // Restated (gotcha: headings-drop-h1-break): the spread opens on the next page. { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } }, { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, // crossheads, one grid line above ] }, headingStyles: [spread, deck], calloutStyles: [pull, visit], chipStyles: [{ id: 'end', background: col('sea'), borderWidth: pt(0), borderRadius: pt(0), fontSize: em(0.6), paddingX: em(0.5), paddingY: em(0), gap: em(0.6) }], // a square captionStyle: { fontFamily: 'Barlow Condensed', fontWeight: 500, fontSize: pt(8.6), lineHeight: pt(10.6), color: col('ink'), gap: mm(2), note: { fontSize: pt(7), color: col('muted'), gap: mm(0.5) } }, paragraphStyles: [{ id: 'colophon', fontFamily: 'Barlow Condensed', fontWeight: 500, fontSize: pt(7.4), lineHeight: pt(9.6), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }], header, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── // #region resources: the photographs, declared once by their real pixels // No figure numbers: the pictures get a type with an empty caption prefix and template. const photoType = { id: 'photo', name: 'Photograph', shortLabel: 'photo', captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; const CREDIT = 'Photograph generated with diffusion models'; // Pixels at the page's 150 dpi (gotcha: bitmap-print-size): each shrinks to its frame. const photo = (id, [w, h], altText, caption) => ({ id, typeId: 'photo', kind: 'bitmap', createdAt: 0, updatedAt: 0, altText, bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h }, note: CREDIT, ...(caption && { caption, placement: across[id] }) }); const resources = [ // the two halves of the spread are drawn by the headings, never cited photo('spread-left', [1200, 1584], 'Salt pans at sunrise, the sky mirrored in still brine.'), photo('spread-right', [1200, 880], 'A salt raker at work below a row of white salt heaps.'), photo('raker', [1500, 1125], 'A wooden rake pushing a ridge of wet salt crystals.', 'Raking the crystallisers at dawn. The salt rolls up in a ridge; a rake that digs in ' + 'tears the clay floor and greys the harvest.'), photo('pans', [1840, 1150], 'Rectangular salt pans in pink, ochre and grey by the sea.', 'The Alvarra flats from the lighthouse hill. The pink basins are nearly ready: an alga ' + 'colours the brine once it holds more than 200 grams of salt a litre.'), photo('flor', [1200, 1488], 'An older woman in a straw hat holding a basket of salt.', 'Amélia Sousa with an afternoon’s flor de sal, about four kilos from two pans.'), photo('sieve', [1500, 1125], 'Hands skimming a crust of salt crystals with a round sieve.', 'Skimming the flower: the sieve works the downwind edge of the pan, where the breeze has ' + 'drifted the crust.'), photo('quay', [960, 1200], 'Sacks of salt on a harbour quay beside a blue boat at dusk.', 'Porto Velho, the last week of September: fifty-kilo sacks wait for the mainland boat.'), { id: 'map', typeId: 'photo', kind: 'svg', createdAt: 0, updatedAt: 0, // drawn below svg: { fileId: 'map.svg', width: MAP.width * 10, height: MAP.height * 10 }, placement: { position: 'here' }, // in the sidebar, under its title caption: 'The Alvarra pans (pink) lie on the eastern flats, 6 km from Porto Velho (the ' + 'squares) by the coast road. The lighthouse hill is the triangle; the ferry, dashed.', altText: 'A map of a long, low island with salt pans at its eastern end and a harbour ' + 'town at the west.' }, ]; // #endregion const markdown = String.raw`---نموذج Markdown · أسطر: 97 · content.en.md
title: "Meridian" subtitle: "November 2026" --- # The Salt Rakers of Marisal {style="spread" kicker="Islands · The Atlantic"} ## On a flat island at the edge of the Atlantic, nine families still make salt by hand, with sun, wind and a wooden rake. Their season lasts eleven weeks, and every year it starts a little later {style="deck" byline="Words by Marta Quintal" credit="Sunrise on the Alvarra pans · Photographs generated with diffusion models" lead="At five in the morning the brine in the Alvarra pans is the colour of pewter, and João Brás is already standing in it up to his ankles. He works the way his father taught him, walking backwards down the pan and drawing the rake towards him in long, even strokes, so that the salt rolls up into a ridge without tearing the clay floor beneath it."} Brás is fifty-eight. He has raked these pans every summer since he was eleven, except for two years of military service and one summer, in 1994, when he broke his wrist falling from the roof of the salt store. The pan he is working this morning measures nine metres by twenty-two, and by half past seven he will have drawn about four hundred kilos of salt into a white ridge along its edge. Then the sun will be too high, and the salt too bright to look at. Marisal is fourteen kilometres long and nowhere higher than a two-storey house. It has no river and almost no trees, and the wind from the north-east blows on two days out of three between June and September. For most of its history the island had nothing to sell except what the sea and that wind could make together. The first salt works were dug on the eastern flats in the 1680s. At their peak, in the years before the Second World War, more than six hundred pans were worked on the island, by some 140 families. The decline came quickly. In the 1960s a plant on the mainland began to make salt by boiling brine under vacuum, cheaply and all year round, and the canneries that had bought Marisal’s salt for two centuries switched to it within a decade. Young men left for the merchant fleet and the building sites of the north. The pans they left behind silted up in a few winters, and their clay walls slumped into the brine like sandcastles after a tide. ## Water, sun and wind The method has not changed in three centuries. At high tide, sea water flows through a sluice into a large reservoir, the *viveiro*, where it settles for a few days and drops its sand. From there it is let down, a few centimetres at a time, through a chain of ever shallower basins. In each one the sun and the wind take a little more of the water, and the brine grows heavier. The basins are laid out so that the water never has to be lifted: the whole works falls about forty centimetres from the sluice to the last pan, and gravity does the rest. Sea water holds about 35 grams of salt in every litre. By the time it reaches the last basins, the crystallisers, it holds more than 250, and salt begins to form on the surface like frost on a window. The whole passage takes about three weeks. The salt makers measure it with a glass hydrometer that floats higher as the brine thickens, but most of them can judge it without one. “You can see it in the colour,” says Brás. “It goes pink first, then a sort of grey. When it shines like oil, the salt is coming.” The pink comes from a single-celled alga that thrives in water too salty for anything else. It is harmless, and it gives the pans their colours when you see them from the lighthouse hill. ::resource{id="raker"} The skill lies in the water level. Too deep, and the salt forms slowly, in small grains; too shallow, and it crusts over with the clay. A salt maker opens and closes the little wooden gates between his basins several times a day, by hand or with the side of his foot. Brás has 46 gates on his eleven pans, and he can tell you the height of the water behind each one without looking. At noon the rakers go home to sleep, and the flats belong to the wind. Brás eats with his wife under the vine at the back of the house: grilled mackerel, bread, a tomato cut in half and rubbed with the coarse grey salt from the bottom of the heap, the salt nobody buys. In the evening he walks back down to the pans to check the gates, and on still nights he sometimes stays until dark, sitting on the wall of the reservoir and listening to the brine tick as the crystals form. ## Eleven weeks The harvest runs from early July to the middle of September, when the first autumn rain usually arrives. It used to begin in June. Over the past twenty years the records of the salt makers’ cooperative show the first raking moving back by about nine days, because late spring storms now fill the basins with rainwater just when they should be concentrating. A single heavy shower in August can dissolve a week’s salt in an hour. The rakers watch the forecast on their phones, and on uncertain evenings they draw the salt into heaps and cover them with plastic sheets held down by stones. ::resource{id="pans"} The work is heavy and short. Raking starts before dawn, while the salt is cool and the light is low enough to show the ridges. By nine the crystallisers glare white and the air above them shimmers. Each pan is raked every three or four days. The wet salt is shovelled into wooden wheelbarrows and pushed along the clay walkways to the heaps at the end of each row, where it drains and dries for two weeks before it goes into the store. The store at the end of the flats was built in 1911, a long whitewashed shed with no windows and a floor of beaten earth. Inside it smells of seaweed and tar, and by the end of September the heaps rise to the roof beams. Nothing made of metal is kept there: in a year the salt eats a nail down to a rust stain in the wood. :::callout{type="pull"} The sea gives you the water. The sun and the wind do the work. All we do is stop it running away. ::: A good pan gives about 60 tonnes of coarse salt in a season. Sold in bulk to the canning factory on the mainland, it brings the salt maker around 90 euros a tonne, less than the diesel it takes to ship it. By the late 1990s fewer than twenty families were still working pans, and most of the eastern flats had been left to the herons. ## The flower of the salt What saved the pans was not the coarse salt. On hot, still afternoons a thin crust forms on the surface of the crystallisers, made of flat, brittle crystals that float on the brine like ice on a pond. This is the *flor de sal*, the flower of the salt. It is skimmed off by hand with a wide, fine-meshed sieve on a long handle, a few millimetres at a time, before the evening wind breaks it up and sinks it. The skimmer works along the downwind edge of the pan, where the breeze has pushed the crust into a drift, and lifts it in shallow passes that barely touch the water. Lift too deep and the sieve brings up brine and clay with the crystals; too slowly, and the crust folds and sinks before it reaches the mesh. A pan gives only one or two per cent of its harvest as flower, and it has to be gathered on every day that it appears. On Marisal it was always women’s work, and for generations it was not sold at all. Families kept it for the table and gave it as a present at weddings. Amélia Sousa, who is seventy-four, has skimmed the pans behind her house every summer since 1962. “My mother called it the salt for visitors,” she says. “The coarse salt was for the fish. The flower was kept for the days when the priest came to eat with us.” Chefs on the mainland describe the flower in the language of wine. The crystals are hollow pyramids a few millimetres across, and they break between the teeth instead of dissolving on the tongue. It tastes less sharply of salt than the coarse grain, because it holds a little more of the sea’s magnesium and calcium, and it is never cooked with: a pinch goes on at the table, over a fried egg or a slice of tomato. ::resource{id="sieve"} ::resource{id="flor"} In 2011 nine families formed a cooperative, the Salineiros de Alvarra, and began to sell the flower in small tins under their own name. A 250-gram tin sells for six euros on the island and for three times that in city delicatessens. The flower now brings in more than the rest of the harvest put together. It has paid for a new sluice, a shared drying shed and the repair of 38 abandoned pans, and this year, for the first time since 1971, the cooperative turned buyers away in August. ## The herons’ share Not everyone welcomed the restoration. By 2010 the abandoned pans on the eastern flats had become one of the largest resting places for wading birds on the island’s side of the ocean: black-winged stilts, avocets, little egrets and, every autumn, a few hundred flamingos on their way south. The birdwatchers who came to count them were the first visitors Marisal had had in years, and they were not pleased to see the walls rebuilt. The answer was a map. The cooperative and the island’s naturalists’ society agreed to leave twenty-two of the old pans flooded all year as a refuge, to keep the working pans free of plastic and lime, and to stop raking near the nesting walls between April and June. The birds seem content with the arrangement. Stilts now nest on the working walls as well, and the rakers step round their eggs. Each September the society’s volunteers count the birds from the lighthouse hill, as they have since 1998. Last year they recorded 41 species on the flats in a single morning, among them a spoonbill ringed as a chick on the far side of the ocean. ## Nine families The cooperative has eleven members under forty. One of them is Rita Brás, João’s niece, who studied marine biology on the mainland and came back in 2019 to work four pans her grandfather had abandoned in 1987. It took her two winters to rebuild their clay walls and floors, treading the wet clay flat with her bare feet as the old salt makers did. She logs the salinity of every basin three times a day and shares the figures with a university group that studies salt marshes. “My uncle thinks I am measuring what he already knows,” she says. “He is right. But when he stops, someone will have to know it from the numbers instead.” Rita’s pans are the smallest on the flats, and the most watched. She has fitted one of them with a floating thermometer and a camera on a pole, and every evening she posts its picture for the cooperative’s customers, who can watch the crust form on brine they will eat a year later. Orders from the mainland doubled in the first summer she did it. The older members were doubtful at first. Now they come round after supper to look at the screen with her and argue about the colour of the water. ::resource{id="quay"} :::callout{type="visit" title="Visiting Marisal"} ::resource{id="map"} **Getting there** The ferry from the mainland takes 2 hours 40 minutes and sails daily from June to September, three times a week in winter. **Getting round** Bicycles for hire at the ferry quay, €12 a day. The pans are six kilometres from Porto Velho by the coast road, which is flat all the way. **When to go** The harvest runs from July to mid-September. The cooperative shows visitors round the pans on weekday mornings at eight; book a day ahead. **Where to stay** Three guesthouses in Porto Velho, from €65 a night. Bring a hat: there is no shade on the flats. **Where to eat** Casa do Moinho, on the harbour, grills the morning’s mackerel and serves it with the coarse grey salt. Lunch for two, about €38. **What to bring home** Flor de sal in 250 g tins, €6 at the cooperative’s shed by the sluice. ::: The old salt makers are not sentimental about their trade. Brás will tell you that his back hurts from June to October and that he would not wish the work on his sons, who drive taxis in the island’s one town. He also says that he has never had a better morning than the first raking of a good year, when the salt comes up under the rake in a single unbroken roll and the whole pan smells of the sea. On the last Saturday of the season the cooperative weighs the year’s harvest on the old platform scale in the salt store, sack by sack, with a notebook and a pencil. Last year it came to 1,860 tonnes of coarse salt and a little under eleven tonnes of flower, the best year since the cooperative began. The figure is chalked on the door of the store, where it stays until the rain washes it off, some time in October. At the end of September the salt goes down to the quay at Porto Velho in linen sacks of fifty kilos each, and the last boat of the season carries it to the mainland. The pans are flooded with sea water for the winter, to protect the clay from the rain and the frost. By November, when the rakers start mending their gates, the flats look like a lake again, and the herons are back on the walls, waiting. :chip[]{style="end"} :::paragraphs{style="colophon"} Marisal, the Alvarra pans and everyone in this feature are fictional · Set in Source Serif 4, Fraunces and Barlow Condensed (SIL Open Font License) · Text: CC BY 4.0 · Photographs generated with diffusion models :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the sidebar's map of Marisal, drawn in code with a seeded PRNG function mulberry32(seed) { return () => { seed = (seed + 0x6d2b79f5) | 0; let r = Math.imul(seed ^ (seed >>> 15), 1 | seed); r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r; return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; } function islandMap() { // in mm: a long, low island lying south-west to north-east const rand = mulberry32(135); const f = (id, a = 1) => `fill="${palette[id]}"${a < 1 ? ` fill-opacity="${a}"` : ''}`; const [cx, cy, rx, ry, turn] = [28, 27, 23, 9.5, -0.5]; // centre, half-axes, radians const place = (u, v) => [cx + u * Math.cos(turn) - v * Math.sin(turn), cy + u * Math.sin(turn) + v * Math.cos(turn)]; // island frame to map const coast = Array.from({ length: 48 }, (_, i) => { const a = (i / 48) * 2 * Math.PI; const k = 0.9 + rand() * 0.16; // a ragged shore return place(rx * k * Math.cos(a), ry * k * Math.sin(a)).map((n) => n.toFixed(2)).join(' '); }); const pans = []; // a grid of basins on the eastern flats, inside the shore for (let u = 6; u < 19; u += 2.4) { for (let v = -5; v < 5; v += 1.9) { if ((u / rx) ** 2 + (v / ry) ** 2 > 0.62) continue; const [x, y] = place(u, v); pans.push(`<rect x="${(x - 1).toFixed(2)}" y="${(y - 0.7).toFixed(2)}" width="2" ` + `height="1.4" transform="rotate(${(turn * 180) / Math.PI} ${x.toFixed(2)} ` + `${y.toFixed(2)})" ${f(rand() < 0.7 ? 'salt' : 'rule')}/>`); } } const [tx, ty] = place(-18, 3); // Porto Velho, on the south-west shore const town = [[0, 0], [1.3, 0.2], [0.4, 1.2], [1.7, 1.3], [-0.9, 0.9]].map(([dx, dy]) => `<rect x="${(tx + dx).toFixed(2)}" y="${(ty + dy).toFixed(2)}" width="0.9" height="0.9" ` + `${f('ink')}/>`).join(''); const [lx, ly] = place(2, -6.5); // the lighthouse hill, on the north shore const ferry = `M${tx} ${ty + 1.5}C${tx - 6} ${ty + 6} 6 46 1.5 48`; // to the mainland, south-west return `<svg xmlns="http://www.w3.org/2000/svg" width="${MAP.width * 10}" ` + `height="${MAP.height * 10}" viewBox="0 0 ${MAP.width} ${MAP.height}">` + `<rect width="${MAP.width}" height="${MAP.height}" ${f('sea', 0.16)}/>` + `<path d="M${coast.join('L')}Z" ${f('paper')} stroke="${palette.muted}" ` + 'stroke-width="0.25"/>' + pans.join('') + town + `<path d="M${lx - 1.4} ${ly + 1.1}L${lx} ${ly - 1.3}L${lx + 1.4} ${ly + 1.1}Z" ${f('ink')}/>` + `<path d="${ferry}" fill="none" stroke="${palette.ink}" stroke-width="0.3" ` + 'stroke-dasharray="1 0.8"/>' + `<path d="M0.4 46.4L1.5 48L2.9 47Z" ${f('ink')}/>` // the arrowhead, a path (no marker) + '</svg>'; } // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) 'Source Serif 4': ['400', '400i', '600'], Fraunces: ['400i', '600'], 'Barlow Condensed': ['500', '600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all([...resources.filter((r) => r.bitmap).map(({ bitmap }) => loadImage(bitmap.fileId, asset(bitmap.fileId))), loadSvg('map.svg', islandMap())]); // Pages 84–89 of the issue: page 84 is a verso, so the opener lies open as a spread. const continuation = { pageIndexOffset: 83, pageNumbering: { startAt: 84 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showPages(doc, { title: 'Magazine feature on a three-column grid' });العُدّة · 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 المجمّع كما هو: الصقه في سكربت الوحدة (module) لأي صفحة، أو افتح الوصفة على CodePen. مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)
تنويعات
#مدّ الاقتباس على الأعمدة الثلاثة
يأخذ الصندوق العائم سمة columns من السياج قبل قيمة نمطه، فيمكن لاقتباس واحد أن يأخذ عرض الصفحة كله.
- :::callout{type="pull"}
+ :::callout{type="pull" columns="3"}#نضّد التحقيق في عمودين
لتحقيق أقل صورًا وأطول فقرات، انظر تحقيق مجلة: من صورة الافتتاح إلى علامة النهاية، بتخطيط double.
أخطاء شائعة
خطأ شائع
العنصر العائم 'top' لا يقع أبدًا في صفحة الإحالة إليه
لا يصعد العنصر العائم أبدًا فوق الإحالة إليه، فالعنصر العائم 'top' الممتد بعرض الصفحة والمُحال إليه في الصفحة N يفتتح الصفحة N+1. أحِل إليه في موضع أبكر، أو استخدم الموضع 'auto' أو 'bottom' الذي يمكنه أن يشغل أسفل صفحة الإحالة. موضع الأشكال →
خطأ شائع
صور الافتتاحية لا تُحتسب أبدًا في الارتفاع الذي تحجزه
في postext 1.4.1 يقيس العنوان ذو التصميم المتقدّم الارتفاعَ الذي يحجزه دون صوره: تُحتسب نصوصه وخطوطه وإطاراته، حتى المثبّتة على الصفحة، أما الصورة، كصورة تمتد بالنزف عبر رأس الصفحة، فلا تحجز شيئًا، فقد يبدأ النص فوقها. اضبط minHeight على الموضع الذي يجب أن يبدأ فيه النص. صفحات افتتاح مصمَّمة →
خطأ شائع
العنوان الممتد بعرض الصفحة يفتح صفحة جديدة دائمًا
العنوان الذي يضبط مستواه أو نمطه span: 'page' يُوزَّع افتتاحيةً في رأس صفحة، سواء كان له breakBefore أم لا. فإذا وُضع بعد نص آخر، أو بعد افتتاحية أخرى في الصفحة نفسها، انتقل إلى الصفحة التالية وترك بقية الصفحة الحالية فارغة. العنوان الرئيسي الممتد عبر العمودين (أو عبر طبقتي الصفحة العمودية) تحت ترويسة الصحيفة يدخل في تصميم الافتتاحية نفسه، بجانب ترويسة الصحيفة، أو في إطار :::callout{span="page"} عنوانًا بلا span، كما تضع الوصفة newspaper-front-page عناوينها الرئيسية. صفحات افتتاح مصمَّمة →
خطأ شائع
الصفحة 1 فردية: خطّط الصفحات بأرقامها الفعلية
الصفحة 1 صفحة يمنى والصفحة 2 أول صفحة زوجية، فخطّط الصفحتين المتقابلتين بأرقام الصفحات الفعلية: الافتتاحية في صفحة زوجية تقابل الصفحة الفردية التي تليها. فواصل الصفحات والأعمدة →
خطأ شائع
الصور النقطية تُوزَّع بالبكسل بدقة dpi المستند: صرّح بمقاس الطباعة
يُحدَّد مقاس مورد الصورة النقطية من العرض والارتفاع اللذين يصرّح بهما، بالبكسل بدقة dpi المستند، لا من الملف. صرّح بعدد البكسلات المناسب للطباعة (نحو 300 dpi بالعرض المطبوع) ليقع الشكل بالمقاس الصحيح ويبقى حادًّا. الأشكال والجداول بوصفها موارد →
خطأ شائع
الشارة المكوّنة من مسافات فقط تطبع ترميزها
الشارة التي لا يحتوي نصها إلا مسافات، بما فيها المسافات غير القابلة للكسر، تطبع :chip[ ] حرفيًا. لشارة إجابة فارغة ضع داخلها رابط الكلمات (U+2060). الشارات داخل السطر →
خطأ شائع
أي كائن headings يُلغي فاصل الصفحة قبل H1
ينتقل H1 افتراضيًا إلى صفحة فردية (always-odd)، لكن تمرير أي كائن headings يعيد ضبط هذا الافتراض، فتتوالى الفصول دون فاصل ولا يفعل span: 'page' شيئًا. أعد كتابة headings.levels[0].breakBefore: { enabled: true, parity } في كل إعداد. فصول تبدأ في صفحة فردية →
خطأ شائع
lineHeight لنص التصميم مُضاعِف، لا بُعد أبدًا
في خانة التصميم، يضاعف lineHeight لعنصر النص مقاس خطه (lineHeight: 1.05). في postext 1.4.1 لا يُرفض بُعدٌ مثل pt(15): يُقاس ارتفاع الافتتاحية NaN، ويسقط ما تحجزه من مساحة، بما فيه minHeight، دون تحذير، فيجري النص تحت العنوان. النصوص والخطوط والمربعات في تصاميم الصفحة →
خطأ شائع
فيض نص التصميم افتراضيًا 'ellipsis-end'
عنصر نص التصميم الذي لا يتسع له عرضه ينتهي افتراضيًا بعلامة الحذف. اضبط overflow: 'wrap' للعناوين التي يجب أن تنكسر على أسطر أكثر. النصوص والخطوط والمربعات في تصاميم الصفحة →
خطأ شائع
8 لغات فقط تُقسَّم بالواصلة، بالرمز المطابق تمامًا
يتوفر تقسيم الكلمات بالواصلة للغات en-us وes وfr وde وit وpt وca وnl، بمطابقة تامة للرمز: 'es-ES' أو أي لغة أخرى تعود دون تنبيه إلى الإنجليزية الأمريكية. تقسيم الكلمات ولغة المستند →
خطأ شائع
حمّل كل أوجه الخط قبل الإخراج
يقيس الإخراج النص بأوجه الخط التي حمّلها المتصفح ويخزّن العروض مؤقتًا، فالوجه الذي يصل بعد البناء الأول يترك فواصل أسطر خاطئة وملف PDF لم يعد يطابق الشاشة. حمّل كل وزن وكل نمط أولًا، واستدعِ clearMeasurementCache() قبل إعادة البناء إذا تأخر وصول أحدها. الخطوط قبل الإخراج →
- الصورة الممتدة على عمودين تتصدّر عمودين ما زالا فارغين. إذا استُشهد بها في العمود الثاني لم تجد هذا الزوج في صفحتها، إذ لم يبقَ إلا العمود الثالث، فتنتظر الصفحة التالية.
- ضُبط التحقيق لينتهي على ارتفاع واحد في الصفحة السادسة تحت صورة الميناء. بعد أي تعديل تحقّق من أن الصفحة الأخيرة ما زالت ممتلئة: بأسطر أقل تنتقل الصورة إلى صفحة وحدها.
- في أسطر من 40 حرفًا تقلّ مواضع الكسر في الفقرة المضبوطة. يسمح
maxJustifyTracking: 15للسطر الذي لا يتسع بضعف المسافة العادية بأن يأخذ حتى 0.015 em من التباعد بين الحروف. أما السطر الأخير ذو الكلمة الواحدة فيُصلح بتعديل النص، كما في هذه الصفحات.
الحقوق
- الوصفة
- Ignacio Ferro
- النص
- نص أصلي, CC BY 4.0
- الصور
- Sunrise on the salt pans (the opening spread, in two halves) · Generated With Diffusion Models · CC BY 4.0
- Sunrise on the salt pans, the recto's half · Generated With Diffusion Models · CC BY 4.0
- A rake drawing up wet salt · Generated With Diffusion Models · CC BY 4.0
- The salt flats from above · Generated With Diffusion Models · CC BY 4.0
- A salt maker with a basket of flor de sal · Generated With Diffusion Models · CC BY 4.0
- Skimming flor de sal with a sieve · Generated With Diffusion Models · CC BY 4.0
- Salt sacks on a harbour quay at dusk · Generated With Diffusion Models · CC BY 4.0
- The sidebar's map of Marisal, drawn in code · Ignacio Ferro · MIT
- الخطوط
- Source Serif 4 (SIL OFL 1.1) · Fraunces (SIL OFL 1.1) · Barlow Condensed (SIL OFL 1.1)
- الكود
- MIT، مثل Postext
حرّر هذا الشرح ↗ (يفتح في تبويب جديد)مجلد الوصفة على GitHub ↗ (يفتح في تبويب جديد)


