Replace markdown editor with native Tiptap editor and add Home navigation
- Migrate renderer from md-editor/md-format to a Tiptap-based native editor (native-doc pipeline: native-doc, markdown-to-native, native-to-markdown, native-assembly, migration, backup) - Use pointer-event-based chapter reordering with grab/grabbing cursors to avoid the Wayland prohibited-drop cursor while preserving reordering - Add a persistent Home button that closes the open book and returns to the Folio library/welcome screen - Update app icons, package deps, theme styles, and tests
This commit is contained in:
parent
0df26d4ce6
commit
786a65ae2a
48 changed files with 5762 additions and 3999 deletions
|
|
@ -1,7 +1,8 @@
|
|||
// Standalone test for the DOCX/ODT Full Book export (office-export.ts).
|
||||
// Bundles the main-process module with esbuild and drives buildDocx/buildOdt
|
||||
// against a throwaway book, then inspects the produced ZIP contents to check
|
||||
// that the manuscript, its chapter order, and its formatting survive.
|
||||
// against a throwaway book whose chapters are stored as native Folio document
|
||||
// JSON, then inspects the produced ZIP contents to check that the manuscript,
|
||||
// its chapter order, and its formatting survive the native -> DOCX path.
|
||||
// Run with: node tests/run-office-export-test.mjs
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
|
|
@ -30,94 +31,161 @@ const check = (name, ok) => {
|
|||
if (!ok) failures += 1;
|
||||
};
|
||||
|
||||
// --- fixture book -----------------------------------------------------------
|
||||
// --- fixture book (native JSON chapters) ------------------------------------
|
||||
const bookDir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-office-test-"));
|
||||
fs.mkdirSync(path.join(bookDir, "chapters"), { recursive: true });
|
||||
const chapters = {};
|
||||
const order = [];
|
||||
const addChapter = (id, title, body) => {
|
||||
const file = path.posix.join("chapters", `${id}.md`);
|
||||
|
||||
// Build a native chapter file ({ schemaVersion, doc }).
|
||||
const addChapter = (id, title, content) => {
|
||||
const file = path.posix.join("chapters", `${id}.json`);
|
||||
order.push(id);
|
||||
chapters[id] = { id, title, file };
|
||||
fs.writeFileSync(path.join(bookDir, file), body);
|
||||
fs.writeFileSync(
|
||||
path.join(bookDir, file),
|
||||
JSON.stringify({ schemaVersion: 1, doc: { type: "doc", content } }, null, 2)
|
||||
);
|
||||
};
|
||||
|
||||
addChapter(
|
||||
"ch1",
|
||||
"Alpha",
|
||||
[
|
||||
"# Alpha",
|
||||
"",
|
||||
"**bold** *italic* ~~strike~~ ==highlight== <u>under</u>",
|
||||
"",
|
||||
'<span style="color:#e11d48">red</span> <span style="background-color:#fde047">marked</span>',
|
||||
"",
|
||||
"- bullet one",
|
||||
"- bullet two",
|
||||
" - nested bullet",
|
||||
"",
|
||||
"1. numbered one",
|
||||
"2. numbered two",
|
||||
"",
|
||||
"> [!note] Callout title",
|
||||
"> callout body",
|
||||
"",
|
||||
"> plain quote",
|
||||
"",
|
||||
"```js",
|
||||
"const answer = 42;",
|
||||
"```",
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"| Head A | Head B |",
|
||||
"| --- | --- |",
|
||||
"| cell 1 | cell 2 |",
|
||||
"",
|
||||
"[Folio](https://example.com)",
|
||||
"",
|
||||
"[[Beta|Go to Beta]]",
|
||||
"",
|
||||
'<div align="center">centered text</div>',
|
||||
].join("\n")
|
||||
// Inline helpers --------------------------------------------------------------
|
||||
const text = (t, marks = []) => ({ type: "text", text: t, marks });
|
||||
const para = (content, attrs = {}) => ({ type: "paragraph", attrs, content });
|
||||
const heading = (level, t) => ({ type: "heading", attrs: { level }, content: [text(t)] });
|
||||
const listItem = (content) => ({ type: "listItem", content });
|
||||
|
||||
addChapter("ch1", "Alpha", [
|
||||
heading(1, "Alpha"),
|
||||
para([
|
||||
text("bold", [{ type: "bold" }]),
|
||||
text(" "),
|
||||
text("italic", [{ type: "italic" }]),
|
||||
text(" "),
|
||||
text("strike", [{ type: "strike" }]),
|
||||
text(" "),
|
||||
text("highlight", [{ type: "highlight" }]),
|
||||
text(" "),
|
||||
text("under", [{ type: "underline" }]),
|
||||
]),
|
||||
para([
|
||||
text("red", [{ type: "textColor", attrs: { color: "#e11d48" } }]),
|
||||
text(" "),
|
||||
text("marked", [{ type: "highlight", attrs: { color: "#fde047" } }]),
|
||||
]),
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
listItem([para([text("bullet one")])]),
|
||||
listItem([
|
||||
para([text("bullet two")]),
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [listItem([para([text("nested bullet")])])],
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "orderedList",
|
||||
content: [
|
||||
listItem([para([text("numbered one")])]),
|
||||
listItem([para([text("numbered two")])]),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "blockquote",
|
||||
attrs: { callout: "note" },
|
||||
content: [
|
||||
para([text("Callout title")], { class: "callout-title" }),
|
||||
para([text("callout body")]),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "blockquote",
|
||||
content: [para([text("plain quote")])],
|
||||
},
|
||||
{ type: "codeBlock", attrs: { language: "js" }, content: [text("const answer = 42;")] },
|
||||
{ type: "horizontalRule" },
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableHeader", content: [para([text("Head A")])] },
|
||||
{ type: "tableHeader", content: [para([text("Head B")])] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "tableRow",
|
||||
content: [
|
||||
{ type: "tableCell", content: [para([text("cell 1")])] },
|
||||
{ type: "tableCell", content: [para([text("cell 2")])] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
para([text("Folio", [{ type: "link", attrs: { href: "https://example.com" } }])]),
|
||||
para([text("Go to Beta", [{ type: "link", attrs: { href: "folio:chapter/Beta" } }])]),
|
||||
para([text("centered text")], { textAlign: "center" }),
|
||||
]);
|
||||
|
||||
addChapter("ch2", "Beta", [para([text("This chapter has no heading of its own.")])]);
|
||||
addChapter("ch3", "Gamma", [
|
||||
heading(1, "Gamma"),
|
||||
heading(2, "Subsection"),
|
||||
para([text("trailing paragraph")]),
|
||||
]);
|
||||
addChapter("ch4", "Empty", []);
|
||||
addChapter("ch5", "Unicode", [para([text("Café — naïve naïve 中文 📖")])]);
|
||||
|
||||
// Real image file so the DOCX embedding path (ImageRun) is exercised: a
|
||||
// relative src must resolve against the book directory and the bytes survive.
|
||||
fs.mkdirSync(path.join(bookDir, "assets"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(bookDir, "assets", "pic.png"),
|
||||
Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64")
|
||||
);
|
||||
addChapter("ch2", "Beta", "This chapter has no heading of its own.");
|
||||
addChapter("ch3", "Gamma", `# Gamma\n\n## Subsection\n\ntrailing paragraph`);
|
||||
addChapter("ch4", "Empty", "");
|
||||
addChapter("ch5", "Unicode", "Café — naïve naïve 中文 📖");
|
||||
addChapter("ch6", "Pics", [{ type: "image", attrs: { src: "assets/pic.png", alt: "a picture" } }]);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(bookDir, "folio.json"),
|
||||
JSON.stringify({ app: "folio", version: 1, title: "Office Test Book", chapterOrder: order, chapters })
|
||||
);
|
||||
|
||||
const origFiles = order.map((id) => chapters[id].file).map((f) => [f, fs.readFileSync(path.join(bookDir, f))]);
|
||||
const origFiles = order
|
||||
.map((id) => chapters[id].file)
|
||||
.map((f) => [f, fs.readFileSync(path.join(bookDir, f))]);
|
||||
const origManifest = fs.readFileSync(path.join(bookDir, "folio.json"));
|
||||
|
||||
const docxBuf = await mod.buildDocx(bookDir);
|
||||
const docxZip = await JSZip.loadAsync(docxBuf);
|
||||
const docxXml = await docxZip.file("word/document.xml").async("string");
|
||||
const docxStyles = await docxZip.file("word/styles.xml").async("string");
|
||||
const docxXml = await docxZip.file("word/document.xml").async("string");
|
||||
const docxStyles = await docxZip.file("word/styles.xml").async("string");
|
||||
|
||||
check("docx is a zip (PK magic)", docxBuf.subarray(0, 2).toString("utf-8") === "PK");
|
||||
check("docx has [Content_Types].xml", !!docxZip.file("[Content_Types].xml"));
|
||||
check("docx has word/document.xml", !!docxZip.file("word/document.xml"));
|
||||
check("docx is a zip (PK magic)", docxBuf.subarray(0, 2).toString("utf-8") === "PK");
|
||||
check("docx has [Content_Types].xml", !!docxZip.file("[Content_Types].xml"));
|
||||
check("docx has word/document.xml", !!docxZip.file("word/document.xml"));
|
||||
|
||||
const stripTags = (xml) => xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
const stripTags = (xml) => xml.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const docxText = stripTags(docxXml);
|
||||
check("docx contains book title", docxText.includes("Office Test Book"));
|
||||
check("docx contains byline", docxText.includes("A book compiled with Folio."));
|
||||
const docxText = stripTags(docxXml);
|
||||
check("docx contains book title", docxText.includes("Office Test Book"));
|
||||
check("docx contains byline", docxText.includes("A book compiled with Folio."));
|
||||
|
||||
const idxAlpha = docxText.indexOf("Alpha");
|
||||
const idxBeta = docxText.indexOf("Beta");
|
||||
const idxGamma = docxText.indexOf("Gamma");
|
||||
const idxUnicode = docxText.indexOf("Unicode");
|
||||
const idxPics = docxText.indexOf("Pics");
|
||||
check(
|
||||
"docx keeps chapter order",
|
||||
idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode
|
||||
idxAlpha >= 0 && idxAlpha < idxBeta && idxBeta < idxGamma && idxGamma < idxUnicode && idxUnicode < idxPics
|
||||
);
|
||||
|
||||
const mediaFiles = Object.keys(docxZip.files).filter((f) => f.startsWith("word/media/"));
|
||||
check("docx embeds the image (no data loss)", mediaFiles.length >= 1);
|
||||
|
||||
check("docx has bold", /<w:b\/>/.test(docxXml));
|
||||
check("docx has italic", /<w:i\/>/.test(docxXml));
|
||||
check("docx has strike", /<w:strike\/>/.test(docxXml));
|
||||
|
|
@ -140,8 +208,8 @@ check("docx has 1.7 line spacing", /<w:spacing[^>]*w:line="408"[^>]*w:lineRule="
|
|||
check("docx has blank-line paragraph spacing", /<w:spacing[^>]*w:after="408"/.test(docxXml));
|
||||
check("docx has chapter break before later chapters", (docxXml.match(/w:before="320"/g) || []).length >= 4);
|
||||
check("docx has callout shading", /w:fill="F5F2ED"/.test(docxXml));
|
||||
check("docx renders ==highlight== (no raw markup)", !docxText.includes("==highlight=="));
|
||||
check("docx renders **bold** (no raw markup)", !docxText.includes("**bold**"));
|
||||
check("docx renders highlight (no raw markup)", !docxText.includes("==highlight=="));
|
||||
check("docx renders bold (no raw markup)", !docxText.includes("**bold**"));
|
||||
check("docx resolves wikilink label", docxText.includes("Go to Beta"));
|
||||
check("docx has no raw wikilink brackets", !docxText.includes("[[Beta"));
|
||||
check("docx headingless chapter gets title", docxText.includes("Beta") && docxText.includes("no heading"));
|
||||
|
|
@ -190,4 +258,4 @@ if (failures > 0) {
|
|||
console.error(`\n${failures} check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nAll office-export checks passed.");
|
||||
console.log("\nAll office-export checks passed.");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue