# This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this file. If not, see . # # Copyright © 2012-2026 The University of Tromsø & # the Norwegian Sámi Parliament # http://giellatekno.uit.no & http://divvun.no # """Convert bible sfm (usfm) files to the Giella xml format.""" import re from lxml import etree # The marker at the start of a line, e.g. \p, \mt1 or \v LINE_MARKER_RE = re.compile(r"\s*\\(?P[a-z]+[0-9]*)\s*") # Footnotes (\f … \f*), cross references (\x … \x*) and published verse # numbers (\vp … \vp*) are not part of the running text, so everything from # the opening to the closing marker is thrown away. NOTE_RE = re.compile(r"\\(f|x|vp)\b.*?\\\1\*", re.DOTALL) # The verse number following a \v marker VERSE_NUMBER_RE = re.compile(r"^[0-9]+[-0-9]*\s*") # Letters written with a combining macron in the 1895 bible, but with a # caron in modern orthography MACRON_RE = re.compile("([cCsSʒƷ])̄") CARON_LETTERS = {"c": "č", "C": "Č", "s": "š", "S": "Š", "ʒ": "ǯ", "Ʒ": "Ǯ"} # Markers starting a paragraph of running text TEXT_MARKERS = set(["p", "m"]) # Markers starting a p element containing a title TITLE_MARKERS = set(["mt1", "mt2", "s1"]) # \s2 holds the summary of a chapter, which is running text of its own SUMMARY_MARKER = "s2" # Markers carrying no text of interest: file identification, running header, # table of contents and published chapter number IGNORED_MARKERS = set(["id", "ide", "h", "toc1", "toc2", "toc3", "cp"]) def macron_to_caron(text): """Replace letters written with a macron with their caron equivalents. The 1895 bible writes č, š and ǯ as c, s and ʒ with a combining macron. Args: text (str): a text string. Returns: (str): the string with carons instead of macrons. """ return MACRON_RE.sub(lambda match: CARON_LETTERS[match.group(1)], text) def clean_text(text): """Normalise the whitespace and the macron letters of a text string. Args: text (str): a text string. Returns: (str): the normalised string. """ return macron_to_caron(" ".join(text.split())) def parse_line(line): """Split an sfm line into its marker and its text. Footnotes and cross references are removed from the text. Args: line (str): a line from an sfm file. Returns: (tuple[str | None, str]): the name of the marker (None if the line does not start with a marker) and the text of the line. """ line = NOTE_RE.sub("", line) match = LINE_MARKER_RE.match(line) if match is None: return None, clean_text(line) return match.group("name"), clean_text(line[match.end() :]) def add_text(paragraph, text): """Add text to a p element, one verse per line. Args: paragraph (lxml.etree.Element): a Giella xml p element. text (str): the text that should be added. """ if not text: return paragraph.text = f"{paragraph.text}\n{text}" if paragraph.text else text def remove_empty(body): """Remove p and section elements without text from the body. Args: body (lxml.etree.Element): a Giella xml body element. """ for paragraph in body.findall(".//p"): if paragraph.text is None: paragraph.getparent().remove(paragraph) for section in body.findall("section"): if not len(section): body.remove(section) def parse_sfm(lines): """Convert the lines of an sfm file to a Giella xml document. Each chapter becomes a section holding one p element, where each verse is a line. Titles end the p element, so a chapter with titles in the middle of it gets one p element per title. Args: lines (collections.abc.Iterable[str]): the lines of an sfm file. Returns: (lxml.etree.Element): a Giella xml document element. """ document = etree.Element("document") body = etree.SubElement(document, "body") section = etree.SubElement(body, "section") paragraph = None for line in lines: marker, text = parse_line(line) if marker in IGNORED_MARKERS: continue if marker == "c": section = etree.SubElement(body, "section") paragraph = None elif marker in TITLE_MARKERS: title = etree.SubElement(section, "p") title.set("type", "title") add_text(title, text) paragraph = None elif marker == SUMMARY_MARKER: summary = etree.SubElement(section, "p") add_text(summary, text) paragraph = None elif marker in TEXT_MARKERS or marker == "v" or marker is None: if marker == "v": text = VERSE_NUMBER_RE.sub("", text) if paragraph is None: paragraph = etree.SubElement(section, "p") add_text(paragraph, text) else: raise UserWarning(f"Unknown sfm marker: \\{marker}") remove_empty(body) return document def convert2intermediate(filename): """Convert an sfm file to the intermediate Giella xml format.""" with open(filename, encoding="utf-8") as sfm_file: return parse_sfm(sfm_file)