Pular para o conteúdo principal
Receita número 118

Receitas · Capítulo 1 · Página e grade

Um conto japonês em dois andares, com o título sobre ambos

Dois contos de Akutagawa numas obras completas: dois andares de 25 caracteres numa página A5, cada título sobre ambos numa página ímpar, finais como caem.

Nesta página
Saída
Canvas · PDF
Postext
Testada com o Postext 1.19.1
Requer ≥ 1.16.1 · postext-pdf ≥ 1.16.1
Licença
Atualizada em 5 de out. de 2026
Código MIT · Texto CC BY 4.0

p. 214–215 · 4–5 de 7

  • Amostra em inglês: ainda sem edição em português
  • Refile 148 × 210 mm
  • 2 colunas, medianiz de 6,3 mm
  • Noto Serif JP 9/15,8
  • Noto Sans JP
  • 7 páginas
  • Nível
  • Postext 1.19.1
  • Diagramado em 24 ms
  • 136 linhas de código

Em poucas palavras

Dois contos como os imprime uma coleção de obras completas japonesa: o texto desce pela página em duas faixas empilhadas, cada conto abre numa página da esquerda com o título na altura toda, e a última página não é nivelada.

O que você vai compor

Sete páginas de um volume de obras completas (全集) numa página A5 encadernada pela direita: O fio da aranha e A fé de Bisei, de Akutagawa Ryūnosuke, compostos em dois andares (二段組) de 25 caracteres de altura e 21 linhas de largura, em Noto Serif JP de 9 pt, com um fio fino entre os andares. Cada conto abre numa página ímpar, a da esquerda, com o título em 20 pt descendo pelos dois andares e o nome do autor perto do pé; os fólios continuam a partir da página 211. As seções 一 二 三 ocupam três linhas do seu andar. Quando um conto termina, a última página fica como o texto cai, o 成り行き (nariyuki) japonês: na página 214, o andar de cima ocupa a página toda e o de baixo tem três linhas e a data. O jornal chinês em andares verticais é a Nº 084, e um título de capítulo sobre duas colunas horizontais é a Nº 017.

Esta receita responde a

  • Como coloco o título do capítulo na largura da página enquanto o texto segue em duas colunas abaixo?
  • Como forço uma quebra de página ou de coluna e faço cada capítulo começar em página ímpar?
  • Como defino a mancha em caracteres, tantos por linha e tantas linhas por página?

A resposta curta

script.js · linhas 34–52no código completo
// On a vertical page two columns are two tiers (段), filled from the upper right: the
// gutter is the gap between them and the column rule a hairline across the page. The grid
// counts characters down one tier, so each tier is exactly 25 em long and the gutter 2 em.
const layout = {
  layoutType: 'double', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
  gutterWidth: pt(2 * BODY),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) },
};
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
// 成り行き (nariyuki): the text fills the upper tier across the whole page before the
// lower one, and a work's last page is not levelled: its lower tier ends where the text
// does. trailing: false is that choice; enabled: false, which implies it, also keeps the
// balancer from adding lines above headings on a character grid (gotcha: cjk-grid-balancing).
const balancing = { enabled: false, trailing: false };
// Each work opens on an odd page, the left-hand one in a book bound on the right, with a
// blank page before it when the last work ended on an odd page.
const work = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' } };
// 一 二 三 take three lines of their tier (3行取り), eight characters down.
const section = { level: 2, fontSize: pt(11), fontWeight: 600, lineSpan: 3, indent: em(8) };

Ingredientes

Tipografia
Noto Serif JP, Noto Sans JP (SIL OFL 1.1)
Materiais
Nenhum: todas as imagens são desenhadas em código

Preparo

#1 · Duas colunas giradas um quarto de volta são dois andares

O código é a resposta curta acima. Numa página com writingMode: 'vertical-rl', layoutType: 'double' empilha as duas colunas: a primeira enche o andar de cima a partir da direita, depois o de baixo, e a medianiz é a faixa entre eles (Escrita vertical). A grade de caracteres conta os caracteres de cima para baixo num andar, então cada andar tem 25 caracteres de 9 pt e a medianiz é arredondada para dois caracteres inteiros; as 21 linhas de 15,75 pt na largura ocupam 116,7 mm dos 148 mm da página. O fio entre os andares atravessa a página, com 0,4 pt num cinza quente.

O balanceamento do Postext terminaria os dois andares da última página de um conto na mesma linha, que é como os jornais chineses e a maioria dos livros europeus em duas colunas fecham um capítulo. Um livro japonês em andares deixa o texto cair: o andar de cima percorre a página toda antes de o de baixo começar. balancing.trailing: false é essa escolha, e enabled: false, que a inclui, também impede o balanceamento de acrescentar linhas acima dos títulos numa grade de caracteres (Balanceamento de colunas).

#2 · Descer o título da obra pelos dois andares

script.js · linhas 56–69no código completo
// A page-span heading is laid out across the whole height of the page, both tiers and the
// gutter, in the flow frame: x runs down the page, y across it leftwards from the right
// edge of the type area. The design reserves seven lines (七行取り); the tiers start after it.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 20; // pt
const opener = { enabled: true, minHeight: lines(7), slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 600,
    fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(TITLE / 2), color: col('ink'),
    placement: at(5 * BODY, 2.5 * LEAD - TITLE / 2) },
  { kind: 'text', id: 'author', content: '{author}', fontFamily: MINCHO, fontSize: pt(10.5),
    letterSpacing: pt(3), color: col('ink'),
    placement: at(HEIGHT - 5 * 10.5 - 4 * 3 - 3 * BODY, 4.5 * LEAD - 10.5 / 2) },
] } };

Um nível de título com span: 'page' é diagramado na altura toda da página, os dois andares e a medianiz, no começo do fluxo, à direita de uma página vertical; os andares começam depois das sete linhas que o design dele reserva. Um título assim sempre abre uma página, e breakBefore.parity: 'odd' faz dela a da esquerda: O fio da aranha termina na página 214, uma página par, então A fé de Bisei abre na 215 sem página em branco entre os dois (Span e design avançado). No design, x desce pela página e y a atravessa a partir da borda direita da mancha; o nome do autor é posicionado a partir do pé do andar de baixo, três caracteres acima.

#3 · Numerar as seções em três linhas do seu andar

O segundo nível, lineSpan: 3 com indent: em(8), compõe 一 二 三 oito caracteres abaixo do topo do andar, numa faixa de três linhas, como o texto do Aozora as marca (八字下げ, 中見出し), de modo que o texto depois de um número de seção continua nas linhas do andar ao lado (Ajustes por nível). As datas que fecham os contos, (大正七年四月十六日), ficam erguidas um caractere acima do pé do andar (地から一字上げ) com :::paragraphs{align=end endIndent=1}; o conversor do Aozora as escreve como um estilo aozora-end1 que as encosta no pé, e os atributos do bloco acrescentam a subida de um caractere.

#4 · Pôr o volume e a obra na margem do corte

script.js · linhas 73–97no código completo
// anchor 'outer' is the fore-edge: the left margin of an odd page and the right one of an
// even page in a book bound on the right. The volume's name on the right-hand page, the
// work on the left; the folios count on from the volume's page 211.
const foreEdge = (id, content, parity, edge, y, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', writingMode: 'vertical-rl',
  fontFamily: GOTHIC, fontSize: pt(7), letterSpacing: pt(1), color: col('muted'),
  overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
  ...extra,
});
const header = { elements: [
  foreEdge('volume', '{title}', 'even', 'top', 2),
  foreEdge('work', '{chapterTitle}', 'odd', 'top', 2),
  // The folio in Arabic digits, set across the margin: down the fore-edge they would lie
  // on their side.
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -1, { pages: 'all', writingMode: undefined,
    letterSpacing: pt(0.5) }),
] };
const none = { elements: [] };
// The first work's opening page carries the colophon across the foot, in the edition's
// language (a style's footer serves every page of its section: gotcha style-header-whole-section).
const first = { id: 'first', footer: { elements: [{ kind: 'text', id: 'colophon', pages: 'opener',
  content: '{attr.colophon}', fontFamily: GOTHIC, fontSize: pt(5.5), lineHeight: 1.45,
  color: col('muted'), overflow: 'wrap', align: 'center',
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-6) },
    size: { width: mm(118) } } }] } };

anchor.to: 'outer' põe o hashira na margem do corte, o nome do volume, 芥川龍之介集, nas páginas da direita e o título da obra nas da esquerda, e pages: 'body' os deixa fora das páginas onde uma obra abre (Elementos de texto verticais). O fólio é composto atravessado na margem, em algarismos arábicos, como muitas obras completas o imprimem: descendo pela margem do corte, três algarismos ficariam deitados. pageNumbering.startAt: 211 numera as páginas como o volume as numera. O estilo de título da primeira obra traz o colofão com pages: 'opener', porque o rodapé de um estilo serve a todas as páginas da sua seção.

A receita completa

Sandbox
// ═══ Postext Cookbook · Nº 118 · A Japanese story in two tiers, its title across both ═══
// https://postext.dev/en/cookbook/two-tier-tankobon
// Code: MIT · Text: 芥川龍之介『蜘蛛の糸』『尾生の信』, Aozora Bunko 92, 24 (PD) · Pictures: none
// Fonts: Noto Serif JP, Noto Sans JP (SIL OFL 1.1) · Needs postext ≥ 1.16.1
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, loadVerticalAlternates,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'en'; // @lang: the language of the colophon; the stories are Japanese in both
const RECIPE = 'two-tier-tankobon';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// #region palette: ink, one deep indigo, a warm book paper
const palette = {
  ink: '#1d1b19', // the text
  indigo: '#2f3d63', // 藍: the cloth of the case; the engine's defaults
  rule: '#8c857b', // the hairline between the two tiers
  muted: '#655e56', // the hashira and the folios
  paper: '#fcfaf4',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  { id: 'main-color', name: 'defaults', value: { hex: palette.indigo, model: 'hex' } },
];
// #endregion
const [MINCHO, GOTHIC] = ['Noto Serif JP', 'Noto Sans JP'];
const [BODY, LEAD, CHARS, LINES] = [9, 15.75, 25, 21]; // pt, pt: 2 段 × 25字 × 21行
const HEIGHT = 2 * CHARS * BODY + 2 * BODY; // pt: two tiers and a gutter of two characters
const lines = (n) => pt(n * LEAD); // n lines across the page

// #region answer: two tiers of 25 characters, the last page of a work left as it falls
// On a vertical page two columns are two tiers (段), filled from the upper right: the
// gutter is the gap between them and the column rule a hairline across the page. The grid
// counts characters down one tier, so each tier is exactly 25 em long and the gutter 2 em.
const layout = {
  layoutType: 'double', writingMode: 'vertical-rl', // binding 'auto' becomes 'right'
  gutterWidth: pt(2 * BODY),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) },
};
const cjk = { grid: { enabled: true, charsPerLine: CHARS, linesPerPage: LINES } };
// 成り行き (nariyuki): the text fills the upper tier across the whole page before the
// lower one, and a work's last page is not levelled: its lower tier ends where the text
// does. trailing: false is that choice; enabled: false, which implies it, also keeps the
// balancer from adding lines above headings on a character grid (gotcha: cjk-grid-balancing).
const balancing = { enabled: false, trailing: false };
// Each work opens on an odd page, the left-hand one in a book bound on the right, with a
// blank page before it when the last work ended on an odd page.
const work = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' } };
// 一 二 三 take three lines of their tier (3行取り), eight characters down.
const section = { level: 2, fontSize: pt(11), fontWeight: 600, lineSpan: 3, indent: em(8) };
// #endregion

// #region opener: the work's title down both tiers, seven lines across, the author below
// A page-span heading is laid out across the whole height of the page, both tiers and the
// gutter, in the flow frame: x runs down the page, y across it leftwards from the right
// edge of the type area. The design reserves seven lines (七行取り); the tiers start after it.
const at = (down, across) => ({ anchor: { to: 'container', edge: 'top-left' },
  offset: { x: pt(down), y: pt(across) } });
const TITLE = 20; // pt
const opener = { enabled: true, minHeight: lines(7), slot: { elements: [
  { kind: 'text', id: 'title', content: '{titleText}', fontFamily: MINCHO, fontWeight: 600,
    fontSize: pt(TITLE), lineHeight: 1, letterSpacing: pt(TITLE / 2), color: col('ink'),
    placement: at(5 * BODY, 2.5 * LEAD - TITLE / 2) },
  { kind: 'text', id: 'author', content: '{author}', fontFamily: MINCHO, fontSize: pt(10.5),
    letterSpacing: pt(3), color: col('ink'),
    placement: at(HEIGHT - 5 * 10.5 - 4 * 3 - 3 * BODY, 4.5 * LEAD - 10.5 / 2) },
] } };
// #endregion

// #region furniture: the hashira and the folio down the fore-edge
// anchor 'outer' is the fore-edge: the left margin of an odd page and the right one of an
// even page in a book bound on the right. The volume's name on the right-hand page, the
// work on the left; the folios count on from the volume's page 211.
const foreEdge = (id, content, parity, edge, y, extra = {}) => ({
  kind: 'text', id, content, parity, pages: 'body', writingMode: 'vertical-rl',
  fontFamily: GOTHIC, fontSize: pt(7), letterSpacing: pt(1), color: col('muted'),
  overflow: 'clip', placement: { anchor: { to: 'outer', edge }, offset: { y: em(y) } },
  ...extra,
});
const header = { elements: [
  foreEdge('volume', '{title}', 'even', 'top', 2),
  foreEdge('work', '{chapterTitle}', 'odd', 'top', 2),
  // The folio in Arabic digits, set across the margin: down the fore-edge they would lie
  // on their side.
  foreEdge('folio', '{pageNumber}', 'all', 'bottom', -1, { pages: 'all', writingMode: undefined,
    letterSpacing: pt(0.5) }),
] };
const none = { elements: [] };
// The first work's opening page carries the colophon across the foot, in the edition's
// language (a style's footer serves every page of its section: gotcha style-header-whole-section).
const first = { id: 'first', footer: { elements: [{ kind: 'text', id: 'colophon', pages: 'opener',
  content: '{attr.colophon}', fontFamily: GOTHIC, fontSize: pt(5.5), lineHeight: 1.45,
  color: col('muted'), overflow: 'wrap', align: 'center',
  placement: { anchor: { to: 'page', edge: 'bottom' }, offset: { y: mm(-6) },
    size: { width: mm(118) } } }] } };
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ja', // written out, never LANG (gotcha: ja-locale-tag)
  colorPalette,
  page: {
    sizePreset: 'custom', width: mm(148), height: mm(210), dpi: 150, // A5
    backgroundColor: col('paper'),
    margins: { top: mm(24), bottom: mm(18), left: mm(14), right: mm(16), mirror: true },
    pageNumbering: { startAt: 211 },
  },
  layout,
  cjk,
  bodyText: {
    fontFamily: MINCHO, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
    boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
    textAlign: 'justify', firstLineIndent: em(1), indentAfterHeading: true,
  },
  headings: { fontFamily: MINCHO, fontWeight: 600, color: col('ink'), balancing,
    levels: [{ ...work, advancedDesign: opener }, section] }, // gotcha: headings-drop-h1-break
  headingStyles: [first],
  header,
  footer: none,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Amostra em Markdown · 93 linhas · content.en.mdtitle: "芥川龍之介集" author: "芥川龍之介" --- # 蜘蛛の糸 {style="first" colophon="Akutagawa Ryūnosuke, 蜘蛛の糸 (The Spider’s Thread, 1918) and 尾生の信 (Bisei’s Faith, 1920). Text: Aozora Bunko 92 and 24, public domain. Set in Noto Serif JP and Noto Sans JP (SIL OFL)."} ## 一 ある日の事でございます。{御釈迦様|おしゃかさま}は極楽の{蓮池|はすいけ}のふちを、独りでぶらぶら御歩きになっていらっしゃいました。池の中に咲いている{蓮|はす}の花は、みんな玉のようにまっ白で、そのまん中にある{金色|きんいろ}の{蕊|ずい}からは、何とも云えない{好|よ}い{匂|におい}が、{絶間|たえま}なくあたりへ{溢|あふ}れて居ります。極楽は丁度朝なのでございましょう。 やがて御釈迦様はその池のふちに{御佇|おたたず}みになって、水の{面|おもて}を{蔽|おお}っている蓮の葉の間から、ふと下の{容子|ようす}を御覧になりました。この極楽の蓮池の下は、丁度{地獄|じごく}の底に当って居りますから、{水晶|すいしよう}のような水を透き徹して、{三途|さんず}の河や針の山の景色が、丁度{覗|のぞ}き{眼鏡|めがね}を見るように、はっきりと見えるのでございます。 するとその地獄の底に、{犍陀多|かんだた}と云う男が一人、ほかの罪人と一しょに{蠢|うごめ}いている姿が、御眼に止まりました。この犍陀多と云う男は、人を殺したり家に火をつけたり、いろいろ悪事を働いた大泥坊でございますが、それでもたった一つ、善い事を致した覚えがございます。と申しますのは、ある時この男が深い林の中を通りますと、小さな{蜘蛛|くも}が一匹、路ばたを{這|は}って行くのが見えました。そこで犍陀多は早速足を挙げて、踏み殺そうと致しましたが、「いや、いや、これも小さいながら、命のあるものに違いない。その命を{無暗|むやみ}にとると云う事は、いくら何でも可哀そうだ。」と、こう急に思い返して、とうとうその蜘蛛を殺さずに助けてやったからでございます。 御釈迦様は地獄の容子を御覧になりながら、この犍陀多には蜘蛛を助けた事があるのを御思い出しになりました。そうしてそれだけの善い事をした{報|むくい}には、出来るなら、この男を地獄から救い出してやろうと御考えになりました。幸い、側を見ますと、{翡翠|ひすい}のような色をした蓮の葉の上に、極楽の蜘蛛が一匹、美しい銀色の糸をかけて居ります。御釈迦様はその蜘蛛の糸をそっと御手に御取りになって、玉のような{白蓮|しらはす}の間から、遥か下にある地獄の底へ、まっすぐにそれを御{下|おろ}しなさいました。 ## 二 こちらは地獄の底の血の池で、ほかの罪人と一しょに、浮いたり沈んだりしていた{犍陀多|かんだた}でございます。何しろどちらを見ても、まっ暗で、たまにそのくら暗からぼんやり浮き上っているものがあると思いますと、それは恐しい針の山の針が光るのでございますから、その心細さと云ったらございません。その上あたりは墓の中のようにしんと静まり返って、たまに聞えるものと云っては、ただ罪人がつく{微|かすか}な{嘆息|たんそく}ばかりでございます。これはここへ落ちて来るほどの人間は、もうさまざまな地獄の{責苦|せめく}に疲れはてて、泣声を出す力さえなくなっているのでございましょう。ですからさすが大泥坊の犍陀多も、やはり血の池の血に{咽|むせ}びながら、まるで死にかかった{蛙|かわず}のように、ただもがいてばかり居りました。 ところがある時の事でございます。{何気|なにげ}なく犍陀多が頭を挙げて、血の池の空を眺めますと、そのひっそりとした暗の中を、遠い遠い天上から、銀色の{蜘蛛|くも}の糸が、まるで人目にかかるのを恐れるように、一すじ細く光りながら、するすると自分の上へ垂れて参るのではございませんか。犍陀多はこれを見ると、思わず手を{拍|う}って喜びました。この糸に{縋|すが}りついて、どこまでものぼって行けば、きっと地獄からぬけ出せるのに相違ございません。いや、うまく行くと、極楽へはいる事さえも出来ましょう。そうすれば、もう針の山へ追い上げられる事もなくなれば、血の池に沈められる事もある筈はございません。 こう思いましたから{犍陀多|かんだた}は、早速その蜘蛛の糸を両手でしっかりとつかみながら、一生懸命に上へ上へとたぐりのぼり始めました。元より大泥坊の事でございますから、こう云う事には昔から、慣れ切っているのでございます。 しかし地獄と極楽との間は、何万里となくございますから、いくら{焦|あせ}って見た所で、容易に上へは出られません。ややしばらくのぼる{中|うち}に、とうとう犍陀多もくたびれて、もう一たぐりも上の方へはのぼれなくなってしまいました。そこで仕方がございませんから、まず一休み休むつもりで、糸の中途にぶら下りながら、遥かに目の下を見下しました。 すると、一生懸命にのぼった甲斐があって、さっきまで自分がいた血の池は、今ではもう暗の底にいつの間にかかくれて居ります。それからあのぼんやり光っている恐しい針の山も、足の下になってしまいました。この分でのぼって行けば、地獄からぬけ出すのも、存外わけがないかも知れません。犍陀多は両手を蜘蛛の糸にからみながら、ここへ来てから何年にも出した事のない声で、「しめた。しめた。」と笑いました。ところがふと気がつきますと、蜘蛛の糸の下の方には、{数限|かずかぎり}もない罪人たちが、自分ののぼった後をつけて、まるで{蟻|あり}の行列のように、やはり上へ上へ一心によじのぼって来るではございませんか。犍陀多はこれを見ると、驚いたのと恐しいのとで、しばらくはただ、{莫迦|ばか}のように大きな口を{開|あ}いたまま、眼ばかり動かして居りました。自分一人でさえ{断|き}れそうな、この細い蜘蛛の糸が、どうしてあれだけの{人数|にんず}の重みに堪える事が出来ましょう。もし万一途中で{断|き}れたと致しましたら、折角ここへまでのぼって来たこの{肝腎|かんじん}な自分までも、元の地獄へ{逆落|さかおと}しに落ちてしまわなければなりません。そんな事があったら、大変でございます。が、そう云う中にも、罪人たちは何百となく何千となく、まっ暗な血の池の底から、うようよと{這|は}い上って、細く光っている蜘蛛の糸を、一列になりながら、せっせとのぼって参ります。今の中にどうかしなければ、糸はまん中から二つに断れて、落ちてしまうのに違いありません。 そこで犍陀多は大きな声を出して、「こら、罪人ども。この蜘蛛の糸は{己|おれ}のものだぞ。お前たちは一体誰に{尋|き}いて、のぼって来た。下りろ。下りろ。」と{喚|わめ}きました。 その途端でございます。今まで何ともなかった蜘蛛の糸が、急に犍陀多のぶら下っている所から、ぷつりと音を立てて{断|き}れました。ですから犍陀多もたまりません。あっと云う{間|ま}もなく風を切って、{独楽|こま}のようにくるくるまわりながら、見る見る中に暗の底へ、まっさかさまに落ちてしまいました。 後にはただ極楽の蜘蛛の糸が、きらきらと細く光りながら、月も星もない空の中途に、短く垂れているばかりでございます。 ## 三 {御釈迦様|おしゃかさま}は極楽の{蓮池|はすいけ}のふちに立って、この一部{始終|しじゅう}をじっと見ていらっしゃいましたが、やがて{犍陀多|かんだた}が血の池の底へ石のように沈んでしまいますと、悲しそうな御顔をなさりながら、またぶらぶら御歩きになり始めました。自分ばかり地獄からぬけ出そうとする、犍陀多の無慈悲な心が、そうしてその心相当な罰をうけて、元の地獄へ落ちてしまったのが、御釈迦様の御目から見ると、浅間しく思召されたのでございましょう。 しかし極楽の蓮池の蓮は、少しもそんな事には{頓着|とんじゃく}致しません。その玉のような白い花は、御釈迦様の{御足|おみあし}のまわりに、ゆらゆら{萼|うてな}を動かして、そのまん中にある金色の{蕊|ずい}からは、何とも云えない{好|よ}い匂が、{絶間|たえま}なくあたりへ{溢|あふ}れて居ります。極楽ももう{午|ひる}に近くなったのでございましょう。 :::paragraphs{align=end endIndent=1} (大正七年四月十六日) ::: # 尾生の信 {尾生|びせい}は橋の下に{佇|たたず}んで、さっきから女の来るのを待っている。 見上げると、高い石の{橋欄|きょうらん}には、{蔦蘿|つたかずら}が半ば{這|は}いかかって、時々その間を通りすぎる往来の人の{白衣|はくい}の裾が、鮮かな入日に照らされながら、悠々と風に吹かれて行く。が、女は未だに来ない。 尾生はそっと口笛を鳴しながら、気軽く橋の下の{洲|す}を見渡した。 橋の下の{黄泥|こうでい}の洲は、二坪ばかりの広さを{剰|あま}して、すぐに水と続いている。{水際|みずぎわ}の{蘆|あし}の間には、{大方|おおかた}{蟹|かに}の{棲家|すみか}であろう、いくつも{円|まる}い穴があって、そこへ波が当る度に、たぶりと云うかすかな音が聞えた。が、女は未だに来ない。 尾生はやや待遠しそうに水際まで{歩|ほ}を移して、舟{一艘|いっそう}通らない静な川筋を眺めまわした。 川筋には青い{蘆|あし}が、{隙間|すきま}もなくひしひしと生えている。のみならずその蘆の間には、{所々|ところどころ}に{川楊|かわやなぎ}が、こんもりと円く茂っている。だからその間を縫う水の{面|おもて}も、川幅の割には広く見えない。ただ、{帯|おび}ほどの澄んだ水が、{雲母|きらら}のような雲の影をたった一つ{鍍金|めっき}しながら、ひっそりと蘆の中にうねっている。が、女は未だに来ない。 尾生は水際から歩をめぐらせて、今度は広くもない{洲|す}の上を、あちらこちらと歩きながら、おもむろに暮色を加えて行く、あたりの静かさに耳を傾けた。 橋の上にはしばらくの間、{行人|こうじん}の跡を絶ったのであろう。{沓|くつ}の音も、{蹄|ひづめ}の音も、あるいはまた車の音も、そこからはもう聞えて来ない。風の音、蘆の音、水の音、――それからどこかでけたたましく、{蒼鷺|あおさぎ}の啼く声がした。と思って立止ると、いつか潮がさし出したと見えて、{黄泥|こうでい}を洗う水の色が、さっきよりは間近に光っている。が、女は未だに来ない。 尾生は険しく{眉|まゆ}をひそめながら、橋の下のうす暗い洲を、いよいよ足早に歩き始めた。その内に川の水は、一寸ずつ、一尺ずつ、次第に洲の上へ上って来る。同時にまた川から{立昇|たちのぼ}る{藻|も}の{匀|におい}や水の匀も、冷たく肌にまつわり出した。見上げると、もう橋の上には鮮かな入日の光が消えて、ただ、石の{橋欄|きょうらん}ばかりが、ほのかに青んだ{暮方|くれがた}の空を、黒々と正しく切り抜いている。が、女は未だに来ない。 尾生はとうとう立ちすくんだ。 川の水はもう沓を濡しながら、鋼鉄よりも冷やかな光を{湛|たた}えて、漫々と橋の下に広がっている。すると、{膝|ひざ}も、腹も、胸も、恐らくは{頃刻|けいこく}を出ない内に、この{酷薄|こくはく}な満潮の水に隠されてしまうのに相違あるまい。いや、そう云う内にも{水嵩|みずかさ}は{益|ますます}高くなって、今ではとうとう{両脛|りょうはぎ}さえも、川波の下に没してしまった。が、女は未だに来ない。 尾生は水の中に立ったまま、まだ{一縷|いちる}の望を便りに、何度も橋の空へ眼をやった。 腹を{浸|ひた}した水の上には、とうに{蒼茫|そうぼう}たる暮色が立ち{罩|こ}めて、{遠近|おちこち}に茂った蘆や柳も、寂しい葉ずれの音ばかりを、ぼんやりした{靄|もや}の中から送って来る。と、尾生の鼻を{掠|かす}めて、{鱸|すずき}らしい魚が一匹、ひらりと白い腹を{飜|ひるがえ}した。その魚の躍った空にも、{疎|まばら}ながらもう星の光が見えて、{蔦蘿|つたかずら}のからんだ{橋欄|きょうらん}の形さえ、いち早い宵暗の中に{紛|まぎ}れている。が、女は未だに来ない。…… :::space{lines=1} :::paragraphs{indent=6} ―――――――――――― ::: :::space{lines=1} 夜半、月の光が{一川|いっせん}の蘆と柳とに{溢|あふ}れた時、川の水と微風とは静に{囁|ささや}き交しながら、橋の下の尾生の死骸を、やさしく海の方へ運んで行った。が、尾生の魂は、寂しい天心の月の光に、思い{憧|こが}れたせいかも知れない。ひそかに死骸を抜け出すと、ほのかに明るんだ空の向うへ、まるで水の{匀|におい}や{藻|も}の匀が音もなく川から立ち昇るように、うらうらと高く昇ってしまった。…… それから幾千年かを隔てた{後|のち}、この魂は無数の{流転|るてん}を{閲|けみ}して、また生を{人間|じんかん}に託さなければならなくなった。それがこう云う私に宿っている魂なのである。だから私は現代に生れはしたが、何一つ意味のある仕事が出来ない。昼も夜も漫然と夢みがちな生活を送りながら、ただ、何か{来|きた}るべき不可思議なものばかりを待っている。ちょうどあの尾生が{薄暮|はくぼ}の橋の下で、永久に来ない恋人をいつまでも待ち暮したように。 :::paragraphs{align=end endIndent=1} (大正八年十二月) :::
`; // content.<lang>.md: the same Japanese text in both // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { 'Noto Serif JP': ['400', '600'], // the text; the titles and 一 二 三 'Noto Sans JP': ['400'], // the hashira, the folios and the colophon }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── // #region voices: each face loads the files that hold the characters it sets const title = markdown.match(/^title: "(.*)"$/m)[1]; const author = markdown.match(/^author: "(.*)"$/m)[1]; const heads = markdown.match(/^#+ [^{\n]*/gm).join(''); await loadFonts(FONTS, markdown); // the Latin files: the colophon, with ū await loadCjkFonts({ [MINCHO]: ['400'] }, markdown, { vertical: true }); await loadCjkFonts({ [MINCHO]: ['600'] }, `${heads}`, { vertical: true }); const colophon = markdown.match(/colophon="([^"]*)"/)[1]; await loadCjkFonts({ [GOTHIC]: ['400'] }, `${title}${heads}${colophon}`, { vertical: true }); // #endregion const doc = await buildWithFonts(() => buildDocument({ markdown }, config()), markdown); showBook(doc, { title: t({ en: 'A Japanese story in two tiers, its title across both', es: 'Un cuento japonés en dos pisos, con el título sobre ambos' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: cjkPdfProvider }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, cjk: igual em todas as receitas · 459 linhas// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v2 ── the same in every recipe · postext.dev/cookbook // Postext measures with the loaded faces and caches the widths: load every face // before the first build, from Fontsource, the files the PDF embeds too. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * č ł † α χ also load latin-ext and greek files (kitSubsetsFor). With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', greek: 'U+0370-03FF', }; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const todo = [...new Set(specs)].map((spec) => [parseInt(spec, 10), spec.endsWith('i') ? 'italic' : 'normal']) .filter(([weight, style]) => !hasFace(family, weight, style)); // before any await const meta = optional || /[^\0-ÿ]/u.test(text) ? await fontsourceMeta(family) : null; const subsets = ['latin', ...kitSubsetsFor(text, meta)]; for (const [weight, style] of todo) { if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` and loads any face the pages use that FONTS missed (a regular * one with a warning), then clears the measurement cache and builds again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout; `base` marks a block's own face. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** A loaded FontFace covers this family, weight and style (fonts.check() would * also say yes for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** The files beyond latin `text` needs that `meta`'s family ships. */ function kitSubsetsFor(text, meta) { return [[/[Ā-˿ᴀ-ᶿḀ-ỿ†ℓⱠ-Ɀ꜠-ꟿ]/u, 'latin-ext'], [/[Ͱ-Ͽ]/u, 'greek']] .filter(([re, x]) => re.test(text) && meta?.subsets?.includes(x)).map(([, x]) => x); } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The family's Fontsource metadata (weights, styles, subsets), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook /** The pages as spreads on a dark desk, page 1 alone, then verso | recto, * each painted when it scrolls near. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v2 ── the same in every recipe that exports a PDF /** The Fontsource files the screen used, as TrueType: the nearest weight the * family ships, upright if it has no italic; latin, then what the face's * letters need (kitSubsetsFor). */ async function fontsourceProvider(family, weight, style, request) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const text = String.fromCodePoint(...(request?.codePoints ?? [])); const more = kitSubsetsFor(text, meta); const files = await Promise.all(['latin', ...more].map(async (subset) => { const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} ${subset}`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); return files.length === 1 ? files[0] : files; } /** A "Build the PDF" button; then "Open the PDF" (a new tab: CodePen's frame * shows no PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · cjk v1 ── Chinese, Japanese and Korean books · postext.dev/cookbook // Fontsource ships a CJK family as about a hundred files per weight, each // declared in its stylesheet with the unicode-range it covers. The screen // loads the files the sample touches; the PDF gets the same files for the // characters its pages set in each face, and embeds each as a subset. // A book bound on the right (vertical text) is shown with its spreads // mirrored: page 1 alone on the left of the spine, then [3 | 2]. /** The files of a Fontsource face, read from its stylesheet: { url, range, * ranges }, the last declared first (the order the browser tries them in). */ function cjkSlices(family, weight, style) { cjkSlices.cache ??= new Map(); const id = fontsourceId(family); const css = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/${weight}${style === 'italic' ? '-italic' : ''}.css`; if (!cjkSlices.cache.has(css)) { cjkSlices.cache.set(css, fetch(css) .then((res) => { if (!res.ok) throw new Error(`Fontsource has no ${family} ${weight} ${style} (${res.status})`); return res.text(); }) .then((text) => [...text.matchAll(/@font-face\s*{([^}]*)}/g)].map(([, rule]) => { const range = /unicode-range:\s*([^;]+);/.exec(rule)?.[1].trim() ?? 'U+0-10FFFF'; const ranges = range.split(',').map((part) => { const [lo, hi = lo] = part.trim().slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return { url: new URL(/url\(([^)]+?\.woff2)\)/.exec(rule)[1], css).href, range, ranges }; }).reverse())); } return cjkSlices.cache.get(css); } /** The file of `slices` that holds code point `cp`, if any. */ function cjkSliceFor(slices, cp) { return slices.find((slice) => slice.ranges.some(([lo, hi]) => cp >= lo && cp <= hi)); } /** Whether Fontsource serves `family` as a Chinese, Japanese or Korean * family (its subsets name the script). Fails when the API does not * answer: a CJK face taken for a Latin one would paint in a system face. */ async function isCjkFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.some((subset) => /^(chinese|japanese|korean)/.test(subset)); } /** faces = { 'Noto Serif TC': ['400', '700'] }, as for loadFonts: the * whole FONTS object may be passed, its other families are left to * loadFonts. Adds one FontFace per file of each CJK face with its * unicodeRange, then loads the files `text` touches. `text` is what the * faces set: the sample for the text face; a book in several voices calls * it once per voice (loadCjkFonts({ 'LXGW WenKai TC': ['400'] }, quotes)), * so the heading and quotation faces fetch and check only their own * characters. Fails when a character of `text` is in no file of a face. * List every weight the pages use: a weight left to buildWithFonts gets * the latin file only. With { vertical: true } it also loads each * family's vertical forms (brackets, quotes, pause marks) for the canvas, * which needs loadVerticalAlternates imported from postext. Resolves to * the number of files loaded. */ async function loadCjkFonts(faces, text, { vertical = false } = {}) { kitStatus('Loading fonts…'); let loaded = 0; try { if (vertical && typeof loadVerticalAlternates !== 'function') { throw new Error('loadCjkFonts(…, { vertical: true }) needs loadVerticalAlternates imported from postext'); } for (const [family, specs] of Object.entries(faces)) { if (!(await isCjkFamily(family))) continue; const twin = []; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const slices = await cjkSlices(family, weight, style); const missing = [...new Set(text)].filter((ch) => /\S/.test(ch) && !cjkSliceFor(slices, ch.codePointAt(0))); if (missing.length) { throw new Error(`${family} ${spec} has no file for ${missing.slice(0, 12).join(' ')}: ` + `give each face the text it sets (loadCjkFonts({ '${family}': ['${spec}'] }, text))`); } for (const slice of slices) { document.fonts.add(new FontFace(family, `url(${slice.url}) format('woff2')`, { weight: String(weight), style, unicodeRange: slice.range })); twin.push({ source: slice.url, weight: String(weight), style, unicodeRange: slice.range }); } const font = `${style === 'italic' ? 'italic ' : ''}${weight} 16px "${family}"`; loaded += (await document.fonts.load(font, text)).length; if (!document.fonts.check(font, text)) throw new Error(`${family} ${spec} did not load for the sample`); } // The same files under a twin name with the `vert` feature on: the // canvas paints the punctuation of vertical lines with it. if (vertical && twin.length) await loadVerticalAlternates(family, twin); } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with CJK faces: a family whose * Fontsource subsets are Chinese, Japanese or Korean gets the files that * hold the characters its pages set (`request.codePoints`); any other * family gets the latin file fontsourceProvider fetches (the "pdf" block) * and, when the face sets letters only latin-ext has, that file too. */ async function cjkPdfProvider(family, weight, style, request) { if (!(await isCjkFamily(family))) return cjkLatinPdfFiles(family, weight, style, request); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const slices = await cjkSlices(family, w, s); const picked = new Set(); for (const cp of request?.codePoints ?? []) { const slice = cjkSliceFor(slices, cp); if (slice) picked.add(slice); } if (!picked.size) picked.add(slices[0]); return Promise.all(slices.filter((slice) => picked.has(slice)).map(async (slice) => { const res = await fetch(slice.url); if (!res.ok) throw new Error(`Fontsource file ${slice.url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } /** A Latin family set next to the CJK faces: its latin file, then its * latin-ext file when the face sets letters only latin-ext has (ō ū in * Hepburn rōmaji, ǎ in pinyin), the file loadFonts adds on screen for * them. Latin comes first: postext-pdf draws a character from the first * file that has it, as the browser takes a character both files hold from * latin. A face Fontsource ships without latin-ext, or whose file does * not come, gets latin alone, and the PDF names the letters it lacks. */ async function cjkLatinPdfFiles(family, weight, style, request) { const meta = await fontsourceMeta(family); const beyond = [...(request?.codePoints ?? [])].some(cjkLatinExtOnly); if (!beyond || !meta?.subsets?.includes('latin-ext')) return fontsourceProvider(family, weight, style); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const id = fontsourceId(family); const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`; const [latin, ext] = await Promise.all([fontsourceProvider(family, weight, style), fetch(url) .then(async (res) => (res.ok ? decompressWoff2(new Uint8Array(await res.arrayBuffer())) : null), () => null)]); return ext ? [latin, ext] : latin; } /** Whether code point `cp` is in Fontsource's latin-ext file and not in * its latin file: Latin Extended-A and -B, IPA, the spacing modifiers and * Latin Extended Additional (loadFonts's test for latin-ext), less the * few latin holds too (ı Œ œ ʻ ʼ ˆ ˚ ˜). */ function cjkLatinExtOnly(cp) { if (!((cp >= 0x100 && cp <= 0x2ff) || (cp >= 0x1e00 && cp <= 0x1eff))) return false; return ![0x131, 0x152, 0x153, 0x2bb, 0x2bc, 0x2c6, 0x2da, 0x2dc].includes(cp); } /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right' and * for vertical text) lies on the desk as it opens: page 1 alone on the * left of the spine, then [3 | 2], the spine shade on each page's inner * edge. `binding` ('left' | 'right') overrides the document's. */ function showBook(docs, { binding, ...options } = {}) { const count = showPages(docs, options); const right = (binding ?? [docs].flat()[0]?.binding) === 'right'; if (!document.getElementById('pt-kit-cjk')) { // The pages keep direction ltr: a canvas draws text in the direction its // element inherits, and under rtl each run would end where the engine // starts it, its brackets mirrored. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-cjk"> .pt-spread[dir="rtl"] canvas { direction: ltr; } .pt-spread[dir="rtl"] figure:first-child canvas { box-shadow: inset 14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } </style>`); } // Each pair stays [verso, recto] in the page; right to left, the verso // sits on the right. Phones stack the pages in reading order either way. for (const spread of document.querySelectorAll('#pages > .pt-spread')) spread.dir = right ? 'rtl' : 'ltr'; document.getElementById('pages').dataset.binding = right ? 'right' : 'left'; return count; } // ─── /Kit ───────────────────────────────────────────────────────────────────────

O script.js montado funciona como está: cole-o como script de módulo em qualquer página ou abra a receita no CodePen. Pasta da receita no GitHub ↗ (abre em uma nova aba)

Variações

#Nivelar a última página

Sem balancing, a última página de cada obra é cortada nivelada: a página 214 termina com 10 linhas no andar de cima e 11 no de baixo, o fechamento que um livro chinês ou europeu em colunas usa.

-const balancing = { enabled: false, trailing: false };
+const balancing = {}; // the defaults: the closing band is cut level

#Abrir cada obra na página seguinte

parity: 'any' começa uma obra na página que vier, como fazem as revistas e as antologias de textos muito curtos.

-const work = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'odd' } };
+const work = { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' } };

#Compor o conto num só andar

Romances e bunko compõem o texto num só andar, sem medianiz; a Nº 117 compõe um bunko assim.

Erros comuns

Erro comum

Marque um texto japonês como 'ja', nunca com zh-Hans nem LANG

As edições de uma receita são en e es, mas uma amostra japonesa é japonesa nas duas: `locale: LANG` a marcaria como inglês ou espanhol, e uma marcação chinesa a comporia pelas regras chinesas (pontuação Kaiming, kana pequeno livre para começar linha, 图 no lugar de 図, formas chinesas dos glifos no PDF). Escreva 'ja': isso escolhe a região do Japão (quebra de linha e pontuação da JLReq, pontos de ênfase em gergelim, espaçamento dos furigana, rótulos 図 e 表) e desativa a hifenização. O lint reprova um texto com kana sob uma marcação zh ou ko. Quebra de linha em japonês (kinsoku) →

Erro comum

Qualquer objeto headings desativa a quebra de página do H1

Por padrão, um H1 salta para uma página ímpar (always-odd), mas passar qualquer objeto headings redefine esse padrão, então os capítulos ficam emendados e span: 'page' não faz nada. Declare de novo headings.levels[0].breakBefore: { enabled: true, parity } em toda configuração. Capítulos que abrem em página ímpar →

Erro comum

Em uma grade de caracteres, desative o balanceamento de colunas

O balanceamento de colunas preenche uma página que termina curta (como quando um título mantido junto ao seu texto deixa linhas em branco no pé) acrescentando linhas da grade acima dos títulos e compondo um parágrafo com uma linha mais frouxa. Uma linha chinesa mais frouxa espaça os seus caracteres (0,13 eme em uma página GB/T 9704, muito acima de balancing.maxTracking), então os caracteres saem das colunas da grade e os títulos saem das suas linhas. Uma página contada em células deve terminar curta: defina headings.balancing: { enabled: false }. Grade de caracteres →

Erro comum

Um título na largura da página sempre abre uma página nova

Um título cujo nível ou estilo define span: 'page' é composto como abertura no alto de uma página, tenha ou não breakBefore. Colocado depois de outro texto, ou de outra abertura na mesma página, ele passa para a página seguinte e deixa vazio o resto da atual. Uma manchete que atravessa as duas colunas (ou os dois andares de uma página vertical) sob o cabeçalho do jornal vai no design da própria abertura, ao lado do cabeçalho, ou dentro de um boxe :::callout{span="page"} como título sem span, que é como newspaper-front-page compõe as suas manchetes. Aberturas desenhadas →

Erro comum

Fontes chinesas são carregadas em fatias, pelo bloco cjk

O Fontsource serve uma família chinesa, japonesa ou coreana em cerca de cem arquivos por peso, cada um com um intervalo de caracteres. loadFonts baixa só o arquivo latin, então na tela os caracteres chineses vêm de uma fonte do sistema e são medidos errado, e o fontsourceProvider entrega ao PDF esse arquivo latin, que os imprime como caixas vazias. Inclua o bloco cjk do kit, chame loadCjkFonts(FONTS, markdown) depois de loadFonts (uma vez por voz, com o texto que ela compõe, quando o livro usa várias fontes CJK) e passe a renderToPdf fontProvider: cjkPdfProvider: os dois pegam os arquivos que contêm os caracteres do texto. Fontes chinesas, japonesas e coreanas →

Erro comum

Um livro encadernado pela direita mostra as páginas duplas com showBook

Em um livro encadernado pela direita (texto árabe, hebraico ou persa, chinês vertical, ou page.binding 'right') a página 1 continua sendo a ímpar, mas fica à esquerda da lombada, e os pares se leem [3 | 2]. showPages dispõe todos os livros como se fossem encadernados pela esquerda; showBook, do bloco book (o bloco cjk traz a mesma função), lê doc.binding e espelha os pares. capture.hero continua nomeando uma página dupla em ordem de leitura, [par, ímpar]: [2, 3]. Livros encadernados pela direita →

Créditos

Texto
  • 芥川龍之介「蜘蛛の糸」 (The Spider’s Thread, 1918); Aozora Bunko card 92, from 「芥川龍之介全集2」 (Chikuma Bunko, 1986), input 平山誠 and 野口英司, proofreading もりみつじゅんじ, converted with aozora.py · Akutagawa Ryūnosuke; Aozora Bunko volunteers · domínio público
  • 芥川龍之介「尾生の信」 (Bisei’s Faith, 1920); Aozora Bunko card 24, from 「芥川龍之介全集3」 (Chikuma Bunko, 1986), input j.utiyama, proofreading かとうかおり, converted with aozora.py; the rule before the coda shortened to fit a tier · Akutagawa Ryūnosuke; Aozora Bunko volunteers · domínio público
Fontes
Noto Serif JP (SIL OFL 1.1) · Noto Sans JP (SIL OFL 1.1)
SandboxPDF