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)
+ })
+})