diff --git a/.vscode/settings.json b/.vscode/settings.json index 1231c6d..fe47b48 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,6 +10,7 @@ "knuth", "multicolumna", "opsz", + "pagebreak", "plass", "postext", "Turborepo" diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c2d45ef..66adce9 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -249,6 +249,17 @@ "baselineGridColorTooltip": "Color of the baseline grid lines", "baselineGridLineWidth": "Line Width", "baselineGridLineWidthTooltip": "Thickness of the baseline grid lines", + "pageNumbering": "Numbering", + "pageNumberingTooltip": "How page numbers are formatted and where they start", + "pageNumberingFormat": "Format", + "pageNumberingFormatTooltip": "Numeric style used for page labels", + "pageNumberingFormatDecimal": "Decimal (1, 2, 3)", + "pageNumberingFormatLowerRoman": "Lower roman (i, ii, iii)", + "pageNumberingFormatUpperRoman": "Upper roman (I, II, III)", + "pageNumberingFormatLowerAlpha": "Lower alpha (a, b, c)", + "pageNumberingFormatUpperAlpha": "Upper alpha (A, B, C)", + "pageNumberingStartAt": "Start at", + "pageNumberingStartAtTooltip": "Numeric value assigned to the first page (1 = first, 17 = start at 17, …)", "debug": "Debug", "debugCursorSync": "Sync Cursor", "debugCursorSyncTooltip": "Show the editor cursor position live on the canvas preview", @@ -289,6 +300,18 @@ "warningsConsecutiveHeadingsDetail": "This heading follows another heading with no text in between.", "warningsListAfterHeadingTitle": "List after heading", "warningsListAfterHeadingDetail": "A list starts right after a heading. Consider adding an introductory paragraph.", + "warningsUnknownDirectiveTitle": "Unknown directive", + "warningsUnknownDirectiveDetail": "A `:::name` directive is not recognized. Supported names: `pagebreak`, `numbering`.", + "warningsNumberingInvalidFormatTitle": "Invalid numbering format", + "warningsNumberingInvalidFormatDetail": "The `format` attribute of `:::numbering` must be one of: decimal, lower-roman, upper-roman, lower-alpha, upper-alpha.", + "warningsNumberingInvalidStartAtTitle": "Invalid numbering startAt", + "warningsNumberingInvalidStartAtDetail": "The `startAt` attribute of `:::numbering` must be an integer greater than or equal to 1.", + "warningsPagebreakInvalidParityTitle": "Invalid pagebreak parity", + "warningsPagebreakInvalidParityDetail": "The `parity` attribute must be `odd` or `even`.", + "warningsParityCascadeTitle": "Parity cascade", + "warningsParityCascadeDetail": "Heading `breakBefore.parity` is producing more than two consecutive blank padding pages — likely a misconfiguration.", + "warningsAlphaPdfOverflowTitle": "Alphabetic page labels past Z", + "warningsAlphaPdfOverflowDetail": "Alphabetic page numbering exceeds Z. The PDF reader will display the literal label (AA, AB, …) via per-page entries.", "warningsInvalidMathTitle": "Invalid LaTeX", "warningsUnclosedMathTitle": "Unclosed math delimiter", "pdfGenerationSection": "PDF Generation", @@ -418,6 +441,15 @@ "headingNumberingTemplatePlaceholder": "e.g. '{'1'}'.'{'2'}'", "headingItalic": "Italic", "headingItalicTooltip": "Render this heading level in italic style", + "headingBreakBefore": "Break before", + "headingBreakBeforeTooltip": "Force a page break before each heading of this level", + "headingBreakBeforeParity": "Break parity", + "headingBreakBeforeParityTooltip": "Restrict which side of the spread this heading can open on — inserts a blank page when needed", + "headingBreakBeforeParityAny": "Any side", + "headingBreakBeforeParityOdd": "Odd (right-hand)", + "headingBreakBeforeParityEven": "Even (left-hand)", + "headingBreakBeforeParityAlwaysOdd": "Always new spread + odd (right-hand)", + "headingBreakBeforeParityAlwaysEven": "Always new spread + even (left-hand)", "unorderedLists": "Unordered Lists", "unorderedListsFont": "Bullet Font", "unorderedListsFontTooltip": "Default font family used to render list bullets (inherits from body text when unset)", @@ -590,6 +622,10 @@ "unorderedList": "Unordered List", "undo": "Undo", "redo": "Redo", + "pagebreakDirective": "Page break", + "pagebreakDirectiveTooltip": "Insert a `:::pagebreak` directive at the current line", + "numberingDirective": "Page numbering", + "numberingDirectiveTooltip": "Insert a `:::numbering` directive to switch the page-number format or restart the counter", "save": "Save", "load": "Load", "exportFile": "Export", diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index f5dab32..c511527 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -249,6 +249,17 @@ "baselineGridColorTooltip": "Color de las líneas de la rejilla base", "baselineGridLineWidth": "Grosor de línea", "baselineGridLineWidthTooltip": "Grosor de las líneas de la rejilla base", + "pageNumbering": "Numeración", + "pageNumberingTooltip": "Cómo se formatean los números de página y dónde empiezan", + "pageNumberingFormat": "Formato", + "pageNumberingFormatTooltip": "Estilo numérico utilizado para las etiquetas de página", + "pageNumberingFormatDecimal": "Decimal (1, 2, 3)", + "pageNumberingFormatLowerRoman": "Romano minúsculo (i, ii, iii)", + "pageNumberingFormatUpperRoman": "Romano mayúsculo (I, II, III)", + "pageNumberingFormatLowerAlpha": "Alfabético minúsculo (a, b, c)", + "pageNumberingFormatUpperAlpha": "Alfabético mayúsculo (A, B, C)", + "pageNumberingStartAt": "Comenzar en", + "pageNumberingStartAtTooltip": "Valor numérico asignado a la primera página (1 = primera, 17 = comenzar en 17, …)", "debug": "Depuración", "debugCursorSync": "Sincronizar cursor", "debugCursorSyncTooltip": "Mostrar en vivo la posición del cursor del editor sobre la previsualización del canvas", @@ -289,6 +300,18 @@ "warningsConsecutiveHeadingsDetail": "Este título sigue a otro título sin texto entre ambos.", "warningsListAfterHeadingTitle": "Lista tras título", "warningsListAfterHeadingDetail": "Una lista comienza justo después de un título. Considera añadir un párrafo introductorio.", + "warningsUnknownDirectiveTitle": "Directiva desconocida", + "warningsUnknownDirectiveDetail": "Una directiva `:::nombre` no está reconocida. Nombres admitidos: `pagebreak`, `numbering`.", + "warningsNumberingInvalidFormatTitle": "Formato de numeración no válido", + "warningsNumberingInvalidFormatDetail": "El atributo `format` de `:::numbering` debe ser uno de: decimal, lower-roman, upper-roman, lower-alpha, upper-alpha.", + "warningsNumberingInvalidStartAtTitle": "Valor inicial de numeración no válido", + "warningsNumberingInvalidStartAtDetail": "El atributo `startAt` de `:::numbering` debe ser un entero mayor o igual a 1.", + "warningsPagebreakInvalidParityTitle": "Paridad de salto de página no válida", + "warningsPagebreakInvalidParityDetail": "El atributo `parity` debe ser `odd` o `even`.", + "warningsParityCascadeTitle": "Cascada de paridad", + "warningsParityCascadeDetail": "El parámetro `breakBefore.parity` del título está produciendo más de dos páginas en blanco de relleno consecutivas — probablemente un error de configuración.", + "warningsAlphaPdfOverflowTitle": "Etiquetas alfabéticas más allá de Z", + "warningsAlphaPdfOverflowDetail": "La numeración alfabética de páginas supera la Z. El lector de PDF mostrará la etiqueta literal (AA, AB, …) mediante entradas por página.", "warningsInvalidMathTitle": "LaTeX no válido", "warningsUnclosedMathTitle": "Delimitador matemático sin cerrar", "pdfGenerationSection": "Generación de PDF", @@ -418,6 +441,15 @@ "headingNumberingTemplatePlaceholder": "ej. '{'1'}'.'{'2'}'", "headingItalic": "Cursiva", "headingItalicTooltip": "Renderiza este nivel de título en estilo cursiva", + "headingBreakBefore": "Romper antes", + "headingBreakBeforeTooltip": "Forzar un salto de página antes de cada título de este nivel", + "headingBreakBeforeParity": "Paridad del salto", + "headingBreakBeforeParityTooltip": "Restringe en qué lado del pliego puede abrirse este título — inserta una página en blanco cuando sea necesario", + "headingBreakBeforeParityAny": "Cualquier lado", + "headingBreakBeforeParityOdd": "Impar (lado derecho)", + "headingBreakBeforeParityEven": "Par (lado izquierdo)", + "headingBreakBeforeParityAlwaysOdd": "Forzar siempre nueva página + impar (lado derecho)", + "headingBreakBeforeParityAlwaysEven": "Forzar siempre nueva página + par (lado izquierdo)", "unorderedLists": "Listas no ordenadas", "unorderedListsFont": "Fuente del bullet", "unorderedListsFontTooltip": "Fuente por defecto para los símbolos de las listas (hereda del cuerpo si no se define)", @@ -590,6 +622,10 @@ "unorderedList": "Lista sin orden", "undo": "Deshacer", "redo": "Rehacer", + "pagebreakDirective": "Salto de página", + "pagebreakDirectiveTooltip": "Inserta una directiva `:::pagebreak` en la línea actual", + "numberingDirective": "Numeración de páginas", + "numberingDirectiveTooltip": "Inserta una directiva `:::numbering` para cambiar el formato de numeración o reiniciar el contador", "save": "Guardar", "load": "Cargar", "exportFile": "Exportar", diff --git a/apps/web/src/components/sandbox/SandboxPage/labels.ts b/apps/web/src/components/sandbox/SandboxPage/labels.ts index 1f8e7e1..7b983cd 100644 --- a/apps/web/src/components/sandbox/SandboxPage/labels.ts +++ b/apps/web/src/components/sandbox/SandboxPage/labels.ts @@ -62,6 +62,17 @@ export function buildSandboxLabels(t: SandboxTranslator): SandboxLabels { baselineGridColorTooltip: t("baselineGridColorTooltip"), baselineGridLineWidth: t("baselineGridLineWidth"), baselineGridLineWidthTooltip: t("baselineGridLineWidthTooltip"), + pageNumbering: t("pageNumbering"), + pageNumberingTooltip: t("pageNumberingTooltip"), + pageNumberingFormat: t("pageNumberingFormat"), + pageNumberingFormatTooltip: t("pageNumberingFormatTooltip"), + pageNumberingFormatDecimal: t("pageNumberingFormatDecimal"), + pageNumberingFormatLowerRoman: t("pageNumberingFormatLowerRoman"), + pageNumberingFormatUpperRoman: t("pageNumberingFormatUpperRoman"), + pageNumberingFormatLowerAlpha: t("pageNumberingFormatLowerAlpha"), + pageNumberingFormatUpperAlpha: t("pageNumberingFormatUpperAlpha"), + pageNumberingStartAt: t("pageNumberingStartAt"), + pageNumberingStartAtTooltip: t("pageNumberingStartAtTooltip"), debug: t("debug"), debugCursorSync: t("debugCursorSync"), debugCursorSyncTooltip: t("debugCursorSyncTooltip"), @@ -102,6 +113,18 @@ export function buildSandboxLabels(t: SandboxTranslator): SandboxLabels { warningsConsecutiveHeadingsDetail: t("warningsConsecutiveHeadingsDetail"), warningsListAfterHeadingTitle: t("warningsListAfterHeadingTitle"), warningsListAfterHeadingDetail: t("warningsListAfterHeadingDetail"), + warningsUnknownDirectiveTitle: t("warningsUnknownDirectiveTitle"), + warningsUnknownDirectiveDetail: t("warningsUnknownDirectiveDetail"), + warningsNumberingInvalidFormatTitle: t("warningsNumberingInvalidFormatTitle"), + warningsNumberingInvalidFormatDetail: t("warningsNumberingInvalidFormatDetail"), + warningsNumberingInvalidStartAtTitle: t("warningsNumberingInvalidStartAtTitle"), + warningsNumberingInvalidStartAtDetail: t("warningsNumberingInvalidStartAtDetail"), + warningsPagebreakInvalidParityTitle: t("warningsPagebreakInvalidParityTitle"), + warningsPagebreakInvalidParityDetail: t("warningsPagebreakInvalidParityDetail"), + warningsParityCascadeTitle: t("warningsParityCascadeTitle"), + warningsParityCascadeDetail: t("warningsParityCascadeDetail"), + warningsAlphaPdfOverflowTitle: t("warningsAlphaPdfOverflowTitle"), + warningsAlphaPdfOverflowDetail: t("warningsAlphaPdfOverflowDetail"), warningsInvalidMathTitle: t("warningsInvalidMathTitle"), warningsUnclosedMathTitle: t("warningsUnclosedMathTitle"), pdfGenerationSection: t("pdfGenerationSection"), @@ -231,6 +254,15 @@ export function buildSandboxLabels(t: SandboxTranslator): SandboxLabels { headingNumberingTemplatePlaceholder: t("headingNumberingTemplatePlaceholder"), headingItalic: t("headingItalic"), headingItalicTooltip: t("headingItalicTooltip"), + headingBreakBefore: t("headingBreakBefore"), + headingBreakBeforeTooltip: t("headingBreakBeforeTooltip"), + headingBreakBeforeParity: t("headingBreakBeforeParity"), + headingBreakBeforeParityTooltip: t("headingBreakBeforeParityTooltip"), + headingBreakBeforeParityAny: t("headingBreakBeforeParityAny"), + headingBreakBeforeParityOdd: t("headingBreakBeforeParityOdd"), + headingBreakBeforeParityEven: t("headingBreakBeforeParityEven"), + headingBreakBeforeParityAlwaysOdd: t("headingBreakBeforeParityAlwaysOdd"), + headingBreakBeforeParityAlwaysEven: t("headingBreakBeforeParityAlwaysEven"), unorderedLists: t("unorderedLists"), unorderedListsFont: t("unorderedListsFont"), unorderedListsFontTooltip: t("unorderedListsFontTooltip"), @@ -403,6 +435,10 @@ export function buildSandboxLabels(t: SandboxTranslator): SandboxLabels { unorderedList: t("unorderedList"), undo: t("undo"), redo: t("redo"), + pagebreakDirective: t("pagebreakDirective"), + pagebreakDirectiveTooltip: t("pagebreakDirectiveTooltip"), + numberingDirective: t("numberingDirective"), + numberingDirectiveTooltip: t("numberingDirectiveTooltip"), save: t("save"), load: t("load"), exportFile: t("exportFile"), diff --git a/docs/configuration-en.mdx b/docs/configuration-en.mdx index 65d3f3f..c092a14 100644 --- a/docs/configuration-en.mdx +++ b/docs/configuration-en.mdx @@ -5,7 +5,7 @@ export const metadata = { sidebarTitle: 'Configuration', description: 'Complete reference for all Postext layout configuration options', lang: 'en', - lastUpdated: '2026-04-21', + lastUpdated: '2026-04-23', readingTime: '18 min', order: 3, }; @@ -232,6 +232,37 @@ When enabled, the canvas expands to include a bleed area and the engine draws cr +### Numbering + +The `page.pageNumbering` block controls how page labels are formatted and where the counter starts. It only defines the document-wide default — to restart numbering mid-document (e.g. roman-numeral front matter switching to decimal chapters from 1), use the `:::numbering` directive (see **Document format → Directives**). + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyTypeDefaultDescription
format'decimal' | 'lower-roman' | 'upper-roman' | 'lower-alpha' | 'upper-alpha''decimal'Numeric style used to render page labels.
startAtnumber1Numeric value assigned to the first page regardless of format. format: 'lower-roman', startAt: 1 yields i, ii, iii, …; format: 'decimal', startAt: 17 yields 17, 18, 19, ….
+ +The computed label is stored on every `VDTPage` as `pageLabel` and is what the \{pageNumber\} header/footer placeholder resolves to. PDFs emit a /PageLabels number tree so Preview / Acrobat's page indicator and "Go to page" navigation match the printed labels exactly. + ## Layout @@ -880,6 +911,12 @@ Per-level overrides support the same properties as the general defaults — `fon '' Reserved for automatic heading numbering (e.g. 'Chapter {n}. '). Currently stored on the resolved config and carried through the pipeline; rendering will honour it in an upcoming release. + + breakBefore + HeadingBreakBeforeConfig + { enabled: false, parity: 'any' } + Force a page break before every heading of this level. parity: 'odd' / 'even' further constrains which side of the spread the heading opens on — a blank padding page is inserted when needed (still counted in the page numbering). 'always-odd' / 'always-even' additionally guarantee at least one mandatory blank separator page between the previous content and the new heading (the separator belongs to the previous chapter; any further parity padding belongs to the new one). When the heading is the very first block of the document and the first page is still empty, parity enforcement is skipped — the heading lands on page 1 as written. + @@ -887,12 +924,61 @@ Per-level overrides support the same properties as the general defaults — `fon headings: { fontFamily: 'Merriweather', levels: [ - { level: 1, fontSize: { value: 24, unit: 'pt' }, color: { hex: '#1a1a2e', model: 'hex' } }, + // Canonical book preset: chapters on a right-hand (odd) page. + { level: 1, fontSize: { value: 24, unit: 'pt' }, breakBefore: { enabled: true, parity: 'odd' } }, { level: 2, fontSize: { value: 18, unit: 'pt' }, italic: true }, ] } ``` +### Break before + +`breakBefore` is orthogonal to the numbering controls: turning it on forces a page break, but the numeric counter only resets when you explicitly insert a `:::numbering` directive. Blank parity pages count as real pages in the sequence and receive headers/footers according to their normal odd/even rules. + +#### Parity values + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ValueBehavior
'any' (default)No parity constraint. The heading just opens on the next page.
'odd'Ensure the heading opens on an odd (right-hand) page. A single blank is inserted only when the natural next page is even.
'even'Same but for an even (left-hand) page.
'always-odd'Guarantee at least one mandatory blank separator page between the previous content and the new heading, then ensure odd parity. Useful when every chapter must start on a fresh spread.
'always-even'Same but for an even page.
+ +#### Blank-page ownership + +Blank pages inserted by `breakBefore` carry chapter-title headers based on *why* they were inserted: + +- Pages inserted to satisfy a parity constraint (`'odd'`, `'even'`, or the parity tail of `'always-*'`) belong to the **upcoming** chapter. Their `{chapterTitle}` header placeholder resolves to the new chapter's title — because the blank exists only to push the new chapter onto the correct parity. +- The mandatory leading separator inserted by `'always-odd'` / `'always-even'` belongs to the **previous** chapter. It's a deliberate end-of-chapter breath, so the `{chapterTitle}` header still shows the old chapter's title. + +#### Document-start exception + +When the very first block of the document is a heading with `breakBefore` enabled — or the source opens with `:::pagebreak` — parity enforcement is skipped while the first page is still empty. The heading lands on page 1 as written, regardless of the configured parity, so a document that begins with a `# Chapter 1` configured `parity: 'odd'` doesn't inherit a spurious leading blank. Once any content has been placed, parity enforcement behaves normally. + ## Unordered Lists diff --git a/docs/configuration-es.mdx b/docs/configuration-es.mdx index 39bfc36..33e79e6 100644 --- a/docs/configuration-es.mdx +++ b/docs/configuration-es.mdx @@ -5,7 +5,7 @@ export const metadata = { sidebarTitle: 'Configuración', description: 'Referencia completa de todas las opciones de configuración de composición de Postext', lang: 'es', - lastUpdated: '2026-04-21', + lastUpdated: '2026-04-23', readingTime: '18 min', order: 3, }; @@ -232,6 +232,37 @@ Al activarse, el lienzo se expande para incluir una zona de sangrado y el motor +### Numeración + +El bloque `page.pageNumbering` controla cómo se formatean las etiquetas de página y dónde empieza el contador. Define únicamente el valor por defecto de todo el documento — para reiniciar la numeración en medio del documento (por ejemplo, números romanos en las páginas preliminares que pasan a decimal desde 1 en los capítulos), usa la directiva `:::numbering` (consulta **Formato del documento → Directivas**). + + + + + + + + + + + + + + + + + + + + + + + + +
PropiedadTipoPor defectoDescripción
format'decimal' | 'lower-roman' | 'upper-roman' | 'lower-alpha' | 'upper-alpha''decimal'Estilo numérico utilizado para renderizar las etiquetas de página.
startAtnumber1Valor numérico asignado a la primera página, independientemente del formato. format: 'lower-roman', startAt: 1 produce i, ii, iii, …; format: 'decimal', startAt: 17 produce 17, 18, 19, ….
+ +La etiqueta calculada se almacena en cada `VDTPage` como `pageLabel` y es el valor que resuelve el marcador \{pageNumber\} en encabezados y pies. Los PDFs emiten un árbol `/PageLabels` de modo que el indicador de página y la navegación «Ir a la página» de Preview / Acrobat coinciden exactamente con las etiquetas impresas. + ## Disposición @@ -880,6 +911,12 @@ Las configuraciones por nivel soportan las mismas propiedades que los valores ge '' Reservado para numeración automática de encabezados (por ejemplo, 'Capítulo {n}. '). Actualmente se almacena en la configuración resuelta y se propaga a través del pipeline; el renderizado lo respetará en una próxima versión. + + breakBefore + HeadingBreakBeforeConfig + { enabled: false, parity: 'any' } + Fuerza un salto de página antes de cada encabezado de este nivel. parity: 'odd' / 'even' restringe además en qué lado del pliego se abrirá — se inserta una página en blanco de relleno cuando sea necesario (sigue contando para la numeración). 'always-odd' / 'always-even' garantizan además al menos una página separadora en blanco obligatoria entre el contenido anterior y el nuevo encabezado (la separadora pertenece al capítulo anterior; cualquier relleno de paridad adicional pertenece al nuevo). Cuando el encabezado es el primer bloque del documento y la primera página sigue vacía, la imposición de paridad se omite — el encabezado aterriza en la página 1 tal cual. + @@ -887,12 +924,61 @@ Las configuraciones por nivel soportan las mismas propiedades que los valores ge headings: { fontFamily: 'Merriweather', levels: [ - { level: 1, fontSize: { value: 24, unit: 'pt' }, color: { hex: '#1a1a2e', model: 'hex' } }, + // Preajuste canónico de libro: los capítulos se abren en la página derecha (impar). + { level: 1, fontSize: { value: 24, unit: 'pt' }, breakBefore: { enabled: true, parity: 'odd' } }, { level: 2, fontSize: { value: 18, unit: 'pt' }, italic: true }, ] } ``` +### Saltar antes + +`breakBefore` es ortogonal a los controles de numeración: activarlo fuerza un salto de página, pero el contador numérico solo se reinicia cuando insertas explícitamente una directiva `:::numbering`. Las páginas de relleno por paridad cuentan como páginas reales en la secuencia y reciben encabezados/pies según sus reglas normales de impar/par. + +#### Valores de paridad + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ValorComportamiento
'any' (por defecto)Sin restricción de paridad. El encabezado simplemente se abre en la siguiente página.
'odd'Garantiza que el encabezado se abra en una página impar (lado derecho). Solo se inserta una página en blanco si la siguiente página natural sería par.
'even'Igual pero para una página par (lado izquierdo).
'always-odd'Garantiza al menos una página separadora en blanco obligatoria entre el contenido anterior y el nuevo encabezado, y después fuerza la paridad impar. Útil cuando cada capítulo debe empezar un pliego nuevo.
'always-even'Igual pero para una página par.
+ +#### Pertenencia de las páginas en blanco + +Las páginas en blanco que inserta `breakBefore` reciben el encabezado `{chapterTitle}` en función del motivo de su inserción: + +- Las páginas insertadas para satisfacer una restricción de paridad (`'odd'`, `'even'`, o la cola de paridad de `'always-*'`) pertenecen al capítulo **entrante**. Su marcador `{chapterTitle}` resuelve al título del nuevo capítulo — porque la página en blanco solo existe para empujar al nuevo capítulo hasta la paridad correcta. +- La página separadora obligatoria que inserta `'always-odd'` / `'always-even'` pertenece al capítulo **anterior**. Es una pausa de cierre de capítulo deliberada, así que `{chapterTitle}` sigue mostrando el título del capítulo saliente. + +#### Excepción al inicio del documento + +Cuando el primer bloque del documento es un encabezado con `breakBefore` activado — o la fuente empieza con `:::pagebreak` —, la imposición de paridad se omite mientras la primera página siga vacía. El encabezado aterriza en la página 1 tal cual, independientemente de la paridad configurada, de modo que un documento que comienza con `# Capítulo 1` con `parity: 'odd'` no arrastra una página en blanco inicial innecesaria. Una vez que se ha colocado cualquier contenido, la imposición de paridad funciona de forma habitual. + ## Listas no ordenadas diff --git a/docs/document-format-en.mdx b/docs/document-format-en.mdx index 5ffe862..c95d633 100644 --- a/docs/document-format-en.mdx +++ b/docs/document-format-en.mdx @@ -5,7 +5,7 @@ export const metadata = { sidebarTitle: 'Document Format', description: 'The markdown subset Postext parses, and the rules for authoring source documents', lang: 'en', - lastUpdated: '2026-04-21', + lastUpdated: '2026-04-23', readingTime: '9 min', order: 5, }; @@ -97,6 +97,103 @@ A single blank line between two list items is tolerated — the list stays toget Lists of mixed kinds at the same depth are accepted (you can switch from unordered to ordered mid-run), but the engine treats the runs as separate for numbering purposes. In practice, keep one kind per depth unless you have a reason to mix them. + +## Directives + +Directives are single-line control tags written as `:::name` or `:::name{attrs}` on their own line. They produce no visible output — they drive the placement and numbering pipeline. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SyntaxEffect
:::pagebreakForce the next block to open on a new page.
:::pagebreak{parity="odd"}Same, plus ensure the new page is odd (right-hand). Inserts a blank padding page when needed.
:::pagebreak{parity="even"}Same, but targeting an even (left-hand) page.
:::pagebreak{parity="always-odd"}Guarantee at least one mandatory blank separator page before landing on an odd page. The separator blank belongs to the preceding content; any further parity padding belongs to what follows. Useful when every chapter must start on a fresh spread.
:::pagebreak{parity="always-even"}Same, but targeting an even page.
:::numbering{format="decimal" startAt=1}At the next page boundary, switch the page-numbering sequence. Both attributes are optional — omit format to keep the format, omit startAt to continue the counter.
+ +Attribute values may be double-quoted ("…"), single-quoted ('…'), or bare (startAt=17). A bare key without `=` is treated as a present-but-empty flag. + +Only `pagebreak` and `numbering` are recognized today — any other `:::name` line is parsed as a paragraph and surfaces an **Unknown directive** warning in the sandbox. + +### `:::pagebreak` + +The directive itself does not force parity on its own — only the next block's layout. Use it to end a preface, force a dedication onto its own page, or mark the end of a section. When both a page break and a numbering reset are wanted at the same point, compose `:::pagebreak` followed by `:::numbering` — the numbering switch applies at the fresh page the `:::pagebreak` just created. + +```md +The old chapter ends here. + +:::pagebreak{parity="odd"} + +# A new chapter +``` + +#### Parity attribute + +The `parity` attribute accepts the same five values as `headings.levels[*].breakBefore.parity`: + +- `'odd'` / `'even'` — the new page opens on the requested side of the spread; a single blank is inserted only when the natural next page is on the wrong side. +- `'always-odd'` / `'always-even'` — guarantee at least one mandatory blank separator page between the previous content and the new page, then enforce parity. The separator blank belongs to the **previous** chapter; any further parity padding belongs to whatever follows. + +#### Blank-page ownership + +The two kinds of blank pages `:::pagebreak` (and `breakBefore`) can introduce are distinguished in the `VDTPage` model: + +- `blankForParity: true` — inserted to satisfy a parity constraint. In `{chapterTitle}` headers this page carries the **upcoming** chapter's title, because the blank exists only to push that chapter onto the right parity. +- `blankForForce: true` — the mandatory leading separator of an `'always-*'` mode. It belongs to the **previous** chapter — a deliberate end-of-chapter breath, not parity padding for the next chapter. + +#### Document-start exception + +When `:::pagebreak` is the very first construct in a document (or a heading with `breakBefore` would pull one in), parity enforcement is skipped while the first page is still empty. The next block lands on page 1 as written, regardless of the requested parity — no spurious leading blank. + +### `:::numbering` + +`:::numbering` is how you restart the page counter mid-document. The canonical book example: + +```md +--- +title: "A Book With Front Matter" +--- + +# Preface + +… + +:::pagebreak{parity="odd"} +:::numbering{format="decimal" startAt=1} + +# Chapter 1 +``` + +Preface pages are labelled `i`, `ii`, `iii`, …; the first chapter opens on a right-hand page labelled `1`. + +Format-only changes (no `startAt`) keep the counter flowing — useful for, say, switching from `lower-alpha` to `upper-alpha` without resetting. + diff --git a/docs/document-format-es.mdx b/docs/document-format-es.mdx index 3c102f9..71c497a 100644 --- a/docs/document-format-es.mdx +++ b/docs/document-format-es.mdx @@ -5,7 +5,7 @@ export const metadata = { sidebarTitle: 'Formato del documento', description: 'El subconjunto de markdown que parsea Postext y las normas para escribir los documentos fuente', lang: 'es', - lastUpdated: '2026-04-21', + lastUpdated: '2026-04-23', readingTime: '9 min', order: 5, }; @@ -97,6 +97,103 @@ Se tolera una única línea en blanco entre dos elementos de lista — la lista Se aceptan listas de tipos mixtos a la misma profundidad (puedes pasar de no ordenada a ordenada a mitad de recorrido), pero el motor trata cada tramo por separado a efectos de numeración. En la práctica, conviene mantener un único tipo por profundidad salvo que haya un motivo claro para mezclarlos. + +## Directivas + +Las directivas son etiquetas de control de una sola línea escritas como `:::nombre` o `:::nombre{atributos}` en su propia línea. No producen salida visible — controlan el pipeline de colocación y numeración. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SintaxisEfecto
:::pagebreakFuerza que el siguiente bloque comience en una nueva página.
:::pagebreak{parity="odd"}Igual, además asegura que la nueva página sea impar (lado derecho). Inserta una página en blanco de relleno cuando sea necesario.
:::pagebreak{parity="even"}Igual, pero apuntando a una página par (lado izquierdo).
:::pagebreak{parity="always-odd"}Garantiza al menos una página separadora en blanco obligatoria antes de aterrizar en una página impar. La página separadora pertenece al contenido anterior; cualquier relleno de paridad adicional pertenece a lo que sigue. Útil cuando cada capítulo debe empezar un pliego nuevo.
:::pagebreak{parity="always-even"}Igual, pero apuntando a una página par.
:::numbering{format="decimal" startAt=1}En el siguiente límite de página, cambia la secuencia de numeración. Ambos atributos son opcionales — omite format para mantener el formato, omite startAt para continuar el contador.
+ +Los valores de los atributos pueden ir entre comillas dobles ("…"), comillas simples ('…') o sin comillas (startAt=17). Una clave sin `=` se trata como una bandera presente con valor vacío. + +Hoy solo se reconocen `pagebreak` y `numbering` — cualquier otra línea `:::nombre` se parsea como párrafo y genera un aviso **Directiva desconocida** en el sandbox. + +### `:::pagebreak` + +La directiva por sí sola no fuerza paridad — solo afecta al siguiente bloque. Úsala para terminar un prólogo, forzar una dedicatoria en su propia página, o marcar el final de una sección. Cuando se quieren ambos efectos (salto de página y reinicio de numeración), compón `:::pagebreak` seguido de `:::numbering` — el cambio de numeración se aplica en la nueva página que acaba de crear `:::pagebreak`. + +```md +El capítulo anterior termina aquí. + +:::pagebreak{parity="odd"} + +# Un nuevo capítulo +``` + +#### Atributo `parity` + +El atributo `parity` acepta los mismos cinco valores que `headings.levels[*].breakBefore.parity`: + +- `'odd'` / `'even'` — la nueva página abre en el lado solicitado del pliego; solo se inserta una página en blanco cuando la siguiente página natural cae en el lado equivocado. +- `'always-odd'` / `'always-even'` — garantizan al menos una página separadora en blanco obligatoria entre el contenido anterior y la nueva página, y después fuerzan la paridad. La separadora pertenece al capítulo **anterior**; cualquier relleno de paridad adicional pertenece a lo que viene después. + +#### Pertenencia de las páginas en blanco + +Los dos tipos de páginas en blanco que `:::pagebreak` (y `breakBefore`) pueden introducir se distinguen en el modelo `VDTPage`: + +- `blankForParity: true` — insertada para satisfacer una restricción de paridad. En el marcador `{chapterTitle}` esta página lleva el título del capítulo **entrante**, porque la página en blanco solo existe para empujar ese capítulo a la paridad correcta. +- `blankForForce: true` — la página separadora obligatoria que añade un modo `'always-*'`. Pertenece al capítulo **anterior** — es una pausa deliberada de cierre de capítulo, no un relleno de paridad para el capítulo siguiente. + +#### Excepción al inicio del documento + +Cuando `:::pagebreak` es la primera construcción del documento (o un encabezado con `breakBefore` lo dispara), la imposición de paridad se omite mientras la primera página siga vacía. El siguiente bloque aterriza en la página 1 tal cual, independientemente de la paridad solicitada — no se inserta una página en blanco inicial superflua. + +### `:::numbering` + +`:::numbering` es la forma de reiniciar el contador de páginas a mitad del documento. Ejemplo canónico de libro: + +```md +--- +title: "Un libro con páginas preliminares" +--- + +# Prefacio + +… + +:::pagebreak{parity="odd"} +:::numbering{format="decimal" startAt=1} + +# Capítulo 1 +``` + +Las páginas del prefacio se etiquetan `i`, `ii`, `iii`, …; el primer capítulo se abre en una página derecha etiquetada `1`. + +Los cambios solo de formato (sin `startAt`) mantienen el contador vivo — útil para, por ejemplo, pasar de `lower-alpha` a `upper-alpha` sin reiniciar. + diff --git a/docs/sandbox-en.mdx b/docs/sandbox-en.mdx index 1714ced..6609e3d 100644 --- a/docs/sandbox-en.mdx +++ b/docs/sandbox-en.mdx @@ -5,7 +5,7 @@ export const metadata = { sidebarTitle: 'Sandbox', description: 'Interactive playground for experimenting with the Postext layout engine', lang: 'en', - lastUpdated: '2026-04-21', + lastUpdated: '2026-04-23', readingTime: '6 min', order: 7, }; @@ -49,10 +49,10 @@ A form-based editor for `PostextConfig`. Settings are grouped into collapsible s | Section | Settings | |---------|----------| -| **Page** | Background color, size preset (or custom width/height), margins (top, bottom, left, right), DPI, cut lines, baseline grid (with color and line width) | +| **Page** | Background color, size preset (or custom width/height), margins (top, bottom, left, right), DPI, cut lines, baseline grid (with color and line width), and a **Numbering** subsection (format + start-at) for page labels | | **Layout** | Layout type (single, double, column-and-a-half), gutter width, side column percentage | | **Body Text** | Font family (with Google Fonts search), font size, line height, text color, text alignment, font weight, bold font weight, hyphenation (toggle + locale) | -| **Headings** | General defaults (font family, line height, color, alignment, weight, margins) plus per-level overrides (H1–H6) for font size, line height, font family, weight, color, and margins | +| **Headings** | General defaults (font family, line height, color, alignment, weight, margins) plus per-level overrides (H1–H6) for font size, line height, font family, weight, color, margins, and a **Break before** toggle with an optional parity selector | Controls are context-aware: gutter width only appears for multi-column layouts, hyphenation settings only appear when text is justified, and side column percentage only shows for the column-and-a-half layout. Changes take effect immediately in the viewport preview. For a full reference of all configuration options and their defaults, see the [Configuration](/en/docs/configuration) page. diff --git a/docs/sandbox-es.mdx b/docs/sandbox-es.mdx index ad3008a..ae9779d 100644 --- a/docs/sandbox-es.mdx +++ b/docs/sandbox-es.mdx @@ -5,7 +5,7 @@ export const metadata = { sidebarTitle: 'Sandbox', description: 'Entorno interactivo para experimentar con el motor de maquetación de Postext', lang: 'es', - lastUpdated: '2026-04-21', + lastUpdated: '2026-04-23', readingTime: '6 min', order: 7, }; @@ -49,10 +49,10 @@ Un editor basado en formulario para `PostextConfig`. Los ajustes se agrupan en s | Sección | Ajustes | |---------|---------| -| **Página** | Color de fondo, tamaño predefinido (o ancho/alto personalizados), márgenes (superior, inferior, izquierdo, derecho), DPI, líneas de corte, rejilla de línea base (con color y grosor de línea) | +| **Página** | Color de fondo, tamaño predefinido (o ancho/alto personalizados), márgenes (superior, inferior, izquierdo, derecho), DPI, líneas de corte, rejilla de línea base (con color y grosor de línea) y una subsección **Numeración** (formato + valor inicial) para las etiquetas de página | | **Maquetación** | Tipo de maquetación (una columna, doble, columna y media), ancho del medianil, porcentaje de columna lateral | | **Texto de cuerpo** | Familia tipográfica (con búsqueda de Google Fonts), tamaño de fuente, interlineado, color de texto, alineación, peso de fuente, peso de la negrita, separación silábica (interruptor + locale) | -| **Encabezados** | Valores por defecto generales (familia tipográfica, interlineado, color, alineación, peso, márgenes) más sobrecargas por nivel (H1–H6) para tamaño de fuente, interlineado, familia tipográfica, peso, color y márgenes | +| **Encabezados** | Valores por defecto generales (familia tipográfica, interlineado, color, alineación, peso, márgenes) más sobrecargas por nivel (H1–H6) para tamaño de fuente, interlineado, familia tipográfica, peso, color, márgenes, y un interruptor **Romper antes** con selector de paridad opcional | Los controles son contextuales: el ancho del medianil solo aparece en maquetaciones multicolumna, los ajustes de separación silábica solo aparecen cuando el texto está justificado, y el porcentaje de columna lateral solo se muestra en la maquetación de columna y media. Los cambios surten efecto inmediatamente en la vista previa del viewport. Para una referencia completa de todas las opciones de configuración y sus valores por defecto, consulta la página de [Configuración](/es/docs/configuration). diff --git a/packages/postext-pdf/src/pdf-backend/index.ts b/packages/postext-pdf/src/pdf-backend/index.ts index 19497b6..1f1dcf1 100644 --- a/packages/postext-pdf/src/pdf-backend/index.ts +++ b/packages/postext-pdf/src/pdf-backend/index.ts @@ -25,6 +25,7 @@ import { import { renderBlock } from './blockRender'; import { renderHeaderFooterSlot } from './headerFooter'; import { addOutlines } from './outlines'; +import { addPageLabels } from './pageLabels'; export interface RenderToPdfOptions { fontProvider: PdfFontProvider; @@ -173,5 +174,7 @@ export async function renderToPdf( addOutlines(pdfDoc, doc); } + addPageLabels(pdfDoc, doc); + return pdfDoc.save(); } diff --git a/packages/postext-pdf/src/pdf-backend/pageLabels.ts b/packages/postext-pdf/src/pdf-backend/pageLabels.ts new file mode 100644 index 0000000..aca5e94 --- /dev/null +++ b/packages/postext-pdf/src/pdf-backend/pageLabels.ts @@ -0,0 +1,71 @@ +import { + PDFArray, + PDFDict, + PDFName, + PDFNumber, + PDFString, + type PDFDocument, +} from 'pdf-lib'; +import type { PageLabelRun, VDTDocument } from 'postext'; +import { collectPageLabelRuns } from 'postext'; + +/** + * postext `NumeralStyle` → PDF `/S` style code. PDF 1.7 §12.4.2 supports + * `/D` (decimal), `/R` (upper roman), `/r` (lower roman), `/A` (upper + * letters A–Z, AA–ZZ), `/a` (lower letters a–z, aa–zz). The PDF letter + * variants only survive up to 26 (AA repeats A twice, not 27) so we + * only emit them when the entire run fits within ±26; otherwise we + * drop the style and emit per-page `/P` prefix entries carrying the + * literal label. + */ +const STYLE_CODE: Record = { + decimal: 'D', + 'upper-roman': 'R', + 'lower-roman': 'r', + 'upper-alpha': 'A', + 'lower-alpha': 'a', +}; + +/** Emit a `/PageLabels` number tree on the PDF catalog. No-op when + * `doc.pages` is empty. Alpha runs whose max value exceeds 26 fall back + * to per-page `/P` prefix entries so PDF viewers show the exact postext + * label (`AA`, `AB`, …) rather than the PDF repeated-letter scheme. */ +export function addPageLabels(pdfDoc: PDFDocument, doc: VDTDocument): void { + if (doc.pages.length === 0) return; + + const ctx = pdfDoc.context; + const nums = PDFArray.withContext(ctx); + const runs: PageLabelRun[] = collectPageLabelRuns( + doc.pages.map((p) => ({ + value: p.pageNumberValue, + label: p.pageLabel, + format: p.pageNumberFormat, + })), + ); + + for (const run of runs) { + const code = STYLE_CODE[run.format] ?? null; + const isAlpha = run.format === 'upper-alpha' || run.format === 'lower-alpha'; + + if (isAlpha && run.maxValue > 26) { + for (let i = run.startPageIndex; i <= run.endPageIndex; i++) { + const page = doc.pages[i]!; + const dict = PDFDict.withContext(ctx); + dict.set(PDFName.of('P'), PDFString.of(page.pageLabel)); + nums.push(PDFNumber.of(i)); + nums.push(dict); + } + continue; + } + + const dict = PDFDict.withContext(ctx); + if (code) dict.set(PDFName.of('S'), PDFName.of(code)); + if (run.startAt !== 1) dict.set(PDFName.of('St'), PDFNumber.of(run.startAt)); + nums.push(PDFNumber.of(run.startPageIndex)); + nums.push(dict); + } + + const pageLabels = PDFDict.withContext(ctx); + pageLabels.set(PDFName.of('Nums'), nums); + pdfDoc.catalog.set(PDFName.of('PageLabels'), pageLabels); +} diff --git a/packages/postext-sandbox/src/controls/CollapsibleSection.tsx b/packages/postext-sandbox/src/controls/CollapsibleSection.tsx index b9586a1..3e257af 100644 --- a/packages/postext-sandbox/src/controls/CollapsibleSection.tsx +++ b/packages/postext-sandbox/src/controls/CollapsibleSection.tsx @@ -15,6 +15,10 @@ interface CollapsibleSectionProps { hasOverrides?: boolean; resetLabel?: string; resetConfirmMessage?: string; + /** Visual density of the header. `'section'` (default) is the uppercase + * primary-section style. `'subsection'` renders a smaller, non-uppercase + * header that nests inside another section. */ + variant?: 'section' | 'subsection'; } export function CollapsibleSection({ @@ -26,6 +30,7 @@ export function CollapsibleSection({ hasOverrides = false, resetLabel = 'Reset section', resetConfirmMessage = 'Reset this section to defaults?', + variant = 'section', }: CollapsibleSectionProps) { const [open, setOpen] = useState(() => { if (sectionId) { @@ -64,20 +69,29 @@ export function CollapsibleSection({ } }; + const isSubsection = variant === 'subsection'; + const headerClass = isSubsection + ? 'flex flex-1 items-center justify-between px-3 py-2 text-xs transition-colors' + : 'flex flex-1 items-center justify-between px-3 py-2 text-xs font-semibold uppercase tracking-wider transition-colors'; + const headerIdleColor = isSubsection ? 'var(--slate)' : 'var(--gilt)'; + const containerClass = isSubsection + ? 'flex w-full items-center' + : 'flex w-full items-center border-b'; + return (