"""Segmented translation round trip: prose out, translations back in.
A translator model mangles anything that is not plain prose — sentinels get
renumbered, ``![`` becomes sentence punctuation, stray ``
`` tags appear.
So the model is never shown any of it: a fragment (a Markdown chunk or a
node title) is parsed with the project's own markdown-it setup
(``markdown.make_md(verbatim=True)`` — extensions included, so container,
attrs, footnote and tasklist syntax never leaks into text tokens) and split
into **prose segments**: the merged text runs, plus image alt texts and
link/image titles. Only those cross the wire, as a plain list of strings
(Job.texts / Result.texts in translate.py) — accompanied, per segment, by
a CONTEXT (Job.contexts): a segment carved out of a larger block (a link
text, a partial run) carries the block's plain text, so the model sees the
sentence it lives in; whole-block segments are self-contextualizing and
carry "". Title fragments carry the article's opening instead (assigned by
the dispatcher from TransItem.context).
Reassembly is server-side offset splicing, not text the model produced:
each segment's source span was located at dispatch (``split``), and
``join`` swaps in the translations. Markup therefore cannot break — it
never left the server. A returned segment must still be pure prose itself
(the model could inject markup INTO a segment); anything else — count
mismatch, empty segment, markup tokens — rejects the whole result and the
fragment stays pending. Punctuation that is prose on the wire but syntax
in the splice context (quotes in a title attribute, brackets in an alt
text, "|" in a table row) is not worth a rejection either: it is swapped
for Unicode look-alikes (``_NEUTRAL``) before splicing.
A block of plain text, prose links and paired text formatting
(strong/em/s) crosses as ONE segment — link texts and formatted text
inline, in sentence context, with the Markdown stripped (the model
mangles it: sentinels get renumbered, ``**`` gets dropped or moved) —
because a label translated apart from its sentence comes back
grammatically incompatible with it (case government, particles, word
order). ``join`` re-inserts the link/formatting markdown into the
translated block at fuzzily matched positions (``_place_marks``): no
markers on the wire, the boundaries are found by aligning the mark's
source words to the translation's words by form similarity (``_find_mark``
— inflection, dropped articles and reordering tolerated), with the
source/translation weight ratio as fallback (the CJK path, where
cross-script form similarity is nil). Placement is approximate: better a
coherent sentence with a slightly shifted link than separately translated
snippets that don't fit together. Blocks with any other inline markup
(code, images, HTML) still split into runs at those boundaries.
Locating is best effort: a run that is not a verbatim source substring
(entity-decoded text, backslash escapes) is skipped — it simply stays in
the original language. A literal "<" in prose ("<1MB") is text, not
markup, but cannot cross as-is — "<" is the prose/markup boundary on the
wire, translators cut their output there — so it crosses encoded as the
fullwidth "<" (``_encode``) and ``join`` decodes it back before
validating and splicing.
"""
import bisect
import difflib
import re
from typing import NamedTuple
from pagerite.markdown import make_md
#: The segmentation parser: the project's own markdown-it, verbatim flavor
#: (see make_md). Never used for rendering.
_MD = make_md(verbatim=True)
#: Any Unicode letter (digits and underscore are not prose).
_LETTER = re.compile(r"[^\W\d_]")
#: A GFM alert marker ([!NOTE] etc.) at the start of a blockquote's first
#: paragraph: syntax, not prose — stripped from the first segment.
_ALERT = re.compile(r"^\[![A-Za-z]+\][ \t]*")
#: Any {...} span: {placeholders} and attrs that ended up inside prose
#: (inline attrs are consumed by the parser; a lone {dates} is not).
_BRACES = re.compile(r"\{[^{}\n]*\}")
def _encode(text: str) -> str:
"""Wire form of a segment or context: a literal "<" as fullwidth "<".
A "<" in prose is text, not markup ("<1MB" — a tag needs a letter or
/!?), but "<" is the prose/markup boundary on the wire (translators
cut output at the first "<", scripts/translator.py), so it cannot
cross as-is. join decodes it back before the pure_prose check and
splicing — anything tag-like the model may have formed around it is
still rejected there.
"""
return text.replace("<", "<")
#: ASCII punctuation that is plain prose to the inline parser (so
#: pure_prose cannot catch it) but Markdown SYNTAX in a splice context:
#: quotes close a quoted image/link title, brackets the [...] of alt and
#: re-inserted link texts, "|" splits a table row, and "\" escapes the
#: character after it (a trailing one eats a title's closing quote).
#: Neutralized to Unicode look-alikes (join), which Markdown treats as
#: plain text everywhere — the quotes are curled the way typographer=True
#: renders them anyway.
_NEUTRAL = str.maketrans(
{
'"': "”",
"'": "’",
"[": "[",
"]": "]",
"\\": "\",
"|": "│",
}
)
#: A link's tail after its text: "](dest)", "](dest \"title\")", "][ref]",
#: "[]" or a bare "]" (shortcut reference); the destination may nest one
#: level of parens. Best effort — a mis-scan fails the span-reconstruction
#: check in _linked_block and the block falls back to per-run segments.
_LINK_TAIL = re.compile(r"\](?:\((?:\\.|[^()\\]|\([^()]*\))*\)|\[(?:\\.|[^\]])*\])?")
#: Weight units for mapping link boundaries from source to translation:
#: a word counts 1 and so does every single CJK ideograph (kana runs count
#: as one) — CJK has no spaces to count words by. Punctuation and
#: whitespace count nothing, so mapped boundaries always land on unit
#: starts.
_UNIT = re.compile(
r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]" # CJK ideographs: one unit each
r"|[\u3040-\u309f\u30a0-\u30ff]+" # kana runs: one unit each
r"|\w+" # anything else word-like (Latin, Cyrillic, Hangul, digits)
)
class Mark(NamedTuple):
"""One inline link or paired formatting (strong/em/s) inside a
whole-block segment: the source weight (unit count, see _UNIT) at the
inner text's start and end (fallback for mapping the boundaries into
the translation when fuzzy word alignment finds nothing, _find_mark),
the exact source syntax around the text ("[" / "](url)", "**" / "**",
...) and the source text itself — the words fuzzy alignment looks for,
and the fallback when the mapped slice comes out empty (better an
untranslated label than a broken "[](url)")."""
w_start: int
w_end: int
pre: str
post: str
inner: str
class Span(NamedTuple):
"""A segment's source span in the fragment: offsets for splicing the
translation back, the segment's source weight and the links to
re-insert into its translation (empty = a plain prose segment)."""
start: int
end: int
weight: int
marks: list[Mark]
def _weight(text: str) -> int:
"""The text's weight in translation-mapping units (see _UNIT)."""
return len(_UNIT.findall(text))
def _runs(children: list) -> list[str]:
"""Prose runs of an inline token's children, in order.
Text tokens merge across soft breaks into one run; every markup token
(emphasis, links, code, images, HTML, footnote refs, hard breaks) is a
run boundary. Link and image *text* is prose; autolink text (the URL
itself) is not. Image tokens contribute their alt-text children and
their title attribute.
"""
runs: list[str] = []
cur: list[str] = []
def flush() -> None:
if cur:
s = "".join(cur)
cur.clear()
if _LETTER.search(s):
runs.append(s)
skip = 0 # inside an autolink (its text is the URL — not prose)
for t in children:
if skip:
if t.type == "link_close":
skip -= 1
continue
if t.type == "text":
cur.append(t.content)
elif t.type == "softbreak":
cur.append("\n")
elif t.type == "link_open" and t.markup == "autolink":
flush()
skip = 1
elif t.type == "image":
flush()
if t.children:
runs.extend(_runs(t.children))
title = t.attrGet("title")
if title and _LETTER.search(title):
runs.append(title)
else:
flush()
if t.children:
runs.extend(_runs(t.children))
flush()
return runs
def _block_text(children: list) -> str:
"""The block's text as a reader sees it: text runs and link texts
merged (softbreaks as newlines); image alts, autolink URLs, code and
other markup content excluded. Used as the translation CONTEXT for
segments carved out of the block (link texts, partial runs): a lone
word translates differently than the same word inside its sentence."""
parts: list[str] = []
skip = 0 # inside an autolink (its text is the URL)
for t in children:
if skip:
if t.type == "link_close":
skip -= 1
continue
if t.type == "text":
parts.append(t.content)
elif t.type == "softbreak":
parts.append("\n")
elif t.type == "link_open" and t.markup == "autolink":
skip = 1
elif t.type == "image":
continue
elif t.children:
parts.append(_block_text(t.children))
return "".join(parts)
def _locate(source: str, needle: str, cursor: int) -> int:
"""The needle's offset in source at/after cursor, -1 when absent.
An occurrence preceded by a backslash is an escaped character, not the
token's source: keep looking (failing that, the run is skipped — it
stays in the original language).
"""
pos = source.find(needle, cursor)
while pos > 0 and source[pos - 1] == "\\":
pos = source.find(needle, pos + 1)
return pos
def _linked_block(
source: str, kids: list, cursor: int, strip_alert: bool
) -> tuple[Span, str] | None:
"""A whole-block segment for an inline of plain text, prose links and
paired text formatting (strong/em/s): (Span, wire text) with the links
and formatting as marks, or None when the block has any other shape —
the caller then falls back to per-run segments.
The block crosses the wire as one prose piece, link texts and formatted
text inline (the model is never shown any Markdown — it mangles it),
so a translation that inflects or reorders around them stays coherent;
join re-inserts the link/formatting syntax at weight-mapped positions.
The source span is located piece by piece and verified by
reconstruction; anything not byte-exact (entities, escapes, an odd
link tail) bails to the fallback.
"""
pieces: list[
tuple[str, str]
] = [] # (text, mark): "" plain, "link", else the delimiter
buf: list[str] = [] # current plain piece
link: list[str] | None = None # current mark's text parts
mark_kind = "" # the current mark's opener ("link" or the delimiter)
for tok in kids:
if tok.type in ("link_open", "strong_open", "em_open", "s_open"):
if link is not None or tok.markup == "autolink":
return None
if buf:
pieces.append(("".join(buf), ""))
buf = []
link = []
mark_kind = "link" if tok.type == "link_open" else tok.markup
elif tok.type in ("link_close", "strong_close", "em_close", "s_close"):
if (
link is None
or ("link" if tok.type == "link_close" else tok.markup) != mark_kind
):
return None
inner = "".join(link)
if not _LETTER.search(inner):
return None
pieces.append((inner, mark_kind))
link = None
elif tok.type in ("text", "softbreak"):
(link if link is not None else buf).append(
"\n" if tok.type == "softbreak" else tok.content
)
else: # code, images, HTML, footnote refs: run boundaries
return None
if link is not None:
return None # unbalanced (the parser should not do this)
if buf:
pieces.append(("".join(buf), ""))
if not any(mark for _, mark in pieces):
return None
if strip_alert and pieces and not pieces[0][1]:
# A GFM alert marker leading the blockquote's first paragraph is
# syntax; strip it from the wire text (it stays out of the span).
first = _ALERT.sub("", pieces[0][0], count=1)
if first.strip():
pieces[0] = (first, "")
else:
pieces.pop(0)
if not pieces:
return None
raw = "".join(text for text, _ in pieces)
lead = len(raw) - len(raw.lstrip())
wire = raw.strip()
if not _LETTER.search(wire) or _BRACES.search(wire):
return None
# Locate each piece verbatim, in order; the source slices between the
# located pieces are then the link syntax, exact by construction.
located: list[tuple[int, int]] = []
pos = cursor
for text_, _ in pieces:
at = _locate(source, text_, pos)
if at == -1:
return None
located.append((at, at + len(text_)))
pos = at + len(text_)
span_start, span_end = located[0][0], located[-1][1]
marks: list[Mark] = []
offset = 0 # raw (pre-strip) plain-text offset of the current piece
for i, ((text_, kind), (s, e)) in enumerate(zip(pieces, located)):
if not kind:
offset += len(text_)
continue
# The syntax around the text: the gap between pieces goes to the
# mark on its left as post (so between two marks the whole "](u)["
# or "**" is the first's post); a block-leading mark takes its
# opener in front of its text ("[" or the delimiter), a
# block-trailing one the scanned link tail or the close delimiter.
if i == 0:
opener = "[" if kind == "link" else kind
if s < len(opener) or source[s - len(opener) : s] != opener:
return None
pre, span_start = opener, s - len(opener)
elif pieces[i - 1][1]:
pre = "" # the previous mark's post covers the whole gap
else:
pre = source[located[i - 1][1] : s]
if i + 1 < len(pieces):
post = source[e : located[i + 1][0]]
elif kind == "link":
m = _LINK_TAIL.match(source, e)
if m is None:
return None
post, span_end = m.group(), m.end()
else:
if source[e : e + len(kind)] != kind:
return None
post, span_end = kind, e + len(kind)
ps = min(max(offset - lead, 0), len(wire))
pe = min(max(offset + len(text_) - lead, 0), len(wire))
if pe <= ps:
return None
marks.append(
Mark(_weight(wire[:ps]), _weight(wire[:pe]), pre, post, wire[ps:pe])
)
offset += len(text_)
# Verify: the marks must reconstruct the source span exactly (the only
# real risk is the guessed tail of a trailing link).
rec: list[str] = []
mi = 0
for text_, kind in pieces:
if kind:
mark = marks[mi]
mi += 1
rec += [mark.pre, text_, mark.post]
else:
rec.append(text_)
if source[span_start:span_end] != "".join(rec):
return None
return Span(span_start, span_end, _weight(wire), marks), _encode(wire)
def split(text: str) -> tuple[list[Span], list[str], list[str]]:
"""Split a fragment into (spans, segments, contexts): prose segments to
translate, their source spans in ``text`` for splicing the translations
back, and per-segment translation context.
A block of plain text, prose links and paired formatting (strong/em/s)
becomes ONE segment (link/formatted text inline, in context, Markdown
stripped), the links and formatting recorded as marks on its Span for
weight-mapped re-insertion in join. Other blocks split into text runs
at markup boundaries; runs containing {...} spans are carved further —
the braces stay out of the wire text. A run that cannot be located
verbatim in the source contributes no segment. A segment's context is
its block's plain text when the segment was carved OUT of a larger
block (a partial run); a segment that IS the whole block (a plain
paragraph, a heading, a linked block) is self-contextualizing and gets
"".
"""
spans: list[Span] = []
segments: list[str] = []
contexts: list[str] = []
cursor = 0
blockquote_fresh = 0 # blockquote depth whose first inline is upcoming
def emit(run: str, at: int, ctx: str) -> None:
"""Carve {...} spans out of the located run; emit the prose pieces,
stripped — padding whitespace stays in the template, off the wire.
A literal "<" crosses encoded (``_encode``): it is text, not
markup, but the wire keeps "<" as the prose/markup boundary."""
pieces = []
pos = 0
for m in _BRACES.finditer(run):
pieces.append((pos, m.start()))
pos = m.end()
pieces.append((pos, len(run)))
for p0, p1 in pieces:
raw = run[p0:p1]
piece = raw.strip()
if _LETTER.search(piece):
start = at + p0 + (len(raw) - len(raw.lstrip()))
spans.append(Span(start, start + len(piece), 0, []))
segments.append(_encode(piece))
contexts.append(ctx)
tokens = _MD.parse(text)
for t in tokens:
if t.type == "blockquote_open":
blockquote_fresh += 1
elif t.type == "blockquote_close":
blockquote_fresh -= 1
elif t.type == "inline":
kids = t.children or []
# An alert marker ([!NOTE]) leading a blockquote's first
# paragraph is syntax; both paths strip it. (Only the first
# inline of the blockquote can carry it — the flag clears on
# the first inline seen.)
alert = bool(blockquote_fresh)
blockquote_fresh = 0
linked = _linked_block(text, kids, cursor, strip_alert=alert)
if linked is not None:
span, wire = linked
spans.append(span)
segments.append(wire)
contexts.append("")
cursor = span.end
continue
runs = _runs(kids)
block = _encode(_block_text(kids).strip())
if alert and runs:
run = _ALERT.sub("", runs[0], count=1)
if _LETTER.search(run):
runs[0] = run
else:
runs.pop(0)
for run in runs:
ctx = block if block and _encode(run.strip()) != block else ""
pos = _locate(text, run, cursor)
if pos != -1:
emit(run, pos, ctx)
cursor = pos + len(run)
elif "\n" in run:
# Indented continuation lines etc. break the verbatim
# match: locate each line separately instead.
for part in run.split("\n"):
if not _LETTER.search(part):
continue
pos = _locate(text, part, cursor)
if pos != -1:
emit(part, pos, ctx)
cursor = pos + len(part)
return spans, segments, contexts
def pure_prose(text: str) -> bool:
"""True when the text parses as nothing but prose (text and softbreak
tokens) — the acceptance test for a translated segment: the model may
not return markup of its own (a `
` here would splice live HTML into
the fragment)."""
children = _MD.parseInline(text)[0].children or []
return all(t.type in ("text", "softbreak") for t in children)
def _word_sim(a: str, b: str) -> float:
"""How likely two words are the same term across a translation, 0..1.
A case-folded exact match is 1; otherwise the better of the sequence
ratio and the shared-prefix ratio — inflection and derivational change
mostly move the ending ("banana" -> "banaanilla") or drop an article or
preposition around it. Case-folded so capitalization differences across
languages don't hide a term, with a small bonus when BOTH sides are
capitalized: a mid-sentence capital on both sides is likely the same
name (capitalization conventions differ per language, so its absence
proves nothing).
"""
bonus = 0.1 if a[:1].isupper() and b[:1].isupper() else 0.0
a, b = a.casefold(), b.casefold()
if a == b:
return 1.0
prefix = 0
for ca, cb in zip(a, b):
if ca != cb:
break
prefix += 1
sim = max(
difflib.SequenceMatcher(None, a, b).ratio(),
prefix / max(len(a), len(b)),
)
return min(1.0, sim + bonus)
#: Alignment costs for _find_mark: skipping a translation word (an article
#: or preposition the target language added) is cheap, skipping a source
#: word (one the translation dropped) costs more — a mark whose words
#: mostly vanished is no match at all. Every matched pair pays _MATCH, so
#: aligning a word to a lookalike-nothing (similarity below _MATCH) is
#: worse than skipping it.
_GAP_T = 0.25
_GAP_S = 0.6
_MATCH = 0.3
def _find_mark(
src: list[str], units: list[re.Match], start: int
) -> tuple[int, int] | None:
"""Locate a mark's source words in the translation's units (from unit
index ``start`` on), as the (start, end) unit-index span of the best
fuzzy alignment; None when no alignment is convincing (the caller falls
back to the weight ratio).
Word-for-word alignment with skips (_word_sim per pair, _GAP_T/_GAP_S
per skipped word): reordering is handled by the search itself, an added
or dropped article/preposition by the skip penalties. Accepted only
with an anchor — one pair of similarity >= 0.7 — and a decent average,
so a fully reworded label doesn't snap onto chance lookalikes.
"""
tgt = [u.group() for u in units[start:]]
n, m = len(src), len(tgt)
if not n or not m:
return None
# dp[i][j]: best score aligning src[:i] to tgt[:j]; a free tail (the
# answer is the best dp[n][j] over j) keeps trailing words costless.
dp = [[0.0] * (m + 1) for _ in range(n + 1)]
back: list[list[tuple[int, int]]] = [[(0, 0)] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
dp[i][0] = dp[i - 1][0] - _GAP_S
back[i][0] = (i - 1, 0)
for j in range(1, m + 1):
options = [
(
dp[i - 1][j - 1] + _word_sim(src[i - 1], tgt[j - 1]) - _MATCH,
(i - 1, j - 1),
),
(dp[i][j - 1] - _GAP_T, (i, j - 1)),
(dp[i - 1][j] - _GAP_S, (i - 1, j)),
]
dp[i][j], back[i][j] = max(options, key=lambda o: o[0])
j_end = max(range(m + 1), key=lambda j: dp[n][j])
pairs: list[tuple[int, int]] = [] # matched (source, target) indices
i, j = n, j_end
while i > 0:
pi, pj = back[i][j]
if (pi, pj) == (i - 1, j - 1):
pairs.append((i - 1, j - 1))
i, j = pi, pj
if not pairs:
return None
pairs.reverse() # backtracking collected them last-first
sims = [_word_sim(src[a], tgt[t]) for a, t in pairs]
# Weak pairs at the span's ends are not part of the label (a declined
# neighbor the DP matched for a pittance) — trim them off.
while len(sims) > 1 and sims[0] < 0.5:
pairs.pop(0)
sims.pop(0)
while len(sims) > 1 and sims[-1] < 0.5:
pairs.pop()
sims.pop()
if max(sims) < 0.7 or sum(sims) / len(sims) < 0.45:
return None
return start + pairs[0][1], start + pairs[-1][1] + 1
def _place_marks(translation: str, weight: int, marks: list[Mark]) -> str | None:
"""Re-insert a whole-block segment's links into its translation.
Each mark's boundaries are found by fuzzy word-form alignment
(_find_mark): the mark's source words are matched against the
translation's units by form similarity — no markers on the wire
(sentinels never survived the model), no assumption that word order or
count survived either. Slicing exactly at unit boundaries keeps the
whitespace between the mark and its neighbors in the plain text, where
it belongs. A mark with no convincing alignment falls back to its
source weight ratio (units before the boundary / total applied to the
translation's units) — the pre-fuzz heuristic, still the CJK path,
where form similarity across scripts is nil. A boundary landing empty
degrades to the source link text: better an untranslated label than a
broken "[](url)". None when the translation has no units to map onto
(the caller rejects the result).
"""
units = list(_UNIT.finditer(translation))
total = len(units)
if not total or not weight:
return None
starts = [u.start() for u in units]
bounds = starts + [len(translation)]
out: list[str] = []
cur = 0 # char cursor: never before the previous mark's end
ucur = 0 # unit cursor, the same monotonicity in unit indices
for mark in marks:
found = _find_mark(_UNIT.findall(mark.inner), units, ucur)
if found is not None:
u1, u2 = found
x1, x2 = units[u1].start(), units[u2 - 1].end()
else:
x1 = bounds[min(round(mark.w_start / weight * total), total)]
x2 = bounds[min(round(mark.w_end / weight * total), total)]
x1 = max(x1, cur)
x2 = max(x2, x1)
# The slice ends at the next unit's start, so the whitespace
# and punctuation before that unit is inside it — but it
# belongs BETWEEN the mark and the following word, not in the
# inner text: end the inner text at its last unit and leave
# the rest for the following slice.
raw = translation[x1:x2]
inner_units = list(_UNIT.finditer(raw))
x2 = x1 + inner_units[-1].end() if inner_units else x1
inner = translation[x1:x2].strip() or mark.inner
out += [translation[cur:x1], mark.pre, inner, mark.post]
cur = x2
ucur = bisect.bisect_left(starts, x2)
out.append(translation[cur:])
return "".join(out)
def join(original: str, spans: list[Span], texts: list[str]) -> str | None:
"""Splice translated segments back into the original fragment; None on
any validation failure (count mismatch, empty or non-prose segment) —
the caller drops the result and the fragment stays pending. Segments
with marks (a block that crossed as one piece) get their links
re-inserted at weight-mapped positions after the prose check.
Markdown-significant ASCII punctuation that pure_prose cannot see
(plain text inline, syntax in the splice context — quoted titles, alt
and link texts, table rows) is neutralized to Unicode look-alikes
(``_NEUTRAL``) before splicing and mark placement (the swap is
char-for-char, so unit alignment is unaffected)."""
if len(texts) != len(spans):
return None
out: list[str] = []
cursor = 0
for span, translation in zip(spans, texts):
# Decode the wire form ("<" back to "<") first: pure_prose then
# validates exactly what gets spliced — a "<" the model formed
# into anything tag-like is markup and rejects the result.
translation = translation.replace("<", "<")
if not translation.strip() or not pure_prose(translation):
return None
translation = translation.translate(_NEUTRAL)
if span.marks:
translation = _place_marks(translation, span.weight, span.marks)
if translation is None:
return None
out.append(original[cursor : span.start])
out.append(translation)
cursor = span.end
out.append(original[cursor:])
return "".join(out)
def has_prose(text: str) -> bool:
"""True when the fragment yields at least one translatable segment.
Chunks that are all markup, code, placeholders or reference definitions
have no business reaching the model: every language renders them from
the original chunk."""
return bool(split(text)[1])