feat(template): add restrict-edit protection script
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 53s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 53s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
สคริปต์ทดลอง make-protected.mjs ตามแนวทาง Token-Based ใน note.md: - เปิด documentProtection readOnly + enforcement ใน settings.xml - ครอบ Content Control ทุกช่องด้วย permStart/permEnd (edGrp=everyone) ให้แก้ได้เฉพาะในช่อง + เติม w:lock=sdtLocked กันลบช่อง - ฝัง custom properties (token/primary-key/owner) ที่ docProps/custom.xml พร้อมแก้ [Content_Types].xml และ _rels/.rels ให้ครบ - verify อ่านไฟล์กลับทุกครั้งหลังเขียน (exit 1 ถ้าไม่ผ่าน) - บันทึกบทเรียน/ผลทดสอบ Word จริง รอบ 1-2 ใน note.md พร้อม T3.docx/output-T3.docx อัปเดตตามผลรัน Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
073f72db7d
commit
b41816e2b6
4 changed files with 368 additions and 0 deletions
Binary file not shown.
298
app/api/template/test/make-protected.mjs
Normal file
298
app/api/template/test/make-protected.mjs
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
// ทดลอง: เปิด 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);
|
||||
|
|
@ -55,3 +55,73 @@ data(json)น่าจะปรับเป็นแบบนี้เพื่
|
|||
- render ปัจจุบันมีไว้ให้ทางศาลทดสอบ อันที่ใช้จริงหลังกดสร้าง Template เพื่อ Download จะสร้างเอกสารเวอร์ชั่นแรกใน object storage หรือถ้ามีอยู่แล้ว จะเป็นการอัปเดต
|
||||
- Backend จัดการ version
|
||||
- อัปเดตข้อมูลและเอกสารกลับระบบเดิม ส่วนนี้ยังไม่ชัดเจนอาจจะทำ mock api เตรียมไว้ก่อน
|
||||
|
||||
# ทดลอง Restrict Edit + Custom Properties (2026-10-07)
|
||||
|
||||
สคริปต์ `make-protected.mjs` — อ่าน docx → เปิด protection + ฝัง custom properties → เขียนไฟล์ → อ่านกลับ verify ทุกครั้ง
|
||||
|
||||
```sh
|
||||
node app/api/template/test/make-protected.mjs [in.docx] [out.docx]
|
||||
# ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ค่าตัวอย่างฝังอยู่ในตัวสคริปต์ CUSTOM_PROPS)
|
||||
```
|
||||
|
||||
## Restrict Edit (เปิดผ่านโค้ด)
|
||||
|
||||
- อยู่ที่ `word/settings.xml` → `<w:documentProtection w:edit="readOnly" w:enforcement="1"/>`
|
||||
- T3.docx มี element นี้อยู่เดิมแต่เป็น `w:enforcement="0"` (ตั้งค่าไว้ใน Word แต่ไม่ได้กดบังคับใช้) — สคริปต์แค่พลิกเป็น 1 ไม่ต้องแทรก element ใหม่
|
||||
- **บทเรียนจากทดสอบใน Word จริง (2026-10-07): `readOnly` + enforcement ล็อคทั้งเอกสารรวมถึง Content Control ด้วย** — CC ไม่ได้ถูกยกเว้นให้แก้อัตโนมัติ ต้องคู่กับข้อถัดไป
|
||||
- **กลไกที่ถูกต้อง: ครอบ `w:sdt` ทุกตัวที่มี `w:tag` ด้วย `<w:permStart w:id="N" w:edGrp="everyone"/>` … `<w:permEnd w:id="N"/>`** — คือกลไก "Exceptions (optional): Everyone" ที่แผง Restrict Editing ของ Word ใช้เวลาเราเลือกบริเวณแล้วติ๊ก Everyone (บริเวณใน permission range แก้ได้ นอกนั้นล็อค) — แทรกที่ระดับเดียวกับ sdt (inline ใน `w:p` หรือ block ใน `w:body` ก็ valid ตาม grammar)
|
||||
- สคริปต์เติม `w:lock="sdtLocked"` ให้ CC ที่ยังไม่มีด้วย (T3 ขาด acc1, nottitle) — พอช่องถูกเปิดให้แก้ผ่าน permission แล้ว ช่องที่ไม่มี lock จะถูก**ลบ**ได้ตามไปด้วย
|
||||
- ไม่ใส่ password → ผู้ใช้ปลดเองได้จาก Review → Restrict Editing → Stop Protection — ตั้งใจไว้แบบนี้เพราะ concept คือ token ใน custom property เป็นตัวกำกับสิทธิ์จริง ส่วน protection เป็นแค่เกราะกันแก้พลาด (ถ้าจะใส่ password ต้องคำนวณ legacy hash: SHA-1 ของ password+salt วนตาม `w:cryptSpinCount` ตาม ECMA-376 — ยังไม่ทำ)
|
||||
|
||||
## ฝังข้อมูลใน Property เอกสาร (token / primary-key / owner)
|
||||
|
||||
ทางเลือกที่ศึกษา:
|
||||
1. **`docProps/custom.xml` (custom properties) — ที่เลือกใช้** เห็นได้ใน Word: File → Info → Properties → Advanced Properties → แท็บ Custom; add-in ฝั่ง Word (Office.js) อ่าน/เขียนได้ผ่าน `Document.properties.customProperties` (WordApi 1.3+)
|
||||
- ไฟล์ที่ไม่มี part นี้อยู่ (กรณีปกติของ docx) ต้องทำครบ 3 อย่างพร้อมกัน ไม่งั้น Word ถือว่าไฟล์เสีย:
|
||||
1. สร้าง `docProps/custom.xml` — property แต่ละตัว: `fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"` คงที่ + `pid` ≥ 2 ไม่ซ้ำ + `name` + ค่า string ใน `vt:lpwstr`
|
||||
2. `[Content_Types].xml` เพิ่ม `<Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>`
|
||||
3. `_rels/.rels` เพิ่ม Relationship ชนิด `.../relationships/custom-properties` Target `docProps/custom.xml`
|
||||
2. `docProps/core.xml` (core properties: title/subject/creator/description) — แก้ง่าย เห็นในหน้า Properties ปกติ เหมาะกับข้อมูลที่แสดงต่อผู้ใช้ ไม่ใช่ token
|
||||
3. `w:docVar` ใน `word/settings.xml` (document variables) — ไม่โผล่ใน UI เลย อ่านได้ผ่าน VBA/VSTO (`Document.Variables`) แต่ Word JS API ไม่มีทางตรง — ไม่เลือก
|
||||
|
||||
gotcha ที่เจอ: `XMLSerializer` ของ @xmldom/xmldom คง `<?xml ...?>` declaration ไว้ในผลลัพธ์อยู่แล้ว — ห้าม prepend เพิ่มเอง จะได้ declaration ซ้อนเป็น XML invalid (Word/node อ่านไม่ได้)
|
||||
|
||||
## extract บอก alignment รายช่อง — key `<tag>@align` (2026-10-07) — **ถอดออกแล้วตามคำขอ (ยังไม่เอา)**
|
||||
|
||||
- เคย implement ใน `lib-tmpl.ts` + spec `lib/openapi.ts` แล้ว**ถอดกลับสถานะเดิม** — extract ปัจจุบันคืนแค่ `{ tag: text }` เหมือนเดิม
|
||||
- แนวทางที่พิสูจน์แล้วว่าใช้ได้ (เผื่อกลับมาทำภายหลัง): คืน key `<tag>@align` (ค่า w:jc เช่น `thaiDistribute`) เฉพาะช่องที่ต่างจาก default ของเอกสาร โดย resolve: `w:pPr/w:jc` ตรงของย่อหน้า → style ที่ใช้ (ไล่ `w:pStyle` → `w:basedOn`) → `docDefaults` ("left"/"start" นับเป็นไม่ตั้ง) — บริบทย่อหน้าของช่อง: CC block = `w:p` แรกใน `w:sdtContent` / CC inline = `w:p` ที่ครอบ sdt
|
||||
- ผลทดสอบตอน implement: direct pPr ชนะ style ✓ inline/block จับได้ทั้งคู่ ✓ และ T3 จัดช่อง 8 ตัวผ่าน style อยู่แล้ว (nottype/blackyear/redyear/acc1/acc2/groupname/detail1/pname — ชิดขวา/กลาง) — ช่องเหล่านี้จะโผล่ `@align` ตามจริง
|
||||
|
||||
## จัด paragraph แบบ Thai Distributed — ตัดสินใจ: จัดเองใน Word ตอนทำ template (2026-10-07)
|
||||
|
||||
- ค่า OOXML คือ `<w:jc w:val="thaiDistribute"/>` ใน `w:pPr` (ใน Word UI: Paragraph → Alignment → Thai Distributed, ทางลัด Ctrl+Shift+J) — ต่างจาก `distribute` ธรรมดาตรงที่จัดกระจายช่องว่างระหว่างคำเป็นหลัก ไม่บีบยืดตัวอักษร/สระ-วรรณยุกต์
|
||||
- **ไม่ตั้งผ่านโค้ด** — ผู้ออกแบบ template จัดเองใน Word ตอนทำ template ทางเดียวกับที่ตั้งฟอนต์/ย่อหน้าอื่น ๆ (เคยทดลองตั้ง default ทั้งเอกสารผ่าน `styles.xml` → `w:docDefaults/w:pPrDefault/w:pPr` แล้วถอดออกตามคำขอ)
|
||||
- วิธีจัดเองใน Word:
|
||||
- รายย่อหน้า/ราย CC: คลิกในย่อหน้า → `Ctrl+Shift+J` หรือ Home → Paragraph launcher → Alignment เลือก Thai Distributed
|
||||
- ทั้งเอกสาร: คลิกขวา style Normal → Modify → Format → Paragraph → Thai Distributed
|
||||
- จัด "เฉพาะช่อง CC" ได้เฉพาะ CC แบบ block (มี `w:p` ใน `w:sdtContent` ของตัวเอง เช่น `detail1`) — CC แบบ inline อยู่ในบรรทัดเดียวกับข้อความอื่น การจัดมีผลทั้งบรรทัด
|
||||
- จุดสำคัญต่อ render pipeline: `insertBlockContent` ของ lib-tmpl clone `w:pPr` ของย่อหน้าแรกให้ทุกบรรทัดที่แทนค่า — จัดไว้ใน CC ตั้งแต่ต้นค่าจะสืบทอดไปทุกบรรทัดที่ render ใส่ข้อมูลอัตโนมัติ ไม่ต้องทำอะไรฝั่ง render
|
||||
- (อ้างอิงเชิงเทคนิคกรณีต้องตั้งผ่านโค้ดภายหลัง: `w:jc` ใน `w:pPr` เป็น fixed sequence — ต้องอยู่หลัง `w:autoSpaceDN`/`w:spacing`/`w:ind` และก่อน `w:textDirection`/`w:rPr`/`w:sectPr`)
|
||||
|
||||
## ผล verify (อัตโนมัติทุกครั้งที่รันสคริปต์)
|
||||
|
||||
- `documentProtection` เปิดใช้ (readOnly + enforcement=1) ✓
|
||||
- `docProps/custom.xml` มี + relationship + content-type override ครบ ✓
|
||||
- Content Control 27 ตัวครบเดิม (plain 26 = มี `w:text`, rich 1 = `detail1`) ✓
|
||||
- permission ranges: permStart/permEnd ครบตามจำนวน CC ที่มี tag (27/27) ✓
|
||||
- ทุก CC มี `w:lock="sdtLocked"` (กันลบช่อง) ✓
|
||||
- `extractContentControls` (lib-tmpl) อ่านคืนได้ทุก tag ตรงกับ output.docx ก่อน protect 100% ✓
|
||||
|
||||
## ผลทดสอบใน Word จริง (2026-10-07 รอบ 1)
|
||||
|
||||
- ✅ แถบเหลือง protection ขึ้น / แก้นอก CC ไม่ได้ / ลบ CC ไม่ได้
|
||||
- ❌ B5: **CC แก้ไม่ได้** เพราะ readOnly ล็อคทั้งเอกสาร → แก้ด้วย permStart/permEnd ตามด้านบนแล้ว (รอบ 2)
|
||||
- ✅ custom properties ครบ / ปลด protection ได้แบบไม่มี password
|
||||
|
||||
## เหลือตรวจใน Word จริง (รอบ 2)
|
||||
|
||||
- เปิด `output-T3.docx` → พิมพ์/แก้ในช่อง CC ต้องได้แล้ว (B5)
|
||||
- ลบ CC ต้องไม่ได้ทุกตัว (รวม acc1, nottitle ที่เติม lock ให้)
|
||||
- ขั้น E เดิม: F12 เซฟสำเนา → เปิดใหม่ → properties ยังอยู่ครบ
|
||||
- (ผ่านแล้วทั้งหมด → ย้าย logic ไปเป็นฟังก์ชันใน lib-tmpl.ts แล้วต่อเข้า render pipeline)
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue