fix(export): unify title-injection logic across DOCX/ODT and PDF; remove drift

This commit is contained in:
avi 2026-08-24 22:10:23 -05:00
commit 3afb43d4ed
3 changed files with 57 additions and 29 deletions

View file

@ -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<number, (doc: FolioChapterDoc) => FolioChapterDoc> = {};

View file

@ -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) {

View file

@ -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 {