跳到主要内容
食谱编号116

排版食谱 · 第7章 · 图与图片

长高以填满短栏的照片

每张照片标出自己的安全区;栏平衡可以裁去安全区以外的部分把照片放高,让被标题或浮动图留短的栏排到底。

本页内容
输出
Canvas · PDF
难度
中级
Postext
已用Postext 1.16.1测试
需要≥ 1.16.1 · postext-pdf ≥ 1.16.1
许可证
更新于2026年10月5日
代码MIT · 文本CC BY 4.0
  • 西班牙文样例:尚无中文版本
  • 成品尺寸225 × 297 mm
  • 2栏, 栏间距7 mm
  • Newsreader 9.8/13.6
  • Archivo
  • Archivo Narrow
  • 6页
  • 难度
  • Postext 1.16.1
  • 排版用时128 ms
  • 180行代码

简单来说

告诉排版每张照片哪一部分必须始终可见。某一栏差几行没排满时,照片会变高,而不是留下空白。

成品一览

一本海岸杂志的三页:一篇写河口小镇各行手艺的报道,开篇有一张出血大图,栏内另有五张照片。示例用同一段文字把报道排了两遍。第一遍里照片保持原文件的形状,第2页的两栏都比网格少一行:左栏停在最后一段之下,右栏停在栏底照片之上。第二遍里每张照片都说明画面哪一部分必须始终可见,引擎就裁去这部分以外的地方:在同一页上,拾贝的妇人和补网的妇人各长高一行,两栏都排到网格的最后一行。裁切位置不需要任何人来挑。

这道食谱解答

  • 怎样让照片裁切后填满偏短的栏,同时主体始终留在画面里?
  • 怎样让各栏底部齐平,最后一页也排得平衡(垂直两端对齐)?
  • 怎样控制图的位置:页面顶部、横跨两栏、就放在这里,还是放在边栏里?

简短回答

script.js · 第47–63行在完整代码中
// Each photo names the rectangle that must always show, in fractions of the file:
// x and width of its width, y and height of its height. Outside it the engine may crop,
// so the photo can stand taller (sides cut, down to the area's width) or lower (top and
// bottom cut, down to its height) than the file, always as wide as its column.
const SAFE_AREAS = {
  mariscadora: { x: 0.2, y: 0.36, width: 0.34, height: 0.46 }, // Carmen, her rake and basket
  redeira: { x: 0.34, y: 0.14, width: 0.4, height: 0.66 }, // Rosa and the net in her lap
  carpintero: { x: 0.12, y: 0.2, width: 0.64, height: 0.62 }, // Manuel and the whole hull
  faro: { x: 0.34, y: 0.14, width: 0.32, height: 0.56 }, // the tower and the walker below it
  pulpeira: { x: 0.2, y: 0.08, width: 0.56, height: 0.82 }, // Lucía, the octopus, the cauldron
};
// Columns end flush on this grid by whole lines. A heading or a photo that does not fit a
// column's foot leaves lines empty there; these settings forbid the usual fixes (space above
// a heading, under a photo, or a paragraph run long), so only a photo with a safe area can
// take them, by growing a line at a time. Without safe areas the short columns stay short.
const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
  stretchAfterFloats: false, looseParagraphs: false };

用料

类型
Newsreader, Archivo, Archivo Narrow(SIL OFL 1.1)
素材
  • carpintero-1600.jpg
  • estuario-1700.jpg
  • faro-1600.jpg
  • mariscadora-1600.jpg
  • pulpeira-1600.jpg
  • redeira-1600.jpg
  • The estuary at low water at dawn, with shellfish gatherers (Generated With Diffusion Models, 原创)
  • A shellfish gatherer kneeling on the wet sand (Generated With Diffusion Models, 原创)
  • A net mender on the quay (Generated With Diffusion Models, 原创)
  • A shipwright planing a wooden boat in his shed (Generated With Diffusion Models, 原创)
  • A lighthouse on a granite point and a walker below it (Generated With Diffusion Models, 原创)
  • A cook lifting an octopus from a copper cauldron at a market (Generated With Diffusion Models, 原创)

做法

#1 · 只标出必须保留的部分

代码就是上面的简短回答。安全区是图片的四个比例值,从左上角量起。引擎可以把照片显示成从整张文件到这个矩形之间的任何形状,宽度始终等于所在的栏:裁掉两侧就更高,裁掉上下就更矮。安全区以外的部分按两侧余白的比例裁去,所以码头上偏右的罗莎,在两侧被裁后仍然偏右。

#2 · 让栏平衡只通过照片取得余量

同一段代码还设定了栏平衡。平衡通常的办法都是加空白:标题上方加一行、照片下方留空、让一段文字多排一行。这里把它们都关掉,好让没有安全区的版本如实显示每一个短栏。带安全区的照片会先于这些办法尝试,因为照片变高不会在文字里留下空洞。

#3 · 安全区属于资源本身

script.js · 第211–245行在完整代码中
const FILES = { estuario: [1700, 1133] }; // px, declared as they are; the rest 1600 × 1067
// Caption and alt text of each photo, one block per photo: content.figures.<lang>.md.
const figureTexts = String.raw`mariscadora
Carmen Lago separa berberechos en su parcela de la ría, en bajamar.
Una mujer con botas verdes, de rodillas en la arena mojada junto a un rastro y una cesta.

redeira
Rosa Doval remienda un paño de cerco en el muelle.
Una mujer sentada en un taburete cose una red verde; detrás, barcos de pesca.

carpintero
Manuel Barreiro cepilla el forro de la gamela.
Un hombre mayor cepilla el costado de una barca de madera a medio construir.

faro
La punta de Arnela: el faro y el camino de la costa.
Un faro blanco sobre rocas de granito; abajo, una caminante con mochila roja.

pulpeira
Lucía Fraga saca un pulpo del caldero de cobre.
Una mujer con mandil saca un pulpo cocido de un caldero de cobre humeante.
`;
const CAPTIONS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/)
  .map((block) => block.split('\n').map((line) => line.trim()))
  .map(([id, caption, alt]) => [id, [caption, alt]]));
const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
const photo = (id, safe) => {
  const [w, h] = FILES[id] ?? [1600, 1067];
  const [caption, alt] = CAPTIONS[id] ?? [];
  return { id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
    ...(caption && { caption, altText: alt, placement: PLACEMENT[id] ?? { position: 'auto' } }),
    ...(safe && SAFE_AREAS[id] && { safeArea: SAFE_AREAS[id] }) };
};
const resources = (safe) => ['estuario', ...Object.keys(CAPTIONS)].map((id) => photo(id, safe));

safeArea和文件、题注、位置一起写在资源上,所以照片不论浮动到哪里、用在哪个文档里,都带着它。示例要么传入、要么省略它,这就是两次排版之间唯一的区别。开篇那张河口照片由标题样式绘制,不是浮动图,所以不需要安全区。

#4 · 两次都排,再对照

script.js · 第256–258行在完整代码中
const build = (safe) => buildWithFonts(() =>
  buildDocument({ markdown, resources: resources(safe) }, config()), markdown);
const docs = { plain: await build(false), safe: await build(true) };

两份文档出自同一份配置和同一段Markdown。只有还要接排下去的页面才会排到页底:第3页是报道的最后一页,各栏只需要在同一高度结束。没有安全区时第二栏比第一栏早一行结束;有了安全区,章鱼那张照片长高这一行,两栏齐平。PDF按钮输出当前桌面上的那一版,PDF里每张照片也按同样方式裁到框内。

完整食谱

沙盒
// ═══ Postext Cookbook · Nº 116 · Photos that grow to fill a short column ═══════════
// https://postext.dev/en/cookbook/photos-fill-short-columns
// Code: MIT · Text: original (CC BY 4.0) · Photos: diffusion models
// Fonts: Newsreader, Archivo, Archivo Narrow (SIL OFL 1.1) · Needs postext ≥ 1.16.1
//
// A magazine feature set twice. Without safe areas some columns end short; with them the
// engine crops each photo outside its area to set it taller, and the photos fill those lines.
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
  defaultResourceTypes,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the sample document ('es' | 'en')
const RECIPE = 'photos-fill-short-columns';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: six named colours, the accents taken from the photos: sea slate, net green
const palette = {
  ink: '#1d2326', // text: a cold near-black
  sea: '#2f5d73', // the accent: kicker, caption labels, folios, references
  net: '#3f6b55', // the second colour: the opener's rule
  rule: '#c9d1d3', // hairlines
  muted: '#5f6a6e', // running heads, credits, the colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': pointed at the accent, no default blue shows.
  { id: 'main-color', name: 'sea (defaults)', value: { hex: palette.sea, model: 'hex' } },
];
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Newsreader', 'Archivo', 'Archivo Narrow'];
const LEAD = 13.6; // body leading in pt: the grid the photos grow by
const [PAGE_W, PAGE_H, TOP, BOTTOM, INNER, OUTER, GUTTER] = [225, 297, 22, 22, 18, 16, 7]; // mm
const PHOTO_H = 150; // the opener's bleed photo: 225 × 150 mm, the shape of the file
const at = (to, edge, x, y, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) },
  ...(size && { size }) });
const text = (id, content, family, size, color, placement, extra) => ({ kind: 'text', id,
  content, fontFamily: family, fontSize: pt(size), color: col(color), placement,
  align: 'left', overflow: 'wrap', ...extra }); // wrap, not '…' (gotcha: overflow-ellipsis-default)
const caps = (size) => ({ fontWeight: 700, textTransform: 'uppercase',
  letterSpacing: pt(size * 0.18) });

// #region answer: a safe area per photo, and balancing that may only grow pictures
// Each photo names the rectangle that must always show, in fractions of the file:
// x and width of its width, y and height of its height. Outside it the engine may crop,
// so the photo can stand taller (sides cut, down to the area's width) or lower (top and
// bottom cut, down to its height) than the file, always as wide as its column.
const SAFE_AREAS = {
  mariscadora: { x: 0.2, y: 0.36, width: 0.34, height: 0.46 }, // Carmen, her rake and basket
  redeira: { x: 0.34, y: 0.14, width: 0.4, height: 0.66 }, // Rosa and the net in her lap
  carpintero: { x: 0.12, y: 0.2, width: 0.64, height: 0.62 }, // Manuel and the whole hull
  faro: { x: 0.34, y: 0.14, width: 0.32, height: 0.56 }, // the tower and the walker below it
  pulpeira: { x: 0.2, y: 0.08, width: 0.56, height: 0.82 }, // Lucía, the octopus, the cauldron
};
// Columns end flush on this grid by whole lines. A heading or a photo that does not fit a
// column's foot leaves lines empty there; these settings forbid the usual fixes (space above
// a heading, under a photo, or a paragraph run long), so only a photo with a safe area can
// take them, by growing a line at a time. Without safe areas the short columns stay short.
const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
  stretchAfterFloats: false, looseParagraphs: false };
// #endregion

// #region opener: the estuary across the head of the page, then kicker, title and standfirst
const opener = {
  enabled: true,
  // Images reserve no height (gotcha: opener-image-no-reserve): minHeight keeps the text
  // under the photo, the kicker, the title and the standfirst.
  minHeight: mm(188),
  slot: { elements: [
    { kind: 'image', id: 'photo', resourceId: 'estuario', // bleeds off the top and both sides
      placement: at('page', 'top-left', 0, 0, { width: mm(PAGE_W), height: mm(PHOTO_H) }) },
    text('kicker', '{attr.kicker}', LABEL, 8.5, 'sea', at('container', 'top-left', 0,
      PHOTO_H - TOP + 9), caps(8.5)),
    text('title', '{titleText}', DISPLAY, 34, 'ink', at('#kicker', 'below', 0, 2.5,
      { width: mm(150), height: 'auto' }), { fontWeight: 800, lineHeight: 1.04 }),
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(2), color: col('net'),
      placement: at('#title', 'below', 0, 4, { width: mm(16) }) },
    text('lead', '{attr.lead}', TEXT, 11.2, 'ink', at('#rule', 'below', 0, 4,
      { width: mm(150), height: 'auto' }), { italic: true, lineHeight: 1.36, hyphenate: true }),
  ] },
};
// #endregion

// #region furniture: magazine and issue on the verso, the feature's title on the recto
const HEAD_Y = 12; // mm from the top edge
const head = (id, content, parity, edge, x, extra) => text(id, content, LABEL, 7.6, 'muted',
  at('page', edge, x, HEAD_Y), { ...caps(7.6), fontWeight: 600, parity, pages: 'body',
    overflow: 'ellipsis', ...extra });
const folio = (id, parity, edge, x, extra) => text(id, '{pageNumber}', DISPLAY, 8.5, 'sea',
  at('page', edge, x, HEAD_Y), { fontWeight: 800, parity, pages: 'body', ...extra });
const header = { elements: [
  folio('verso-folio', 'even', 'top-left', OUTER),
  head('verso-title', '{title} · {subtitle}', 'even', 'top-left', OUTER + 8),
  head('recto-title', '{chapterTitle}', 'odd', 'top-right', -(OUTER + 8), { align: 'right' }),
  folio('recto-folio', 'odd', 'top-right', -OUTER, { align: 'right' }),
] };
const footer = { elements: [] }; // the opener carries no folio: its photo bleeds off the head
// #endregion

const types = () => defaultResourceTypes(LANG).map((type) => ({ ...type,
  numberingTemplate: '{n}', resetOn: 'never' })); // 'Figura 3', not '1.3', in a one-article issue

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: t({ en: 'en-gb', es: 'es' }), // exact codes (gotcha: hyphenation-locales)
  resourceTypes: types(), // "Figura" in Spanish (gotcha: resource-types-locale)
  colorPalette,
  page: { width: mm(PAGE_W), height: mm(PAGE_H), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(BOTTOM), left: mm(INNER), right: mm(OUTER),
      mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(GUTTER) },
  bodyText: {
    fontFamily: TEXT, fontSize: pt(9.8), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('sea'),
    textAlign: 'justify', firstLineIndent: mm(4), indentAfterHeading: false,
    hyphenation: { enabled: true }, optimalLineBreaking: true,
    avoidWidows: true, avoidOrphans: true, avoidRunts: true,
  },
  headings: {
    fontFamily: DISPLAY, fontWeight: 800, color: col('ink'),
    balancing,
    levels: [
      // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
      { level: 1, fontSize: pt(34), span: 'page', breakBefore: { enabled: true, parity: 'odd' },
        marginTop: pt(0), marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(12), lineHeight: pt(LEAD), marginTop: pt(LEAD),
        marginBottom: pt(0) }, // one grid line above, none below
    ],
  },
  captionStyle: { fontFamily: LABEL, fontSize: pt(8), color: col('ink'), gap: mm(2),
    labelBold: true, labelColor: col('sea'), descriptionItalic: false,
    note: { color: col('muted') } },
  paragraphStyles: [{ id: 'colophon', fontFamily: LABEL, fontSize: pt(7.2), lineHeight: pt(10),
    color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) }],
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Markdown样例 · 65行 · content.es.mdtitle: "Mareas" subtitle: "Revista de la costa · Número 14" --- # Gente de la bajamar {kicker="Oficios del mar · Ría de Arnela" lead="Dos veces al día el agua se retira de la ría y deja al descubierto un kilómetro de arena. En ese rato trabajan las mariscadoras; en el muelle, las redeiras cosen lo que el mar rompió la noche anterior. Pasamos una semana de octubre con cinco oficios que siguen el reloj de la marea."} Arnela tiene mil cuatrocientos vecinos, un puerto con once barcos de bajura y una ría que en bajamar se queda casi seca. Desde la carretera de la costa se ve el pueblo entero: las casas blancas apretadas en la ladera, la iglesia arriba y, abajo, la lonja con su tejado de uralita. Lo que no se ve desde arriba es el horario. En Arnela nadie queda a las cinco o a las seis; se queda «a media marea» o «cuando baje», y la tabla de mareas que la cofradía pega cada mes junto a la puerta de la lonja manda más que cualquier reloj. Esta semana la bajamar cae a las siete y cuarto de la mañana y a las siete y media de la tarde. Una hora antes, el arenal ya está lleno de gente. ## A pie de marea Carmen Lago empezó a mariscar a los catorce años, con su madre, y tiene sesenta y uno. Trabaja con un rastro de mango largo, un balde y una cesta de malla, siempre en la misma parcela de la ría, la que la cofradía asigna cada temporada a su grupo de nueve mujeres (:ref{id="mariscadora" case="lower"}). Rastrilla la arena a contrapelo de la corriente, se agacha, aparta con la mano los berberechos que no llegan a la talla mínima y los devuelve al agujero del que salieron. —El berberecho chico se queda —dice—. Si lo coges hoy, en marzo no hay. Cada mariscadora puede sacar al día un cupo fijo por especie, que la cofradía revisa según el estado del banco. En octubre el de Carmen son tres kilos de almeja fina y ocho de berberecho. Lo que pasa del cupo vuelve a la arena. A las nueve, cuando el agua empieza a cubrir las primeras charcas, el grupo sube la cuesta hasta la lonja con los baldes. Allí se lava el marisco, se pesa por mariscadora y se clasifica por calibre antes de la subasta de las once. El trabajo no termina en el arenal. Dos días a la semana el grupo «siembra»: traslada almeja pequeña de las zonas donde se amontona a las que se han quedado vacías, y limpia la arena de algas y de las estrellas de mar que se comen la cría. Nadie cobra por esas horas. «Es como regar la huerta», explica Carmen, que todavía se acuerda de los inviernos en que no había nada que sacar. ## La subasta de las once La lonja de Arnela es una nave alargada con el suelo de hormigón siempre mojado. A las once menos cuarto ya están alineadas sobre la cinta las cajas de la mañana: el marisco de las mariscadoras, separado por especie y calibre, y el pescado de los barcos de bajura, merluza de pincho, jurel, algún rodaballo. Cada caja lleva una etiqueta con el peso, la zona y el nombre de quien la trajo. La subasta es a la baja. Un panel electrónico empieza por un precio alto y va bajando de diez en diez céntimos; el primer comprador que pulsa su mando se queda la caja a ese precio. Compran tres mayoristas de la comarca, dos restaurantes del pueblo y un cocedero que envasa almeja para toda la provincia. En octubre el kilo de almeja fina ronda los veintiséis euros y el de berberecho, los siete. Una mañana buena se subastan dos toneladas en cuarenta minutos. Marisa Couto lleva el registro de la lonja desde hace diecinueve años. Anota en una hoja cada venta, con el comprador y el precio, y a final de mes reparte lo que corresponde a cada mariscadora y a cada barco, descontada la comisión de la cofradía. «Antes se gritaba», recuerda. «Ahora solo se oye el pitido del panel, y a la una ya está todo fregado.» ## Las redeiras del muelle En el extremo del muelle, junto a las cajas apiladas de la lonja, trabaja Rosa Doval sobre un taburete bajo, con una red verde extendida a su alrededor como una alfombra (:ref{id="redeira" case="lower"}). Los barcos de cerco entraron de madrugada con la sardina y con tres paños rotos; uno de ellos lo partió una roca y tiene un agujero en el que cabe una persona. Rosa cose con una aguja de plástico del largo de una mano, cargada de hilo, y una tablilla que fija el tamaño de cada malla. Corta los bordes deshilachados hasta dejar mallas enteras, cuenta las que faltan y teje el remiendo nudo a nudo. Un agujero grande le lleva una mañana. Un paño nuevo, entre doscientos y trescientos metros de red, casi un mes de trabajo de cuatro mujeres. En Arnela quedan siete redeiras. Se organizaron hace doce años, cuando los armadores empezaron a mandar las redes a talleres del interior, y ahora cobran por hora y no por remiendo. La más joven tiene veintiocho años y aprendió en un curso de la cofradía; la mayor, ochenta y uno, y ya solo cose los días de buen tiempo. Rosa prefiere el muelle a la nave que les cedió el ayuntamiento: «Con la red al sol se ven mejor los agujeros, y además me entero de lo que trae cada barco». ## La última carpintería de ribera Al fondo de la ensenada, donde el camino de la costa se convierte en pista de tierra, una nave de madera con las puertas abiertas huele a resina y a pino recién cortado. Es el astillero de Manuel Barreiro, el último de los cinco que llegó a tener la ría. Manuel tiene setenta y cuatro años y trabaja con su sobrino en una gamela de seis metros, una barca de fondo plano que se usaba para el marisqueo a flote (:ref{id="carpintero" case="lower"}). El casco está montado sobre la grada desde agosto. Primero se pone la quilla, después las cuadernas de roble, curvadas al fuego, y por último las tablas de pino que forman el forro, clavadas una a una con clavos de cobre. Manuel no usa planos. Las formas las saca de unas plantillas de tablero que heredó de su padre y que cuelgan de la pared del fondo, cada una con el nombre de la barca para la que se hizo escrito a lápiz. En los años ochenta el astillero botaba quince barcos al año. Ahora hace dos o tres, casi siempre para clubes de remo o para familias que quieren recuperar la barca del abuelo. Esta gamela la encargó la asociación de vecinos, que quiere sacarla en las regatas de verano con los colores del pueblo. Manuel calcula que estará en el agua en abril, si el invierno deja trabajar con las puertas abiertas. ## El faro y el camino Desde el astillero, el camino sube entre tojos hasta la punta de Arnela, donde un faro blanco con la linterna roja marca la entrada de la ría (:ref{id="faro" case="lower"}). Lo automatizaron en 1993, y desde entonces nadie vive en la casa del torrero, pero Xosé Pazos, que lo atendió durante veintidós años, sigue subiendo dos veces por semana a revisar las baterías y a anotar en un cuaderno el viento y el estado de la mar. El sendero que lleva hasta allí forma parte de una ruta de once kilómetros que rodea toda la ría, de playa en playa, y que la mancomunidad señalizó hace tres años con postes de madera y flechas amarillas. En octubre se cruzan pocos caminantes: algún grupo de jubilados, parejas con mochila y, los fines de semana, corredores que bajan a la carrera hasta el puerto. Pazos les indica, si preguntan, por dónde se baja a la cala del Cantal sin resbalar en la roca mojada. Con mal tiempo la ola rompe contra la punta y sube por las rocas hasta la base de la torre. Pazos guarda una foto de 1987 en la que el agua llega a la puerta. Aquel invierno se quedó tres días sin poder bajar al pueblo, con la radio, una caja de latas y el cuaderno. ## El caldero de los domingos El domingo, el mercado de abastos abre a las nueve y a las diez ya hay cola delante del puesto de Lucía Fraga. Lucía cuece el pulpo como lo cocía su madre, en un caldero de cobre de ochenta litros sobre un fuego de leña: lo «asusta» metiéndolo y sacándolo del agua hirviendo tres veces para que la piel no se despegue, lo deja cocer unos veinte minutos y lo saca con un gancho cuando la punta del tentáculo cede al pincharla. ::resource{id="pulpeira"} Lo corta con tijera sobre platos de madera, le echa sal gruesa, pimentón dulce y picante a partes iguales y un chorro de aceite, y lo sirve con cachelos, patatas cocidas en la misma agua. Una ración cuesta doce euros. Los domingos de verano Lucía cuece treinta pulpos; en octubre, catorce o quince, que compra el viernes en la lonja a los barcos de nasa del propio puerto. A las dos de la tarde la marea vuelve a estar baja. En el arenal ya se distinguen los primeros baldes y, en el muelle, una red tendida al sol. :::paragraphs{style="colophon"} Arnela y sus vecinos son imaginarios · Compuesto en Newsreader, Archivo y Archivo Narrow (SIL Open Font License) · Texto: CC BY 4.0 · Fotografías: modelos de difusión :::
`; // content.<lang>.md, inlined by the Cookbook // #region photos: one resource per file; the safe area is the only difference between builds const FILES = { estuario: [1700, 1133] }; // px, declared as they are; the rest 1600 × 1067 // Caption and alt text of each photo, one block per photo: content.figures.<lang>.md. const figureTexts = String.raw`mariscadora
Markdown样例 · 18行 · content.figures.es.mdCarmen Lago separa berberechos en su parcela de la ría, en bajamar. Una mujer con botas verdes, de rodillas en la arena mojada junto a un rastro y una cesta. redeira Rosa Doval remienda un paño de cerco en el muelle. Una mujer sentada en un taburete cose una red verde; detrás, barcos de pesca. carpintero Manuel Barreiro cepilla el forro de la gamela. Un hombre mayor cepilla el costado de una barca de madera a medio construir. faro La punta de Arnela: el faro y el camino de la costa. Un faro blanco sobre rocas de granito; abajo, una caminante con mochila roja. pulpeira Lucía Fraga saca un pulpo del caldero de cobre. Una mujer con mandil saca un pulpo cocido de un caldero de cobre humeante.
`; const CAPTIONS = Object.fromEntries(figureTexts.trim().split(/\n\s*\n/) .map((block) => block.split('\n').map((line) => line.trim())) .map(([id, caption, alt]) => [id, [caption, alt]])); const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot const photo = (id, safe) => { const [w, h] = FILES[id] ?? [1600, 1067]; const [caption, alt] = CAPTIONS[id] ?? []; return { id, typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h }, ...(caption && { caption, altText: alt, placement: PLACEMENT[id] ?? { position: 'auto' } }), ...(safe && SAFE_AREAS[id] && { safeArea: SAFE_AREAS[id] }) }; }; const resources = (safe) => ['estuario', ...Object.keys(CAPTIONS)].map((id) => photo(id, safe)); // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) Newsreader: ['400', '400i', '600'], Archivo: ['800'], 'Archivo Narrow': ['600', '700'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); await Promise.all(resources(false).map((r) => loadImage(r.bitmap.fileId, asset(r.bitmap.fileId)))); // #region builds: the same text, config and photos, without and then with the safe areas const build = (safe) => buildWithFonts(() => buildDocument({ markdown, resources: resources(safe) }, config()), markdown); const docs = { plain: await build(false), safe: await build(true) }; // #endregion const title = t({ en: 'Photos that grow to fill a short column', es: 'Fotos que crecen hasta llenar la columna' }); // Two buttons put either build on the desk; the PDF follows the one shown. const LABELS = { plain: t({ en: 'Without safe areas', es: 'Sin zona segura' }), safe: t({ en: 'With safe areas', es: 'Con zona segura' }) }; const switches = Object.keys(docs).map((key) => Object.assign(document.createElement('button'), { type: 'button', value: key, textContent: LABELS[key] })); const show = (key) => { showPages(docs[key], { title }); for (const b of switches) b.ariaPressed = String(b.value === key); document.querySelectorAll('#pt-actions a, [data-postext-pdf]').forEach((old) => old.remove()); offerPdf(() => renderToPdf(docs[key], { fontProvider: fontsourceProvider, resourceBytes: imageBytes }), `${RECIPE}-${key}.pdf`); }; for (const b of switches) b.addEventListener('click', () => show(b.value)); show('safe'); document.getElementById('pt-actions').prepend(...switches); document.head.insertAdjacentHTML('beforeend', '<style>#pt-actions [aria-pressed=true] { text-decoration: underline }</style>');
工具包 · core, fonts, viewer, pdf, images:每道食谱都相同 · 310行// ─── 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 · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { 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 res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show 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 ───────────────────────────────────────────────────────────────────────

组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗ (在新标签页中打开)

变化

#让行内照片留在文字旁

设为position: 'here'并带安全区的照片,如果比所在栏剩下的空间稍高,会从上下裁掉一些,而不是移到下一栏。

-const PLACEMENT = { pulpeira: { position: 'here' } }; // the rest float to the first free slot
+const PLACEMENT = { pulpeira: { position: 'here' }, faro: { position: 'here' } };

#把其他平衡手段还给栏平衡

用默认设置时照片仍然最先尝试,安全区够不到的部分再交给标题和浮动图。

-const balancing = { enabled: true, maxLinesPerHeading: 0, stretchAfterLists: false,
-  stretchAfterFloats: false, looseParagraphs: false };
+const balancing = { enabled: true };

常见问题

易错点

传入任何headings对象都会关掉H1换页

默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →

易错点

章首页的图片从不计入它预留的高度

在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →

易错点

设计文本的overflow默认为'ellipsis-end'

宽度放不下的设计文本元素默认以省略号结尾。需要折成多行的标题,设置overflow: 'wrap'。 页面设计中的文字、线条和框 →

易错点

用defaultResourceTypes(locale)本地化Figure/Table

配置的locale决定断词,不决定题注:没有resourceTypes时,内置类型用英文写作Figure和Table。西班牙语传入resourceTypes: defaultResourceTypes('es');其他语言请在resourceTypes中自己写出名称。 用你的语言显示“图”和“表” →

易错点

只有8种语言区域能断词,且须代码完全一致

断词支持en-us、es、fr、de、it、pt、ca和nl,须完全匹配:'es-ES'或其他任何语言都会悄悄退回美式英语。 断词与文档语言 →

易错点

排版前加载所有字体

排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →

  • 覆盖整张图片的安全区,或有一边小于图片2%的安全区,会被忽略:照片照常完整显示。
  • 只有栏内浮动图和行内照片会长高。横跨两栏的浮动照片保持原形,因为它一长高就会同时推动下面的所有栏。

致谢

文本
  • The feature, the captions and the colophon, in Spanish and English; Arnela and its people are imaginary · Postext Cookbook · CC BY 4.0
图片
  • The estuary at low water at dawn, with shellfish gatherers · Generated With Diffusion Models · 原创
  • A shellfish gatherer kneeling on the wet sand · Generated With Diffusion Models · 原创
  • A net mender on the quay · Generated With Diffusion Models · 原创
  • A shipwright planing a wooden boat in his shed · Generated With Diffusion Models · 原创
  • A lighthouse on a granite point and a walker below it · Generated With Diffusion Models · 原创
  • A cook lifting an octopus from a copper cauldron at a market · Generated With Diffusion Models · 原创
字体
Newsreader (SIL OFL 1.1) · Archivo (SIL OFL 1.1) · Archivo Narrow (SIL OFL 1.1)
沙盒PDF