fix(export): unify title-injection logic across DOCX/ODT and PDF; remove drift
This commit is contained in:
parent
4ce0a5ebfb
commit
3afb43d4ed
3 changed files with 57 additions and 29 deletions
|
|
@ -56,6 +56,42 @@ export const CALLOUT_TITLE_CLASS = "callout-title";
|
||||||
// into [[wikilinks]]; the wikilink plugin turns [[text]] into it on parse.
|
// into [[wikilinks]]; the wikilink plugin turns [[text]] into it on parse.
|
||||||
export const CHAPTER_LINK_PREFIX = "folio:chapter/";
|
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.
|
// Each entry migrates the document FROM its key version TO key+1.
|
||||||
// Entries are additive and must be non-destructive.
|
// Entries are additive and must be non-destructive.
|
||||||
const MIGRATIONS: Record<number, (doc: FolioChapterDoc) => FolioChapterDoc> = {};
|
const MIGRATIONS: Record<number, (doc: FolioChapterDoc) => FolioChapterDoc> = {};
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import {
|
||||||
CALLOUT_TITLE_CLASS,
|
CALLOUT_TITLE_CLASS,
|
||||||
CHAPTER_LINK_PREFIX,
|
CHAPTER_LINK_PREFIX,
|
||||||
} from "./native-doc.js";
|
} from "./native-doc.js";
|
||||||
|
import { nativeHasTitleHeading } from "./native-doc.js";
|
||||||
import type { FolioMark, FolioNode } from "./native-doc.js";
|
import type { FolioMark, FolioNode } from "./native-doc.js";
|
||||||
|
|
||||||
const MONO_FONT = "Consolas";
|
const MONO_FONT = "Consolas";
|
||||||
|
|
@ -192,14 +193,13 @@ function nodeText(node: FolioNode): string {
|
||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether the chapter's native content already opens with a heading matching
|
// Whether to prepend a title heading. Delegates to the shared predicate so
|
||||||
// its title, in which case we must not inject a duplicate title heading.
|
// 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 {
|
function needsTitleInjection(content: FolioNode[], title: string): boolean {
|
||||||
const t = title.trim().toLowerCase();
|
return !nativeHasTitleHeading(content, title);
|
||||||
if (!t) return false;
|
|
||||||
const first = content[0];
|
|
||||||
if (first && first.type === "heading" && nodeText(first).trim().toLowerCase() === t) return false;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function headingLevel(level: number) {
|
function headingLevel(level: number) {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,18 @@
|
||||||
// These helpers are kept free of DOM/IPC so they can be unit-tested directly
|
// These helpers are kept free of DOM/IPC so they can be unit-tested directly
|
||||||
// and reused by the renderer (book.ts).
|
// and reused by the renderer (book.ts).
|
||||||
import type { FolioNode } from "../main/native-doc.js";
|
import type { FolioNode } from "../main/native-doc.js";
|
||||||
|
import {
|
||||||
|
nativeNodeText,
|
||||||
|
nativeHasTitleHeading,
|
||||||
|
buildChapterNativeContent,
|
||||||
|
} from "../main/native-doc.js";
|
||||||
import type { WikilinkTarget } from "./wikilinks.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 {
|
export interface FullBookChapterInput {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -22,33 +32,15 @@ export interface FullBookAssembly {
|
||||||
|
|
||||||
// Concatenate the plain text of a native node tree (used for word/char counts).
|
// Concatenate the plain text of a native node tree (used for word/char counts).
|
||||||
// Atom leaf nodes (chapterBreak, image, horizontal rule, …) carry no text.
|
// Atom leaf nodes (chapterBreak, image, horizontal rule, …) carry no text.
|
||||||
export function nativeNodeText(node: FolioNode): string {
|
// (Implementation lives in native-doc.ts; re-exported above.)
|
||||||
if (node.text) return node.text;
|
|
||||||
let t = "";
|
|
||||||
for (const c of node.content ?? []) t += nativeNodeText(c);
|
|
||||||
return t;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Whether the native content already opens with a heading matching its title,
|
// Whether the native content already opens with a heading matching its title,
|
||||||
// in which case we must not inject a duplicate title heading.
|
// in which case we must not inject a duplicate title heading.
|
||||||
export function nativeHasTitleHeading(content: FolioNode[], title: string): boolean {
|
// (Implementation lives in native-doc.ts; re-exported above.)
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
// heading when the content does not already provide one.
|
||||||
export function buildChapterNativeContent(content: FolioNode[], title: string): FolioNode[] {
|
// (Implementation lives in native-doc.ts; re-exported above.)
|
||||||
if (nativeHasTitleHeading(content, title)) return content.slice();
|
|
||||||
return [
|
|
||||||
{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: title }] },
|
|
||||||
...content,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// A ChapterBreak anchor marking where a chapter begins in the assembled view.
|
// A ChapterBreak anchor marking where a chapter begins in the assembled view.
|
||||||
export function chapterBreakNode(id: string, title: string, first: boolean): FolioNode {
|
export function chapterBreakNode(id: string, title: string, first: boolean): FolioNode {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue