/** * Kelomn static-article law citation drawer — mirrors SPA LawChip + LawCitationModal. * Safe on production: embedded .lawquote text is always the fallback (RULE 1). * Live API enriches when resolver returns citation-grade varak; wrong hits are rejected. */ const THAI_DIGITS = "๐๑๒๓๔๕๖๗๘๙"; const OCS_SEARCH_URL = "https://www.ocs.go.th/searchlaw"; /** * --------------------------------------------------------------------------------------- * PDF-identical statutory-text formatter — JS port of * ~/.claude/skills/thai-legal-paragraph-format/assets/format_pdf_identical.py (+ the * kelomn_law_format.py primitives it depends on). Adds ONLY newlines + leading indent — * words 100% verbatim (RULE 1 of skill thai-legal-paragraph-format). See SKILL.md * "กฎ 3 เสา": (1) first-line indent every block, (2) blank line before every block incl. * อนุมาตรา, (3) natural flow within a block (collapse cosmetic column-wrap). * * Verified byte-for-byte identical to the Python reference on 6 real statutes (PDPA ม.19, * ม.37; ป.พ.พ. ม.34; ป.อ. ม.288; + 2 sections with real (ก)(ข)(ค)(ง) sub-items) — see * scratchpad/{py_reference_out.json,js_reference_out.json} diff (2026-07-11). * --------------------------------------------------------------------------------------- */ const TH2AR_MAP = (() => { const m = {}; for (let i = 0; i < THAI_DIGITS.length; i++) m[THAI_DIGITS[i]] = i; return m; })(); function lawThaiToInt(s) { let digits = ""; for (const ch of s) { if (Object.prototype.hasOwnProperty.call(TH2AR_MAP, ch)) digits += String(TH2AR_MAP[ch]); } return digits === "" ? -1 : parseInt(digits, 10); } // _PAREN_NUM = re.compile(r"\(([" + _TH + r"]+(?:/[" + _TH + r"]+)?)\)") const LAW_PAREN_NUM_SRC = "\\(([๐-๙]+(?:/[๐-๙]+)?)\\)"; // _LEADING_NUM = re.compile(r"^\s*\xa0*\s*\(([" + _TH + r"]+)\)") const LAW_LEADING_NUM = /^\s*\xa0*\s*\(([๐-๙]+)\)/; // _ENUM = re.compile(r"^\((?:[๐-๙]{1,3}|[ก-ฮ])\)") const LAW_ENUM_RE = /^\((?:[๐-๙]{1,3}|[ก-ฮ])\)/; // _SUBITEM_KO = re.compile(r"^\([ก-ฮ]\)") const LAW_SUBITEM_KO = /^\([ก-ฮ]\)/; // _VAKK_OPEN const LAW_VAKK_OPEN = new RegExp( "^(ถ้า|ในกรณี|ในการพิจารณา|ความผิดตาม|ความในวรรค|ความในมาตรา|บทบัญญัติ|" + "ต้องระวางโทษ|ผู้กระทำต้องระวางโทษ|ทั้งนี้|เมื่อ|ให้ถือ|ให้นำ|มิให้|" + "ห้ามมิให้|การกระทำตาม|เพื่อประโยชน์)", ); const LAW_INDENT = " "; // ~1 tab (level 1). (ก)(ข) sub-item = LAW_INDENT * 2 (level 2). function lawStripWs(s) { return s.replace(/\s/g, ""); } // _break_subitems_global(text, start_expected=1) function lawBreakSubitemsGlobal(text, startExpected = 1) { const re = new RegExp(LAW_PAREN_NUM_SRC, "g"); const out = []; let pos = 0; let expected = startExpected; let m; while ((m = re.exec(text)) !== null) { const numPart = m[1].split("/")[0]; if (lawThaiToInt(numPart) === expected) { let seg = text.slice(pos, m.index); if (seg.endsWith(" ") || seg.endsWith("\x00")) seg = seg.slice(0, -1); out.push(seg + "\n" + m[0]); pos = m.index + m[0].length; expected += 1; } } out.push(text.slice(pos)); return out.join(""); } // _break_line(line) function lawBreakLine(line) { const m = LAW_LEADING_NUM.exec(line); const start = m ? lawThaiToInt(m[1]) + 1 : 1; return lawBreakSubitemsGlobal(line, start); } // format_law_text_pre(text) function lawFormatTextPre(text) { const varak = text .split("\n") .map((v) => v.trim()) .filter((v) => v.length > 0); return varak.map(lawBreakLine).join("\n").trim(); } // _collapse_cosmetic(text) — collapse cosmetic column-wrap \n into a space; keep \n only // before a structural boundary (อนุมาตรา marker / indented line / วรรค-opener). function lawCollapseCosmetic(text) { const lines = text.split("\n"); let out = lines[0].replace(/\s+$/, ""); for (let i = 1; i < lines.length; i++) { const nxt = lines[i]; const s = nxt.trim(); const indented = nxt.length - nxt.replace(/^ +/, "").length >= 2; const keep = LAW_ENUM_RE.test(s) || indented || LAW_VAKK_OPEN.test(s); if (keep) { out += "\n" + s; } else { out += (out.endsWith(" ") ? "" : " ") + s; } } return out; } // Shared block-builder (indent every block + blank line before every block) + RULE-1 gate. function lawBuildBlocks(pre, sourceForGate) { const blocks = []; for (const ln of pre.split("\n")) { let s = ln.trim(); s = s.replace(/^ +/, "").replace(/^\xa0+/, ""); if (!s) continue; const indent = LAW_SUBITEM_KO.test(s) ? LAW_INDENT + LAW_INDENT : LAW_INDENT; blocks.push(indent + s); } const out = blocks.join("\n\n"); if (lawStripWs(out) !== lawStripWs(sourceForGate)) { return null; // RULE-1 fail — honest-hold, caller must fall back to raw/original text } return out; } /** * formatPdfIdentical(text) — single flat string input (text_varak / text / embedded
* lawquote text). Returns the PDF-identical rendering, or null if RULE-1 fails (caller
* must then display the untouched original — never a mangled render).
*/
function formatPdfIdentical(text) {
if (!text || !text.trim()) return "";
const pre = lawFormatTextPre(lawCollapseCosmetic(text));
return lawBuildBlocks(pre, text);
}
/**
* formatPdfIdenticalParagraphs(paragraphs) — ADAPTATION for API responses that already
* segment a มาตรา into a `paragraphs: string[]` array (one real วรรค per element — a KNOWN
* boundary, not a guess). Unlike formatPdfIdentical() we do NOT run cosmetic-collapse
* across the whole joined text, because that could wrongly MERGE two real, distinct
* paragraphs whenever a paragraph's first line doesn't happen to start with a recognized
* วรรค-opener word (the cosmetic-collapse heuristic is designed for a single flat OCR
* string where paragraph boundaries are NOT already known — here they are, so we must not
* risk collapsing them). We only collapse cosmetic wraps WITHIN each already-known
* paragraph, then run the same shared indent/blank-line pass.
*/
function formatPdfIdenticalParagraphs(paragraphs) {
if (!paragraphs || !paragraphs.length) return "";
const collapsed = paragraphs.map((p) => lawCollapseCosmetic(p));
const pre = lawFormatTextPre(collapsed.join("\n"));
return lawBuildBlocks(pre, paragraphs.join("\n"));
}
const API_BASE = (() => {
const host = typeof location !== "undefined" ? location.hostname : "";
if (host === "localhost" || host === "127.0.0.1") return "";
return "https://api.kelomn.com";
})();
function thaiToArabic(value) {
return String(value || "").replace(/[๐-๙]/g, (ch) => String(THAI_DIGITS.indexOf(ch)));
}
function arabicToThai(value) {
return String(value || "").replace(/\d/g, (d) => THAI_DIGITS[Number(d)]);
}
function normalizeWs(text) {
return String(text || "").replace(/\s+/g, " ").trim();
}
function apiLooksWrong(actHint, sectionArabic, apiText, fallbackText) {
const api = normalizeWs(apiText);
const fb = normalizeWs(fallbackText);
if (!api || !fb || api === fb) return false;
const hint = normalizeWs(actHint).toLowerCase();
if (hint.includes("ลิขสิทธิ์") && sectionArabic === "15") {
if (fb.includes("สิทธิแต่ผู้เดียว") && api.includes("มิให้นำบทบัญญัติ")) return true;
if (fb.includes("ทำซ้ำหรือดัดแปลง") && !api.includes("ทำซ้ำ")) return true;
}
return false;
}
function parseActAndSections(raw) {
const text = normalizeWs(raw.replace(/<[^>]+>/g, " "));
if (!text) return null;
let act = text;
let sectionPart = "";
const m = text.match(/^(.*?)\s*มาตรา\s*([\d๐-๙,\s]+)$/u);
if (m) {
act = normalizeWs(m[1]);
sectionPart = m[2];
}
const sections = sectionPart
? sectionPart
.split(/[,,]/)
.map((s) => thaiToArabic(s.trim()))
.filter(Boolean)
: [];
if (!act) return null;
return { act, sections };
}
function parseLawquoteBlock(quoteEl) {
const head = quoteEl.querySelector(".head");
const pre = quoteEl.querySelector("pre.law-text, pre");
const fallbackText = pre ? pre.textContent || "" : "";
let parsed = head ? parseActAndSections(head.textContent || "") : null;
const cite =
quoteEl.nextElementSibling?.classList?.contains("cite-source")
? quoteEl.nextElementSibling
: quoteEl.parentElement?.querySelector(".cite-source");
if (cite) {
const strong = cite.querySelector("strong");
const fromCite = parseActAndSections(strong ? strong.textContent || "" : cite.textContent || "");
if (fromCite) {
parsed = {
act: fromCite.act || parsed?.act || "",
sections: fromCite.sections?.length ? fromCite.sections : parsed?.sections || [],
};
}
}
if (!parsed?.act) return null;
if (!parsed.sections.length) {
const m = (head?.textContent || fallbackText).match(/มาตรา\s*([๐-๙\d]+)/u);
if (m) parsed.sections = [thaiToArabic(m[1])];
}
return {
act: parsed.act,
sections: parsed.sections,
fallbackText,
title: head ? normalizeWs(head.textContent || "") : parsed.act,
};
}
async function fetchLawCitation(act, section) {
const params = new URLSearchParams({ law: act, section });
const url = `${API_BASE}/api/law-by-citation?${params}`;
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
try {
const body = await res.json();
const detail = body?.detail;
if (detail && typeof detail === "object") return detail;
} catch {
/* ignore */
}
return { resolved: false };
}
return res.json();
}
// Applies formatPdfIdentical* and honest-holds to the untouched raw text if the RULE-1
// gate fails (words must stay 100% verbatim — never ship a mangled render).
function lawFormatOrRaw(raw) {
const out = formatPdfIdentical(raw);
return out !== null ? out : raw;
}
function resolveDisplayBody(data, fallbackText, act, section) {
// Source precedence UNCHANGED from prior behavior (paragraphs → text_varak/text →
// embedded lawquote) — only the FORMATTING applied at each stage changes (skill
// thai-legal-paragraph-format: indent every block + blank line before every block incl.
// อนุมาตรา + collapse cosmetic column-wrap). RULE-1 (words verbatim) is gated at every
// stage; on failure we honest-hold to the untouched raw text rather than risk a
// mis-formatted render.
const paragraphs = data?.paragraphs;
if (Array.isArray(paragraphs) && paragraphs.length) {
const cleaned = paragraphs
.map((p) => (typeof p === "string" ? p : p?.text || "").trim())
.filter(Boolean);
if (cleaned.length) {
const formatted = formatPdfIdenticalParagraphs(cleaned);
const body = formatted !== null ? formatted : cleaned.join("\n\n");
return { body, source: "api-paragraphs" };
}
}
const text = (data?.text_varak || data?.text || "").trim();
if (text && !apiLooksWrong(act, section, text, fallbackText)) {
return { body: lawFormatOrRaw(text), source: data?.from_text_varak ? "api-varak" : "api-text" };
}
if (fallbackText.trim()) {
return { body: lawFormatOrRaw(fallbackText.trim()), source: "embedded-lawquote" };
}
if (text) return { body: lawFormatOrRaw(text), source: "api-fallback" };
return { body: "", source: "none" };
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text != null) node.textContent = text;
return node;
}
function renderBodyParagraphs(container, body) {
container.replaceChildren();
const quote = el("div", "kelomn-law-drawer-quote");
for (const para of body.split(/\n{2,}/).filter(Boolean)) {
quote.appendChild(el("p", "kelomn-law-drawer-para", para));
}
container.appendChild(quote);
}
function closeDrawer() {
const root = document.getElementById("kelomn-law-drawer-root");
if (!root) return;
root.replaceChildren();
root.setAttribute("aria-hidden", "true");
document.body.classList.remove("kelomn-law-drawer-open");
}
function openDrawer({ act, section, sectionDisplay, title, fallbackText }) {
let root = document.getElementById("kelomn-law-drawer-root");
if (!root) {
root = el("div");
root.id = "kelomn-law-drawer-root";
document.body.appendChild(root);
}
root.setAttribute("aria-hidden", "false");
document.body.classList.add("kelomn-law-drawer-open");
root.replaceChildren();
const overlay = el("button", "kelomn-law-drawer-overlay");
overlay.type = "button";
overlay.setAttribute("aria-label", "ปิด");
overlay.addEventListener("click", closeDrawer);
const panel = el("aside", "kelomn-law-drawer-panel");
panel.setAttribute("role", "dialog");
panel.setAttribute("aria-modal", "true");
panel.setAttribute("aria-labelledby", "kelomn-law-drawer-title");
const header = el("header", "kelomn-law-drawer-header");
header.appendChild(el("div", "kelomn-law-drawer-icon", "§"));
const titles = el("div", "kelomn-law-drawer-titles");
titles.appendChild(el("h2", null, `${title || act} ม.${sectionDisplay || section}`));
titles.lastChild.id = "kelomn-law-drawer-title";
const actTitle = el("p", "kelomn-law-drawer-sub");
titles.appendChild(actTitle);
header.appendChild(titles);
const closeBtn = el("button", "kelomn-law-drawer-close", "×");
closeBtn.type = "button";
closeBtn.setAttribute("aria-label", "ปิดหน้าต่าง");
closeBtn.addEventListener("click", closeDrawer);
header.appendChild(closeBtn);
const bodyWrap = el("div", "kelomn-law-drawer-body");
bodyWrap.appendChild(el("p", "kelomn-law-drawer-loading", "กำลังดึงตัวบท…"));
const footer = el("footer", "kelomn-law-drawer-footer");
const ocs = document.createElement("a");
ocs.href = OCS_SEARCH_URL;
ocs.target = "_blank";
ocs.rel = "noopener noreferrer";
ocs.textContent = "ค้นตัวบทฉบับทางการที่ สำนักงานคณะกรรมการกฤษฎีกา ↗";
footer.appendChild(ocs);
footer.appendChild(
el("p", "kelomn-law-drawer-note", "เพื่อการศึกษา · โปรดตรวจสำเนาทางการก่อนใช้อ้างอิง"),
);
panel.append(header, bodyWrap, footer);
root.append(overlay, panel);
fetchLawCitation(act, section)
.then((data) => {
const { body, source } = resolveDisplayBody(data, fallbackText, act, section);
if (data?.act_title) actTitle.textContent = data.act_title;
bodyWrap.replaceChildren();
if (!body) {
const empty = el("div", "kelomn-law-drawer-empty");
empty.appendChild(el("p", "kelomn-law-drawer-empty-title", "ยังไม่มีตัวบทของมาตรานี้ในระบบ"));
empty.appendChild(
el("p", null, "โปรดดูตัวบทจากแหล่งทางการก่อนใช้อ้างอิงนะคะ"),
);
bodyWrap.appendChild(empty);
return;
}
renderBodyParagraphs(bodyWrap, body);
const badgeText =
source === "embedded-lawquote"
? "แสดงจากตัวบทในบทความ (ตรวจสอบแล้ว)"
: source.startsWith("api")
? "ดึงสดจาก Kelomn Law API"
: "";
if (badgeText) bodyWrap.appendChild(el("p", "kelomn-law-drawer-badge", badgeText));
})
.catch(() => {
bodyWrap.replaceChildren();
const fb = (fallbackText || "").trim();
if (fb) {
renderBodyParagraphs(bodyWrap, fb);
bodyWrap.appendChild(
el("p", "kelomn-law-drawer-badge", "แสดงจากตัวบทในบทความ (API ไม่พร้อม)"),
);
} else {
bodyWrap.appendChild(
el("p", "kelomn-law-drawer-error", "ดึงตัวบทไม่สำเร็จ · ลองใหม่อีกครั้ง"),
);
}
});
function onKey(e) {
if (e.key === "Escape") {
closeDrawer();
window.removeEventListener("keydown", onKey);
}
}
window.addEventListener("keydown", onKey);
}
function injectChipsForQuote(quoteEl) {
if (quoteEl.dataset.kelomnLawDrawer) return;
const meta = parseLawquoteBlock(quoteEl);
if (!meta || !meta.sections.length) return;
quoteEl.dataset.kelomnLawDrawer = "1";
const cite =
quoteEl.nextElementSibling?.classList?.contains("cite-source")
? quoteEl.nextElementSibling
: null;
const wrap = el("div", "law-cite-chips");
wrap.setAttribute("role", "group");
wrap.setAttribute("aria-label", "เปิดตัวบทมาตรา");
for (const section of meta.sections) {
const btn = el("button", "law-cite-chip", `ม.${arabicToThai(section)}`);
btn.type = "button";
btn.title = "คลิกเพื่ออ่านตัวบทมาตรานี้";
btn.addEventListener("click", () => {
openDrawer({
act: meta.act,
section,
sectionDisplay: arabicToThai(section),
title: meta.title,
fallbackText: meta.fallbackText,
});
});
wrap.appendChild(btn);
}
if (cite) cite.insertAdjacentElement("afterend", wrap);
else quoteEl.insertAdjacentElement("afterend", wrap);
}
function boot() {
if (document.body.dataset.kelomnLawDrawerBoot) return;
document.body.dataset.kelomnLawDrawerBoot = "1";
document.querySelectorAll(".lawquote").forEach(injectChipsForQuote);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}