In short
Two pages of a newspaper's comics section: a large colour comic on the cover, then an article about the cartoonist in four columns with the daily four-panel strip along the bottom.
What you'll build
The comics pull-out of a weekend newspaper, The Corrington Despatch, folded to 230 × 310 mm. The cover holds the Sunday half page of Pip & Otto, a penguin and a walrus who run a bakery on an ice floe, with the contents under it. Inside, an interview with the strip's cartoonist runs in four columns, with readers' letters and an advert, and the week's daily strip, four square panels, runs along the foot of the page. Both comics are :::strip blocks in the newspaper's text, and the pull-out comes in six languages.
This recipe answers
- How do I put a comic strip on a newspaper page?
The short answer
// In the Markdown each strip is a fenced block, where the paper wants it:
// :::strip{split="55 / * [40 | *]" height=210mm} the Sunday half page, on the cover
// :::strip{split="* | * | * | *" span=page placement=bottom} the daily: four square panels
// On the one-column cover the Sunday strip is set in the text, 210 mm deep. Inside, the daily
// floats to the foot of the page, as wide as its four columns, and the columns of the article
// end level above it. A strip never splits; one that does not fit moves to the next page.
// The panels, gutters and balloons come from the comics settings, as on a comic page.
const comics = {
gutter: { horizontal: mm(3), vertical: mm(3) },
panel: { borderWidth: pt(0.75), borderColor: col('ink'), background: col('paper') },
lettering: { fontFamily: LETTERING, fontSize: pt(7.5), color: col('ink'), inset: mm(1) },
balloonStyles: [
{ id: 'speech', fill: col('paper'), stroke: col('ink') },
{ id: 'whisper', fill: col('paper'), stroke: col('ink') },
{ id: 'shout', fill: col('paper'), stroke: col('ink') },
{ id: 'caption', fill: col('accent'), stroke: col('accent'), color: col('paper'),
fontFamily: LABEL, bold: true },
{ id: 'sfx', fontFamily: SFX, color: col('accent'), haloColor: col('paper') },
],
};
const layout = { layoutType: 'multiple', columnCount: 4, gutterWidth: mm(GUTTER),
columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
A page-wide daily strip and a Sunday half page, set in the columns
Ingredients
- Features
- Comic strips in the textThree or more columnsColumn rulePanel borders and guttersSpeakers and anchorsComic pagesComic letteringRe-lettering in other languagesBalloon stylesDesigned openersRunning heads and foliosCallout boxesParagraph stylesSemantic colour palettePDF exportSection geometryFigure placement
- Also uses
- Boxes that split or stay wholePanel art and cropsHeading attributesHeading stylesFull-width chapter bandPaper colourFonts embedded in the PDFFigures and tables as resourcesSound effects
- Type
- Newsreader, Archivo Narrow, Comic Neue, Bangers, Noto Serif SC, Noto Sans SC, ZCOOL KuaiLe, ZCOOL QingKe HuangYou, Noto Serif JP, Noto Sans JP, Zen Antique, Dela Gothic One, Noto Naskh Arabic, Noto Kufi Arabic, Playpen Sans Arabic, Lalezar (SIL OFL 1.1)
- Assets
po1.jpgpo2.jpgpo3.jpgpo4.jpgpo5.jpgpo6.jpgpo7.jpg- Strip panel: Pip walks into the bakery (Generated With Diffusion Models, original)
- Strip panel: Otto and the big croissant (Generated With Diffusion Models, original)
- Strip panel: Pip and the croissant on the counter (Generated With Diffusion Models, original)
- Strip panel: Otto eats the croissant (Generated With Diffusion Models, original)
- Strip panel: fishing on the floe (Generated With Diffusion Models, original)
- Strip panel: Pip and his fish (Generated With Diffusion Models, original)
- Strip panel: Otto lands a boot (Generated With Diffusion Models, original)
Method
#1 · Write the strip where the paper wants it
The code is the short answer above. A :::strip has the body of a comic page (its split, its ::panel lines and their script), but it is a block in the text (strips). On the one-column cover the Sunday strip is set where it is written, 210 mm deep, and the contents follow it. Its split, 55 / * [40 | *], gives the wide fishing scene a tier of its own and the two gags a row below. A strip never splits: one that does not fit moves whole to the next page.

#2 · Float the daily across the columns
The inside page opens with an H1 whose heading style switches the section to four columns. The daily strip is written right after it with span=page placement=bottom: it floats to the foot of the page as wide as the four columns, and the columns of the article end level above it. Without height or aspect, a strip takes the shape that makes its panels square, four to one here. Written with placement=top it would open the next page instead, because a float never goes above the point it is written at.
#3 · Fit each picture to its cell
// From the art manifest; fractions of each picture, the same in every language.
const face = (x, y, width, height) => ({ x, y, width, height });
const ART = {
po1: { width: 1024, height: 1024, safeArea: face(0.11, 0.22, 0.71, 0.45),
anchors: [{ id: 'pip', x: 0.27, y: 0.58, face: face(0.11, 0.44, 0.22, 0.2) },
{ id: 'otto', x: 0.67, y: 0.37, face: face(0.56, 0.23, 0.24, 0.22) }] },
po2: { width: 1024, height: 1024, safeArea: face(0.07, 0.14, 0.79, 0.56),
anchors: [{ id: 'pip', x: 0.23, y: 0.62, face: face(0.07, 0.5, 0.22, 0.18) },
{ id: 'otto', x: 0.75, y: 0.31, face: face(0.52, 0.14, 0.33, 0.22) }],
avoid: [face(0.28, 0.17, 0.38, 0.39)] },
po3: { width: 1024, height: 1024, safeArea: face(0.03, 0.18, 0.72, 0.56),
anchors: [{ id: 'pip', x: 0.275, y: 0.36, face: face(0.1, 0.19, 0.25, 0.21) }],
avoid: [face(0.34, 0.41, 0.64, 0.41)] },
po4: { width: 1024, height: 1024, safeArea: face(0.07, 0.19, 0.84, 0.53),
anchors: [{ id: 'pip', x: 0.23, y: 0.61, face: face(0.07, 0.48, 0.22, 0.18) },
{ id: 'otto', x: 0.69, y: 0.36, face: face(0.53, 0.19, 0.39, 0.23) }],
avoid: [face(0.31, 0.63, 0.05, 0.08), face(0.39, 0.32, 0.29, 0.18)] },
po5: { width: 1400, height: 600, safeArea: face(0.16, 0.17, 0.6, 0.66),
anchors: [{ id: 'pip', x: 0.345, y: 0.53, face: face(0.29, 0.4, 0.09, 0.18) },
{ id: 'otto', x: 0.625, y: 0.33, face: face(0.58, 0.17, 0.12, 0.23) }] },
po6: { width: 1152, height: 1152, safeArea: face(0.3, 0.21, 0.43, 0.35),
anchors: [{ id: 'pip', x: 0.46, y: 0.39, face: face(0.35, 0.25, 0.2, 0.18) },
{ id: 'fish', x: 0.66, y: 0.46, face: face(0.64, 0.41, 0.07, 0.12) }] },
po7: { width: 1400, height: 933, safeArea: face(0.07, 0.15, 0.81, 0.65),
anchors: [{ id: 'pip', x: 0.195, y: 0.64, face: face(0.1, 0.56, 0.15, 0.14) },
{ id: 'otto', x: 0.795, y: 0.35, face: face(0.7, 0.18, 0.18, 0.22) },
{ id: 'sfx', x: 0.62, y: 0.7 }],
avoid: [face(0.44, 0.3, 0.15, 0.32)] },
};
const ALT = {
po1: t({ en: 'Pip the penguin waddles into Otto the walrus’s tiny bakery, waving.',
es: 'Pip, el pingüino, entra saludando en la pequeña panadería de Otto, la morsa.',
ca: 'En Pip, el pingüí, entra saludant a la petita fleca de l’Otto, la morsa.',
zh: '企鹅皮普摇摇摆摆走进海象奥托的小面包店,挥着手。',
ar: 'البطريق بيب يدخل مخبز الفظّ أوتو الصغير وهو يلوّح.',
ja: 'ペンギンのピップが手を振りながら、セイウチのオットーの小さなパン屋に入ってくる。',
pt: 'O pinguim Pip entra acenando na padariazinha da morsa Otto.' }),
po2: t({ en: 'Otto proudly holds up a croissant as big as Pip.',
es: 'Otto enseña orgulloso un cruasán tan grande como Pip.',
ca: 'L’Otto ensenya orgullós un croissant tan gran com en Pip.',
zh: '奥托得意地举起一个和皮普一样大的羊角面包。',
ar: 'أوتو يرفع بفخر كرواسونًا بحجم بيب.',
ja: 'オットーが、ピップと同じくらい大きなクロワッサンを得意げに持ち上げる。',
pt: 'Otto ergue, todo orgulhoso, um croissant do tamanho do Pip.' }),
po3: t({ en: 'Pip stands on the counter next to the giant croissant, scratching his head.',
es: 'Pip, de pie en el mostrador junto al cruasán gigante, se rasca la cabeza.',
ca: 'En Pip, dret al taulell al costat del croissant gegant, es grata el cap.',
zh: '皮普站在柜台上,挨着巨大的羊角面包挠头。',
ar: 'بيب واقف على المنضدة بجانب الكرواسون العملاق يحكّ رأسه.',
ja: 'ピップがカウンターの巨大なクロワッサンの横に立ち、頭をかいている。',
pt: 'Pip, de pé no balcão ao lado do croissant gigante, coça a cabeça.' }),
po4: t({ en: 'Otto munches the big half of the croissant; Pip holds a tiny end piece.',
es: 'Otto se come la mitad grande del cruasán; Pip sostiene una punta diminuta.',
ca: 'L’Otto es menja la meitat gran del croissant; en Pip aguanta una punta minúscula.',
zh: '奥托大口吃着大半个羊角面包;皮普手里拿着一小截面包尖。',
ar: 'أوتو يلتهم النصف الكبير من الكرواسون، وبيب يمسك طرفًا صغيرًا.',
ja: 'オットーがクロワッサンの大きいほうをほおばり、ピップは小さな端っこを持っている。',
pt: 'Otto devora a metade grande do croissant; Pip segura uma pontinha.' }),
po5: t({ en: 'Pip and Otto fish through a hole in an ice floe beside a tiny bakery hut.',
es: 'Pip y Otto pescan por un agujero en un témpano, junto a una caseta de panadería.',
ca: 'En Pip i l’Otto pesquen per un forat en un glaç, al costat d’una caseta de fleca.',
zh: '皮普和奥托在浮冰的冰洞里钓鱼,旁边是一间小面包屋。',
ar: 'بيب وأوتو يصطادان من ثقب في جليد طافٍ بجانب كوخ مخبز صغير.',
ja: '流氷の穴でピップとオットーが釣りをしている。そばに小さなパン屋の小屋。',
pt: 'Pip e Otto pescam num buraco do bloco de gelo, ao lado de uma padariazinha.' }),
po6: t({ en: 'Pip proudly holds up a tiny, unimpressed fish.',
es: 'Pip levanta orgulloso un pez diminuto y nada impresionado.',
ca: 'En Pip aixeca orgullós un peix minúscul i gens impressionat.',
zh: '皮普得意地举起一条小小的、一脸不屑的鱼。',
ar: 'بيب يرفع بفخر سمكة صغيرة لا يبدو عليها أي إعجاب.',
ja: 'ピップが、少しもうれしそうでない小さな魚を得意げに持ち上げる。',
pt: 'Pip ergue, todo orgulhoso, um peixinho nada impressionado.' }),
po7: t({ en: 'Otto reels in a soggy boot; Pip has fallen on his back laughing.',
es: 'Otto saca del agua una bota empapada; Pip se ha caído de espaldas de la risa.',
ca: 'L’Otto treu de l’aigua una bota xopa; en Pip ha caigut d’esquena de riure.',
zh: '奥托钓上来一只湿透的靴子;皮普笑得仰面摔倒。',
ar: 'أوتو يسحب حذاءً مبتلًا، وبيب سقط على ظهره من الضحك.',
ja: 'オットーがずぶぬれの長靴を釣り上げ、ピップは笑いころげてあおむけに倒れている。',
pt: 'Otto puxa uma bota encharcada; Pip caiu de costas de tanto rir.' }),
};
The pictures were drawn square or wide, and the cells of the Sunday split are not: the boot gag sits in a cell 112 × 88 mm. Each safe area is cut to what the joke needs, Pip and Otto and the boot, so the crop never letterboxes the picture (panels and art).
#4 · Print the same page in six papers
const [TEXT, LABEL, LETTERING, SFX] = t({
en: ['Newsreader', 'Archivo Narrow', 'Comic Neue', 'Bangers'],
es: ['Newsreader', 'Archivo Narrow', 'Comic Neue', 'Bangers'],
ca: ['Newsreader', 'Archivo Narrow', 'Comic Neue', 'Bangers'],
pt: ['Newsreader', 'Archivo Narrow', 'Comic Neue', 'Bangers'],
zh: ['Noto Serif SC', 'Noto Sans SC', 'ZCOOL KuaiLe', 'ZCOOL QingKe HuangYou'],
ja: ['Noto Serif JP', 'Noto Sans JP', 'Zen Antique', 'Dela Gothic One'],
ar: ['Noto Naskh Arabic', 'Noto Kufi Arabic', 'Playpen Sans Arabic', 'Lalezar'],
});
const CJK = LANG === 'zh' || LANG === 'ja';
// Design text aligns to physical sides: an Arabic page starts on the right.
const [START, END] = LANG === 'ar' ? ['right', 'left'] : ['left', 'right'];
Each edition sets the newspaper in faces for its script and letters the strips in its own comic faces. The Japanese balloons run vertically. The Japanese and Arabic editions read the strips right to left, so the panels of every row change places; the Arabic one, set right to left, also mirrors the columns of the page. The pictures and the split stay the same; only content.<lang>.md changes (reading direction).
The whole recipe
// ═══ Postext Cookbook · Nº 146 · A daily strip and a Sunday half page in the newspaper ═══ // https://postext.dev/en/cookbook/newspaper-daily-strip // Code: MIT · Text: original (CC BY 4.0) · Pictures: generated with diffusion models // Fonts: Newsreader, Archivo Narrow, Comic Neue, Bangers and 12 more (OFL) · Needs postext ≥ 1.21.0 // // Two pages of a newspaper's pull-out comics section: a four-panel daily strip across the head // of a four-column page, and a Sunday half page with its own panel grid. Both are :::strip blocks // in the text: the columns flow around them as they flow around a page-wide figure. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf'; const LANG = 'en'; // @lang: the sample's language: 'en' | 'es' | 'ca' | 'zh' | 'ar' | 'ja' | 'pt' const RECIPE = 'newspaper-daily-strip'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: newsprint, ink and the paper's red const palette = { ink: '#16181b', // text, rules, panel borders and the lettering paper: '#f6f3ec', // newsprint accent: '#b3261e', // section flags, kickers and the sound effects tint: '#e9e4d8', // the advert's box rule: '#8f9196', // the column rules muted: '#575b61', // bylines and the folio line }; 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' } })), { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } }, ]; // #endregion // #region editions: the newspaper's text, label and lettering faces in each script const [TEXT, LABEL, LETTERING, SFX] = t({ en: ['Newsreader', 'Archivo Narrow', 'Comic Neue', 'Bangers'], es: ['Newsreader', 'Archivo Narrow', 'Comic Neue', 'Bangers'], ca: ['Newsreader', 'Archivo Narrow', 'Comic Neue', 'Bangers'], pt: ['Newsreader', 'Archivo Narrow', 'Comic Neue', 'Bangers'], zh: ['Noto Serif SC', 'Noto Sans SC', 'ZCOOL KuaiLe', 'ZCOOL QingKe HuangYou'], ja: ['Noto Serif JP', 'Noto Sans JP', 'Zen Antique', 'Dela Gothic One'], ar: ['Noto Naskh Arabic', 'Noto Kufi Arabic', 'Playpen Sans Arabic', 'Lalezar'], }); const CJK = LANG === 'zh' || LANG === 'ja'; // Design text aligns to physical sides: an Arabic page starts on the right. const [START, END] = LANG === 'ar' ? ['right', 'left'] : ['left', 'right']; // #endregion const MARGIN = { top: 31, bottom: 14, side: 12 }; // mm: a newspaper's narrow, even margins const GUTTER = 4; // mm: four columns of 48.5 mm on the 206 mm text width const LEAD = CJK ? 15 : LANG === 'ar' ? 16 : 12.2; // pt: the baseline grid const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } }); const label = (size, look = {}) => ({ fontFamily: LABEL, fontSize: pt(size), fontWeight: 700, color: col('ink'), ...look }); // #region answer: a page-wide daily strip and a Sunday half page, set in the columns // In the Markdown each strip is a fenced block, where the paper wants it: // :::strip{split="55 / * [40 | *]" height=210mm} the Sunday half page, on the cover // :::strip{split="* | * | * | *" span=page placement=bottom} the daily: four square panels // On the one-column cover the Sunday strip is set in the text, 210 mm deep. Inside, the daily // floats to the foot of the page, as wide as its four columns, and the columns of the article // end level above it. A strip never splits; one that does not fit moves to the next page. // The panels, gutters and balloons come from the comics settings, as on a comic page. const comics = { gutter: { horizontal: mm(3), vertical: mm(3) }, panel: { borderWidth: pt(0.75), borderColor: col('ink'), background: col('paper') }, lettering: { fontFamily: LETTERING, fontSize: pt(7.5), color: col('ink'), inset: mm(1) }, balloonStyles: [ { id: 'speech', fill: col('paper'), stroke: col('ink') }, { id: 'whisper', fill: col('paper'), stroke: col('ink') }, { id: 'shout', fill: col('paper'), stroke: col('ink') }, { id: 'caption', fill: col('accent'), stroke: col('accent'), color: col('paper'), fontFamily: LABEL, bold: true }, { id: 'sfx', fontFamily: SFX, color: col('accent'), haloColor: col('paper') }, ], }; const layout = { layoutType: 'multiple', columnCount: 4, gutterWidth: mm(GUTTER), columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } }; // #endregion // The section flag heads every page of the pull-out: its name over a 3 pt bar, the folio line // above it. It lives in the header, so the cover's strip starts right under it, and a float // written after an opener can still take that page's foot (gotcha: top-float-next-page). const WIDTH = 230 - 2 * MARGIN.side; // mm: the text width const SECTION = t({ en: 'Funnies', es: 'Tebeos', ca: 'Còmics', zh: '漫画版', ar: 'صفحة الكوميكس', ja: '漫画面', pt: 'Quadrinhos' }); const across = (y) => ({ ...at('page', 'top-left', MARGIN.side, y), size: { width: mm(WIDTH) } }); const header = { elements: [ { kind: 'text', id: 'folio', content: '{pageNumber} · {title} · {publishDate}', ...label(8, { color: col('muted') }), align: START, placement: across(8) }, { kind: 'text', id: 'flag', content: SECTION, ...label(30, { color: col('accent') }), lineHeight: 1, align: START, placement: across(12.5) }, { kind: 'text', id: 'tag', content: '{subtitle}', ...label(9, { color: col('muted') }), align: END, placement: across(19.5) }, { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'), placement: across(24.5) }, ] }; const config = () => ({ // a factory: the engine caches resolved configs per object locale: t({ en: 'en-gb', es: 'es', ca: 'ca', ja: 'ja', zh: 'zh-Hans', ar: 'ar', pt: 'pt-BR' }), colorPalette, comics, layout: { layoutType: 'single' }, // the cover; the inside page is a section in four columns // The pull-out is a tabloid sheet folded once more: 230 × 310 mm. page: { sizePreset: 'custom', width: mm(230), height: mm(310), dpi: 150, backgroundColor: col('paper'), margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.side), right: mm(MARGIN.side) } }, bodyText: { fontFamily: TEXT, fontSize: pt(CJK ? 8.5 : 9), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'), textAlign: 'justify', firstLineIndent: CJK ? em(1) : mm(3), indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true, avoidWidows: true, avoidOrphans: true, avoidRunts: true }, headings: { fontFamily: TEXT, fontWeight: 700, color: col('ink'), levels: [ // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break). { level: 1, span: 'page', fontSize: pt(28), lineHeight: pt(32), marginTop: pt(0), marginBottom: pt(LEAD * 0.5), breakBefore: { enabled: true, parity: 'any' } }, { level: 2, fontFamily: LABEL, fontSize: pt(12), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, { level: 3, fontFamily: LABEL, fontSize: pt(9.5), lineHeight: pt(LEAD), marginTop: pt(LEAD), marginBottom: pt(0) }, ] }, // An H1 opens the inside page, and its style turns that page into four columns. headingStyles: [{ id: 'inside', layout }], paragraphStyles: [ { id: 'contents', fontFamily: LABEL, fontSize: pt(14), lineHeight: pt(LEAD * 1.5), textAlign: 'left', firstLineIndent: pt(0), spaceBetween: pt(LEAD * 0.5) }, { id: 'byline', ...label(8.5, { color: col('muted') }), textAlign: 'left', firstLineIndent: pt(0), marginBottom: pt(LEAD * 0.5) }, { id: 'flush', firstLineIndent: pt(0), textAlign: 'left' }, // letters, ragged { id: 'imprint', ...label(7, { color: col('muted'), fontWeight: 400 }), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }, ], calloutStyles: [{ id: 'ad', background: col('tint'), keepTogether: true, stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('accent') }, padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) }, titleStyle: { ...label(7.5, { color: col('muted') }), gap: mm(2) }, // "Advertisement" body: { fontFamily: LABEL, fontSize: pt(11), lineHeight: pt(LEAD * 1.2), textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true } }], header, footer: { elements: [] }, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown sample · 106 lines · content.en.md
title: "The Corrington Despatch" subtitle: "Pip & Otto at ten · Letters to the floe" publishDate: "Saturday 17 October 2026" --- :::strip{split="55 / * [40 | *]" height=210mm} ::panel{art=po5} caption{at=top-start}: PIP & OTTO · Sunday on the floe pip: Otto, you own a bakery. Why do you fish? otto: Bread never puts up a fight. ::panel{art=po6} pip: Look! Supper! fish{whisper}: Put me back. ::panel{art=po7} sfx{at="62% 72%"}: SPLOOSH pip{shout}: HA! It’s your size! otto: Hm. Nearly new, too. ::: :::paragraphs{style="contents"} **Inside:** Pip & Otto turn ten. Ines Barlow on a decade of croissants, fish and one bow tie. **Letters to the floe:** your questions, Otto’s answers. Both on page 2. **Next Sunday:** Otto learns to skate. ::: # Ten winters on the floe {style="inside"} :::strip{split="* | * | * | *" span=page placement=bottom} ::panel{art=po1} pip: Morning, Otto! One croissant, please. otto: Coming right up! ::panel{art=po2} otto: Fresh out of the oven! ::panel{art=po3} pip: Have you got a smaller one? ::panel{art=po4} sfx: CHOMP otto: There. Smaller. ::: :::paragraphs{style="byline"} By Ruth Okafor · Arts reporter ::: Ines Barlow draws a penguin and a walrus every weekday morning at a kitchen table in Saltmarsh Lane, and she has done it for ten years. *Pip & Otto* first ran in the Despatch on 17 October 2016, squeezed under the crossword. It now runs in eleven papers, in six languages, and it still fits in four square panels. “The rule is that the joke has to land in the fourth panel and not before,” Barlow says. “If Otto is funny in panel two, I’ve wasted the strip.” Pip is small, polite and always hungry. Otto runs a bakery on an ice floe, wears a bow tie to work and has never once given a straight answer. Readers write to both of them. Most of the letters are addressed to Otto. Barlow grew up above her parents’ bakery in Harrow Road, which explains the croissants. The penguin came later, from a postcard her grandmother sent from Cape Town. “I drew Pip on the back of an order book when I was nine. He had the scarf already. Otto took me another twenty years.” She sent the first six strips to the Despatch in the summer of 2016, with a covering letter of one line. The comics editor at the time, Martin Fry, ran them as a trial for a month. The trial has not ended. Fry, now retired, still gets the strip a day early by post. “He tells me when a joke is weak,” Barlow says. “He is usually right.” The work starts on Monday with five pencil roughs on sheets of A4, four boxes to a sheet. By Wednesday they are inked and coloured. The pictures go to the paper with no words in them; the words go separately, as a short script: who speaks, in which panel, and what they say. A sound effect gets a line of its own. That habit started with the French edition in 2019. “Lettering a strip twice by hand was a week’s work,” she says. “Now each paper sets the words in its own language, and the balloons are drawn around them.” The Spanish papers get *¡Recién salido del horno!*; the Japanese edition sets its balloons in vertical columns and, like the Arabic one, reads the panels from right to left. The pictures never change. The Sunday strip is the exception to the four-panel rule. It takes half a page, in colour, and Barlow treats it as a short story: three panels, a slower joke, and usually a fish. This week’s is on the cover. What is next for the pair? “Otto is going to learn to skate,” Barlow says. “He will not enjoy it.” ## Pip & Otto in numbers :::paragraphs{style="flush"} **2,516** daily strips since October 2016. **520** Sunday half pages. **11** newspapers, **6** languages. **1** bow tie. ::: ## Letters to the floe :::paragraphs{style="flush"} **Dear Otto,** my son wants to know why a walrus needs a bow tie. — *Grace, Abbotsford* ::: **Otto replies:** A baker who does not dress for work bakes flat bread. :::paragraphs{style="flush"} **Dear Pip,** have you ever finished a whole croissant? — *Tomasz, aged 8, Riverside* ::: **Pip replies:** Once. It took two days and Otto charged me twice. :::paragraphs{style="flush"} **Dear Otto,** is the bakery on the floe a real place? I would like to visit. — *Mrs D. Keane, Harrow* ::: **Otto replies:** It moves about. Bring a coat. :::paragraphs{style="flush"} **Dear Ines Barlow,** will Pip ever grow? — *Sam, Brook Lane* ::: **Ines Barlow replies:** He grows a little every winter. He does not notice. :::paragraphs{style="flush"} Write to Pip and Otto at the Despatch, 12 Bridge Street, Corrington, or by email. Letters may be shortened. ::: :::callout{type="ad" title="Advertisement"} **Ten Winters on the Floe.** Every Pip & Otto Sunday strip from the first ten years, in one book. 240 pages in colour, £18 from the Despatch shop and every good bookseller. ::: :::paragraphs{style="imprint"} Set in Newsreader and Archivo Narrow; lettered in Comic Neue and Bangers (SIL OFL). Pictures generated with diffusion models. :::`; // content.<lang>.md, inlined by the Cookbook // #region art: the seven pictures: safe area, speakers' anchors and avoid zones // From the art manifest; fractions of each picture, the same in every language. const face = (x, y, width, height) => ({ x, y, width, height }); const ART = { po1: { width: 1024, height: 1024, safeArea: face(0.11, 0.22, 0.71, 0.45), anchors: [{ id: 'pip', x: 0.27, y: 0.58, face: face(0.11, 0.44, 0.22, 0.2) }, { id: 'otto', x: 0.67, y: 0.37, face: face(0.56, 0.23, 0.24, 0.22) }] }, po2: { width: 1024, height: 1024, safeArea: face(0.07, 0.14, 0.79, 0.56), anchors: [{ id: 'pip', x: 0.23, y: 0.62, face: face(0.07, 0.5, 0.22, 0.18) }, { id: 'otto', x: 0.75, y: 0.31, face: face(0.52, 0.14, 0.33, 0.22) }], avoid: [face(0.28, 0.17, 0.38, 0.39)] }, po3: { width: 1024, height: 1024, safeArea: face(0.03, 0.18, 0.72, 0.56), anchors: [{ id: 'pip', x: 0.275, y: 0.36, face: face(0.1, 0.19, 0.25, 0.21) }], avoid: [face(0.34, 0.41, 0.64, 0.41)] }, po4: { width: 1024, height: 1024, safeArea: face(0.07, 0.19, 0.84, 0.53), anchors: [{ id: 'pip', x: 0.23, y: 0.61, face: face(0.07, 0.48, 0.22, 0.18) }, { id: 'otto', x: 0.69, y: 0.36, face: face(0.53, 0.19, 0.39, 0.23) }], avoid: [face(0.31, 0.63, 0.05, 0.08), face(0.39, 0.32, 0.29, 0.18)] }, po5: { width: 1400, height: 600, safeArea: face(0.16, 0.17, 0.6, 0.66), anchors: [{ id: 'pip', x: 0.345, y: 0.53, face: face(0.29, 0.4, 0.09, 0.18) }, { id: 'otto', x: 0.625, y: 0.33, face: face(0.58, 0.17, 0.12, 0.23) }] }, po6: { width: 1152, height: 1152, safeArea: face(0.3, 0.21, 0.43, 0.35), anchors: [{ id: 'pip', x: 0.46, y: 0.39, face: face(0.35, 0.25, 0.2, 0.18) }, { id: 'fish', x: 0.66, y: 0.46, face: face(0.64, 0.41, 0.07, 0.12) }] }, po7: { width: 1400, height: 933, safeArea: face(0.07, 0.15, 0.81, 0.65), anchors: [{ id: 'pip', x: 0.195, y: 0.64, face: face(0.1, 0.56, 0.15, 0.14) }, { id: 'otto', x: 0.795, y: 0.35, face: face(0.7, 0.18, 0.18, 0.22) }, { id: 'sfx', x: 0.62, y: 0.7 }], avoid: [face(0.44, 0.3, 0.15, 0.32)] }, }; const ALT = { po1: t({ en: 'Pip the penguin waddles into Otto the walrus’s tiny bakery, waving.', es: 'Pip, el pingüino, entra saludando en la pequeña panadería de Otto, la morsa.', ca: 'En Pip, el pingüí, entra saludant a la petita fleca de l’Otto, la morsa.', zh: '企鹅皮普摇摇摆摆走进海象奥托的小面包店,挥着手。', ar: 'البطريق بيب يدخل مخبز الفظّ أوتو الصغير وهو يلوّح.', ja: 'ペンギンのピップが手を振りながら、セイウチのオットーの小さなパン屋に入ってくる。', pt: 'O pinguim Pip entra acenando na padariazinha da morsa Otto.' }), po2: t({ en: 'Otto proudly holds up a croissant as big as Pip.', es: 'Otto enseña orgulloso un cruasán tan grande como Pip.', ca: 'L’Otto ensenya orgullós un croissant tan gran com en Pip.', zh: '奥托得意地举起一个和皮普一样大的羊角面包。', ar: 'أوتو يرفع بفخر كرواسونًا بحجم بيب.', ja: 'オットーが、ピップと同じくらい大きなクロワッサンを得意げに持ち上げる。', pt: 'Otto ergue, todo orgulhoso, um croissant do tamanho do Pip.' }), po3: t({ en: 'Pip stands on the counter next to the giant croissant, scratching his head.', es: 'Pip, de pie en el mostrador junto al cruasán gigante, se rasca la cabeza.', ca: 'En Pip, dret al taulell al costat del croissant gegant, es grata el cap.', zh: '皮普站在柜台上,挨着巨大的羊角面包挠头。', ar: 'بيب واقف على المنضدة بجانب الكرواسون العملاق يحكّ رأسه.', ja: 'ピップがカウンターの巨大なクロワッサンの横に立ち、頭をかいている。', pt: 'Pip, de pé no balcão ao lado do croissant gigante, coça a cabeça.' }), po4: t({ en: 'Otto munches the big half of the croissant; Pip holds a tiny end piece.', es: 'Otto se come la mitad grande del cruasán; Pip sostiene una punta diminuta.', ca: 'L’Otto es menja la meitat gran del croissant; en Pip aguanta una punta minúscula.', zh: '奥托大口吃着大半个羊角面包;皮普手里拿着一小截面包尖。', ar: 'أوتو يلتهم النصف الكبير من الكرواسون، وبيب يمسك طرفًا صغيرًا.', ja: 'オットーがクロワッサンの大きいほうをほおばり、ピップは小さな端っこを持っている。', pt: 'Otto devora a metade grande do croissant; Pip segura uma pontinha.' }), po5: t({ en: 'Pip and Otto fish through a hole in an ice floe beside a tiny bakery hut.', es: 'Pip y Otto pescan por un agujero en un témpano, junto a una caseta de panadería.', ca: 'En Pip i l’Otto pesquen per un forat en un glaç, al costat d’una caseta de fleca.', zh: '皮普和奥托在浮冰的冰洞里钓鱼,旁边是一间小面包屋。', ar: 'بيب وأوتو يصطادان من ثقب في جليد طافٍ بجانب كوخ مخبز صغير.', ja: '流氷の穴でピップとオットーが釣りをしている。そばに小さなパン屋の小屋。', pt: 'Pip e Otto pescam num buraco do bloco de gelo, ao lado de uma padariazinha.' }), po6: t({ en: 'Pip proudly holds up a tiny, unimpressed fish.', es: 'Pip levanta orgulloso un pez diminuto y nada impresionado.', ca: 'En Pip aixeca orgullós un peix minúscul i gens impressionat.', zh: '皮普得意地举起一条小小的、一脸不屑的鱼。', ar: 'بيب يرفع بفخر سمكة صغيرة لا يبدو عليها أي إعجاب.', ja: 'ピップが、少しもうれしそうでない小さな魚を得意げに持ち上げる。', pt: 'Pip ergue, todo orgulhoso, um peixinho nada impressionado.' }), po7: t({ en: 'Otto reels in a soggy boot; Pip has fallen on his back laughing.', es: 'Otto saca del agua una bota empapada; Pip se ha caído de espaldas de la risa.', ca: 'L’Otto treu de l’aigua una bota xopa; en Pip ha caigut d’esquena de riure.', zh: '奥托钓上来一只湿透的靴子;皮普笑得仰面摔倒。', ar: 'أوتو يسحب حذاءً مبتلًا، وبيب سقط على ظهره من الضحك.', ja: 'オットーがずぶぬれの長靴を釣り上げ、ピップは笑いころげてあおむけに倒れている。', pt: 'Otto puxa uma bota encharcada; Pip caiu de costas de tanto rir.' }), }; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── // Every face of the six editions; each edition loads the latin files of all of them and the // Chinese, Japanese or Arabic files of its own (gotcha: fonts-first). const FONTS = { Newsreader: ['400', '400i', '700'], 'Archivo Narrow': ['400', '700'], 'Comic Neue': ['400', '400i', '700', '700i'], Bangers: ['400'], 'Noto Serif SC': ['400', '700'], 'Noto Sans SC': ['400', '700'], 'ZCOOL KuaiLe': ['400'], 'ZCOOL QingKe HuangYou': ['400'], 'Noto Serif JP': ['400', '700'], 'Noto Sans JP': ['400', '700'], 'Zen Antique': ['400'], 'Dela Gothic One': ['400'], 'Noto Naskh Arabic': ['400', '700'], 'Noto Kufi Arabic': ['400', '700'], 'Playpen Sans Arabic': ['400', '700'], Lalezar: ['400'], }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── const faces = Object.fromEntries([TEXT, LABEL, LETTERING, SFX].map((f) => [f, FONTS[f]])); await loadFonts(FONTS, markdown); await loadCjkFonts(faces, markdown); await loadArabicFonts(faces, markdown); await loadComicFonts({ [LETTERING]: FONTS[LETTERING], [SFX]: FONTS[SFX] }, markdown); const panel = (id, url) => comicPanel(id, url, { ...ART[id], alt: ALT[id] }); const resources = await Promise.all([ panel('po1', asset('po1.jpg')), panel('po2', asset('po2.jpg')), panel('po3', asset('po3.jpg')), panel('po4', asset('po4.jpg')), panel('po5', asset('po5.jpg')), panel('po6', asset('po6.jpg')), panel('po7', asset('po7.jpg')), ]); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showBook(doc, { title: t({ en: 'A daily strip and a Sunday half page in the newspaper', es: 'Una tira diaria y media página dominical en el periódico', pt: 'Uma tirinha diária e meia página de domingo no jornal' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: comicPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);Kit · core, fonts, viewer, pdf, images, cjk, arabic, comics: the same in every recipe · 722 lines
// ─── 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 v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). 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', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { 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` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds 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. */ 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' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes 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; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), 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 /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · 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 · cjk v1 ── Chinese, Japanese and Korean books · postext.dev/cookbook // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { cjkSlices.cache.set(css, fetch(css) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], css).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` that holds code point `cp`, if any. */ function cjkSliceFor(slices, cp) { return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi)); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to buildWithFonts gets * the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files that * hold the characters its pages set (`request.codePoints`); any other * family gets the latin file fontsourceProvider fetches (the "pdf" block) * and, when the face sets letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.filter((slice) => picked.has(slice)).map(async (slice) => { const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them. Latin comes first: postext-pdf draws a character from the first * file that has it, as the browser takes a character both files hold from * latin. A face Fontsource ships without latin-ext, or whose file does * not come, gets latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); return ext ? [latin, ext] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] 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); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── Kit · arabic v1 ── Arabic-script faces · postext.dev/cookbook // Fontsource ships an Arabic family as one file per subset and weight: the // `arabic` file holds the letters, the harakat, the Arabic-Indic digits, the // Arabic punctuation and the presentation forms; `latin` and `latin-ext` // hold the rest. loadFonts loads the latin files; this block adds the arabic // file of every Arabic family, for the canvas and for the PDF, which shapes // the letters with HarfBuzz from the same bytes. /** The code points of Fontsource's `arabic` subset, as its stylesheets * declare them (the same unicode-range the browser picks the file by). A * function, not a const: the kit is inlined after the recipe's top-level * awaits, and a const read before its line throws, where a function * declaration is hoisted. */ function arabicRange() { return 'U+0600-06FF,U+0750-077F,U+0870-088E,U+0890-0891,U+0897-08E1,U+08E3-08FF,' + 'U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE70-FE74,U+FE76-FEFC,U+102E0-102FB,' + 'U+10E60-10E7E,U+10EC2-10EC4,U+10EFC-10EFF,U+1EE00-1EEFF'; } /** Whether code point `cp` is in the arabic file. */ function inArabicRange(cp) { inArabicRange.ranges ??= arabicRange().split(',').map((part) => { const [lo, hi = lo] = part.slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return inArabicRange.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); } /** Whether Fontsource serves `family` with an `arabic` subset. Fails when * the API does not answer: an Arabic face taken for a Latin one would set * its letters in a system face. */ async function isArabicFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.includes('arabic'); } /** The arabic file of a face. */ function arabicFileUrl(family, weight, style) { const id = fontsourceId(family); return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-arabic-${weight}-${style}.woff2`; } /** faces = { Amiri: ['400', '700'] }, as for loadFonts, after it: the whole * FONTS object may be passed, its families without an arabic subset are * left alone. Adds the arabic file of every listed weight of each Arabic * family (the latin files come from loadFonts) and loads it. `text` is * the sample: fails when it holds an Arabic-script character the arabic * file does not cover. List every weight the pages set in Arabic: a weight * left to buildWithFonts gets the latin file only, and its Arabic letters * fall back to a system face. Resolves to the number of files loaded. */ async function loadArabicFonts(faces, text = '') { kitStatus('Loading fonts…'); let loaded = 0; try { const outside = [...new Set(text)].filter((ch) => /\p{Script=Arabic}/u.test(ch) && !inArabicRange(ch.codePointAt(0))); if (outside.length) throw new Error(`Fontsource's arabic files have no ${outside.slice(0, 12).join(' ')}`); for (const [family, specs] of Object.entries(faces)) { if (!(await isArabicFamily(family))) continue; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const face = new FontFace(family, `url(${arabicFileUrl(family, weight, style)}) format('woff2')`, { weight: String(weight), style, unicodeRange: arabicRange() }); document.fonts.add(await face.load().catch(() => { throw new Error(`Fontsource has no arabic file for ${family} ${weight} ${style}`); })); loaded++; } } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with Arabic faces: a family with an * arabic subset gets its arabic file when its pages set Arabic letters * (`request.codePoints`), then its latin file, and its latin-ext file for * the letters beyond latin (transliteration: ā ḥ ʿ). The arabic file comes * first: it also holds the space and the brackets, so a line of Arabic is * shaped as one run and not cut at every space. Any other family goes to * fontsourceProvider (the "pdf" block). */ async function arabicPdfProvider(family, weight, style, request) { if (!(await isArabicFamily(family))) return fontsourceProvider(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const wanted = [...(request?.codePoints ?? [])]; const id = fontsourceId(family); const urls = []; if (!wanted.length || wanted.some(inArabicRange)) urls.push(arabicFileUrl(family, w, s)); urls.push(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) { urls.push(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`); } return Promise.all(urls.map(async (url) => { const res = await fetch(url); if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } // ─── Kit · comics v1 ── comic pages · postext.dev/cookbook // The engine letters a comic in a face per language (defaultComicFont: // Comic Neue, Zen Antique for Japanese, Noto Sans SC, LXGW WenKai TC, // Playpen Sans Arabic; defaultComicSfxFont: Bangers, Dela Gothic One, // ZCOOL KuaiLe, Lalezar) and asks for the bold of a shout or a sound effect // and the italic of an inner voice. Most of these faces ship one weight and // no italic: this block declares the file Fontsource does ship for those // variants, so the canvas measures and paints the letters the PDF embeds // (the providers snap to the shipped file) and not a bold or a slant the // browser makes up. It also turns the art manifest into panel resources. /** faces = { 'Comic Neue': ['400', '700'], Bangers: ['400'] }, as for * loadFonts, after it (and after loadCjkFonts or loadArabicFonts for the * faces of those scripts); the whole FONTS object may be passed. List in * FONTS only what Fontsource ships (Bangers: ['400']): loadFonts fails on * the rest. For each family, the regular, bold, italic and bold italic it * lacks are declared with its nearest file (the regular for the bold, the * upright for the italic) and loaded for `text`. A CJK face needs the cjk * block; an Arabic face setting Arabic, the arabic block. Resolves to the * number of variants added. */ async function loadComicFonts(faces, text = '') { kitStatus('Loading fonts…'); let added = 0; try { for (const family of Object.keys(faces)) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); const weights = meta.weights?.length ? meta.weights : [400, 700]; const styles = meta.styles?.length ? meta.styles : ['normal']; for (const [weight, style] of [[400, 'normal'], [700, 'normal'], [400, 'italic'], [700, 'italic']]) { if ((weights.includes(weight) && styles.includes(style)) || hasFace(family, weight, style)) continue; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && styles.includes('italic') ? 'italic' : 'normal'; for (const { url, range } of await comicFaceFiles(family, w, s, meta, text)) { document.fonts.add(new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: range })); } await document.fonts.load(`${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`, text || 'A'); added++; } } } catch (error) { kitFail(error); throw error; } return added; } /** The files of a shipped face ({ url, range }): a CJK face's slices (the * cjk block reads them from its stylesheet), else latin, the latin-ext * and greek files `text` needs, and the arabic file when `text` holds * Arabic. */ async function comicFaceFiles(family, weight, style, meta, text) { if (meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset))) { if (typeof cjkSlices !== 'function') throw new Error(`${family} is a CJK face: list the cjk kit block`); return (await cjkSlices(family, weight, style)).map(({ url, range }) => ({ url, range })); } const id = fontsourceId(family); const file = (subset) => `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const files = ['latin', ...kitSubsetsFor(text, meta)].map((subset) => ({ url: file(subset), range: comicRange(subset) })); if (meta.subsets?.includes('arabic') && /\p{Script=Arabic}/u.test(text)) { if (typeof arabicRange !== 'function') throw new Error(`${family} sets Arabic: list the arabic kit block`); files.unshift({ url: file('arabic'), range: arabicRange() }); } return files; } /** The unicode-range loadFonts gives a Fontsource file. A function, not a * const: the kit is inlined after the recipe's top-level awaits. */ function comicRange(subset) { return { 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', greek: 'U+0370-03FF', }[subset]; } /** The PDF font provider of a comic in any script: a CJK face goes to * cjkPdfProvider, an Arabic face to arabicPdfProvider, any other to * fontsourceProvider (the "pdf" block). Each embeds the shipped weight * and style nearest to the one asked for, the file loadComicFonts * declared for the canvas. */ async function comicPdfProvider(family, weight, style, request) { const subsets = (await fontsourceMeta(family))?.subsets ?? []; if (subsets.some((subset) => /^(chinese|japanese|korean)/.test(subset))) { if (typeof cjkPdfProvider !== 'function') throw new Error(`${family} is a CJK face: list the cjk kit block`); return cjkPdfProvider(family, weight, style, request); } if (subsets.includes('arabic') && typeof arabicPdfProvider === 'function') { return arabicPdfProvider(family, weight, style, request); } return fontsourceProvider(family, weight, style, request); } /** A panel picture as a resource for art= (and pop=): loads `url` * (write asset('lh-arrive.jpg') in the call, so the lint checks the file) * and declares it with `meta`, its entry in the art manifest: * { width, height, alt, safeArea, anchors, avoid }. The safe area is what * every crop keeps, the anchors the speakers' mouths, heads and faces, * the avoid zones what no balloon covers; all in fractions of the picture, * the same in every language. Without width and height, the picture's own * size. Needs the images block. Resolves to the resource. */ async function comicPanel(id, url, meta = {}) { const fileId = decodeURIComponent(url.split('/').pop()); await loadImage(fileId, url); let { width, height } = meta; if (!width || !height) { const bitmap = await createImageBitmap(new Blob([imageBytes(fileId)])); ({ width, height } = bitmap); bitmap.close(); } return { id, typeId: meta.typeId ?? 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId, format: /\.png$/i.test(fileId) ? 'png' : 'jpeg', width, height }, ...(meta.alt && { altText: meta.alt }), ...(meta.safeArea && { safeArea: meta.safeArea }), ...(meta.anchors?.length && { anchors: meta.anchors }), ...(meta.avoid?.length && { avoid: meta.avoid }), }; } // ─── /Kit ───────────────────────────────────────────────────────────────────────
The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)
Variations
#Give the daily strip a fixed height
-:::strip{split="* | * | * | *" span=page placement=bottom}
+:::strip{split="* | * | * | *" span=page placement=bottom height=45mm}#Run a one-column strip in the text
Without span=page a strip is as wide as its column and is set where it is written, like an inline figure.
-:::strip{split="* | * | * | *" span=page placement=bottom}
+:::strip{split="* / * / * / *" aspect=0.25}Pitfalls
Pitfall
Any headings object switches off the H1 page break
By default an H1 breaks to a recto (always-odd), but passing any headings object resets that default, so chapters run on and span: 'page' does nothing. Restate headings.levels[0].breakBefore: { enabled: true, parity } in every config. Chapters that open on a recto →
Pitfall
Load every face before layout
Layout measures text with the faces the browser has loaded and caches the widths, so a face that arrives after the first build leaves wrong line breaks and a PDF that no longer matches the screen. Load every weight and style first, and call clearMeasurementCache() before rebuilding when one arrives late. Fonts before layout →
Pitfall
A 'top' float never lands on its citing page
A float never goes above its own reference, so a page-wide 'top' float cited on page N opens page N+1. Cite it earlier, or use position 'auto' or 'bottom', which can take the foot of the citing page. Figure placement →
- The section flag is drawn by the header, not by an H1 opener, so nothing but the folio line sits above the cover's strip.
- A safe area wider than its cell's shape letterboxes the picture with the panel's background, and the build warns
comicPanelLetterbox.
Credits
- Recipe
- Ignacio Ferro
- Text
- Original prose, CC BY 4.0
- Images
- Strip panel: Pip walks into the bakery · Generated With Diffusion Models · original
- Strip panel: Otto and the big croissant · Generated With Diffusion Models · original
- Strip panel: Pip and the croissant on the counter · Generated With Diffusion Models · original
- Strip panel: Otto eats the croissant · Generated With Diffusion Models · original
- Strip panel: fishing on the floe · Generated With Diffusion Models · original
- Strip panel: Pip and his fish · Generated With Diffusion Models · original
- Strip panel: Otto lands a boot · Generated With Diffusion Models · original
- Fonts
- Newsreader (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1) · Comic Neue (SIL OFL 1.1) · Bangers (SIL OFL 1.1) · Noto Serif SC (SIL OFL 1.1) · Noto Sans SC (SIL OFL 1.1) · ZCOOL KuaiLe (SIL OFL 1.1) · ZCOOL QingKe HuangYou (SIL OFL 1.1) · Noto Serif JP (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1) · Zen Antique (SIL OFL 1.1) · Dela Gothic One (SIL OFL 1.1) · Noto Naskh Arabic (SIL OFL 1.1) · Noto Kufi Arabic (SIL OFL 1.1) · Playpen Sans Arabic (SIL OFL 1.1) · Lalezar (SIL OFL 1.1)
Edit this write-up ↗ (opens in a new tab)Recipe folder on GitHub ↗ (opens in a new tab)


