"""convert.py — likumi.lv konsolidētā likuma Word (.docx) vai lapa (HTML) → likumi-0.1 XML. Lieto: python3 convert.py .html> HTML lapu un grozījumu lapas lejupielādē: python3 tools/html_source.py fetch Strukturē pēc Word stiliem un sarakstu numerācijas (nevis ar MI). Neskaidros gadījumus ieraksta verification JSON (`.verify.json`), nevis min. """ import sys, os, re, json, hashlib, datetime from lxml import etree NS = "urn:pppa:vpk:likumi:0.1" W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" SUP = str.maketrans("0123456789", "⁰¹²³⁴⁵⁶⁷⁸⁹") ROMAN = {"I": 1, "V": 5, "X": 10, "L": 50} MONTHS = {"janvār": 1, "februār": 2, "mart": 3, "aprīl": 4, "maij": 5, "jūnij": 6, "jūlij": 7, "august": 8, "septembr": 9, "oktobr": 10, "novembr": 11, "decembr": 12} def roman(s): v = 0 for i, c in enumerate(s): x = ROMAN[c]; v += -x if i + 1 < len(s) and ROMAN[s[i + 1]] > x else x return v def dmy(s): # 03.04.2026 -> 2026-04-03 d, m, y = s.split("."); return f"{y}-{m}-{d}" def lvdate(s): # 2000.gada 19.oktobrī m = re.search(r"(\d{4})\.\s*gada\s+(\d{1,2})\.\s*([a-zēūīāšžčņļķģ]+)", s) if not m: return None mon = next(v for k, v in MONTHS.items() if m.group(3).startswith(k)) return f"{m.group(1)}-{mon:02d}-{int(m.group(2)):02d}" def text_of(p): """Paragraph text; superscript digits → Unicode superscript (4¹).""" if hasattr(p, "_num"): return p.text # html_source.Para out = [] for r in p.runs: t = r.text out.append(t.translate(SUP) if r.font.superscript and t.strip().isdigit() else t) return re.sub(r"\s+", " ", "".join(out)).strip() def sup_suffix(n): # "4¹" -> ("4","1") base = re.match(r"\d+", n).group(0) rest = n[len(base):].translate(str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹", "0123456789")) return base, rest def idpart(prefix, n): b, s = sup_suffix(n) letter = s[-1].lower() if s[-1:].isalpha() else "" # "644¹⁸A" → art644s18a s = s.rstrip("ABCDEFGHIJKLMNOPQRSTUVWXYZ") return f"{prefix}{b}" + (f"s{s}" if s else "") + letter class Numbering: def __init__(self, d): self.num, self.abs = {}, {} el = d.part.numbering_part.element for a in el.findall(W + "abstractNum"): self.abs[a.get(W + "abstractNumId")] = {l.get(W + "ilvl"): int(l.find(W + "start").get(W + "val")) if l.find(W + "start") is not None else 1 for l in a.findall(W + "lvl")} for n in el.findall(W + "num"): starts = dict(self.abs.get(n.find(W + "abstractNumId").get(W + "val"), {})) for o in n.findall(W + "lvlOverride"): so = o.find(W + "startOverride") if so is not None: starts[o.get(W + "ilvl")] = int(so.get(W + "val")) self.num[str(n.get(W + "numId"))] = starts self.count = {} def next(self, numId, ilvl): numId = str(numId) c = self.count.setdefault(numId, {}) c[ilvl] = c.get(ilvl, self.num.get(numId, {}).get(str(ilvl), 1) - 1) + 1 for k in list(c): if k > ilvl: del c[k] return c[ilvl] def celex(t): """CELEX pirmajam ES aktam tekstā: direktīva 2008/114/EK (L) vai kopš 2015 — direktīva/regula/lēmums (ES) 2022/2557. Veco regulu numurs (EK) Nr. 1049/2001 (numurs/gads) netiek minēts — atstāj bez CELEX.""" for m in re.finditer(r"\((?:ES|ES, Euratom)\)\s*(\d{4})/(\d+)|\b(\d{4})/(\d+)/(?:EK|ES|EEK)\b", t): y, n = (m.group(1), m.group(2)) if m.group(1) else (m.group(3), m.group(4)) before = t[max(0, m.start() - 60):m.start()].lower() k = max(("L", before.rfind("direktīv")), ("R", before.rfind("regul")), ("D", before.rfind("lēmum")), key=lambda x: x[1]) kind = k[0] if k[1] >= 0 else ("L" if m.group(3) else None) if kind: return f"3{y}{kind}{int(n):04d}" return None def numpr(p): if hasattr(p, "_num"): return p._num # html_source.Para: (drukātais numurs, līmenis) pPr = p._p.pPr if pPr is None or pPr.numPr is None or pPr.numPr.numId is None: return None return pPr.numPr.numId.val, (pPr.numPr.ilvl.val if pPr.numPr.ilvl is not None else 0) NOTE = re.compile(r"^\((Ar grozījumiem|\d{2}\.\d{2}\.\d{4}\.? likuma redakcijā|Pants\b|Daļa\b|Punkts\b|Nodaļ)") DELETED = re.compile(r"^\((Izslēgt|izslēgt)") ART = re.compile(r"^(\d+)\.?([⁰¹²³⁴⁵⁶⁷⁸⁹]*[A-Z]?)\.?\s*pants(?:\.|(?=\s|$))\s*(.*)$") AP_SIG = re.compile(r"^((?:Latvijas Republikas )?Augstākās Padomes (?:priekšsēdētāj|sekretār|priekšsēdētāja vietniek)\S*)\s+(.+)$") SECT = re.compile(r"^([A-Z])\s+sadaļa\.?\s*(.*)$") # sadaļa virs nodaļām (PPP likums, kapitāla daļu likums) CHAP = re.compile(r"^([IVXL]+)([⁰¹²³⁴⁵⁶⁷⁸⁹]*|\s[A-C](?=\s))\s+nodaļa\.?\s*(.*)$") INLINE_PART = re.compile(r"^\((\d+[⁰¹²³⁴⁵⁶⁷⁸⁹]+)\)\s*(.*)$") INLINE_POINT = re.compile(r"^(\d+[⁰¹²³⁴⁵⁶⁷⁸⁹]*)\)\s*(.*)$") def E(parent, tag, text=None, **attrs): e = etree.SubElement(parent, f"{{{NS}}}{tag}", {k: str(v) for k, v in attrs.items() if v is not None}) if text is not None: e.text = text return e def convert(path, out): if path.endswith(".html"): sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import html_source ps, nb, raw, src_name, issues = html_source.load(path) src_kind = "likumi.lv konsolidētās redakcijas lapas (HTML)" pase_adopted = html_source.pase(open(path, encoding="utf-8").read()).get("Pieņemts", "") else: import docx d = docx.Document(path) ps = [p for p in d.paragraphs] nb = Numbering(d) issues = [] raw = open(path, "rb").read() src_name = path.split("/")[-1] src_kind = "likumi.lv konsolidētā teksta (Word)" pase_adopted = "" # --- galvene (likumi.lv) --- lines = [text_of(p) for p in ps] head = " ".join(lines[:4]) issuer = re.search(r"Izdevējs:\s*(\S+)", head).group(1) typ = re.search(r"Veids:\s*(\S+)", head).group(1) pub = re.search(r"Latvijas Vēstnesis,\s*([\d/]+)", head) asof = dmy(re.search(r"Attēlotā redakcija:\s*(\d{2}\.\d{2}\.\d{4})", head).group(1)) i = 4 amend_txt = [] if lines[i].startswith("Grozījumi"): i += 1 while lines[i] and not lines[i].startswith("Saeima ir pieņēmusi"): amend_txt.append(lines[i]); i += 1 while not lines[i]: i += 1 formula = None if lines[i].startswith("Saeima ir pieņēmusi"): formula = lines[i]; i += 1 title = lines[i]; i += 1 # --- noslēgums (no beigām) --- tail = {} j = len(lines) - 1 while j > i: t = lines[j] if not t or t.startswith("©"): j -= 1; continue if t.startswith("Rīgā"): tail["prom"] = t elif re.match(r"^Valsts prezident\S*\s+vietā$", t): tail["sig_for"] = t elif re.match(r"^(Valsts prezident|Saeimas priekšsēdētāj)", t): tail["sig"] = t elif t.startswith("Likums Saeimā pieņemts"): tail["adopt"] = t elif AP_SIG.match(t): tail.setdefault("ap_sigs", []).insert(0, t) elif t.startswith("Likums stājas spēkā"): tail["inforce"] = t else: break j -= 1 end = j + 1 adopted = lvdate(tail.get("adopt", "")) if not adopted and pase_adopted: adopted = dmy(pase_adopted.rstrip(".")) issues.append(f"Pieņemšanas datums ņemts no likumi.lv pases: {pase_adopted}") adopted = adopted or "1900-01-01" if adopted == "1900-01-01": issues.append("Pieņemšanas datums nav atrasts") doc_id = f"lv-lik-{adopted}" # divi likumi, pieņemti vienā dienā: otrais saņem -2 (… -9); izšķir likumi.yaml pēc likumi_id lid = os.path.basename(path)[:-5] if path.endswith(".html") else None if lid and os.path.exists("likumi.yaml"): import yaml taken = {e["id"]: str(e.get("likumi_id")) for e in yaml.safe_load(open("likumi.yaml", encoding="utf-8"))} base, k = doc_id, 1 while doc_id in taken and taken[doc_id] != lid: k += 1 if k > 9: raise SystemExit(f"Vairāk nekā 9 likumi pieņemti {adopted}") doc_id = f"{base}-{k}" if k > 1: issues.append(f"Tajā pašā dienā pieņemts cits likums — ID {doc_id}") root = etree.Element(f"{{{NS}}}Law", nsmap={None: NS}, id=doc_id, schemaVersion="0.1") act = E(root, "Act") E(act, "Title", title); E(act, "Issuer", issuer); E(act, "Type", typ); E(act, "Adopted", adopted) E(act, "Published", "Latvijas Vēstnesis" + (f", {pub.group(1)}" if pub else ""), issue=pub.group(1) if pub else None) if "inforce" in tail: f = lvdate(tail["inforce"]) if f: E(act, "InForce", f) tv = E(root, "TextVersion", basis="consolidated", asOf=asof) for m in re.finditer(r"(\d{2}\.\d{2}\.\d{4})\. likums / (.*?) / Stājas spēkā (\d{2}\.\d{2}\.\d{4})\.", " ".join(amend_txt)): E(tv, "Amendment", f"{m.group(1)}. likums", adopted=dmy(m.group(1)), published=m.group(2).strip(), inForce=dmy(m.group(3))) src = E(root, "Source") E(src, "File", src_name); E(src, "SHA256", hashlib.sha256(raw).hexdigest()) E(src, "Retrieved", datetime.date.today().isoformat()) if formula: E(root, "EnactingFormula", formula) body = E(root, "Body") # --- korpuss --- chap = art = part = point = None container = body chap_parent = body # sadaļa, ja likumā tādas ir tp = eu = None mode = "body" last = None # pēdējā vienība, kurai pievienot piezīmi open_note = None chap_no = 0 pending_chap = None used = set() parts_seen = [] def uid(x): if x in used: issues.append(f"Dubults ID {x}") used.add(x); return x def new_art(n, heading): nonlocal art, part, point, parts_seen art = E(container, "Article", id=uid(f"{doc_id}.{idpart('art', n)}"), n=n) if heading: E(art, "Heading", heading) part = point = None; parts_seen = [] return art def add_note(t): nonlocal open_note tgt = last if last is not None else art if tgt is None: issues.append(f"Piezīme bez vienības: {t[:60]}"); return open_note = E(tgt, "Note", t) for k in range(i, end): p = ps[k]; t = lines[k] if not t: continue # turpinājuma rinda nepabeigtai piezīmei "(Ar grozījumiem ... " bez ")" if open_note is not None and open_note.text.count("(") > open_note.text.count(")"): open_note.text += " " + t; continue open_note = None st = p.style.name if pending_chap is not None and st.startswith("Heading 1") and not CHAP.match(t): E(pending_chap, "Title", t); pending_chap = None; continue pending_chap = None ms = SECT.match(t) if ms and st.startswith("Heading 1"): chap_parent = sect = E(body, "Chapter", id=uid(f"{doc_id}.s{ms.group(1)}"), n=ms.group(1), kind="division") if ms.group(2): E(sect, "Title", ms.group(2)) else: pending_chap = sect container = sect; mode = "body"; art = last = None; continue if st.startswith("Heading 1") or (CHAP.match(t) and len(t) < 160): m = CHAP.match(t) chap_no = roman(m.group(1)) if m else chap_no + 1 if not m: issues.append(f"Nodaļai nav numura virsrakstā, pieņemts {chap_no}: {t[:50]}") ttl = m.group(3) if m else t suf = (m.group(2) or "").strip() if m else "" # iestarpināta nodaļa: II¹ → c2.1 (n 2¹); V A → c5.1 (n 5A); V B → c5.2 sub = (int(suf.translate(str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹", "0123456789"))) if suf[:1] in "⁰¹²³⁴⁵⁶⁷⁸⁹" else "ABC".index(suf) + 1) if suf else None chap = E(chap_parent, "Chapter", id=uid(f"{doc_id}.c{chap_no}" + (f".{sub}" if sub else "")), n=str(chap_no) + suf, kind="general" if chap_no == 1 and "Vispārīg" in ttl else "substantive") if ttl: E(chap, "Title", ttl) else: pending_chap = chap container = chap; mode = "body"; art = last = None; continue if t == "Pārejas noteikumi": tp = E(root, "TransitionalProvisions", id=uid(f"{doc_id}.tp")); E(tp, "Title", t) mode = "tp"; last = None; continue if t.startswith("Informatīva atsauce uz Eiropas Savienības"): eu = E(root, "EuReference", id=uid(f"{doc_id}.eu")); E(eu, "Title", t) mode = "eu"; last = None; continue if mode == "eu": if numpr(p) is None and len(eu.findall(f"{{{NS}}}Item")) == 0: E(eu, "Lead", t); continue if numpr(p) is None: it = eu.findall(f"{{{NS}}}Item")[-1]; it.find(f"{{{NS}}}Text").text += " " + t; continue n = len(eu.findall(f"{{{NS}}}Item")) + 1 it = E(eu, "Item", id=uid(f"{doc_id}.eu.{n}")) cx = celex(t) if cx: it.set("celex", cx) E(it, "Text", t); continue if mode == "tp": np_ = numpr(p) if np_ and np_[1] == 0: n = str(nb.next(*np_)) last = point = E(tp, "Point", id=uid(f"{doc_id}.tp.{idpart('p', n)}"), n=n, kind="deleted" if DELETED.match(t) else "transitional", status="deleted" if DELETED.match(t) else None) E(point, "Text", t); continue m = INLINE_POINT.match(t) if (np_ or m) and point is not None: if np_ and last is not None and last.getparent() is not tp and last is not point: parent = last if INLINE_POINT.match(lines[k - 1] or "") else last.getparent() else: parent = point n = m.group(1) if m and not np_ else str(nb.next(*np_)) body_t = m.group(2) if m and not np_ else t sp = E(parent, "Point", id=uid(f"{parent.get('id')}.{idpart('p', n)}"), n=n) E(sp, "Text", body_t); last = sp; continue if NOTE.match(t): last = point if point is not None else last; add_note(t); continue if point is not None and point.find(f"{{{NS}}}Text") is not None: point.find(f"{{{NS}}}Text").text += "\n" + t issues.append(f"Pārejas noteikumu {point.get('n')}. punkts: nenumurēta rindkopa pievienota punktam"); continue issues.append(f"Pārejas noteikumos neklasificēta rinda: {t[:70]}"); continue # --- pants --- m = ART.match(t) if m and (st.startswith("Heading 2") or numpr(p) is None) and len(m.group(2)) < 200: new_art(m.group(1) + m.group(2), m.group(3) or None); last = art; continue if art is None and chap is not None and (NOTE.match(t) or DELETED.match(t)): nt = etree.Element(f"{{{NS}}}Note"); nt.text = t chap.insert(1, nt); open_note = nt; continue if art is None: issues.append(f"Teksts ārpus panta: {t[:70]}"); continue if NOTE.match(t): last = art; add_note(t); continue np_ = numpr(p) mi = INLINE_PART.match(t) if np_ is None else None if (np_ and np_[1] == 0) or mi: if mi: n, txt = mi.group(1), mi.group(2) else: v = nb.next(*np_); n = str(v); txt = t if parts_seen and not getattr(nb, "explicit", False): # Word numerācija "41" pēc "4" = 4¹ prev = parts_seen[-1] pb = sup_suffix(prev)[0] if v != int(pb) + 1 and str(v).startswith(pb) and len(str(v)) > len(pb): n = pb + str(v)[len(pb):].translate(SUP) issues.append(f"{art.get('n')}. pants: numurs {v} interpretēts kā {n}") if art.find(f"{{{NS}}}Text") is not None: issues.append(f"{art.get('n')}. pantā teksts un daļas sajaukti") dele = bool(DELETED.match(txt)) part = E(art, "Part", id=uid(f"{art.get('id')}.{idpart('d', n)}"), n=n, kind="deleted" if dele else None, status="deleted" if dele else None) E(part, "Text", txt); parts_seen.append(n); point = None; last = part; continue if np_ and np_[1] >= 1: parent = part if part is not None else art if parent is art and art.find(f"{{{NS}}}Text") is None: issues.append(f"{art.get('n')}. pants: punkti bez ievadteksta") n = str(nb.next(*np_)) if np_[1] >= 2 and point is not None: parent = point dele = bool(DELETED.match(t)) pt = E(parent, "Point", id=uid(f"{parent.get('id')}.{idpart('p', n)}"), n=n, kind="deleted" if dele else None, status="deleted" if dele else None) E(pt, "Text", t) if np_[1] == 1: point = pt last = pt; continue # nenumurēta rindkopa pantā if art.find(f"{{{NS}}}Part") is None and art.find(f"{{{NS}}}Text") is None: dele = bool(DELETED.match(t)) E(art, "Text", t) if dele: art.set("status", "deleted") last = art; continue tgt = last if last is not None else art tx = tgt.find(f"{{{NS}}}Text") if tx is not None: tx.text += "\n" + t; issues.append(f"{art.get('n')}. pants: nenumurēta rindkopa pievienota iepriekšējai vienībai") else: issues.append(f"Neklasificēta rinda: {t[:70]}") # piezīmes jābūt pēc teksta/punktiem: sakārtot for el in root.iter(f"{{{NS}}}Article", f"{{{NS}}}Part", f"{{{NS}}}Point"): notes = el.findall(f"{{{NS}}}Note") for nt in notes: el.remove(nt); el.append(nt) if "inforce" in tail: E(root, "InForceStatement", tail["inforce"]) if "adopt" in tail: E(root, "Adoption", tail["adopt"]) if "prom" in tail: E(root, "Promulgation", tail["prom"]) if "sig" in tail: role, name = re.match(r"^(Valsts prezident\S*|Saeimas priekšsēdētāj\S*)\s+(.*)$", tail["sig"]).groups() if "sig_for" in tail: role += f" ({tail['sig_for']})" E(root, "Signatory", name, role=role) for t in tail.get("ap_sigs", []): role, name = AP_SIG.match(t).groups() E(root, "Signatory", name, role=role) # pants, kas pieņemts, bet vēl nav spēkā: likumi.lv rāda tikai nosaukumu un piezīmi for a in root.iter(f"{{{NS}}}Article"): if a.find(f"{{{NS}}}Text") is None and a.find(f"{{{NS}}}Part") is None: nts = " ".join(x.text or "" for x in a.findall(f"{{{NS}}}Note")) if re.search(r"stājas spēkā \d{2}\.\d{2}\.\d{4}\.? un iekļauts likuma redakcijā", nts): a.set("status", "pending"); issues.append(f"{a.get('n')}. pants vēl nav spēkā — bez teksta (status pending)") else: issues.append(f"{a.get('n')}. pantam nav teksta") # likumi.lv identifikators (HTML avots) if lid: act_el = root.find(f"{{{NS}}}Act") if act_el.find(f"{{{NS}}}LikumiId") is None: E(act_el, "LikumiId", lid); E(act_el, "Url", f"https://likumi.lv/ta/id/{lid}") # pielikumi (tikai HTML avots) ann_chars = 0 for idx, a in enumerate(getattr(nb, "annexes", []), 1): mn = re.search(r"(\d+)\.?\s*([⁰¹²³⁴⁵⁶⁷⁸⁹]*)\.?\s*pielikums", a["heading"]) an = (mn.group(1) + mn.group(2)) if mn else str(idx) ann = E(root, "Annex", id=uid(f"{doc_id}.{idpart('ann', an)}"), n=an) E(ann, "Heading", a["heading"]) if a["title"]: E(ann, "Title", a["title"]) for ln in a["lines"]: E(ann, "Line", ln) ann_chars += sum(len(re.sub(r"\s", "", x)) for x in [a["heading"], a["title"] or "", *a["lines"]]) # viena ES akta atsauce bez saraksta: ievadteikums ir vienīgais punkts for eu in root.findall(f"{{{NS}}}EuReference"): lead = eu.find(f"{{{NS}}}Lead") if lead is not None and not eu.findall(f"{{{NS}}}Item"): eu.remove(lead) it = E(eu, "Item", id=uid(f"{doc_id}.eu.1")) cx = celex(lead.text) if cx: it.set("celex", cx) E(it, "Text", lead.text) # elementu secība saskaņā ar XSD order = ["Act", "TextVersion", "Source", "EnactingFormula", "Body", "TransitionalProvisions", "EuReference", "InForceStatement", "Adoption", "Promulgation", "Signatory", "Annex", "DataVersion"] kids = list(root) for c in kids: root.remove(c) for name in order: for c in kids: if etree.QName(c).localname == name: root.append(c) dv = E(root, "DataVersion", number=1, date=datetime.date.today().isoformat()) E(dv, "Change", f"Pirmā versija: konvertēts no {src_kind} ar tools/convert.py") etree.ElementTree(root).write(out, encoding="UTF-8", xml_declaration=True, pretty_print=True) # pilnīguma pārbaude: avota teksta zīmes (bez galvenes) pret XML tekstu def body_chars(x): # numuri, kas kļūst par atribūtiem (Article@n, Chapter@n), netiek skaitīti x = re.sub(r"^\d+\.?[⁰¹²³⁴⁵⁶⁷⁸⁹]*[A-Z]?\.?\s*pants(?:\.|(?=\s|$))", "", x) x = re.sub(r"^[IVXL]+([⁰¹²³⁴⁵⁶⁷⁸⁹]*|\s[A-C])?\s+nodaļa\.?", "", x) return len(re.sub(r"\s", "", x)) src_chars = sum(body_chars(x) for x in lines[i - 1:end] if x) + ann_chars xml_chars = sum(len(re.sub(r"\s", "", x)) for x in root.itertext() if x) - sum(len(re.sub(r"\s", "", e.text or "")) for e in root.iter(f"{{{NS}}}Change", f"{{{NS}}}File", f"{{{NS}}}SHA256", f"{{{NS}}}Retrieved", f"{{{NS}}}Issuer", f"{{{NS}}}Type", f"{{{NS}}}Adopted", f"{{{NS}}}Published", f"{{{NS}}}InForce", f"{{{NS}}}Amendment", f"{{{NS}}}Signatory", f"{{{NS}}}EnactingFormula", f"{{{NS}}}Adoption", f"{{{NS}}}InForceStatement", f"{{{NS}}}Promulgation", f"{{{NS}}}LikumiId", f"{{{NS}}}Url")) # noslēgums nav avota skaitā counts = {k: len(list(root.iter(f"{{{NS}}}{k}"))) for k in ["Chapter", "Article", "Part", "Point", "Note", "Amendment"]} rep = {"id": doc_id, "counts": counts, "sourceChars": src_chars, "xmlChars": xml_chars, "issues": issues} json.dump(rep, open(out.replace(".xml", ".verify.json"), "w"), ensure_ascii=False, indent=1) return rep if __name__ == "__main__": print(json.dumps(convert(sys.argv[1], sys.argv[2]), ensure_ascii=False, indent=1))