diff --git a/docs/RENDERING_AUDIT.md b/docs/RENDERING_AUDIT.md new file mode 100644 index 0000000..5b17386 --- /dev/null +++ b/docs/RENDERING_AUDIT.md @@ -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 `
([\s\S]*?)<\/pre>/)
+ if (!match || !match[1].trim()) {
+ if (process.env.SCRIPTO_VISUAL_DEBUG) {
+ // eslint-disable-next-line no-console
+ console.error(`[visual] page kept at ${page}`)
+ }
+ throw new Error('probe produced no output')
+ }
+
+ const decoded = match[1]
+ .replace(/"/g, '"')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/'/g, "'")
+ .replace(/&/g, '&')
+ const report = JSON.parse(decoded) as PageReport & { error?: string }
+ if (report.error) throw new Error(`pagination failed: ${report.error}`)
+ return report
+}
+
+export const fixtureSource = (fixture: string): string =>
+ readFileSync(`${FIXTURES}/${fixture}.md`, 'utf8')
diff --git a/tests/visual/layout.test.ts b/tests/visual/layout.test.ts
new file mode 100644
index 0000000..0fd0173
--- /dev/null
+++ b/tests/visual/layout.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from 'vitest'
+
+// The renderer's code-block header pulls a label from the i18n context, which
+// needs a React provider we have no use for here — the label is hidden in the
+// PDF anyway and never affects layout.
+vi.mock('@/i18n', () => ({
+ useLanguage: () => ({ t: (key: string) => key, lang: 'en', dir: 'ltr', setLang: () => {} }),
+}))
+
+const { findChrome, fixtureSource, paginate, sourceCodeLines } = await import('./harness')
+type PageReport = Awaited>
+
+/**
+ * Layout regressions are invisible to unit tests: every bug fixed in this
+ * suite's history was a single CSS declaration that broke rendering while every
+ * other test stayed green. These render fixtures through the real export
+ * pipeline and Paged.js, then assert on the laid-out result.
+ *
+ * Needs a Chrome/Chromium binary (CHROME_PATH overrides discovery). Skipped
+ * rather than failed where none exists, so `npm test` still works on a bare box.
+ */
+
+const FIXTURES = [
+ 'checklists',
+ 'code-blocks',
+ 'tables',
+ 'structure',
+ 'rtl-arabic',
+ 'page-directives',
+] as const
+
+// Opt-in: `SCRIPTO_VISUAL=1 npm test`. Headless Chrome's --dump-dom races
+// Paged.js's async layout under a virtual time budget, so the run is not yet
+// reliable enough to gate every commit on. Everything else about the harness
+// works — see docs/VISUAL_REGRESSION.md for the remaining fix.
+const enabled = process.env.SCRIPTO_VISUAL === '1' && findChrome() !== null
+const suite = enabled ? describe : describe.skip
+
+// One pagination run per fixture, shared by every assertion about it.
+const reports = new Map>()
+const report = (fixture: string): Promise => {
+ if (!reports.has(fixture)) reports.set(fixture, paginate(fixture))
+ return reports.get(fixture)!
+}
+
+suite('paginated layout', () => {
+ describe.each(FIXTURES)('%s', (fixture) => {
+ it('lays out at least one page', async () => {
+ expect((await report(fixture)).pages).toBeGreaterThan(0)
+ })
+
+ it('keeps every element inside the page box', async () => {
+ // Anything sticking out horizontally is silently clipped in the PDF —
+ // there is no scrollbar on paper. This is what caught the flex task-list,
+ // the unwrapped code lines, and the overflowing wide tables.
+ const { overflowing } = await report(fixture)
+ expect(overflowing).toEqual([])
+ })
+
+ it('renders every source code line exactly once, in order', async () => {
+ // Paged.js can drop the line straddling a page edge when it fragments a
+ // code block, leaving an empty shell behind. Comparing the rendered run
+ // against the source is the only way to see it.
+ const { codeLines } = await report(fixture)
+ const source = sourceCodeLines(fixtureSource(fixture))
+ expect(codeLines.map((l) => l.text.replace(/\n$/, ''))).toEqual(source)
+ })
+
+ it('never renders a blank line where the source has content', async () => {
+ const { codeLines } = await report(fixture)
+ const source = sourceCodeLines(fixtureSource(fixture))
+ const blanked = codeLines.filter(
+ (rendered, index) => !rendered.text.trim() && (source[index] ?? '').trim(),
+ )
+ expect(blanked).toEqual([])
+ })
+ })
+
+ it('honours ::page-break and :::landscape', async () => {
+ const { pages } = await report('page-directives')
+ // Two forced breaks plus a landscape section that breaks either side.
+ expect(pages).toBeGreaterThanOrEqual(4)
+ })
+}, 240_000)
+
+
diff --git a/vitest.config.ts b/vitest.config.ts
index 9eef0e8..f0d115d 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,8 +1,13 @@
import { defineConfig } from 'vitest/config'
+import path from 'node:path'
export default defineConfig({
+ resolve: { alias: { '@': path.resolve(__dirname, './src') } },
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
+ // Layout tests spawn headless Chrome and paginate real documents.
+ testTimeout: 240_000,
+ hookTimeout: 240_000,
},
})