成品一览
Sierra,一本页面为280 × 210 mm的风景摄影集:用六张图版记录花岗岩山脉中的一天,从第一缕晨光到初雪后的早晨。每张图版铺满整页直到成品边缘,只在左下角带一个小号罗马数字和时刻;第一张还带有标题SIERRA,用72 pt的Syne从黎明的天空中反白出来。暴风雨作为一整幅图跨过第4页和第5页之间的中缝。三篇短文排在靠近订口的一个100 mm宽的栏里,与其后的图版相对。暴风雨那篇短文以一张较小的黄昏图版收尾,它浮动在文字下方,罗马数字写在题注里。最后一张图版从同一块巨石上拍摄第二张图版里的冰斗,只是覆上了雪。这些图版是用扩散模型生成的照片;雪景图版是在正午图版上编辑得到的,所以两个视角吻合。
这道食谱解答
- 怎样给每章不同的照片、颜色或章首页样式?
- 怎样插入有意留白的页面,或让某一部分从新的跨页开始?
- 怎样在章首页和空白页上隐藏书眉,或者把空白左页涂成篇的颜色?
- 怎样排不编号的插图:饰图、小花饰、标志?
简短回答
// '{attr.art}' is filled in per heading, so a plain plate names its picture on its line:
// # Noon {style="lamina" art="mediodia" …}. Only the cover and the last plate draw extras.
const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws
{ id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below
{ id: 'lamina', art: '{attr.art}' }, // any plate on a page of its own
{ id: 'pliego', art: '{attr.art}', half: 'verso' }, // one picture across a spread:
{ id: 'pliego-recto', art: '{attr.art}', half: 'recto' }, // the left half, then the right
{ id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) },
];
const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({
id, span: 'page', // an opener: a span heading always starts a page of its own
// The left half opens on an even page, a verso, so the right half faces it across the
// gutter (gotcha: parity-page1-recto).
...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }),
// No margins: the plate's column is the page, and minHeight fills it, so what follows starts
// on the next page. Images reserve no room (gotcha: opener-image-no-reserve), and a minHeight
// taller than the column is dropped whole (gotcha: opener-taller-than-column).
margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) },
advancedDesign: { enabled: true, minHeight: mm(PAGE.height), slot: { elements: [
{ kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture,
placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left
{ width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) },
...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)),
] } },
});
用料
- 功能
- 页面设计中的图片标题样式设计过的章首页锚定设计元素分节版式标题属性从右页开始的章按页面角色显示书眉决定图位置的引用自定义资源类型题注样式封面、扉页和版权页纸张颜色对称页边距页面设计中的文字、线条和框断词与文档语言语义调色板作为资源的图和表在Canvas上绘制页面
- 类型
- Andada Pro, Syne, Syne Mono(SIL OFL 1.1)
- 素材
alba-1600.jpgatardecer-2000.jpgmediodia-1600.jpgnieve-1600.jpgnoche-1600.jpgtormenta-2400.jpg- Plate I, dawn: ridges fading into a peach haze under a dark blue sky (Generated With Diffusion Models, 原创)
- Plate II, noon: a granite cirque and its lake (Generated With Diffusion Models, 原创)
- Plate III, storm: rain, lightning and a break of sun across a spread (Generated With Diffusion Models, 原创)
- Plate IV, dusk: the crest lit orange under a violet sky (Generated With Diffusion Models, 原创)
- Plate V, night: stars over the ridges and a lit refuge (Generated With Diffusion Models, 原创)
- Plate VI, snow: the cirque of plate II the morning after (Generated With Diffusion Models, 原创)
做法
#1 · 每张图版一个标题样式,由列表生成
代码见上文的简短回答。plate()为PLATES的每一行生成一个标题样式,Markdown则按图版一行一个的方式决定随笔的顺序:# Night {style="lamina" art="noche" n="V" hora="22:10"}。图像元素的resourceId是'{attr.art}',和文字里的占位符一样,所以普通图版都共用lamina样式,在各自的标题行上指明图片;只有封面和最后一张图版要画额外的内容,才各有自己的样式(图像元素)。span: 'page'让每张图版从新的一页开始。页边距为零,图版所在的栏就和页面一样高,minHeight把这一栏填满,所以图版之后的内容都从下一页开始。这两项设置缺一不可。没有minHeight,暴风雨的右半不画任何文字,只保留标题本身7.6 mm的一行,下一篇短文就会从这一页开始,压在图片上。如果用文字页的页边距,栏深只有158 mm,比210 mm的minHeight短,postext 1.4.1会完全放弃这项预留,三篇短文都会印在它们之前的图版上。
跨页的两个样式pliego和pliego-recto画的是同一幅560 mm宽的图,这里是art="tormenta":左页那一半贴页面左边缘,右页那一半再向左移280 mm,这样每页只显示自己的一半,其余部分被画布在成品边缘裁掉。parity: 'even'保证两半相对。如果没有它,从Markdown里拿掉正午图版后,左半会落在第3页(一个右页),右半翻到背面的第4页;有了它,第3页留空,暴风雨仍然铺满第4页和第5页。
#2 · 罗马数字和时刻取自标题行
const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt
// A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside
// a larger one this much lower and the two share a baseline.
const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72;
const caption = (colour) => [
{ kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700,
fontSize: pt(NUMERAL.size), color: colour, align: 'left',
placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) },
{ kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar
placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) },
];
罗马数字和时刻都是标题属性,所以内容留在Markdown里,一套版面设计适用于所有图版。时刻挂在罗马数字的right-of,两者顶端对齐。设计文字的基线位于元素顶端以下其行框高度的0.8处,行框默认是字号的1.2倍,所以dropTo(13, 8)把时刻下移0.8 × 1.2 × (13 − 8) pt,让8 pt的时刻落在13 pt罗马数字的基线上,距页面顶端193.4 mm。时刻以一个中点开头,和图版IV的题注一样,因为Syne的I只是一道竖线,单独放在时刻旁边会被看成一条线。暴风雨的右半不画题注;雪景图版的题注用墨色,因为它的前景是白色。
#3 · 直接印在图版上的标题和版权页说明
const cover = (colour) => [
{ kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800,
fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase',
color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) },
{ kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono',
fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour,
align: 'left', placement: at('#title', 'below', 1.5, 3) },
];
// The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and
// the second line on the baseline of the plate's numeral.
const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 };
const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key,
content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2),
color: colour, align: 'right',
placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) }));
第一张图版就是封面。{title}和{subtitle}读取frontmatter,PLATES中的extra把这两个元素加到这张图版的版面设计里,颜色与它的题注相同。版权页说明以同样的方式放在最后一张图版上,在雪地上用墨色:两个7.5 pt的元素,一行一个,分别取自colofon和tipos属性,右对齐,第二个与罗马数字同一基线。单个自动换行的元素会在宽度用完处随意断开;一行一个元素,断行就落在许可证之后。
#4 · 短文紧随图版,不设分页
const textOpener = { enabled: true, slot: { elements: [
{ kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8),
letterSpacing: pt(0.8), color: col('accent'), align: 'left',
placement: at('container', 'top-left', 0, 0) },
{ kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700,
fontSize: pt(28), lineHeight: 1.05, // a multiple (gotcha: design-lineheight-multiple)
color: col('ink'), align: 'left', overflow: 'wrap',
placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) },
] } };
// The folio and the book's title under the text block, flush with its left edge, on the
// baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so
// they are opener pages, and 'body' leaves them out.
const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body',
fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'),
align: 'left', ...extra });
const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot
const footer = { elements: [
foot('folio', '{pageNumber}', { color: col('ink'),
placement: at('container', 'top-left', 0, FOOT) }),
foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }),
] };
短文之前的图版占满整页,所以短文不需要自己分页,自然从下一页开始。一页的第一个块若是带分页的标题,这一页就是章首页,页码的pages: 'body'会把它排除。不设分页,文字页就保留页码;而由通栏标题打开的图版页没有页码。只要存在headings对象,postext 1.4.1已经会去掉一级标题的分页,所以删掉breakBefore: { enabled: false }目前不会有任何变化。这一行的作用在于将来某个版本恢复默认的“总在奇数页分页”时:它让短文不分页,其所在页仍是带页码的正文页。FOOT把页码放在图版罗马数字的基线上,距页面顶端193.4 mm,这样短文的页码和对页图版的罗马数字位于同一条线上。
#5 · 声明所有图版,只引用浮动的那一张
// Plate IV is the one plate that floats, so a counter of its type would number it 1. The type
// prints no number (no caption prefix, an empty template) and the caption carries the numeral.
// The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else.
const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }),
shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '',
resetOn: 'never', counterFormat: 'decimal' };
// JPEGs in assets/, cut to the page's shape: 1600 × 1200 px for 280 × 210 mm (145 dpi).
const picture = (id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'bitmap',
bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h },
createdAt: 0, updatedAt: 0, altText: t(alt), ...extra });
const resources = [ // the five the heading styles draw, never cited, and plate IV
picture('alba', [1600, 1200], { en: 'Eight ridges fading into a peach dawn haze.',
es: 'Ocho crestas que se pierden en la bruma del alba.' }),
picture('mediodia', [1600, 1200], { en: 'A granite cirque and its lake under a pale noon sky.',
es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }),
picture('tormenta', [2400, 900], { en: 'A storm over the range: rain on the left, lightning '
+ 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la '
+ 'izquierda; un rayo y un claro de sol a la derecha.' }),
picture('noche', [1600, 1200], { en: 'Stars over dark ridges and two lit windows at a refuge.',
es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }),
picture('nieve', [1600, 1200], { en: 'The cirque the morning after, white with new snow.',
es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }),
// 2000 px, 339 mm at the layout's 150 dpi, which the float fits to the 100 mm measure.
picture('atardecer', [2000, 800], { en: 'A crest lit orange under strips of cloud, violet sky.',
es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' },
{ caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }),
];
设计中的图像元素画的是已声明的资源,所以六张图片都在resources里:食谱assets/中的JPEG,每张按它所填满的页面或跨页的形状裁好,并按像素尺寸声明,版面只读取这个尺寸。loadImage()在构建之前注册每个文件。只有图版IV用:ref引用,因此也只有它浮动。它占据引用之后第一个空闲位置,就在它那篇短文所在的页上,比最后一段低一行。如果是带编号的类型,它的题注会是Plate 1,因为它是该类型中唯一浮动的资源。lamina的numberingTemplate为空,题注本身以IV开头。句子里由:ref的text说出图版的名字,因为不带参数的:ref只会印出plate这个词。
完整食谱
// ═══ Postext Cookbook · Nº 068 · Photo essay with full-bleed plates ═══════════════ // https://postext.dev/en/cookbook/photo-essay-full-bleed // Code: MIT · Text: original (CC BY 4.0) · Plates: diffusion models // Fonts: Andada Pro, Syne, Syne Mono (SIL OFL 1.1) · Needs postext ≥ 1.8.0 // Sierra, a landscape photobook: one day in a mountain range in six plates, each on a page of // its own and one across the gutter of a spread, with three short texts between them. import { buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage, } from 'https://esm.sh/postext'; const LANG = 'es'; // @lang: the language of the sample document ('en' | 'es') const RECIPE = 'photo-essay-full-bleed'; // ─── 1 · Design ───────────────────────────────────────────────────────────── // #region palette: the paper between the plates, the ink and one rust for the labels const palette = { ink: '#1c1f27', // text: the blue-black of the night plate paper: '#f3f0ea', // every page's ground: a pale stone, seen only between the plates accent: '#94462e', // the times over each text: the dusk plate's rust, dark enough for 8 pt muted: '#5f646d', // the running foot and the caption of the small plate white: '#fbfaf7', // type set on the plates }; // Design elements read the hex, not the palette, in 1.4.1 (gotcha: palette-skips-designs). 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' } })); // #endregion const PAGE = { width: 280, height: 210 }; // a landscape photobook, in mm const MARGIN = { top: 28, bottom: 24, inner: 40, outer: 140 }; // text pages: a 100 mm measure const LEAD = 15; // body leading in pt const at = (to, edge, x = 0, y = 0, size) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) }, ...(size && { size }) }); // #region answer: the plates' heading styles, generated from a list // '{attr.art}' is filled in per heading, so a plain plate names its picture on its line: // # Noon {style="lamina" art="mediodia" …}. Only the cover and the last plate draw extras. const PLATES = [ // the style id, its picture, the ink of its caption, anything extra it draws { id: 'alba', art: 'alba', extra: (colour) => cover(colour) }, // an arrow: cover() is below { id: 'lamina', art: '{attr.art}' }, // any plate on a page of its own { id: 'pliego', art: '{attr.art}', half: 'verso' }, // one picture across a spread: { id: 'pliego-recto', art: '{attr.art}', half: 'recto' }, // the left half, then the right { id: 'nieve', art: 'nieve', ink: 'ink', extra: (colour) => colophon(colour) }, ]; const plate = ({ id, art, half, ink = 'white', extra = () => [] }) => ({ id, span: 'page', // an opener: a span heading always starts a page of its own // The left half opens on an even page, a verso, so the right half faces it across the // gutter (gotcha: parity-page1-recto). ...(half === 'verso' && { breakBefore: { enabled: true, parity: 'even' } }), // No margins: the plate's column is the page, and minHeight fills it, so what follows starts // on the next page. Images reserve no room (gotcha: opener-image-no-reserve), and a minHeight // taller than the column is dropped whole (gotcha: opener-taller-than-column). margins: { top: mm(0), bottom: mm(0), left: mm(0), right: mm(0) }, advancedDesign: { enabled: true, minHeight: mm(PAGE.height), slot: { elements: [ { kind: 'image', id: 'picture', resourceId: art, // the recto half is the same picture, placement: at('page', 'top-left', half === 'recto' ? -PAGE.width : 0, 0, // moved left { width: mm(half ? 2 * PAGE.width : PAGE.width), height: mm(PAGE.height) }) }, ...(half === 'recto' ? [] : caption(col(ink))), ...extra(col(ink)), ] } }, }); // #endregion // #region caption: the plate's numeral and hour, lower left, from the heading's attributes const NUMERAL = { x: 16, y: PAGE.height - 21, size: 13 }; // mm from the top left; size in pt // A design text's baseline sits 0.8 of its line box below its top: set a smaller label beside // a larger one this much lower and the two share a baseline. const dropTo = (big, small, lineHeight = 1.2) => (0.8 * lineHeight * (big - small) * 25.4) / 72; const caption = (colour) => [ { kind: 'text', id: 'numeral', content: '{attr.n}', fontFamily: 'Syne', fontWeight: 700, fontSize: pt(NUMERAL.size), color: colour, align: 'left', placement: at('page', 'top-left', NUMERAL.x, NUMERAL.y) }, { kind: 'text', id: 'hour', content: '· {attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8), letterSpacing: pt(0.8), color: colour, align: 'left', // the dot: a lone I reads as a bar placement: at('#numeral', 'right-of', 1.6, dropTo(NUMERAL.size, 8)) }, ]; // #endregion // #region cover: the book's title reversed out of the dawn sky of the first plate const cover = (colour) => [ { kind: 'text', id: 'title', content: '{title}', fontFamily: 'Syne', fontWeight: 800, fontSize: pt(72), lineHeight: 1, letterSpacing: pt(6), textTransform: 'uppercase', color: colour, align: 'left', placement: at('page', 'top-left', 22, 26) }, { kind: 'text', id: 'subtitle', content: '{subtitle}', fontFamily: 'Syne Mono', fontSize: pt(10), letterSpacing: pt(2), textTransform: 'uppercase', color: colour, align: 'left', placement: at('#title', 'below', 1.5, 3) }, ]; // The colophon: one element per line, 10.5 pt apart, so the break falls after the licence, and // the second line on the baseline of the plate's numeral. const COLOPHON = { y: NUMERAL.y + dropTo(NUMERAL.size, 7.5), leading: (10.5 * 25.4) / 72 }; const colophon = (colour) => ['colofon', 'tipos'].map((key, line) => ({ kind: 'text', id: key, content: `{attr.${key}}`, fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.2), color: colour, align: 'right', placement: at('page', 'top-right', -16, COLOPHON.y - (1 - line) * COLOPHON.leading) })); // #endregion // #region texts: a text between plates opens on the next page with its hours above it const textOpener = { enabled: true, slot: { elements: [ { kind: 'text', id: 'hours', content: '{attr.hora}', fontFamily: 'Syne Mono', fontSize: pt(8), letterSpacing: pt(0.8), color: col('accent'), align: 'left', placement: at('container', 'top-left', 0, 0) }, { kind: 'text', id: 'title', content: '{titleText}', fontFamily: 'Syne', fontWeight: 700, fontSize: pt(28), lineHeight: 1.05, // a multiple (gotcha: design-lineheight-multiple) color: col('ink'), align: 'left', overflow: 'wrap', placement: at('#hours', 'below', 0, 2.5, { width: 'fill' }) }, ] } }; // The folio and the book's title under the text block, flush with its left edge, on the // baseline of the plates' numerals. Not on the plates: a span heading opens their pages, so // they are opener pages, and 'body' leaves them out. const foot = (id, content, extra) => ({ kind: 'text', id, content, pages: 'body', fontFamily: 'Syne Mono', fontSize: pt(7.5), letterSpacing: pt(0.8), color: col('muted'), align: 'left', ...extra }); const FOOT = NUMERAL.y + dropTo(NUMERAL.size, 7.5) - (PAGE.height - MARGIN.bottom); // from the foot const footer = { elements: [ foot('folio', '{pageNumber}', { color: col('ink'), placement: at('container', 'top-left', 0, FOOT) }), foot('book', '{title}', { textTransform: 'uppercase', placement: at('#folio', 'right-of', 5) }), ] }; // #endregion const config = () => ({ // a factory: the engine caches resolved configs per object // The English sample is British English, set with the US patterns: 1.4.1 ships no en-gb. locale: t({ en: 'en-us', es: 'es' }), // exact codes (gotcha: hyphenation-locales) colorPalette, resourceTypes: [lamina], page: { width: mm(PAGE.width), height: mm(PAGE.height), dpi: 150, backgroundColor: col('paper'), // the ground of every page; the plates cover it margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner), right: mm(MARGIN.outer), mirror: true } }, // left is the inner margin layout: { layoutType: 'single' }, bodyText: { fontFamily: 'Andada Pro', fontSize: pt(10.5), lineHeight: pt(LEAD), color: col('ink'), boldColor: col('ink'), italicColor: col('ink'), // both default to blue referenceColor: col('ink'), referenceBold: false, // 'lámina IV' reads as a word of the text firstLineIndent: mm(4), indentAfterHeading: false, // justified and hyphenated by default minWordSpacing: 0.7, maxWordSpacing: 1.6, // a narrower range than the defaults, 0.6 to 2 maxRuntTracking: 0 }, // tracking 1.4.1 never paints (gotcha: runt-tracking-unpainted) // A heading's own line is measured even where its design paints the title: set it in a face // the page loads, or the kit fetches Open Sans for it. headings: { fontFamily: 'Syne', levels: [ // A text follows its plate with no forced break (gotcha: headings-drop-h1-break): the // plate fills its page, so the text still starts a page, and that page stays a body page, // with its running foot. { level: 1, breakBefore: { enabled: false }, advancedDesign: textOpener }, ] }, headingStyles: PLATES.map(plate), captionStyle: { fontFamily: 'Syne Mono', fontSize: pt(7.5), color: col('muted') }, header: { elements: [] }, footer, }); // ─── 2 · Content ──────────────────────────────────────────────────────────── const markdown = String.raw`---Markdown样例 · 43行 · content.es.md
title: "Sierra" subtitle: "Ensayo en seis luces" --- # Alba {style="alba" n="I" hora="08:12"} # La subida {hora="06:40–14:06 · 1770–2180 m"} Salimos del aparcamiento a las seis y cuarenta, con frontales, por la calzada de losas que sube entre piornos. A esa hora el granito guarda todavía el frío de la noche y las botas suenan más de lo que deberían. Desde la primera curva se ven abajo las luces del pueblo, once, contadas dos veces; desde la segunda ya no. A las ocho y doce el sol toca la cresta más alta. Es un filo rosado que dura lo que se tarda en sacar la cámara; luego la luz baja por las canales, despacio, y tarda casi una hora en llegar al sendero. El cervunal está blanco de escarcha y cruje bajo las botas. Una cabra montés nos mira desde un bolo sin apartarse, y somos nosotros los que damos un rodeo. En el collado paramos a comer. Hay una fuente que no sale en el mapa, un caño de hierro clavado en la piedra, y el agua está tan fría que duelen los dientes. Andrés apunta en una libreta la hora y la altitud de cada foto; a la una, el termómetro que lleva colgado de la mochila marca once grados al sol. El mediodía solar cae aquí pasadas las dos, por el horario de verano. A esa hora las sombras se esconden debajo de las piedras y la laguna, vista desde el collado, parece una chapa que alguien ha dejado olvidada entre las paredes. # Mediodía {style="lamina" art="mediodia" n="II" hora="14:06"} # Tormenta {style="pliego" art="tormenta" n="III" hora="16:40"} # Tormenta {style="pliego-recto" art="tormenta"} # Once segundos {hora="15:00–19:50 · 2180–1950 m"} A las tres el aire se vuelve pesado. Por el oeste sube una nube que al principio parece otra montaña. Recogemos deprisa. El primer trueno llega once segundos después del relámpago: casi cuatro kilómetros, calcula Andrés, que siempre cuenta. El siguiente llega a los cuatro. Nos metemos bajo el voladizo de un bloque del tamaño de una casa. Primero cae granizo menudo y luego agua, y la pared de enfrente se borra detrás de la cortina y vuelve a salir más oscura, lavada. Nadie habla en una hora larga. Hacia las siete y media la tormenta se va valle abajo y deja el cielo roto en franjas. El sol asoma un momento por debajo de las nubes, ya pegado al horizonte, y enciende la cresta de un color que no dura ni tres minutos (:ref{id="atardecer" text="lámina IV"}). Bajamos al refugio empapados, junto al arroyo, que ahora baja lleno. # Noche {style="lamina" art="noche" n="V" hora="22:10"} # Primera nieve {hora="22:10–09:00 · 1950 m"} A las diez el cielo está limpio y tiene más estrellas de las que caben en la ventana del refugio. A medianoche alguien abre la puerta y dice que nieva. Salimos en calcetines. La nieve cae recta, sin viento, en copos grandes que tardan en deshacerse en las mangas, y la linterna solo alumbra un cono de puntos blancos que vienen hacia nosotros. Por la mañana el termómetro de la ventana marca dos bajo cero. Hay un palmo de nieve en la puerta y la laguna tiene una orilla de hielo fino. Las crestas que ayer eran grises son blancas con rayas negras, las aristas donde la nieve no agarra, y el cielo tiene el mismo color que el suelo. La última foto la hace Andrés a las ocho y treinta y uno, desde el mismo bolo que ayer a mediodía. Tarda un rato en encontrarlo: las piedras que le servían de referencia son ahora montones blancos iguales. Al final da con él comparando la cresta con la foto de ayer en la pantalla de la cámara. Es la primera nevada del otoño, dos semanas antes de lo habitual, según el guarda. Salimos a las nueve. La calzada de losas no se ve, y bajamos buscando los hitos de piedra. # Nieve {style="nieve" n="VI" hora="08:31" colofon="Sierra. Ensayo en seis luces · Láminas generadas con modelos de difusión · Textos: CC BY 4.0" tipos="Compuesto en Andada Pro, Syne y Syne Mono (SIL OFL)"}`; // content.<lang>.md, inlined by the Cookbook // #region plates: every picture is a resource; only plate IV is cited, so only it floats // Plate IV is the one plate that floats, so a counter of its type would number it 1. The type // prints no number (no caption prefix, an empty template) and the caption carries the numeral. // The text names the plate with :ref's text, since a bare :ref prints 'lámina' and nothing else. const lamina = { id: 'lamina', name: t({ en: 'Plate', es: 'Lámina' }), shortLabel: t({ en: 'plate', es: 'lámina' }), numberingTemplate: '', resetOn: 'never', counterFormat: 'decimal' }; // JPEGs in assets/, cut to the page's shape: 1600 × 1200 px for 280 × 210 mm (145 dpi). const picture = (id, [w, h], alt, extra = {}) => ({ id, typeId: 'lamina', kind: 'bitmap', bitmap: { fileId: `${id}-${w}.jpg`, format: 'jpeg', width: w, height: h }, createdAt: 0, updatedAt: 0, altText: t(alt), ...extra }); const resources = [ // the five the heading styles draw, never cited, and plate IV picture('alba', [1600, 1200], { en: 'Eight ridges fading into a peach dawn haze.', es: 'Ocho crestas que se pierden en la bruma del alba.' }), picture('mediodia', [1600, 1200], { en: 'A granite cirque and its lake under a pale noon sky.', es: 'Un circo de granito y su laguna bajo el cielo pálido del mediodía.' }), picture('tormenta', [2400, 900], { en: 'A storm over the range: rain on the left, lightning ' + 'and a break of sun on the right.', es: 'Una tormenta sobre la sierra: lluvia a la ' + 'izquierda; un rayo y un claro de sol a la derecha.' }), picture('noche', [1600, 1200], { en: 'Stars over dark ridges and two lit windows at a refuge.', es: 'Estrellas sobre crestas oscuras y dos ventanas encendidas en un refugio.' }), picture('nieve', [1600, 1200], { en: 'The cirque the morning after, white with new snow.', es: 'El circo a la mañana siguiente, blanco de nieve nueva.' }), // 2000 px, 339 mm at the layout's 150 dpi, which the float fits to the 100 mm measure. picture('atardecer', [2000, 800], { en: 'A crest lit orange under strips of cloud, violet sky.', es: 'Una cresta encendida de naranja bajo franjas de nube, en un cielo violeta.' }, { caption: t({ en: 'IV · Dusk · 19:41', es: 'IV · Atardecer · 19:41' }) }), ]; // #endregion // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // text, display and label faces, loaded before the build (gotcha: fonts-first) 'Andada Pro': ['400'], Syne: ['700', '800'], 'Syne Mono': ['400'] }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); const files = resources.map((r) => r.bitmap.fileId); // a resource names a file: load each await Promise.all(files.map((f) => loadImage(f, asset(f)))); const doc = await buildWithFonts(() => buildDocument({ markdown, resources }, config()), markdown); showPages(doc, { title: t({ en: 'Sierra: a photo essay in landscape', es: 'Sierra: un ensayo fotográfico apaisado' }) });工具包 · core, fonts, viewer, images:每道食谱都相同 · 270行
// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── /Kit ───────────────────────────────────────────────────────────────────────
组合好的script.js可以直接运行:把它粘贴到任何页面的模块脚本中,或在CodePen上打开这道食谱。 GitHub上的食谱文件夹 ↗
变化
#让图版浮动到条目对页
如果图版要浮动到与图录条目相对的右页,并按比例设定图的宽度,参见与图版相对的图录条目。
#以出血照片开篇的文章
如果照片要出血铺满文章顶部,标题和导语压在照片上,参见杂志专题。
常见问题
易错点
章首页的图片从不计入它预留的高度
在postext 1.4.1中,高级设计标题计算预留高度时不算其中的图片:文字、线条和框都计入,即使它们锚定在页面上;但图片(例如出血铺满页面顶部的图)不预留任何空间,所以正文可能排到它上面。把minHeight设为正文应当开始的位置。 设计过的章首页 →
易错点
比栏还高的章首页会失去全部预留空间
在postext 1.4.1中,当高级设计标题预留的高度(其minHeight,或其最低的设计元素)超过它所开启的栏时,标题只保留它自身标题行的高度,不给警告,正文就排到设计上面。整页插图需要一栏与页面等高:给它的标题样式设零外边距,再把minHeight设为页面高度。 设计过的章首页 →
易错点
传入任何headings对象都会关掉H1换页
默认情况下,H1换页到右页(always-odd),但只要传入headings对象,这个默认值就会被重置,于是各章接排,span: 'page'也不起作用。在每份配置中重新写明headings.levels[0].breakBefore: { enabled: true, parity }。 从右页开始的章 →
易错点
替换调色板时,设计元素和引用颜色不会跟着变
postext 1.4.1把colorPalette读入文字样式(正文、标题、列表、题注、表格、框),但不读入页眉、页脚、章首页和篇章页的元素,也不读入bodyText.referenceColor:它们保留写在paletteId旁边的十六进制颜色。替换调色板时(例如做深色屏幕版或换色),在构建前根据colorPalette重写每一个关联的颜色。 语义调色板 →
易错点
设计文本的lineHeight是倍数,不是尺寸
在设计槽位中,文本元素的lineHeight是其字号的倍数(lineHeight: 1.05)。在postext 1.4.1中,写成pt(15)这样的尺寸值不会被拒绝:章首页的高度会算成NaN,它预留的空间(连同minHeight)被丢弃,也不给出警告,正文就排到了标题底下。 页面设计中的文字、线条和框 →
易错点
SVG插图中不要用<marker>或滤镜(会退回位图)
SVG图只有在不含<marker>、滤镜和蒙版时,才能在PDF中保持矢量;否则会退回位图,而且层层嵌套的滤镜可能让它在Chrome中变成空白。箭头用路径来画。 作为资源的图和表 →
易错点
消除孤字时收紧的字距可能根本不绘制
在postext 1.4.1中,段落以孤字结尾时,排版会把它排短一行:先收紧词间距,再用最多maxRuntTracking个千分之一em的负字距。Canvas和PDF渲染器只绘制大于零的字距,所以收紧了字距的段落印出来时并没有收紧:两端对齐的行从词间空格中扣掉了这部分差额,显得拥挤,末行还可能超出行长,在栏边被裁掉。设置bodyText.maxRuntTracking: 0,保留词间距的修正,再改写重新出现的孤字。 段末孤行、段首孤行与孤字 →
易错点
排版前加载所有字体
排版用浏览器已加载的字体测量文字,并缓存宽度,所以首次构建之后才到的字体会造成断行错误,PDF也不再与屏幕一致。先加载所有字重和样式;有字体迟到时,重新构建前调用clearMeasurementCache()。 排版前加载字体 →
- 不要引用由版面设计绘制的图版。一旦被引用,它还会作为图浮动:在第一篇短文里
:ref正午图版,会在第3页多出一份副本,暴风雨则后移两页,中间隔一个空白页。 - 黄昏图版位于短文下方一行处,而不在页面底部。在图版之前的最后一页上,postext 1.4.1会把位于文字下方的浮动体向上提到文字处,
position: 'bottom'在这里不起作用。 - 每篇短文在两个版本中都调整到正好一页。第一篇若延续到第3页,正午图版会移到第4页,暴风雨移到第6页和第7页,第5页空白。
- 暴风雨的右半是第二个
# Storm标题。页面上不为它印标题,但由标题生成的:::toc会把暴风雨列两次,分别在第4页和第5页。 - 图版止于成品边缘,也就是画布的边缘。打开
page.cutLines后,画布会在成品尺寸四周扩大,每张图版周围的出血区域露出纸色。用于印刷时,把图版锚定到'bleed',并让每张图版在四边各放大一个出血量。
致谢
- 文本
- The three texts, the captions and the colophon, in Spanish and English, and the six plates, drawn in code · Postext Cookbook · CC BY 4.0
- 图片
- Plate I, dawn: ridges fading into a peach haze under a dark blue sky · Generated With Diffusion Models · 原创
- Plate II, noon: a granite cirque and its lake · Generated With Diffusion Models · 原创
- Plate III, storm: rain, lightning and a break of sun across a spread · Generated With Diffusion Models · 原创
- Plate IV, dusk: the crest lit orange under a violet sky · Generated With Diffusion Models · 原创
- Plate V, night: stars over the ridges and a lit refuge · Generated With Diffusion Models · 原创
- Plate VI, snow: the cirque of plate II the morning after · Generated With Diffusion Models · 原创
- 字体
- Andada Pro (SIL OFL 1.1) · Syne (SIL OFL 1.1) · Syne Mono (SIL OFL 1.1)


