1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""Rebuild code listings from spans, keeping keyword weight and spacing.

pdftotext -layout gets a listing's *shape* right but throws away font
weight, and it pads tokens apart ("X , O", "board [ row ][ col ]") because
it reconstructs columns from x-positions.  The -wbt 40 span extraction has
neither problem: it preserves the bold runs acmart uses for Forge keywords
and keeps each token's real text.

So listings are rebuilt here from spans:
  * spans are grouped into lines by their `top`,
  * indentation is derived from the first span's `left`, using the width
    of one Inconsolata character as the column unit,
  * a gap between two spans becomes a space only if it is wide enough to
    be one (adjacent tokens like "b.board" have no gap at all).
"""

import html as htmllib
import re

LIGATURES = {"\ufb00": "ff", "\ufb01": "fi", "\ufb02": "fl",
             "\ufb03": "ffi", "\ufb04": "ffl"}

SPAN_RE = re.compile(
    r'<text top="([\d.]+)" left="([\d.]+)" width="([\d.]+)" '
    r'height="([\d.]+)" font="(\d+)">(.*?)</text>',
    re.S,
)
PAGE_RE = re.compile(r'<page number="(\d+)".*?(?=<page number=|</pdf2xml>)',
                     re.S)
FONTSPEC_RE = re.compile(
    r'<fontspec id="(\d+)" size="([\d.]+)" family="([^"]+)" color="([^"]+)"')

# Inconsolata is monospaced; at the sizes used here one character is about
# this many units wide.  Measured from single-character spans, see width_unit.
DEFAULT_UNIT = 7.3


def _fix(s):
    for k, v in LIGATURES.items():
        s = s.replace(k, v)
    return s


class CodeSpan:
    __slots__ = ("top", "left", "width", "text", "bold", "mono", "italic")

    def __init__(self, top, left, width, text, bold, mono=True, italic=False):
        self.top, self.left, self.width = top, left, width
        self.text, self.bold = text, bold
        self.mono, self.italic = mono, italic

    @property
    def right(self):
        return self.left + self.width


def load(path="spans_code.xml"):
    """page -> list of CodeSpan.

    Monospace spans are the listing itself; roman spans are kept too,
    because a displayed line may mix the two ("no x, y: A | ... *is not
    equivalent to* no x: A | no y: A | ...").  Dropping them would lose
    prose.  `mono` records which is which so the renderer can tell.
    """
    raw = open(path, encoding="utf-8").read()
    fonts = {i: (float(sz), fam.split("+")[-1])
             for i, sz, fam, _c in FONTSPEC_RE.findall(raw)}
    out = {}
    for pm in PAGE_RE.finditer(raw):
        page = int(pm.group(1))
        for m in SPAN_RE.finditer(pm.group(0)):
            size, family = fonts[m.group(5)]
            mono = "Inconsolata" in family or "SFTT" in family
            inner = m.group(6)
            bold = "<b>" in inner or family.endswith(("TB", "-Bold"))
            italic = family.endswith(("TI", "-Italic"))
            text = _fix(htmllib.unescape(re.sub(r"<[^>]+>", "", inner)))
            if not text.strip():
                continue
            out.setdefault(page, []).append(
                CodeSpan(float(m.group(1)), float(m.group(2)),
                         float(m.group(3)), text, bold, mono, italic))
    return out


def width_unit(spans):
    """Width of one character, measured from the monospace spans only."""
    widths = [s.width / len(s.text)
              for s in spans if s.mono and len(s.text) >= 3]
    if not widths:
        return DEFAULT_UNIT
    widths.sort()
    return widths[len(widths) // 2]


def group_rows(spans, tol=3.0):
    """Group spans into visual rows; bold and roman differ slightly in top."""
    rows = []
    for s in sorted(spans, key=lambda s: (s.top, s.left)):
        if rows and abs(s.top - rows[-1][0].top) <= tol:
            rows[-1].append(s)
        else:
            rows.append([s])
    for r in rows:
        r.sort(key=lambda s: s.left)
    return rows


def render_row(row, unit, base_left, escape, bold_wrap, roman_wrap=None):
    """Render one line of a listing.

    `roman_wrap` marks up the non-monospace runs that some displayed lines
    interleave with code; without it that prose would be lost.
    """
    indent = max(0, int(round((row[0].left - base_left) / unit)))
    out = [" " * indent]
    prev = None
    for s in row:
        if prev is not None:
            gap = s.left - prev.right
            # A gap of about one character or more is a real space; the
            # sub-pixel gaps between adjacent tokens ("b" "." "board") are
            # not.  Rounding keeps runs like ") -> Player }" intact.
            spaces = int(round(gap / unit)) if gap > unit * 0.45 else 0
            if not spaces and gap > 0.8 and not s.mono:
                spaces = 1
            out.append(" " * spaces)
        txt = escape(s.text)
        if not s.mono and roman_wrap is not None:
            out.append(roman_wrap(txt, s))
        elif s.bold:
            out.append(bold_wrap(txt))
        else:
            out.append(txt)
        prev = s
    return "".join(out).rstrip()


def block_for(page, rows_wanted, unit_cache, escape, bold_wrap):
    """Render the rows of `page` that match a wanted signature."""
    raise NotImplementedError