diff --git a/src/lib/headingNumbers.ts b/src/lib/headingNumbers.ts new file mode 100644 index 0000000..1255602 --- /dev/null +++ b/src/lib/headingNumbers.ts @@ -0,0 +1,18 @@ +/** + * Detects headings that already carry a manual number in their text + * (`1. Intro`, `3.1 Storage`, `2) Scope`, Arabic-Indic `٣.١ التخزين`). + * Auto-numbering must leave these untouched and not spend a counter on them, + * otherwise documents written with their own scheme render `1.2 1. Intro`. + */ +const MANUAL_NUMBER_RE = /^\s*([0-9٠-٩]+(?:\.[0-9٠-٩]+)*)([.)]?)(\s+|(?=[^\s0-9٠-٩.]))\S/u + +export const startsWithManualNumber = (text: string): boolean => { + const match = MANUAL_NUMBER_RE.exec(text) + if (!match) return false + const [, digits, punctuation, separator] = match + // Punctuated ("1.", "2)", even glued "1.Intro") or dotted-multipart ("3.1") + // counts as a manual number; a bare year-like prefix ("2024 Report") does + // not, and neither does a bare number glued to a word ("3D Rendering"). + if (punctuation !== '') return true + return digits.includes('.') && separator !== '' +} diff --git a/src/markdown/MarkdownRenderer.tsx b/src/markdown/MarkdownRenderer.tsx index 2b98349..f4ff2b9 100644 --- a/src/markdown/MarkdownRenderer.tsx +++ b/src/markdown/MarkdownRenderer.tsx @@ -1,5 +1,6 @@ import { memo, useMemo, type ComponentPropsWithoutRef, type ReactNode } from 'react' import ReactMarkdown, { defaultUrlTransform, type Components } from 'react-markdown' +import { startsWithManualNumber } from '@/lib/headingNumbers' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import remarkDirective from 'remark-directive' @@ -42,6 +43,10 @@ function hastToText(node: HastNode | undefined): string { return node.children.map(hastToText).join('') } +function selfNumberedAttr(node: HastNode | undefined): { 'data-self-numbered'?: '' } { + return startsWithManualNumber(hastToText(node)) ? { 'data-self-numbered': '' } : {} +} + function languageFromClass(className?: string[] | string): string { if (!className) return '' const list = Array.isArray(className) ? className : className.split(' ') @@ -123,6 +128,19 @@ function MarkdownRendererImpl({ content, resolvedTheme }: MarkdownRendererProps) } return }, + // Headings that already carry a manual number opt out of auto-numbering. + h1({ node, children, ...props }) { + return

{children as ReactNode}

+ }, + h2({ node, children, ...props }) { + return

{children as ReactNode}

+ }, + h3({ node, children, ...props }) { + return

{children as ReactNode}

+ }, + h4({ node, children, ...props }) { + return

{children as ReactNode}

+ }, } }, [resolvedTheme]) diff --git a/src/pdf/buildExportContent.ts b/src/pdf/buildExportContent.ts index 68a4737..37aee68 100644 --- a/src/pdf/buildExportContent.ts +++ b/src/pdf/buildExportContent.ts @@ -1,4 +1,5 @@ import { buildCoverHtml, resolveDocDirection } from './pageStyles' +import { startsWithManualNumber } from '@/lib/headingNumbers' import { escapeHtml } from '@/lib/utils' import type { PdfConfig, TocEntry } from '@/types' @@ -73,6 +74,8 @@ function bakeHeadingNumbers(root: HTMLElement): void { const counters = [0, 0, 0, 0] root.querySelectorAll('h1, h2, h3, h4').forEach((h) => { if (h.closest('.footnotes')) return + // Self-numbered headings keep their own number and don't consume a slot. + if (startsWithManualNumber(h.textContent ?? '')) return const level = Number(h.tagName[1]) counters[level - 1] += 1 for (let i = level; i < counters.length; i++) counters[i] = 0 diff --git a/src/styles/document.css b/src/styles/document.css index 2dc60b7..147306f 100644 --- a/src/styles/document.css +++ b/src/styles/document.css @@ -198,6 +198,21 @@ .scripto-doc[data-numbered] .pdf-toc h2::before { content: none; } +/* Headings that carry their own number in the text keep it and are fully + transparent to the auto counters (no increment, no child reset) — matching + bakeHeadingNumbers so preview and PDF always agree. */ +.scripto-doc[data-numbered] h1[data-self-numbered], +.scripto-doc[data-numbered] h2[data-self-numbered], +.scripto-doc[data-numbered] h3[data-self-numbered] { + counter-reset: none; +} +.scripto-doc[data-numbered] h1[data-self-numbered]::before, +.scripto-doc[data-numbered] h2[data-self-numbered]::before, +.scripto-doc[data-numbered] h3[data-self-numbered]::before, +.scripto-doc[data-numbered] h4[data-self-numbered]::before { + content: none; + counter-increment: none; +} /* Baked heading numbers in the PDF export (buildExportContent) — match the accent colour the live-preview CSS counters use. */ .scripto-doc .heading-number { diff --git a/tests/headingNumbers.test.ts b/tests/headingNumbers.test.ts new file mode 100644 index 0000000..c2b876d --- /dev/null +++ b/tests/headingNumbers.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { startsWithManualNumber } from '../src/lib/headingNumbers' + +describe('startsWithManualNumber', () => { + it('detects common manual numbering styles', () => { + expect(startsWithManualNumber('1. Executive Summary')).toBe(true) + expect(startsWithManualNumber('3.1 Compute Instance')).toBe(true) + expect(startsWithManualNumber('10.2.4 Deep Section')).toBe(true) + expect(startsWithManualNumber('2) Scope')).toBe(true) + expect(startsWithManualNumber(' 4. Indented')).toBe(true) + expect(startsWithManualNumber('1.0 Overview')).toBe(true) + }) + + it('detects Arabic-Indic numbering', () => { + expect(startsWithManualNumber('١. الملخص التنفيذي')).toBe(true) + expect(startsWithManualNumber('٣.١ التخزين')).toBe(true) + }) + + it('handles glued and malformed spacing', () => { + expect(startsWithManualNumber('1.Introduction')).toBe(true) + expect(startsWithManualNumber('2)Scope')).toBe(true) + expect(startsWithManualNumber('1.5x Faster')).toBe(false) + }) + + it('leaves ordinary headings alone', () => { + expect(startsWithManualNumber('Executive Summary')).toBe(false) + expect(startsWithManualNumber('2024 Annual Report')).toBe(false) + expect(startsWithManualNumber('Top 10 Tips')).toBe(false) + expect(startsWithManualNumber('3D Rendering')).toBe(false) + expect(startsWithManualNumber('42')).toBe(false) + expect(startsWithManualNumber('')).toBe(false) + }) +})