Files
recipe_tool/lib/editor135-core.ts
hanhan 6e1d2c6728 feat: 新增135编辑器转换子功能
- 新增 /editor135 子页面:上传幼儿园食谱docx → 解析并填充135模板 → 预览/源代码/解析结果三视图
- 新增 /api/editor135/render 解析接口(jsdom 解析填充,逐条回读校验 mismatch)
- 新增 /api/editor135/img 微信CDN图片代理(解决预览防盗链,仅允许 *.qpic.cn)
- 缺失星期自动删除:docx 缺某天(含周末/间隔天)时整段移除该天内容,尾部三审三校/温馨提示保留
- 逐节点精确区间删除算法:处理相邻天共用容器、嵌套结构,不误删保留天
- 新增依赖 jsdom + @types/jsdom;附 6 场景回归测试脚本 scripts/test-remove-days.ts
- 首页 header 增加「135编辑器转换」导航入口
2026-09-08 23:18:53 +08:00

614 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* recipe-core.ts — 尚城幼儿园食谱转换核心库
*
* 职责:
* 1. parseDocxToRecipe: docx(mammoth) → 结构化食谱 JSON(支持常规宽表/转置宽表/长表/分段文本)
* 2. fillTemplate: 食谱 JSON + 135编辑器模板HTML → 按原样式填充后的完整 HTML
* (只改文本,不改任何 style/图片/结构;条目不足克隆模板条目,多余删除)
*
* 逻辑移植自已验证的 tools/parse_recipe.mjs 与 tools/fill_135editor.mjs。
*/
import { JSDOM } from "jsdom";
// ---------- 类型 ----------
export interface RecipeItem { zh: string; en: string }
export interface RecipeMeal { meal: string; items: RecipeItem[] }
export interface RecipeDay { day: string; meals: RecipeMeal[] }
export interface Recipe { days: RecipeDay[] }
export interface MealFillInfo {
day: string; meal: string;
replaced: number; added: number; removed: number;
oldCount: number; newCount: number;
}
export interface FillReport {
filled: MealFillInfo[];
warnings: string[];
repaired: { day: string; meal: string; text: string }[];
mismatch: string[];
/** 模板中存在、但 docx 中没有的星期 → 已从输出中整段移除 */
removedDays: string[];
verification: {
stylesAdded: number; stylesRemoved: number;
styleSampleAdded: [string, number][]; styleSampleRemoved: [string, number][];
imgsUnchanged: boolean; sections: string; elements: string;
};
}
// ---------- docx 解析 ----------
const DAY_RE = /^星期[一二三四五六日]$/;
const DAY_ALIASES: Record<string, string> = {
周一: "星期一", 周二: "星期二", 周三: "星期三", 周四: "星期四",
周五: "星期五", 周六: "星期六", 周日: "星期日", 周天: "星期日",
礼拜一: "星期一", 礼拜二: "星期二", 礼拜三: "星期三", 礼拜四: "星期四",
礼拜五: "星期五", 礼拜六: "星期六", 礼拜日: "星期日", 礼拜天: "星期日",
};
const MEAL_ALIASES: Record<string, string> = {
早餐: "早餐", 早点: "早点", 水果: "早点", 果点: "早点",
午餐: "午餐", 中餐: "午餐", 午饭: "午餐",
体弱儿餐: "体弱儿餐", 体弱儿: "体弱儿餐", 特殊餐: "体弱儿餐",
午点: "午点", 午睡后: "午点", 点心: "午点", 下午点心: "午点", 下午茶: "午点",
晚餐: "晚餐", 晚点: "晚点",
};
const SEP_RE = /[;;\n、]+/;
const CJK_RE = /[\u4e00-\u9fff]/;
const LATIN_RE = /[A-Za-z]/;
const norm = (s: unknown) => String(s ?? "").replace(/\u00a0/g, " ").replace(/\s+/g, " ").trim();
const normKey = (s: unknown) => String(s ?? "").replace(/\s+/g, "");
function canonDay(s: unknown): string | null {
const t = normKey(s);
const m = t.match(/星期[一二三四五六日]/);
if (m) return m[0];
if (DAY_RE.test(t)) return t;
const w = t.match(/周[一二三四五六日天]/);
if (w) return w[0] === "周天" ? "星期日" : w[0].replace("周", "星期");
return DAY_ALIASES[t] ?? null;
}
function canonMeal(s: unknown): string | null {
const t = normKey(s);
if (MEAL_ALIASES[t]) return MEAL_ALIASES[t];
// 关键词按长短优先, 避免 "早点/水果餐" 被 "早" 误判为早餐
if (t.includes("体弱")) return "体弱儿餐";
if (t.includes("早点") || t.includes("水果")) return "早点";
if (t.includes("早餐")) return "早餐";
if (t.includes("午点") || t.includes("点心")) return "午点";
if (t.includes("午餐") || t.includes("中餐")) return "午餐";
if (t.includes("晚点")) return "晚点";
if (t.includes("晚餐")) return "晚餐";
if (t.includes("早")) return "早餐";
if (t.includes("午")) return "午餐";
if (t.includes("晚")) return "晚餐";
return null;
}
function ensureDay(rec: Recipe, day: string): RecipeDay {
let d = rec.days.find((x) => x.day === day);
if (!d) { d = { day, meals: [] }; rec.days.push(d); }
return d;
}
function ensureMeal(d: RecipeDay, meal: string): RecipeMeal {
let m = d.meals.find((x) => x.meal === meal);
if (!m) { m = { meal, items: [] }; d.meals.push(m); }
return m;
}
function addItem(m: RecipeMeal, zh: string, en: string) {
const z = norm(zh), e = norm(en);
if (!z && !e) return;
m.items.push({ zh: z, en: e });
}
/** 格子→菜品列表(中英交替行: 中文行=菜名, 紧跟英文行=其英文名)。必须接收原始文本(含换行)。 */
function splitCell(cell: string): { zhList: string[]; enList: string[] } {
const raw = String(cell ?? "").replace(/\r/g, "");
const parts = raw.split(SEP_RE).map(norm).filter(Boolean);
const items: { zh: string; en: string }[] = [];
for (const p of parts) {
if (CJK_RE.test(p)) items.push({ zh: p, en: "" });
else if (LATIN_RE.test(p)) {
if (items.length) items[items.length - 1].en = p;
else items.push({ zh: "", en: p });
} else items.push({ zh: p, en: "" });
}
if (!items.some((x) => x.zh)) return { zhList: [], enList: items.map((x) => x.en) };
return { zhList: items.map((x) => x.zh).filter(Boolean), enList: items.map((x) => x.en).filter(Boolean) };
}
/** B1 常规宽表: 表头含≥2个星期列, 第一列是餐次 */
function parseWideB1(rows: string[][]): Recipe | null {
const rec: Recipe = { days: [] };
let header: string[] | null = null;
let dayCols: (string | null)[] = [];
for (const row of rows) {
const days = row.map(normKey).map(canonDay);
if (days.filter(Boolean).length >= 2) { header = row; dayCols = days; break; }
}
if (!header) return null;
let curMeal: string | null = null;
for (const row of rows) {
const mealKey = canonMeal(row[0] ?? "");
if (mealKey) curMeal = mealKey;
if (!curMeal) continue;
for (let ci = 0; ci < header.length; ci++) {
const day = dayCols[ci];
if (!day || !(row[ci] ?? "").trim()) continue;
const { zhList, enList } = splitCell(row[ci]);
if (!zhList.length && !enList.length) continue;
const m = ensureMeal(ensureDay(rec, day), curMeal);
const n = Math.max(zhList.length, enList.length);
for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? "");
}
}
return rec.days.length ? rec : null;
}
/** B2 转置宽表: 星期在第一列, 餐次在表头行(用户真实文件即此格式) */
function parseWideB2(rows: string[][]): Recipe | null {
const rec: Recipe = { days: [] };
for (let ri = 0; ri < rows.length; ri++) {
const cells = rows[ri].map(normKey);
const mealCols = cells
.map((c, i) => (canonMeal(c) && i > 0 ? { meal: canonMeal(c)!, col: i } : null))
.filter((x): x is { meal: string; col: number } => !!x);
if (mealCols.length < 2) continue;
for (let rj = ri + 1; rj < rows.length; rj++) {
const day = canonDay(rows[rj][0] ?? "");
if (!day) continue;
for (const { meal, col } of mealCols) {
if (!(rows[rj][col] ?? "").trim()) continue;
const { zhList, enList } = splitCell(rows[rj][col]);
if (!zhList.length && !enList.length) continue;
const m = ensureMeal(ensureDay(rec, day), meal);
const n = Math.max(zhList.length, enList.length);
for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? "");
}
}
if (rec.days.length) return rec;
}
return null;
}
/** 长表: 列 = 星期|餐次|菜品|英文, 空星期/餐次沿用上一行(兼容合并单元格) */
function parseLongRows(rows: string[][]): Recipe {
const rec: Recipe = { days: [] };
let curDay: string | null = null, curMeal: string | null = null;
for (const row of rows) {
if (!row.some((c) => norm(c))) continue;
let dayIdx = -1, mealIdx = -1;
for (let i = 0; i < row.length; i++) {
if (canonDay(row[i]) && dayIdx < 0) dayIdx = i;
if (canonMeal(row[i]) && mealIdx < 0 && i !== dayIdx) mealIdx = i;
}
if (dayIdx >= 0) curDay = canonDay(row[dayIdx]);
if (mealIdx >= 0) curMeal = canonMeal(row[mealIdx]);
if (!curDay || !curMeal) continue;
const zhCells: string[] = [], enCells: string[] = [];
for (let i = 0; i < row.length; i++) {
if (i === dayIdx || i === mealIdx || !norm(row[i])) continue;
(CJK_RE.test(row[i]) ? zhCells : enCells).push(row[i]);
}
const m = ensureMeal(ensureDay(rec, curDay), curMeal);
const zhParts = zhCells.flatMap((c) => splitCell(c).zhList);
let enParts = enCells.map(norm).filter(Boolean);
if (!enParts.length) enParts = zhCells.flatMap((c) => splitCell(c).enList);
const n = Math.max(zhParts.length, enParts.length);
for (let i = 0; i < n; i++) addItem(m, zhParts[i] ?? "", enParts[i] ?? "");
}
return rec;
}
/** 分段文本: 【星期一】/ 早餐:菜;菜 */
function parseTextLines(lines: string[]): Recipe {
const rec: Recipe = { days: [] };
let curDay: string | null = null, curMeal: string | null = null;
for (const raw0 of lines) {
const raw = raw0.replace(/\r/g, "").trim();
if (!raw) continue;
const dayM = raw.match(/^[【[]?\s*(星期[一二三四五六日])\s*[】\]]?$/);
if (dayM) { curDay = dayM[1]; curMeal = null; continue; }
const mealM = raw.match(/^(早餐|早点|水果|午餐|中餐|体弱儿餐|午点|点心|晚餐|晚点)\s*[::]\s*(.*)$/);
if (mealM) {
curMeal = canonMeal(mealM[1]);
if (curDay && curMeal) {
const m = ensureMeal(ensureDay(rec, curDay), curMeal);
const { zhList, enList } = splitCell(mealM[2]);
const n = Math.max(zhList.length, enList.length);
for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? "");
}
continue;
}
if (curDay && curMeal && (CJK_RE.test(raw) || LATIN_RE.test(raw))) {
const m = ensureMeal(ensureDay(rec, curDay), curMeal);
const { zhList, enList } = splitCell(raw);
const n = Math.max(zhList.length, enList.length);
for (let i = 0; i < n; i++) addItem(m, zhList[i] ?? "", enList[i] ?? "");
}
}
return rec;
}
/** mammoth HTML → 表格行(单元格内多个<p>按行还原) */
function htmlToRows(html: string): string[][] {
const rows: string[][] = [];
const tableRe = /<tr[^>]*>([\s\S]*?)<\/tr>/g;
let mt: RegExpExecArray | null;
while ((mt = tableRe.exec(html))) {
const cells = [...mt[1].matchAll(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/g)].map((c) =>
(c[1] ?? "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&nbsp;/g, " "),
);
rows.push(cells);
}
return rows;
}
export async function parseDocxToRecipe(buf: Buffer): Promise<Recipe> {
const mammoth = await import("mammoth");
const { value: html } = await mammoth.convertToHtml({ buffer: buf });
const rows = htmlToRows(html);
if (rows.length) {
const rec = parseWideB1(rows) ?? parseWideB2(rows) ?? parseLongRows(rows);
if (rec.days.length) return rec;
}
return parseTextLines(html.replace(/<[^>]+>/g, "\n").split("\n"));
}
// ---------- 模板填充(逻辑与 fill_135editor.mjs 页面脚本一致, DOM 换成 jsdom) ----------
const MEAL_KEY: Record<string, string> = {
早餐: "breakfast", 早点: "fruit", 水果: "fruit",
午餐: "lunch", 中餐: "lunch",
体弱儿餐: "special",
午点: "snack", 点心: "snack",
晚餐: "dinner", 晚点: "dinner",
};
const MEAL_KEY_TO_LABEL: Record<string, string> = {
breakfast: "早餐", fruit: "早点", lunch: "午餐", special: "体弱儿餐", snack: "午点", dinner: "晚餐",
};
const DAY_SET = new Set(["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]);
interface El {
tagName: string;
children: HTMLElement[];
childNodes: ChildNode[];
textContent: string;
parentElement: HTMLElement | null;
nextElementSibling: HTMLElement | null;
getAttribute(n: string): string | null;
cloneNode(deep: boolean): any;
querySelectorAll(s: string): any[];
remove(): void;
insertBefore(n: Node, r: Node | null): Node;
parentNode: HTMLElement | null;
}
interface Item {
zhEl: HTMLElement; zhCont: HTMLElement[];
enEl: HTMLElement | null; enCont: HTMLElement[];
zhBlock: HTMLElement | null; enBlock: HTMLElement | null;
ca: HTMLElement | null; valid: boolean;
}
interface MealCol { key: string; items: Item[] }
interface DayCol { day: string; meals: MealCol[]; titleEl: HTMLElement | null }
const leafText = (el: HTMLElement) => (el.textContent ?? "").replace(/\u00a0/g, " ").trim();
function collect(body: HTMLElement): DayCol[] {
const days: DayCol[] = [];
let curDay: DayCol | null = null, curMeal: MealCol | null = null, curItem: Item | null = null, lastKind: string | null = null;
const getDay = (t: string, el?: HTMLElement) => { let d = days.find((x) => x.day === t); if (!d) { d = { day: t, meals: [], titleEl: el ?? null }; days.push(d); } if (el && !d.titleEl) d.titleEl = el; return d; };
const getMeal = (d: DayCol, key: string) => { let m = d.meals.find((x) => x.key === key); if (!m) { m = { key, items: [] }; d.meals.push(m); } return m; };
for (const el of body.querySelectorAll("*") as unknown as HTMLElement[]) {
if (el.children.length) continue;
const t = leafText(el);
if (!t) continue;
if (DAY_SET.has(t)) { curDay = getDay(t, el); curMeal = null; curItem = null; lastKind = null; continue; }
const mk = MEAL_KEY[t];
if (mk) {
if (!curDay) continue;
curMeal = getMeal(curDay, mk); curItem = null; lastKind = null; continue;
}
if (!curDay || !curMeal) continue;
if (CJK_RE.test(t)) {
if (lastKind === "zh" && curItem) curItem.zhCont.push(el);
else { curItem = { zhEl: el, zhCont: [], enEl: null, enCont: [], zhBlock: null, enBlock: null, ca: null, valid: false }; curMeal.items.push(curItem); lastKind = "zh"; }
} else if (LATIN_RE.test(t)) {
if (curItem && lastKind === "zh" && !curItem.enEl) { curItem.enEl = el; lastKind = "en"; }
else if (curItem && lastKind === "en") curItem.enCont.push(el);
}
}
// 配对: 中英块的最近公共祖先下必须是相邻兄弟
const pathTo = (el: HTMLElement): HTMLElement[] => { const p: HTMLElement[] = []; let n: HTMLElement | null = el; while (n && n !== body) { p.unshift(n); n = n.parentElement; } return p; };
for (const d of days) for (const m of d.meals) for (const it of m.items) {
it.valid = false;
if (!it.enEl) continue;
const zp = pathTo(it.zhEl), ep = pathTo(it.enEl);
let ca: HTMLElement | null = null;
for (let i = 0; i < Math.min(zp.length, ep.length); i++) { if (zp[i] === ep[i]) ca = zp[i]; else break; }
if (!ca) continue;
const zb = zp[zp.indexOf(ca) + 1], eb = ep[ep.indexOf(ca) + 1];
if (!zb || !eb) continue;
if (zb.nextElementSibling !== eb) continue;
it.zhBlock = zb; it.enBlock = eb; it.ca = ca; it.valid = true;
}
return days;
}
function daysToRecipe(days: DayCol[]): Recipe {
return {
days: days.map((d) => ({
day: d.day,
meals: d.meals.map((m) => ({
meal: MEAL_KEY_TO_LABEL[m.key],
items: m.items.filter((it) => it.valid).map((it) => ({
zh: [it.zhEl, ...it.zhCont].map(leafText).join(""),
en: it.enEl && it.enCont.length ? [it.enEl, ...it.enCont].map(leafText).join("") : (it.enEl ? leafText(it.enEl) : ""),
})),
})),
})),
};
}
interface Snapshot { styles: Map<string, number>; imgs: string[]; sections: number; elements: number }
function snapshot(body: HTMLElement): Snapshot {
const styles = new Map<string, number>();
let sections = 0, elements = 0;
for (const el of body.querySelectorAll("*") as unknown as HTMLElement[]) {
elements++;
if (el.tagName === "SECTION") sections++;
const st = el.getAttribute("style") || "";
if (st) styles.set(st, (styles.get(st) ?? 0) + 1);
}
const imgs = [...body.querySelectorAll("img")].map((im: any) => im.getAttribute("src") || "");
return { styles, imgs, sections, elements };
}
function diffSnapshot(a: Snapshot, b: Snapshot) {
const extras: [string, number][] = [], missing: [string, number][] = [];
for (const [k, v] of b.styles) { const dv = v - (a.styles.get(k) ?? 0); if (dv > 0) extras.push([k, dv]); }
for (const [k, v] of a.styles) { const dv = (b.styles.get(k) ?? 0) - v; if (dv > 0) missing.push([k, dv]); }
const ia = [...a.imgs].sort().join("|"), ib = [...b.imgs].sort().join("|");
return {
stylesAdded: extras.length, stylesRemoved: missing.length,
styleSampleAdded: extras.slice(0, 3), styleSampleRemoved: missing.slice(0, 3),
imgsUnchanged: ia === ib,
sections: `${a.sections} -> ${b.sections}`, elements: `${a.elements} -> ${b.elements}`,
};
}
/** 块内文本统一设置: 兼容 元素叶子 / 块直接文本节点 / 空块;不触碰style与结构 */
function setBlockText(block: HTMLElement, txt: string, wantCJK: boolean) {
for (const n of [...block.childNodes]) if (n.nodeType === 3) n.textContent = "";
const leaves = [...block.querySelectorAll("*")].filter((el: any) => el.children.length === 0) as HTMLElement[];
if (leaves.length) {
const main = leaves.find((el) => (wantCJK ? CJK_RE.test(leafText(el)) : LATIN_RE.test(leafText(el)))) ?? leaves[0];
for (const el of leaves) if (el !== main) (el as HTMLElement).textContent = "";
main.textContent = txt;
return;
}
const dts = [...block.childNodes].filter((n) => n.nodeType === 3);
if (dts.length) { dts[0].textContent = txt; return; }
block.textContent = txt;
}
/** 归一化修复: 列表容器里的裸文本菜名包进模板自身样式块(视觉不变) */
function normalizeBareText(days: DayCol[]): { day: string; meal: string; text: string }[] {
const repaired: { day: string; meal: string; text: string }[] = [];
for (const d of days) for (const m of d.meals) {
const valid = m.items.filter((it) => it.valid);
if (!valid.length) continue;
const model = valid[valid.length - 1];
const containers = new Set(valid.map((it) => it.ca).filter(Boolean) as HTMLElement[]);
for (const ca of containers) {
for (const node of [...ca.childNodes]) {
if (node.nodeType !== 3) continue;
const t = (node.textContent ?? "").replace(/\u00a0/g, " ").trim();
if (!t || t.length > 60) continue;
const isZh = CJK_RE.test(t), isEn = LATIN_RE.test(t);
if (!isZh && !isEn) continue;
if (isZh && model.zhBlock && model.enBlock) {
const zc = model.zhBlock.cloneNode(true) as HTMLElement;
const ec = model.enBlock.cloneNode(true) as HTMLElement;
setBlockText(zc, t, true);
setBlockText(ec, "", false);
ca.insertBefore(zc, node);
(zc.parentNode as HTMLElement).insertBefore(ec, zc.nextSibling);
} else if (model.enBlock) {
const ec = model.enBlock.cloneNode(true) as HTMLElement;
setBlockText(ec, t, false);
ca.insertBefore(ec, node);
}
ca.removeChild(node);
repaired.push({ day: d.day, meal: MEAL_KEY_TO_LABEL[m.key], text: t });
}
}
}
return repaired;
}
/**
* 移除模板中存在、但 docx 食谱里没有的"天"区块(标题块+全部餐次块,含中间隔断)。
* 算法: 对相邻两天标题叶子求最近公共祖先(LCA),在该层级删除 [当天子块, 下一天子块) 区间;
* 对最后一个模板天, 以该天最后一个有效条目的英文块为终点(含端点)删除。
* 只删区块节点, 不触碰任何保留内容的 style 与结构。
*/
function removeMissingDays(body: HTMLElement, dayCols: DayCol[], recipe: Recipe): string[] {
const wanted = new Set((recipe.days ?? []).map((d) => d.day));
const removed: string[] = [];
const leaves = Array.from(body.querySelectorAll<HTMLElement>("*")).filter(
(el) => el.children.length === 0 && el.textContent?.trim(),
);
/**
* 逐节点精确删除文档序区间 [a, b) (includeB 时含 b)。
* 规则: 节点属于 a 的祖先链时不可整体删除(内部可能含 a 之前需保留的内容, 如相邻天共用容器),
* 只前进到其 nextSibling(必然位于 a 之后, 可删)或爬升;
* 其余节点整体位于 a 之后, 直接删除, 无 nextSibling 时爬升(父节点按祖先链判断再决定);
* 前进进入的节点若包含 b 则下钻。保证不误删保留天。
*/
const removeBetween = (a: HTMLElement, b: HTMLElement | null, includeB: boolean) => {
if (!a || !a.parentNode || (b && a.contains(b))) return;
const ancestors = new Set<Node>();
for (let n: Node | null = a.parentElement; n; n = n.parentElement) ancestors.add(n);
let cur: Node | null = a;
let guard = 0;
while (cur && guard++ < 500000) {
if (cur === body) return;
if (b && cur === b) {
if (includeB) cur.parentNode?.removeChild(cur);
return;
}
const el = cur as HTMLElement;
const isAncestor = ancestors.has(cur);
if (!isAncestor && b && typeof el.contains === "function" && el.contains(b)) {
cur = el.firstChild; // 整体位于 a 之后且含 b: 下钻
continue;
}
if (isAncestor) {
cur = cur.nextSibling ?? cur.parentNode; // 祖先不删: 前进(在 a 之后)或爬升
continue;
}
const nx = cur.nextSibling;
const parent = cur.parentNode;
cur.parentNode?.removeChild(cur);
cur = nx ?? parent;
}
};
for (let i = 0; i < dayCols.length; i++) {
const d = dayCols[i];
if (wanted.has(d.day)) continue;
const titleEl = d.titleEl;
if (!titleEl || !body.contains(titleEl)) continue;
// 区间终点: 优先用下一个模板天的标题(排除); 最后一个天用尾部标记(排除), 兜底用最后条目的英文块(含端点)
const nextTitle = dayCols[i + 1]?.titleEl ?? null;
let endLeaf: HTMLElement | null = nextTitle && nextTitle !== titleEl && body.contains(nextTitle) ? nextTitle : null;
let includeEnd = false;
if (!endLeaf) {
const tail = leaves.find((el) => {
const t = el.textContent!.trim();
return (t === "三审三校" || t.startsWith("温馨提示")) && el !== titleEl && !titleEl.contains(el);
});
if (tail && body.contains(tail)) {
endLeaf = tail;
includeEnd = false;
} else {
const items = d.meals.flatMap((m) => m.items.filter((it) => it.valid));
endLeaf = items.length ? (items[items.length - 1].enBlock ?? null) : null;
includeEnd = !!endLeaf;
}
}
removeBetween(titleEl, endLeaf, includeEnd);
removed.push(d.day);
}
return removed;
}
export function fillTemplate(templateHtml: string, recipe: Recipe): { html: string; report: FillReport } {
const dom = new JSDOM(`<html><body>${templateHtml}</body></html>`, { url: "https://www.135editor.com/" });
const body = dom.window.document.body as unknown as HTMLElement;
const days = collect(body);
const repaired = normalizeBareText(days);
const dayCols = repaired.length ? collect(body) : days;
// 先移除 docx 中缺失的星期(如周五缺席则整段删除周五), 再重新收集(删除后节点引用会失效)并做基线快照与填充
const removedDays = removeMissingDays(body, dayCols, recipe);
const templateDayNames = new Set(dayCols.map((d) => d.day));
const fillCols = removedDays.length ? collect(body) : dayCols;
const baseline = snapshot(body);
const warnings: string[] = [];
const filled: MealFillInfo[] = [];
const joinLeaves = (els: HTMLElement[]) => els.map(leafText).join("");
for (const rd of recipe.days ?? []) {
const d = fillCols.find((x) => x.day === rd.day);
if (!d) { warnings.push(`编辑器模板中找不到 ${rd.day}`); continue; }
for (const rm of rd.meals ?? []) {
const mk = MEAL_KEY[rm.meal];
if (!mk) { warnings.push(`未知餐次: ${rm.meal}`); continue; }
const m = d.meals.find((x) => x.key === mk);
if (!m) { warnings.push(`${rd.day} 模板中无 ${rm.meal}`); continue; }
const oldItems = m.items.filter((it) => it.valid);
if (!oldItems.length) { warnings.push(`${rd.day} ${rm.meal} 模板无可克隆条目`); continue; }
const newItems = rm.items ?? [];
let added = 0, removed = 0, replaced = 0;
// 1) 就地替换(文本未变不碰叶子, 保持字节分布)
for (let i = 0; i < Math.min(oldItems.length, newItems.length); i++) {
const it = oldItems[i], ni = newItems[i];
if (joinLeaves([it.zhEl, ...it.zhCont]) !== (ni.zh ?? "")) {
it.zhEl.textContent = ni.zh ?? "";
for (const el of it.zhCont) el.textContent = "";
}
const enNow = it.enEl ? joinLeaves([it.enEl, ...it.enCont]) : "";
if (enNow !== (ni.en ?? "")) {
if (it.enEl) it.enEl.textContent = ni.en ?? "";
for (const el of it.enCont) el.textContent = "";
}
replaced++;
}
// 2) 锚点递进克隆补足
const model = oldItems[oldItems.length - 1];
let anchorEn = model.enBlock!;
for (let i = oldItems.length; i < newItems.length; i++) {
const zc = model.zhBlock!.cloneNode(true) as HTMLElement;
const ec = model.enBlock!.cloneNode(true) as HTMLElement;
const parent = anchorEn.parentNode!;
parent.insertBefore(zc, anchorEn.nextSibling);
parent.insertBefore(ec, zc.nextSibling);
setBlockText(zc, newItems[i].zh ?? "", true);
setBlockText(ec, newItems[i].en ?? "", false);
anchorEn = ec;
added++;
}
// 3) 从后往前删除多余
for (let i = oldItems.length - 1; i >= newItems.length; i--) {
const it = oldItems[i];
it.zhBlock?.remove();
it.enBlock?.remove();
for (const el of [...it.zhCont, ...it.enCont]) el.textContent = "";
removed++;
}
filled.push({ day: rd.day, meal: rm.meal, replaced, added, removed, oldCount: oldItems.length, newCount: newItems.length });
}
}
// 校验: 重新收集逐项比对
const after = snapshot(body);
const recipe2 = daysToRecipe(collect(body));
const mismatch: string[] = [];
for (const rd of recipe.days ?? []) {
if (!templateDayNames.has(rd.day)) continue; // 模板本来就没有该天(如周末), 已在 warnings 提示, 不算 mismatch
const d2 = recipe2.days.find((x) => x.day === rd.day);
if (!d2) { mismatch.push(`${rd.day} 未收集到`); continue; }
for (const rm of rd.meals ?? []) {
const m2 = d2.meals.find((x) => x.meal === rm.meal);
const exp = rm.items ?? [];
const got = m2?.items ?? [];
if (got.length !== exp.length) { mismatch.push(`${rd.day} ${rm.meal} 条数 ${exp.length}→${got.length}`); continue; }
for (let i = 0; i < exp.length; i++) {
if ((got[i].zh ?? "") !== (exp[i].zh ?? "") || (got[i].en ?? "") !== (exp[i].en ?? "")) {
mismatch.push(`${rd.day} ${rm.meal} #${i + 1} 文本不符`);
break;
}
}
}
}
const html = (body as any).innerHTML as string;
return {
html,
report: { filled, warnings, repaired, mismatch, removedDays, verification: diffSnapshot(baseline, after) },
};
}
/** 提取模板当前食谱(可用于基线对照/测试) */
export function extractTemplateRecipe(templateHtml: string): Recipe {
const dom = new JSDOM(`<html><body>${templateHtml}</body></html>`, { url: "https://www.135editor.com/" });
const body = dom.window.document.body as unknown as HTMLElement;
return daysToRecipe(collect(body));
}