mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 05:14:19 +02:00
* feat(translate): offline translation for the Information Library Implements #1272. Ships as a Supply Depot app so users who do not need translation never see it. A path-preserving reverse proxy in front of kiwix-serve. /viewer, /skin/*, /search, /random and /catalog/* are forwarded byte for byte; only text/html under /content/ is rewritten. Preserving the path is the whole design: viewer.js keeps the content iframe in sync with location.hash by comparing contentWindow.location.pathname, so moving the path starts the ping-pong its own comment warns about and forces us to patch Kiwix's JavaScript on every Kiwix bump. Because the path never moves, no Kiwix JS is modified, and Kiwix's header, search, library and random keep working because they are Kiwix. Language is a cookie, so links inside the ZIM need no rewriting either. Bergamot rather than the AI Assistant: measured on the same box and the same passage, 12,818 words/s on CPU against llama3.1:8b at 8 words/s on an RTX 5060. It is also more careful with names, where the LLM translated the product name and a smaller model hallucinated a clause. No GPU, so it works on CPU-only boxes where the LLM path is unusable. Models come from Firefox Remote Settings, the endpoint Firefox itself uses, not the mozilla/firefox-translations-models repo, which was archived on 2026-08-21. MPL-2.0, about 37 MB per direction. Failure handling is deliberate throughout: a failed model fetch is non-fatal, a translation error returns the original article with a comment rather than an error page, and a box with no models still starts and still forwards the library. Translation being unavailable must never make the Information Library unreachable. amd64 only, and pinned as such in the build workflow. Bergamot's intgemm backend is x86 specific and there is no aarch64 wheel, so a multi-arch build would produce an ARM image that pulls cleanly and dies at start. Also adds .gitattributes pinning *.sh to LF. The repo stores LF but Windows checks out CRLF, and a CRLF after a shebang makes the interpreter unresolvable inside the image. CI builds on Linux so this never bit, but it breaks a local image build and would have bitten the existing sidecar script too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV * fix(translate): vary the cache on the language cookie Found by clicking the language buttons in a browser rather than trusting curl. Kiwix sends ETag and Cache-Control: max-age=3600, and the proxy forwarded both unchanged on a response whose content now varies by cookie. The browser cached the English article for an hour and served it straight back when the reader picked a language, so the feature looked like it simply did not work. A shared cache would have been worse, serving one reader's language to another. Translatable responses now send Vary: Cookie, and their ETag is namespaced by language so revalidation still works per language rather than being turned off. Forwarded paths are untouched, so /viewer, /skin/* and /catalog/* stay byte for byte identical to Kiwix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV * fix(translate): move off port 8480, which Vaultwarden already uses Caught by seeing the two cards side by side in Supply Depot with the same port. My port survey grepped ui_location for a bare number, and Vaultwarden's is 'https:8480' because it needs an https Open link, so it did not match and 8480 looked free. Moved to 8460, which fits the existing spacing between Meshtastic Web on 8450 and Homebox on 8470. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV * fix(translate): pin the base image to python 3.10 The build fails outright on 3.12: bergamot publishes no wheel for it and pip resolves no further than 0.1.2. The spike used 3.10 for this reason and I had modernised it without checking. Caught by building the image rather than reading it. Moving off this pin means moving off the 2022 PyPI wheel to a build from mozilla/translations, which is the follow-up already noted in the Dockerfile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV * fix(translate): translate text that lives in a bare div Sotoki-generated StackExchange ZIMs put every question excerpt in a <div class="...excerpt">, so on a questions list the titles translated and the descriptions underneath them did not. Reported against the ham radio ZIM. div could not simply be added to BLOCK_TAGS: the outermost-block rule would claim a top-level layout wrapper and send the whole page as one job. It is now claimed speculatively and abandoned the moment anything block-level opens inside it, so a wrapper yields to the blocks it contains and only a genuine text leaf is translated. Also moves the test harness's reporting block back to the end of the file. New checks had been appended after it, so they executed but their failures were never reported: verified that removing div from the set now fails 3 checks, where before it reported success. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV * fix(translate): decode numeric character references before translating Bergamot's HTML mode decodes named entities (`&` arrives as "&") but not numeric ones. It treats `'` as literal text and escapes the ampersand on the way out, so "If I'm in the wilderness" came back as "Si I&#x27;m dans la nature" and rendered the escape sequence visibly to the user. Confirmed by round-tripping fragments through the model directly. Numeric refs are now decoded before the round trip. `&`, `<` and `>` are deliberately left encoded: decoding those would inject real markup into the fragment and fail the balance check that gates HTML mode, dropping the block to plain text and losing its inline links. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
212 lines
7.9 KiB
Python
212 lines
7.9 KiB
Python
"""Locate the translatable block elements in a ZIM article's HTML.
|
|
|
|
Bergamot's HTML mode re-places inline tags onto the translated tokens using
|
|
alignment rather than translating fragments separately, which is what makes
|
|
`<a href="Water">water</a>` come back as `<a href="Water">l'eau</a>` with the
|
|
link on the correct word even when the target language reorders the sentence.
|
|
|
|
That mode REQUIRES balanced markup and raises "Not all tags were closed"
|
|
otherwise, so blocks are located with a real parser. Regex cannot do this: a
|
|
non-greedy `<li>...</li>` happily stops at a nested list's closing tag. Anything
|
|
that still fails the balance check is translated as plain text instead, losing
|
|
its inline markup but not its content.
|
|
"""
|
|
|
|
import html
|
|
import re
|
|
from html.parser import HTMLParser
|
|
|
|
# Elements whose text is translated. Deliberately the outermost balanced block:
|
|
# translating a whole <p> at once is what gives the aligner enough context to
|
|
# place the inline tags sensibly.
|
|
BLOCK_TAGS = {
|
|
"p", "li", "h1", "h2", "h3", "h4", "h5", "h6",
|
|
"dt", "dd", "caption", "figcaption", "th", "td",
|
|
}
|
|
|
|
# Never descend into these. `table` is here because its nesting defeated the
|
|
# balance check in the prototype, which is why infobox rows stay untranslated.
|
|
OPAQUE = {"script", "style", "code", "pre", "math", "svg", "table"}
|
|
|
|
# `div` is not a semantic text element, but real prose lives in one often enough
|
|
# that leaving it out loses whole pages. Sotoki-generated StackExchange ZIMs put
|
|
# every question excerpt in `<div class="...excerpt">`, so the titles translated
|
|
# and the descriptions underneath them did not. Claimed only as a leaf: see
|
|
# BlockFinder.handle_starttag.
|
|
CLAIMABLE = BLOCK_TAGS | {"div"}
|
|
|
|
VOID = {
|
|
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
"link", "meta", "param", "source", "track", "wbr",
|
|
}
|
|
|
|
# Bergamot emits <font> wrappers in HTML mode; they are not in the source and
|
|
# are stripped back out.
|
|
FONTS = re.compile(r"(?is)</?font[^>]*>")
|
|
TAGS = re.compile(r"(?is)<[^>]+>")
|
|
|
|
# Two consecutive letters. Filters out blocks that are only punctuation, digits
|
|
# or whitespace, which are not worth a translation round trip.
|
|
HAS_TEXT = re.compile(r"[A-Za-z]{2}")
|
|
|
|
# Bergamot's HTML mode decodes NAMED entities (`&` arrives as "&") but not
|
|
# numeric character references: it treats `'` as literal text and escapes
|
|
# the ampersand on the way out, so `I'm` comes back as `I&#x27;m` and
|
|
# renders as visible markup instead of an apostrophe. Decode them before the
|
|
# round trip.
|
|
#
|
|
# `&`, `<` and `>` are deliberately left encoded. Decoding those would inject
|
|
# real markup into the fragment and break the balance check that gates HTML
|
|
# mode, turning a translated block into a plain-text one that loses its links.
|
|
NUMERIC_REF = re.compile(r"&#(?:[xX]([0-9a-fA-F]+)|([0-9]+));")
|
|
MARKUP_CHARS = {0x26, 0x3C, 0x3E} # & < >
|
|
|
|
|
|
def decode_numeric_refs(frag: str) -> str:
|
|
"""Turn numeric character references into characters Bergamot can carry."""
|
|
|
|
def one(match: "re.Match[str]") -> str:
|
|
hex_digits, dec_digits = match.group(1), match.group(2)
|
|
try:
|
|
codepoint = int(hex_digits, 16) if hex_digits else int(dec_digits)
|
|
except ValueError:
|
|
return match.group(0)
|
|
if codepoint in MARKUP_CHARS or not 0 < codepoint <= 0x10FFFF:
|
|
return match.group(0)
|
|
return chr(codepoint)
|
|
|
|
return NUMERIC_REF.sub(one, frag)
|
|
|
|
|
|
class BlockFinder(HTMLParser):
|
|
"""Yield (inner_start, inner_end) offsets for the outermost balanced blocks.
|
|
|
|
Only the outermost block is claimed: once inside one, nested blocks are
|
|
ignored until it closes, so a `<li>` containing a `<p>` is translated once
|
|
rather than twice with overlapping spans.
|
|
"""
|
|
|
|
def __init__(self, src: str):
|
|
super().__init__(convert_charrefs=False)
|
|
self.src = src
|
|
# Byte offset of the start of each line, so getpos() can be converted
|
|
# into an absolute offset into the source.
|
|
self.lines = [0]
|
|
for line in src.splitlines(keepends=True):
|
|
self.lines.append(self.lines[-1] + len(line))
|
|
self.spans: list[tuple[int, int]] = []
|
|
self.claim: tuple[str, int, int] | None = None
|
|
self.depth = 0
|
|
self.opaque = 0
|
|
|
|
def _off(self) -> int:
|
|
line, col = self.getpos()
|
|
return self.lines[line - 1] + col
|
|
|
|
def handle_starttag(self, tag, attrs):
|
|
if tag in VOID:
|
|
return
|
|
if tag in OPAQUE:
|
|
self.opaque += 1
|
|
self.depth += 1
|
|
|
|
# A `div` is claimable only when it turns out to be a text leaf. Claim it
|
|
# speculatively, then abandon that claim the moment anything block-level
|
|
# opens inside it, so the inner block is translated rather than the
|
|
# layout wrapper that happens to contain it. Without this, `div` could
|
|
# not be in the set at all: the outermost-block rule would claim a
|
|
# top-level wrapper and swallow the whole page in one job.
|
|
if self.claim and self.claim[0] == "div" and tag in CLAIMABLE:
|
|
self.claim = None
|
|
|
|
if self.claim is None and not self.opaque and tag in CLAIMABLE:
|
|
end = self.src.find(">", self._off())
|
|
self.claim = (tag, self.depth, end + 1)
|
|
|
|
def handle_startendtag(self, tag, attrs):
|
|
# Self-closing: no depth change, nothing to claim.
|
|
pass
|
|
|
|
def handle_endtag(self, tag):
|
|
if tag in VOID:
|
|
return
|
|
if self.claim and tag == self.claim[0] and self.depth == self.claim[1]:
|
|
self.spans.append((self.claim[2], self._off()))
|
|
self.claim = None
|
|
self.depth = max(0, self.depth - 1)
|
|
if tag in OPAQUE:
|
|
self.opaque = max(0, self.opaque - 1)
|
|
|
|
|
|
def find_blocks(body: str) -> list[tuple[int, int]]:
|
|
"""Block spans for `body`, or an empty list if it will not parse."""
|
|
try:
|
|
finder = BlockFinder(body)
|
|
finder.feed(body)
|
|
return finder.spans
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def balanced(frag: str) -> bool:
|
|
"""Cheap well-formedness check for what is handed to Bergamot's HTML mode.
|
|
|
|
Bergamot raises rather than degrading on unbalanced input, so this is the
|
|
gate that decides HTML mode versus the plain-text fallback.
|
|
"""
|
|
stack: list[str] = []
|
|
for m in re.finditer(r"(?is)<\s*(/?)\s*([a-z0-9]+)[^>]*?(/?)\s*>", frag):
|
|
closing, tag, self_close = m.group(1), m.group(2).lower(), m.group(3)
|
|
if tag in VOID or self_close:
|
|
continue
|
|
if closing:
|
|
if not stack or stack.pop() != tag:
|
|
return False
|
|
else:
|
|
stack.append(tag)
|
|
return not stack
|
|
|
|
|
|
def plain(frag: str) -> str:
|
|
"""Tag-stripped, entity-decoded text of a fragment."""
|
|
return html.unescape(TAGS.sub("", frag))
|
|
|
|
|
|
def plan(body: str) -> tuple[list[str], list[str], list[tuple[int, int]]]:
|
|
"""Decide what to translate and how.
|
|
|
|
Returns (jobs, kinds, spans) where kinds[i] is "html" or "text" and spans[i]
|
|
is where jobs[i] came from. Blocks with no real text are dropped entirely.
|
|
"""
|
|
jobs: list[str] = []
|
|
kinds: list[str] = []
|
|
spans: list[tuple[int, int]] = []
|
|
|
|
for start, end in find_blocks(body):
|
|
frag = body[start:end]
|
|
if not HAS_TEXT.search(plain(frag)):
|
|
continue
|
|
if balanced(frag):
|
|
jobs.append(decode_numeric_refs(frag))
|
|
kinds.append("html")
|
|
else:
|
|
text = plain(frag).strip()
|
|
if not text:
|
|
continue
|
|
jobs.append(text)
|
|
kinds.append("text")
|
|
spans.append((start, end))
|
|
|
|
return jobs, kinds, spans
|
|
|
|
|
|
def splice(body: str, spans: list[tuple[int, int]], replacements: list[str]) -> str:
|
|
"""Substitute translated blocks back into the page.
|
|
|
|
Applied back to front so that earlier offsets stay valid as the string
|
|
length changes underneath.
|
|
"""
|
|
for (start, end), new in sorted(zip(spans, replacements), key=lambda x: -x[0][0]):
|
|
body = body[:start] + new + body[end:]
|
|
return body
|