Skip to content
Open
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
10 changes: 10 additions & 0 deletions make-pdf/src/print-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,16 @@ function inlineRules(): string {
`}`,
`strong { font-weight: 700; }`,
`em { font-style: italic; }`,
// APA-style hanging indent (0.5in) for reference-list entries. Scoped to
// .references so ordinary body paragraphs are untouched. wrapped by
// render.ts's wrapReferencesSection() around the content following an
// <h1>References</h1> heading.
`.references p {`,
` margin: 0 0 11pt;`,
` padding-left: 0.5in;`,
` text-indent: -0.5in;`,
`}`,
`.references p:first-child { margin-top: 0; }`,
].join("\n");
}

Expand Down
98 changes: 87 additions & 11 deletions make-pdf/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,13 @@ export function render(opts: RenderOptions): RenderResult {
// 4. Smartypants (code-safe)
const typographicHtml = smartypants(decoded);

// 4.5. Custom cover extraction (see extractCustomCover doc comment). Must
// run before title derivation so extractFirstHeading scans the REST of
// the document, not any decorative markup inside the custom cover.
const { cover: customCoverHtml, rest: bodyAfterCover } = extractCustomCover(typographicHtml);

// 4. Derive metadata (title from first H1 if not provided)
const derivedTitle = opts.title ?? extractFirstHeading(typographicHtml) ?? "Document";
const derivedTitle = opts.title ?? extractFirstHeading(bodyAfterCover) ?? "Document";
const derivedAuthor = opts.author ?? "";
const derivedDate = opts.date ?? formatToday();

Expand All @@ -113,22 +118,26 @@ export function render(opts: RenderOptions): RenderResult {
const css = printCss(cssOptions);

// 6. Assemble document
const coverBlock = opts.cover
? buildCoverBlock({
title: derivedTitle,
subtitle: opts.subtitle,
author: derivedAuthor,
date: derivedDate,
})
: "";
// A custom cover (extracted above) takes priority over the built-in
// template — they're mutually exclusive, never combined.
const coverBlock = customCoverHtml
? customCoverHtml
: opts.cover
? buildCoverBlock({
title: derivedTitle,
subtitle: opts.subtitle,
author: derivedAuthor,
date: derivedDate,
})
: "";

// TOC anchors must resolve: assign id="toc-N" to each H1-H3 in the same
// order buildTocBlock scans them, or every TOC link is a dead href (masked
// in PDFs by Chromium outline bookmarks, glaring in --to html). Headings
// that already carry an id keep it — the ids array records the ACTUAL id
// per heading so TOC entries always link to something real.
const anchored = opts.toc ? addHeadingIds(typographicHtml) : { html: typographicHtml, ids: [] };
const anchoredHtml = anchored.html;
const anchored = opts.toc ? addHeadingIds(bodyAfterCover) : { html: bodyAfterCover, ids: [] };
const anchoredHtml = wrapReferencesSection(anchored.html);

const tocBlock = opts.toc
? buildTocBlock(anchoredHtml, anchored.ids)
Expand Down Expand Up @@ -368,6 +377,73 @@ function wrapChaptersByH1(html: string): string {
return chunks.join("\n");
}

/**
* Wrap an APA-style "References" section in a `.references` container so
* print CSS can apply a hanging indent to each entry without touching
* ordinary body paragraphs. Detects an <h1> whose text is exactly
* "References" (case-insensitive) and wraps everything from there to the
* next <h1> (or end of document) in <div class="references">...</div>.
* The heading itself stays outside the wrapper so its own styling is
* unaffected. No-op if no such heading exists.
*/
function wrapReferencesSection(html: string): string {
const h1Re = /<h1\b[^>]*>([\s\S]*?)<\/h1>/gi;
let m: RegExpExecArray | null;
let headingStart = -1;
let headingEnd = -1;
while ((m = h1Re.exec(html)) !== null) {
const text = decodeTextEntities(stripTags(m[1])).trim().toLowerCase();
if (text === "references") {
headingStart = m.index;
headingEnd = m.index + m[0].length;
break;
}
}
if (headingStart === -1) return html;

// Find the next H1 after the References heading (end of section), or EOF.
const nextH1Re = /<h1\b[^>]*>/gi;
nextH1Re.lastIndex = headingEnd;
const next = nextH1Re.exec(html);
const sectionEnd = next ? next.index : html.length;

const before = html.slice(0, headingEnd);
const body = html.slice(headingEnd, sectionEnd);
const after = html.slice(sectionEnd);

return `${before}<div class="references">${body}</div>${after}`;
}

/**
* Extract a custom, fully-styled cover section from the very start of the
* document, so it can be slotted into the same position the built-in
* --cover template occupies (before the TOC), instead of being left as
* ordinary body content (which the assembly order in render() places
* AFTER the TOC — see the `coverBlock, tocBlock, chapterHtml` order below).
*
* Detects a top-level `<section class="ai4ld-cover" ...>...</section>`
* as the first non-whitespace content in the document. This is a distinct
* class from the tool's own generated `.cover` (buildCoverBlock) so the two
* mechanisms never collide — a document uses one or the other, never both.
* Callers should NOT also pass --cover when using a custom cover section.
*
* No nested-tag balancing: the custom cover section is expected to be a
* single flat block (divs/spans/hr, no inner <section>), so the first
* </section> after the opening tag is assumed to close it.
*/
function extractCustomCover(html: string): { cover: string; rest: string } {
const trimmed = html.replace(/^\s+/, "");
const openMatch = trimmed.match(/^<section\s+class="ai4ld-cover"[^>]*>/i);
if (!openMatch) return { cover: "", rest: html };
const closeIdx = trimmed.indexOf("</section>", openMatch[0].length);
if (closeIdx === -1) return { cover: "", rest: html };
const closeEnd = closeIdx + "</section>".length;
return {
cover: trimmed.slice(0, closeEnd),
rest: trimmed.slice(closeEnd),
};
}

function extractFirstHeading(html: string): string | null {
const m = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
return m ? decodeTextEntities(stripTags(m[1]).trim()) : null;
Expand Down
17 changes: 16 additions & 1 deletion make-pdf/src/smartypants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,22 @@

const CODE_ZONE_RE = /<(pre|code|script|style)\b[^>]*>[\s\S]*?<\/\1>/gi;
const TAG_RE = /<[^>]+>/g;
const URL_RE = /\bhttps?:\/\/\S+/g;
// BUG FIX (2026-07-20): a bare autolinked URL like <a href="X">X</a> has
// the URL text sitting with zero whitespace before its own closing </a>
// tag. TAG_RE carves that </a> into a "\u0000SMARTPANTS_PRESERVED_N\u0000"
// placeholder BEFORE this pattern runs. The old pattern (\S+, non-
// whitespace-greedy) swallowed that adjacent placeholder into the URL
// match, since \u0000 and the placeholder text are all non-whitespace.
// The outer carve() then stored "https://...\u0000SMARTPANTS_PRESERVED_N\u0000"
// as ONE preserved zone. String.replace() with a global regex does not
// rescan replacement text for further matches, so that inner placeholder
// was never restored -- it leaked through as literal "SMARTPANTS_PRESERVED_N"
// text glued onto the end of the URL, corrupting the link (found in DOI
// reference lists across the ebook PDFs). Excluding \u0000 from the URL
// char class stops the match from ever crossing into an adjacent,
// already-carved zone.
const URL_RE = /\bhttps?:\/\/[^\s\u0000]+/g;


/**
* Apply smartypants to an HTML string. Zones that should not be touched:
Expand Down
103 changes: 100 additions & 3 deletions make-pdf/test/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,30 @@ describe("smartypants", () => {
expect(out).toContain(`href="it's-a-test.html"`);
});

test("does NOT leak SMARTPANTS_PRESERVED placeholders into autolinked URLs", () => {
// Regression test: found 2026-07-20 in AI4LD ebook PDF reference
// lists. A bare autolinked URL (anchor text == href, zero whitespace
// before the closing tag) previously had its </a> placeholder
// swallowed by the URL regex's greedy \S+, leaking raw
// "SMARTPANTS_PRESERVED_N" text into the rendered link.
const input = `<p>See <a href="https://doi.org/10.1016/j.caeai.2026.100637">https://doi.org/10.1016/j.caeai.2026.100637</a> for details.</p>`;
const out = smartypants(input);
expect(out).not.toContain("SMARTPANTS_PRESERVED");
expect(out).toContain(
`<a href="https://doi.org/10.1016/j.caeai.2026.100637">https://doi.org/10.1016/j.caeai.2026.100637</a>`
);
});

test("does NOT leak placeholders when a linked URL is immediately followed by another tag", () => {
// Same bug, different adjacency: URL directly abutting a second tag
// (e.g. two consecutive auto-linked references with no separating
// whitespace) must not bleed a placeholder into either link.
const input = `<p><a href="https://a.example/x">https://a.example/x</a><a href="https://b.example/y">https://b.example/y</a></p>`;
const out = smartypants(input);
expect(out).not.toContain("SMARTPANTS_PRESERVED");
expect(out).toBe(input);
});

test("does NOT convert -- in CLI flags", () => {
// Prose like "try --verbose mode" should not turn -- into em dash
const out = smartypants(`<p>Try --verbose mode.</p>`);
Expand Down Expand Up @@ -157,6 +181,72 @@ describe("render (end-to-end)", () => {
expect(result.html).toContain("\u2014");
});

test("wraps a References section for APA hanging-indent styling", () => {
const result = render({
markdown: `# My Ebook\n\nBody text.\n\n# References\n\nSmith, J. (2020). A paper.\n\nJones, K. (2021). Another paper.\n`,
});
expect(result.html).toMatch(
/<div class="references">[\s\S]*Smith, J\. \(2020\)[\s\S]*Jones, K\. \(2021\)[\s\S]*<\/div>/
);
// The heading itself stays outside the wrapper.
expect(result.html).not.toMatch(/<div class="references">\s*<h1/);
});

test("References wrapping is case-insensitive and a no-op with no such heading", () => {
const lower = render({ markdown: `# Doc\n\nBody.\n\n# references\n\nRef one.\n` });
expect(lower.html).toContain('<div class="references">');

const none = render({ markdown: `# Doc\n\nJust body text, no references section.\n` });
expect(none.html).not.toContain('<div class="references">');
});

test("extracts a custom .ai4ld-cover section and places it before the TOC", () => {
const result = render({
markdown: `<section class="ai4ld-cover" style="background:#05C7F2;">\n <div>My Custom Cover</div>\n</section>\n\n# Real Title\n\nBody.\n`,
toc: true,
});
const coverIdx = result.html.indexOf("My Custom Cover");
const tocIdx = result.html.indexOf('<section class="toc">');
// "Body." (not "Real Title") — the TOC itself legitimately contains a
// link with the text "Real Title", which would make that string a false
// marker for body content; "Body." appears only in the real chapter.
const bodyIdx = result.html.indexOf("Body.");
expect(coverIdx).toBeGreaterThan(-1);
expect(tocIdx).toBeGreaterThan(-1);
expect(bodyIdx).toBeGreaterThan(-1);
// Custom cover must render BEFORE the TOC, which must render before body
// content — the bug this fixes: body content (including a cover
// written as ordinary markdown) always landed AFTER the TOC because
// the assembly order is coverBlock, tocBlock, chapterHtml, and only
// the built-in --cover template populated coverBlock.
expect(coverIdx).toBeLessThan(tocIdx);
expect(tocIdx).toBeLessThan(bodyIdx);
});

test("custom cover title text does not create a duplicate TOC entry", () => {
// The cover title must NOT be an <h1> (that's exactly what caused the
// duplicate-heading bug) — verify a same-titled <div> inside the
// custom cover isn't picked up as a second heading by the TOC.
const result = render({
markdown: `<section class="ai4ld-cover">\n <div>Real Title</div>\n</section>\n\n# Real Title\n\nBody.\n`,
toc: true,
});
const matches = result.html.match(/<a[^>]*>Real Title<\/a>/g) ?? [];
expect(matches.length).toBe(1);
});

test("derives title from the real first H1, not text inside a custom cover", () => {
const result = render({
markdown: `<section class="ai4ld-cover">\n <div>Not The Title</div>\n</section>\n\n# Actual Title\n\nBody.\n`,
});
expect(result.meta.title).toBe("Actual Title");
});

test("no custom cover present is a no-op (ordinary documents unaffected)", () => {
const result = render({ markdown: `# Just a doc\n\nNo cover here.\n` });
expect(result.html).not.toContain("ai4ld-cover");
});

test("derives title from first H1 when --title is not passed", () => {
const result = render({ markdown: `# My Title\n\nBody.` });
expect(result.meta.title).toBe("My Title");
Expand Down Expand Up @@ -236,12 +326,19 @@ describe("render (end-to-end)", () => {
expect(result.html).toContain("Safe");
});

test("respects text-align: left — no justify in print CSS", () => {
test("respects text-align: left — no justify or first-line indent on body paragraphs", () => {
const result = render({ markdown: `para1\n\npara2\n` });
// The rule from the design-review fix: no p + p indent, text-align: left.
// The rule from the design-review fix: no p + p first-line indent, text-align: left.
expect(result.printCss).toContain("text-align: left");
expect(result.printCss).not.toContain("text-align: justify");
expect(result.printCss).not.toContain("text-indent");
// Ordinary paragraphs (the bare `p { ... }` rule) must not carry a
// first-line text-indent. This does NOT ban text-indent everywhere —
// `.references p` intentionally uses a negative text-indent for the
// APA hanging-indent pattern (see the "wraps a References section"
// test above); scope the check to the base rule specifically.
const baseParagraphRule = result.printCss.match(/(?<!\.references )\bp\s*\{[^}]*\}/);
expect(baseParagraphRule?.[0]).toBeDefined();
expect(baseParagraphRule?.[0]).not.toContain("text-indent");
});

test("includes CJK font fallback in body", () => {
Expand Down