From dde8abc332aede6a8bf790ba05aaf7bef35395ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Ferro=20Pic=C3=B3n?= Date: Thu, 23 Apr 2026 08:17:41 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20pagination=20=E2=80=94=20numbering?= =?UTF-8?q?=20formats,=20forced=20breaks,=20resets,=20PDF=20page=20labels?= =?UTF-8?q?=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Overhauls the pagination system so postext can produce books and long-form documents with the numbering conventions readers expect (roman front matter, arabic chapters from 1, chapters opening on the right-hand page, etc.). - `page.pageNumbering` (format + startAt) drives the document-wide default. - `:::pagebreak` and `:::numbering` directives parsed as new block type, consumed by the placement pipeline as control markers. - Heading `breakBefore` (enabled + parity) forces page opens per level, padding with a blank parity page when needed. - `VDTPage` now carries `pageNumberValue`, `pageLabel`, `pageNumberFormat`, and `blankForParity`. - `buildPageLabels` / `collectPageLabelRuns` added to `numbering.ts`. - PDF backend emits a `/PageLabels` number tree matching VDT runs, with per-page `P` prefix fallback past Z for alpha formats. - `{pageNumber}` placeholder now resolves to `page.pageLabel`. - Sandbox: Numbering subsection under Page; `Break before` toggle + parity selector per heading level. - New warnings: unknown directive, invalid numbering format / startAt, invalid parity, parity cascade, alpha PDF overflow. - Docs updated in EN + ES (configuration, document-format, sandbox). - Tests: buildPageLabels, collectPageLabelRuns, directive parser. Closes #46 Unblocks #45 Co-Authored-By: Claude Opus 4.7 (1M context) --- .vscode/settings.json | 1 + apps/web/messages/en.json | 30 ++++ apps/web/messages/es.json | 30 ++++ .../components/sandbox/SandboxPage/labels.ts | 30 ++++ docs/configuration-en.mdx | 46 +++++- docs/configuration-es.mdx | 46 +++++- docs/document-format-en.mdx | 73 ++++++++- docs/document-format-es.mdx | 73 ++++++++- docs/sandbox-en.mdx | 6 +- docs/sandbox-es.mdx | 6 +- packages/postext-pdf/src/pdf-backend/index.ts | 3 + .../postext-pdf/src/pdf-backend/pageLabels.ts | 71 +++++++++ .../src/sidebar/WarningsPanel.tsx | 35 +++++ .../HeadingsSection/HeadingLevelSection.tsx | 34 ++++- .../src/sidebar/sections/PageSection.tsx | 57 ++++++- .../src/types/defaultLabels.ts | 30 ++++ packages/postext-sandbox/src/types/labels.ts | 32 ++++ .../postext-sandbox/src/warnings/compute.ts | 141 ++++++++++++++++++ .../postext-sandbox/src/warnings/types.ts | 18 ++- .../postext/src/__tests__/directives.test.ts | 58 +++++++ .../postext/src/__tests__/exports.test.ts | 4 + .../src/__tests__/numbering-page.test.ts | 106 +++++++++++++ packages/postext/src/defaults/headings.ts | 36 ++++- packages/postext/src/defaults/index.ts | 2 +- packages/postext/src/defaults/page.ts | 28 +++- packages/postext/src/index.ts | 12 +- packages/postext/src/numbering.ts | 107 +++++++++++++ packages/postext/src/parse/blockParser.ts | 46 +++++- packages/postext/src/parse/index.ts | 2 + packages/postext/src/parse/types.ts | 16 +- packages/postext/src/pipeline/build.ts | 101 ++++++++++++- packages/postext/src/pipeline/placeholders.ts | 2 +- packages/postext/src/pipeline/placement.ts | 51 +++++++ packages/postext/src/types.ts | 41 +++++ packages/postext/src/vdt.ts | 17 +++ 35 files changed, 1358 insertions(+), 33 deletions(-) create mode 100644 packages/postext-pdf/src/pdf-backend/pageLabels.ts create mode 100644 packages/postext/src/__tests__/directives.test.ts create mode 100644 packages/postext/src/__tests__/numbering-page.test.ts 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..9053667 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,13 @@ "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)", "unorderedLists": "Unordered Lists", "unorderedListsFont": "Bullet Font", "unorderedListsFontTooltip": "Default font family used to render list bullets (inherits from body text when unset)", diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index f5dab32..76b2cc5 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,13 @@ "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)", "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)", diff --git a/apps/web/src/components/sandbox/SandboxPage/labels.ts b/apps/web/src/components/sandbox/SandboxPage/labels.ts index 1f8e7e1..31755fc 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,13 @@ 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"), unorderedLists: t("unorderedLists"), unorderedListsFont: t("unorderedListsFont"), unorderedListsFontTooltip: t("unorderedListsFontTooltip"), diff --git a/docs/configuration-en.mdx b/docs/configuration-en.mdx index 65d3f3f..00b7dbc 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). + @@ -887,12 +924,17 @@ 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. + ## Unordered Lists diff --git a/docs/configuration-es.mdx b/docs/configuration-es.mdx index 39bfc36..d1a5090 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). + @@ -887,12 +924,17 @@ 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. + ## Listas no ordenadas diff --git a/docs/document-format-en.mdx b/docs/document-format-en.mdx index 5ffe862..62f51e9 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,77 @@ 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.
:::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 +``` + +### `:::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..d78fa58 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,77 @@ 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).
:::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 +``` + +### `:::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/sidebar/WarningsPanel.tsx b/packages/postext-sandbox/src/sidebar/WarningsPanel.tsx index 6175423..47a8226 100644 --- a/packages/postext-sandbox/src/sidebar/WarningsPanel.tsx +++ b/packages/postext-sandbox/src/sidebar/WarningsPanel.tsx @@ -28,6 +28,14 @@ function iconFor(kind: WarningPayload['kind']) { case 'headerFooterUnknownPlaceholder': case 'headerFooterMetadataMissing': return FileText; + case 'unknownDirective': + case 'numberingInvalidFormat': + case 'numberingInvalidStartAt': + case 'pagebreakInvalidParity': + case 'headingBreakInvalidParity': + case 'parityCascade': + case 'alphaPdfOverflow': + return FileWarning; default: return AlertTriangle; } @@ -59,6 +67,19 @@ function titleFor(payload: WarningPayload, labels: SandboxLabels): string { return labels.warningsHeaderFooterUnknownPlaceholderTitle; case 'headerFooterMetadataMissing': return labels.warningsHeaderFooterMetadataMissingTitle; + case 'unknownDirective': + return labels.warningsUnknownDirectiveTitle; + case 'numberingInvalidFormat': + return labels.warningsNumberingInvalidFormatTitle; + case 'numberingInvalidStartAt': + return labels.warningsNumberingInvalidStartAtTitle; + case 'pagebreakInvalidParity': + case 'headingBreakInvalidParity': + return labels.warningsPagebreakInvalidParityTitle; + case 'parityCascade': + return labels.warningsParityCascadeTitle; + case 'alphaPdfOverflow': + return labels.warningsAlphaPdfOverflowTitle; } } @@ -98,6 +119,20 @@ function detailFor(payload: WarningPayload, labels: SandboxLabels): string { return `${payload.slot} · {${payload.name}} — ${labels.warningsHeaderFooterUnknownPlaceholderDetail}`; case 'headerFooterMetadataMissing': return `${payload.slot} · {${payload.name}} — ${labels.warningsHeaderFooterMetadataMissingDetail}`; + case 'unknownDirective': + return `:::${payload.name} — ${labels.warningsUnknownDirectiveDetail}`; + case 'numberingInvalidFormat': + return `format="${payload.value}" — ${labels.warningsNumberingInvalidFormatDetail}`; + case 'numberingInvalidStartAt': + return `startAt=${payload.value} — ${labels.warningsNumberingInvalidStartAtDetail}`; + case 'pagebreakInvalidParity': + return `parity="${payload.value}" — ${labels.warningsPagebreakInvalidParityDetail}`; + case 'headingBreakInvalidParity': + return `H${payload.level} parity="${payload.value}" — ${labels.warningsPagebreakInvalidParityDetail}`; + case 'parityCascade': + return `${payload.runLength} — ${labels.warningsParityCascadeDetail}`; + case 'alphaPdfOverflow': + return labels.warningsAlphaPdfOverflowDetail; } } diff --git a/packages/postext-sandbox/src/sidebar/sections/HeadingsSection/HeadingLevelSection.tsx b/packages/postext-sandbox/src/sidebar/sections/HeadingsSection/HeadingLevelSection.tsx index d86b99f..ac45dd9 100644 --- a/packages/postext-sandbox/src/sidebar/sections/HeadingsSection/HeadingLevelSection.tsx +++ b/packages/postext-sandbox/src/sidebar/sections/HeadingsSection/HeadingLevelSection.tsx @@ -2,13 +2,14 @@ import type { useSandboxLabels } from '../../../context/SandboxContext'; import { DEFAULT_HEADINGS_CONFIG, dimensionsEqual, colorsEqual } from 'postext'; -import type { HeadingLevelConfig, ColorValue, Dimension, DimensionUnit } from 'postext'; +import type { HeadingLevelConfig, HeadingBreakBeforeConfig, HeadingBreakParity, ColorValue, Dimension, DimensionUnit } from 'postext'; import { CollapsibleSection, ColorPicker, DimensionInput, FontPicker, NumberInput, + SelectInput, TextInput, ToggleSwitch, } from '../../../controls'; @@ -34,7 +35,7 @@ export function HeadingLevelSection({ labels, }: { level: number; - resolved: { fontSize: Dimension; lineHeight: Dimension; fontFamily: string; color: ColorValue; fontWeight: number; marginTop: Dimension; marginBottom: Dimension; numberingTemplate: string; italic: boolean }; + resolved: { fontSize: Dimension; lineHeight: Dimension; fontFamily: string; color: ColorValue; fontWeight: number; marginTop: Dimension; marginBottom: Dimension; numberingTemplate: string; italic: boolean; breakBefore: { enabled: boolean; parity: HeadingBreakParity } }; raw: HeadingLevelConfig | undefined; generalFont: string; generalLineHeight: Dimension; @@ -57,6 +58,8 @@ export function HeadingLevelSection({ const isMarginBottomDefault = dimensionsEqual(resolved.marginBottom, generalMarginBottom); const isNumberingDefault = (resolved.numberingTemplate ?? '') === ''; const isItalicDefault = resolved.italic === false; + const isBreakBeforeEnabledDefault = resolved.breakBefore.enabled === false; + const isBreakBeforeParityDefault = resolved.breakBefore.parity === 'any'; const hasOverrides = raw !== undefined && Object.keys(raw).filter((k) => k !== 'level').length > 0; return ( @@ -160,6 +163,33 @@ export function HeadingLevelSection({ isDefault={isNumberingDefault} onReset={() => onReset(level, 'numberingTemplate')} /> + { + const next: HeadingBreakBeforeConfig = { enabled: v }; + if (resolved.breakBefore.parity !== 'any') next.parity = resolved.breakBefore.parity; + onUpdate(level, { breakBefore: next }); + }} + tooltip={labels.headingBreakBeforeTooltip} + isDefault={isBreakBeforeEnabledDefault} + onReset={() => onReset(level, 'breakBefore')} + /> + {resolved.breakBefore.enabled && ( + onUpdate(level, { breakBefore: { enabled: true, parity: v as HeadingBreakParity } })} + tooltip={labels.headingBreakBeforeParityTooltip} + isDefault={isBreakBeforeParityDefault} + onReset={() => onUpdate(level, { breakBefore: { enabled: true } })} + /> + )} ); } diff --git a/packages/postext-sandbox/src/sidebar/sections/PageSection.tsx b/packages/postext-sandbox/src/sidebar/sections/PageSection.tsx index 5785b16..01039de 100644 --- a/packages/postext-sandbox/src/sidebar/sections/PageSection.tsx +++ b/packages/postext-sandbox/src/sidebar/sections/PageSection.tsx @@ -2,8 +2,8 @@ import { memo } from 'react'; import { useSandboxDispatch, useSandboxLabels, useSandboxSelector } from '../../context/SandboxContext'; -import { resolvePageConfig, PAGE_SIZE_PRESETS, DEFAULT_PAGE_CONFIG, DEFAULT_CUT_LINES, dimensionsEqual, colorsEqual } from 'postext'; -import type { PageConfig, PageSizePreset, Dimension } from 'postext'; +import { resolvePageConfig, PAGE_SIZE_PRESETS, DEFAULT_PAGE_CONFIG, DEFAULT_CUT_LINES, DEFAULT_PAGE_NUMBERING, dimensionsEqual, colorsEqual } from 'postext'; +import type { PageConfig, PageNumberingConfig, PageSizePreset, PageNumberFormat, Dimension } from 'postext'; import { CollapsibleSection, ColorPicker, @@ -69,6 +69,24 @@ export const PageSection = memo(function PageSection() { } }; + const updatePageNumbering = (partial: Partial) => { + updatePage({ pageNumbering: { ...raw?.pageNumbering, ...partial } }); + }; + + const resetPageNumberingField = (field: keyof PageNumberingConfig) => { + if (!raw?.pageNumbering) return; + const next = { ...raw.pageNumbering }; + delete next[field]; + const hasKeys = Object.keys(next).length > 0; + if (hasKeys) { + updatePage({ pageNumbering: next }); + } else { + const r = { ...raw }; + delete r.pageNumbering; + dispatch({ type: 'UPDATE_CONFIG', payload: { page: Object.keys(r).length > 0 ? r : undefined } }); + } + }; + const resetCutLinesField = (field: 'enabled' | 'bleed' | 'markLength' | 'markOffset' | 'markWidth' | 'color') => { if (!raw?.cutLines || typeof raw.cutLines === 'boolean') return; const next = { ...raw.cutLines }; @@ -120,6 +138,16 @@ export const PageSection = memo(function PageSection() { const isCutLinesMarkOffsetDefault = dimensionsEqual(page.cutLines.markOffset, DEFAULT_CUT_LINES.markOffset); const isCutLinesMarkWidthDefault = dimensionsEqual(page.cutLines.markWidth, DEFAULT_CUT_LINES.markWidth); const isCutLinesColorDefault = colorsEqual(page.cutLines.color, DEFAULT_CUT_LINES.color); + const isNumberingFormatDefault = page.pageNumbering.format === DEFAULT_PAGE_NUMBERING.format; + const isNumberingStartAtDefault = page.pageNumbering.startAt === DEFAULT_PAGE_NUMBERING.startAt; + + const PAGE_NUMBER_FORMAT_OPTIONS = [ + { value: 'decimal', label: labels.pageNumberingFormatDecimal }, + { value: 'lower-roman', label: labels.pageNumberingFormatLowerRoman }, + { value: 'upper-roman', label: labels.pageNumberingFormatUpperRoman }, + { value: 'lower-alpha', label: labels.pageNumberingFormatLowerAlpha }, + { value: 'upper-alpha', label: labels.pageNumberingFormatUpperAlpha }, + ]; return ( )} + + updatePageNumbering({ format: v as PageNumberFormat })} + tooltip={labels.pageNumberingFormatTooltip} + isDefault={isNumberingFormatDefault} + onReset={() => resetPageNumberingField('format')} + /> + updatePageNumbering({ startAt: v })} + min={1} + step={1} + tooltip={labels.pageNumberingStartAtTooltip} + isDefault={isNumberingStartAtDefault} + onReset={() => resetPageNumberingField('startAt')} + /> + + ); }); diff --git a/packages/postext-sandbox/src/types/defaultLabels.ts b/packages/postext-sandbox/src/types/defaultLabels.ts index 9b5cd7f..501341f 100644 --- a/packages/postext-sandbox/src/types/defaultLabels.ts +++ b/packages/postext-sandbox/src/types/defaultLabels.ts @@ -58,6 +58,17 @@ export const DEFAULT_LABELS: SandboxLabels = { 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', @@ -99,6 +110,18 @@ export const DEFAULT_LABELS: SandboxLabels = { 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', @@ -235,6 +258,13 @@ export const DEFAULT_LABELS: SandboxLabels = { 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)', unorderedLists: 'Unordered Lists', unorderedListsFont: 'Bullet Font', unorderedListsFontTooltip: 'Default font family used to render list bullets (inherits from body text when unset)', diff --git a/packages/postext-sandbox/src/types/labels.ts b/packages/postext-sandbox/src/types/labels.ts index a084646..e0866d5 100644 --- a/packages/postext-sandbox/src/types/labels.ts +++ b/packages/postext-sandbox/src/types/labels.ts @@ -68,6 +68,19 @@ export interface SandboxLabels { baselineGridLineWidth: string; baselineGridLineWidthTooltip: string; + // Page numbering (inside Page section) + pageNumbering: string; + pageNumberingTooltip: string; + pageNumberingFormat: string; + pageNumberingFormatTooltip: string; + pageNumberingFormatDecimal: string; + pageNumberingFormatLowerRoman: string; + pageNumberingFormatUpperRoman: string; + pageNumberingFormatLowerAlpha: string; + pageNumberingFormatUpperAlpha: string; + pageNumberingStartAt: string; + pageNumberingStartAtTooltip: string; + // Debug section debug: string; debugCursorSync: string; @@ -111,6 +124,18 @@ export interface SandboxLabels { warningsConsecutiveHeadingsDetail: string; warningsListAfterHeadingTitle: string; warningsListAfterHeadingDetail: string; + warningsUnknownDirectiveTitle: string; + warningsUnknownDirectiveDetail: string; + warningsNumberingInvalidFormatTitle: string; + warningsNumberingInvalidFormatDetail: string; + warningsNumberingInvalidStartAtTitle: string; + warningsNumberingInvalidStartAtDetail: string; + warningsPagebreakInvalidParityTitle: string; + warningsPagebreakInvalidParityDetail: string; + warningsParityCascadeTitle: string; + warningsParityCascadeDetail: string; + warningsAlphaPdfOverflowTitle: string; + warningsAlphaPdfOverflowDetail: string; warningsInvalidMathTitle?: string; warningsUnclosedMathTitle?: string; @@ -252,6 +277,13 @@ export interface SandboxLabels { headingNumberingTemplatePlaceholder: string; headingItalic: string; headingItalicTooltip: string; + headingBreakBefore: string; + headingBreakBeforeTooltip: string; + headingBreakBeforeParity: string; + headingBreakBeforeParityTooltip: string; + headingBreakBeforeParityAny: string; + headingBreakBeforeParityOdd: string; + headingBreakBeforeParityEven: string; // Unordered lists section unorderedLists: string; diff --git a/packages/postext-sandbox/src/warnings/compute.ts b/packages/postext-sandbox/src/warnings/compute.ts index 1b463e6..4e0b22b 100644 --- a/packages/postext-sandbox/src/warnings/compute.ts +++ b/packages/postext-sandbox/src/warnings/compute.ts @@ -8,6 +8,7 @@ import { parseMarkdownWithIssues, resolveDebugConfig, resolveHeaderFooterConfig, + resolveHeadingsConfig, collectPlaceholderNames, isKnownPlaceholder, isMetadataPlaceholder, @@ -115,6 +116,142 @@ function collectConsecutiveHeadingsWarnings(blocks: ContentBlock[], markdown: st return out; } +const DIRECTIVE_RE = /^:::\s*([a-z][a-z0-9-]*)\b/; +const ALLOWED_PAGE_FORMATS = new Set([ + 'decimal', + 'lower-roman', + 'upper-roman', + 'lower-alpha', + 'upper-alpha', +]); + +function collectDirectiveWarnings( + markdown: string, + blocks: ContentBlock[], +): Warning[] { + const out: Warning[] = []; + let idx = 0; + + // 1. Unknown directive-looking lines that didn't parse as a directive + // block. We scan the markdown for `:::name` lines and flag those whose + // `name` isn't recognized. + const rawLines = markdown.split('\n'); + let offset = 0; + for (const rawLine of rawLines) { + const lineLen = rawLine.length; + const m = rawLine.trim().match(DIRECTIVE_RE); + if (m) { + const name = m[1]!; + if (name !== 'pagebreak' && name !== 'numbering') { + out.push({ + id: `directive-unknown-${idx++}-${offset}`, + payload: { kind: 'unknownDirective', name }, + sourceStart: offset, + sourceEnd: offset + lineLen, + line: lineNumberForOffset(markdown, offset), + }); + } + } + offset += lineLen + 1; // +1 for '\n' + } + + // 2. Attribute-level validation on parsed directive blocks. + for (const b of blocks) { + if (b.type !== 'directive' || !b.directiveAttrs) continue; + const attrs = b.directiveAttrs; + if (b.directiveName === 'numbering') { + if (attrs.format !== undefined && !ALLOWED_PAGE_FORMATS.has(attrs.format)) { + out.push({ + id: `numbering-format-${idx++}-${b.sourceStart}`, + payload: { kind: 'numberingInvalidFormat', value: attrs.format }, + sourceStart: b.sourceStart, + sourceEnd: b.sourceEnd, + line: lineNumberForOffset(markdown, b.sourceStart), + }); + } + if (attrs.startAt !== undefined) { + const n = Number(attrs.startAt); + if (!Number.isInteger(n) || n < 1) { + out.push({ + id: `numbering-startat-${idx++}-${b.sourceStart}`, + payload: { kind: 'numberingInvalidStartAt', value: attrs.startAt }, + sourceStart: b.sourceStart, + sourceEnd: b.sourceEnd, + line: lineNumberForOffset(markdown, b.sourceStart), + }); + } + } + } else if (b.directiveName === 'pagebreak') { + if (attrs.parity !== undefined && attrs.parity !== 'odd' && attrs.parity !== 'even') { + out.push({ + id: `pagebreak-parity-${idx++}-${b.sourceStart}`, + payload: { kind: 'pagebreakInvalidParity', value: attrs.parity }, + sourceStart: b.sourceStart, + sourceEnd: b.sourceEnd, + line: lineNumberForOffset(markdown, b.sourceStart), + }); + } + } + } + return out; +} + +function collectHeadingBreakParityWarnings(config: PostextConfig): Warning[] { + const out: Warning[] = []; + const headings = resolveHeadingsConfig(config.headings); + for (const lvl of headings.levels) { + const parity = lvl.breakBefore.parity; + if (parity !== 'any' && parity !== 'odd' && parity !== 'even') { + out.push({ + id: `h-break-parity-${lvl.level}`, + payload: { kind: 'headingBreakInvalidParity', level: lvl.level, value: String(parity) }, + }); + } + } + return out; +} + +function collectParityCascadeWarnings(doc: VDTDocument | null): Warning[] { + if (!doc) return []; + const out: Warning[] = []; + let run = 0; + let runStartIndex = -1; + let idx = 0; + for (let i = 0; i < doc.pages.length; i++) { + if (doc.pages[i]!.blankForParity) { + if (run === 0) runStartIndex = i; + run++; + } else { + if (run > 2) { + out.push({ + id: `parity-cascade-${idx++}-${runStartIndex}`, + payload: { kind: 'parityCascade', runLength: run }, + }); + } + run = 0; + } + } + if (run > 2) { + out.push({ + id: `parity-cascade-${idx++}-${runStartIndex}`, + payload: { kind: 'parityCascade', runLength: run }, + }); + } + return out; +} + +function collectAlphaOverflowWarnings(doc: VDTDocument | null): Warning[] { + if (!doc) return []; + const hasOverflow = doc.pages.some( + (p) => + (p.pageNumberFormat === 'upper-alpha' || p.pageNumberFormat === 'lower-alpha') + && p.pageNumberValue > 26, + ); + return hasOverflow + ? [{ id: 'alpha-pdf-overflow', payload: { kind: 'alphaPdfOverflow' } }] + : []; +} + function collectListAfterHeadingWarnings(blocks: ContentBlock[], markdown: string): Warning[] { const out: Warning[] = []; let idx = 0; @@ -238,6 +375,10 @@ export function computeWarnings(params: { } warnings.push(...collectHeaderFooterWarnings(config, doc)); + warnings.push(...collectDirectiveWarnings(markdown, blocks)); + warnings.push(...collectHeadingBreakParityWarnings(config)); + warnings.push(...collectParityCascadeWarnings(doc)); + warnings.push(...collectAlphaOverflowWarnings(doc)); return warnings; } diff --git a/packages/postext-sandbox/src/warnings/types.ts b/packages/postext-sandbox/src/warnings/types.ts index cf3fa64..2c69db5 100644 --- a/packages/postext-sandbox/src/warnings/types.ts +++ b/packages/postext-sandbox/src/warnings/types.ts @@ -10,7 +10,14 @@ export type WarningKind = | 'invalidMath' | 'unclosedMath' | 'headerFooterUnknownPlaceholder' - | 'headerFooterMetadataMissing'; + | 'headerFooterMetadataMissing' + | 'unknownDirective' + | 'numberingInvalidFormat' + | 'numberingInvalidStartAt' + | 'pagebreakInvalidParity' + | 'headingBreakInvalidParity' + | 'parityCascade' + | 'alphaPdfOverflow'; export type WarningPayload = | { kind: 'missingFont'; family: string } @@ -50,7 +57,14 @@ export type WarningPayload = slot: 'header' | 'footer'; elementIndex: number; name: string; - }; + } + | { kind: 'unknownDirective'; name: string } + | { kind: 'numberingInvalidFormat'; value: string } + | { kind: 'numberingInvalidStartAt'; value: string } + | { kind: 'pagebreakInvalidParity'; value: string } + | { kind: 'headingBreakInvalidParity'; level: number; value: string } + | { kind: 'parityCascade'; runLength: number } + | { kind: 'alphaPdfOverflow' }; export interface Warning { id: string; diff --git a/packages/postext/src/__tests__/directives.test.ts b/packages/postext/src/__tests__/directives.test.ts new file mode 100644 index 0000000..cc67df7 --- /dev/null +++ b/packages/postext/src/__tests__/directives.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { parseMarkdown, parseDirectiveAttrs } from '../parse/blockParser'; + +describe('parseDirectiveAttrs', () => { + it('parses double-quoted values', () => { + expect(parseDirectiveAttrs('format="decimal"')).toEqual({ format: 'decimal' }); + }); + it('parses single-quoted values', () => { + expect(parseDirectiveAttrs("format='lower-roman'")).toEqual({ format: 'lower-roman' }); + }); + it('parses bare values', () => { + expect(parseDirectiveAttrs('startAt=1')).toEqual({ startAt: '1' }); + }); + it('parses multiple attributes', () => { + expect(parseDirectiveAttrs('format="decimal" startAt=17')).toEqual({ + format: 'decimal', + startAt: '17', + }); + }); + it('parses bare flag as empty string', () => { + const out = parseDirectiveAttrs('flag'); + expect(out.flag).toBe(''); + }); +}); + +describe('parseMarkdown directives', () => { + it('recognizes :::pagebreak', () => { + const blocks = parseMarkdown(':::pagebreak\n'); + expect(blocks).toHaveLength(1); + expect(blocks[0]!.type).toBe('directive'); + expect(blocks[0]!.directiveName).toBe('pagebreak'); + expect(blocks[0]!.directiveAttrs).toEqual({}); + }); + + it('recognizes :::pagebreak{parity="odd"}', () => { + const blocks = parseMarkdown(':::pagebreak{parity="odd"}\n'); + expect(blocks[0]!.directiveName).toBe('pagebreak'); + expect(blocks[0]!.directiveAttrs).toEqual({ parity: 'odd' }); + }); + + it('recognizes :::numbering{format="decimal" startAt=1}', () => { + const blocks = parseMarkdown(':::numbering{format="decimal" startAt=1}\n'); + expect(blocks[0]!.directiveName).toBe('numbering'); + expect(blocks[0]!.directiveAttrs).toEqual({ format: 'decimal', startAt: '1' }); + }); + + it('unknown directive name falls through to paragraph', () => { + const blocks = parseMarkdown(':::nosuch\n'); + expect(blocks).toHaveLength(1); + expect(blocks[0]!.type).toBe('paragraph'); + }); + + it('directive surrounded by content', () => { + const md = 'Before\n\n:::pagebreak\n\nAfter\n'; + const blocks = parseMarkdown(md); + expect(blocks.map((b) => b.type)).toEqual(['paragraph', 'directive', 'paragraph']); + }); +}); diff --git a/packages/postext/src/__tests__/exports.test.ts b/packages/postext/src/__tests__/exports.test.ts index 606899e..4886c86 100644 --- a/packages/postext/src/__tests__/exports.test.ts +++ b/packages/postext/src/__tests__/exports.test.ts @@ -34,6 +34,7 @@ describe("package exports", () => { "extractFrontmatter", "DEFAULT_PAGE_CONFIG", "DEFAULT_CUT_LINES", + "DEFAULT_PAGE_NUMBERING", "PAGE_SIZE_PRESETS", "resolvePageConfig", "DEFAULT_LAYOUT_CONFIG", @@ -94,6 +95,9 @@ describe("package exports", () => { "isMetadataPlaceholder", "parseMarkdownWithIssues", "MATH_PLACEHOLDER", + "buildPageLabels", + "collectPageLabelRuns", + "formatNumeral", "initMathEngine", "isMathReady", "onMathReady", diff --git a/packages/postext/src/__tests__/numbering-page.test.ts b/packages/postext/src/__tests__/numbering-page.test.ts new file mode 100644 index 0000000..2b9f38d --- /dev/null +++ b/packages/postext/src/__tests__/numbering-page.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { buildPageLabels, collectPageLabelRuns } from '../numbering'; + +describe('buildPageLabels', () => { + it('single decimal segment', () => { + const labels = buildPageLabels(3, [ + { startPageIndex: 0, format: 'decimal', startAt: 1 }, + ]); + expect(labels.map((l) => l.label)).toEqual(['1', '2', '3']); + expect(labels.every((l) => l.format === 'decimal')).toBe(true); + expect(labels[0]!.value).toBe(1); + expect(labels[2]!.value).toBe(3); + }); + + it('roman front matter then decimal chapters from 1', () => { + const labels = buildPageLabels(6, [ + { startPageIndex: 0, format: 'lower-roman', startAt: 1 }, + { startPageIndex: 4, format: 'decimal', startAt: 1 }, + ]); + expect(labels.map((l) => l.label)).toEqual(['i', 'ii', 'iii', 'iv', '1', '2']); + expect(labels[3]!.format).toBe('lower-roman'); + expect(labels[4]!.format).toBe('decimal'); + }); + + it('format-only switch continues the counter', () => { + const labels = buildPageLabels(4, [ + { startPageIndex: 0, format: 'decimal', startAt: 1 }, + { startPageIndex: 2, format: 'upper-roman' }, + ]); + // Counter carries over: 1, 2, then III, IV. + expect(labels.map((l) => l.label)).toEqual(['1', '2', 'III', 'IV']); + }); + + it('startAt-only switch resets value but keeps format', () => { + const labels = buildPageLabels(4, [ + { startPageIndex: 0, format: 'decimal', startAt: 1 }, + { startPageIndex: 2, startAt: 17 }, + ]); + expect(labels.map((l) => l.label)).toEqual(['1', '2', '17', '18']); + }); + + it('alpha overflow past Z uses bijective labels', () => { + const labels = buildPageLabels(28, [ + { startPageIndex: 0, format: 'upper-alpha', startAt: 1 }, + ]); + expect(labels[0]!.label).toBe('A'); + expect(labels[25]!.label).toBe('Z'); + expect(labels[26]!.label).toBe('AA'); + expect(labels[27]!.label).toBe('AB'); + }); + + it('empty document returns empty array', () => { + expect(buildPageLabels(0, [])).toEqual([]); + }); + + it('falls back to decimal from 1 when no segment given', () => { + const labels = buildPageLabels(3, []); + expect(labels.map((l) => l.label)).toEqual(['1', '2', '3']); + }); +}); + +describe('collectPageLabelRuns', () => { + it('groups contiguous decimal pages into one run', () => { + const labels = buildPageLabels(4, [ + { startPageIndex: 0, format: 'decimal', startAt: 1 }, + ]); + const runs = collectPageLabelRuns(labels); + expect(runs).toHaveLength(1); + expect(runs[0]!.startPageIndex).toBe(0); + expect(runs[0]!.endPageIndex).toBe(3); + expect(runs[0]!.startAt).toBe(1); + expect(runs[0]!.maxValue).toBe(4); + }); + + it('splits runs on format change', () => { + const labels = buildPageLabels(6, [ + { startPageIndex: 0, format: 'lower-roman', startAt: 1 }, + { startPageIndex: 4, format: 'decimal', startAt: 1 }, + ]); + const runs = collectPageLabelRuns(labels); + expect(runs).toHaveLength(2); + expect(runs[0]!.format).toBe('lower-roman'); + expect(runs[0]!.endPageIndex).toBe(3); + expect(runs[1]!.format).toBe('decimal'); + expect(runs[1]!.startAt).toBe(1); + }); + + it('splits runs on counter reset', () => { + const labels = buildPageLabels(4, [ + { startPageIndex: 0, format: 'decimal', startAt: 1 }, + { startPageIndex: 2, startAt: 17 }, + ]); + const runs = collectPageLabelRuns(labels); + expect(runs).toHaveLength(2); + expect(runs[1]!.startAt).toBe(17); + }); + + it('alpha run past Z tracks maxValue for PDF overflow detection', () => { + const labels = buildPageLabels(28, [ + { startPageIndex: 0, format: 'upper-alpha', startAt: 1 }, + ]); + const runs = collectPageLabelRuns(labels); + expect(runs).toHaveLength(1); + expect(runs[0]!.maxValue).toBe(28); + }); +}); diff --git a/packages/postext/src/defaults/headings.ts b/packages/postext/src/defaults/headings.ts index f0384e0..c1db7ae 100644 --- a/packages/postext/src/defaults/headings.ts +++ b/packages/postext/src/defaults/headings.ts @@ -1,6 +1,16 @@ -import type { HeadingsConfig, HeadingLevelConfig, ResolvedHeadingsConfig, ResolvedHeadingLevelConfig, ColorValue, Dimension } from '../types'; +import type { HeadingsConfig, HeadingLevelConfig, HeadingBreakBeforeConfig, ResolvedHeadingsConfig, ResolvedHeadingLevelConfig, ResolvedHeadingBreakBeforeConfig, ColorValue, Dimension } from '../types'; import { dimensionsEqual, colorsEqual, DEFAULT_MAIN_COLOR } from './shared'; +const DEFAULT_BREAK_BEFORE: ResolvedHeadingBreakBeforeConfig = { enabled: false, parity: 'any' }; + +function resolveBreakBefore(raw?: HeadingBreakBeforeConfig): ResolvedHeadingBreakBeforeConfig { + if (!raw) return { ...DEFAULT_BREAK_BEFORE }; + return { + enabled: raw.enabled ?? DEFAULT_BREAK_BEFORE.enabled, + parity: raw.parity ?? DEFAULT_BREAK_BEFORE.parity, + }; +} + const DEFAULT_HEADING_FONT = 'Open Sans'; const DEFAULT_HEADING_LINE_HEIGHT: Dimension = { value: 1.2, unit: 'em' }; const DEFAULT_HEADING_COLOR: ColorValue = { ...DEFAULT_MAIN_COLOR }; @@ -9,12 +19,12 @@ const DEFAULT_HEADING_MARGIN_TOP: Dimension = { value: 1.5, unit: 'em' }; const DEFAULT_HEADING_MARGIN_BOTTOM: Dimension = { value: 0.5, unit: 'em' }; const DEFAULT_HEADING_LEVELS: ResolvedHeadingLevelConfig[] = [ - { level: 1, fontSize: { value: 18, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false }, - { level: 2, fontSize: { value: 15, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false }, - { level: 3, fontSize: { value: 12, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false }, - { level: 4, fontSize: { value: 10, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false }, - { level: 5, fontSize: { value: 9, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false }, - { level: 6, fontSize: { value: 8, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false }, + { level: 1, fontSize: { value: 18, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false, breakBefore: { ...DEFAULT_BREAK_BEFORE } }, + { level: 2, fontSize: { value: 15, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false, breakBefore: { ...DEFAULT_BREAK_BEFORE } }, + { level: 3, fontSize: { value: 12, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false, breakBefore: { ...DEFAULT_BREAK_BEFORE } }, + { level: 4, fontSize: { value: 10, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false, breakBefore: { ...DEFAULT_BREAK_BEFORE } }, + { level: 5, fontSize: { value: 9, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false, breakBefore: { ...DEFAULT_BREAK_BEFORE } }, + { level: 6, fontSize: { value: 8, unit: 'pt' }, lineHeight: DEFAULT_HEADING_LINE_HEIGHT, fontFamily: DEFAULT_HEADING_FONT, color: DEFAULT_HEADING_COLOR, fontWeight: DEFAULT_HEADING_FONT_WEIGHT, marginTop: DEFAULT_HEADING_MARGIN_TOP, marginBottom: DEFAULT_HEADING_MARGIN_BOTTOM, numberingTemplate: '', italic: false, breakBefore: { ...DEFAULT_BREAK_BEFORE } }, ]; export const DEFAULT_HEADINGS_CONFIG: ResolvedHeadingsConfig = { @@ -54,6 +64,7 @@ export function resolveHeadingsConfig(partial?: HeadingsConfig): ResolvedHeading marginBottom: override?.marginBottom ?? generalMarginBottom, numberingTemplate: override?.numberingTemplate ?? def.numberingTemplate, italic: override?.italic ?? def.italic, + breakBefore: resolveBreakBefore(override?.breakBefore), }; }); @@ -141,6 +152,17 @@ export function stripHeadingsDefaults(headings?: HeadingsConfig): HeadingsConfig entry.italic = level.italic; levelHasOverride = true; } + if (level.breakBefore) { + const enabledOverride = level.breakBefore.enabled !== undefined && level.breakBefore.enabled !== DEFAULT_BREAK_BEFORE.enabled; + const parityOverride = level.breakBefore.parity !== undefined && level.breakBefore.parity !== DEFAULT_BREAK_BEFORE.parity; + if (enabledOverride || parityOverride) { + entry.breakBefore = { + ...(enabledOverride ? { enabled: level.breakBefore.enabled } : {}), + ...(parityOverride ? { parity: level.breakBefore.parity } : {}), + }; + levelHasOverride = true; + } + } if (levelHasOverride) strippedLevels.push(entry); } if (strippedLevels.length > 0) { diff --git a/packages/postext/src/defaults/index.ts b/packages/postext/src/defaults/index.ts index 8f80129..412b51e 100644 --- a/packages/postext/src/defaults/index.ts +++ b/packages/postext/src/defaults/index.ts @@ -12,7 +12,7 @@ import { stripPdfGenerationDefaults } from './pdfGeneration'; import { stripHeaderFooterDefaults } from './headerFooter'; export { dimensionsEqual, colorsEqual, resolveColorValue, applyPaletteToConfig, applyPaletteToResolvedConfig, DEFAULT_COLOR_PALETTE, DEFAULT_MAIN_COLOR, DEFAULT_MAIN_COLOR_ID, DEFAULT_MAIN_COLOR_NAME, DEFAULT_MAIN_COLOR_HEX, cloneDefaultColorPalette, isDefaultColorPalette } from './shared'; -export { PAGE_SIZE_PRESETS, DEFAULT_CUT_LINES, DEFAULT_PAGE_CONFIG, resolvePageConfig, stripPageDefaults } from './page'; +export { PAGE_SIZE_PRESETS, DEFAULT_CUT_LINES, DEFAULT_PAGE_CONFIG, DEFAULT_PAGE_NUMBERING, resolvePageConfig, stripPageDefaults } from './page'; export { DEFAULT_COLUMN_RULE, DEFAULT_LAYOUT_CONFIG, resolveLayoutConfig, stripLayoutDefaults } from './layout'; export { DEFAULT_HYPHENATION_CONFIG, DEFAULT_BODY_TEXT_CONFIG, hyphenationEqual, resolveBodyTextConfig, stripBodyTextDefaults } from './bodyText'; export { DEFAULT_HEADINGS_CONFIG, resolveHeadingsConfig, stripHeadingsDefaults } from './headings'; diff --git a/packages/postext/src/defaults/page.ts b/packages/postext/src/defaults/page.ts index f63853c..1dfd4fc 100644 --- a/packages/postext/src/defaults/page.ts +++ b/packages/postext/src/defaults/page.ts @@ -1,4 +1,4 @@ -import type { PageConfig, ResolvedPageConfig, PageMargins, PageSizePreset, Dimension, CutLinesConfig } from '../types'; +import type { PageConfig, ResolvedPageConfig, ResolvedPageNumberingConfig, PageMargins, PageNumberingConfig, PageSizePreset, Dimension, CutLinesConfig } from '../types'; import { dimensionsEqual, colorsEqual } from './shared'; export const PAGE_SIZE_PRESETS: Record< @@ -27,6 +27,11 @@ export const DEFAULT_CUT_LINES = { color: { hex: '#000000', model: 'hex' } as const, }; +export const DEFAULT_PAGE_NUMBERING: ResolvedPageNumberingConfig = { + format: 'decimal', + startAt: 1, +}; + export const DEFAULT_PAGE_CONFIG: ResolvedPageConfig = { backgroundColor: { hex: 'transparent', model: 'hex' }, sizePreset: '17x24', @@ -36,8 +41,17 @@ export const DEFAULT_PAGE_CONFIG: ResolvedPageConfig = { dpi: 300, cutLines: { ...DEFAULT_CUT_LINES }, baselineGrid: { enabled: false, color: { hex: '#cccccc', model: 'hex' }, lineWidth: { value: 0.5, unit: 'pt' } }, + pageNumbering: { ...DEFAULT_PAGE_NUMBERING }, }; +function resolvePageNumbering(raw?: PageNumberingConfig): ResolvedPageNumberingConfig { + if (!raw) return { ...DEFAULT_PAGE_NUMBERING }; + return { + format: raw.format ?? DEFAULT_PAGE_NUMBERING.format, + startAt: raw.startAt ?? DEFAULT_PAGE_NUMBERING.startAt, + }; +} + function resolveCutLines(raw?: CutLinesConfig | boolean): ResolvedPageConfig['cutLines'] { if (!raw) return { ...DEFAULT_CUT_LINES }; // Backward compat: old saved configs may have cutLines as a plain boolean @@ -77,6 +91,7 @@ export function resolvePageConfig(partial?: PageConfig): ResolvedPageConfig { lineWidth: partial.baselineGrid.lineWidth ?? DEFAULT_PAGE_CONFIG.baselineGrid.lineWidth, } : { ...DEFAULT_PAGE_CONFIG.baselineGrid }, + pageNumbering: resolvePageNumbering(partial.pageNumbering), }; } @@ -139,6 +154,17 @@ export function stripPageDefaults(page?: PageConfig): PageConfig | undefined { hasOverride = true; } } + if (page.pageNumbering) { + const formatOverride = page.pageNumbering.format !== undefined && page.pageNumbering.format !== DEFAULT_PAGE_NUMBERING.format; + const startAtOverride = page.pageNumbering.startAt !== undefined && page.pageNumbering.startAt !== DEFAULT_PAGE_NUMBERING.startAt; + if (formatOverride || startAtOverride) { + result.pageNumbering = { + ...(formatOverride ? { format: page.pageNumbering.format } : {}), + ...(startAtOverride ? { startAt: page.pageNumbering.startAt } : {}), + }; + hasOverride = true; + } + } if (page.baselineGrid) { const enabledOverride = page.baselineGrid.enabled !== undefined && page.baselineGrid.enabled !== DEFAULT_PAGE_CONFIG.baselineGrid.enabled; const colorOverride = page.baselineGrid.color !== undefined && !colorsEqual(page.baselineGrid.color, DEFAULT_PAGE_CONFIG.baselineGrid.color); diff --git a/packages/postext/src/index.ts b/packages/postext/src/index.ts index 76405d9..8aaadea 100644 --- a/packages/postext/src/index.ts +++ b/packages/postext/src/index.ts @@ -12,7 +12,7 @@ export { hyphenateText, setHyphenationLocale } from './hyphenate'; export { parseMarkdown } from './parse'; export { extractFrontmatter } from './frontmatter'; export type { ParsedFrontmatter } from './frontmatter'; -export { DEFAULT_PAGE_CONFIG, DEFAULT_CUT_LINES, PAGE_SIZE_PRESETS, resolvePageConfig, DEFAULT_LAYOUT_CONFIG, DEFAULT_COLUMN_RULE, resolveLayoutConfig, stripLayoutDefaults, DEFAULT_BODY_TEXT_CONFIG, DEFAULT_HYPHENATION_CONFIG, resolveBodyTextConfig, stripBodyTextDefaults, hyphenationEqual, DEFAULT_HEADINGS_CONFIG, resolveHeadingsConfig, stripHeadingsDefaults, DEFAULT_UNORDERED_LISTS_STATIC, resolveUnorderedListsConfig, stripUnorderedListsDefaults, DEFAULT_ORDERED_LISTS_STATIC, resolveOrderedListsConfig, stripOrderedListsDefaults, DEFAULT_MATH_CONFIG, resolveMathConfig, stripMathDefaults, dimensionsEqual, colorsEqual, resolveColorValue, applyPaletteToConfig, applyPaletteToResolvedConfig, DEFAULT_COLOR_PALETTE, DEFAULT_MAIN_COLOR, DEFAULT_MAIN_COLOR_ID, DEFAULT_MAIN_COLOR_NAME, DEFAULT_MAIN_COLOR_HEX, cloneDefaultColorPalette, isDefaultColorPalette, stripPageDefaults, stripConfigDefaults, DEFAULT_DEBUG_CONFIG, resolveDebugConfig, stripDebugDefaults, DEFAULT_HTML_VIEWER_CONFIG, resolveHtmlViewerConfig, stripHtmlViewerDefaults, DEFAULT_PDF_GENERATION_CONFIG, resolvePdfGenerationConfig, stripPdfGenerationDefaults, DEFAULT_HEADER_FOOTER_SLOT, DEFAULT_HEADER_SLOT, DEFAULT_FOOTER_SLOT, DEFAULT_TEXT_ELEMENT, DEFAULT_RULE_ELEMENT, resolveHeaderFooterConfig, stripHeaderFooterDefaults } from './defaults'; +export { DEFAULT_PAGE_CONFIG, DEFAULT_CUT_LINES, DEFAULT_PAGE_NUMBERING, PAGE_SIZE_PRESETS, resolvePageConfig, DEFAULT_LAYOUT_CONFIG, DEFAULT_COLUMN_RULE, resolveLayoutConfig, stripLayoutDefaults, DEFAULT_BODY_TEXT_CONFIG, DEFAULT_HYPHENATION_CONFIG, resolveBodyTextConfig, stripBodyTextDefaults, hyphenationEqual, DEFAULT_HEADINGS_CONFIG, resolveHeadingsConfig, stripHeadingsDefaults, DEFAULT_UNORDERED_LISTS_STATIC, resolveUnorderedListsConfig, stripUnorderedListsDefaults, DEFAULT_ORDERED_LISTS_STATIC, resolveOrderedListsConfig, stripOrderedListsDefaults, DEFAULT_MATH_CONFIG, resolveMathConfig, stripMathDefaults, dimensionsEqual, colorsEqual, resolveColorValue, applyPaletteToConfig, applyPaletteToResolvedConfig, DEFAULT_COLOR_PALETTE, DEFAULT_MAIN_COLOR, DEFAULT_MAIN_COLOR_ID, DEFAULT_MAIN_COLOR_NAME, DEFAULT_MAIN_COLOR_HEX, cloneDefaultColorPalette, isDefaultColorPalette, stripPageDefaults, stripConfigDefaults, DEFAULT_DEBUG_CONFIG, resolveDebugConfig, stripDebugDefaults, DEFAULT_HTML_VIEWER_CONFIG, resolveHtmlViewerConfig, stripHtmlViewerDefaults, DEFAULT_PDF_GENERATION_CONFIG, resolvePdfGenerationConfig, stripPdfGenerationDefaults, DEFAULT_HEADER_FOOTER_SLOT, DEFAULT_HEADER_SLOT, DEFAULT_FOOTER_SLOT, DEFAULT_TEXT_ELEMENT, DEFAULT_RULE_ELEMENT, resolveHeaderFooterConfig, stripHeaderFooterDefaults } from './defaults'; export { resolvePlaceholders, computeChapterTitles, collectPlaceholderNames, isKnownPlaceholder, isMetadataPlaceholder } from './pipeline/placeholders'; export type { PlaceholderContext, PlaceholderResult } from './pipeline/placeholders'; export type { @@ -38,6 +38,12 @@ export type { CutLinesConfig, PageConfig, ResolvedPageConfig, + PageNumberFormat, + PageNumberingConfig, + ResolvedPageNumberingConfig, + HeadingBreakParity, + HeadingBreakBeforeConfig, + ResolvedHeadingBreakBeforeConfig, LayoutType, ColumnRuleConfig, LayoutConfig, @@ -104,7 +110,9 @@ export type { VDTHeaderFooterTextBlock, VDTRuleBlock, } from './vdt'; -export type { ContentBlock, ContentBlockType, InlineSpan, TextSpan, MathSpan, MathMeta, ListKind, ParseIssue } from './parse'; +export type { ContentBlock, ContentBlockType, DirectiveAttrs, DirectiveName, InlineSpan, TextSpan, MathSpan, MathMeta, ListKind, ParseIssue } from './parse'; export { parseMarkdownWithIssues, MATH_PLACEHOLDER } from './parse'; +export { buildPageLabels, collectPageLabelRuns, formatNumeral } from './numbering'; +export type { NumeralStyle, PageNumberSegment, PageLabelInfo, PageLabelRun } from './numbering'; export type { MathRender, MathPath, MathViewBox } from './math/types'; export { initMathEngine, isMathReady, onMathReady, renderMath, placeholderRender, clearMathCache } from './math'; diff --git a/packages/postext/src/numbering.ts b/packages/postext/src/numbering.ts index 936dda3..b30f48c 100644 --- a/packages/postext/src/numbering.ts +++ b/packages/postext/src/numbering.ts @@ -163,6 +163,113 @@ function renderTemplate( export type HeadingTemplates = Partial>; +// --------------------------------------------------------------------------- +// Page-level numbering sequencer +// --------------------------------------------------------------------------- + +/** A switch in the page-numbering sequence. At `startPageIndex`, the + * (optional) `format` and/or (optional) `startAt` take effect. + * A `format`-only segment keeps the counter flowing; a `startAt`-only + * segment resets the counter without changing the format. The first + * segment (typically pushed from `cfg.page.pageNumbering`) must set both + * and have `startPageIndex === 0`. */ +export interface PageNumberSegment { + startPageIndex: number; + format?: NumeralStyle; + startAt?: number; +} + +export interface PageLabelInfo { + value: number; + label: string; + format: NumeralStyle; +} + +/** Assign (format, numeric value, rendered label) to every page. + * + * Segments must be sorted by `startPageIndex`. The first segment is + * treated as the document-wide default and must define both `format` + * and `startAt`. Later segments with an omitted field carry over the + * previous one. */ +export function buildPageLabels( + pageCount: number, + segments: PageNumberSegment[], +): PageLabelInfo[] { + if (pageCount <= 0) return []; + if (segments.length === 0) { + // Defensive default — behave as a plain decimal-from-1 sequence. + return Array.from({ length: pageCount }, (_, i) => ({ + value: i + 1, + label: String(i + 1), + format: 'decimal' as NumeralStyle, + })); + } + const sorted = [...segments].sort((a, b) => a.startPageIndex - b.startPageIndex); + const out: PageLabelInfo[] = new Array(pageCount); + + let curFormat: NumeralStyle = sorted[0]!.format ?? 'decimal'; + let curValue: number = sorted[0]!.startAt ?? 1; + let segIdx = 0; + // Absorb any segments that apply before / at page 0. + while (segIdx < sorted.length && sorted[segIdx]!.startPageIndex <= 0) { + const s = sorted[segIdx]!; + if (s.format !== undefined) curFormat = s.format; + if (s.startAt !== undefined) curValue = s.startAt; + segIdx++; + } + + for (let i = 0; i < pageCount; i++) { + while (segIdx < sorted.length && sorted[segIdx]!.startPageIndex === i) { + const s = sorted[segIdx]!; + if (s.format !== undefined) curFormat = s.format; + if (s.startAt !== undefined) curValue = s.startAt; + segIdx++; + } + const label = formatNumeral(curValue, curFormat); + out[i] = { value: curValue, label, format: curFormat }; + curValue++; + } + return out; +} + +/** One contiguous run of pages sharing `(format, counter)`. Boundary rule: + * a run starts whenever the format differs from the previous page or the + * counter is not exactly `previous + 1`. Feeds the PDF `/PageLabels` + * emitter. `maxValue` is the max `value` seen in the run — used by the + * PDF backend to detect the alpha-overflow (`>26`) case. */ +export interface PageLabelRun { + startPageIndex: number; + endPageIndex: number; + format: NumeralStyle; + startAt: number; + maxValue: number; +} + +export function collectPageLabelRuns(labels: PageLabelInfo[]): PageLabelRun[] { + const runs: PageLabelRun[] = []; + for (let i = 0; i < labels.length; i++) { + const p = labels[i]!; + const prev = runs[runs.length - 1]; + if ( + prev + && prev.format === p.format + && p.value === prev.startAt + (i - prev.startPageIndex) + ) { + prev.endPageIndex = i; + if (p.value > prev.maxValue) prev.maxValue = p.value; + continue; + } + runs.push({ + startPageIndex: i, + endPageIndex: i, + format: p.format, + startAt: p.value, + maxValue: p.value, + }); + } + return runs; +} + export function computeHeadingNumbers( blocks: ContentBlock[], templates: HeadingTemplates, diff --git a/packages/postext/src/parse/blockParser.ts b/packages/postext/src/parse/blockParser.ts index ec7e6c9..8391ec3 100644 --- a/packages/postext/src/parse/blockParser.ts +++ b/packages/postext/src/parse/blockParser.ts @@ -5,12 +5,32 @@ * positions back into the original markdown. */ -import type { ContentBlock, ListKind, ParseIssue } from './types'; +import type { ContentBlock, DirectiveAttrs, DirectiveName, ListKind, ParseIssue } from './types'; import { extractInlineMath, fixMathSourceMap, injectMathSpans } from './inlineMath'; import { parseInlineFormatting, stripInlineFormatting } from './inlineFormatting'; import { buildBlockMapping } from './sourceMapping'; const HEADING_RE = /^(#{1,6})\s+(.+)$/; +/** `:::name` or `:::name{attrs}` on its own line. */ +const DIRECTIVE_RE = /^:::\s*([a-z][a-z0-9-]*)\s*(?:\{([^}]*)\})?\s*$/; +/** Set of directive names recognized today. Unknown names fall through to + * paragraph-parsing and downstream warnings flag them. */ +const KNOWN_DIRECTIVES: ReadonlySet = new Set(['pagebreak', 'numbering']); + +/** Parse the attribute blob inside a `:::name{ ... }` directive. + * Supports `key="quoted"`, `key='quoted'`, `key=bare`, and bare `key`. */ +export function parseDirectiveAttrs(raw: string): DirectiveAttrs { + const out: DirectiveAttrs = {}; + // Token forms: key="..." | key='...' | key=bare | bare + const tokenRe = /([A-Za-z_][A-Za-z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s]+)))?/g; + let m: RegExpExecArray | null; + while ((m = tokenRe.exec(raw)) !== null) { + const key = m[1]!; + const value = m[2] ?? m[3] ?? m[4] ?? ''; + out[key] = value; + } + return out; +} const TASK_ITEM_RE = /^(\s*)([-*+])\s+\[([ xX])\]\s+(.*)$/; const ORDERED_LIST_ITEM_RE = /^(\s*)(\d+)([.)])\s+(.*)$/; const LIST_ITEM_RE = /^(\s*)([-*+])\s+(.*)$/; @@ -146,6 +166,30 @@ export function parseMarkdownWithIssues(markdown: string): { blocks: ContentBloc continue; } + // Directive — `:::name` or `:::name{attrs}` on its own line. Only known + // directive names are promoted to a `directive` block; unknown names + // fall through to paragraph parsing so they remain visible in the + // output (and downstream warnings surface the typo). + const directiveMatch = trimmed.match(DIRECTIVE_RE); + if (directiveMatch && KNOWN_DIRECTIVES.has(directiveMatch[1] as DirectiveName)) { + const name = directiveMatch[1] as DirectiveName; + const attrsRaw = directiveMatch[2] ?? ''; + const srcStart = lineOffsets[i]!; + const srcEnd = lineEndOffset(i); + blocks.push({ + type: 'directive', + text: '', + spans: [], + directiveName: name, + directiveAttrs: parseDirectiveAttrs(attrsRaw), + sourceStart: srcStart, + sourceEnd: srcEnd, + sourceMap: [], + }); + i++; + continue; + } + // Heading const headingMatch = trimmed.match(HEADING_RE); if (headingMatch) { diff --git a/packages/postext/src/parse/index.ts b/packages/postext/src/parse/index.ts index 5e22bd9..e0c6d6c 100644 --- a/packages/postext/src/parse/index.ts +++ b/packages/postext/src/parse/index.ts @@ -1,5 +1,7 @@ export type { ContentBlockType, + DirectiveName, + DirectiveAttrs, MathMeta, InlineSpan, TextSpan, diff --git a/packages/postext/src/parse/types.ts b/packages/postext/src/parse/types.ts index 4943e99..ab751aa 100644 --- a/packages/postext/src/parse/types.ts +++ b/packages/postext/src/parse/types.ts @@ -3,7 +3,17 @@ export type ContentBlockType = | 'paragraph' | 'blockquote' | 'listItem' - | 'mathDisplay'; + | 'mathDisplay' + | 'directive'; + +/** Attributes parsed from a `:::name{key="v" other=bare flag}` directive. + * Values are kept as strings; directive consumers validate/coerce. A bare + * `flag` becomes `{ flag: '' }`. */ +export type DirectiveAttrs = Record; + +/** Recognized directive names. Unknown names are not parsed as directives — + * they fall through to the paragraph branch and surface via warnings. */ +export type DirectiveName = 'pagebreak' | 'numbering'; /** Metadata attached to an `InlineSpan` when it represents a math formula. * The span's `text` is a single `\uFFFC` (object replacement character) @@ -63,6 +73,10 @@ export interface ContentBlock { checked?: boolean; /** TeX source for `mathDisplay` blocks. */ tex?: string; + /** For `directive` blocks: the directive name (e.g. `'pagebreak'`). */ + directiveName?: DirectiveName; + /** For `directive` blocks: parsed attributes. */ + directiveAttrs?: DirectiveAttrs; /** Character offset of the first source character of this block in the original markdown */ sourceStart: number; /** Character offset just past the last source character of this block */ diff --git a/packages/postext/src/pipeline/build.ts b/packages/postext/src/pipeline/build.ts index e880fd8..f7bce3b 100644 --- a/packages/postext/src/pipeline/build.ts +++ b/packages/postext/src/pipeline/build.ts @@ -8,7 +8,13 @@ import { type VDTBlock, } from '../vdt'; import { parseMarkdownMemo } from '../parse'; -import { computeHeadingNumbers, type HeadingTemplates } from '../numbering'; +import { + buildPageLabels, + computeHeadingNumbers, + type HeadingTemplates, + type NumeralStyle, + type PageNumberSegment, +} from '../numbering'; import { extractFrontmatter } from '../frontmatter'; import { initHyphenator } from '../measure'; import type { MeasurementCache } from '../measure'; @@ -25,6 +31,8 @@ import { createPageWithColumns, currentColumn, advanceToNextColumn, + advanceToNextPageBoundary, + enforcePageParity, placeBlockInColumn, } from './placement'; import { chooseParagraphSplit } from './orphanWidow'; @@ -114,9 +122,87 @@ export function buildDocument( let blockIdCounter = 0; let pendingSpacing = 0; + // Page-numbering segments. The implicit first segment comes from + // `cfg.page.pageNumbering`; `:::numbering` directives append more, + // each applied at the next page boundary. + const pageNumberSegments: PageNumberSegment[] = [ + { + startPageIndex: 0, + format: resolved.page.pageNumbering.format, + startAt: resolved.page.pageNumbering.startAt, + }, + ]; + let pendingNumberingChange: + | { format?: NumeralStyle; startAt?: number } + | null = null; + let lastSeenPageIndex = 0; + + /** Commits any pending `:::numbering` change once we've crossed into a + * new page. Called after every block iteration. */ + const flushPendingNumberingAtBoundary = (): void => { + if (cursor.pageIndex > lastSeenPageIndex) { + if (pendingNumberingChange) { + pageNumberSegments.push({ + startPageIndex: cursor.pageIndex, + ...pendingNumberingChange, + }); + pendingNumberingChange = null; + } + lastSeenPageIndex = cursor.pageIndex; + } + }; + + const ALLOWED_PAGE_FORMATS: ReadonlySet = new Set([ + 'decimal', + 'lower-roman', + 'upper-roman', + 'lower-alpha', + 'upper-alpha', + ]); + for (let blockIdx = 0; blockIdx < contentBlocks.length; blockIdx++) { if (options?.shouldCancel?.()) throw new BuildCancelledError(); const rawBlock = contentBlocks[blockIdx]!; + + // --- Directives ---------------------------------------------------- + if (rawBlock.type === 'directive') { + const name = rawBlock.directiveName; + const attrs = rawBlock.directiveAttrs ?? {}; + if (name === 'pagebreak') { + pendingSpacing = 0; + advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx); + const parity = attrs.parity; + if (parity === 'odd' || parity === 'even') { + enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, parity); + } + flushPendingNumberingAtBoundary(); + } else if (name === 'numbering') { + const change: { format?: NumeralStyle; startAt?: number } = {}; + const fmt = attrs.format as NumeralStyle | undefined; + if (fmt && ALLOWED_PAGE_FORMATS.has(fmt)) change.format = fmt; + if (attrs.startAt !== undefined) { + const n = Number(attrs.startAt); + if (Number.isInteger(n) && n >= 1) change.startAt = n; + } + if (Object.keys(change).length > 0) pendingNumberingChange = change; + } + continue; + } + + // --- Heading `breakBefore` ---------------------------------------- + if (rawBlock.type === 'heading' && rawBlock.level) { + const level = resolved.headings.levels.find((l) => l.level === rawBlock.level); + const bb = level?.breakBefore; + if (bb && bb.enabled) { + pendingSpacing = 0; + advanceToNextPageBoundary(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx); + if (bb.parity === 'odd' || bb.parity === 'even') { + enforcePageParity(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx, bb.parity); + } + flushPendingNumberingAtBoundary(); + } + } + const id = `block-${blockIdCounter++}`; const kind = resolveBlockKind(rawBlock, { @@ -553,6 +639,19 @@ export function buildDocument( } break; } + + flushPendingNumberingAtBoundary(); + } + + // Stamp page-number info onto every page (including blank parity pages). + const labels = buildPageLabels(doc.pages.length, pageNumberSegments); + for (let i = 0; i < doc.pages.length; i++) { + const info = labels[i]; + if (!info) continue; + const page = doc.pages[i]!; + page.pageNumberValue = info.value; + page.pageLabel = info.label; + page.pageNumberFormat = info.format; } buildHeadersAndFooters(doc); diff --git a/packages/postext/src/pipeline/placeholders.ts b/packages/postext/src/pipeline/placeholders.ts index 597269b..085424e 100644 --- a/packages/postext/src/pipeline/placeholders.ts +++ b/packages/postext/src/pipeline/placeholders.ts @@ -132,7 +132,7 @@ export function resolvePlaceholders( function resolveName(name: string, ctx: PlaceholderContext): string { switch (name) { case 'pageNumber': - return String(ctx.page.index + 1); + return ctx.page.pageLabel; case 'totalPages': return String(ctx.allPages.length); case 'title': diff --git a/packages/postext/src/pipeline/placement.ts b/packages/postext/src/pipeline/placement.ts index ad2132d..aeaae8e 100644 --- a/packages/postext/src/pipeline/placement.ts +++ b/packages/postext/src/pipeline/placement.ts @@ -10,6 +10,7 @@ import { type VDTPage, type VDTColumn, } from '../vdt'; +import type { HeadingBreakParity } from '../types'; import { computeColumnBboxes } from './config'; export interface PlacementCursor { @@ -73,6 +74,56 @@ export function advanceToNextColumn( } } +/** Advance `cursor` forward until it points to the first column of a new + * page. No-op when the current page is empty (nothing placed yet) — avoids + * emitting a blank leading page when a `:::pagebreak` directive lands + * before any content. */ +export function advanceToNextPageBoundary( + doc: VDTDocument, + cursor: PlacementCursor, + resolved: ResolvedConfig, + contentArea: BoundingBox, + pageWidthPx: number, + pageHeightPx: number, +): void { + const curPage = doc.pages[cursor.pageIndex]!; + const curPageEmpty = curPage.columns.every((c) => c.blocks.length === 0); + if (curPageEmpty) return; + const startPageIndex = cursor.pageIndex; + do { + advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx); + } while (cursor.pageIndex === startPageIndex); +} + +/** After a forced page break, make sure the page we're sitting on has the + * requested parity. If not, flag the current page as a blank-for-parity + * placeholder and advance to the next page. Repeats until parity matches + * (at most one extra page in practice). Parity uses `pageIndex + 1` + * (page 1 = odd) — the same convention as header/footer parity. */ +export function enforcePageParity( + doc: VDTDocument, + cursor: PlacementCursor, + resolved: ResolvedConfig, + contentArea: BoundingBox, + pageWidthPx: number, + pageHeightPx: number, + parity: HeadingBreakParity, +): void { + if (parity === 'any') return; + while (true) { + const curPage = doc.pages[cursor.pageIndex]!; + const pageNumber = curPage.index + 1; + const isOdd = pageNumber % 2 === 1; + const ok = parity === 'odd' ? isOdd : !isOdd; + if (ok) return; + curPage.blankForParity = true; + const startPageIndex = cursor.pageIndex; + do { + advanceToNextColumn(doc, cursor, resolved, contentArea, pageWidthPx, pageHeightPx); + } while (cursor.pageIndex === startPageIndex); + } +} + export function placeBlockInColumn( block: VDTBlock, blockHeight: number, diff --git a/packages/postext/src/types.ts b/packages/postext/src/types.ts index ccf25fa..29a1921 100644 --- a/packages/postext/src/types.ts +++ b/packages/postext/src/types.ts @@ -130,6 +130,27 @@ export interface CutLinesConfig { color?: ColorValue; } +export type PageNumberFormat = + | 'decimal' + | 'lower-roman' + | 'upper-roman' + | 'lower-alpha' + | 'upper-alpha'; + +export interface PageNumberingConfig { + /** Format for page labels. Default: `'decimal'`. */ + format?: PageNumberFormat; + /** Numeric starting value assigned to the first page of the document, + * regardless of format. `format: 'lower-roman', startAt: 1` yields + * `i, ii, iii, ...`. Default: 1. */ + startAt?: number; +} + +export interface ResolvedPageNumberingConfig { + format: PageNumberFormat; + startAt: number; +} + export interface PageConfig { backgroundColor?: ColorValue; sizePreset?: PageSizePreset; @@ -139,6 +160,7 @@ export interface PageConfig { dpi?: number; cutLines?: CutLinesConfig; baselineGrid?: BaselineGridConfig; + pageNumbering?: PageNumberingConfig; } export interface ResolvedPageConfig { @@ -150,6 +172,7 @@ export interface ResolvedPageConfig { dpi: number; cutLines: { enabled: boolean; bleed: Dimension; markLength: Dimension; markOffset: Dimension; markWidth: Dimension; color: ColorValue }; baselineGrid: { enabled: boolean; color: ColorValue; lineWidth: Dimension }; + pageNumbering: ResolvedPageNumberingConfig; } export type LayoutType = 'single' | 'double' | 'oneAndHalf'; @@ -305,6 +328,22 @@ export interface ResolvedBodyTextConfig { keepColonWithList: boolean; } +export type HeadingBreakParity = 'any' | 'odd' | 'even'; + +export interface HeadingBreakBeforeConfig { + /** When true, force a page break before every heading of this level. */ + enabled?: boolean; + /** Optional parity constraint on the page the heading opens on. + * `'odd'` / `'even'` inserts a blank padding page when needed. Default: + * `'any'`. */ + parity?: HeadingBreakParity; +} + +export interface ResolvedHeadingBreakBeforeConfig { + enabled: boolean; + parity: HeadingBreakParity; +} + export interface HeadingLevelConfig { level: number; fontSize?: Dimension; @@ -316,6 +355,7 @@ export interface HeadingLevelConfig { marginBottom?: Dimension; numberingTemplate?: string; italic?: boolean; + breakBefore?: HeadingBreakBeforeConfig; } export interface ResolvedHeadingLevelConfig { @@ -329,6 +369,7 @@ export interface ResolvedHeadingLevelConfig { marginBottom: Dimension; numberingTemplate: string; italic: boolean; + breakBefore: ResolvedHeadingBreakBeforeConfig; } export interface HeadingsConfig { diff --git a/packages/postext/src/vdt.ts b/packages/postext/src/vdt.ts index d515a40..dd3bbcd 100644 --- a/packages/postext/src/vdt.ts +++ b/packages/postext/src/vdt.ts @@ -11,6 +11,7 @@ import type { ResolvedHeaderFooterSlot, HeaderFooterHAlign, } from './types'; +import type { NumeralStyle } from './numbering'; import type { MathRender } from './math/types'; // --------------------------------------------------------------------------- @@ -187,6 +188,19 @@ export interface VDTPage { footer?: VDTHeaderFooterSlot; marginNotes: VDTBlock[]; footnoteArea?: VDTFootnoteArea; + /** Numeric counter for this page from the active page-numbering sequence. + * Always set after placement — defaults to `index + 1` when no explicit + * numbering config applies. */ + pageNumberValue: number; + /** Rendered label for `pageNumberValue` using `pageNumberFormat` + * (e.g. `'iv'`, `'1'`, `'A'`). */ + pageLabel: string; + /** Format active at this page. */ + pageNumberFormat: NumeralStyle; + /** Marks pages inserted purely to satisfy a parity constraint + * (`:::pagebreak{parity=...}` or heading `breakBefore.parity`). + * They render empty body content but still consume a page number. */ + blankForParity?: boolean; } export interface VDTDocument { @@ -241,6 +255,9 @@ export function createVDTPage( height, columns: [], marginNotes: [], + pageNumberValue: index + 1, + pageLabel: String(index + 1), + pageNumberFormat: 'decimal', }; } From 942b910a2fac1cc31f6d37c5b97b0890ae4818ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ignacio=20Ferro=20Pic=C3=B3n?= Date: Thu, 23 Apr 2026 09:50:29 +0200 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20agregar=20directivas=20de=20salto?= =?UTF-8?q?=20y=20numeraci=C3=B3n=20de=20p=C3=A1gina=20con=20sus=20etiquet?= =?UTF-8?q?as=20y=20herramientas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/messages/en.json | 4 ++ apps/web/messages/es.json | 4 ++ .../components/sandbox/SandboxPage/labels.ts | 4 ++ .../src/controls/CollapsibleSection.tsx | 22 ++++++++-- .../src/editor/EditorToolbar.tsx | 41 +++++++++++++++++++ .../src/sidebar/sections/PageSection.tsx | 41 ++++++++++--------- .../src/types/defaultLabels.ts | 4 ++ packages/postext-sandbox/src/types/labels.ts | 4 ++ 8 files changed, 101 insertions(+), 23 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 9053667..dfc917a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -620,6 +620,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 76b2cc5..1756227 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -620,6 +620,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 31755fc..36b8556 100644 --- a/apps/web/src/components/sandbox/SandboxPage/labels.ts +++ b/apps/web/src/components/sandbox/SandboxPage/labels.ts @@ -433,6 +433,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/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 (