diff --git a/src/main/native-doc.ts b/src/main/native-doc.ts index 5708ca3..f841655 100644 --- a/src/main/native-doc.ts +++ b/src/main/native-doc.ts @@ -56,6 +56,42 @@ export const CALLOUT_TITLE_CLASS = "callout-title"; // into [[wikilinks]]; the wikilink plugin turns [[text]] into it on parse. export const CHAPTER_LINK_PREFIX = "folio:chapter/"; +// ---- Chapter presentation helpers ---------------------------------------- +// Shared by every assembled/exported rendering of a chapter (Full Book view, +// PDF print, DOCX/ODT) so "does this chapter already carry its own title?" +// has exactly one answer everywhere. Before this lived in two files with +// different rules, DOCX and PDF could disagree about injecting the title. + +/** Concatenated plain text of a node subtree (atom leaves contribute nothing). */ +export function nativeNodeText(node: FolioNode): string { + if (node.text) return node.text; + let t = ""; + for (const c of node.content ?? []) t += nativeNodeText(c); + return t; +} + +/** Whether any top-level heading in the content already matches the title + * (case/space-insensitive). If true, renderers must not inject a duplicate + * title heading ahead of the content. */ +export function nativeHasTitleHeading(content: FolioNode[], title: string): boolean { + const t = title.trim().toLowerCase(); + if (!t) return false; + for (const n of content) { + if (n.type === "heading" && nativeNodeText(n).trim().toLowerCase() === t) return true; + } + return false; +} + +/** Top-level nodes for one chapter, injecting a title heading when the + * content does not already provide one. */ +export function buildChapterNativeContent(content: FolioNode[], title: string): FolioNode[] { + if (nativeHasTitleHeading(content, title)) return content.slice(); + return [ + { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: title }] }, + ...content, + ]; +} + // Each entry migrates the document FROM its key version TO key+1. // Entries are additive and must be non-destructive. const MIGRATIONS: Record FolioChapterDoc> = {}; diff --git a/src/main/office-export.ts b/src/main/office-export.ts index f12d6c3..f6fc2fe 100644 --- a/src/main/office-export.ts +++ b/src/main/office-export.ts @@ -35,6 +35,7 @@ import { CALLOUT_TITLE_CLASS, CHAPTER_LINK_PREFIX, } from "./native-doc.js"; +import { nativeHasTitleHeading } from "./native-doc.js"; import type { FolioMark, FolioNode } from "./native-doc.js"; const MONO_FONT = "Consolas"; @@ -192,14 +193,13 @@ function nodeText(node: FolioNode): string { return t; } -// Whether the chapter's native content already opens with a heading matching -// its title, in which case we must not inject a duplicate title heading. +// Whether to prepend a title heading. Delegates to the shared predicate so +// DOCX/ODT answer identically to the Full Book view and PDF print (the old +// local copy only inspected the FIRST node, so a chapter whose self-title +// heading sat deeper in the document got a duplicate injected here while the +// other paths correctly skipped it). function needsTitleInjection(content: FolioNode[], title: string): boolean { - const t = title.trim().toLowerCase(); - if (!t) return false; - const first = content[0]; - if (first && first.type === "heading" && nodeText(first).trim().toLowerCase() === t) return false; - return true; + return !nativeHasTitleHeading(content, title); } function headingLevel(level: number) { diff --git a/src/renderer/native-assembly.ts b/src/renderer/native-assembly.ts index 8315fb2..324144e 100644 --- a/src/renderer/native-assembly.ts +++ b/src/renderer/native-assembly.ts @@ -5,8 +5,18 @@ // These helpers are kept free of DOM/IPC so they can be unit-tested directly // and reused by the renderer (book.ts). import type { FolioNode } from "../main/native-doc.js"; +import { + nativeNodeText, + nativeHasTitleHeading, + buildChapterNativeContent, +} from "../main/native-doc.js"; import type { WikilinkTarget } from "./wikilinks.js"; +// The title-heading helpers are shared with the DOCX/ODT exporter and live +// next to the schema in native-doc.ts. Re-exported so existing consumers +// (and the fullbook test entry) keep their import paths. +export { nativeNodeText, nativeHasTitleHeading, buildChapterNativeContent }; + export interface FullBookChapterInput { id: string; title: string; @@ -22,33 +32,15 @@ export interface FullBookAssembly { // Concatenate the plain text of a native node tree (used for word/char counts). // Atom leaf nodes (chapterBreak, image, horizontal rule, …) carry no text. -export function nativeNodeText(node: FolioNode): string { - if (node.text) return node.text; - let t = ""; - for (const c of node.content ?? []) t += nativeNodeText(c); - return t; -} +// (Implementation lives in native-doc.ts; re-exported above.) // Whether the native content already opens with a heading matching its title, // in which case we must not inject a duplicate title heading. -export function nativeHasTitleHeading(content: FolioNode[], title: string): boolean { - const t = title.trim().toLowerCase(); - if (!t) return false; - for (const n of content) { - if (n.type === "heading" && nativeNodeText(n).trim().toLowerCase() === t) return true; - } - return false; -} +// (Implementation lives in native-doc.ts; re-exported above.) -// Build the top-level nodes for one chapter in the Full Book, injecting a title +// Top-level nodes for one chapter in the Full Book, injecting a title // heading when the content does not already provide one. -export function buildChapterNativeContent(content: FolioNode[], title: string): FolioNode[] { - if (nativeHasTitleHeading(content, title)) return content.slice(); - return [ - { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: title }] }, - ...content, - ]; -} +// (Implementation lives in native-doc.ts; re-exported above.) // A ChapterBreak anchor marking where a chapter begins in the assembled view. export function chapterBreakNode(id: string, title: string, first: boolean): FolioNode {