299 lines
13 KiB
JavaScript
299 lines
13 KiB
JavaScript
|
|
// ทดลอง: เปิด 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("<?xml")
|
||
|
|
? text
|
||
|
|
: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\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(
|
||
|
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||
|
|
`<Properties xmlns="${CUSTOM_NS}" xmlns:vt="${VT_NS}"/>`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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(/<w:documentProtection[^>]*\/>/)?.[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(/<w:sdtPr>[\s\S]*?<\/w:sdtPr>/g) ?? [];
|
||
|
|
const plain = sdtPrs.filter((p) => /<w:text\b/.test(p)).length;
|
||
|
|
const rich = sdtPrs.length - plain;
|
||
|
|
const tagged = sdtPrs.filter((p) => /<w:tag w:val="/.test(p)).length;
|
||
|
|
const permStarts = (docXml.match(/<w:permStart /g) ?? []).length;
|
||
|
|
const permEnds = (docXml.match(/<w:permEnd /g) ?? []).length;
|
||
|
|
const missingLock = sdtPrs.filter(
|
||
|
|
(p) => /<w:tag w:val="/.test(p) && !/<w:lock /.test(p),
|
||
|
|
).length;
|
||
|
|
|
||
|
|
console.log("-- verify (อ่านไฟล์กลับหลังเขียน) --");
|
||
|
|
console.log(`documentProtection : ${prot ?? "(ไม่พบ!)"} ${enforced ? "✓" : "✗ ไม่ถูกเปิดใช้"}`);
|
||
|
|
console.log(`docProps/custom.xml: ${customXml ? "มี ✓" : "ไม่พบ ✗"}`);
|
||
|
|
console.log(
|
||
|
|
"custom properties :",
|
||
|
|
Object.entries(propsBack)
|
||
|
|
.map(([k, v]) => `${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);
|