Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/lib/headingNumbers.ts
Original file line number Diff line number Diff line change
@@ -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 !== ''
}
18 changes: 18 additions & 0 deletions src/markdown/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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(' ')
Expand Down Expand Up @@ -123,6 +128,19 @@ function MarkdownRendererImpl({ content, resolvedTheme }: MarkdownRendererProps)
}
return <input {...rest} type={type} />
},
// Headings that already carry a manual number opt out of auto-numbering.
h1({ node, children, ...props }) {
return <h1 {...props} {...selfNumberedAttr(node as HastNode)}>{children as ReactNode}</h1>
},
h2({ node, children, ...props }) {
return <h2 {...props} {...selfNumberedAttr(node as HastNode)}>{children as ReactNode}</h2>
},
h3({ node, children, ...props }) {
return <h3 {...props} {...selfNumberedAttr(node as HastNode)}>{children as ReactNode}</h3>
},
h4({ node, children, ...props }) {
return <h4 {...props} {...selfNumberedAttr(node as HastNode)}>{children as ReactNode}</h4>
},
}
}, [resolvedTheme])

Expand Down
3 changes: 3 additions & 0 deletions src/pdf/buildExportContent.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -73,6 +74,8 @@ function bakeHeadingNumbers(root: HTMLElement): void {
const counters = [0, 0, 0, 0]
root.querySelectorAll<HTMLElement>('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
Expand Down
15 changes: 15 additions & 0 deletions src/styles/document.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions tests/headingNumbers.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading