เพิ่มกลุ่ม API docx template สำหรับ wordaddin (WIP): - POST /api/template/extract ดึงค่า Content Control ทั้งหมดจาก .docx - POST /api/template/render แทนค่า JSON ลง template คืน .docx ใหม่ - GET /api/template/test ตรวจว่า service มีชีวิต logic แชร์ที่ app/api/template/lib-tmpl.ts (pizzip+xpath) ไม่แตะ DB เพิ่มหน้า Swagger UI ที่ GET /api/swagger — spec inline จาก lib/openapi.ts ครอบ API ทุกกลุ่ม (template + N10), asset self-host ที่ public/note/swagger-ui/ จาก postinstall scripts/copy-swagger-ui.mjs (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 เหมือน patch-pdf-worker, Docker build รัน script นี้ด้วยผ่าน web.Dockerfile) middleware เปิดกว้างให้ api/template, api/swagger, note/swagger-ui จาก code review: จำกัดไฟล์แนบ 100MB ทั้ง extract/render ก่อนอ่าน เข้าหน่วยความจำกัน DoS, คืน 400 แทน 500 เมื่อ JSON ผิด format, sync คำอธิบาย BadRequest ใน openapi spec ให้ตรง behavior ใหม่ Co-Authored-By: Claude Code <noreply@anthropic.com>
518 lines
11 KiB
TypeScript
518 lines
11 KiB
TypeScript
import PizzaZip from "pizzip";
|
|
import {
|
|
DOMParser,
|
|
XMLSerializer,
|
|
type Document as XmlDocument,
|
|
type Element as XmlElement,
|
|
type Node as XmlNode,
|
|
type Attr as XmlAttr,
|
|
} from "@xmldom/xmldom";
|
|
import xpath from "xpath";
|
|
|
|
export interface ExtractedData {
|
|
[tagName: string]: string;
|
|
}
|
|
|
|
const W_NS =
|
|
"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
|
|
|
const asXpathNode = (node: XmlNode): globalThis.Node =>
|
|
node as unknown as globalThis.Node;
|
|
|
|
function createSelector() {
|
|
return xpath.useNamespaces({ w: W_NS });
|
|
}
|
|
|
|
/**
|
|
* อ่าน Content Control (w:sdt) จาก DOCX Buffer
|
|
* ปัจจุบันอ่านจาก word/document.xml ตาม logic ของโค้ดต้นฉบับ
|
|
*/
|
|
export function extractContentControls(
|
|
docxBuffer: Buffer,
|
|
): ExtractedData {
|
|
const zip = new PizzaZip(docxBuffer);
|
|
const docXmlFile = zip.file("word/document.xml");
|
|
|
|
if (!docXmlFile) {
|
|
throw new Error("Invalid docx file: word/document.xml not found.");
|
|
}
|
|
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(docXmlFile.asText(), "text/xml");
|
|
const select = createSelector();
|
|
const result: ExtractedData = {};
|
|
|
|
const sdtNodes = select(
|
|
"//w:sdt",
|
|
asXpathNode(doc),
|
|
) as unknown as XmlElement[];
|
|
|
|
sdtNodes.forEach((sdt) => {
|
|
const tagAttr = select(
|
|
"./w:sdtPr/w:tag/@w:val",
|
|
asXpathNode(sdt),
|
|
true,
|
|
) as unknown as XmlAttr | undefined;
|
|
|
|
const tagName = tagAttr?.value;
|
|
|
|
if (!tagName) return;
|
|
|
|
const sdtContent = select(
|
|
"./w:sdtContent",
|
|
asXpathNode(sdt),
|
|
true,
|
|
) as unknown as XmlElement | undefined;
|
|
|
|
if (!sdtContent) {
|
|
result[tagName] = "";
|
|
return;
|
|
}
|
|
|
|
const nodes = select(
|
|
".//w:p | .//w:t | .//w:tab | .//w:br | .//w:noBreakHyphen | .//w:softHyphen | .//w:sym",
|
|
asXpathNode(sdtContent),
|
|
) as unknown as XmlElement[];
|
|
|
|
let extractedText = "";
|
|
|
|
nodes.forEach((node) => {
|
|
switch (node.nodeName) {
|
|
case "w:p":
|
|
if (
|
|
extractedText.length > 0 &&
|
|
!extractedText.endsWith("\n")
|
|
) {
|
|
extractedText += "\n";
|
|
}
|
|
break;
|
|
|
|
case "w:t":
|
|
extractedText += node.textContent || "";
|
|
break;
|
|
|
|
case "w:tab":
|
|
extractedText += "\t";
|
|
break;
|
|
|
|
case "w:br":
|
|
extractedText += "\n";
|
|
break;
|
|
|
|
case "w:noBreakHyphen":
|
|
extractedText += "\u2011";
|
|
break;
|
|
|
|
case "w:softHyphen":
|
|
extractedText += "\u00ad";
|
|
break;
|
|
|
|
case "w:sym": {
|
|
const charAttr = node.getAttribute("w:char");
|
|
if (charAttr) {
|
|
const charCode = Number.parseInt(charAttr, 16);
|
|
extractedText += String.fromCharCode(charCode);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
result[tagName] = extractedText;
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
type SdtControlType = "plain" | "rich" | "other";
|
|
type SdtStructure = "block" | "inline";
|
|
|
|
function detectSdtControlType(
|
|
sdt: XmlElement,
|
|
select: xpath.XPathSelect,
|
|
): { type: SdtControlType; multiLine: boolean } {
|
|
const sdtPr = select(
|
|
"./w:sdtPr",
|
|
asXpathNode(sdt),
|
|
true,
|
|
) as unknown as XmlElement | undefined;
|
|
|
|
if (!sdtPr) {
|
|
return { type: "other", multiLine: false };
|
|
}
|
|
|
|
const textEl = select(
|
|
"./w:text",
|
|
asXpathNode(sdtPr),
|
|
true,
|
|
) as unknown as XmlElement | undefined;
|
|
|
|
if (textEl) {
|
|
const multiLineAttr = select(
|
|
"./@w:multiLine",
|
|
asXpathNode(textEl),
|
|
true,
|
|
) as unknown as XmlAttr | undefined;
|
|
|
|
return {
|
|
type: "plain",
|
|
multiLine:
|
|
multiLineAttr?.value === "1" ||
|
|
multiLineAttr?.value === "true",
|
|
};
|
|
}
|
|
|
|
const richTextEl = select(
|
|
"./w:richText",
|
|
asXpathNode(sdtPr),
|
|
true,
|
|
) as unknown as XmlElement | undefined;
|
|
|
|
if (richTextEl) {
|
|
return { type: "rich", multiLine: true };
|
|
}
|
|
|
|
const otherTypeTags = [
|
|
"w:date",
|
|
"w:dropDownList",
|
|
"w:comboBox",
|
|
"w:checkbox",
|
|
"w:citation",
|
|
"w:group",
|
|
"w:docPartObj",
|
|
"w:docPartList",
|
|
];
|
|
|
|
for (const tag of otherTypeTags) {
|
|
const found = select(
|
|
`./${tag}`,
|
|
asXpathNode(sdtPr),
|
|
true,
|
|
) as unknown as XmlElement | undefined;
|
|
|
|
if (found) {
|
|
return { type: "other", multiLine: false };
|
|
}
|
|
}
|
|
|
|
return { type: "rich", multiLine: true };
|
|
}
|
|
|
|
function detectSdtStructure(
|
|
sdtContent: XmlElement,
|
|
select: xpath.XPathSelect,
|
|
): SdtStructure {
|
|
const directP = select(
|
|
"./w:p",
|
|
asXpathNode(sdtContent),
|
|
) as unknown as XmlElement[];
|
|
|
|
return directP.length > 0 ? "block" : "inline";
|
|
}
|
|
|
|
function buildRunChildren(
|
|
doc: XmlDocument,
|
|
text: string,
|
|
): XmlElement[] {
|
|
const nodes: XmlElement[] = [];
|
|
const tokens = text.split(/(\t|\n)/);
|
|
|
|
tokens.forEach((token) => {
|
|
if (token === "") return;
|
|
|
|
if (token === "\t") {
|
|
nodes.push(doc.createElementNS(W_NS, "w:tab"));
|
|
} else if (token === "\n") {
|
|
nodes.push(doc.createElementNS(W_NS, "w:br"));
|
|
} else {
|
|
const t = doc.createElementNS(W_NS, "w:t");
|
|
t.setAttribute("xml:space", "preserve");
|
|
t.textContent = token;
|
|
nodes.push(t);
|
|
}
|
|
});
|
|
|
|
if (nodes.length === 0) {
|
|
const t = doc.createElementNS(W_NS, "w:t");
|
|
t.setAttribute("xml:space", "preserve");
|
|
t.textContent = "";
|
|
nodes.push(t);
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
function buildRun(
|
|
doc: XmlDocument,
|
|
text: string,
|
|
rPr?: XmlElement,
|
|
): XmlElement {
|
|
const run = doc.createElementNS(W_NS, "w:r");
|
|
|
|
if (rPr) {
|
|
run.appendChild(rPr.cloneNode(true));
|
|
}
|
|
|
|
buildRunChildren(doc, text).forEach((node) =>
|
|
run.appendChild(node),
|
|
);
|
|
|
|
return run;
|
|
}
|
|
|
|
function insertInlineContent(
|
|
doc: XmlDocument,
|
|
sdtContent: XmlElement,
|
|
select: xpath.XPathSelect,
|
|
text: string,
|
|
allowLineBreak: boolean,
|
|
): void {
|
|
const safeText = allowLineBreak
|
|
? text
|
|
: text.replace(/\n/g, " ");
|
|
|
|
const existingRuns = select(
|
|
"./w:r",
|
|
asXpathNode(sdtContent),
|
|
) as unknown as XmlElement[];
|
|
|
|
const firstRPr =
|
|
existingRuns.length > 0
|
|
? (select(
|
|
"./w:rPr",
|
|
asXpathNode(existingRuns[0]),
|
|
true,
|
|
) as unknown as XmlElement | undefined)
|
|
: undefined;
|
|
|
|
existingRuns.forEach((r) => {
|
|
if (r.parentNode) r.parentNode.removeChild(r);
|
|
});
|
|
|
|
sdtContent.appendChild(
|
|
buildRun(doc, safeText, firstRPr),
|
|
);
|
|
}
|
|
|
|
function insertBlockContent(
|
|
doc: XmlDocument,
|
|
sdtContent: XmlElement,
|
|
select: xpath.XPathSelect,
|
|
text: string,
|
|
allowMultiParagraph: boolean,
|
|
): void {
|
|
const allParagraphs = select(
|
|
"./w:p",
|
|
asXpathNode(sdtContent),
|
|
) as unknown as XmlElement[];
|
|
|
|
if (allParagraphs.length === 0) {
|
|
// ป้องกัน template ที่มีโครงสร้างผิดปกติ
|
|
const p = doc.createElementNS(W_NS, "w:p");
|
|
sdtContent.appendChild(p);
|
|
allParagraphs.push(p);
|
|
}
|
|
|
|
const firstP = allParagraphs[0];
|
|
|
|
const firstPPr = select(
|
|
"./w:pPr",
|
|
asXpathNode(firstP),
|
|
true,
|
|
) as unknown as XmlElement | undefined;
|
|
|
|
const firstRun = select(
|
|
"./w:r",
|
|
asXpathNode(firstP),
|
|
true,
|
|
) as unknown as XmlElement | undefined;
|
|
|
|
const firstRPr = firstRun
|
|
? (select(
|
|
"./w:rPr",
|
|
asXpathNode(firstRun),
|
|
true,
|
|
) as unknown as XmlElement | undefined)
|
|
: undefined;
|
|
|
|
allParagraphs.forEach((p) => {
|
|
if (p !== firstP && p.parentNode) {
|
|
p.parentNode.removeChild(p);
|
|
}
|
|
});
|
|
|
|
const existingRuns = select(
|
|
"./w:r",
|
|
asXpathNode(firstP),
|
|
) as unknown as XmlElement[];
|
|
|
|
existingRuns.forEach((r) => {
|
|
if (r.parentNode) r.parentNode.removeChild(r);
|
|
});
|
|
|
|
if (!allowMultiParagraph) {
|
|
firstP.appendChild(buildRun(doc, text, firstRPr));
|
|
return;
|
|
}
|
|
|
|
text.split("\n").forEach((line, index) => {
|
|
let targetP: XmlElement;
|
|
|
|
if (index === 0) {
|
|
targetP = firstP;
|
|
} else {
|
|
targetP = doc.createElementNS(W_NS, "w:p");
|
|
|
|
if (firstPPr) {
|
|
targetP.appendChild(firstPPr.cloneNode(true));
|
|
}
|
|
|
|
sdtContent.appendChild(targetP);
|
|
}
|
|
|
|
const run = doc.createElementNS(W_NS, "w:r");
|
|
|
|
if (firstRPr) {
|
|
run.appendChild(firstRPr.cloneNode(true));
|
|
}
|
|
|
|
buildRunChildren(doc, line).forEach((node) =>
|
|
run.appendChild(node),
|
|
);
|
|
|
|
targetP.appendChild(run);
|
|
});
|
|
}
|
|
|
|
function insertFallbackContent(
|
|
sdtContent: XmlElement,
|
|
select: xpath.XPathSelect,
|
|
text: string,
|
|
): void {
|
|
const textNodes = select(
|
|
".//w:t",
|
|
asXpathNode(sdtContent),
|
|
) as unknown as XmlElement[];
|
|
|
|
if (textNodes.length === 0) return;
|
|
|
|
textNodes[0].textContent = text;
|
|
|
|
for (let i = 1; i < textNodes.length; i++) {
|
|
textNodes[i].textContent = "";
|
|
}
|
|
}
|
|
|
|
export type TemplateData = Record<string, unknown>;
|
|
|
|
function normalizeValue(value: unknown): string {
|
|
if (value === null || value === undefined) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof value === "string") {
|
|
return value;
|
|
}
|
|
|
|
if (
|
|
typeof value === "number" ||
|
|
typeof value === "boolean" ||
|
|
typeof value === "bigint"
|
|
) {
|
|
return String(value);
|
|
}
|
|
|
|
// รองรับ JSON ที่ส่ง array/object มา แต่ Content Control ต้องการ text
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
/**
|
|
* นำ JSON data ไปแทนค่า Content Control ใน DOCX template
|
|
* คืนค่าเป็น Buffer ของ DOCX ที่สร้างเสร็จแล้ว
|
|
*/
|
|
export function templateContentControls(
|
|
templateBuffer: Buffer,
|
|
data: TemplateData,
|
|
): Buffer {
|
|
const zip = new PizzaZip(templateBuffer);
|
|
const docXmlFile = zip.file("word/document.xml");
|
|
|
|
if (!docXmlFile) {
|
|
throw new Error("Invalid docx file: word/document.xml not found.");
|
|
}
|
|
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(docXmlFile.asText(), "text/xml");
|
|
const select = createSelector();
|
|
|
|
const sdtNodes = select(
|
|
"//w:sdt",
|
|
asXpathNode(doc),
|
|
) as unknown as XmlElement[];
|
|
|
|
sdtNodes.forEach((sdt) => {
|
|
const tagAttr = select(
|
|
"./w:sdtPr/w:tag/@w:val",
|
|
asXpathNode(sdt),
|
|
true,
|
|
) as unknown as XmlAttr | undefined;
|
|
|
|
const tagName = tagAttr?.value;
|
|
|
|
if (!tagName || !Object.prototype.hasOwnProperty.call(data, tagName)) {
|
|
return;
|
|
}
|
|
|
|
const newValue = normalizeValue(data[tagName]);
|
|
|
|
const sdtContent = select(
|
|
"./w:sdtContent",
|
|
asXpathNode(sdt),
|
|
true,
|
|
) as unknown as XmlElement | undefined;
|
|
|
|
if (!sdtContent) return;
|
|
|
|
const { type: controlType, multiLine } =
|
|
detectSdtControlType(sdt, select);
|
|
|
|
const structure = detectSdtStructure(sdtContent, select);
|
|
|
|
if (controlType === "other") {
|
|
insertFallbackContent(sdtContent, select, newValue);
|
|
return;
|
|
}
|
|
|
|
if (structure === "inline") {
|
|
const allowLineBreak =
|
|
controlType === "plain" ? multiLine : true;
|
|
|
|
insertInlineContent(
|
|
doc,
|
|
sdtContent,
|
|
select,
|
|
newValue,
|
|
allowLineBreak,
|
|
);
|
|
} else {
|
|
insertBlockContent(
|
|
doc,
|
|
sdtContent,
|
|
select,
|
|
newValue,
|
|
controlType === "rich",
|
|
);
|
|
}
|
|
});
|
|
|
|
const serializer = new XMLSerializer();
|
|
zip.file(
|
|
"word/document.xml",
|
|
serializer.serializeToString(doc),
|
|
);
|
|
|
|
return zip.generate({
|
|
type: "nodebuffer",
|
|
compression: "DEFLATE",
|
|
});
|
|
}
|