Skip to main content
Recipe number 136

Cookbook · Chapter 10 · Output & integration

A living newspaper whose pictures move

Six video resources set as press photographs on a four-column tabloid; autoplay, loop and exclusive: false make the clips play together in Folio.

  • Trim 280 × 430 mm
  • 4 columns, 4.5 mm gutter
  • Libre Caslon Text 8.8/11.6
  • Courier Prime
  • Oswald
  • 4 pages
  • Level
  • Postext 1.18.0
  • Laid out in 530 ms
  • 250 lines of code

In short

A 1930s-style local newspaper where every photograph is a short silent film. On paper they are still pictures; in the 3D viewer all of them move together, over and over, like a newspaper in a fairy tale.

What you'll build

The Wexcombe Lantern, an invented seaside weekly of June 1933, on a 280 × 430 mm tabloid page in four ruled columns: a condensed nameplate, a lead story with its picture across three columns, a second across two, tide tables, small ads and a sports page. Every picture is a five-second black-and-white film. In print, and in the flat pages under the viewer, each one is its first frame. Opened in Folio as a folded newspaper on light newsprint, the pictures on the open pages move at once and keep moving: the mayor speaks, the aviator waves, the fisherman laughs, the goalkeeper dives and gets up again. Nothing is drawn by the page; the layout is an ordinary newspaper, and the clips do the rest.

This recipe answers

  • How do I make several silent video loops play at once on a page, and start again each time the page is turned to?
  • How do I show a newspaper in the 3D viewer as folded sheets, with the business pages on salmon paper?

The short answer

script.js · lines 46–60in full code
// A video resource prints its poster (the first frame) on the flat page. In Folio, and in the
// HTML viewer, the poster comes alive: autoplay starts it muted when its page is on show, loop
// keeps it going, and exclusive: false lets it run alongside the other clips instead of
// pausing them, as one exclusive player at a time would.
const videoStyle = {
  playMark: { enabled: false }, // a press photograph, not a play button
  qr: { enabled: false }, // the clips live on the page, not behind a link
  linkPoster: false,
  player: { autoplay: true, loop: true, muted: true, exclusive: false, controls: false },
};
const clip = (id, url, [w, h], placement, caption, altText) => ({ id, typeId: 'picture',
  kind: 'video', createdAt: 0, updatedAt: 0, caption, altText, placement,
  video: { source: 'file', fileId: `${id}-loop.mp4`, url, // url: where the clip plays from
    format: 'mp4', width: w, height: h, duration: 5,
    poster: { fileId: `${id}-poster.jpg`, format: 'jpeg', width: w, height: h } } });

Every picture is a clip that loops, autoplays and plays with the others

Ingredients

Type
Libre Caslon Text, Oswald, Courier Prime (SIL OFL 1.1)
Assets
  • actress-loop.mp4
  • actress-poster.jpg
  • aviator-loop.mp4
  • aviator-poster.jpg
  • clock-loop.mp4
  • clock-poster.jpg
  • fisherman-loop.mp4
  • fisherman-poster.jpg
  • keeper-loop.mp4
  • keeper-poster.jpg
  • mayor-loop.mp4
  • mayor-poster.jpg
  • Mrs Edith Rowe speaks at the opening of the Harbour line: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg (Generated With Diffusion Models, CC BY 4.0)
  • The first frame of the mayor loop, its printed picture (Generated With Diffusion Models, CC BY 4.0)
  • The Guildhall clock tower: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg (Generated With Diffusion Models, CC BY 4.0)
  • The first frame of the clock loop, its printed picture (Generated With Diffusion Models, CC BY 4.0)
  • Skipper Jacob Hosking with the cod: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg (Generated With Diffusion Models, CC BY 4.0)
  • The first frame of the fisherman loop, its printed picture (Generated With Diffusion Models, CC BY 4.0)
  • Miss Constance Lacey in the cockpit: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg (Generated With Diffusion Models, CC BY 4.0)
  • The first frame of the aviator loop, its printed picture (Generated With Diffusion Models, CC BY 4.0)
  • Miss Rosalind Avery on the stage: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg (Generated With Diffusion Models, CC BY 4.0)
  • The first frame of the actress loop, its printed picture (Generated With Diffusion Models, CC BY 4.0)
  • Tom Rundle in goal: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg (Generated With Diffusion Models, CC BY 4.0)
  • The first frame of the keeper loop, its printed picture (Generated With Diffusion Models, CC BY 4.0)

Method

#1 · Make every picture a clip that loops with the others

The code is the short answer above. A video resource prints its poster as a picture, so the clips take the place of photographs and caption like them. Three player options make the page alive: autoplay starts a clip when its page is on show (always muted, as browsers require), loop plays it again from the start, and exclusive: false lets it run alongside the other clips. By default a video is exclusive: starting it pauses whatever else is playing, which suits a lecture but not a page of moving photographs. The play mark and the QR code are switched off, so on paper the clips read as press photographs.

#2 · Give each loop its place on the grid

script.js · lines 64–90in full code
const resources = [
  clip('mayor', asset('mayor-loop.mp4'), [640, 480],
    { position: 'top', span: 'column', columns: 3 },
    '**Mrs Edith Rowe** speaks from the platform at the foot of Quay Hill before cutting '
      + 'the ribbon.',
    'A woman in a dark coat and small hat speaks at a draped podium, one hand raised.'),
  clip('clock', asset('clock-loop.mp4'), [480, 640],
    { position: 'top', span: 'column', columns: 2 },
    '**Twelve o’clock** on the Guildhall tower, for the first time since 1924.',
    'A stone clock tower against clouds, pigeons on its ledge.'),
  clip('fisherman', asset('fisherman-loop.mp4'), [640, 480],
    { position: 'auto', span: 'column', columns: 2 },
    '**Skipper Jacob Hosking** and the 82-pound cod on the quay.',
    'An old fisherman in an oilskin laughs, holding up a huge cod.'),
  clip('aviator', asset('aviator-loop.mp4'), [640, 480],
    { position: 'top', span: 'column', columns: 2 },
    '**Miss Constance Lacey** waves from the cockpit on Wexcombe Common.',
    'A young woman in a flying helmet waves from the open cockpit of a biplane.'),
  clip('actress', asset('actress-loop.mp4'), [480, 640],
    { position: 'top', span: 'column', columns: 2 },
    '**Miss Rosalind Avery** as Hester Lane, on the garden steps in Act III.',
    'An actress in a long gown on a garden stage set, one arm outstretched.'),
  clip('keeper', asset('keeper-loop.mp4'), [640, 480],
    { position: 'top', span: 'column', columns: 3 },
    '**Tom Rundle** at Gannet Park, before the penalty he saved.',
    'A goalkeeper in a flat cap crouches on his line in front of a packed terrace.'),
];

The layout is layoutType: 'multiple' with columnCount: 4. A float's columns sets how many of them it takes: the mayor and the goalkeeper three, the aviator, the clock tower, the fisherman and the actress two. A float across several columns takes the head of a run of empty columns that start level, or the foot of the column it is cited in and the empty ones after it, so where the ::resource line stands decides the slot. The mayor is cited after the first paragraph, which leaves column 1 to the text and puts the picture over columns 2 to 4.

#3 · Set the masthead and the section pages

script.js · lines 130–146in full code
const nameplate = { enabled: true, slot: { elements: [
  depth(10), // the masthead is ten grid lines deep; the rules and the dateline hang from it
  { kind: 'rule', id: 'foot', thickness: pt(2.5), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -1.5), size: { width: 'fill' } } },
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.price}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...typed(9, { fontWeight: 700 }),
      align }, { ...at('#foot', 'above', 0, -1.6), size: { width: 'fill' } })),
  rule('thin', '#date-left', 0.6, 1.8),
  text('name', '{titleText}', { ...oswald(66, 700, { textTransform: 'uppercase',
    letterSpacing: pt(1.5) }), lineHeight: 1, align: 'center' },
  { ...at('#thin', 'above', 0, -2), size: { width: 'fill' } }),
  // Two ears over the name: the paper's age on the left, the weather on the right.
  text('since', '{attr.since}', { ...caps(8, 600, 'flag'), align: 'left' },
    { ...at('container', 'top-left', 0, 1), size: { width: mm(90) } }),
  text('weather', 'Weather: {attr.weather}', { ...typed(8.5, { italic: true }), align: 'right' },
    { ...at('container', 'top-right', 0, 1), size: { width: mm(110) } }),
] } };

The front page's level-1 heading is the paper's name, drawn from a design slot: two ears above it, the name in Oswald capitals, and a dateline between a thin and a heavy rule. The inside pages open with a red section flag on a 3 pt bar (the pages region), and a folio line at the head of every page but the first.

#4 · Open the paper in Folio

script.js · lines 566–575in full code
const stage = Object.assign(document.createElement('section'),
  { id: 'folio', ariaLabel: 'The newspaper in 3D' });
stage.style.cssText = 'height: min(82vh, 820px); margin: 0 auto; max-width: 1280px';
document.getElementById('pages').before(stage);
createFolioFromDocument(stage, doc, {
  // Folio plays each clip from its video.url, muted, while its page is on show.
  appearance: { textureBaseUrl: 'https://postext.dev/folio/textures' },
  onChange: ({ pages }) => kitStatus(`${doc.pages.length} pages · open at `
    + pages.map((i) => doc.pages[i].pageLabel || i + 1).join('–')),
});

config.folio lays the paper on oak in daylight: 45 g/m² newsprint, which the next page shows through, and binding.type: 'folded', sheets folded once and laid one inside the other with no staples and no spine, the way a newspaper is put together. Folio draws every clip on the open spread into one texture, up to eight at a time, starts the autoplaying loops each time a turn settles on their page and stops them when the page is turned away. A click on an exclusive video still plays it alone, with its sound, and the loops wait until it stops.

The whole recipe

Sandbox
// ═══ Postext Cookbook · Nº 136 · A living newspaper of video loops ══════════════
// https://postext.dev/en/cookbook/living-newspaper-video-loops
// Code: MIT · Text: original (CC BY 4.0) · Clips: generated with diffusion models (CC BY 4.0)
// Fonts: Oswald, Libre Caslon Text, Courier Prime (SIL OFL 1.1) · Needs postext ≥ 1.18.0
import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage }
  from 'https://esm.sh/postext';
import { createFolioFromDocument } from 'https://esm.sh/postext-folio';

const LANG = 'en'; // @lang: the language of the sample document ('en')
const RECIPE = 'living-newspaper-video-loops';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: newsprint, a warm black and a dark red for the flags
const palette = {
  ink: '#16130f', // text, rules and the nameplate
  paper: '#f3efe4', // newsprint, and type reversed out of ink or red
  flag: '#8e1b14', // the one accent: section flags, kickers, the jump lines
  tint: '#e6dfcf', // the In brief strip
  rule: '#8f897c', // hairlines: the column rules
  muted: '#5b554b', // bylines, captions' credits, the imprint
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [ // defaults link to 'main-color': point it at the ink, never blue
  ...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
const MARGIN = { top: 12, bottom: 12, side: 12 }; // mm: a tabloid's narrow margins
const GUTTER = 4.5; // mm between the four columns
const LEAD = 11.6; // body leading in pt: the baseline grid
const oswald = (size, weight, look = {}) => ({ fontFamily: 'Oswald', fontSize: pt(size),
  fontWeight: weight, color: col('ink'), ...look });
const caps = (size, weight, colour = 'ink') => oswald(size, weight, { color: col(colour),
  textTransform: 'uppercase', letterSpacing: pt(size * 0.12) });
const typed = (size, look = {}) => ({ fontFamily: 'Courier Prime', fontSize: pt(size),
  color: col('ink'), ...look }); // the dateline and the captions: a typewriter
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const text = (id, content, look, placement) => ({ kind: 'text', id, content, align: 'left',
  overflow: 'wrap', color: col('ink'), ...look, placement }); // never the ellipsis default
const rule = (id, under, weight, gap = 0) => ({ kind: 'rule', id, thickness: pt(weight),
  color: col('ink'), placement: { ...at(under, 'above', 0, -gap), size: { width: 'fill' } } });
const depth = (lines) => ({ kind: 'box', id: 'depth', style: {}, placement: {
  ...at('container', 'top-left'), size: { width: 'fill', height: pt(lines * LEAD) } } });

// #region answer: every picture is a clip that loops, autoplays and plays with the others
// A video resource prints its poster (the first frame) on the flat page. In Folio, and in the
// HTML viewer, the poster comes alive: autoplay starts it muted when its page is on show, loop
// keeps it going, and exclusive: false lets it run alongside the other clips instead of
// pausing them, as one exclusive player at a time would.
const videoStyle = {
  playMark: { enabled: false }, // a press photograph, not a play button
  qr: { enabled: false }, // the clips live on the page, not behind a link
  linkPoster: false,
  player: { autoplay: true, loop: true, muted: true, exclusive: false, controls: false },
};
const clip = (id, url, [w, h], placement, caption, altText) => ({ id, typeId: 'picture',
  kind: 'video', createdAt: 0, updatedAt: 0, caption, altText, placement,
  video: { source: 'file', fileId: `${id}-loop.mp4`, url, // url: where the clip plays from
    format: 'mp4', width: w, height: h, duration: 5,
    poster: { fileId: `${id}-poster.jpg`, format: 'jpeg', width: w, height: h } } });
// #endregion

// #region clips: where each loop sits on the four-column grid
const resources = [
  clip('mayor', asset('mayor-loop.mp4'), [640, 480],
    { position: 'top', span: 'column', columns: 3 },
    '**Mrs Edith Rowe** speaks from the platform at the foot of Quay Hill before cutting '
      + 'the ribbon.',
    'A woman in a dark coat and small hat speaks at a draped podium, one hand raised.'),
  clip('clock', asset('clock-loop.mp4'), [480, 640],
    { position: 'top', span: 'column', columns: 2 },
    '**Twelve o’clock** on the Guildhall tower, for the first time since 1924.',
    'A stone clock tower against clouds, pigeons on its ledge.'),
  clip('fisherman', asset('fisherman-loop.mp4'), [640, 480],
    { position: 'auto', span: 'column', columns: 2 },
    '**Skipper Jacob Hosking** and the 82-pound cod on the quay.',
    'An old fisherman in an oilskin laughs, holding up a huge cod.'),
  clip('aviator', asset('aviator-loop.mp4'), [640, 480],
    { position: 'top', span: 'column', columns: 2 },
    '**Miss Constance Lacey** waves from the cockpit on Wexcombe Common.',
    'A young woman in a flying helmet waves from the open cockpit of a biplane.'),
  clip('actress', asset('actress-loop.mp4'), [480, 640],
    { position: 'top', span: 'column', columns: 2 },
    '**Miss Rosalind Avery** as Hester Lane, on the garden steps in Act III.',
    'An actress in a long gown on a garden stage set, one arm outstretched.'),
  clip('keeper', asset('keeper-loop.mp4'), [640, 480],
    { position: 'top', span: 'column', columns: 3 },
    '**Tom Rundle** at Gannet Park, before the penalty he saved.',
    'A goalkeeper in a flat cap crouches on his line in front of a packed terrace.'),
];
// #endregion

// The three small tables: the week's tides, the picture houses and the cricket.
const cell = (content, align = 'left') => ({ content, align });
const head = (content, align = 'left') => ({ content, align, isHeader: true });
const table = (id, caption, columnWidths, rows, placement) => ({ id, typeId: 'picture',
  kind: 'table', createdAt: 0, updatedAt: 0, caption, placement, table: { model: {
    headerRowCount: 1, columnWidths, rows: [rows[0].map((h, i) => head(h, i ? 'right' : 'left')),
      ...rows.slice(1).map((r) => r.map((c, i) => cell(c, i ? 'right' : 'left')))] } } });
resources.push(
  table('tides', '**High water at Wexcombe Quay**', [1.2, 1, 1, 0.9], [
    ['Day', 'Morning', 'Evening', 'Feet'], ['Sat 10', '5.12', '5.38', '16.4'],
    ['Sun 11', '5.58', '6.21', '16.9'], ['Mon 12', '6.41', '7.03', '17.2'],
    ['Tue 13', '7.23', '7.44', '17.3'], ['Wed 14', '8.04', '8.25', '17.0']],
  { position: 'here' }),
  table('pictures', '**This week at the picture houses**', [1, 2.2, 1.3], [
    ['House', 'Programme', 'Shows'], ['Regal', 'All Aboard for Lisbon', '2.30, 6, 8.30'],
    ['Electric', 'The Lone Rider of Red Mesa', '6.15, 8.40'],
    ['Both', 'Children’s matinée', 'Sat 10.30']], { position: 'here' }),
  table('wireless', '**The Regional programme**', [0.8, 2.6], [
    ['Time', 'Programme'], ['6.0', 'The Children’s Hour'], ['6.40', 'News and weather'],
    ['7.0', 'Cornish Songs, by the Wexcombe Male Voice Choir'],
    ['7.45', 'Talk: The Coasting Trade, by a ship’s master'],
    ['8.15', 'Dance Music from the Assembly Rooms'], ['10.0', 'Second news; shipping']],
  { position: 'here' }),
  table('fixtures', '**This week’s fixtures**', [1.1, 2.3, 1.1], [
    ['Day', 'Match', 'Ground'], ['Sat 10', 'Cricket: Wexcombe v. Truro', 'The Rec'],
    ['Wed 14', 'Bowls: Park v. Hayle', 'Park green'],
    ['Sat 17', 'Cricket: Redruth v. Wexcombe', 'Redruth'],
    ['Sat 24', 'County Cup final: Athletic v. Rovers', 'Gannet Park']], { position: 'here' }),
  table('wheelers', '**Fifty miles: the first five**', [0.3, 1.2, 1.9], [
    ['', 'Rider', 'Time'], ['1', 'L. Uren', '2 h 11 m 40 s'], ['2', 'T. Bray', '2 h 14 m 05 s'],
    ['3', 'S. Harvey', '2 h 15 m 52 s'], ['4', 'C. Nance', '2 h 19 m 31 s'],
    ['5', 'R. Jenkin', '2 h 20 m 18 s']], { position: 'here' }),
  table('scores', '**Wexcombe v. St Austell**', [1.6, 0.7, 1.7], [
    ['Innings', 'Runs', 'Best'], ['Wexcombe', '168', 'Dunstan 64'],
    ['St Austell', '127', 'Carne 5 for 22']], { position: 'here' }));

// #region nameplate: the front page's H1 is the paper's name, ruled above and below
const nameplate = { enabled: true, slot: { elements: [
  depth(10), // the masthead is ten grid lines deep; the rules and the dateline hang from it
  { kind: 'rule', id: 'foot', thickness: pt(2.5), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -1.5), size: { width: 'fill' } } },
  ...[['left', '{attr.issue}'], ['center', '{publishDate}'], ['right', '{attr.price}']].map(
    ([align, content]) => text(`date-${align}`, content, { ...typed(9, { fontWeight: 700 }),
      align }, { ...at('#foot', 'above', 0, -1.6), size: { width: 'fill' } })),
  rule('thin', '#date-left', 0.6, 1.8),
  text('name', '{titleText}', { ...oswald(66, 700, { textTransform: 'uppercase',
    letterSpacing: pt(1.5) }), lineHeight: 1, align: 'center' },
  { ...at('#thin', 'above', 0, -2), size: { width: 'fill' } }),
  // Two ears over the name: the paper's age on the left, the weather on the right.
  text('since', '{attr.since}', { ...caps(8, 600, 'flag'), align: 'left' },
    { ...at('container', 'top-left', 0, 1), size: { width: mm(90) } }),
  text('weather', 'Weather: {attr.weather}', { ...typed(8.5, { italic: true }), align: 'right' },
    { ...at('container', 'top-right', 0, 1), size: { width: mm(110) } }),
] } };
// #endregion

// #region pages: a section flag on each inside page, a folio line at the head
const section = { enabled: true, slot: { elements: [
  depth(3),
  { kind: 'rule', id: 'bar', thickness: pt(3), color: col('ink'),
    placement: { ...at('#depth', 'align-bottom', 0, -2), size: { width: 'fill' } } },
  text('flag', '{titleText}', { ...caps(13, 700, 'paper'), box: { backgroundColor: col('flag'),
    padding: { top: mm(1.4), right: mm(3.2), bottom: mm(1.2), left: mm(3.2) } } },
  at('#bar', 'above')),
] } };
const folio = (parity, side, s) => [
  text(`n-${parity}`, '{pageNumber}', { ...oswald(11, 700), align: side },
    at('page', `top-${side}`, s * MARGIN.side, 5)),
  text(`t-${parity}`, '{title} · {publishDate}', { ...typed(8), align: side },
    at(`#n-${parity}`, s > 0 ? 'right-of' : 'left-of', s * 3, 0.7)),
].map((element) => ({ ...element, parity }));
const header = { elements: [...folio('even', 'left', 1), ...folio('odd', 'right', -1)] };
// #endregion

const banner = { id: 'banner', backgroundEnabled: false, marginTop: pt(0),
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) },
  marginBottom: pt(LEAD / 2), titleStyle: { ...caps(9, 700, 'flag'), gap: mm(1.4) },
  body: { fontFamily: 'Libre Caslon Text', fontSize: pt(12), lineHeight: pt(15),
    textAlign: 'center', firstLineIndent: pt(0) } };
const briefs = { id: 'briefs', background: col('tint'), columnGap: mm(GUTTER),
  padding: { top: mm(3), right: mm(4), bottom: mm(4), left: mm(4) },
  titleStyle: { ...caps(10, 700, 'flag'), gap: mm(2) },
  body: { fontFamily: 'Libre Caslon Text', fontSize: pt(8.4), lineHeight: pt(11),
    textAlign: 'left', firstLineIndent: pt(0) } };
const headline = (id, size, look = {}) => ({ id, fontFamily: 'Oswald', fontWeight: 600,
  fontSize: pt(size), lineHeight: pt(size * 1.05), marginBottom: pt(size * 0.3), ...look });
// A box in a column (the tram fares), and the small advertisements across the page's foot.
const info = { id: 'info', background: col('tint'), marginTop: pt(LEAD), marginBottom: pt(LEAD),
  padding: { top: mm(2.5), right: mm(3), bottom: mm(3), left: mm(3) },
  stripe: { enabled: true, side: 'top', width: pt(2.5), color: col('flag') },
  titleStyle: { ...oswald(11, 600), gap: mm(1.5) },
  body: { fontFamily: 'Libre Caslon Text', fontSize: pt(8.4), lineHeight: pt(11),
    textAlign: 'left', firstLineIndent: pt(0) } };
const ads = { ...briefs, id: 'ads', background: col('paper'),
  border: { enabled: true, width: pt(0.6), color: col('ink') },
  body: { ...typed(8), lineHeight: pt(10.6), textAlign: 'left', firstLineIndent: pt(0) } };
const label = { fontFamily: 'Courier Prime', fontSize: pt(8), firstLineIndent: pt(0) };

const config = () => ({ // a factory: the engine caches resolved configs per object
  colorPalette, resourceTypes, videoStyle,
  page: { sizePreset: 'tabloid', dpi: 150, backgroundColor: col('paper'), // 280 × 430 mm
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.side),
      right: mm(MARGIN.side) } },
  layout: { layoutType: 'multiple', columnCount: 4, gutterWidth: mm(GUTTER),
    columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } },
  bodyText: { fontFamily: 'Libre Caslon Text', fontSize: pt(8.8), lineHeight: pt(LEAD),
    color: col('ink'), boldColor: col('ink'), textAlign: 'justify', firstLineIndent: mm(3),
    indentAfterHeading: false, hyphenation: { enabled: true }, optimalLineBreaking: true,
    minWordSpacing: 0.75, maxWordSpacing: 1.6, avoidWidows: true, avoidOrphans: true,
    maxJustifyTracking: 15 }, // ‰ of an em: narrow columns justify with a little tracking
  headings: { fontFamily: 'Oswald', fontWeight: 600, marginBottom: pt(0), levels: [
    // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
    { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
      advancedDesign: section },
    { level: 2, fontSize: pt(20), lineHeight: pt(22), marginTop: pt(LEAD) },
    { level: 3, fontSize: pt(10.5), lineHeight: pt(LEAD), fontWeight: 700,
      marginTop: pt(LEAD / 2) },
  ] },
  headingStyles: [
    { id: 'front', advancedDesign: nameplate, header: { elements: [] } },
    { id: 'section' }, // inside pages: the flag of the level-1 design
    headline('lead', 44, { fontWeight: 700 }), headline('second', 20),
    headline('third', 12, { marginBottom: pt(2) }),
  ],
  calloutStyles: [banner, briefs, info, ads],
  tableStyle: { rules: 'horizontal', borderColor: col('rule'), borderWidth: pt(0.5),
    headerBackground: col('ink'), headerColor: col('paper'), headerFontFamily: 'Oswald',
    headerFontSize: pt(7.5), bodyFontFamily: 'Courier Prime', bodyFontSize: pt(7.8),
    cellPadding: mm(1.1) },
  paragraphStyles: [{ ...label, id: 'byline', textAlign: 'left', color: col('muted') },
    { id: 'flush', firstLineIndent: pt(0) },
    { ...label, id: 'jump', textAlign: 'right', color: col('flag') },
    { id: 'imprint', ...label, fontSize: pt(6.8), lineHeight: pt(9), textAlign: 'left',
      color: col('muted'), marginTop: pt(LEAD) }],
  captionStyle: { fontFamily: 'Courier Prime', fontSize: pt(7.8), gap: mm(1.4),
    note: { fontSize: pt(6.6), color: col('muted') } },
  header, footer: { elements: [] },
  folio: { tilt: 24, paper: { type: 'newsprint', grammage: 45, shade: '#f9f7f1' },
    binding: { type: 'folded', cover: 'pages' }, surface: { type: 'oak' },
    lighting: { environment: 'daylight' } },
});
// An unnumbered type: captions print without "Figure 1".
const resourceTypes = [{ id: 'picture', name: 'Picture', shortLabel: 'Picture',
  captionPrefix: '', numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }];

// ─── 2 · Content ────────────────────────────────────────────────────────────
const front = String.raw`---
Markdown sample · 147 lines · content.en.mdtitle: "The Wexcombe Lantern" author: "The Wexcombe Lantern" publishDate: "Saturday 10 June 1933" --- # The Wexcombe Lantern {style="front" issue="No. 4,117" since="Established 1871" price="Twopence" weather="Fair, a fresh breeze from the west"} :::callout{type="banner" span="page" title="The Harbour Line"} ## Trams run to the sea at last {style="lead"} *Mayor Edith Rowe opens the new line before four thousand people, and drives the first car down Quay Hill herself* ::: :::paragraphs{style="byline"} By **Arthur Penhale** · Municipal Correspondent ::: :::paragraphs{style="flush"} The trams reached the harbour yesterday, eleven years after the Corporation first promised them. At a quarter past eleven the Mayor, Mrs Edith Rowe, cut a ribbon of green silk across the rails at the foot of Quay Hill, and a crowd that stretched back past the Fish Market cheered loud enough to lift the gulls off the warehouse roofs. ::: ::resource{id="mayor"} “It has taken us longer than the Romans took to build their roads,” Mrs Rowe told the crowd from a platform dressed in bunting, “and our road is only a mile and three furlongs long. But it is ours, it is paid for, and from Monday a docker in Saltings Row can be at the Town Hall in nine minutes for a penny.” The Harbour line leaves the main route at Market Cross and runs down Quay Hill, along the Strand and out to a new turning loop beside the lifeboat house. Four cars, rebuilt in the Corporation’s own works at Fennick Lane, will run every eight minutes from six in the morning until half past eleven at night. The line cost £41,300, of which £12,000 came from the government’s grant for public works. Some two hundred men of the town were taken on to lay it, most of them from the register at the Labour Exchange, and the Borough Engineer, Mr Lionel Cardew, said that not one day had been lost to accident since the first sett was lifted in October. :::paragraphs{style="jump"} A hill too steep, and the fares: **page 2** ::: ::resource{id="aviator"} ## Miss Lacey lands on the Common {style="second"} :::paragraphs{style="flush"} The aviator Miss Constance Lacey brought her biplane down on Wexcombe Common at twenty past six on Wednesday evening, at the end of a flight from Croydon of three hours and fifty minutes. She came in low over the cricket pavilion, scattering a game of the Wednesday Eleven, and rolled to a stop forty yards from the bandstand. ::: Miss Lacey, who is twenty-four, is flying round the coast of England and Wales in stages to raise money for the lifeboat fund, and has so far raised £1,108. “The Common is the best field I have met since Kent,” she said, “though the cows took some persuading.” Her machine is a two-seater of 1929 with a Gipsy engine, bought second-hand from a flying club in Hampshire and named *Kittiwake*. She navigates with a road map folded on her knee, and follows the railway lines where the coast turns misty. “The Great Western has never once let me down,” she said. “I cannot say the same for the weather.” She stayed the night at the Mermaid, where the landlord refused to take her money, and left at seven the next morning for Penzance, waving to a crowd of schoolchildren who had been let out of lessons to see her off. Two of them had sat up all night on the pavilion roof to be sure of a good view. The pavilion has asked for a signed photograph for its wall, and the Wednesday Eleven for a replay. ### The weather Fair at first, with a fresh breeze from the west; cloud later and a little rain in the evening, clearing overnight. Sea moderate, with a swell on the bar at low water. Sunrise 4.58, sunset 9.31. Yesterday’s highest temperature in the town was 68 degrees, at the Fish Market at three o’clock, and the lowest 51, at the coastguard station at dawn. The Harbour Master reports the best visibility of the year from Gannet Point, where the Stags could be seen plainly at noon and the smoke of the Scilly packet an hour before she was due. *Tomorrow:* bright, a light northerly air, good drying weather. Bathers are reminded that the red flag on West Sands means what it says. :::callout{type="briefs" span="page" placement="bottom" title="In brief"} :::columns{count=4 breaks="2,3,4"} **Lighthouse dark for a night.** The Gannet Point light will be shut down on Tuesday from dusk to midnight while its new lamp is fitted. Ships are warned by the Harbour Master. **Bathing tents return.** The striped bathing tents are back on West Sands for the summer, at threepence the hour. Mixed bathing is allowed east of the groyne only. **Choir to Cardiff.** The Wexcombe Male Voice Choir sails on Thursday for the Eisteddfod. Forty-two singers, one harmonium and the conductor’s spaniel will make the crossing. **Lost umbrella.** A lady’s umbrella with an ivory handle in the shape of a heron was left in car No. 4 on Monday. It may be claimed at the Depot, Fennick Lane. ::: ::: # Town & Harbour {style="section"} ## Trams: a hill too steep, they said {style="second"} :::paragraphs{style="byline"} Continued from page 1 ::: Many doubted that a tram could manage Quay Hill at all. The gradient is one in eleven at its worst, and a petition of 1928 warned of runaway cars and ruin in the Fish Market. Mr Cardew’s answer was a track brake of his own design, tried on the hill for six weeks this spring with cars loaded with sandbags. Mrs Rowe silenced the doubters in the simplest way. After the ribbon was cut she climbed to the front platform of car No. 9, took the controller from the motorman and drove the first car down the hill herself, at a steady walking pace and to a roar from the pavements. She is believed to be the first Mayor of Wexcombe to drive anything at all. ## The clock that stopped for nine years strikes again {style="second"} :::paragraphs{style="byline"} By **Mary Tregarthen** ::: :::paragraphs{style="flush"} For nine years the clock on the Guildhall tower has said twenty to four. Yesterday, at noon precisely, it said twelve, and the bell in the cupola struck the hour for the first time since the great gale of 1924 bent its hammer and cracked the escapement wheel. ::: ::resource{id="clock"} The work was done by Mr Samuel Opie, clockmaker, of Chapel Street, and his two sons, who have climbed the tower every evening since February. “The wheel was cast in 1799, and nobody casts them like that now,” said Mr Opie. “So we cast it like that ourselves.” The new hammer was forged at the Harbour Works from an old capstan bar. A small crowd had gathered in Guildhall Square by a quarter to twelve, many of them with their watches out. When the first stroke sounded there was a moment of silence, and then a cheer, and Mr Opie’s younger son was seen to wave his cap from the louvres of the belfry. The Town Clerk said that the old joke, that Wexcombe was the only town in England where it was always teatime, would now have to be retired. The pigeons, he added, would have to get used to the bell. ## A cod the size of a boy {style="second"} :::paragraphs{style="flush"} The largest cod landed at Wexcombe in living memory came ashore on Thursday in the hold of the *Morning Star*, skipper Jacob Hosking, 71. It weighed 82 pounds on the Fish Market scales and measured five feet and an inch from its nose to the fork of its tail. ::: ::resource{id="fisherman"} “I’ve fished these grounds since I was eleven,” Mr Hosking said, “and I never saw his like. He fought for three-quarters of an hour, and I reckon he’d have won if my grandson hadn’t been there to hold my belt.” The fish was sold to Messrs Kitto of the Strand for a guinea and is to be shown in their window, packed in ice, until Monday. A photograph has been sent to the Natural History Museum, which has asked whether the skipper might part with the head. Mr Hosking says he will think about it. ## Council votes for an open-air bathing pool {style="second"} The Town Council on Tuesday voted by eighteen votes to seven to build an open-air bathing pool at Gannet Point, in the hollow of the old quarry below the coastguard cottages. The pool will be filled by the tide through a sluice, and will measure fifty yards by twenty, with diving stages and a café. The Borough Engineer puts the cost at £9,600. Councillor Bastian, who led the opposition, said the town already had a perfectly good sea. Councillor Mrs Pascoe replied that the sea was perfectly good only in August, and that children had been drowned in it. Work is to begin in October, so that the pool may open for the summer of 1934. Seventy men will be employed. ## Lifeboat out twice in one night {style="second"} The Wexcombe lifeboat *Sarah Jolly* was launched twice on Tuesday night, the first time at a quarter to eleven to the yacht *Petrel*, whose engine had failed off Gannet Point with three men aboard, and the second at half past two to a fishing boat reported overdue from the mackerel grounds. The *Petrel* was towed into harbour shortly after midnight. The fishing boat, the *Brothers’ Pride*, was found at anchor in the lee of the Stags, her crew asleep. “They had a good catch and saw no reason to hurry,” said the coxswain, Mr William Bassett. “I told them what I thought of that, and then I gave them a tow, because the tide was turning.” The crew of twelve were back in their beds by five. It was the boat’s fourteenth service this year. ## Fire at the rope works {style="second"} A fire broke out in the tarring shed of Messrs Pengelly’s rope works on Fennick Lane shortly after four on Thursday afternoon, and for an hour threatened the whole of the long rope walk beside it. The alarm was given by a boy delivering newspapers, who saw smoke coming from under the door and ran to the fire station in Market Street, a distance of three hundred yards, in what the Chief Officer called the best time he had ever seen. The brigade had two jets on the shed within ten minutes. The roof fell in at a quarter to five, but the fire was kept from the rope walk, where some forty coils of new manila rope were stored for the harbour, and the walls of the shed are standing. Mr Pengelly puts the damage at £300, and says that work will go on as usual on Monday, in the open air if it must. The boy, Jimmy Rowse, aged twelve, has been given a reward of five shillings by the firm and a ride on the engine by the brigade. He said he preferred the ride. :::callout{type="info" title="Harbour line: what you need to know"} Cars run every eight minutes from Market Cross to the Lifeboat House, calling at Quay Hill, the Fish Market, the Custom House and Strand Corner. The fare is one penny all the way, a halfpenny for children under fourteen. Workmen’s returns at three-halfpence are sold before eight in the morning. On Sundays cars start at nine. ::: ::resource{id="tides"} ## From the Guildhall {style="third"} The Watch Committee has fixed the speed of the trams on Quay Hill at four miles an hour going down and eight coming up, and a man is to stand at the Fish Market corner with a red flag on market days until the public is used to the cars. The Finance Committee reports that the rates will not rise this year, for the first time since 1927, and the Libraries Committee that the new reading room at Saltings Row has lent 2,140 books in its first month, chiefly detective stories. A proposal to ring the restored Guildhall bell at midnight on New Year’s Eve was carried with one vote against, from the councillor who lives next door. ## Shipping {style="third"} **Arrived:** *Mary Jane*, ketch, from Swansea with coal; *Goldfinch*, steamer, from Rotterdam with timber; *Ellen*, schooner, from Fowey with china clay for the potteries; *Pride of Wexcombe*, trawler, from the Smalls with a full hold. **Sailed:** *Morning Star* and *Brothers’ Pride* for the mackerel grounds; *Lynher*, barge, for Plymouth with pit props; *Goldfinch*, in ballast, for Cardiff. **In harbour:** the training ship *Exe*, until Wednesday; visitors on board each afternoon from two, and the band of the ship plays on the quarterdeck at four. **Fish Market:** mackerel 9s. the box, pilchards 6s., hake scarce at 2s. 6d. the stone; crab plentiful and cheap.
`; // content.en.md: the front page and Town & Harbour const arts = String.raw`# Stage & Screen {style="section"}
Markdown sample · 86 lines · content.stage.en.md ## A Lady of the Garden at the Pavilion {style="second"} :::paragraphs{style="byline"} Reviewed by **Harold Vosper** ::: :::paragraphs{style="flush"} The Pavilion Players opened their summer season on Monday with *A Lady of the Garden*, a comedy in three acts by a playwright who prefers to remain anonymous, and whose modesty the second act fully justifies. The first and third, however, are a delight, and the evening belongs to Miss Rosalind Avery. ::: ::resource{id="actress"} As Miss Hester Lane, a gardener’s daughter who inherits the great house where her father kept the roses, Miss Avery is by turns wistful, imperious and very funny. Her long speech to the assembled relations in the third act, delivered from the top of the garden steps with one arm flung out towards the vegetable beds, stopped the play for a full minute of applause on the first night. Mr Frank Polglase is a fine, glowering butler, who conveys more with one raised eyebrow than the second act manages with its whole cast, and Miss Daisy Kitto, as the cousin who expected to inherit, is spiteful in the best possible way. The painted garden by Mr Tom Rule, the company’s carpenter, is so convincing that a lady in the second row was heard to ask where she might buy the delphiniums. The second act is another matter. It is set in a solicitor’s office, it lasts fifty minutes, and it consists almost entirely of people reading a will aloud to one another. Miss Avery does her best with a scene in which she must faint on to a filing cabinet, but the audience on Monday was plainly waiting for the garden to come back, and when it did, so did the play. The Pavilion itself has been painted for the season, cream and green, and the management has kept its promise of last summer: the seats in the back six rows have been raised on a new floor, so that the shilling public can see the stage over the hats in front. The orchestra of five, under Mr Leonard Jewell, plays a selection from *Bitter Sweet* in the intervals and plays it well. *A Lady of the Garden* runs nightly at eight until the 24th, with a matinée on Wednesdays at half past two. Seats from sixpence to three shillings; the box office at the Pavilion is open from ten. The Players follow it on the 26th with a thriller, *The Lamp in the Lighthouse*, for which the management promises a real storm. ## Band concert on the Parade {style="third"} The Wexcombe Silver Band gives the first of its Sunday concerts on the Parade bandstand tomorrow at three, under its new conductor, Mr Alfred Penna, late of the Royal Marines. The programme includes a selection from *The Gondoliers*, the overture to *Poet and Peasant* and a march of Mr Penna’s own, *The Harbour Line*, written for the opening of the trams and played for the first time in public. Deck chairs twopence; the collection is for the hospital. ## Roses and sweet peas at the Drill Hall {style="third"} The Horticultural Society’s summer show filled the Drill Hall on Thursday with three hundred and forty exhibits, sixty more than last year, and with the scent of sweet peas, which took the whole of the north wall. The challenge cup for roses went to Mr George Nance of Trevenna Lane for six blooms of *Ophelia* that the judges called the best seen in the town since the war, and the cup for vegetables to Mrs Annie Pascoe, whose onions weighed two pounds each. The prize for the best-kept allotment, judged in May, went to the Saltings Row allotments for the second year. In the children’s class for a wild flower collection, every entry received a prize, by order of the President. ## Harbour paintings at the Institute {style="third"} The Wexcombe Art Society’s summer exhibition opened at the Literary Institute on Wednesday, with a hundred and twelve pictures by forty-one members, the largest show in the society’s history. The harbour, as ever, is the favourite subject: there are nineteen views of it, at every hour and in every weather, and one, by Miss Phyllis Body, painted from the top of the Guildhall tower while the clockmakers were at work. The prize for the best picture in oils went to Mr Ernest Lugg for *Pilchard Boats, Evening*, and the prize for water-colours to Mrs Jane Treloar, whose *Quay Hill in Rain* shows the new tram lines glistening, two days before they were finished. The exhibition is open daily from ten until six, until the 1st of July. Admission threepence; members free. ## On the screen this week {style="second"} ::resource{id="pictures"} At the **Regal**, the picture of the week is a musical comedy of shipboard life, with the news and a coloured cartoon. At the **Electric**, a western in the old style, and a serial whose heroine has now been tied to a railway line for three weeks running. Children’s matinée on Saturday mornings at both houses, price twopence. ## Wireless this evening {style="third"} ::resource{id="wireless"} ## Letters to the Editor {style="second"} **Sir,** — Now that we have trams to the harbour, may I ask when we are to have a seat at the top of Quay Hill? Those of us who walk up it, as I have done every morning for forty years, would be glad of somewhere to recover. — *Yours, &c.,* **J. Trevenna**, Saltings Row. **Sir,** — Your correspondent of last week complains of the noise of the gulls on the Strand. I would remind him that the gulls were here first, and that they do not, so far as I know, write to the newspapers about him. — *Yours, &c.,* **Ellen Rodda**, Fore Street. **Sir,** — I see the Guildhall clock is going again. Might the Council now turn its attention to the one at the station, which has been five minutes fast since the Coronation and has caused me to miss nothing, but has caused my wife to wait for a great many trains? — *Yours, &c.,* **H. B. Clemo**, The Parade. **Sir,** — May I, through your columns, thank the young man who carried my shopping basket from Market Cross to the top of Chapel Street on Tuesday and refused to give his name? I hope he reads the *Lantern*. — *Yours, &c.,* **Mary Tonkin**, Chapel Street. **Sir,** — I have read in your paper that the new trams are painted green and cream. Mine, I see, is painted green and cream also: the colour came through the hedge from the Corporation’s works in Fennick Lane, where they paint the cars with the windows open. Who pays for my washing? — *Yours, &c.,* **E. J. Hosken**, Fennick Lane. **Sir,** — Your report of the Harbour line says the cars will run until half past eleven at night. Will somebody tell the Regal, whose second house does not come out until twenty to twelve? — *Yours, &c.,* **A Picturegoer**, Fennick Lane. :::callout{type="ads" span="page" placement="bottom" title="Small advertisements"} :::columns{count=4 breaks="4,7,10"} **TO LET.** Furnished rooms, sea view, use of piano. Suit quiet gentleman. Apply Mrs Jose, 3 Marine Terrace. **BICYCLE** for sale, lady’s, three-speed, little used, £2 10s. or near offer. Box 112, Lantern Office. **WANTED,** a smart boy to deliver groceries, must be able to ride. Apply Hicks & Son, Market Street. **DANCING** every Saturday at the Assembly Rooms, eight till midnight, Billy Rowse and his Melodians. Admission one shilling. **PIANO TUNING** by an experienced man, late of Broadwood’s. Terms moderate. Write J. Kemp, 9 Chapel Street. **LOST** on the Common, Wednesday evening, a child’s red woollen glove. Finder please return to the Mermaid. Reward. **MOTOR COACH** to St Ives every Thursday, leaving Market Cross at 9.30, returning by 7. Fare 3s. 6d. Book at Rodda’s Garage. **FOR SALE,** pram, navy blue, good springs, 25s. Also cot. Apply after six, 21 Saltings Row. **WIRELESS SETS** repaired and recharged. Accumulators called for and delivered weekly. Tregenza, 4 Fore Street. **HOLIDAY APARTMENTS,** two bedrooms and sitting room, near sands, July vacant. Mrs Bray, Seaview, The Parade. **GENTLEMAN’S** gold watch, lost between the Rec and the Mermaid on Saturday. Sentimental value. Reward at this office. **DRESSMAKING** and alterations, ladies’ and children’s, at moderate charges. Miss Uren, 12 Lemon Street. ::: :::
`; // content.stage.en.md: Stage & Screen const sport = String.raw`# Sport {style="section"}
Markdown sample · 74 lines · content.sport.en.md ## Rundle saves the Cup {style="second"} :::paragraphs{style="byline"} By **Walter Jago** · at Gannet Park ::: :::paragraphs{style="flush"} Wexcombe Athletic are in the final of the County Cup, and they owe it to Tom Rundle. With the score at one goal each and four minutes left on a pitch like a ploughed field, the referee gave a penalty against the home side for a handball nobody else in the ground saw. ::: ::resource{id="keeper"} Penrose of Treviskey placed the ball, took four paces back and struck it hard and low towards the right-hand post. Rundle, who is a carpenter by trade and the shortest goalkeeper in the league, went full length into the mud and held it against his chest. The crowd of 6,200, the largest at Gannet Park since the war, threw caps in the air, and one man was seen to throw his wife’s hat. From the clearance Athletic broke down the left, and young Billy Trounce, who is seventeen, ran half the length of the field before shooting past the Treviskey goalkeeper for the winner. He was carried off the pitch at the final whistle by supporters who had climbed the railings, and did not get his boots back until Sunday. The first half had belonged to Treviskey. Their centre-forward, Hendra, scored after nine minutes with a header from a corner, and only a fine save by Rundle from the same player, low down at the near post, kept the score at one goal to nil at the interval. Athletic were level ten minutes after the restart, when Opie, son of the clockmaker, turned in a cross from Trounce at the second attempt. The final, against Penhallow Rovers, will be played at Gannet Park on the 24th, kick-off at three. The club asks supporters to come early and to leave their wives’ hats at home. ## Rovers come by the long road {style="third"} Penhallow Rovers, Athletic’s opponents in the final, have needed three replays to get there. They drew with St Just twice, the second time after extra time in a thunderstorm, and beat them at the third attempt at Camborne, where the match was stopped for a quarter of an hour while a horse was led off the pitch. Their semi-final against Porthleven was settled by a goal from their captain, Arthur Kitto, the Penhallow blacksmith, who is forty-one and says this will be his last season, as he has said for the past four. Rovers have the stronger side on paper, with three men who have played for the county, and they beat Athletic twice in the league this winter. But they have never won the Cup, and they have never played at Gannet Park, where the slope from the town end is worth a goal to anyone who knows it. Athletic’s trainer, Mr Sam Rowe, has asked the groundsman not to cut the grass this week. Tickets for the stand, at a shilling, go on sale at Hicks & Son on Monday morning, and the club expects them to be gone by dinner time. ## The Harbour Mile {style="third"} The Harbour Mile, run on Whit Monday along the Strand from the Custom House to the lifeboat slip and back, was won for the third year running by Jack Penrose of the Wexcombe Harriers in 4 minutes 31 seconds, a record for the course. He was followed home by his brother Harry, eleven yards behind, and by a visiting runner from Truro, who said that he had been told the course was flat. Forty-four ran, the youngest fourteen and the oldest sixty-two, and every one of them finished, though two had to be helped up the slip. The new trams were stopped for twenty minutes while the race passed, the first time, said the Borough Engineer, that the line has given way to anybody. ## Swimming club’s new raft {style="third"} The Wexcombe Amateur Swimming Club has moored a new diving raft off West Sands, forty yards out and built by members over the winter from timber and oil drums given by the Harbour Board. It was christened on Sunday by the club’s oldest member, Mrs Grace Tonkin, who is seventy-three and swam out to it. The club’s summer gala is on the 15th of July; races for juniors, ladies and veterans, and a novelty race in fancy dress. ## Fixtures {style="third"} ::resource{id="fixtures"} ## Cricket {style="third"} ::resource{id="scores"} Wexcombe beat St Austell by 41 runs at the Rec on Saturday. Dunstan’s 64, his first fifty of the season, carried the town to 168, and the Rev. A. Carne took five wickets for 22 with his slow left-arm bowling, which St Austell found as hard to read as his sermons. ## Regatta and bowls {style="third"} The Harbour Regatta is fixed for Saturday the 1st of July, with gig racing, a swimming race across the harbour mouth and the greasy pole. Entries to the Secretary at the Harbour Office by the 24th. At bowls, Wexcombe Park beat Camborne by nine shots in the County League; Mr W. Oates, aged eighty-one, was again the best player on the green, and was presented afterwards with a new set of woods to mark his sixtieth season with the club. He said that he would keep the old ones for wet days. ## Wheelers’ fifty-mile trial {style="third"} The Wexcombe Wheelers held their fifty-mile time trial on Sunday morning over the coast road to Hayle and back, starting at six o’clock to have the road to themselves. Twenty-three riders started at one-minute intervals and twenty finished; two punctured at Gwithian and one stopped, by his own account, for breakfast. The winner, Mr Leonard Uren, a postman, beat the club record by four minutes on a machine he built himself from parts, and was back at his round by nine. ::resource{id="wheelers"} The club’s next event is the hill climb up Quay Hill on the 25th, which the Watch Committee has allowed on the condition that the riders keep clear of the tram lines, and the trams of the riders. ## Gigs on the bay {style="third"} The pilot gigs were out on the bay on Wednesday evening for the first time this summer, six boats in a practice race from the lifeboat slip round the Stags and back. The Wexcombe crew in the old *Bonnet*, built in 1838 and still the fastest boat on this coast in a flat sea, led from the start and won by four lengths from Penhallow. The crews race for the Harbour Cup at the Regatta on the 1st of July, when nine gigs are expected. ## Angling {style="third"} Sea fishing from the pier has been good all week, with mackerel taken on feathers at every tide and a bass of nine pounds by Mr Fred Curnow on Tuesday evening, the best of the season so far. The Sea Angling Club’s monthly competition on Sunday was won by Mr Curnow again, with a catch of eleven pounds. Visitors may fish from the pier on payment of twopence to the pier master. ## Racing at Penhallow {style="third"} The Whitsun meeting at Penhallow Downs drew a crowd of nine thousand on Monday, many of whom came by the special trains from Wexcombe. The big race, the Downs Plate over a mile and a half, went to *Harbour Lights*, owned by Mr Jory of Trelask and ridden by his nephew, at a hundred to eight. The favourite unseated his rider at the second hurdle and finished the race alone, to great applause, a good length ahead of the field. The bookmakers had a poor afternoon. Three favourites were beaten, and the last race, the Farmers’ Handicap, was won by a grey mare from a farm above Wexcombe that had been ploughing on Saturday and had never run on a course before. Her owner, Mr Tom Oliver, said that he had entered her for a lark and backed her with half a crown, and that he would now buy a new plough with the winnings. The meeting closes on Wednesday with the Hunters’ Steeplechase, and the Great Western will again run special trains from Wexcombe at half past eleven and a quarter past twelve, returning after the last race. The Lantern’s tipster, who has not had a winner since Easter, refuses to name a horse. :::paragraphs{style="imprint"} Printed and published by the Wexcombe Lantern Company, Ltd., at their offices, 14 Market Street, Wexcombe. Every Saturday. The town, its people and its news are invented. The pictures are films generated with diffusion models. :::
`; // content.sport.en.md: Sport const markdown = [front, arts, sport].join('\n\n'); // one document, four pages // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Libre Caslon Text': ['400', '400i', '700'], Oswald: ['600', '700'], 'Courier Prime': ['400', '400i', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all(resources.filter((r) => r.kind === 'video').map(({ video: { poster } }) => loadImage(poster.fileId, asset(poster.fileId)))); // the printed frames const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: 'The Wexcombe Lantern · a living newspaper' }); // #region folio: the newspaper in 3D, every clip playing on the open pages at once const stage = Object.assign(document.createElement('section'), { id: 'folio', ariaLabel: 'The newspaper in 3D' }); stage.style.cssText = 'height: min(82vh, 820px); margin: 0 auto; max-width: 1280px'; document.getElementById('pages').before(stage); createFolioFromDocument(stage, doc, { // Folio plays each clip from its video.url, muted, while its page is on show. appearance: { textureBaseUrl: 'https://postext.dev/folio/textures' }, onChange: ({ pages }) => kitStatus(`${doc.pages.length} pages · open at ` + pages.map((i) => doc.pages[i].pageLabel || i + 1).join('–')), }); // #endregion
Kit · core, fonts, viewer, images: the same in every recipe · 270 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 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 ───────────────────────────────────────────────────────────────────────

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

#Let the reader start each clip

Without autoplay the pictures stay still until the reader clicks one; the loops still play together once started.

-  player: { autoplay: true, loop: true, muted: true, exclusive: false, controls: false },
+  player: { loop: true, muted: true, exclusive: false, controls: false },

#One exclusive clip with its sound

A single video can keep the default and play on its own terms: set player: { exclusive: true, muted: false } on that resource's video, and starting it holds the loops until it ends.

#A broadsheet in six columns

The two-column front page sets a smaller paper with still photographs; the same grid takes six columns on a broadsheet trim with sizePreset: 'broadsheet'.

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

Design text overflow defaults to 'ellipsis-end'

A design text element that does not fit its width ends in an ellipsis by default. Set overflow: 'wrap' for titles that should break onto more lines. Text, rules and boxes in page designs →

Pitfall

:::columns works only inside a box and never splits

:::columns is ignored outside a callout, and a box that splits never cuts inside a columns group. A breaks attribute counts child blocks, with a nested box as one. Columns inside a box →

A newspaper section is a level-1 heading that opens a page, and a float never crosses it. A picture that finds no slot on its section's last page is set on a page of its own before the next section, so fit the copy to the page: here the trams story jumps to page 2 rather than push the aviator off the front page.

Credits

Text
Original prose, CC BY 4.0
Images
  • Mrs Edith Rowe speaks at the opening of the Harbour line: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg · Generated With Diffusion Models · CC BY 4.0
  • The first frame of the mayor loop, its printed picture · Generated With Diffusion Models · CC BY 4.0
  • The Guildhall clock tower: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg · Generated With Diffusion Models · CC BY 4.0
  • The first frame of the clock loop, its printed picture · Generated With Diffusion Models · CC BY 4.0
  • Skipper Jacob Hosking with the cod: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg · Generated With Diffusion Models · CC BY 4.0
  • The first frame of the fisherman loop, its printed picture · Generated With Diffusion Models · CC BY 4.0
  • Miss Constance Lacey in the cockpit: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg · Generated With Diffusion Models · CC BY 4.0
  • The first frame of the aviator loop, its printed picture · Generated With Diffusion Models · CC BY 4.0
  • Miss Rosalind Avery on the stage: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg · Generated With Diffusion Models · CC BY 4.0
  • The first frame of the actress loop, its printed picture · Generated With Diffusion Models · CC BY 4.0
  • Tom Rundle in goal: a black-and-white loop of 5 s, a still generated from a text prompt and animated with the same first and last frame, encoded with ffmpeg · Generated With Diffusion Models · CC BY 4.0
  • The first frame of the keeper loop, its printed picture · Generated With Diffusion Models · CC BY 4.0
Fonts
Libre Caslon Text (SIL OFL 1.1) · Oswald (SIL OFL 1.1) · Courier Prime (SIL OFL 1.1)
Sandbox