mirror of
https://github.com/unslothai/unsloth.git
synced 2026-10-19 08:54:21 +00:00
Studio: keep exponents when the model reads a web page (#13183)
Some checks are pending
CodeQL / Languages to analyse (push) Waiting to run
CodeQL / Analyze () (push) Blocked by required conditions
Core / Core (HF=default + TRL=default) (push) Blocked by required conditions
Core / Core zoo (HF=default + TRL=default) (push) Blocked by required conditions
Core / Core zoo (HF=4.57.6 + TRL<1) (push) Blocked by required conditions
Core / Resolve the unsloth_zoo revision (push) Waiting to run
Core / Core (HF=4.57.6 + TRL<1) (push) Blocked by required conditions
Core / Core (HF=latest + TRL=latest) (push) Blocked by required conditions
Interrupted install recovery / kill@ (push) Blocked by required conditions
Interrupted install recovery / windows kill@ (push) Blocked by required conditions
Core / Core zoo (HF=latest + TRL=latest) (push) Blocked by required conditions
Core / llama.cpp build + smoke (push) Waiting to run
Cross-platform parity / parity (ubuntu-latest) (push) Waiting to run
Cross-platform parity / parity (windows-latest) (push) Waiting to run
Interrupted install recovery / select legs (push) Waiting to run
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
Kaggle T4 Notebook CI / gate (push) Waiting to run
Kaggle T4 Notebook CI / T4 smoke (push) Blocked by required conditions
Kaggle T4 Unsloth GPU CI / gate (push) Waiting to run
Kaggle T4 Unsloth GPU CI / Unsloth GPU smoke (push) Blocked by required conditions
Local Agent Guides CI / connection (dsh, stable) (push) Waiting to run
Local Agent Guides CI / connection (claude, stable) (push) Waiting to run
Local Agent Guides CI / connection (codex, stable) (push) Waiting to run
Local Agent Guides CI / connection (openclaw, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, v2) (push) Waiting to run
Local Agent Guides CI / connection (pi, stable) (push) Waiting to run
Local Agent Guides CI / connection (vibe, stable) (push) Waiting to run
Local Agent Guides CI / file-edit (claude) (push) Waiting to run
Local Agent Guides CI / file-edit (codex) (push) Waiting to run
Local Agent Guides CI / file-edit (dsh) (push) Waiting to run
Local Agent Guides CI / file-edit (hermes) (push) Waiting to run
Local Agent Guides CI / connection (hermes, stable) (push) Waiting to run
Local Agent Guides CI / file-edit (openclaw) (push) Waiting to run
Local Agent Guides CI / file-edit (opencode) (push) Waiting to run
Local Agent Guides CI / file-edit (pi) (push) Waiting to run
Local Agent Guides CI / file-edit (vibe) (push) Waiting to run
Local Agent Guides CI / resume (claude) (push) Waiting to run
Local Agent Guides CI / resume (codex) (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Local Agent Guides CI / resume (opencode) (push) Waiting to run
Local Agent Guides CI / resume (pi) (push) Waiting to run
Local Agent Guides CI / prompt-cache (gemma-3-270m) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: audio-torch280 (push) Waiting to run
Security audit / pip scan-packages :: audio-torch290 (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.11, floor) (push) Waiting to run
Backend CI / (Python 3.13, a-k) (push) Waiting to run
Backend CI / (Python 3.13, rest) (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / npm scan-packages (Unsloth frontend tarballs) (push) Waiting to run
Backend CI / (Python 3.13, l-r) (push) Waiting to run
Backend CI / Repo tests (CPU, rest) (push) Waiting to run
Backend CI / Repo tests (CPU, studio) (push) Waiting to run
Backend CI / Repo tests (CPU, studio-install) (push) Waiting to run
Backend CI / Shell installer tests (push) Waiting to run
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Frontend CI / Frontend unit tests (Windows) (push) Waiting to run
Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Unsloth UI + API + Update + Inference CI / Chat UI, API, Update and GGUF inference (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (macos) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (extra) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (chat) (push) Waiting to run
Unsloth UI CI / Loaded-models indicator (cross-browser) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (Windows) (push) Waiting to run
Studio VAE seam guard / seam-guard (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (macOS) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection + VC++ round-trip (VS 2026) (push) Waiting to run
studiobench CI / Selftests, lint and the single-file build (push) Waiting to run
studiobench CI / Real-path session, every action ran (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 units, no-VS resolve, VC++ round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Application Control CI / installer survives a denied unsloth.exe (push) Waiting to run
Windows ARM64 CI / woa (pwsh-7) (push) Waiting to run
Windows ARM64 CI / woa (windows-powershell-5.1) (push) Waiting to run
Windows No Compiler CI / the installer spawns no C# compiler (push) Waiting to run
Workflow trigger lint / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Workflow trigger lint / security suites (torch-dependent) (push) Waiting to run
Some checks are pending
CodeQL / Languages to analyse (push) Waiting to run
CodeQL / Analyze () (push) Blocked by required conditions
Core / Core (HF=default + TRL=default) (push) Blocked by required conditions
Core / Core zoo (HF=default + TRL=default) (push) Blocked by required conditions
Core / Core zoo (HF=4.57.6 + TRL<1) (push) Blocked by required conditions
Core / Resolve the unsloth_zoo revision (push) Waiting to run
Core / Core (HF=4.57.6 + TRL<1) (push) Blocked by required conditions
Core / Core (HF=latest + TRL=latest) (push) Blocked by required conditions
Interrupted install recovery / kill@ (push) Blocked by required conditions
Interrupted install recovery / windows kill@ (push) Blocked by required conditions
Core / Core zoo (HF=latest + TRL=latest) (push) Blocked by required conditions
Core / llama.cpp build + smoke (push) Waiting to run
Cross-platform parity / parity (ubuntu-latest) (push) Waiting to run
Cross-platform parity / parity (windows-latest) (push) Waiting to run
Interrupted install recovery / select legs (push) Waiting to run
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Waiting to run
Kaggle T4 Notebook CI / gate (push) Waiting to run
Kaggle T4 Notebook CI / T4 smoke (push) Blocked by required conditions
Kaggle T4 Unsloth GPU CI / gate (push) Waiting to run
Kaggle T4 Unsloth GPU CI / Unsloth GPU smoke (push) Blocked by required conditions
Local Agent Guides CI / connection (dsh, stable) (push) Waiting to run
Local Agent Guides CI / connection (claude, stable) (push) Waiting to run
Local Agent Guides CI / connection (codex, stable) (push) Waiting to run
Local Agent Guides CI / connection (openclaw, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, stable) (push) Waiting to run
Local Agent Guides CI / connection (opencode, v2) (push) Waiting to run
Local Agent Guides CI / connection (pi, stable) (push) Waiting to run
Local Agent Guides CI / connection (vibe, stable) (push) Waiting to run
Local Agent Guides CI / file-edit (claude) (push) Waiting to run
Local Agent Guides CI / file-edit (codex) (push) Waiting to run
Local Agent Guides CI / file-edit (dsh) (push) Waiting to run
Local Agent Guides CI / file-edit (hermes) (push) Waiting to run
Local Agent Guides CI / connection (hermes, stable) (push) Waiting to run
Local Agent Guides CI / file-edit (openclaw) (push) Waiting to run
Local Agent Guides CI / file-edit (opencode) (push) Waiting to run
Local Agent Guides CI / file-edit (pi) (push) Waiting to run
Local Agent Guides CI / file-edit (vibe) (push) Waiting to run
Local Agent Guides CI / resume (claude) (push) Waiting to run
Local Agent Guides CI / resume (codex) (push) Waiting to run
Security audit / pip scan-packages :: extras (push) Waiting to run
Security audit / pip scan-packages :: studio (push) Waiting to run
Local Agent Guides CI / resume (opencode) (push) Waiting to run
Local Agent Guides CI / resume (pi) (push) Waiting to run
Local Agent Guides CI / prompt-cache (gemma-3-270m) (push) Waiting to run
MLX CI on Mac M1 / dispatch (push) Waiting to run
Security audit / advisory audit (pip + npm + cargo) (push) Waiting to run
Security audit / pip scan-packages :: audio-torch280 (push) Waiting to run
Security audit / pip scan-packages :: audio-torch290 (push) Waiting to run
Security audit / npm provenance + new install-script diff (push) Waiting to run
Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Backend CI / (Python 3.11, floor) (push) Waiting to run
Backend CI / (Python 3.13, a-k) (push) Waiting to run
Backend CI / (Python 3.13, rest) (push) Waiting to run
Security audit / pip scan-packages :: hf-stack (push) Waiting to run
Security audit / npm scan-packages (Unsloth frontend tarballs) (push) Waiting to run
Backend CI / (Python 3.13, l-r) (push) Waiting to run
Backend CI / Repo tests (CPU, rest) (push) Waiting to run
Backend CI / Repo tests (CPU, studio) (push) Waiting to run
Backend CI / Repo tests (CPU, studio-install) (push) Waiting to run
Backend CI / Shell installer tests (push) Waiting to run
Unsloth export capability / capability (windows-latest) (push) Waiting to run
Frontend CI / Frontend build + bundle sanity (push) Waiting to run
Frontend CI / Frontend unit tests (Windows) (push) Waiting to run
Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Mac Unsloth UI + API + Update + Inference CI / Chat UI, API, Update and GGUF inference (push) Waiting to run
Unsloth Tauri CI / Tauri Linux debug build (no codesign) (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (macos) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (extra) (push) Waiting to run
Unsloth UI CI / Chat UI Tests (chat) (push) Waiting to run
Unsloth UI CI / Loaded-models indicator (cross-browser) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Unsloth Tauri CI / Rust unit tests (windows) (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (Windows) (push) Waiting to run
Studio VAE seam guard / seam-guard (push) Waiting to run
Windows Unsloth API CI / Unsloth API & Auth Tests (push) Waiting to run
Unsloth Update CI / Unsloth Updating Tests (macOS) (push) Waiting to run
Windows Unsloth GGUF CI / Unsloth install + inference without Visual Studio (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection (VS 2022) (push) Waiting to run
Windows Unsloth GGUF CI / real-VS detection + VC++ round-trip (VS 2026) (push) Waiting to run
studiobench CI / Selftests, lint and the single-file build (push) Waiting to run
studiobench CI / Real-path session, every action ran (push) Waiting to run
Wheel CI / Wheel build + content sanity + import smoke (push) Waiting to run
Windows Unsloth GGUF CI / GGUF inference smoke (API, tools, vision) (push) Waiting to run
Windows Unsloth GGUF CI / setup.ps1 units, no-VS resolve, VC++ round-trip (windows-latest) (push) Waiting to run
Windows Unsloth UI CI / Chat UI Tests (push) Waiting to run
Windows Unsloth Update CI / Unsloth Updating Tests (push) Waiting to run
Windows Application Control CI / installer survives a denied unsloth.exe (push) Waiting to run
Windows ARM64 CI / woa (pwsh-7) (push) Waiting to run
Windows ARM64 CI / woa (windows-powershell-5.1) (push) Waiting to run
Windows No Compiler CI / the installer spawns no C# compiler (push) Waiting to run
Workflow trigger lint / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Waiting to run
Workflow trigger lint / security suites (torch-dependent) (push) Waiting to run
* Studio: keep exponents when the model reads a web page * Keep symbol marks plain and linked header titles single * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Keep exponents in stripped header headings and bound tracked sup nesting * Leave baseless superscripts as text and keep heading copies in sync * Ignore Markdown delimiters when finding a superscript base or ordinal * Require a letter, digit or closing bracket as the exponent base; group products; French ordinals * Bound the superscript base scan and read through same-site link markers * Group exponents that are implicit products * Bound the base scan by characters and group products split by emphasis * Parenthesise every multi-token exponent and leave split price cents plain * Trim each part before joining the price context * Read the price context without renderer delimiters * Accept locale grouping in split-cent prices and common footnote markers * Strip delimiters across the price context and keep TM/SM marks plain * Keep Romance ordinal indicators plain after a digit * Read the price window across more parts; Roman numerals take ordinals * Treat inner Markdown delimiters in an exponent as operators * Any Unicode currency sign marks split cents; keep French superior abbreviations plain * Recognise ISO currency codes before split cents * Check split-cent currency codes against the full ISO 4217 list * Plural French ordinals and ZWG * Treat only two-digit superscripts after a currency amount as cents * Read doc-noteref from the role token list; add XCG; compact the ISO code set * Keep the French professor title plain * Accept apostrophe thousands separators in split prices * Keep French-Canadian MC/MD marks plain * Keep parenthesised trademark marks plain * Drop superscript frames an ancestor closes; three-decimal currency cents * Close a superscript in O(1); keep Mr and Mrs plain * Zero-decimal currencies never take split cents * Keep the feminine plural ordinal ères plain * Stop tracking superscripts past the depth cap; keep Jr and Sr plain * Add VED; pin S^T as a case-sensitive exponent * Match any footnote/noteref class token; French 2de/2d ordinals * Feminine professor title and bis/ter numbering stay plain * Citation and endnote class tokens mark a note * Feminine doctor title stays plain * Match note class parts at word boundaries; leading-dot cents only after a currency * fnref/fn note classes and the MR trademark stay plain * Plural Saint and company abbreviations stay plain * French nds ordinal stays plain * Ms title stays plain * Full-width closing brackets are exponent bases * Comma-led split cents and reference-* note classes * SVC; numeric citation ranges and lists stay plain * Comma citation lists only after a word; decimal and thousands commas stay exponents * Zero-decimal currency signs never take split cents * Mixed comma and en-dash citation ranges stay plain * Meridiem markers after a time stay plain * Citation ranges only after prose; French second suffixes only after 2 * Linear citation-list match after prose words only --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
This commit is contained in:
parent
c538b0fb8f
commit
76dac379bc
2 changed files with 499 additions and 15 deletions
|
|
@ -17,8 +17,10 @@ error placeholders, session banners, cookie prompts) from the result.
|
|||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import itertools
|
||||
import re
|
||||
import secrets
|
||||
import unicodedata
|
||||
from html.parser import HTMLParser
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
|
@ -208,6 +210,125 @@ _MAX_REPEATED_CELL_CHARS = 200
|
|||
_MIN_SCOPE_SPAN_CHARS = 256
|
||||
_INLINE_EMPHASIS = {"strong": "**", "b": "**", "em": "*", "i": "*"}
|
||||
|
||||
# after a word, digits joined by commas / en dashes are citations (claim<sup>1,3–5</sup>); one separator per
|
||||
# step keeps the match linear. Hyphen / minus stay exponents (10<sup>-3</sup>), and so do thousands (1,000)
|
||||
_CITATION_LIST = re.compile(r"\d+(?:\s*[–,]\s*\d+)+")
|
||||
_THOUSANDS = re.compile(r"\d{1,3}(?:,\d{3})+")
|
||||
_PLAIN_SUFFIXES = frozenset(
|
||||
{"st", "nd", "rd", "th", "tm", "sm", "mc", "md", "(tm)", "(sm)", "(r)", "(c)", "mr", "m.r."}
|
||||
)
|
||||
# French / Romance ordinals after a digit (1er, 2e, 1º, 2ª); after a letter "e" can be Euler's number
|
||||
# XVe siècle, François Ier: a Roman numeral takes ordinals like a digit
|
||||
_ROMAN_NUMERAL_TAIL = re.compile(r"(?<![^\W\d_])[IVXLCDM]+$")
|
||||
# French superior abbreviations: Mme, Mlle, Mgr, Dr, Pr, no, St, Cie; keyed on the whole base word
|
||||
_SUPERIOR_ABBREVIATIONS = {
|
||||
"M": frozenset({"me", "mes", "lle", "lles", "gr", "e", "r", "rs", "s"}),
|
||||
"D": frozenset({"r", "rs", "re", "res"}),
|
||||
"P": frozenset({"r", "rs", "re", "res"}),
|
||||
"n": frozenset({"o", "os"}),
|
||||
"N": frozenset({"o", "os"}),
|
||||
"S": frozenset({"t", "te", "ts", "tes", "r"}),
|
||||
"J": frozenset({"r"}),
|
||||
"C": frozenset({"ie", "ies"}),
|
||||
}
|
||||
_LAST_WORD = re.compile(r"(?<![^\W\d_])[^\W\d_]+$")
|
||||
|
||||
|
||||
def _last_word(text: str) -> str:
|
||||
match = _LAST_WORD.search(text)
|
||||
return match.group() if match else ""
|
||||
|
||||
|
||||
# French second / seconde (2d, 2de, 2ds, 2des) only after a 2: 10<sup>d</sup> stays a power
|
||||
_SECOND_SUFFIXES = frozenset({"d", "de", "ds", "des"})
|
||||
_DIGIT_ORDINAL_SUFFIXES = frozenset(
|
||||
{
|
||||
"e",
|
||||
"es",
|
||||
"er",
|
||||
"ers",
|
||||
"re",
|
||||
"res",
|
||||
"ère",
|
||||
"ères",
|
||||
"ème",
|
||||
"èmes",
|
||||
"eme",
|
||||
"emes",
|
||||
"nd",
|
||||
"nde",
|
||||
"bis",
|
||||
"ter",
|
||||
"quater",
|
||||
"quinquies",
|
||||
"am",
|
||||
"pm",
|
||||
"a.m.",
|
||||
"p.m.",
|
||||
"ndes",
|
||||
"nds",
|
||||
"º",
|
||||
"ª",
|
||||
"o",
|
||||
"a",
|
||||
}
|
||||
)
|
||||
_MD_DELIMITERS = "*_`"
|
||||
_STRIP_MD_DELIMITERS = str.maketrans("", "", _MD_DELIMITERS)
|
||||
# SiteLinks wraps same-site links in invisible \x00 markers; the base is the text before them
|
||||
_SITE_LINK_MARKER_TAIL = re.compile(r"\x00[0-9a-f]+:\d+:[se]\x00$")
|
||||
# parts a base lookup reads back: enough for delimiters and link markers, bounded on hostile pages
|
||||
_SUP_BASE_SCAN_PARTS = 8
|
||||
_SUP_BASE_SCAN_CHARS = 128
|
||||
# a caret binds one token: a signed number or one letter goes bare, anything longer in parentheses
|
||||
_BARE_EXPONENT = re.compile(r"[-+−]?(?:\d+(?:[.,]\d+)?|[^\W\d_])")
|
||||
# split cents: $19<sup>99</sup> is a price, not an exponent
|
||||
_PRICE_TAIL = re.compile(r"(\S)\s?\d(?:[\d,.'’]|[ \u00a0\u202f]\d)*$")
|
||||
# ISO 4217 codes: CHF 19<sup>95</sup> is a price like $19<sup>99</sup>
|
||||
_CURRENCY_CODES = frozenset(
|
||||
(
|
||||
"AED AFN ALL AMD ANG AOA ARS AUD AWG AZN BAM BBD BDT BGN BHD BIF BMD BND BOB BRL BSD BTN "
|
||||
"BWP BYN BZD CAD CDF CHF CLP CNY COP CRC CUP CVE CZK DJF DKK DOP DZD EGP ERN ETB EUR FJD "
|
||||
"FKP GBP GEL GHS GIP GMD GNF GTQ GYD HKD HNL HTG HUF IDR ILS INR IQD IRR ISK JMD JOD JPY "
|
||||
"KES KGS KHR KMF KPW KRW KWD KYD KZT LAK LBP LKR LRD LSL LYD MAD MDL MGA MKD MMK MNT MOP "
|
||||
"MRU MUR MVR MWK MXN MYR MZN NAD NGN NIO NOK NPR NZD OMR PAB PEN PGK PHP PKR PLN PYG QAR "
|
||||
"RON RSD RUB RWF SAR SBD SCR SDG SEK SGD SHP SLE SOS SRD SSP SVC STN SYP SZL THB TJS TMT TND "
|
||||
"TOP TRY TTD TWD TZS UAH UGX USD UYU UZS VED VES VND VUV WST XAF XCD XCG XOF XPF YER ZAR ZMW "
|
||||
"ZWG ZWL"
|
||||
).split()
|
||||
)
|
||||
_THREE_DECIMAL_CURRENCIES = frozenset({"BHD", "IQD", "JOD", "KWD", "LYD", "OMR", "TND"})
|
||||
# no minor unit: JPY 10<sup>12</sup> is a power, never cents
|
||||
_ZERO_DECIMAL_CURRENCIES = frozenset(
|
||||
"BIF CLP DJF GNF ISK JPY KMF KRW PYG RWF UGX VND VUV XAF XOF XPF".split()
|
||||
)
|
||||
_CODE_PRICE_TAIL = re.compile(r"\b([A-Z]{3})[ \u00a0\u202f]?\d(?:[\d,.'’]|[ \u00a0\u202f]\d)*$")
|
||||
# note markers that keep their plain-text form, like Wikipedia's class="reference"
|
||||
_FOOTNOTE_CLASSES = frozenset({"reference", "footnote", "footnote-ref", "noteref", "fn", "cite"})
|
||||
# class token parts (split on - and _) starting with these mark a note too: footnote-reference, citation
|
||||
_FOOTNOTE_CLASS_PREFIXES = (
|
||||
"footnote",
|
||||
"noteref",
|
||||
"cite",
|
||||
"citation",
|
||||
"endnote",
|
||||
"fnref",
|
||||
"reference",
|
||||
)
|
||||
_CLASS_PART_SPLIT = re.compile(r"[-_]")
|
||||
# deeper <sup> nests render as plain text: each tracked level rescans its whole suffix on close
|
||||
_MAX_SUP_DEPTH = 8
|
||||
|
||||
|
||||
def _visible_tail(text: str) -> str:
|
||||
"""*text* without the emphasis/code delimiters and link markers the renderer appended."""
|
||||
while True:
|
||||
trimmed = _SITE_LINK_MARKER_TAIL.sub("", text.rstrip(_MD_DELIMITERS))
|
||||
if trimmed == text:
|
||||
return text
|
||||
text = trimmed
|
||||
|
||||
|
||||
# measured density: 0.94-1.00 for link lists, 0.13-0.90 for content headers
|
||||
_HEADER_LINK_DENSITY = 0.93
|
||||
# below this the ratio is noise: link lists start at 182 chars, link-dense headers stop at 93
|
||||
|
|
@ -599,6 +720,12 @@ class _MarkdownRenderer(HTMLParser):
|
|||
# Blockquote state: stack of buffers so nested blockquotes get the right ">" depth.
|
||||
self._bq_stack: list[list[str]] = []
|
||||
|
||||
# per open <sup>: its _open_tags index, then (output list, start), the (copy, start) pairs that tee the
|
||||
# same text, the base text and whether a currency amount precedes it
|
||||
self._sup_starts: list[
|
||||
tuple[int, tuple[list[str], int, list[tuple[list[str], int]], str, int] | None]
|
||||
] = []
|
||||
|
||||
def _nested_buffer_open(self, frame: _HeaderFrame) -> bool:
|
||||
"""True when a side buffer opened *inside* *frame* still holds content.
|
||||
|
||||
|
|
@ -641,22 +768,106 @@ class _MarkdownRenderer(HTMLParser):
|
|||
# Tally once, on the emit reaching the frame; counting again on flush doubled it.
|
||||
if frame is not None and not nested_open:
|
||||
frame.rendered_chars += len(measured.strip())
|
||||
frame.parts.append(text)
|
||||
return
|
||||
elif self._in_link and self._heading_marks:
|
||||
self._link_heading_parts.append(text)
|
||||
self._emit_target().append(text)
|
||||
|
||||
def _emit_target(self) -> list[str]:
|
||||
frame = self._header_stack[-1] if self._header_stack else None
|
||||
if frame is not None and not self._nested_buffer_open(frame):
|
||||
return frame.parts
|
||||
if self._in_link:
|
||||
self._link_text_parts.append(text)
|
||||
if self._heading_marks:
|
||||
self._link_heading_parts.append(text)
|
||||
elif self._in_cell:
|
||||
self._cell_parts.append(text)
|
||||
elif self._in_pre:
|
||||
self._pre_parts.append(text)
|
||||
elif self._table_stack and len(self._bq_stack) <= self._table_stack[-1].outer_bq_depth:
|
||||
self._table_stack[-1].parts.append(text)
|
||||
elif self._bq_stack:
|
||||
self._bq_stack[-1].append(text)
|
||||
else:
|
||||
self._out.append(text)
|
||||
return self._link_text_parts
|
||||
if self._in_cell:
|
||||
return self._cell_parts
|
||||
if self._in_pre:
|
||||
return self._pre_parts
|
||||
if self._table_stack and len(self._bq_stack) <= self._table_stack[-1].outer_bq_depth:
|
||||
return self._table_stack[-1].parts
|
||||
if self._bq_stack:
|
||||
return self._bq_stack[-1]
|
||||
return self._out
|
||||
|
||||
def _sup_base(self, target: list[str]) -> str:
|
||||
"""The visible text a <sup> raises (base = its last character), or "" when none: after whitespace or
|
||||
sentence punctuation it is a footnote marker (``fact.<sup>1</sup>``,
|
||||
``<a href="#fn1"><sup>1</sup></a>``) or a fraction numerator (``<sup>1</sup>⁄``)."""
|
||||
for part in itertools.islice(reversed(target), _SUP_BASE_SCAN_PARTS):
|
||||
part = part[-_SUP_BASE_SCAN_CHARS:]
|
||||
part = _visible_tail(part)
|
||||
if part:
|
||||
base = part[-1]
|
||||
return part if base.isalnum() or base in ")]}|)]}" else ""
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _after_price(target: list[str]) -> int:
|
||||
"""Digits of the minor unit when *target* ends in a currency amount ($19 -> 2, KWD 19 -> 3), else 0."""
|
||||
context = _visible_tail("".join(p[-40:] for p in target[-8:])[-40:])
|
||||
context = context.translate(_STRIP_MD_DELIMITERS)
|
||||
price = _PRICE_TAIL.search(context)
|
||||
# any Unicode currency sign (Sc): $, €, ₺, ₱, ...; or an ISO code: CHF 19
|
||||
if price and unicodedata.category(price.group(1)) == "Sc":
|
||||
# ₫ ₲ ₩ have no minor unit (¥ is shared with two-decimal CNY, so it stays 2)
|
||||
return 0 if price.group(1) in "₫₲₩" else 2
|
||||
code = _CODE_PRICE_TAIL.search(context)
|
||||
if (
|
||||
not code
|
||||
or code.group(1) not in _CURRENCY_CODES
|
||||
or code.group(1) in _ZERO_DECIMAL_CURRENCIES
|
||||
):
|
||||
return 0
|
||||
return 3 if code.group(1) in _THREE_DECIMAL_CURRENCIES else 2
|
||||
|
||||
def _sup_copies(self) -> list[tuple[list[str], int]]:
|
||||
copies = [self._link_heading_parts, self._seg_heading_texts]
|
||||
if self._header_stack:
|
||||
copies.append(self._header_stack[-1].heading_parts)
|
||||
return [(copy, len(copy)) for copy in copies]
|
||||
|
||||
def _finish_sup(self, opened) -> None:
|
||||
if opened is None or opened[0] is not self._emit_target():
|
||||
return
|
||||
target, start, copies, base, after_price = opened
|
||||
joined = "".join(target[start:])
|
||||
raw = joined.strip()
|
||||
shown = self._site_links.clean(raw) if self._site_links is not None else raw
|
||||
visible = shown.strip(_MD_DELIMITERS)
|
||||
if (
|
||||
not visible
|
||||
or "\n" in visible
|
||||
or visible[0] == "["
|
||||
# $19<sup>.99</sup> / €19<sup>,99</sup> are split cents; 10<sup>.5</sup> with no currency is a power
|
||||
or (visible[0] in ".," and after_price)
|
||||
or not any(c.isalnum() for c in visible)
|
||||
or visible.lower() in _PLAIN_SUFFIXES
|
||||
or (
|
||||
len(_last_word(base)) > 1
|
||||
and _CITATION_LIST.fullmatch(visible)
|
||||
and ("–" in visible or not _THOUSANDS.fullmatch(visible))
|
||||
)
|
||||
or (base[-1] == "2" and visible.lower() in _SECOND_SUFFIXES)
|
||||
or (
|
||||
(base[-1].isdigit() or _ROMAN_NUMERAL_TAIL.search(base))
|
||||
and visible.lower() in _DIGIT_ORDINAL_SUFFIXES
|
||||
)
|
||||
or visible in _SUPERIOR_ABBREVIATIONS.get(_last_word(base), ())
|
||||
# split cents are two digits; $2<sup>n</sup> or USD 10<sup>6</sup> stays an exponent
|
||||
or (len(visible) == after_price and visible.isdigit())
|
||||
):
|
||||
return
|
||||
# only the emphasis wrapping a whole exponent is renderer syntax; an inner * is an operator
|
||||
token = shown.strip(_MD_DELIMITERS)
|
||||
bare = _BARE_EXPONENT.fullmatch(token) or (
|
||||
token.startswith("(") and token.endswith(")") and token.count("(") == 1
|
||||
)
|
||||
exponent = f"^{raw}" if bare else f"^({raw})"
|
||||
exponent += joined[len(joined.rstrip()) :]
|
||||
target[start:] = [exponent]
|
||||
# headings are teed into these copies; a stale one renders "E=mc2" or skews the prose gate
|
||||
for copy, copy_start in copies:
|
||||
if "".join(copy[copy_start:]) == joined:
|
||||
copy[copy_start:] = [exponent]
|
||||
|
||||
def _seg_heading_prose(self) -> int:
|
||||
"""Heading characters in this segment that the gate would otherwise read as
|
||||
|
|
@ -879,6 +1090,9 @@ class _MarkdownRenderer(HTMLParser):
|
|||
if name in _IMPLICIT_CLOSERS:
|
||||
self._closable_open -= 1
|
||||
del self._open_tags[index:]
|
||||
# a <sup> an ancestor closed (<p>x<sup>2</p>) is gone; a kept frame would fill the depth cap
|
||||
while self._sup_starts and self._sup_starts[-1][0] >= index:
|
||||
self._sup_starts.pop()
|
||||
|
||||
def _close_implicit(self, tag: str) -> None:
|
||||
"""HTML5 optional-end-tag recovery for a start tag about to open.
|
||||
|
|
@ -1116,6 +1330,36 @@ class _MarkdownRenderer(HTMLParser):
|
|||
elif tag == "br":
|
||||
self._emit("\n")
|
||||
|
||||
elif tag == "sup":
|
||||
target = self._emit_target()
|
||||
reference = (
|
||||
any(
|
||||
token in _FOOTNOTE_CLASSES
|
||||
or any(
|
||||
part == "fn" or part.startswith(_FOOTNOTE_CLASS_PREFIXES)
|
||||
for part in _CLASS_PART_SPLIT.split(token)
|
||||
)
|
||||
for token in (attr_dict.get("class") or "").lower().split()
|
||||
)
|
||||
or "doc-noteref" in (attr_dict.get("role") or "").lower().split()
|
||||
)
|
||||
# past the cap nothing is tracked, so the stack stays bounded on hostile pages
|
||||
if len(self._sup_starts) < _MAX_SUP_DEPTH:
|
||||
self._sup_starts.append(
|
||||
(
|
||||
len(self._open_tags) - 1,
|
||||
None
|
||||
if reference or not (base := self._sup_base(target))
|
||||
else (
|
||||
target,
|
||||
len(target),
|
||||
self._sup_copies(),
|
||||
base,
|
||||
self._after_price(target) if base[-1].isdigit() else 0,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
elif tag in _BLOCK_TAGS:
|
||||
if not self._li_marker_pending:
|
||||
self._emit("\n\n")
|
||||
|
|
@ -1201,6 +1445,17 @@ class _MarkdownRenderer(HTMLParser):
|
|||
if self._skip_depth:
|
||||
return
|
||||
|
||||
closing_sup = None
|
||||
# O(1): only a <sup> that is still the innermost open tag converts; malformed nesting drops on unwind
|
||||
if (
|
||||
tag == "sup"
|
||||
and self._sup_starts
|
||||
and self._open_tags
|
||||
and self._open_tags[-1] == "sup"
|
||||
and self._sup_starts[-1][0] == len(self._open_tags) - 1
|
||||
):
|
||||
closing_sup = self._sup_starts.pop()[1]
|
||||
|
||||
if not self._exit_tag(tag):
|
||||
return
|
||||
|
||||
|
|
@ -1218,6 +1473,9 @@ class _MarkdownRenderer(HTMLParser):
|
|||
elif tag in _INLINE_EMPHASIS:
|
||||
self._emit(_INLINE_EMPHASIS[tag])
|
||||
|
||||
elif tag == "sup":
|
||||
self._finish_sup(closing_sup)
|
||||
|
||||
elif tag in _BLOCK_TAGS:
|
||||
self._emit("\n\n")
|
||||
|
||||
|
|
|
|||
|
|
@ -869,6 +869,232 @@ def test_page_that_fits_keeps_its_links(monkeypatch):
|
|||
assert "[[1]](#cite_note-1)" in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"markup, expected",
|
||||
[
|
||||
("(2<sup>53</sup> – 1)", "(2^53 – 1)"),
|
||||
("2<sup>−52</sup>", "2^−52"),
|
||||
("1.898×10<sup>27</sup> kg", "1.898×10^27 kg"),
|
||||
("6.02214076×10<sup>23</sup> mol<sup>−1</sup>", "6.02214076×10^23 mol^−1"),
|
||||
("2<sup><i>n</i>+1</sup> nodes", "2^(*n*+1) nodes"),
|
||||
("2<sup>n + 1</sup>", "2^(n + 1)"),
|
||||
("the 1<sup>st</sup> and 2<sup>nd</sup>", "the 1st and 2nd"),
|
||||
("Intel<sup>®</sup> Core<sup>™</sup> i7", "Intel® Core™ i7"),
|
||||
("now $19<sup>.99</sup> only", "now $19.99 only"),
|
||||
("price<sup>*</sup> and terms<sup>†</sup>", "price* and terms†"),
|
||||
("10<sup>6 </sup>years", "10^6 years"),
|
||||
("Add <sup>1</sup>⁄<sub>2</sub> cup", "Add 1⁄2 cup"),
|
||||
("Add 1 <sup>1</sup>/<sub>2</sub> cups", "Add 1 1/2 cups"),
|
||||
("<sup>1</sup> Footnote text", "1 Footnote text"),
|
||||
("Add <em><sup>1</sup></em>⁄ cup", "Add *1*⁄ cup"),
|
||||
("the 1<sup><em>st</em></sup> one", "the 1*st* one"),
|
||||
("a<b><sup>2</sup></b>", "a**^2**"),
|
||||
("x<sup>n×2</sup>", "x^(n×2)"),
|
||||
("x<sup>2n</sup> and y<sup>n2</sup>", "x^(2n) and y^(n2)"),
|
||||
("x<sup>2<em>n</em></sup>", "x^(2*n*)"),
|
||||
("x<sup>2*3</sup> and x<sup><em>n</em></sup>", "x^(2*3) and x^*n*"),
|
||||
("e<sup>iπ</sup> + 1 = 0", "e^(iπ) + 1 = 0"),
|
||||
("now $19<sup>99</sup> or € 1,299<sup>95</sup>", "now $1999 or € 1,29995"),
|
||||
("<b>$19</b><sup>99</sup>", "**$19**99"),
|
||||
("$<b>19</b><sup>99</sup>", "$**19**99"),
|
||||
("Brand<sup>TM</sup> and Service<sup>SM</sup>", "BrandTM and ServiceSM"),
|
||||
("el 1<sup>º</sup> y la 2<sup>ª</sup>, x<sup>a</sup>", "el 1º y la 2ª, x^a"),
|
||||
(
|
||||
"le XV<sup>e</sup> siècle, François I<sup>er</sup>, MAX<sup>e</sup>",
|
||||
"le XVe siècle, François Ier, MAX^e",
|
||||
),
|
||||
("<span>$</span> <b>19</b><sup>99</sup>", "$ **19**99"),
|
||||
(
|
||||
"₺19<sup>99</sup> or ₱19<sup>99</sup>, a19<sup>2</sup>",
|
||||
"₺1999 or ₱1999, a19^2",
|
||||
),
|
||||
(
|
||||
"CHF 19<sup>95</sup> or USD 19<sup>99</sup>, ABC 10<sup>3</sup>",
|
||||
"CHF 1995 or USD 1999, ABC 10^3",
|
||||
),
|
||||
(
|
||||
"AED 19<sup>99</sup>, TWD 19<sup>99</sup>, ILS 19<sup>99</sup>",
|
||||
"AED 1999, TWD 1999, ILS 1999",
|
||||
),
|
||||
(
|
||||
"ZWG 19<sup>99</sup>, les 1<sup>ers</sup> et 1<sup>res</sup>",
|
||||
"ZWG 1999, les 1ers et 1res",
|
||||
),
|
||||
("$2<sup>n</sup> and USD 10<sup>6</sup>, $19<sup>99</sup>", "$2^n and USD 10^6, $1999"),
|
||||
(
|
||||
'XCG 19<sup>99</sup>, claim<sup role="doc-noteref presentation">2</sup>',
|
||||
"XCG 1999, claim2",
|
||||
),
|
||||
("le P<sup>r</sup> Martin et les P<sup>rs</sup>", "le Pr Martin et les Prs"),
|
||||
("CHF 1’299<sup>95</sup> or CHF 1'299<sup>95</sup>", "CHF 1’29995 or CHF 1'29995"),
|
||||
("Marque<sup>MC</sup> et Produit<sup>MD</sup>", "MarqueMC et ProduitMD"),
|
||||
("Brand<sup>(TM)</sup> and Other<sup>(R)</sup>", "Brand(TM) and Other(R)"),
|
||||
(
|
||||
"KWD 19<sup>950</sup>, BHD 1<sup>234</sup>, USD 10<sup>100</sup>",
|
||||
"KWD 19950, BHD 1234, USD 10^100",
|
||||
),
|
||||
("M<sup>r</sup>, M<sup>s</sup> and M<sup>rs</sup> Smith", "Mr, Ms and Mrs Smith"),
|
||||
("JPY 10<sup>12</sup> and KRW 10<sup>12</sup>", "JPY 10^12 and KRW 10^12"),
|
||||
("les 1<sup>ères</sup> places", "les 1ères places"),
|
||||
("John J<sup>r</sup> and John S<sup>r</sup>", "John Jr and John Sr"),
|
||||
("VED 19<sup>99</sup> and S<sup>T</sup>", "VED 1999 and S^T"),
|
||||
(
|
||||
'claim<sup class="footnote-reference">2</sup>, la 2<sup>de</sup>, le 2<sup>d</sup>',
|
||||
"claim2, la 2de, le 2d",
|
||||
),
|
||||
(
|
||||
"la P<sup>re</sup> Durand, 12<sup>bis</sup> rue, article 3<sup>ter</sup>",
|
||||
"la Pre Durand, 12bis rue, article 3ter",
|
||||
),
|
||||
(
|
||||
'claim<sup class="citation">2</sup> and fact<sup class="endnote-ref">3</sup>',
|
||||
"claim2 and fact3",
|
||||
),
|
||||
("la D<sup>re</sup> Roy et les D<sup>res</sup>", "la Dre Roy et les Dres"),
|
||||
(
|
||||
'x<sup class="excited">2</sup> and 10<sup>.5</sup>, $19<sup>.99</sup>',
|
||||
"x^2 and 10^(.5), $19.99",
|
||||
),
|
||||
(
|
||||
'claim<sup class="fnref">2</sup>, fact<sup class="fn-ref">3</sup>, Marca<sup>MR</sup>',
|
||||
"claim2, fact3, MarcaMR",
|
||||
),
|
||||
(
|
||||
"les S<sup>ts</sup>, les S<sup>tes</sup> et les C<sup>ies</sup>",
|
||||
"les Sts, les Stes et les Cies",
|
||||
),
|
||||
("les 2<sup>nds</sup>", "les 2nds"),
|
||||
("(a+b)<sup>2</sup> and 「引用」<sup>1</sup>", "(a+b)^2 and 「引用」1"),
|
||||
(
|
||||
'€19<sup>,99</sup> and claim<sup class="reference-number">2</sup>',
|
||||
"€19,99 and claim2",
|
||||
),
|
||||
(
|
||||
"SVC 19<sup>99</sup>, claim<sup>1–3</sup>, fact<sup>2,5</sup>, 10<sup>-3</sup>",
|
||||
"SVC 1999, claim1–3, fact2,5, 10^-3",
|
||||
),
|
||||
("x<sup>2,5</sup> and 10<sup>1,000</sup>", "x^2,5 and 10^1,000"),
|
||||
("₫10<sup>12</sup> and ₩10<sup>12</sup>", "₫10^12 and ₩10^12"),
|
||||
("claim<sup>1,3–5</sup>", "claim1,3–5"),
|
||||
("Doors 9:30<sup>pm</sup>, ends 11<sup>p.m.</sup>", "Doors 9:30pm, ends 11p.m."),
|
||||
("10<sup>6–8</sup> CFU/mL and 10<sup>d</sup>", "10^(6–8) CFU/mL and 10^d"),
|
||||
("(x+1)<sup>2–4</sup>", "(x+1)^(2–4)"),
|
||||
(
|
||||
"M<sup>me</sup> Dupont, D<sup>r</sup> Martin, n<sup>o</sup> 5, Om<sup>e</sup>",
|
||||
"Mme Dupont, Dr Martin, no 5, Om^e",
|
||||
),
|
||||
("€1.299<sup>95</sup> or €1 299<sup>95</sup>", "€1.29995 or €1 29995"),
|
||||
(
|
||||
'claim<sup role="doc-noteref">1</sup> and fact<sup class="footnote">2</sup>',
|
||||
"claim1 and fact2",
|
||||
),
|
||||
("x<sup>-n</sup> and 10<sup>2.5</sup>", "x^-n and 10^2.5"),
|
||||
("now $19<sup><em>.99</em></sup>", "now $19*.99*"),
|
||||
("A fact.<sup>1</sup> Next, a list,<sup>2</sup>", "A fact.1 Next, a list,2"),
|
||||
("(a+b)<sup>2</sup> and km<sup>2</sup>.", "(a+b)^2 and km^2."),
|
||||
("le 1<sup>er</sup> mai, 2<sup>e</sup> et x<sup>e</sup>", "le 1er mai, 2e et x^e"),
|
||||
],
|
||||
)
|
||||
def test_superscripts_keep_their_exponent(markup, expected):
|
||||
assert html_to_markdown(f"<p>{markup}</p>") == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"title",
|
||||
[
|
||||
"<h1><a href='/p'>E=mc<sup>2</sup></a></h1>",
|
||||
"<a href='/p'><h1>E=mc<sup>2</sup></h1></a>",
|
||||
],
|
||||
)
|
||||
def test_linked_header_title_keeps_one_exponent(title):
|
||||
nav = "".join(f"<a href='/s{i}'>Section number {i}</a> " for i in range(12))
|
||||
html = f"<header>{title}{nav}</header><p>{'Body text here. ' * 40}</p>"
|
||||
out = html_to_markdown(html, main_content = True)
|
||||
assert out.count("E=mc") == 1
|
||||
assert "E=mc^2](/p)" in out
|
||||
|
||||
|
||||
def test_stripped_header_keeps_the_exponent_in_its_heading():
|
||||
nav = "".join(f"<a href='/s{i}'>Section number {i}</a> " for i in range(12))
|
||||
html = f"<header><h1>E=mc<sup>2</sup></h1>{nav}</header><p>{'Body text here. ' * 40}</p>"
|
||||
out = html_to_markdown(html, main_content = True)
|
||||
assert "# E=mc^2" in out
|
||||
assert "E=mc2" not in out
|
||||
|
||||
|
||||
def test_deeply_nested_superscripts_track_a_bounded_depth():
|
||||
out = html_to_markdown("<p>x" + "<sup>a" * 50 + "</sup>" * 50 + "</p>")
|
||||
assert out.translate(str.maketrans("", "", "^()")) == "x" + "a" * 50
|
||||
assert out.count("^") == 8
|
||||
|
||||
|
||||
def test_linked_footnote_wrapping_its_superscript_renders_unchanged():
|
||||
html = '<p>text<a role="doc-noteref" href="#fn1"><sup>1</sup></a> more</p>'
|
||||
assert html_to_markdown(html) == "text[1](#fn1) more"
|
||||
|
||||
|
||||
def test_exponent_headings_do_not_read_as_body_prose():
|
||||
heads = "".join(f"<h2>x<sup>n+{i}</sup></h2>" for i in range(67))
|
||||
body = "<p>" + "Real document body sentence. " * 12 + "</p>"
|
||||
html = f"<html><body><article>{heads}</article><div>{body}</div></body></html>"
|
||||
assert "Real document body" in html_to_markdown(html, main_content = True)
|
||||
|
||||
|
||||
def test_same_site_linked_base_keeps_its_exponent():
|
||||
site_links = SiteLinks("https://e.com/page")
|
||||
html = '<p>5 <a href="/metre">m</a><sup>2</sup> and <a href="https://x.org/m">m</a><sup>3</sup></p>'
|
||||
out = site_links.clean(html_to_markdown(html, site_links = site_links))
|
||||
assert out == "5 [m](/metre)^2 and [m](https://x.org/m)^3"
|
||||
|
||||
|
||||
def test_empty_superscripts_do_not_rescan_the_page():
|
||||
start = time.perf_counter()
|
||||
html_to_markdown("<p>" + "<i></i><sup></sup>" * 8000 + "</p>")
|
||||
assert time.perf_counter() - start < 2
|
||||
start = time.perf_counter()
|
||||
html_to_markdown("<p>" + "*" * 250000 + "<sup></sup>" * 23000 + "</p>")
|
||||
assert time.perf_counter() - start < 3
|
||||
start = time.perf_counter()
|
||||
html_to_markdown("<p>claim<sup>" + "1–" * 16000 + "x</sup></p>")
|
||||
assert time.perf_counter() - start < 2
|
||||
start = time.perf_counter()
|
||||
html_to_markdown(
|
||||
"<p>" + "<b>" + "word 1 " * 40000 + "</b>" * 1 + "9" + "<sup></sup>" * 20000 + "</p>"
|
||||
)
|
||||
assert time.perf_counter() - start < 3
|
||||
|
||||
|
||||
def test_unclosed_superscripts_do_not_exhaust_the_depth_cap():
|
||||
html = "<p>x<sup>2</p>" * 10 + "<p>1.898×10<sup>27</sup> kg</p>"
|
||||
assert html_to_markdown(html).endswith("1.898×10^27 kg")
|
||||
|
||||
|
||||
def test_untracked_superscripts_keep_the_stack_bounded():
|
||||
from core.inference._html_to_md import _MarkdownRenderer
|
||||
|
||||
renderer = _MarkdownRenderer()
|
||||
renderer.feed("<p>" + "<sup>" * 5000 + "x")
|
||||
assert len(renderer._sup_starts) <= 8
|
||||
|
||||
|
||||
def test_footnote_superscripts_render_unchanged():
|
||||
html = (
|
||||
'<p>mass<sup class="reference"><a href="#cite_note-12">[12]</a></sup> and '
|
||||
"volume<sup>[13]</sup></p>"
|
||||
)
|
||||
assert html_to_markdown(html) == "mass[[12]](#cite_note-12) and volume[13]"
|
||||
|
||||
|
||||
def test_fetched_page_keeps_exponents_beside_footnotes(monkeypatch):
|
||||
body = (
|
||||
"<html><body><main><article><p>Jupiter has a mass of 1.898×10<sup>27</sup> kg"
|
||||
'<sup class="reference"><a href="#cite_note-12">[12]</a></sup> and a surface area of '
|
||||
"6.1419×10<sup>10</sup> km<sup>2</sup>.</p></article></main></body></html>"
|
||||
)
|
||||
out = _page_text(monkeypatch, "https://en.wikipedia.org/wiki/Jupiter", body, "text/html")
|
||||
assert "1.898×10^27 kg[[12]](#cite_note-12) and a surface area of 6.1419×10^10 km^2." in out
|
||||
|
||||
|
||||
def test_page_cut_by_the_room_left_drops_its_site_link_urls(monkeypatch):
|
||||
from core.inference import tools
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue