Saltar al contenido principal
Receta número 105

Recetario · Capítulo 2 · Texto y tipografía

Una novela árabe compuesta de derecha a izquierda

Una novela árabe en 14 × 21 cm: con locale 'ar' el texto va de derecha a izquierda, el libro se encuaderna por la derecha y los folios dicen ١٢.

En esta página
Salida
Canvas · PDF
Postext
Probada con Postext 1.15.0
Requiere ≥ 1.15.0 · postext-pdf ≥ 1.15.0
Licencia
Actualizada el 4 oct 2026
Código MIT · Texto CC BY 4.0

pp. ١٠–١١ · 1–2 de 7

  • Formato 140 × 210 mm
  • 1 columna
  • Markazi Text 13,5/22
  • Noto Kufi Arabic
  • Reem Kufi
  • 7 páginas
  • Nivel
  • Postext 1.15.0
  • Compuesto en 46 ms
  • 158 líneas de código

En pocas palabras

Las primeras páginas de una novela escrita en árabe, como las imprimiría una editorial árabe: las líneas van de derecha a izquierda, el libro se abre por lo que un lector occidental llama el final y los números de página usan cifras árabes.

Lo que vas a componer

El comienzo de حروف من رصاص (Letras de plomo), una novela sobre un nieto que vuelve a abrir la imprenta de su abuelo en un callejón de El Cairo, compuesta como una edición árabe en rústica: 14 × 21 cm, Markazi Text de 13,5 pt sobre 22 pt de interlineado, justificado, con la sangría a la derecha. El fragmento empieza en la página 10 con una portadilla de parte en el azul de la puerta de la imprenta, una celosía de estrellas de ocho puntas dibujada con código y el nombre de la parte en Reem Kufi de 64 pt. Enfrente, en la página izquierda, se abre el capítulo, numerado en letras: الفصل الأول. Los folios dicen ١٠, ١١, ١٢; el título del libro va sobre las páginas derechas y el del capítulo sobre las izquierdas. Una novela en rústica compone el mismo tipo de libro de izquierda a derecha.

Esta receta responde a

  • ¿Cómo compongo un libro árabe de derecha a izquierda y lo encuaderno por la derecha?

La respuesta corta

script.js · líneas 35–50en el código completo
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  // left and right are the flow's sides: in a right-to-left book left is the spine side,
  // so these margins put 20 mm at the spine and 16 mm outside on both pages of a spread.
  margins: { top: mm(23), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true },
  // binding: 'auto' (unset) is 'right' for a right-to-left locale: page 1 is a left page.
};
const bodyText = {
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  // justify ends every line on both sides; the engine lengthens joined letters (kashida,
  // on by default for Arabic) after it opens the spaces a quarter. Never hyphenated.
  textAlign: 'justify', optimalLineBreaking: true,
  firstLineIndent: em(1.5), indentAfterHeading: false, // the indent is on the right
  avoidWidows: true, avoidOrphans: true, avoidRunts: true,
};

Ingredientes

Tipografía
Markazi Text, Reem Kufi, Noto Kufi Arabic (SIL OFL 1.1)
Recursos
Ninguno: todas las imágenes se dibujan en código

Elaboración

#1 · Un idioma, tres decisiones

El código está en la respuesta corta, más arriba. La configuración solo dice locale: 'ar', y el motor saca de ahí tres cosas: el texto va de derecha a izquierda (direction: 'auto'), el libro se encuaderna por la derecha (page.binding: 'auto') y todos los números que escribe el motor, folios incluidos, usan las cifras arábigo-índicas (numerals: 'auto') (Empezar un libro en árabe). Márgenes y alineaciones siguen diciendo left y right, pero en un libro de derecha a izquierda nombran los lados del flujo: margins.left es el lado del lomo y la sangría queda a la derecha. Las líneas justificadas abren primero los espacios y después alargan las letras enlazadas con el tatweel, la cachida de los libros árabes; el texto nunca se divide con guiones (Justificación y cachida).

Página 12: líneas justificadas con cachida, la sangría a la derecha y el folio ١٢ en el borde exterior.

#2 · El número del capítulo en letras

script.js · líneas 54–75en el código completo
const SINK = 58; // mm: where the text starts on an opener
const centred = (y, size) => ({ anchor: { to: 'container', edge: 'top' },
  offset: { y: mm(y) }, ...(size && { size }) });
const opener = {
  enabled: true, minHeight: mm(SINK),
  slot: { elements: [
    { kind: 'image', id: 'star', resourceId: 'star', placement: centred(8, { width: mm(9) }) },
    // {number} is the level's numbering template below: الفصل الأول, الفصل الثاني…
    { kind: 'text', id: 'kicker', content: '{number}', fontFamily: LABEL, fontSize: pt(10),
      fontWeight: 600, color: col('door'), align: 'center', placement: centred(22) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(30),
      fontWeight: 600, lineHeight: 1.3, color: col('ink'), align: 'center', overflow: 'wrap',
      placement: centred(29, { width: 'fill' }) },
  ] },
};
const chapter = {
  level: 1, fontFamily: DISPLAY, fontWeight: 600, marginTop: pt(0), marginBottom: pt(0),
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  breakBefore: { enabled: true, parity: 'any' },
  // A masculine ordinal in words: {1:ordinal-feminine} would give الأولى, الثانية…
  numberingTemplate: 'الفصل {1:ordinal}', advancedDesign: opener,
};

El numberingTemplate del nivel escribe الفصل y el número como ordinal masculino, así que el segundo capítulo es الفصل الثاني sin tocar el Markdown; {1:ordinal-feminine} da الأولى, الثانية para un nombre femenino como الليلة (Ordinales y cardinales con letras). La apertura lo imprime con {number} sobre el título. Las aperturas árabes suelen ir centradas, y con align: 'center' no hace falta pensar en qué lado empieza el diseño.

#3 · Las cabeceras se quedan en su página

script.js · líneas 79–96en el código completo
// Running heads and folios keep their physical sides in a right-to-left book. The right page
// is an even page here and is read first: the book's title; the left page, the chapter's.
const label = { fontFamily: LABEL, fontSize: pt(8), color: col('muted'), pages: 'body' };
const at = (edge, x, y = 12) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const folio = { ...label, fontWeight: 600, color: col('ink') };
const header = { elements: [
  { kind: 'text', id: 'r-folio', content: '{pageNumber}', parity: 'even', ...folio,
    align: 'right', placement: at('top-right', -OUTER) },
  { kind: 'text', id: 'r-title', content: '{title}', parity: 'even', ...label, align: 'right',
    placement: at('top-right', -(OUTER + 9)) },
  { kind: 'text', id: 'l-chapter', content: '{chapterTitle}', parity: 'odd', ...label,
    align: 'left', placement: at('top-left', OUTER + 9) },
  { kind: 'text', id: 'l-folio', content: '{pageNumber}', parity: 'odd', ...folio,
    align: 'left', placement: at('top-left', OUTER) },
] };
// The openers carry no head: their folio drops to the foot, centred.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', ...folio,
  pages: 'opener', align: 'center', placement: at('bottom', 0, -11) }] };

El texto sigue el flujo, pero las cabeceras y los folios conservan sus lados físicos. La página 12 es par y, en este libro, página derecha: su folio va arriba a la derecha y el título del libro a su lado; la 13 queda a la izquierda y lleva el capítulo. Si lees el pliego desde la derecha, como un lector árabe, el título del libro llega primero. showBook coloca las páginas en espejo, la 11 a la izquierda de la 10, y la pantalla enseña los pliegos tal como se abre el libro.

#4 · Una portadilla dibujada con código

script.js · líneas 100–118en el código completo
// # الدرب {style="plate" kicker="…" line="…"}: a heading that paints a whole page.
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } });
const plate = {
  id: 'plate', span: 'page', numbered: false, runningChapter: false, toc: false,
  breakBefore: { enabled: true, parity: 'any' }, // (gotcha: style-inherits-break)
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'field', resourceId: 'lattice', placement: {
      anchor: { to: 'bleed', edge: 'top-left' },
      size: { width: mm(TRIM.width), height: mm(TRIM.height) } } },
    { kind: 'text', id: 'part', content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(11),
      fontWeight: 600, color: col('brass'), align: 'center', placement: onPage(76) },
    { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(64),
      fontWeight: 600, lineHeight: 1.2, color: col('paper'), align: 'center',
      placement: onPage(86) },
    { kind: 'text', id: 'line', content: '{attr.line}', fontFamily: TEXT, fontSize: pt(13),
      color: col('paper'), align: 'center', placement: onPage(124) },
  ] } },
};

La portadilla es un título de primer nivel, # الدرب {style="plate" …}, cuyo estilo pinta la hoja entera y le quita cabecera y folio. La celosía es un SVG generado en el propio script con dos cuadrados girados 45°, la estrella jatam de la ornamentación geométrica islámica, en el azul y el latón de la paleta, y el PDF la conserva como trazados vectoriales. numbered: false deja la portadilla fuera de la cuenta de capítulos, y la apertura que sigue es الفصل الأول.

La receta completa

Sandbox
// ═══ Postext Cookbook · Nº 105 · An Arabic novel set right to left, bound on the right ═══
// https://postext.dev/en/cookbook/arabic-novel-right-bound
// Code: MIT · Text: original Arabic prose (CC BY 4.0) · Pattern: drawn in code
// Fonts: Markazi Text, Reem Kufi, Noto Kufi Arabic (SIL OFL 1.1) · Needs postext ≥ 1.15.0
import {
  buildDocument, renderPageToCanvas, clearMeasurementCache, registerResourceImage,
} from 'https://esm.sh/postext';
import { renderToPdf, decompressWoff2 } from 'https://esm.sh/postext-pdf';

const LANG = 'es'; // @lang: the language of the frame; the novel is Arabic in both editions
const RECIPE = 'arabic-novel-right-bound';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// The blue of the press's door, the brass of its key, ink on a cream book paper.
// #region palette: the door's blue, the key's brass, every colour linked by id
const palette = {
  ink: '#1f1c19', // text: a warm near-black
  door: '#2b4766', // the accent: the part page's field, the openers' kickers
  brass: '#b08d4a', // the second accent: the medallion's line and the openers' star
  lattice: '#3d5d82', // the pattern's lines, a step lighter than the field
  rule: '#cbc3b5', // hairlines
  muted: '#6b645b', // running heads, folios, the colophon
  paper: '#fbf8f1', // a cream book paper
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = Object.entries({ ...palette, 'main-color': palette.door })
  .map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } }));
// #endregion
const [TEXT, DISPLAY, LABEL] = ['Markazi Text', 'Reem Kufi', 'Noto Kufi Arabic'];
const [BODY, LEAD] = [13.5, 22]; // pt: Naskh sets small, so 13.5 pt on 1.63 × the size
const TRIM = { width: 140, height: 210 }; // mm: 14 × 21 cm, the Arab trade novel
const OUTER = 16; // mm: the outer margin, where the folios stand

// #region answer: the locale sets the direction, the binding and the digits
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  // left and right are the flow's sides: in a right-to-left book left is the spine side,
  // so these margins put 20 mm at the spine and 16 mm outside on both pages of a spread.
  margins: { top: mm(23), bottom: mm(22), left: mm(20), right: mm(OUTER), mirror: true },
  // binding: 'auto' (unset) is 'right' for a right-to-left locale: page 1 is a left page.
};
const bodyText = {
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  // justify ends every line on both sides; the engine lengthens joined letters (kashida,
  // on by default for Arabic) after it opens the spaces a quarter. Never hyphenated.
  textAlign: 'justify', optimalLineBreaking: true,
  firstLineIndent: em(1.5), indentAfterHeading: false, // the indent is on the right
  avoidWidows: true, avoidOrphans: true, avoidRunts: true,
};
// #endregion

// #region opener: the chapter number in words over the title, sunk under a brass star
const SINK = 58; // mm: where the text starts on an opener
const centred = (y, size) => ({ anchor: { to: 'container', edge: 'top' },
  offset: { y: mm(y) }, ...(size && { size }) });
const opener = {
  enabled: true, minHeight: mm(SINK),
  slot: { elements: [
    { kind: 'image', id: 'star', resourceId: 'star', placement: centred(8, { width: mm(9) }) },
    // {number} is the level's numbering template below: الفصل الأول, الفصل الثاني…
    { kind: 'text', id: 'kicker', content: '{number}', fontFamily: LABEL, fontSize: pt(10),
      fontWeight: 600, color: col('door'), align: 'center', placement: centred(22) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(30),
      fontWeight: 600, lineHeight: 1.3, color: col('ink'), align: 'center', overflow: 'wrap',
      placement: centred(29, { width: 'fill' }) },
  ] },
};
const chapter = {
  level: 1, fontFamily: DISPLAY, fontWeight: 600, marginTop: pt(0), marginBottom: pt(0),
  // Restated: any headings object drops the H1 break (gotcha: headings-drop-h1-break).
  breakBefore: { enabled: true, parity: 'any' },
  // A masculine ordinal in words: {1:ordinal-feminine} would give الأولى, الثانية…
  numberingTemplate: 'الفصل {1:ordinal}', advancedDesign: opener,
};
// #endregion

// #region heads: physical, so the right page carries the book and the left the chapter
// Running heads and folios keep their physical sides in a right-to-left book. The right page
// is an even page here and is read first: the book's title; the left page, the chapter's.
const label = { fontFamily: LABEL, fontSize: pt(8), color: col('muted'), pages: 'body' };
const at = (edge, x, y = 12) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const folio = { ...label, fontWeight: 600, color: col('ink') };
const header = { elements: [
  { kind: 'text', id: 'r-folio', content: '{pageNumber}', parity: 'even', ...folio,
    align: 'right', placement: at('top-right', -OUTER) },
  { kind: 'text', id: 'r-title', content: '{title}', parity: 'even', ...label, align: 'right',
    placement: at('top-right', -(OUTER + 9)) },
  { kind: 'text', id: 'l-chapter', content: '{chapterTitle}', parity: 'odd', ...label,
    align: 'left', placement: at('top-left', OUTER + 9) },
  { kind: 'text', id: 'l-folio', content: '{pageNumber}', parity: 'odd', ...folio,
    align: 'left', placement: at('top-left', OUTER) },
] };
// The openers carry no head: their folio drops to the foot, centred.
const footer = { elements: [{ kind: 'text', id: 'drop-folio', content: '{pageNumber}', ...folio,
  pages: 'opener', align: 'center', placement: at('bottom', 0, -11) }] };
// #endregion

// #region plate: the part page, the door's blue edge to edge under a lattice of stars
// # الدرب {style="plate" kicker="…" line="…"}: a heading that paints a whole page.
const onPage = (y) => ({ anchor: { to: 'page', edge: 'top' }, offset: { y: mm(y) } });
const plate = {
  id: 'plate', span: 'page', numbered: false, runningChapter: false, toc: false,
  breakBefore: { enabled: true, parity: 'any' }, // (gotcha: style-inherits-break)
  header: { elements: [] }, footer: { elements: [] },
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'field', resourceId: 'lattice', placement: {
      anchor: { to: 'bleed', edge: 'top-left' },
      size: { width: mm(TRIM.width), height: mm(TRIM.height) } } },
    { kind: 'text', id: 'part', content: '{attr.kicker}', fontFamily: LABEL, fontSize: pt(11),
      fontWeight: 600, color: col('brass'), align: 'center', placement: onPage(76) },
    { kind: 'text', id: 'name', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(64),
      fontWeight: 600, lineHeight: 1.2, color: col('paper'), align: 'center',
      placement: onPage(86) },
    { kind: 'text', id: 'line', content: '{attr.line}', fontFamily: TEXT, fontSize: pt(13),
      color: col('paper'), align: 'center', placement: onPage(124) },
  ] } },
};
// #endregion

const config = () => ({ // a factory: the engine caches resolved configs per object
  locale: 'ar', // written out, never LANG (gotcha: arabic-locale-tag)
  colorPalette, page, bodyText, layout: { layoutType: 'single' },
  headings: { fontFamily: DISPLAY, fontWeight: 600, color: col('ink'), levels: [chapter] },
  headingStyles: [plate],
  paragraphStyles: [
    // The colophon is in the reader's language, set left to right ({dir=ltr} on its block).
    { id: 'colophon', fontFamily: TEXT, fontSize: pt(8.5), lineHeight: pt(11),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  header, footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
Muestra en Markdown · 63 líneas · content.es.mdtitle: "حروف من رصاص" author: "نادية الشريف" --- # الدرب {style="plate" kicker="القسم الأول" line="«الحرف العربي لا يعيش وحده.»"} # الباب الأزرق نزلتُ من سيارة الأجرة عند مدخل الدرب، فالسائق لم يشأ أن يغامر بسيارته بين جدرانٍ تكاد تتلامس من فوق. كان الوقت ظهرًا، والشمس واقفة فوق الرؤوس لا تترك للظل إلا شريطًا رفيعًا عند أسفل البيوت. حملتُ حقيبتي ومشيت. اثنتا عشرة سنة لم أطأ فيها هذا البلاط، ومع ذلك عرفت قدماي الحجر المكسور أمام دكان الحلاق، فتجنّبته قبل أن أراه. لم يتغيّر الدرب كثيرًا. تغيّرت اللافتات وحدها: صار دكان الفول محلًّا لإصلاح الهواتف، وصارت ورشة النجار مخزنًا للبلاستيك تعلوه لافتة صفراء بخطٍّ مطبوع لا روح فيه. أما الشرفات الخشبية فبقيت مائلة كما كانت، وبقي الغسيل منشورًا عليها كأنه لم يُرفع منذ سافرت. كانت أم صابر جالسة على كرسيّها الخيزران أمام بابها، تقشّر البازلاء في صحن من الصاج. رفعت رأسها حين سمعت عجلات الحقيبة على الحجر، وظلّت تنظر إليّ لحظة طويلة قبل أن تضع الصحن على الأرض وتقوم. قالت: «يوسف؟ ابن سعاد؟» قلت: «أنا يا خالة.» ضمّتني إلى صدرها فشممت رائحة الصابون والبصل معًا، ثم أبعدتني لتنظر في وجهي، ثم ضمّتني مرة أخرى. قالت إن جدّي كان يذكرني كل يوم، وإنه كان يقول لكل من يمرّ بالمطبعة إن حفيده يعمل مهندسًا في بلاد الثلج، وإنه سيرجع. ثم سكتت، لأن الرجوع جاء متأخرًا أربعين يومًا. دخلت بيتها وعادت بمفتاح نحاسي كبير معلّق بخيط من الدوبارة. قالت: «تركه عندي قبل أن يدخل المستشفى. قال: إذا جاء يوسف فأعطيه المفتاح، ولا تعطيه لأحد غيره، ولا حتى لخاله.» ضحكت وهي تقول ذلك، وضحكت معها، لأن خالي لم يدخل المطبعة في حياته إلا ليطلب مالًا. كانت المطبعة في آخر الدرب، حيث ينعطف نحو الجامع الصغير. بابها خشبي عريض مطليّ بالأزرق، والطلاء قد تقشّر عند الأطراف فظهر تحته طلاء أخضر أقدم، وتحت الأخضر خشب رمادي. وفوق الباب لوحة من الصفيح كتب عليها جدّي بيده، بخط الثلث الذي تعلّمه في شبابه: «مطبعة النجمة، لصاحبها منصور عبد الحميد، تأسّست سنة ١٩٥٢». وتحتها بخط أصغر: «طبع الكتب والدفاتر وبطاقات الأفراح». أدرت المفتاح مرتين. قاوم القفل قليلًا، ثم استسلم بصوت أعرفه، طقّة مكتومة تتبعها أخرى. ودفعت الباب. خرجت إليّ رائحة المكان قبل أن تعتاد عيناي العتمة: حبر الطباعة، ذلك الخليط من الزيت والسخام الذي لا يشبه شيئًا آخر، ورائحة الورق المكدّس، ورائحة الرصاص الباردة التي كان جدّي يقول إنها رائحة الصبر. وقفت على العتبة حتى بدأت الأشياء تخرج من الظل واحدًا بعد الآخر. في الصدر آلة الطباعة الكبيرة، السوداء، ذات العجلة الحديدية التي كنت أحسبها وأنا صغير ساعة عملاقة. وعلى الجدار الأيمن الخزائن الخشبية العالية، بأدراجها الضيقة المائلة، كل درج منها صندوق حروف. وعلى الطاولة الطويلة تحت النافذة أوراق مصفوفة تحت قطعة من الرخام، ومقصّ، وفنجان قهوة جفّ في قاعه البُنّ. وكان التقويم المعلّق بجانب الباب ما يزال على شهر مايو، لم يمزّق أحد أوراقه بعد ذلك. فتحت النافذة. دخل الضوء مائلًا ومعه غبار ناعم أخذ يدور ببطء فوق الطاولة. وعلى مسمار خلف الباب وجدت مريلته الجلدية معلّقة، بنّية داكنة، لامعة عند البطن من كثرة ما مسحت عليها يداه. لم ألمسها. جلست على الكرسي العالي الذي كان يجلس عليه وهو يصفّ الحروف، ونظرت إلى الخزائن، وفكّرت أنني لا أعرف ماذا أفعل بكل هذا. جاءني صوت أم صابر من الباب: «الغداء جاهز يا يوسف. لا تجلس هنا وحدك في العتمة.» قلت إنني قادم. ولم أقم إلا بعد أن أغلقت النافذة، ومررت بيدي على حافة الطاولة، كما كان هو يفعل كل مساء قبل أن يطفئ النور. # صندوق الحروف استيقظت في السادسة على صوت بائع الخبز ينادي في الدرب، ولم أعرف أين أنا. كنت قد نمت على الأريكة في الغرفة الخلفية من المطبعة، تلك التي كان جدّي يستريح فيها بعد الظهر، لأنني لم أحتمل فكرة النوم في فندق على بعد عشر دقائق من هذا المكان. غسلت وجهي في الحوض الصغير، وصنعت قهوة على الموقد الذي لا يشتعل إلا إذا ضربته ضربة خفيفة من الجانب، كما علّمني جدّي. ثم وقفت أمام الخزائن. كان صندوق الحروف أول ما عرفته من هذه المهنة. كنت في السابعة حين أجلسني جدّي على هذا الكرسي نفسه، وفتح أمامي درجًا، وقال: «هذه هي العربية كلها يا يوسف. ثمانية وعشرون حرفًا، ولكن لا تصدّق من يقول لك إنها ثمانية وعشرون.» لم أفهم يومها، ففتح لي درجًا آخر، وثالثًا. كان في كل درج مئات من الخانات الصغيرة، وفي كل خانة قطع من الرصاص لا يزيد طول الواحدة منها على عُقلة الإصبع، وعلى رأس كل قطعة حرف بارز مقلوب. أخرج من خانة قطعة، ومن خانة أخرى قطعة ثانية، ووضعهما في كفّي. قال: «هذه عين في أول الكلمة، وهذه عين في آخرها. حرف واحد، وشكلان. وعندنا عين في الوسط، وعين وحدها. الحرف العربي يا ولدي لا يعيش وحده، يتغيّر بحسب جاره.» صرت بعد ذلك أقضي العطلة الصيفية كلها هنا. تعلّمت أن أمسك المِصَفّ، تلك المسطرة المعدنية ذات الحافة، بيدي اليسرى، وأن ألتقط الحروف باليمنى من غير أن أنظر، وأن أصفّها من اليمين إلى اليسار، مقلوبة، كما تُقرأ في المرآة. وتعلّمت أن السطر لا يُترك قصيرًا أبدًا. فإذا انتهت الكلمات قبل نهاية السطر، ملأ جدّي الفراغ بقطع صغيرة بلا حروف يدسّها بين الكلمات، أو مدّ بعض الحروف بقطع خاصة كان يسمّيها «المدّات»، حتى يستوي السطر مع جاره من الجانبين. كان يقول إن المدّ صنعة، وإن الصبي يمدّ حيث يشاء، أما الصنايعي فيعرف أين يجوز المدّ وأين لا يجوز: لا مدّ بعد الكاف، ولا في كلمتين متجاورتين، ولا تجعل المدّات تقف بعضها فوق بعض في سطور متتالية، فتبدو الصفحة كأن فيها نهرًا. وكان يمسك صفحة مطبوعة من كتاب قديم، ويمرّر إصبعه على سطورها، ويقول: «انظر. هنا مدّ، وهنا، وهنا. هل تراها؟ لا تراها. هذا هو الشغل.» فتحت الدرج الأول الآن. كانت الحروف في مكانها، مرصوصة ونظيفة. لكن الدرج الثاني كان ناقصًا، والثالث أيضًا، وحين بحثت عن الحروف الناقصة وجدتها مصفوفة على الطاولة الصغيرة بجانب الآلة: صفحة كاملة، محزومة بالخيط، مثبّتة داخل إطار حديدي، جاهزة للطبع. انحنيت عليها. القراءة في الرصاص صعبة، لأن كل شيء مقلوب، والحروف كلها بلون واحد رمادي. ولكنني عرفت بعد قليل أنها قصيدة، أبياتها مصفوفة شطرين شطرين، وفوقها عنوان بحروف أكبر. وفي الزاوية ورقة مطوية، بخط جدّي المرتعش في سنواته الأخيرة: «ديوان أحمد الصافي. الملزمة الأولى. يُطبع ألف نسخة على ورق أصفر.» لم يكن على الطاولة شيء آخر من الديوان: لا مخطوطة، ولا اسم ناشر، ولا عنوان. كانت الصفحة الأولى وحدها، تنتظر منذ مايو. جلست على الكرسي العالي، وأخذت أقرأ الأبيات حرفًا حرفًا، من اليمين إلى اليسار، كما علّمني. في الخارج بدأ الدرب يستيقظ: باب يُفتح، وماء يُرشّ على الحجر، وراديو بعيد يقرأ نشرة الأخبار. وفي الداخل، بيني وبين الصفحة المقلوبة، كان جدّي ما يزال يعمل. :::paragraphs{style="colophon" dir=ltr} Comienzo de novela escrito en árabe para el Recetario de Postext; la novela, su autora y sus personajes son inventados. Compuesto en Markazi Text, Reem Kufi y Noto Kufi Arabic (SIL OFL) · Texto: CC BY 4.0. :::
`; // content.<lang>.md: the same Arabic text in both // #region art: an eight-pointed star lattice and the brass star of the openers // Two squares turned 45° make the eight-pointed star (khātam); stars on a square grid, with a // small square between every four, make the lattice. Lines only: the PDF keeps them as paths. const star = (cx, cy, r) => { const pts = []; for (let k = 0; k < 16; k++) { const a = (k * Math.PI) / 8; const d = k % 2 ? r * 0.7654 : r; // 0.7654: where the two squares' sides cross pts.push(`${(cx + d * Math.sin(a)).toFixed(2)} ${(cy - d * Math.cos(a)).toFixed(2)}`); } return `M${pts.join('L')}Z`; }; function lattice() { const [W, H, S] = [TRIM.width + 6, TRIM.height + 6, 14]; // mm, 3 mm bleed a side let lines = ''; for (let y = -S; y <= H + S; y += S) { for (let x = -S / 2; x <= W + S; x += S) { lines += star(x, y, S * 0.46); lines += `M${x + S / 2} ${y + S / 2 - 2.6}l2.6 2.6l-2.6 2.6l-2.6 -2.6Z`; } } const [cx, cy] = [W / 2, 104]; // the medallion, behind the part's name return `<svg xmlns="http://www.w3.org/2000/svg" width="${W * 10}" height="${H * 10}" ` + `viewBox="0 0 ${W} ${H}"><rect width="${W}" height="${H}" fill="${palette.door}"/>` + `<path d="${lines}" fill="none" stroke="${palette.lattice}" stroke-width="0.4"/>` + `<path d="${star(cx, cy, 56)}" fill="${palette.door}" stroke="${palette.brass}" ` + 'stroke-width="0.7"/>' + `<path d="${star(cx, cy, 52)}" fill="none" stroke="${palette.brass}" stroke-width="0.3"/>` + '</svg>'; } const brassStar = () => '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" ' + `viewBox="0 0 20 20"><path d="${star(10, 10, 9.5)}" fill="${palette.brass}"/>` + `<path d="${star(10, 10, 4)}" fill="${palette.paper}"/></svg>`; // #endregion const resources = [ { id: 'lattice', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'lattice.svg', width: (TRIM.width + 6) * 10, height: (TRIM.height + 6) * 10 }, altText: t({ en: 'A lattice of eight-pointed stars in blue lines on a deep blue field, ' + 'with a large star outlined in brass at its centre.', es: 'Una celosía de estrellas de ocho puntas en líneas azules sobre un fondo azul oscuro, ' + 'con una gran estrella perfilada en latón en el centro.' }) }, { id: 'star', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0, svg: { fileId: 'star.svg', width: 200, height: 200 }, altText: t({ en: 'A small eight-pointed brass star.', es: 'Una pequeña estrella de latón de ocho puntas.' }) }, ]; // ─── 3 · Fonts ────────────────────────────────────────────────────────────── const FONTS = { // every face the pages use, loaded before the build (gotcha: fonts-first) 'Markazi Text': ['400'], // TEXT: the novel, the part page's line, the colophon 'Reem Kufi': ['600'], // DISPLAY: the part's name, the chapter titles 'Noto Kufi Arabic': ['400', '600'], // LABEL: running heads, folios, kickers }; // ─── 4 · Build & show ─────────────────────────────────────────────────────── await loadFonts(FONTS, markdown); // Each Arabic face's letters live in a file of their own (gotcha: arabic-fonts-subset). await loadArabicFonts(FONTS, markdown); await loadSvg('lattice.svg', lattice()); await loadSvg('star.svg', brassStar()); // The excerpt opens on page 10 of the book, a right-hand page: folios and parity follow it. const continuation = { pageIndexOffset: 9, pageNumbering: { startAt: 10 } }; const doc = await buildWithFonts( () => buildDocument({ markdown, resources, continuation }, config()), markdown); showBook(doc, { title: t({ en: 'An Arabic novel, bound on the right', es: 'Una novela árabe encuadernada por la derecha' }) }); offerPdf(() => renderToPdf(doc, { fontProvider: arabicPdfProvider, resourceBytes: imageBytes }), `${RECIPE}.pdf`);
Kit · core, fonts, viewer, pdf, images, arabic, book: igual en todas las recetas · 446 líneas// ─── Kit ── helpers shared by every Cookbook recipe · postext.dev/cookbook ───── // ─── Kit · core v1 ── the same in every recipe · postext.dev/cookbook ───────── function mm(value) { return { value, unit: 'mm' }; } function pt(value) { return { value, unit: 'pt' }; } function em(value) { return { value, unit: 'em' }; } /** The sample language's string: t({ en: 'Figure', es: 'Figura' }). */ function t(strings) { return strings[LANG] ?? Object.values(strings)[0]; } /** A file in this recipe's assets folder, served from the Postext repo by jsDelivr. */ function asset(file) { return `https://cdn.jsdelivr.net/gh/drnachio/postext@main/cookbook/${RECIPE}/assets/${file}`; } // ─── Kit · fonts v1 ── the same in every recipe · postext.dev/cookbook ──────── // Postext measures text with the faces the browser has loaded, and caches the // widths, so every face must be ready before the first build. Faces come from // Fontsource: the same static files the PDF embeds, so screen and PDF agree. /** faces = { 'Family Name': ['400', '400i', '700'] }. `text` is the sample: * letters beyond Latin-1 (č, ł, ő…) also load the latin-ext files. With * `optional`, a face Fontsource does not ship is skipped instead of failing. * Resolves to the number of faces added. */ async function loadFonts(faces, text = '', { optional = false } = {}) { kitStatus('Loading fonts…'); const ranges = { latin: 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,' + 'U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD', 'latin-ext': 'U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,' + 'U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF', }; const subsets = /[Ā-˿Ḁ-ỿ]/.test(text) ? ['latin', 'latin-ext'] : ['latin']; const jobs = []; let added = 0; for (const [family, specs] of Object.entries(faces)) { const id = fontsourceId(family); const meta = optional ? await fontsourceMeta(family) : null; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; if (hasFace(family, weight, style)) continue; if (optional && !(meta?.weights.includes(weight) && meta.styles.includes(style))) continue; for (const subset of subsets) { const url = `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-${subset}-${weight}-${style}.woff2`; const face = new FontFace(family, `url(${url}) format('woff2')`, { weight: String(weight), style, unicodeRange: ranges[subset] }); jobs.push(face.load().then((ready) => { document.fonts.add(ready); added++; }, () => { if (subset === 'latin' && !optional) throw new Error(`Fontsource has no ${family} ${weight} ${style}`); })); } } } await Promise.all(jobs).catch((error) => { kitFail(error); throw error; }); return added; } /** Runs `build` (a buildDocument or buildBundle call) and checks the faces * the pages use. A regular face missing from FONTS is loaded with a warning; * bold and italic variants are loaded when the family ships them. Then the * measurement caches are cleared and the build runs again. */ async function buildWithFonts(build, text = '') { const tried = new Set(); for (let round = 0; round < 3; round++) { kitStatus('Laying out…'); await new Promise(requestAnimationFrame); // let the status paint first const result = await Promise.resolve().then(build).catch((error) => { kitFail(error); throw error; }); const wanted = { base: {}, variants: {} }; for (const { font, base } of [result].flat().flatMap(fontStringsOf)) { const { family, weight, style } = parseFont(font); const key = `${family}|${weight}|${style}`; if (tried.has(key) || hasFace(family, weight, style)) continue; tried.add(key); (wanted[base ? 'base' : 'variants'][family] ??= []).push(`${weight}${style === 'italic' ? 'i' : ''}`); } if (Object.keys(wanted.base).length) { console.warn(`[cookbook] FONTS does not list ${JSON.stringify(wanted.base)}: loading them.`); } const added = await loadFonts(wanted.base, text) + await loadFonts(wanted.variants, text, { optional: true }); if (added === 0) return result; clearMeasurementCache(); } throw new Error('The fonts did not settle after three builds.'); } /** Every font string of the layout. `base` marks a block's own face; its * bold, italic and bold-italic variants are listed whether or not used. */ function fontStringsOf(doc) { const found = new Map(); const walk = (node) => { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { node.forEach(walk); return; } for (const [key, value] of Object.entries(node)) { if (typeof value === 'string' && /fontString$/i.test(key)) { found.set(value, found.get(value) || key === 'fontString'); } else if (value && typeof value === 'object') walk(value); } }; walk(doc.pages); walk(doc.blocks); return [...found].map(([font, base]) => ({ font, base })); } /** '700 37.5px Open Sans' / 'italic 400 13px "Source Serif 4"' → { family, weight, style }. * A string with no weight ('95.8px Young Serif', from a design text) is 400. */ function parseFont(font) { const m = /^(?:(italic|oblique)\s+)?(?:small-caps\s+)?(?:(\d+|bold|normal)\s+)?[\d.]+px\s+(.+)$/.exec(font.trim()); if (!m) throw new Error(`Unexpected font string: ${font}`); const weight = m[2] === 'bold' ? 700 : !m[2] || m[2] === 'normal' ? 400 : Number(m[2]); return { family: m[3].replace(/^["']|["']$/g, ''), weight, style: m[1] ? 'italic' : 'normal' }; } /** True when a loaded FontFace covers exactly this family, weight and style * (document.fonts.check() is also true for families nobody declared). */ function hasFace(family, weight, style) { for (const face of document.fonts) { if (face.status !== 'loaded' || face.style !== style) continue; if (face.family.replace(/^["']|["']$/g, '') !== family) continue; const [low, high = low] = face.weight.split(' ').map(Number); if (weight >= low && weight <= high) return true; } return false; } /** Fontsource's id for a family: 'Source Serif 4' → 'source-serif-4'. */ function fontsourceId(family) { return family.toLowerCase().replace(/\s+/g, '-'); } /** The weights and styles a family ships ({ weights: [400, 700], styles: ['normal', 'italic'] }), or null. */ function fontsourceMeta(family) { fontsourceMeta.cache ??= new Map(); const id = fontsourceId(family); if (!fontsourceMeta.cache.has(id)) { fontsourceMeta.cache.set(id, fetch(`https://api.fontsource.org/v1/fonts/${id}`) .then((res) => (res.ok ? res.json() : null), () => null)); } return fontsourceMeta.cache.get(id); } // ─── Kit · viewer v1 ── the same in every recipe · postext.dev/cookbook ─────── /** Shows the pages as facing spreads on a dark desk: the first page is a * recto on its own, then verso | recto pairs, as in a bound book. Pages * are painted when they scroll near the screen. */ function showPages(docs, { title, width = 460 } = {}) { const root = viewer(title); const pages = [docs].flat().flatMap((doc) => doc.pages.map((page) => ({ doc, page, n: (doc.pageIndexOffset ?? 0) + page.index }))); const spreads = []; let verso = null; for (const p of pages) { if (p.n % 2 === 1) { if (verso) spreads.push([verso, null]); verso = p; } else { spreads.push([verso, p]); verso = null; } } if (verso) spreads.push([verso, null]); const density = Math.min(window.devicePixelRatio || 1, 2); showPages.painter?.disconnect(); const painter = new IntersectionObserver((entries) => { for (const { isIntersecting, target } of entries) { if (!isIntersecting) continue; painter.unobserve(target); const { doc, page } = target.postext; renderPageToCanvas(page, doc, target, { scale: (width * density) / page.width }); } }, { rootMargin: '800px' }); showPages.painter = painter; root.replaceChildren(...spreads.map((pair) => { const spread = document.createElement('div'); spread.className = 'pt-spread'; for (const p of pair) { const figure = document.createElement('figure'); if (p) { const label = p.page.pageLabel || String(p.n + 1); const canvas = document.createElement('canvas'); canvas.postext = p; canvas.style.aspectRatio = `${p.page.width} / ${p.page.height}`; canvas.setAttribute('role', 'img'); canvas.setAttribute('aria-label', `Page ${label}`); const folio = document.createElement('figcaption'); folio.textContent = label; figure.append(canvas, folio); painter.observe(canvas); } else figure.className = 'pt-blank'; spread.append(figure); } return spread; })); kitStatus(`${pages.length} ${pages.length === 1 ? 'page' : 'pages'}`); document.documentElement.dataset.postext = 'ready'; return pages.length; } /** The desk, the bar and the error reporting, created once. */ function viewer(title) { if (!document.getElementById('pt-kit')) { document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit"> :root { color-scheme: dark; } body { margin: 0; background: #0e1014; color: #b9bcc4; font: 13px/1.45 system-ui, sans-serif; } #pt-bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 10px 16px; background: rgb(14 16 20 / .92); backdrop-filter: blur(6px); border-bottom: 1px solid #23262d; } #pt-bar strong { color: #f4f1ea; font-weight: 600; } #pt-actions { display: flex; gap: 12px; margin-left: auto; } #pt-actions a, #pt-actions button { color: #d8a21a; font: inherit; background: none; border: 0; padding: 0; cursor: pointer; } #pages { display: grid; justify-items: center; gap: 48px; padding: 32px 16px 72px; } .pt-spread { display: flex; } .pt-spread figure { margin: 0; width: min(460px, 44vw); } .pt-spread canvas { display: block; width: 100%; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figure:first-child canvas { box-shadow: inset -14px 0 14px -14px rgb(0 0 0 / .18), 0 1px 2px rgb(0 0 0 / .5), 0 22px 44px -16px rgb(0 0 0 / .8); } .pt-spread figcaption { margin-top: 10px; text-align: center; font: 600 10px/1 system-ui, sans-serif; letter-spacing: .18em; text-transform: uppercase; color: #6c7079; } .pt-blank { visibility: hidden; } @media (max-width: 760px) { .pt-spread { flex-direction: column; gap: 32px; } .pt-spread figure { width: min(460px, 92vw); } .pt-blank { display: none; } } </style>`); document.body.insertAdjacentHTML('afterbegin', '<header id="pt-bar"><strong id="pt-title"></strong><span id="pt-status" role="status"></span><span id="pt-actions"></span></header>'); document.getElementById('pt-title').textContent = document.title || 'Postext'; addEventListener('error', (event) => kitFail(event.error ?? event.message)); addEventListener('unhandledrejection', (event) => kitFail(event.reason)); } if (title) document.getElementById('pt-title').textContent = title; return document.getElementById('pages') ?? document.body.appendChild(Object.assign(document.createElement('main'), { id: 'pages' })); } function kitStatus(text) { viewer(); document.getElementById('pt-status').textContent = text; } function kitFail(error) { document.documentElement.dataset.postext = 'error'; kitStatus(`Error: ${error?.message ?? error}`); } // ─── Kit · pdf v1 ── the same in every recipe that exports a PDF ────────────── /** postext-pdf embeds TrueType bytes. Fetch the Fontsource file the screen * used, snapping to a weight the family ships and falling back to upright * when it has no italic: the PDF asks for every face a block could use. */ async function fontsourceProvider(family, weight, style) { const id = fontsourceId(family); const meta = await fontsourceMeta(family); const weights = meta?.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && meta && !meta.styles.includes('italic') ? 'normal' : style; const res = await fetch(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (!res.ok) throw new Error(`Fontsource has no ${family} ${w} ${s} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); } /** A "Build the PDF" button in the bar. Once built: "Open the PDF" (a new * tab, since CodePen's preview frame cannot show PDFs) and a download link. */ function offerPdf(makePdf, filename) { viewer(); const button = Object.assign(document.createElement('button'), { type: 'button', textContent: 'Build the PDF' }); button.dataset.postextPdf = filename; button.addEventListener('click', async () => { button.disabled = true; button.textContent = 'Building the PDF…'; try { const bytes = await makePdf(); const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' })); const size = `${Math.max(1, Math.round(bytes.length / 1024))} KB`; button.replaceWith( Object.assign(document.createElement('a'), { href: url, target: '_blank', rel: 'noopener', textContent: 'Open the PDF ↗' }), Object.assign(document.createElement('a'), { href: url, download: filename, textContent: `Download ${filename} · ${size}` })); } catch (error) { button.disabled = false; button.textContent = 'Build the PDF'; kitFail(error); } }); document.getElementById('pt-actions').append(button); } // ─── Kit · images v1 ── recipes with pictures · postext.dev/cookbook ────────── /** Registers a photo or PNG for the canvas and keeps its bytes for the PDF. * fetch → ImageBitmap never taints the canvas (a plain cross-origin <img> would). */ async function loadImage(fileId, url) { const res = await fetch(url); if (!res.ok) throw new Error(`Image not found (${res.status}): ${url}`); const bytes = new Uint8Array(await res.arrayBuffer()); registerResourceImage(fileId, await createImageBitmap(new Blob([bytes]))); (loadImage.bytes ??= new Map()).set(fileId, bytes); } /** Registers SVG markup (drawn in code, or fetched) as a vector image. */ async function loadSvg(fileId, svg) { const img = new Image(); img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; await img.decode(); registerResourceImage(fileId, img); (loadImage.bytes ??= new Map()).set(fileId, new TextEncoder().encode(svg)); } /** renderToPdf({ resourceBytes: imageBytes }) */ function imageBytes(fileId) { return loadImage.bytes?.get(fileId); } /** renderToHtml({ resourceImageUrl: imageUrl }) */ function imageUrl(fileId) { const bytes = imageBytes(fileId); if (!bytes) return undefined; imageUrl.urls ??= new Map(); if (!imageUrl.urls.has(fileId)) { const type = /\.svg$/i.test(fileId) ? 'image/svg+xml' : /\.png$/i.test(fileId) ? 'image/png' : 'image/jpeg'; imageUrl.urls.set(fileId, URL.createObjectURL(new Blob([bytes], { type }))); } return imageUrl.urls.get(fileId); } // ─── Kit · arabic v1 ── Arabic-script faces · postext.dev/cookbook ─────────── // Fontsource ships an Arabic family as one file per subset and weight: the // `arabic` file holds the letters, the harakat, the Arabic-Indic digits, the // Arabic punctuation and the presentation forms; `latin` and `latin-ext` // hold the rest. loadFonts loads the latin files; this block adds the arabic // file of every Arabic family, for the canvas and for the PDF, which shapes // the letters with HarfBuzz from the same bytes. /** The code points of Fontsource's `arabic` subset, as its stylesheets * declare them (the same unicode-range the browser picks the file by). A * function, not a const: the kit is inlined after the recipe's top-level * awaits, and a const read before its line throws, where a function * declaration is hoisted. */ function arabicRange() { return 'U+0600-06FF,U+0750-077F,U+0870-088E,U+0890-0891,U+0897-08E1,U+08E3-08FF,' + 'U+200C-200E,U+2010-2011,U+204F,U+2E41,U+FB50-FDFF,U+FE70-FE74,U+FE76-FEFC,U+102E0-102FB,' + 'U+10E60-10E7E,U+10EC2-10EC4,U+10EFC-10EFF,U+1EE00-1EEFF'; } /** Whether code point `cp` is in the arabic file. */ function inArabicRange(cp) { inArabicRange.ranges ??= arabicRange().split(',').map((part) => { const [lo, hi = lo] = part.slice(2).split('-'); return [parseInt(lo, 16), parseInt(hi, 16)]; }); return inArabicRange.ranges.some(([lo, hi]) => cp >= lo && cp <= hi); } /** Whether Fontsource serves `family` with an `arabic` subset. Fails when * the API does not answer: an Arabic face taken for a Latin one would set * its letters in a system face. */ async function isArabicFamily(family) { const meta = await fontsourceMeta(family); if (!meta) throw new Error(`api.fontsource.org did not describe ${family}: reload to try again`); return !!meta.subsets?.includes('arabic'); } /** The arabic file of a face. */ function arabicFileUrl(family, weight, style) { const id = fontsourceId(family); return `https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-arabic-${weight}-${style}.woff2`; } /** faces = { Amiri: ['400', '700'] }, as for loadFonts, after it: the whole * FONTS object may be passed, its families without an arabic subset are * left alone. Adds the arabic file of every listed weight of each Arabic * family (the latin files come from loadFonts) and loads it. `text` is * the sample: fails when it holds an Arabic-script character the arabic * file does not cover. List every weight the pages set in Arabic: a weight * left to buildWithFonts gets the latin file only, and its Arabic letters * fall back to a system face. Resolves to the number of files loaded. */ async function loadArabicFonts(faces, text = '') { kitStatus('Loading fonts…'); let loaded = 0; try { const outside = [...new Set(text)].filter((ch) => /\p{Script=Arabic}/u.test(ch) && !inArabicRange(ch.codePointAt(0))); if (outside.length) throw new Error(`Fontsource's arabic files have no ${outside.slice(0, 12).join(' ')}`); for (const [family, specs] of Object.entries(faces)) { if (!(await isArabicFamily(family))) continue; for (const spec of new Set(specs)) { const weight = parseInt(spec, 10); const style = spec.endsWith('i') ? 'italic' : 'normal'; const face = new FontFace(family, `url(${arabicFileUrl(family, weight, style)}) format('woff2')`, { weight: String(weight), style, unicodeRange: arabicRange() }); document.fonts.add(await face.load().catch(() => { throw new Error(`Fontsource has no arabic file for ${family} ${weight} ${style}`); })); loaded++; } } } catch (error) { kitFail(error); throw error; } return loaded; } /** The PDF font provider for recipes with Arabic faces: a family with an * arabic subset gets its arabic file when its pages set Arabic letters * (`request.codePoints`), then its latin file, and its latin-ext file for * the letters beyond latin (transliteration: ā ḥ ʿ). The arabic file comes * first: it also holds the space and the brackets, so a line of Arabic is * shaped as one run and not cut at every space. Any other family goes to * fontsourceProvider (the "pdf" block). */ async function arabicPdfProvider(family, weight, style, request) { if (!(await isArabicFamily(family))) return fontsourceProvider(family, weight, style); const meta = await fontsourceMeta(family); const weights = meta.weights?.length ? meta.weights : [400, 700]; const w = weights.reduce((a, b) => (Math.abs(b - weight) < Math.abs(a - weight) ? b : a)); const s = style === 'italic' && !meta.styles.includes('italic') ? 'normal' : style; const wanted = [...(request?.codePoints ?? [])]; const id = fontsourceId(family); const urls = []; if (!wanted.length || wanted.some(inArabicRange)) urls.push(arabicFileUrl(family, w, s)); urls.push(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-${w}-${s}.woff2`); if (meta.subsets.includes('latin-ext') && wanted.some((cp) => /[Ā-˿Ḁ-ỿ]/u.test(String.fromCodePoint(cp)))) { urls.push(`https://cdn.jsdelivr.net/npm/@fontsource/${id}@5/files/${id}-latin-ext-${w}-${s}.woff2`); } return Promise.all(urls.map(async (url) => { const res = await fetch(url); if (!res.ok) throw new Error(`Fontsource file ${url} (${res.status})`); return decompressWoff2(new Uint8Array(await res.arrayBuffer())); })); } // ─── Kit · book v1 ── books bound on either edge · postext.dev/cookbook ────── // A book bound on the right (Arabic, Hebrew or Persian text, vertical // Chinese, or page.binding 'right') opens from what a Latin reader calls // the back: page 1 lies alone on the left of the spine, then [3 | 2]. /** showPages for a book bound on either edge. A right-bound book (the * document says so: doc.binding is 'right' for page.binding 'right', for * text that runs right to left and for vertical text, when the binding is * left to 'auto') 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-book')) { // The pages keep direction ltr, as in a left-bound book: a canvas takes // the direction its element inherits, and under the spread's rtl a run // painted for an ltr canvas would end where the engine starts it. document.head.insertAdjacentHTML('beforeend', `<style id="pt-kit-book"> .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 ───────────────────────────────────────────────────────────────────────

El script.js compuesto funciona tal cual: pégalo como script de módulo en cualquier página o abre la receta en CodePen. Carpeta de la receta en GitHub ↗ (se abre en una nueva pestaña)

Variantes

#Imprime cifras europeas

Una edición magrebí imprime 1, 2, 3; locale: 'ar-MA' lo hace por sí solo, y la edición magrebí enseña el libro entero.

-  locale: 'ar', // written out, never LANG (gotcha: arabic-locale-tag)
+  locale: 'ar', numerals: 'latn',

#Justifica solo con los espacios

Algunas editoriales no ponen tatweel en el texto corrido: los espacios absorben toda la diferencia.

   textAlign: 'justify', optimalLineBreaking: true,
+  kashida: 'none',

Errores frecuentes

Error frecuente

Etiqueta un libro árabe 'ar', no con LANG

Las ediciones de una receta son en y es, pero una muestra árabe es árabe en las dos: `locale: LANG` la compondría de izquierda a derecha, la encuadernaría por la izquierda, numeraría sus páginas 1 2 3 y llamaría Figure o Figura a sus figuras. Escribe tú la etiqueta: 'ar' (cifras arábigo-índicas, la convención del Mashriq), una región como 'ar-EG' o 'ar-SA', o 'ar-MA', 'ar-DZ' o 'ar-TN' para una edición magrebí con cifras europeas. Una página latina que solo cita árabe conserva su propio locale. Texto de derecha a izquierda →

Error frecuente

Las fuentes árabes necesitan su archivo arabic, con el bloque arabic

Fontsource sirve Amiri, Noto Naskh Arabic o Scheherazade New en un archivo por subconjunto, y las letras árabes están en el archivo arabic. loadFonts solo descarga latin (y latin-ext), así que en pantalla el árabe sale de una fuente del sistema y se mide mal, y fontsourceProvider entrega al PDF ese archivo latin, que imprime cajas vacías. Añade el bloque arabic del kit, llama a loadArabicFonts(FONTS, markdown) después de loadFonts, con todos los pesos que las páginas usan en árabe listados en FONTS, y pasa a renderToPdf fontProvider: arabicPdfProvider. Fuentes árabes →

Error frecuente

Un libro encuadernado por la derecha muestra sus pliegos con showBook

En un libro encuadernado por la derecha (texto árabe, hebreo o persa, chino vertical, o page.binding 'right') la página 1 sigue siendo impar, pero queda a la izquierda del lomo, y los pliegos se leen [3 | 2]. showPages compone todos los libros como si se encuadernaran por la izquierda; showBook, del bloque book (el bloque cjk trae la misma función), lee doc.binding y refleja los pliegos. capture.hero sigue nombrando un pliego en orden de lectura, [par, impar]: [2, 3]. Libros encuadernados por la derecha →

Error frecuente

Cualquier objeto headings desactiva el salto de página del H1

Por defecto un H1 salta a una página impar (always-odd), pero cualquier objeto headings anula ese valor, así que los capítulos van seguidos y span: 'page' no hace nada. Vuelve a declarar headings.levels[0].breakBefore: { enabled: true, parity } en cada configuración. Capítulos que abren en página impar →

Error frecuente

Un estilo de título hereda el salto de página de su nivel

Una entrada de headingStyles toma de su nivel de título todo lo que no fija, también breakBefore. Un índice o un colofón con estilo sobre un H1 tras un :::pagebreak hereda la paridad 'odd' y cae detrás de una página en blanco. Dale a ese estilo breakBefore: { enabled: false }. Estilos de título →

Error frecuente

Carga todas las fuentes antes de componer

La composición mide el texto con las fuentes que el navegador ha cargado y guarda los anchos, así que una fuente que llega después de la primera composición deja cortes de línea erróneos y un PDF que ya no coincide con la pantalla. Carga antes todos los pesos y estilos, y llama a clearMeasurementCache() antes de recomponer si alguna llega tarde. Fuentes antes de componer →

  • Un colofón en inglés o en español al final de un libro árabe necesita {dir=ltr} en su bloque: sin él, la línea latina se alinea y se puntúa como un párrafo de derecha a izquierda, con el punto final a la izquierda.

Créditos

Texto
  • El comienzo de una novela, حروف من رصاص (Letras de plomo), escrito en árabe para la receta; su autora y sus personajes son inventados · Postext Cookbook · original
Fuentes
Markazi Text (SIL OFL 1.1) · Reem Kufi (SIL OFL 1.1) · Noto Kufi Arabic (SIL OFL 1.1)
SandboxPDF