#!/usr/bin/env python3
#
# This file is licensed under the MIT No Attribution license.
#

"""Recover word spaces and displayed lines in dvisvgm's PDF output."""

import copy
import difflib
import re
import subprocess
import sys
import xml.etree.ElementTree as ET


SVG = "{http://www.w3.org/2000/svg}"
XLINK = "{http://www.w3.org/1999/xlink}"
ET.register_namespace("", SVG[1:-1])
ET.register_namespace("xlink", XLINK[1:-1])


def characters(text):
    return "".join(c for c in text if not c.isspace())


def extracted_lines(page):
    text = []
    positions = []
    for number, line in enumerate(page.iter("line")):
        space = False
        for char in line.iter("char"):
            for c in char.get("c", ""):
                if c.isspace():
                    space = True
                else:
                    text.append(c)
                    positions.append((number, space))
                    space = False
    return "".join(text), positions


def align(text, extracted, positions):
    if text == extracted:
        return positions
    # Clipping, overprinting, and unusual glyph encodings can make the two
    # extractors disagree. Recover the matching portions without replacing
    # or reordering any of the SVG's original characters.
    result = [None] * len(text)
    matcher = difflib.SequenceMatcher(None, text, extracted, autojunk=False)
    for a, b, size in matcher.get_matching_blocks():
        result[a:a + size] = positions[b:b + size]
    return result


def append_space(text):
    last = list(text.iter())[-1]
    last.text = (last.text or "") + " "


def xml_character(match):
    code = int(match[1], 16) if match[1] else int(match[2])
    if (code in (9, 10, 13) or 0x20 <= code <= 0xD7FF
            or 0xE000 <= code <= 0xFFFD or 0x10000 <= code <= 0x10FFFF):
        return match[0]
    return b"&#xfffd;"


def recover(root, page):
    texts = list(root.iter(SVG + "text"))
    if not texts:
        return
    original = "".join("".join(t.itertext()) for t in texts)
    extracted, positions = extracted_lines(page)
    positions = align(characters(original), extracted, positions)
    parents = {child: parent for parent in root.iter() for child in parent}
    offset = 0
    previous = None

    for text in texts:
        parent = parents[text]
        # dvisvgm emits a string followed by flat, positioned tspans.
        # Leave any other text structure alone.
        if any(child.tag != SVG + "tspan" or len(child) or child.tail
               for child in text):
            offset += len(characters("".join(text.itertext())))
            previous = None
            continue
        runs = [(None, text.text or "")]
        runs.extend((child, child.text or "") for child in text)
        fragments = []
        fragment = None
        line = None
        y = text.get("y")

        for node, value in runs:
            if not value:
                continue
            length = len(characters(value))
            location = positions[offset] if length else None
            offset += length
            if node is not None:
                y = node.get("y", y)
            # A new absolute x position lets us add a separator or split a
            # line without changing where the next visible glyph is drawn.
            anchored = node is None or "x" in node.attrib
            next_line = location[0] if location is not None else line
            if fragment is None or (anchored and next_line != line):
                fragment = ET.Element(text.tag, dict(text.attrib))
                fragment.attrib.pop("id", None)
                if node is not None:
                    fragment.set("x", node.get("x", text.get("x")))
                    fragment.set("y", y)
                fragment.set("style", "display:block;" + fragment.get("style", ""))
                fragments.append((fragment, next_line, bool(location and location[1])))
            elif (anchored and location is not None and location[1]
                  and value and not value[0].isspace()
                  and not "".join(fragment.itertext()).endswith(tuple(" \t\r\n"))):
                append_space(fragment)
            line = next_line
            if node is None:
                fragment.text = value
            else:
                child = copy.deepcopy(node)
                fragment.append(child)

        if text.get("id") and fragments:
            fragments[0][0].set("id", text.get("id"))
        index = list(parent).index(text)
        parent.remove(text)
        for fragment, line, space in fragments:
            # Font changes start new <text> elements in dvisvgm. Bring
            # adjacent runs on the same line together as styled tspans.
            compatible = (previous is not None and line is not None
                          and previous[1] == line and previous[2] is parent
                          and index > 0 and parent[index - 1] is previous[0]
                          and fragment.get("transform") == previous[0].get("transform"))
            if compatible:
                target = previous[0]
                if (space and not "".join(target.itertext()).endswith(tuple(" \t\r\n"))
                        and not "".join(fragment.itertext()).startswith(tuple(" \t\r\n"))):
                    append_space(target)
                fragment.tag = SVG + "tspan"
                fragment.attrib.pop("transform", None)
                fragment.set("style", fragment.get("style", "").removeprefix("display:block;"))
                if not fragment.get("style"):
                    fragment.attrib.pop("style", None)
                target.append(fragment)
            else:
                parent.insert(index, fragment)
                index += 1
                previous = (fragment, line, parent)


def main():
    pdf, *paths = sys.argv[1:]
    result = subprocess.run(
        ["mutool", "draw", "-q", "-F", "stext", "-o", "-", pdf],
        check=True, stdout=subprocess.PIPE)
    # Some PDF fonts yield control codes that XML cannot represent. Treat
    # those as unmapped characters when aligning with the original SVG.
    xml = re.sub(rb"&#(?:x([0-9a-fA-F]+)|([0-9]+));", xml_character, result.stdout)
    pages = {page.get("id"): page for page in ET.fromstring(xml).iter("page")}
    for path in paths:
        tree = ET.parse(path)
        root = tree.getroot()
        # Font changes now also appear on tspans within a line.
        for style in root.iter(SVG + "style"):
            style.text = re.sub(r"\btext(\.f\d+)\b", r"\1", style.text or "")
        for group in root.iter(SVG + "g"):
            if group.get("id") in pages:
                recover(group, pages[group.get("id")])
        # The PDF page wrapper adds xlink hyperlinks after this step.
        if not any(name.startswith(XLINK) for element in root.iter()
                   for name in element.attrib):
            root.set("xmlns:xlink", XLINK[1:-1])
        tree.write(path, encoding="utf-8", xml_declaration=True)


if __name__ == "__main__":
    main()
