Skip to content
Merged

Dev #15

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
192 changes: 192 additions & 0 deletions docs/RENDERING_AUDIT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Rendering audit & page-layout features

Written 2026-08-16. Covers the checklist/code/table rendering bugs found from two
editor screenshots, the fixes, the new author-facing layout controls, and what is
still open.

---

## 1 · The bug that started it

Every checklist in the editor was rendering as columns — bold runs in one column,
inline code broken to one character per line in another.

One declaration caused it:

```css
.scripto-doc .task-list-item { display: flex }
```

A flex container promotes **every inline child to a flex item**. So `**bold**`,
`` `code` ``, and each text run became its own column. `word-break: break-word`
on inline code then let those items shrink to their min-content width, which for
a code token is a single character.

Replaced with normal inline flow and a hanging checkbox:

```css
.scripto-doc li.task-list-item { list-style: none }
.scripto-doc .task-list-item input[type='checkbox'] {
font: inherit; /* the HTML export ships no Tailwind preflight */
margin-inline: -1.55em 0.5em; /* hangs into the list's own indent */
}
```

Five further defects fell out of the same rule and were fixed with it:

| Symptom | Why |
| --- | --- |
| Nested checklists rendered sideways | A nested `<ul>` became a sibling flex column |
| Plain bullets lost their markers next to task items | `list-style: none` was on the list, not the item |
| Ordered checklists lost their numbering | A flex item is not a `list-item`, so the counter never advanced |
| Code blocks and quotes sat beside the checkbox | Block children became flex columns |
| Long URLs ran off the page | A flex item will not shrink below min-content |

---

## 2 · Code blocks

### Long lines were clipped

`pre` had `overflow-x: auto`. A scrollbar means nothing on paper — the excess is
simply cut off. Lines now wrap with `white-space: pre-wrap`, and a wrapped
continuation hangs past the line-number gutter so it reads as a continuation
rather than a new statement.

### Tall blocks jumped pages instead of splitting

The cause was not obvious. `overflow-x: auto` made every block a **scroll
container**, and a scroll container is *monolithic* under CSS fragmentation — it
can never be split, so `break-inside` was being ignored regardless of its value.

Removing it let blocks split, but Paged.js then blanks the line straddling the
page edge. Rather than fight the paginator, the export now cuts long listings
into page-sized atomic pieces before pagination:

- `src/pdf/chunkCodeBlocks.ts` — splits at ~10 rendered rows, estimated from the
page width and the `pre` metrics.
- `document.css` `[data-code-chunk]` rules — suppress every interior edge so the
pieces read as one block.

Paged.js therefore only ever places whole blocks, which is the case it handles
correctly. Verified: a 59-line block ends page 2 at line 28 and resumes page 3 at
line 29, with nothing lost.

---

## 3 · Wide tables

Type and padding step down automatically as the column count grows, gated with
`:has()` so it needs no markup from the author:

| Columns | Font | Cell padding |
| --- | --- | --- |
| ≤ 5 | 0.92em | 0.55em 0.85em |
| 6–7 | 0.82em | 0.4em 0.5em |
| 8–9 | 0.74em | 0.3em 0.35em |
| 10+ | stacked — one labelled block per row |

Only long tokens and URLs may break (`overflow-wrap: anywhere` on `code` and `a`
inside cells); ordinary words use `break-word`. Without that split, a long
free-text column starves the rest and headers come out as `Platfor / m`.

Past 10 columns `stackWideTables.ts` copies each header into a `data-label` on the
body cells and CSS renders one labelled block per row — CSS alone cannot reach
across rows to fetch a header.

---

## 4 · New author controls

Three directives, handled by `remarkPageDirectives.ts`:

```markdown
::page-break start the next block on a fresh page

:::keep-together never split this run across a page boundary
| Step | Owner |
| --- | --- |
| Draft | Platform |
:::

:::landscape put this run on its own rotated page
…a table too wide for portrait…
:::
```

`landscape` uses a named `@page` sized from the configured paper, rotated, and
breaks either side of the section.

---

## 5 · Content that still cannot fit

`src/pdf/fitToPage.ts` runs after pagination and scales down anything still wider
than its page — display math, diagrams, ASCII figures, code. Type size is reduced
rather than a transform applied, because that also shrinks the element's box; a
transform would leave the original height behind as a gap. Shrinking only frees
vertical space, so the pages already laid out stay valid.

`renderPagedPreview` now returns `fit: { scaled, clipped }` so the UI can warn
when something was still too wide at the minimum scale.

---

## 6 · Export leaks fixed

- The **Copy** button was being printed into every PDF.
- Every checkbox carried `node="[object Object]"` into the preview DOM, the
standalone HTML export, and the PDF (react-markdown's hast node, spread onto
the element).
- The standalone HTML export linked KaTeX CSS from a CDN. If unreachable,
`.katex-mathml` stops being hidden and every equation renders twice — once
typeset, once as raw MathML. Now inlined, so the export is genuinely
self-contained. The Word export gained the same stylesheet, so math survives
there too.

---

## 7 · Visual regression harness

`tests/visual/` renders fixtures through the **real** pipeline — same stylesheet,
same `buildPageCss`, same export DOM transforms, same paginator — then asserts on
the laid-out result:

- no element extends past the page content box
- the rendered code-line sequence matches the source exactly
- no line renders blank where the source has content

Fixtures: `checklists`, `code-blocks`, `tables`, `structure`, `rtl-arabic`,
`page-directives`. Every bug in this document would have been caught by the first
assertion alone.

### Status: opt-in, not yet gating

```bash
SCRIPTO_VISUAL=1 npm test
```

Headless Chrome's `--dump-dom` races Paged.js's async layout under a virtual time
budget — the run is not yet reliable enough to gate commits on. Two things were
found and fixed along the way and are worth knowing:

- Paged.js **preloads every `@font-face`** before laying out, and stalls silently
— zero pages, no error — if one cannot load. KaTeX's relative font URLs resolve
nowhere from a temp page, so the harness embeds the faces as data URIs.
- `PagedConfig.before` / `.after` are the supported hooks for running transforms
and probing results; `window.PagedPolyfill` does not exist.

**Remaining fix:** drive Chrome over the DevTools Protocol and wait on the
`after` hook, instead of `--dump-dom` plus a virtual time budget.

---

## 8 · Still open

| Item | Note |
| --- | --- |
| Harness reliability | See above — the blocker to making it gate CI |
| Chunk slack | Code chunks are ~10 rows, so a page can end that far short |
| Stacked table rendering | Transform and CSS are in, but not yet visually confirmed end to end |
| Templates | 5 proposed (Arabic/bilingual invoice, incident postmortem, ADR, statement of work, investor update) — not built. With 55 templates and 19 skins already shipped this was judged the lowest-value track |
| Export presets | Named bundles of paper size, margins, skin and font — not started |
7 changes: 5 additions & 2 deletions src/io/exporters.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import documentCss from '@/styles/document.css?inline'
import katexCss from 'katex/dist/katex.min.css?inline'
import { downloadTextFile, escapeHtml } from '@/lib/utils'
import { documentDataAttrs, documentStyleVars } from '@/pdf/documentStyle'
import { FONT_STACKS } from '@/lib/constants'
import type { PdfConfig } from '@/types'

const FONTS_HREF =
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600;8..60,700&family=Lora:wght@400;500;600;700&family=Noto+Naskh+Arabic:wght@400;500;600;700&family=Cairo:wght@400;500;600;700&family=Amiri:wght@400;700&display=swap'
const KATEX_HREF = 'https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css'

/** `<html>` lang/dir attributes for an exported document. */
function htmlLangDir(config: PdfConfig): string {
Expand Down Expand Up @@ -55,8 +55,10 @@ ${author ? `<meta name="author" content="${escapeHtml(author)}" />` : ''}
${keywords ? `<meta name="keywords" content="${escapeHtml(keywords)}" />` : ''}
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="${FONTS_HREF}" rel="stylesheet" />
<link href="${KATEX_HREF}" rel="stylesheet" />
<style>
/* Inlined, not linked: an unreachable CDN would stop hiding .katex-mathml and
every equation would render twice — once typeset, once as raw MathML text. */
${katexCss}
${documentCss}
body { margin: 0; background: #f1f3f7; color: #1f2532; font-family: ${FONT_STACKS[config.font]}; }
.page { max-width: 820px; margin: 0 auto; padding: 48px 24px; }
Expand Down Expand Up @@ -92,6 +94,7 @@ export function exportWord(docElement: HTMLElement, config: PdfConfig, name: str
<meta charset="utf-8" />
<title>${escapeHtml(config.meta.title || 'Document')}</title>
<style>
${katexCss}
${documentCss}
body { font-family: ${FONT_STACKS[config.font]}; }
${config.customCss}
Expand Down
44 changes: 33 additions & 11 deletions src/markdown/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { memo, useMemo, type ComponentPropsWithoutRef, type ReactNode } from 'react'
import { memo, useMemo, type ReactNode } from 'react'
import ReactMarkdown, { defaultUrlTransform, type Components } from 'react-markdown'
import { startsWithManualNumber } from '@/lib/headingNumbers'
import remarkGfm from 'remark-gfm'
Expand All @@ -13,6 +13,7 @@ import rehypePrismPlus from 'rehype-prism-plus'
import { rehypeSourceLine } from './plugins/rehypeSourceLine'
import { rehypeFenceMeta } from './plugins/rehypeFenceMeta'
import { remarkCallouts } from './plugins/remarkCallouts'
import { remarkPageDirectives } from './plugins/remarkPageDirectives'
import { remarkMarks } from './plugins/remarkMarks'
import { CodeBlock } from './components/CodeBlock'
import { Mermaid } from './components/Mermaid'
Expand Down Expand Up @@ -63,6 +64,7 @@ const remarkPlugins = [
remarkMath,
remarkDirective,
remarkCallouts,
remarkPageDirectives,
remarkDefinitionList,
remarkMarks,
remarkGemoji,
Expand Down Expand Up @@ -131,35 +133,55 @@ function MarkdownRendererImpl({ content, resolvedTheme }: MarkdownRendererProps)
a({ href, children }) {
const isExternal = typeof href === 'string' && /^https?:\/\//.test(href)
return (
<a
href={href}
{...(isExternal ? { target: '_blank', rel: 'noreferrer noopener' } : {})}
>
<a href={href} {...(isExternal ? { target: '_blank', rel: 'noreferrer noopener' } : {})}>
{children as ReactNode}
</a>
)
},
img({ src, alt }) {
return <img src={typeof src === 'string' ? src : ''} alt={alt ?? ''} loading="lazy" decoding="async" />
return (
<img
src={typeof src === 'string' ? src : ''}
alt={alt ?? ''}
loading="lazy"
decoding="async"
/>
)
},
input({ type, checked, onChange: _onChange, ...rest }: ComponentPropsWithoutRef<'input'>) {
input({ node: _node, type, checked, onChange: _onChange, ...rest }) {
if (type === 'checkbox') {
return <input {...rest} type="checkbox" checked={Boolean(checked)} readOnly disabled />
}
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>
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>
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>
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>
return (
<h4 {...props} {...selfNumberedAttr(node as HastNode)}>
{children as ReactNode}
</h4>
)
},
}
}, [resolvedTheme])
Expand Down
44 changes: 44 additions & 0 deletions src/markdown/plugins/remarkPageDirectives.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { visit } from 'unist-util-visit'

// Layout control the author can reach for when the automatic rules aren't
// enough:
//
// ::page-break start the next block on a fresh page
// :::keep-together … ::: never split this run across a page boundary
// :::landscape … ::: put this run on its own rotated page
//
// The CSS lives in document.css (`.page-break`, `.keep-together`) and
// pageStyles.ts (the named `landscape` page).

interface DirectiveNode {
type: 'containerDirective' | 'leafDirective' | 'textDirective'
name: string
children: unknown[]
data?: Record<string, unknown>
}

const CONTAINERS: Record<string, string> = {
'keep-together': 'keep-together',
landscape: 'page-landscape',
}

export function remarkPageDirectives() {
return (tree: unknown): void => {
visit(tree as never, (node: DirectiveNode) => {
if (node.type === 'leafDirective' && node.name === 'page-break') {
node.data = {
hName: 'div',
hProperties: { className: ['page-break'], 'aria-hidden': 'true' },
hChildren: [],
}
return
}

if (node.type !== 'containerDirective') return
const className = CONTAINERS[node.name]
if (!className) return

node.data = { hName: 'section', hProperties: { className: [className] } }
})
}
}
2 changes: 2 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 { prepareForPaging } from './prepareForPaging'
import { startsWithManualNumber } from '@/lib/headingNumbers'
import { escapeHtml } from '@/lib/utils'
import type { PdfConfig, TocEntry } from '@/types'
Expand Down Expand Up @@ -103,6 +104,7 @@ export function buildExportContent(

const clone = liveDoc.cloneNode(true) as HTMLElement
namespaceIds(clone)
prepareForPaging(clone, config)
// Number headings as literal text and turn off the CSS counters (data-numbered)
// so they don't double up — Paged.js can't reset baked-in text across pages.
if (config.numberedHeadings) {
Expand Down
Loading
Loading