// ทดลอง: เปิด Restrict Edit (readOnly + enforcement) และฝัง custom properties // (token / primary-key / owner) ลงไฟล์ docx — ตามแนวทาง Token-Based ใน note.md // // ใช้: node app/api/template/test/make-protected.mjs [in.docx] [out.docx] // ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ผล render ล่าสุด) // // สิ่งที่แก้ในไฟล์ docx: // 1. word/settings.xml — w:documentProtection edit="readOnly" enforcement="1" // (เอกสารทั้งฉบับอ่านอย่างเดียว — ทดสอบแล้ว: readOnly ล็อค CC ด้วย // จึงต้องคู่กับข้อ 2 ถึงจะแก้ใน CC ได้) // 2. word/document.xml ฯลฯ — ครอบ w:sdt ทุกตัวที่มี w:tag ด้วย // w:permStart(w:edGrp="everyone") / w:permEnd — กลไก "Exceptions: Everyone" // ของแผง Restrict Editing ใน Word (บริเวณใน perm แก้ได้ นอกนั้นล็อค) // พร้อมเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี กันลบช่องตอนอยู่ในโหมดนี้ // 3. docProps/custom.xml — สร้างใหม่ เก็บค่า custom properties แบบ vt:lpwstr // 4. [Content_Types].xml — เพิ่ม Override สำหรับ custom.xml // 5. _rels/.rels — เพิ่ม Relationship ชี้ docProps/custom.xml import PizzaZip from "pizzip"; import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; import xpath from "xpath"; import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const __dirname = dirname(fileURLToPath(import.meta.url)); const inPath = process.argv[2] ?? join(__dirname, "output-T3.docx"); const outPath = process.argv[3] ?? inPath; // ตัวอย่างข้อมูลที่จะฝัง (ใน production จะมาจาก backend ตอน render เอกสาร) const CUSTOM_PROPS = { token: "eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1MTAwMSIsImZpZCI6IkRPTi0yMDI2LTAwMDEyMyIsImV4cCI6MTc5NjI0NzIwMH0.demo", "primary-key": "DOC-2026-000123", owner: "u1001", }; const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const CUSTOM_NS = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; const VT_NS = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"; const REL_CUSTOM = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"; const CT_CUSTOM = "application/vnd.openxmlformats-officedocument.custom-properties+xml"; const FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"; const parser = new DOMParser(); const serializer = new XMLSerializer(); const parseXml = (text) => parser.parseFromString(text, "text/xml"); // xmldom serializer อาจคง xml declaration ไว้แล้ว — เติมเฉพาะเมื่อยังไม่มี // (ถ้า prepend ซ้ำจะได้ declaration สองอัน ไฟล์ XML invalid) const serialize = (doc) => { const text = serializer.serializeToString(doc); return text.startsWith("\r\n' + text; }; // ครอบ w:sdt ที่มี w:tag ทุกตัวด้วย w:permStart/w:permEnd (edGrp="everyone") // และเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี — คืนจำนวน CC ที่ครอบ function unlockContentControls(zip) { const select = xpath.useNamespaces({ w: W_NS }); const asNode = (n) => n; const parts = Object.keys(zip.files).filter((n) => /^word\/(document|header\d*|footer\d*)\.xml$/.test(n), ); // id ของ perm ต้องไม่ซ้ำในเอกสาร — เริ่มสูงกว่าค่าสูงสุดที่มีอยู่ (จากทุก part) let nextId = 999; for (const part of parts) { const ids = select( "//w:permStart/@w:id | //w:permEnd/@w:id", asNode(parseXml(zip.file(part).asText())), ); for (const id of ids) { const n = Number.parseInt(id.value, 10); if (Number.isFinite(n) && n >= nextId) nextId = n; } } let wrapped = 0; let locked = 0; for (const part of parts) { const doc = parseXml(zip.file(part).asText()); // เฉพาะ sdt ระดับบนสุด (ไม่อยู่ใน sdt อื่น) มี w:tag = ช่องข้อมูลของ template // และยังไม่ถูกครอบด้วย permStart อยู่แล้ว (กัน double-wrap ตอนรันซ้ำ) const sdts = select( "//w:sdt[./w:sdtPr/w:tag][not(ancestor::w:sdt)][not(preceding-sibling::*[1][self::w:permStart])]", asNode(doc), ); if (sdts.length === 0) continue; for (const sdt of sdts) { // เติม lock ให้ CC ที่ยังไม่มี — กันลบช่องตอนเปิดช่องให้แก้ผ่าน permission const sdtPr = select("./w:sdtPr", sdt)[0]; const hasLock = select("./w:lock", sdtPr).length > 0; if (!hasLock) { const lock = doc.createElementNS(W_NS, "w:lock"); lock.setAttribute("w:val", "sdtLocked"); // w:lock ต้องอยู่หลัง w:id ตามลำดับ schema ของ sdtPr const idEl = select("./w:id", sdtPr)[0]; if (idEl && idEl.nextSibling) { sdtPr.insertBefore(lock, idEl.nextSibling); } else { sdtPr.appendChild(lock); } locked++; } const start = doc.createElementNS(W_NS, "w:permStart"); start.setAttribute("w:id", String(nextId)); start.setAttribute("w:edGrp", "everyone"); const end = doc.createElementNS(W_NS, "w:permEnd"); end.setAttribute("w:id", String(nextId)); nextId++; sdt.parentNode.insertBefore(start, sdt); sdt.parentNode.insertBefore(end, sdt.nextSibling); wrapped++; } zip.file(part, serialize(doc)); } return { wrapped, locked }; } function enableReadOnlyProtection(zip) { const file = zip.file("word/settings.xml"); if (!file) throw new Error("word/settings.xml not found"); const doc = parseXml(file.asText()); const prots = doc.getElementsByTagNameNS(W_NS, "documentProtection"); const prot = prots.length > 0 ? prots[0] : null; if (prot) { prot.setAttribute("w:edit", "readOnly"); prot.setAttribute("w:enforcement", "1"); } else { // T3 มี element นี้อยู่แล้ว แต่รองรับไฟล์ที่ไม่มี: แทรกเป็น child แรกของ w:settings const el = doc.createElementNS(W_NS, "w:documentProtection"); el.setAttribute("w:edit", "readOnly"); el.setAttribute("w:enforcement", "1"); doc.documentElement.insertBefore(el, doc.documentElement.firstChild); } zip.file("word/settings.xml", serialize(doc)); } function setCustomProperties(zip, props) { // --- 1. docProps/custom.xml : สร้างใหม่ หรือ merge กับของเดิม --- const existing = zip.file("docProps/custom.xml"); let doc; if (existing) { doc = parseXml(existing.asText()); // ลบ property ชื่อซ้ำออกก่อน (เขียนทับค่าใหม่) const all = Array.from(doc.getElementsByTagNameNS(CUSTOM_NS, "property")); for (const p of all) { if (Object.prototype.hasOwnProperty.call(props, p.getAttribute("name"))) { p.parentNode.removeChild(p); } } } else { doc = parseXml( `` + ``, ); } // pid ต้อง >= 2 และไม่ซ้ำ — เริ่มจากค่าสูงสุดที่มีอยู่ +1 const usedPids = Array.from( doc.getElementsByTagNameNS(CUSTOM_NS, "property"), ).map((p) => Number.parseInt(p.getAttribute("pid"), 10) || 1); let nextPid = Math.max(1, ...usedPids) + 1; for (const [name, value] of Object.entries(props)) { const prop = doc.createElementNS(CUSTOM_NS, "property"); prop.setAttribute("fmtid", FMTID); prop.setAttribute("pid", String(nextPid++)); prop.setAttribute("name", name); const val = doc.createElementNS(VT_NS, "vt:lpwstr"); val.textContent = value; prop.appendChild(val); doc.documentElement.appendChild(prop); } zip.file("docProps/custom.xml", serialize(doc)); // --- 2. [Content_Types].xml : เพิ่ม Override (ครั้งเดียว) --- const ct = parseXml(zip.file("[Content_Types].xml").asText()); const hasCt = Array.from(ct.getElementsByTagName("Override")).some( (o) => o.getAttribute("PartName") === "/docProps/custom.xml", ); if (!hasCt) { const el = ct.createElement("Override"); el.setAttribute("PartName", "/docProps/custom.xml"); el.setAttribute("ContentType", CT_CUSTOM); ct.documentElement.appendChild(el); zip.file("[Content_Types].xml", serialize(ct)); } // --- 3. _rels/.rels : เพิ่ม Relationship (ครั้งเดียว) --- const rels = parseXml(zip.file("_rels/.rels").asText()); const relNodes = Array.from(rels.getElementsByTagName("Relationship")); if (!relNodes.some((r) => r.getAttribute("Type") === REL_CUSTOM)) { const usedIds = new Set(relNodes.map((r) => r.getAttribute("Id"))); let n = 1; while (usedIds.has(`rId${n}`)) n++; const el = rels.createElement("Relationship"); el.setAttribute("Id", `rId${n}`); el.setAttribute("Type", REL_CUSTOM); el.setAttribute("Target", "docProps/custom.xml"); rels.documentElement.appendChild(el); zip.file("_rels/.rels", serialize(rels)); } } // อ่านกลับมาตรวจทุกอย่างหลังเขียนไฟล์ function verify(path) { const zip = new PizzaZip(readFileSync(path)); const settings = zip.file("word/settings.xml")?.asText() ?? ""; const prot = settings.match(/]*\/>/)?.[0]; const enforced = prot?.includes('w:edit="readOnly"') && prot?.includes('w:enforcement="1"'); const customXml = zip.file("docProps/custom.xml")?.asText(); const propsBack = {}; if (customXml) { const doc = parseXml(customXml); for (const p of Array.from( doc.getElementsByTagNameNS(CUSTOM_NS, "property"), )) { propsBack[p.getAttribute("name")] = p.textContent; } } const rels = zip.file("_rels/.rels")?.asText() ?? ""; const ct = zip.file("[Content_Types].xml")?.asText() ?? ""; const docXml = zip.file("word/document.xml")?.asText() ?? ""; const sdtPrs = docXml.match(/[\s\S]*?<\/w:sdtPr>/g) ?? []; const plain = sdtPrs.filter((p) => / / `${k}="${v.length > 40 ? v.slice(0, 40) + "…" : v}"`) .join(", ") || "(ว่าง)", ); console.log( `relationship : ${rels.includes(REL_CUSTOM) ? "✓" : "✗ ไม่พบ"} | content-type override: ${ct.includes(CT_CUSTOM) ? "✓" : "✗ ไม่พบ"}`, ); console.log(`content controls : ${sdtPrs.length} ตัว (plain=${plain}, rich=${rich}, มี tag=${tagged})`); console.log( `permission ranges : permStart=${permStarts} / permEnd=${permEnds} (ต้องเท่ากับมี tag=${tagged}) ` + (permStarts === tagged && permEnds === tagged ? "✓" : "✗"), ); console.log( `CC ไม่มี lock : ${missingLock} ตัว (ต้องเป็น 0 — กันลบช่อง) ${missingLock === 0 ? "✓" : "✗"}`, ); return ( enforced && !!customXml && permStarts === tagged && permEnds === tagged && missingLock === 0 ); } const zip = new PizzaZip(readFileSync(inPath)); enableReadOnlyProtection(zip); const { wrapped, locked } = unlockContentControls(zip); setCustomProperties(zip, CUSTOM_PROPS); if (locked > 0) { console.log(`เติม w:lock="sdtLocked" ให้ CC อีก ${locked} ตัวที่ยังไม่มี`); } console.log(`ครอบ CC ด้วย permission range: ${wrapped} ตัว`); const out = zip.generate({ type: "nodebuffer", compression: "DEFLATE" }); writeFileSync(outPath, out); console.log(`เขียนไฟล์แล้ว: ${outPath} (${out.length.toLocaleString()} bytes)`); const ok = verify(outPath); process.exit(ok ? 0 : 1);