Coverage for src/local_deep_research/text_optimization/citation_formatter.py: 97%

1104 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1"""Citation formatter for adding hyperlinks and alternative citation styles.""" 

2 

3import html 

4import re 

5import unicodedata 

6import urllib.parse 

7from array import array 

8from enum import Enum 

9from html.entities import html5 

10from typing import Any, Dict, List, Optional, Tuple 

11from urllib.parse import urlparse, urlsplit, urlunsplit 

12 

13from loguru import logger 

14from slugify import slugify 

15 

16from ..content_fetcher.url_classifier import URLClassifier, URLType 

17 

18# Schemes a citation may carry into a markdown link. An allowlist rather than a 

19# list of known-bad schemes: the formatter's job here is deciding what may be 

20# rendered, and `javascript:`, `data:`, `vbscript:`, `blob:` and whatever comes 

21# next all fail the same test without having to be enumerated. A relative URL 

22# stays allowed, because a library document is cited as `/library/...`. A 

23# destination that only looks scheme-less is still checked: its HTML-character- 

24# reference-decoded form must pass too (`javascript:` renders as a live 

25# `javascript:` href), and one that names a host (`//host/x`) is refused. 

26_LINKABLE_URL_SCHEMES = frozenset({"http", "https"}) 

27 

28# A scheme check alone is not enough. The destination is interpolated into 

29# `](...)`, so anything markdown would read as the end of that destination 

30# lets a source URL reopen markdown and write its own link past the gate. 

31# Percent-encoding keeps the link resolvable, since a server decodes `%20` 

32# back to a space. `[` and `]` are deliberately NOT encoded: they do not end a 

33# destination, and encoding them would break IPv6 literals (`http://[::1]/x`). 

34_DESTINATION_ESCAPES = { 

35 " ": "%20", 

36 "<": "%3C", 

37 ">": "%3E", 

38 '"': "%22", 

39 "'": "%27", 

40 "\\": "%5C", 

41 "`": "%60", 

42} 

43 

44# Parentheses are the exception, and they are why this is two tables rather 

45# than one. CommonMark ends a bare destination at the first *unbalanced* `)` 

46# and carries balanced pairs through unharmed, so encoding them unconditionally 

47# rewrites ordinary URLs that nothing was wrong with -- Wikipedia alone titles 

48# thousands of articles `..._(disambiguation)` -- and the reader is shown a URL 

49# that is not the one the source served. A balanced destination is therefore 

50# left verbatim, and only an unbalanced one is encoded, which is exactly the 

51# case that would otherwise end early. 

52_PARENTHESIS_ESCAPES = {"(": "%28", ")": "%29"} 

53 

54 

55def _parentheses_are_balanced(url: str) -> bool: 

56 """Whether markdown will carry every `(`/`)` in ``url`` inside the link.""" 

57 depth = 0 

58 for character in url: 

59 if character == "(": 

60 depth += 1 

61 elif character == ")": 

62 depth -= 1 

63 if depth < 0: 

64 return False 

65 return depth == 0 

66 

67 

68def _destination_is_refused(form: str) -> bool: 

69 """Whether one spelling of a destination fails the control or scheme rule.""" 

70 if any( 

71 ord(character) < 0x20 or ord(character) == 0x7F for character in form 

72 ): 

73 return True 

74 try: 

75 scheme = urlsplit(form).scheme 

76 except ValueError: 

77 return True 

78 if scheme: 

79 return scheme not in _LINKABLE_URL_SCHEMES 

80 # No scheme reads as relative, except where a browser takes the host from 

81 # it: `//host/x`, and equally `///host/x` or `/\host/x`, which it resolves 

82 # the same way and ``urlsplit`` does not. Rendered from a file on disk, 

83 # an exported report resolves it to `file://host/x`. 

84 return form.lstrip().replace("\\", "/").startswith("//") 

85 

86 

87def _authority_head_has_userinfo(netloc: str, path: str) -> bool: 

88 """Whether a linkable-scheme URL spells its authority without ``//``. 

89 

90 WHATWG-consuming renderers read ``https:user:pass@example.org/x`` — and 

91 its ``https:/``, ``https:///`` and ``https:\\`` spellings — as the 

92 authority ``user:pass@example.org``: any run of slashes or backslashes 

93 stands in for the ``//``. ``urlsplit`` populates ``netloc`` only for the 

94 ``//``-delimited form, so an @-in-netloc test alone lets those spellings 

95 keep their userinfo. The authority-ish head — what follows the scheme, 

96 past leading slashes or backslashes, up to the first ``/``, ``?`` or 

97 ``#`` — is where those parsers read it; an @ there is treated as a 

98 credential. An @ further along (``.../@author/...``, ``?q=a@b``) never 

99 lands in it. 

100 

101 The head is rebuilt from the ``netloc`` and ``path`` that ``urlsplit`` 

102 parsed, never cut out of the input at the scheme's length. ``urlsplit`` 

103 drops leading spaces and C0 controls, and every tab and newline, before 

104 it reads the scheme, as a browser does. ``&#32;https:/user:pass@host`` 

105 decodes to a leading space and then the URL, so a cut at 

106 ``len("https") + 1`` started on the ``:``, found no ``@`` before the 

107 next ``/``, and let the credential through. ``?`` and ``#`` need no 

108 search here: ``urlsplit`` has already split them off. 

109 """ 

110 rest = f"//{netloc}{path}" if netloc else path 

111 head = rest.strip().lstrip("/\\").partition("/")[0] 

112 return "@" in head 

113 

114 

115def _spells_hidden_userinfo(candidate: str) -> bool: 

116 """Whether a raw or entity-decoded reading of ``candidate`` hides userinfo. 

117 

118 Consulted after stripping literal userinfo, so every reading that finds 

119 an @ is a spelling the plain parse missed: 

120 an authority not delimited by ``//``, or one assembled by rendered HTML 

121 entities — ``https&#58;//user:pass@host`` breaks the scheme token, parses 

122 as scheme-less, and decodes into a live credential URL in a browser. 

123 

124 A reading that ``urlsplit`` rejects counts as hiding userinfo, just as 

125 ``_destination_is_refused`` refuses one: the guard cannot vouch for what 

126 it cannot parse. Stripping can produce such a reading. 

127 ``https://[@v1.u:p]&#64;v1.example/`` parses, but its userinfo is the 

128 ``[`` before the first ``@``, and once that is removed what is left has 

129 an unbalanced ``]``, which ``urlsplit`` rejects. A browser still decodes 

130 that remainder into ``https://v1.u:p]@v1.example/``, whose userinfo is 

131 ``v1.u:p]``. 

132 """ 

133 try: 

134 parsed = urlsplit(candidate) 

135 except ValueError: 

136 return True 

137 if ( 

138 parsed.scheme in _LINKABLE_URL_SCHEMES 

139 and "@" not in parsed.netloc 

140 and _authority_head_has_userinfo(parsed.netloc, parsed.path) 

141 ): 

142 return True 

143 decoded = html.unescape(candidate) 

144 if decoded == candidate: 

145 return False 

146 try: 

147 cooked = urlsplit(decoded) 

148 except ValueError: 

149 return True 

150 return cooked.scheme in _LINKABLE_URL_SCHEMES and ( 

151 "@" in cooked.netloc 

152 or _authority_head_has_userinfo(cooked.netloc, cooked.path) 

153 ) 

154 

155 

156def _safe_link_destination(url: str) -> str: 

157 """Return ``url`` ready to be interpolated into ``](...)``, or "" to refuse. 

158 

159 Four things have to hold, and what comes back is the string that was 

160 checked rather than the one that came in: 

161 

162 1. No control character survives. ``urlsplit`` removes tabs and newlines 

163 and strips leading C0 controls only in order to *parse*; they stay in 

164 the string. A destination carrying a newline ends there and the rest is 

165 emitted as document body. NUL is not stripped at all, so 

166 ``java\x00script:`` parses as scheme-less and would otherwise pass. 

167 2. The scheme is ``http``, ``https``, or absent. Looser than 

168 ``CitationFormatter._is_linkable_url``, which requires a scheme and is 

169 what SOURCE_TAGGED uses to decide whether to render a link at all; the 

170 other modes keep linking the relative URLs a library document uses. A 

171 scheme-less destination that names a host (``//host/x``) is refused. 

172 3. HTTP userinfo is removed before the URL reaches a report or export. 

173 It is a credential even when the destination itself is otherwise 

174 safe, and it is refused rather than stripped when it is spelled 

175 where the plain parser does not look — an authority not delimited 

176 by ``//``, or one assembled from HTML entities — or when what is 

177 left once it is removed no longer parses. 

178 4. What markdown would read as syntax is percent-encoded, so the gate 

179 cannot be walked around by ending the destination early. 

180 

181 Rules 1 and 2 are checked on the character-reference-decoded form too. 

182 marked and Python-Markdown copy the destination into ``href`` as written, 

183 and the HTML parser then decodes ``javascript&colon;alert(1)`` into a live 

184 ``javascript:`` URL. The decoded form is only checked, never emitted: 

185 rewriting ``&`` would break every query string with more than one 

186 parameter. 

187 

188 The rendered markdown reaches the browser through DOMPurify, which strips a 

189 ``javascript:`` href before it reaches the DOM. That is one layer, and it is 

190 the only one today: an exported .md / .qmd / .tex and every other markdown 

191 consumer gets whatever this returns. 

192 

193 A refused destination leaves the citation unlinked rather than dropping 

194 it: callers treat an empty URL as no URL, so the citation stays a plain 

195 ``[N]`` bracket, or an unlinked tag such as ``[local-N]`` in 

196 SOURCE_TAGGED mode. 

197 """ 

198 candidate = url.strip() 

199 if not candidate: 

200 return "" 

201 if _destination_is_refused(candidate) or _destination_is_refused( 

202 html.unescape(candidate) 

203 ): 

204 return "" 

205 parsed = urlsplit(candidate) 

206 if parsed.scheme in _LINKABLE_URL_SCHEMES and "@" in parsed.netloc: 

207 # A parseable authority keeps its host and loses its userinfo. 

208 host = parsed.netloc.rpartition("@")[2] 

209 if not host: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true

210 return "" 

211 candidate = urlunsplit(parsed._replace(netloc=host)) 

212 if _spells_hidden_userinfo(candidate): 

213 # The userinfo is spelled where the plain parse does not look — 

214 # without the ``//``, or through HTML entities that a rendered 

215 # link decodes back into a live credential URL — or what is left 

216 # no longer parses. Refuse rather than emit it. 

217 return "" 

218 escapes = dict(_DESTINATION_ESCAPES) 

219 if not _parentheses_are_balanced(candidate): 

220 escapes.update(_PARENTHESIS_ESCAPES) 

221 return "".join(escapes.get(character, character) for character in candidate) 

222 

223 

224# Marker emitted by ``IntegratedReportGenerator._format_final_report`` 

225# around the ``## Sources`` block it appends to the end of a detailed-mode 

226# report. Looked up by ``find_sources_section`` so the splitter can locate 

227# the appended sources section unambiguously, even when the LLM has 

228# emitted its own ``## Sources`` (or ``References:`` etc.) header earlier 

229# in the prose. The sentinel is an HTML comment, and renderMarkdown() 

230# (services/ui.js) shows raw HTML in Markdown as literal text, so it must 

231# not reach displayed report text: ``strip_appended_sources_sentinel`` 

232# removes it when a detailed report is saved, when a saved report is 

233# assembled for display or export, and from news-feed findings. 

234LDR_APPENDED_SOURCES_SENTINEL = "<!-- LDR_APPENDED_SOURCES_START -->" 

235 

236_SOURCES_SECTION_PATTERNS = [ 

237 re.compile( 

238 r"^#{1,3}\s*(?:Sources|References|Bibliography|Citations)", 

239 re.MULTILINE | re.IGNORECASE, 

240 ), 

241 re.compile( 

242 r"^(?:Sources|References|Bibliography|Citations):?\s*$", 

243 re.MULTILINE | re.IGNORECASE, 

244 ), 

245] 

246 

247 

248def find_sources_section( 

249 content: str, *, trust_sentinel: bool = True 

250) -> Tuple[int, bool]: 

251 """Locate the sources/references section in *content*. 

252 

253 Returns ``(position, on_sentinel)``. ``position`` is the character 

254 offset of the section start, or ``-1`` if no section is found. 

255 ``on_sentinel`` is ``True`` iff the unique sentinel emitted by the 

256 report generator was matched (in which case the boundary is correct 

257 by construction); ``False`` if the position came from the legacy 

258 regex patterns (which can match an early ``## Sources`` line inside 

259 the answer body and need an over-strip safety check downstream). 

260 

261 The sentinel is matched at its **last** occurrence (``rfind``) so a 

262 spoofed earlier occurrence inside LLM-generated prose — e.g. the 

263 LLM quoting the marker string verbatim while summarizing the 

264 codebase — cannot silently truncate the answer body or disable the 

265 over-strip safety check. Only a sentinel on its own line is trusted; 

266 inline occurrences are skipped while searching backwards. The report 

267 generator only ever emits the sentinel once at the end of the assembled 

268 report, so the last standalone occurrence is the trusted one. 

269 

270 When ``trust_sentinel`` is ``False`` (e.g. raw LLM output from the 

271 quick-mode save path, which never adds the sentinel itself), the 

272 sentinel branch is skipped — only the legacy regex patterns run, so 

273 a spurious ``on_sentinel=True`` cannot arise from the LLM quoting 

274 the marker. 

275 """ 

276 if trust_sentinel: 

277 sentinel_pos = content.rfind(LDR_APPENDED_SOURCES_SENTINEL) 

278 while sentinel_pos != -1: 

279 preceded = sentinel_pos == 0 or content[sentinel_pos - 1] in ( 

280 "\n", 

281 "\r", 

282 ) 

283 after = content[sentinel_pos + len(LDR_APPENDED_SOURCES_SENTINEL) :] 

284 followed = not after or after[0] in ("\n", "\r") 

285 if preceded and followed: 

286 return sentinel_pos, True 

287 sentinel_pos = content.rfind( 

288 LDR_APPENDED_SOURCES_SENTINEL, 0, sentinel_pos 

289 ) 

290 for pattern in _SOURCES_SECTION_PATTERNS: 

291 match = pattern.search(content) 

292 if match: 

293 return match.start(), False 

294 return -1, False 

295 

296 

297def strip_appended_sources_sentinel(content: str) -> str: 

298 """Remove the report generator's appended-sources sentinel from *content*. 

299 

300 Only the occurrence ``find_sources_section`` trusts is removed: the last 

301 one that stands on its own line, which is the one 

302 ``IntegratedReportGenerator`` emitted. The line breaks around it are kept. 

303 A copy quoted inside a line, or an earlier one, is report text and is 

304 left as written. 

305 

306 The sentinel is an HTML comment. renderMarkdown() (services/ui.js) shows 

307 raw HTML in Markdown as literal text, so a sentinel left in report text 

308 would appear on the page. The detailed-mode save site keeps the whole 

309 report, Sources block and sentinel included, in 

310 ``CitationMode.NO_HYPERLINKS`` mode and when the citation formatter 

311 raises; it now removes the sentinel before saving, and reports saved 

312 that way earlier still contain it. 

313 

314 Anything that is not a non-empty string is returned unchanged. 

315 """ 

316 if not isinstance(content, str) or not content: 

317 return content 

318 sentinel_start, on_sentinel = find_sources_section(content) 

319 if not on_sentinel: 

320 return content 

321 return ( 

322 content[:sentinel_start] 

323 + content[sentinel_start + len(LDR_APPENDED_SOURCES_SENTINEL) :] 

324 ) 

325 

326 

327_BIB_SOURCES_PATTERN = re.compile( 

328 r"^\[(\d+(?:,\s*\d+)*)\]\s*(.+?)(?:\n\s*URL:\s*(.+?))?$", 

329 re.MULTILINE, 

330) 

331 

332 

333# Single-pass character map. A sequential replace() chain would re-escape 

334# the braces its own replacements introduce — the same class of bug that 

335# _escape_latex_text's single regex pass avoids below, where "\\" becomes 

336# "\textbackslash{}" without a later brace rule mangling it again. 

337_BIBTEX_ESCAPES = { 

338 "\\": r"\textbackslash{}", 

339 # Balanced pairs, not backslash escapes: BibTeX's field scanner counts 

340 # brace CHARACTERS and has no escape mechanism, so "\{" is still an open 

341 # brace to it. A backslashed brace left the value unbalanced, and the 

342 # scanner ran to EOF swallowing the rest of the .bib. 

343 "{": r"{\textbraceleft}", 

344 "}": r"{\textbraceright}", 

345 "&": r"\&", 

346 "%": r"\%", 

347 "$": r"\$", 

348 "#": r"\#", 

349 "_": r"\_", 

350 "~": r"\textasciitilde{}", 

351 "^": r"\textasciicircum{}", 

352 '"': "'", 

353} 

354 

355 

356# U+0085 NEL already falls inside the 0x7F-0x9F range below; only these two 

357# separators sit outside it. 

358_UNICODE_LINE_SEPARATORS = "\u2028\u2029" 

359 

360 

361def is_line_breaking_char(char: str) -> bool: 

362 """True if *char* can begin a new line for some consumer of our output. 

363 

364 ONE definition, imported by every producer. The recurring bug in this 

365 area has been fixing one instance and leaving a sibling — CR handled 

366 but not NEL, the URL sanitised but not the citation index — so the 

367 union is deliberately wider than any single consumer needs: C0 and DEL 

368 for everything, C1 because such a codepoint is never legitimate text, 

369 and U+2028/U+2029 because CSS Text makes them mandatory breaks in 

370 rendered HTML even though Python's ``re`` and ``str.splitlines()`` 

371 ignore them. 

372 

373 Imported by ``search_utilities._sanitize_sources_field``; 

374 ``test_bibliography_escaping`` asserts the two agree on every BMP 

375 codepoint, so the claim cannot rot into a comment again. 

376 """ 

377 code = ord(char) 

378 return ( 

379 code < 0x20 or 0x7F <= code <= 0x9F or char in _UNICODE_LINE_SEPARATORS 

380 ) 

381 

382 

383def _escape_bibtex(value: str) -> str: 

384 r"""Escape a value for interpolation into a BibTeX field. 

385 

386 Titles come from search-result metadata — whatever a page calls itself 

387 — and were previously written into ``title = "{...}"`` raw. A title 

388 ending the field and opening another (``Benign}", url = "...", note = 

389 "{x``) injected an attacker-controlled ``url`` that BibTeX preferred, 

390 and an unbalanced brace ran the parser on into the rest of the file. 

391 """ 

392 return "".join( 

393 # Control characters are flattened, not escaped. The .bib is 

394 # embedded in a ```bibtex markdown fence by the Quarto exporter, 

395 # and a bare CR — which ``_BIB_SOURCES_PATTERN``'s ``.`` matches, 

396 # since only ``\n`` is excluded — closes that fence once a reader 

397 # normalises CRLF, turning the remainder into live markup. 

398 # ``_safe_bibtex_url`` already rejects these; this matches it. 

399 " " if is_line_breaking_char(ch) else _BIBTEX_ESCAPES.get(ch, ch) 

400 for ch in value 

401 ) 

402 

403 

404_LATEX_TEXT_SPECIALS: dict[str, str] = { 

405 "\\": r"\textbackslash{}", 

406 "&": r"\&", 

407 "%": r"\%", 

408 "$": r"\$", 

409 "#": r"\#", 

410 "_": r"\_", 

411 "{": r"\{", 

412 "}": r"\}", 

413 "~": r"\textasciitilde{}", 

414 "^": r"\textasciicircum{}", 

415} 

416_LATEX_TEXT_SPECIALS_RE = re.compile( 

417 "[" + re.escape("".join(_LATEX_TEXT_SPECIALS)) + "]" 

418) 

419 

420 

421def _escape_latex_text(text: str) -> str: 

422 r"""Escape every LaTeX-special character in one pass. 

423 

424 One regex pass is load-bearing. Sequential ``str.replace`` passes 

425 re-process their own output: the braces in ``\textbackslash{}`` get 

426 escaped again and a literal backslash comes out as the invalid 

427 ``\textbackslash\{\}``. ``re.sub`` never rescans replacement text, 

428 so each special character is replaced exactly once. 

429 """ 

430 return _LATEX_TEXT_SPECIALS_RE.sub( 

431 lambda m: _LATEX_TEXT_SPECIALS[m.group()], text 

432 ) 

433 

434 

435# Matches the leading '#' markers of an ATX heading so the heading TEXT 

436# can be escaped while the marker itself survives for the 

437# heading-conversion pass in export_to_latex. 

438_HEADING_PREFIX_RE = re.compile(r"^(\s*#+\s+)(.*)$") 

439 

440 

441def _safe_bibtex_url(value: str) -> str: 

442 r"""Return *value* if it is safe to place in ``url = {...}``, else "". 

443 

444 Escaping is wrong for a URL — a ``\%`` would corrupt a percent-encoded 

445 octet. Nor may the offending characters simply be DROPPED: removing a 

446 backslash from ``//trusted.example\@evil.example/p`` turns the trusted 

447 host into userinfo, so the exported link points at a different host 

448 than the one the reader vetted in the report. A URL containing a 

449 breakout character is not a URL to repair — it is omitted, and the 

450 caller falls through to its existing URL-less branch. 

451 """ 

452 if any(ch in '{}"\\ ' or is_line_breaking_char(ch) for ch in value): 

453 # Local-document sources legitimately trip this: a LangChain 

454 # retriever sets a result's url from its ``source`` metadata, which 

455 # is often a filesystem path carrying spaces or backslashes. 

456 # Dropping the link silently would leave the user guessing why a 

457 # bibliography entry has none, so record it. 

458 # Truncated: a hostile result can carry a megabyte-long URL, and 

459 # this fires once per affected entry — and the Quarto exporter 

460 # builds the bibliography twice per export, so it fires twice per 

461 # entry in practice. ``!r`` keeps control characters escaped. 

462 logger.debug( 

463 "Omitting unsafe bibliography URL: {!r}{}", 

464 value[:200], 

465 "…" if len(value) > 200 else "", 

466 ) 

467 return "" 

468 return value 

469 

470 

471def _iter_citation_group(group: str): 

472 r"""Yield each index on one Sources line (``"1, 2, 3"`` -> "1", "2", "3"). 

473 

474 ``format_links_to_markdown`` collapses every citation of one source 

475 onto a single line carrying all of its indices, so a single-index 

476 pattern drops such a source from the bibliography entirely. Exporters 

477 emit one entry per index so each ``[N]`` in the body resolves. 

478 

479 Indices are yielded as STRINGS, exactly as written: the body citations 

480 are rewritten from the same text, so converting ``007`` to ``7`` here 

481 would key the entry ``ref7`` while the body still cites ``ref007``. 

482 No digit filtering either — the pattern's ``\d`` matches only decimal 

483 digits (category ``Nd``), which ``int()`` always accepts, so filtering 

484 could only discard valid indices such as Arabic-Indic ones. 

485 """ 

486 for part in group.split(","): 

487 part = part.strip() 

488 if part: 488 ↛ 486line 488 didn't jump to line 486 because the condition on line 488 was always true

489 yield part 

490 

491 

492def _sort_key(index: str): 

493 """Order bibliography entries numerically where possible.""" 

494 try: 

495 return (0, int(index), "") 

496 except ValueError: 

497 return (1, 0, index) 

498 

499 

500def _collect_bibliography_entries(content: str) -> dict: 

501 """Map each citation index to the ``(title, url)`` it names. 

502 

503 Selection rule: a URL-bearing entry never loses to a URL-less one, and 

504 among URL-bearing entries the LATER occurrence wins. 

505 

506 Both halves matter. A report repeats its Sources block once per section 

507 and then again as a combined block, and the combined block is both last 

508 and authoritative — so later-wins. And the group-aware pattern can match 

509 a line of prose that opens with ``[1, 2]`` where a single-index pattern 

510 could not; such a line carries no ``URL:``, whereas every line 

511 ``format_links_to_markdown`` renders does, so the URL preference is what 

512 keeps prose from taking a citation key. 

513 

514 Deliberately NOT scoped to a Sources section. Bounding the block at 

515 markers like ``\n---`` looked safer but silently emptied the 

516 bibliography whenever a thematic break, a setext underline or a table 

517 delimiter row came first — while the body was still rewritten to cite 

518 entries that no longer existed. 

519 """ 

520 entries: dict[str, tuple[str, str]] = {} 

521 for match in _BIB_SOURCES_PATTERN.finditer(content): 

522 title = match.group(2).strip() 

523 url = match.group(3).strip() if match.group(3) else "" 

524 for index in _iter_citation_group(match.group(1)): 

525 # Prefer an entry carrying a URL: a real Sources line has one, 

526 # a prose false positive does not. 

527 if url or index not in entries: 527 ↛ 524line 527 didn't jump to line 524 because the condition on line 527 was always true

528 entries[index] = (title, url) 

529 return entries 

530 

531 

532class CitationMode(Enum): 

533 """Available citation formatting modes.""" 

534 

535 NUMBER_HYPERLINKS = "number_hyperlinks" # [1] with hyperlinks 

536 DOMAIN_HYPERLINKS = "domain_hyperlinks" # [arxiv.org] with hyperlinks 

537 DOMAIN_ID_HYPERLINKS = ( 

538 "domain_id_hyperlinks" # [arxiv.org] or [arxiv.org-1] with smart IDs 

539 ) 

540 DOMAIN_ID_ALWAYS_HYPERLINKS = ( 

541 "domain_id_always_hyperlinks" # [arxiv.org-1] always with IDs 

542 ) 

543 SOURCE_TAGGED_HYPERLINKS = "source_tagged_hyperlinks" 

544 """Preserve the global citation number and prefix it with a short source 

545 tag derived from the URL: known academic sources via ``URLClassifier`` 

546 (``arxiv-7``, ``pubmed-3``), domain otherwise (``nytimes.com-9``), and 

547 ``local-N`` for empty / local URLs. Unlike DOMAIN_ID_* modes the 

548 suffix is the original citation number, so labels never collide and 

549 match the bibliography order: ``[1]`` arxiv + ``[2]`` openai + ``[3]`` 

550 arxiv -> ``arxiv-1``, ``openai-2``, ``arxiv-3``.""" 

551 NO_HYPERLINKS = "no_hyperlinks" # [1] without hyperlinks 

552 

553 

554class CitationFormatter: 

555 """Formats citations in markdown documents with various styles.""" 

556 

557 def __init__(self, mode: CitationMode = CitationMode.NUMBER_HYPERLINKS): 

558 self.mode = mode 

559 # Use negative lookbehind and lookahead to avoid matching already formatted citations 

560 # Also match Unicode lenticular brackets 【】 (U+3010 and U+3011) that LLMs sometimes generate 

561 self.citation_pattern = re.compile( 

562 r"(?<![\[【])[\[【](\d+)[\]】](?![\]】])" 

563 ) 

564 self.comma_citation_pattern = re.compile( 

565 r"[\[【](\d+(?:\s*,\s*\d+)+)[\]】]" # (#6944) space allowed BEFORE the comma too — a hand-edited `[[2 ,3]]` must stay a group 

566 ) 

567 # Also match "Source X" or "source X" patterns 

568 self.source_word_pattern = re.compile(r"\b[Ss]ource\s+(\d+)\b") 

569 self.sources_pattern = re.compile( 

570 r"^\[(\d+(?:,\s*\d+)*)\]\s*(.+?)(?:\n\s*URL:\s*(.+?))?$", 

571 re.MULTILINE, 

572 ) 

573 

574 def _create_source_word_replacer(self, formatter_func): 

575 """Create a replacement function for 'Source X' patterns. 

576 

577 Args: 

578 formatter_func: A function that takes citation_num and returns formatted text 

579 

580 Returns: 

581 A replacement function for use with regex sub 

582 """ 

583 

584 def replace_source_word(match): 

585 citation_num = match.group(1) 

586 return formatter_func(citation_num) 

587 

588 return replace_source_word 

589 

590 def _create_citation_formatter(self, sources_dict, format_pattern): 

591 """Create a formatter function for citations. 

592 

593 Args: 

594 sources_dict: Dictionary mapping citation numbers to data 

595 format_pattern: A callable that takes (citation_num, data) and returns formatted string 

596 

597 Returns: 

598 A function that formats citations or returns fallback 

599 """ 

600 

601 def formatter(citation_num): 

602 if citation_num in sources_dict: 

603 data = sources_dict[citation_num] 

604 return format_pattern(citation_num, data) 

605 return f"[{citation_num}]" 

606 

607 return formatter 

608 

609 def _replace_comma_citations(self, content, lookup, format_one): 

610 """Replace comma-separated citations like [1, 2, 3] using *lookup* and *format_one*. 

611 

612 Args: 

613 content: Text to process 

614 lookup: Dict mapping citation number (str) to data 

615 format_one: ``(num, data) -> str`` callback that formats a single citation 

616 """ 

617 

618 def _replacer(match): 

619 nums = [n.strip() for n in match.group(1).split(",")] 

620 parts = [] 

621 for num in nums: 

622 if num in lookup: 

623 parts.append(format_one(num, lookup[num])) 

624 else: 

625 parts.append(f"[{num}]") 

626 return "".join(parts) 

627 

628 return self.comma_citation_pattern.sub(_replacer, content) 

629 

630 def format_document(self, content: str) -> str: 

631 """Format citations in *content* and return the full document. 

632 

633 This is the legacy single-string entry point: it concatenates 

634 the answer half and the sources half produced by 

635 :meth:`format_document_split`. New code that needs to know 

636 where the boundary between answer and sources actually fell 

637 should use :meth:`format_document_split` instead so the boundary 

638 is returned explicitly (no re-parsing of the concatenated output). 

639 """ 

640 formatted_answer, sources_md, _on_sentinel = self.format_document_split( 

641 content 

642 ) 

643 return formatted_answer + sources_md 

644 

645 def format_document_split( 

646 self, content: str, *, trust_sentinel: bool = True 

647 ) -> Tuple[str, str, bool]: 

648 """Format citations and return ``(answer, sources_md, on_sentinel)``. 

649 

650 The boundary between the LLM's answer and the trailing Sources 

651 section is computed inside this method. Callers that only want 

652 the answer (e.g. the chat-mode save site) get a clean split 

653 without re-applying a regex on concatenated output downstream. 

654 ``on_sentinel`` mirrors the second element of 

655 :func:`find_sources_section`'s return — ``True`` iff the split 

656 boundary came from the appended-sources sentinel emitted by 

657 ``IntegratedReportGenerator``, in which case the boundary is 

658 correct by construction and downstream over-strip safety checks 

659 should be skipped. Pass ``trust_sentinel=False`` for raw LLM 

660 output (quick-mode save path) where the sentinel would only 

661 appear if the LLM quoted the marker itself. 

662 

663 Returns ``(content, "", False)`` when the formatter is in 

664 NO_HYPERLINKS mode or when no Sources section can be found in 

665 ``content``. 

666 """ 

667 if self.mode == CitationMode.NO_HYPERLINKS: 

668 return content, "", False 

669 

670 sources_start, on_sentinel = find_sources_section( 

671 content, trust_sentinel=trust_sentinel 

672 ) 

673 if sources_start == -1: 

674 return content, "", False 

675 

676 document_content = content[:sources_start] 

677 sources_content = content[sources_start:] 

678 

679 sources = self._parse_sources(sources_content) 

680 

681 if self.mode == CitationMode.NUMBER_HYPERLINKS: 

682 formatted_content = self._format_number_hyperlinks( 

683 document_content, sources 

684 ) 

685 elif self.mode == CitationMode.DOMAIN_HYPERLINKS: 

686 formatted_content = self._format_domain_hyperlinks( 

687 document_content, sources 

688 ) 

689 elif self.mode == CitationMode.DOMAIN_ID_HYPERLINKS: 

690 formatted_content = self._format_domain_id_hyperlinks( 

691 document_content, sources 

692 ) 

693 elif self.mode == CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS: 

694 formatted_content = self._format_domain_id_always_hyperlinks( 

695 document_content, sources 

696 ) 

697 elif self.mode == CitationMode.SOURCE_TAGGED_HYPERLINKS: 697 ↛ 704line 697 didn't jump to line 704 because the condition on line 697 was always true

698 formatted_content = self._format_source_tagged_hyperlinks( 

699 document_content, 

700 sources, 

701 self._parse_collections(sources_content), 

702 ) 

703 else: 

704 formatted_content = document_content 

705 

706 return formatted_content, sources_content, on_sentinel 

707 

708 def apply_inline_hyperlinks( 

709 self, content: str, sources: List[Dict[str, Any]] 

710 ) -> str: 

711 """Hyperlink ``[N]`` refs using a structured source list. 

712 

713 Dispatches on ``self.mode`` so the user's chosen citation 

714 format (Settings → Report → Citation Format) is honored on 

715 the fallback path the same way it is in 

716 :meth:`format_document_split`. Inherits all the existing 

717 per-mode guards (lookbehind/lookahead against ``[[1]]``, 

718 comma-list handling like ``[1,2,3]``, ``Source N`` word form, 

719 missing-index pass-through, lenticular bracket support). 

720 

721 Used as the safe fallback at save time when the LLM does NOT 

722 emit a Sources section in its prose — the structured source 

723 list (e.g. ``search_system.all_links_of_system``) is the 

724 canonical source of URLs and indices. 

725 """ 

726 if not content or not sources: 

727 return content or "" 

728 if self.mode == CitationMode.NO_HYPERLINKS: 

729 return content 

730 

731 # Search-engine result dicts use either "url" or "link" for the 

732 # destination — Searxng emits {"link": ..., "title": ..., "snippet": ...} 

733 # (search_engine_searxng.py:538) and other engines use "url". 

734 # Looking up only `s["url"]` silently dropped every Searxng-sourced 

735 # citation, leaving the answer body with plain `[N]` brackets even 

736 # though the Sources section beneath was fully populated. Accept 

737 # both keys so the hyperlink fallback works regardless of engine. 

738 def _src_url(s): 

739 return _safe_link_destination(s.get("url") or s.get("link") or "") 

740 

741 adapted: Dict[str, Tuple[str, str]] = { 

742 str(s["index"]): (s.get("title", "Untitled"), _src_url(s)) 

743 for s in sources 

744 if _src_url(s) and s.get("index") is not None 

745 } 

746 if not adapted: 

747 return content 

748 

749 # Per-mode dispatch — mirrors format_document_split so the user's 

750 # chosen citation format applies on this fallback path too. 

751 # Previously this was hard-coded to _format_number_hyperlinks, 

752 # which meant chat-mode answers (which always hit this fallback 

753 # because the langgraph-agent synthesis doesn't emit a ## Sources 

754 # block in its prose) ignored the report.citation_format setting 

755 # entirely — every chat answer came out as [[N]](url) even when 

756 # the user picked domain-based or source-tagged formatting. 

757 if self.mode == CitationMode.DOMAIN_HYPERLINKS: 

758 return self._format_domain_hyperlinks(content, adapted) 

759 if self.mode == CitationMode.DOMAIN_ID_HYPERLINKS: 

760 return self._format_domain_id_hyperlinks(content, adapted) 

761 if self.mode == CitationMode.DOMAIN_ID_ALWAYS_HYPERLINKS: 

762 return self._format_domain_id_always_hyperlinks(content, adapted) 

763 if self.mode == CitationMode.SOURCE_TAGGED_HYPERLINKS: 

764 # Pull collection names off the structured source dicts 

765 # (format_links_to_markdown uses the same shape: 

766 # link["metadata"]["collection_name"]) so the SOURCE_TAGGED 

767 # formatter can surface library/RAG tags as the citation 

768 # label when present. 

769 collections: Dict[str, str] = {} 

770 for s in sources: 

771 idx = s.get("index") 

772 if idx is None: 772 ↛ 773line 772 didn't jump to line 773 because the condition on line 772 was never true

773 continue 

774 meta = s.get("metadata") or {} 

775 coll = meta.get("collection_name") 

776 if coll: 

777 collections.setdefault(str(idx), str(coll)) 

778 return self._format_source_tagged_hyperlinks( 

779 content, adapted, collections 

780 ) 

781 # NUMBER_HYPERLINKS is the default and the catch-all for any 

782 # mode added later that doesn't have an explicit branch above. 

783 return self._format_number_hyperlinks(content, adapted) 

784 

785 def _find_sources_section( 

786 self, content: str, trust_sentinel: bool = True 

787 ) -> Tuple[int, bool]: 

788 """Find the start of the sources/references section. 

789 

790 Returns ``(position, on_sentinel)`` — see 

791 :func:`find_sources_section` for the semantics. ``on_sentinel`` 

792 is True iff the boundary came from the unique sentinel emitted 

793 by the report generator (i.e. it is correct by construction). 

794 """ 

795 return find_sources_section(content, trust_sentinel=trust_sentinel) 

796 

797 def _parse_sources( 

798 self, sources_content: str 

799 ) -> Dict[str, Tuple[str, str]]: 

800 """ 

801 Parse sources section to extract citation numbers, titles, and URLs. 

802 

803 Returns: 

804 Dictionary mapping citation number to (title, url) tuple 

805 """ 

806 sources = {} 

807 matches = list(self.sources_pattern.finditer(sources_content)) 

808 

809 for match in matches: 

810 citation_nums_str = match.group(1) 

811 title = match.group(2).strip() 

812 url = _safe_link_destination(match.group(3) or "") 

813 

814 # Handle comma-separated citation numbers like [36, 3] 

815 # Split by comma and strip whitespace 

816 individual_nums = [ 

817 num.strip() for num in citation_nums_str.split(",") 

818 ] 

819 

820 # Add an entry for each individual number 

821 for num in individual_nums: 

822 sources[num] = (title, url) 

823 

824 return sources 

825 

826 def _format_number_hyperlinks( 

827 self, content: str, sources: Dict[str, Tuple[str, str]] 

828 ) -> str: 

829 """Replace [1] with hyperlinked version where only the number is linked.""" 

830 # Filter sources that have URLs 

831 url_sources = { 

832 num: (title, url) for num, (title, url) in sources.items() if url 

833 } 

834 

835 # Create formatter for citations with number hyperlinks 

836 def format_number_link(citation_num, data): 

837 _, url = data 

838 return f"[[{citation_num}]]({url})" 

839 

840 # Handle comma-separated citations like [1, 2, 3] 

841 content = self._replace_comma_citations( 

842 content, url_sources, format_number_link 

843 ) 

844 

845 formatter = self._create_citation_formatter( 

846 url_sources, format_number_link 

847 ) 

848 

849 # Handle individual citations 

850 def replace_citation(match): 

851 return ( 

852 formatter(match.group(1)) 

853 if match.group(1) in url_sources 

854 else match.group(0) 

855 ) 

856 

857 content = self.citation_pattern.sub(replace_citation, content) 

858 

859 # Also handle "Source X" patterns 

860 return self.source_word_pattern.sub( 

861 self._create_source_word_replacer(formatter), content 

862 ) 

863 

864 def _format_domain_hyperlinks( 

865 self, content: str, sources: Dict[str, Tuple[str, str]] 

866 ) -> str: 

867 """Replace [1] with [domain.com] hyperlinked version.""" 

868 

869 # Filter sources that have URLs 

870 url_sources = { 

871 num: (title, url) for num, (title, url) in sources.items() if url 

872 } 

873 

874 # Pre-compute labels so [1, 2, 3] lists and standalone [N] refs 

875 # use the same string for a given citation. Previously this 

876 # closure called _extract_domain on every match, so a relative 

877 # URL (``/library/document/...``) produced ``[[]]`` for every 

878 # occurrence. _citation_label falls back to a slugified title 

879 # for relative URLs, keeping the label non-empty and readable. 

880 label_cache: Dict[str, str] = { 

881 num: self._citation_label(num, title, url) 

882 for num, (title, url) in url_sources.items() 

883 } 

884 

885 # Create formatter for citations with domain hyperlinks 

886 def format_domain_link(citation_num, data): 

887 _, url = data 

888 label = label_cache[citation_num] 

889 return f"[[{label}]]({url})" 

890 

891 # Handle comma-separated citations like [1, 2, 3] 

892 content = self._replace_comma_citations( 

893 content, url_sources, format_domain_link 

894 ) 

895 

896 formatter = self._create_citation_formatter( 

897 url_sources, format_domain_link 

898 ) 

899 

900 # Handle individual citations 

901 def replace_citation(match): 

902 return ( 

903 formatter(match.group(1)) 

904 if match.group(1) in url_sources 

905 else match.group(0) 

906 ) 

907 

908 content = self.citation_pattern.sub(replace_citation, content) 

909 

910 # Also handle "Source X" patterns 

911 return self.source_word_pattern.sub( 

912 self._create_source_word_replacer(formatter), content 

913 ) 

914 

915 def _format_domain_id_hyperlinks( 

916 self, content: str, sources: Dict[str, Tuple[str, str]] 

917 ) -> str: 

918 """Replace [1] with [domain.com-1] hyperlinked version with hyphen-separated IDs.""" 

919 # First, create a mapping of domains to their citation numbers. 

920 # _citation_label (vs raw _extract_domain) means relative URLs 

921 # like ``/library/document/...`` get a slugified title as the 

922 # "domain" instead of an empty string. Without this, a RAG 

923 # result with one citation rendered as ``[[]](url)`` (the 

924 # label disappeared), and a multi-citation set rendered as 

925 # ``[[-1]](url)`` (the orphaned ``-N`` suffix leaked through). 

926 domain_citations: dict[str, list[Any]] = {} 

927 

928 for citation_num, (title, url) in sources.items(): 

929 if url: 

930 domain = self._citation_label(citation_num, title, url) 

931 if domain not in domain_citations: 

932 domain_citations[domain] = [] 

933 domain_citations[domain].append((citation_num, url)) 

934 

935 # Create a mapping from citation number to domain with ID 

936 citation_to_domain_id = {} 

937 for domain, citations in domain_citations.items(): 

938 if len(citations) > 1: 

939 # Multiple citations from same domain - add hyphen and number 

940 for idx, (citation_num, url) in enumerate(citations, 1): 

941 citation_to_domain_id[citation_num] = ( 

942 f"{domain}-{idx}", 

943 url, 

944 ) 

945 else: 

946 # Single citation from domain - no ID needed 

947 citation_num, url = citations[0] 

948 citation_to_domain_id[citation_num] = (domain, url) 

949 

950 # Create formatter for citations with domain_id hyperlinks 

951 def format_domain_id_link(citation_num, data): 

952 domain_id, url = data 

953 return f"[[{domain_id}]]({url})" 

954 

955 # Handle comma-separated citations 

956 content = self._replace_comma_citations( 

957 content, citation_to_domain_id, format_domain_id_link 

958 ) 

959 

960 formatter = self._create_citation_formatter( 

961 citation_to_domain_id, format_domain_id_link 

962 ) 

963 

964 # Handle individual citations 

965 def replace_citation(match): 

966 return ( 

967 formatter(match.group(1)) 

968 if match.group(1) in citation_to_domain_id 

969 else match.group(0) 

970 ) 

971 

972 content = self.citation_pattern.sub(replace_citation, content) 

973 

974 # Also handle "Source X" patterns 

975 return self.source_word_pattern.sub( 

976 self._create_source_word_replacer(formatter), content 

977 ) 

978 

979 def _format_domain_id_always_hyperlinks( 

980 self, content: str, sources: Dict[str, Tuple[str, str]] 

981 ) -> str: 

982 """Replace [1] with [domain.com-1] hyperlinked version, always with IDs.""" 

983 # Use _citation_label so relative URLs (e.g. RAG /library/document/... 

984 # results) produce a slugified-title prefix instead of an empty 

985 # string. Without this, every label here collapses to ``-N`` 

986 # (e.g. ``[[-1]](url)``) and the user has no idea what the link 

987 # references. See _format_domain_id_hyperlinks for the same fix. 

988 domain_citations: dict[str, list[Any]] = {} 

989 

990 for citation_num, (title, url) in sources.items(): 

991 if url: 

992 domain = self._citation_label(citation_num, title, url) 

993 if domain not in domain_citations: 

994 domain_citations[domain] = [] 

995 domain_citations[domain].append((citation_num, url)) 

996 

997 # Create a mapping from citation number to domain with ID 

998 citation_to_domain_id = {} 

999 for domain, citations in domain_citations.items(): 

1000 # Always add hyphen and number for consistency 

1001 for idx, (citation_num, url) in enumerate(citations, 1): 

1002 citation_to_domain_id[citation_num] = (f"{domain}-{idx}", url) 

1003 

1004 # Create formatter for citations with domain_id hyperlinks 

1005 def format_domain_id_link(citation_num, data): 

1006 domain_id, url = data 

1007 return f"[[{domain_id}]]({url})" 

1008 

1009 # Handle comma-separated citations 

1010 content = self._replace_comma_citations( 

1011 content, citation_to_domain_id, format_domain_id_link 

1012 ) 

1013 

1014 formatter = self._create_citation_formatter( 

1015 citation_to_domain_id, format_domain_id_link 

1016 ) 

1017 

1018 # Handle individual citations 

1019 def replace_citation(match): 

1020 return ( 

1021 formatter(match.group(1)) 

1022 if match.group(1) in citation_to_domain_id 

1023 else match.group(0) 

1024 ) 

1025 

1026 content = self.citation_pattern.sub(replace_citation, content) 

1027 

1028 # Also handle "Source X" patterns 

1029 return self.source_word_pattern.sub( 

1030 self._create_source_word_replacer(formatter), content 

1031 ) 

1032 

1033 # Sources section may carry a "Collection: <name>" line for RAG / 

1034 # library hits (emitted by ``utilities/search_utilities.format_links_to_markdown``). 

1035 # Read one line at a time: overlapping multiline whitespace patterns 

1036 # can repeatedly rescan an entire run of blank lines. 

1037 _collection_header_pattern = re.compile(r"^\[(\d+(?:,\s*\d+)*)\]") 

1038 

1039 def _parse_collections(self, sources_content: str) -> Dict[str, str]: 

1040 """Extract ``{citation_num: collection_name}`` from a sources 

1041 block. Returns an empty dict when no ``Collection:`` lines exist 

1042 — the absence of collection info is the common case (web URLs) 

1043 and must never raise.""" 

1044 collections: Dict[str, str] = {} 

1045 citation_nums = () 

1046 for line in sources_content.split("\n"): 

1047 if line.startswith("["): 

1048 match = self._collection_header_pattern.match(line) 

1049 citation_nums = ( 

1050 tuple(_iter_citation_group(match.group(1))) if match else () 

1051 ) 

1052 continue 

1053 field = line.strip() 

1054 if citation_nums and field.startswith("Collection:"): 

1055 collection = field[len("Collection:") :].strip() 

1056 if collection: 

1057 for num in citation_nums: 

1058 collections[num] = collection 

1059 citation_nums = () 

1060 return collections 

1061 

1062 def _format_source_tagged_hyperlinks( 

1063 self, 

1064 content: str, 

1065 sources: Dict[str, Tuple[str, str]], 

1066 collections: Dict[str, str], 

1067 ) -> str: 

1068 """Replace ``[N]`` with ``[[source-N]](url)``. 

1069 

1070 ``source`` resolves to (in order): the RAG ``Collection:`` 

1071 tag for library hits, the short URLClassifier tag for known 

1072 academic sources (``arxiv``, ``pubmed``, ...), the cleaned 

1073 domain otherwise, or ``local`` for empty/file URLs. ``N`` is 

1074 the original global citation number — labels never collide and 

1075 the suffix always matches the bibliography ordering. 

1076 

1077 Args: 

1078 content: Document body (sources section already split off). 

1079 sources: ``{citation_num: (title, url)}`` parsed from the 

1080 sources block. 

1081 collections: ``{citation_num: collection_name}`` parsed from 

1082 optional ``Collection:`` lines in the sources block 

1083 (empty dict when no library/RAG hits are cited). Wins 

1084 over URL-derived tags when present for a given citation. 

1085 """ 

1086 

1087 # Pre-compute tags so each URL-having source resolves its label 

1088 # once, however many [N] / [1, 2, 3] refs cite it. 

1089 tag_cache: Dict[str, str] = { 

1090 num: self._extract_source_label( 

1091 url, collection=collections.get(num) 

1092 ) 

1093 for num, (_, url) in sources.items() 

1094 if url 

1095 } 

1096 

1097 def format_link(citation_num, data): 

1098 _, url = data 

1099 label = tag_cache.get(citation_num) 

1100 if label is None: 

1101 # Only URL-less sources are absent from tag_cache. Don't 

1102 # pass this as dict.get()'s default — that would evaluate 

1103 # the expensive lookup eagerly on every cache hit too. 

1104 label = self._extract_source_label( 

1105 url, collection=collections.get(citation_num) 

1106 ) 

1107 tag = f"{label}-{citation_num}" 

1108 # Only emit a hyperlink for http(s) URLs — local/file URLs are 

1109 # rendered as plain bracketed tags so the markdown stays clean 

1110 # and viewers don't try to navigate to a server-local path. 

1111 return ( 

1112 f"[[{tag}]]({url})" 

1113 if self._is_linkable_url(url) 

1114 else f"[{tag}]" 

1115 ) 

1116 

1117 # Handle comma-separated citations like [1, 2, 3] 

1118 content = self._replace_comma_citations(content, sources, format_link) 

1119 

1120 formatter = self._create_citation_formatter(sources, format_link) 

1121 

1122 # Handle individual citations 

1123 def replace_citation(match): 

1124 return ( 

1125 formatter(match.group(1)) 

1126 if match.group(1) in sources 

1127 else match.group(0) 

1128 ) 

1129 

1130 content = self.citation_pattern.sub(replace_citation, content) 

1131 

1132 # Also handle "Source X" patterns 

1133 return self.source_word_pattern.sub( 

1134 self._create_source_word_replacer(formatter), content 

1135 ) 

1136 

1137 @staticmethod 

1138 def _slugify_collection(name: str) -> str: 

1139 """Make a user-set collection name safe for inline citations. 

1140 

1141 Collection names are free-form strings (``"My Papers"``, 

1142 ``"team/finance"``). Citations need a compact token that won't 

1143 break markdown — strip whitespace, lowercase, replace runs of 

1144 non-alphanumeric chars with a single hyphen, trim leading and 

1145 trailing hyphens, and fall back to ``"local"`` if the result is 

1146 empty. ``-N`` is appended downstream so we strip trailing 

1147 hyphens to keep the join clean. 

1148 """ 

1149 slug = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") 

1150 return slug or "local" 

1151 

1152 @staticmethod 

1153 def _slugify_title(title: str, max_length: int = 32) -> Optional[str]: 

1154 """Generate a slug from a document title using python-slugify. 

1155 

1156 Returns ``None`` when ``title`` is empty / whitespace / pure 

1157 punctuation, or when ``slugify`` produces an empty result. The 

1158 ``None`` sentinel lets callers distinguish "no meaningful slug" 

1159 from "slug happens to be a real word" (e.g. a document literally 

1160 titled ``"Doc"`` must yield ``"doc"`` as its label, not fall 

1161 through to the citation-number fallback). 

1162 """ 

1163 if not title: 

1164 return None 

1165 slug = slugify(title, max_length=max_length, separator="-") 

1166 return slug if slug else None 

1167 

1168 def _citation_label(self, citation_num: str, title: str, url: str) -> str: 

1169 """Return the inline-citation label for a source. 

1170 

1171 Order of preference: 

1172 1. The URL's domain (``arxiv.org``, ``example.com``) — used by 

1173 web citations. Falls through when the URL is relative or 

1174 empty, as is common for RAG / library documents. 

1175 2. A slugified version of the document title (truncated to 

1176 keep labels compact) — gives users a readable label for 

1177 local-library hits. 

1178 3. The citation number itself — last-resort guarantee the 

1179 label is never empty (an empty label produces ``[[]]`` 

1180 which renders as an uninformative ``[]`` link). 

1181 """ 

1182 domain = self._extract_domain(url) if url else "" 

1183 if domain: 

1184 return domain 

1185 slug = self._slugify_title(title) 

1186 if slug: 

1187 return slug 

1188 return str(citation_num) 

1189 

1190 @staticmethod 

1191 def _is_linkable_url(url: str) -> bool: 

1192 """Return True iff ``url`` is a http(s) URL safe to wrap in a 

1193 markdown hyperlink. Empty strings and file:// / local: schemes 

1194 are not linkable. 

1195 

1196 Built on ``_safe_link_destination`` so the scheme and control-character 

1197 rules live in one place; the only thing added here is that a scheme has 

1198 to be PRESENT. SOURCE_TAGGED uses that to decide whether to render a 

1199 link at all, which is why it, alone among the modes, does not link a 

1200 relative URL. 

1201 """ 

1202 if not _safe_link_destination(url): 

1203 return False 

1204 try: 

1205 return urlsplit(url.strip()).scheme in _LINKABLE_URL_SCHEMES 

1206 except ValueError: 

1207 return False 

1208 

1209 def _extract_source_label( 

1210 self, url: str, collection: str | None = None 

1211 ) -> str: 

1212 """Return a short source tag for ``url``. 

1213 

1214 Resolution order: 

1215 1. ``collection`` (when supplied) wins outright — RAG / library 

1216 hits surface their collection name as the citation tag 

1217 (``mypapers``, ``personal-notes``, ...). The renderer in 

1218 ``utilities/search_utilities.format_links_to_markdown`` 

1219 emits a ``Collection:`` line per source for library results, 

1220 which the formatter parses back into this argument. 

1221 2. Empty URL or non-http(s) scheme (``file://``, ``local:``, ...) → 

1222 ``"local"``. Uniform fallback when no collection name is 

1223 available. 

1224 3. ``URLClassifier`` matches a known academic source → use the 

1225 enum value (``arxiv``, ``pubmed``, ``pmc``, ``biorxiv``, 

1226 ``medrxiv``, ``semantic_scholar``, ``doi``). 

1227 4. Otherwise → fall back to ``_extract_domain`` (e.g. 

1228 ``arxiv.org``, ``nytimes.com``). 

1229 """ 

1230 if collection: 

1231 return self._slugify_collection(collection) 

1232 if not url: 

1233 return "local" 

1234 try: 

1235 parsed = urlparse(url) 

1236 except (ValueError, AttributeError): 

1237 return "local" 

1238 scheme = (parsed.scheme or "").lower() 

1239 if scheme not in ("http", "https"): 

1240 return "local" 

1241 

1242 url_type = URLClassifier.classify(url) 

1243 # Generic HTML/PDF/INVALID → fall back to domain. Everything else 

1244 # is a known academic source whose enum value is the short tag. 

1245 if url_type in (URLType.HTML, URLType.PDF, URLType.INVALID): 

1246 return self._extract_domain(url) 

1247 return str(url_type.value) 

1248 

1249 def _extract_domain(self, url: str) -> str: 

1250 """Extract domain name from URL.""" 

1251 try: 

1252 # The authority as browsers and requests read it: they end it at 

1253 # a backslash, where urllib reads on, so 

1254 # https://evil.example\@arxiv.org/ reaches evil.example. 

1255 authority = urlparse("//" + urlparse(url).netloc.split("\\", 1)[0]) 

1256 # Without any userinfo: https://arxiv.org@evil.example/ reaches 

1257 # evil.example, and https://user:token@example.com/ would put 

1258 # the token in the label. 

1259 domain = authority.netloc.rpartition("@")[2] 

1260 # Remove www. prefix if present 

1261 if domain.startswith("www."): 

1262 domain = domain[4:] 

1263 # Keep known domains as-is 

1264 known_domains = { 

1265 "arxiv.org": "arxiv.org", 

1266 "github.com": "github.com", 

1267 "reddit.com": "reddit.com", 

1268 "youtube.com": "youtube.com", 

1269 "pypi.org": "pypi.org", 

1270 "milvus.io": "milvus.io", 

1271 "medium.com": "medium.com", 

1272 } 

1273 

1274 # The host itself or a subdomain of it. A substring test on the 

1275 # netloc also labelled arxiv.org.evil.example as arxiv.org. The 

1276 # root dot of a fully qualified name (arxiv.org.) is dropped, as 

1277 # utilities/arxiv.py does, since that host is still arxiv.org. 

1278 # A port that is not a number hides the rest of the authority 

1279 # from `hostname` (arxiv.org:443.evil.example), and requests 

1280 # refuses it, so it earns no known label. 

1281 try: 

1282 _ = authority.port 

1283 host = (authority.hostname or "").rstrip(".") 

1284 except ValueError: 

1285 host = "" 

1286 for known, display in known_domains.items(): 

1287 if host == known or host.endswith("." + known): 

1288 return display 

1289 

1290 # Preserve valid ports on other domains, as the existing 

1291 # citation-label contract requires. For an invalid authority, 

1292 # leave out a port that the check above refused (arxiv.org:99999); 

1293 # a colon followed by more labels (arxiv.org:443.evil.example) 

1294 # is still part of the untrusted name. 

1295 name, _, port = domain.rpartition(":") 

1296 if not host and name and "." not in port and "]" not in port: 

1297 domain = name 

1298 parts = domain.split(".") 

1299 if len(parts) >= 2: 

1300 return ".".join(parts[-2:]) 

1301 return domain 

1302 except (ValueError, AttributeError): 

1303 return "source" 

1304 

1305 

1306# Whether a ``.qmd`` runs code is decided by more than one reader, and they 

1307# disagree with each other. From quarto-cli and knitr: 

1308# 

1309# languagesWithClasses (src/core/pandoc/pandoc-partition.ts), which picks 

1310# the engine, run over the WHOLE file with the /gm flags: 

1311# /^[\t >]*```+\s*\{([a-zA-Z][a-zA-Z0-9_.]*)([^}]*)?\}\s*$/gm 

1312# breakQuartoMd (src/core/lib/break-quarto-md.ts): 

1313# ^\s*(```+)\s*\{([=A-Za-z][=A-Za-z0-9._]*)( *[ ,].*)?\}\s*$ 

1314# quartoMdToJupyter (src/core/jupyter/jupyter.ts), the Jupyter engine: 

1315# "^(\\s*)(```+)\\s*\\{" + kernelspec.language.toLowerCase() + 

1316# "( *[ ,].*)?\\}\\s*$" 

1317# knitr's md chunk.begin (R/pattern.R): 

1318# '^[\t >]*```+\\s*\\{([a-zA-Z0-9_]+( *[ ,].*)?)\\}\\s*$' 

1319# 

1320# They differ in fence tracking (none for languagesWithClasses and knitr, 

1321# none that shields a cell for quartoMdToJupyter), in the prefix they allow 

1322# (``>`` for blockquotes), in line splitting (/m treats \r, U+2028 and U+2029 

1323# as line ends) and in the meaning of ``\s``. Modelling any one of them left 

1324# a cell another one runs. So nothing is modelled: every one of them needs a 

1325# run of three or more backticks, optional whitespace, ``{`` and a language 

1326# character, and that shape is broken wherever it occurs. 

1327# 

1328# The whitespace class is the union of Python's ``\s`` (which adds 

1329# \x1c-\x1f and \x85), JavaScript's (which adds U+FEFF) and U+180E (a space 

1330# before Unicode 6.3). It includes every line break, so a header split 

1331# across lines is caught too. A language character is ``=`` or anything 

1332# Python's Unicode ``\w`` matches, a superset of the ASCII classes above and 

1333# of a locale-dependent ``[a-zA-Z]``. ``{.class}`` and ``{#id}`` (Pandoc 

1334# attributes, which no reader runs) are left alone. 

1335_ANY_SPACE = r"[\s᠎]" 

1336_QUARTO_CELL_HEADER = re.compile( 

1337 rf"^(?P<indent>(?:{_ANY_SPACE}|>)*)(?P<fence>`{{3,}}){_ANY_SPACE}*" 

1338 r"\{(?P<lang>[=\w][=\w.]*)" 

1339 rf"(?P<rest>[^=\w.}}\n\r][^}}\n\r]*)?\}}{_ANY_SPACE}*$" 

1340) 

1341# The backstop: an opening brace of the same shape anywhere in the text. The 

1342# lookbehind starts a match only at the first backtick of a run, which keeps 

1343# the scan linear on a long run of backticks. 

1344_QUARTO_CELL_BRACE = re.compile( 

1345 rf"(?<!`)(?P<head>`{{3,}}{_ANY_SPACE}*)\{{(?=[=\w])" 

1346) 

1347# Quarto's own line splitting (``lines()`` in src/core/lib/text.ts). 

1348_QUARTO_LINE_BREAK = re.compile(r"(\r\n?|\n)") 

1349 

1350 

1351def _defuse_quarto_cells(body: str) -> str: 

1352 """Turn executable Quarto cells in report prose into display fences. 

1353 

1354 Markdown to ``.qmd`` is not format-preserving: a ```` ```{python} ```` 

1355 fence is inert text in a markdown report and an EXECUTABLE cell in a 

1356 Quarto document, which ``quarto render`` — the only reason to ask for this 

1357 format — runs. Report bodies carry web-influenced content, so the fence 

1358 that arrives need not have been written by the user reading it. 

1359 

1360 Dropping the braces keeps the code visible and stops it being a cell. A 

1361 plain ```` ```python ```` fence is display-only already and is untouched. 

1362 

1363 This fails closed. Every line shaped like a cell header is demoted, with 

1364 any indent or ``>`` prefix, wherever it is: inside another fence, in a 

1365 blockquote or in plain prose alike. The demotion removes only the two 

1366 braces and the whitespace around them; the language and any options stay, 

1367 so ```` ```{python, echo=false} ```` becomes ```` ```python, echo=false 

1368 ````. A report that quotes the syntax as an example therefore shows it 

1369 without its braces; that is the accepted cost. Any other occurrence of a 

1370 fence followed by ``{`` and a language character — inside a line of 

1371 prose, split across a line break, or with a ``}`` in its options — loses 

1372 only that opening brace, so no text after it is dropped. Neither step 

1373 adds or removes a backtick or a line break, so no line changes whether 

1374 it opens a Markdown code fence. 

1375 

1376 One regex test per line and one scan of the whole text: linear. 

1377 """ 

1378 parts = _QUARTO_LINE_BREAK.split(body) 

1379 for index in range(0, len(parts), 2): 

1380 cell = _QUARTO_CELL_HEADER.match(parts[index]) 

1381 if cell: 

1382 parts[index] = ( 

1383 f"{cell.group('indent')}{cell.group('fence')}" 

1384 f"{cell.group('lang')}{cell.group('rest') or ''}" 

1385 ) 

1386 return _QUARTO_CELL_BRACE.sub(r"\g<head>", "".join(parts)) 

1387 

1388 

1389# Quarto and Pandoc read more than cells out of the text before and while 

1390# rendering it. Two features are reachable from a report body: 

1391# 

1392# * Shortcodes. ``{{< env NAME >}}`` or ``{{< meta key >}}`` prints the 

1393# renderer's environment or metadata into the output, and 

1394# ``{{< include path >}}`` pulls in a file, cells included, before the 

1395# engine is chosen. 

1396# * YAML metadata blocks. src/core/yaml.ts collects every block in the file 

1397# with /gm, after removing HTML comments (/<!--[\W\w]*?-->/gm) and fenced 

1398# code, and merges them into the render metadata (engine, filters, 

1399# pdf-engine-opt, ...): 

1400# /(^)(---[ \t]*[\r\n]+(?![ \t]*[\r\n]+)[\W\w]*?[\r\n]+(?:---|\.\.\.)) 

1401# ([ \t]*)$/gm 

1402# Pandoc's own yaml_metadata_block opens at a ``---`` line in any block 

1403# context: after a ``>`` or list prefix, and inside a grid-table cell 

1404# (``| --- |``), where the dashes do not end the line. Quarto removes the 

1405# front matter before Pandoc reads the file and passes the metadata with 

1406# ``--metadata-file`` (src/command/render/pandoc.ts in v1.10.18: the 

1407# ``partitionYamlFrontMatter`` call at line 1146 and the push at 1351), so 

1408# the reader format below switches that extension off and Pandoc reads no 

1409# metadata block from the body at all. 

1410# 

1411# Shortcodes are the hard part, because Quarto decodes text in many places 

1412# before it looks for them (quarto-cli v1.10.18): Pandoc entities and 

1413# backslash escapes in attribute values, a callout title or a caption 

1414# attribute read again as a whole document (``string_to_quarto_ast_*``), 

1415# ``qmd``/``qmd-base64`` attributes on a Span or Div, raw blocks in the 

1416# ``pandoc-native``, ``pandoc-json`` or ``pandoc-reader-<any reader>`` 

1417# formats, ``\QuartoMarkdownBase64{}`` in raw LaTeX, and raw HTML whose 

1418# attributes and tables Pandoc's HTML reader parses. Breaking each 

1419# spelling in turn has failed repeatedly, so the exported file switches 

1420# those readers off instead. Its front matter sets 

1421# ``from: _QUARTO_READER_FORMAT``: Pandoc's markdown without raw HTML, raw 

1422# TeX, raw attributes, fenced or native divs and spans, attribute blocks 

1423# on headers, links, images, code and fences, YAML metadata blocks or a 

1424# ``%`` title block. 

1425# Quarto passes ``from`` to its reader (``user-defined-from`` in 

1426# src/command/render/pandoc.ts, used by readqmd.lua for the document and 

1427# for every string read again through ``string_to_quarto_ast_*``), so the 

1428# body yields no attribute, no raw block or inline, no Div or Span and no 

1429# metadata, and every decoder above is unreachable. The front matter itself 

1430# is not read with that format: Quarto passes it to Pandoc with 

1431# ``--metadata-file`` (pandoc.ts:1351), and Pandoc parses a metadata file 

1432# with all of its default markdown extensions whenever the reader is not 

1433# literally ``markdown`` or ``commonmark`` (``readerOptsMeta`` in Pandoc's 

1434# App.hs). Quarto's reader is a Lua custom reader 

1435# (filters/qmd-reader.lua), so raw HTML, a raw ``{=pandoc-native}`` inline 

1436# or a ``[...]{qmd-base64=...}`` span in the title would reach the 

1437# decoders above. ``_quarto_title_markdown`` therefore escapes the title 

1438# so that Pandoc reads it as plain text, whatever the extensions. 

1439# The same reader format applies to the markup Quarto itself 

1440# appends to the body before Pandoc reads it (pandoc.ts:1275-1283): the 

1441# ``:::{#quarto-meta-markdown .hidden}`` envelope of ``[...]{.hidden ...}`` 

1442# spans in a website, blog or book project (website.ts:328-330, 

1443# website-meta.ts:588, markdown-pipeline.ts:99-121), and the same kind of 

1444# envelope for revealjs (format-reveal.ts:304) or a document that sets 

1445# ``math:`` (pandoc.ts:627-659), as well as the ``keep-source`` block 

1446# (codetools.ts:72-89). The exported file sets none of these and is meant 

1447# to be rendered on its own. Rendered inside such a project or with those 

1448# options, that markup shows as text at the end of the page and what it 

1449# feeds (the rendered page and site titles and descriptions) is lost. No 

1450# reader format can keep Quarto's markup and drop the report's: both are 

1451# read in one pass, and the Divs and Spans that markup needs are what the 

1452# report would use to reach the decoders above. 

1453# What still decodes body text is one fixed chain: 

1454# 

1455# 1. the raw file. lpegshortcode.lua opens a shortcode at a literal 

1456# ``{{<`` (escaped ``{{{<`` and ``{{</*``), and parse-shortcode.ts, 

1457# which expands includes, at a literal ``{{< ``. ``wrap_lpeg_match`` 

1458# then restores ``cf5733e5-0370-4aae-8689-61bad1dd9ec0&#x<hex>;`` to 

1459# the character it encodes, after matching but before Pandoc reads 

1460# the text. 

1461# 2. Pandoc, once: an entity (``&`` and non-space characters up to the 

1462# first ``;``, looked up as ``&name;``, ``&#123;`` or ``&#x7b;``) or a 

1463# backslash escape becomes its character, in text, link targets and 

1464# image sources. Code, code blocks and math are not decoded. 

1465# 3. readqmd.lua hex-decodes 

1466# ``b58fc729-690b-4000-b19f-365a4093b2ff;<hex>;`` in text, code, math, 

1467# code blocks, link targets and image sources and reads the result as 

1468# a shortcode, and customnodes/shortcodes.lua runs the ``{{<`` parser 

1469# again on code, code blocks, math, link targets and image sources. 

1470# 

1471# Those two UUIDs are the only shortcode encodings in quarto-cli's filters 

1472# (a sweep of every UUID in src/resources finds the others naming ids, 

1473# data URIs, LaTeX placeholders, the AST vault or a Confluence macro). 

1474# A few Quarto filters still read text with Pandoc's own reader, every 

1475# extension on: a ``{...}`` ending a table caption (common/tables.lua), 

1476# ``{#eq-...}`` after display math (crossref/equations.lua) and a 

1477# ```` ```quarto-title-block ```` fence (quarto-post/book.lua, after 

1478# shortcodes have run). In v1.10.18 none of them reaches a shortcode 

1479# parser or the ``.qmd`` reader. 

1480# Each step is broken by 

1481# inserting a zero-width space (U+200B), which no reader treats as 

1482# whitespace and no decoding removes: 

1483# 

1484# A. ``b58fc729...`` and ``cf5733e5...`` are broken after their first 

1485# letter, and so is ``quarto-shortcode__``, the class ast/parse.lua 

1486# turns into a shortcode (no Span can carry it any more; this is kept 

1487# as a second line). A letter can be spelled only as itself or as an 

1488# entity (rule B); ``-`` and ``_`` also with backslashes before them. 

1489# B. An ``&`` gets the space after it if Pandoc would decode it (step 2) 

1490# to ``{``, ``-``, ``_`` or an ASCII letter or digit (the characters 

1491# of an opener or a sentinel), or if it starts ``&#`` and anything but 

1492# a plain decimal or hex reference of at most 8 digits up to the 

1493# ``;``. Pandoc decodes only once, so ``&amp;``, ``&lt;`` or ``&nbsp;`` 

1494# stay as they are, and so does every ``&`` that starts no entity: one 

1495# that no ``;`` closes before a space, as in a URL query 

1496# (``?q=a&hl=en``) or C code (``&x``), or one whose name is not an 

1497# HTML5 entity. 

1498# C. Two ``{``, with any backslashes before the second, followed after any 

1499# whitespace or backslashes by ``<``, ``%`` or ``&`` (which may decode 

1500# to either or to whitespace) get the space after the first. No step 

1501# can then put ``{{`` before a ``<`` or ``%``. ``{{\partial`` in math 

1502# is left alone. 

1503# HTML comment openers ``<!--`` get the zero-width space between the 

1504# dashes, so yaml.ts cannot remove a comment to join text into a new line 

1505# start. Three dashes ending a line become four when everything before 

1506# them on the line is whitespace and block-prefix characters (``>``, 

1507# ``:``, ``~``, ``|``) or footnote labels (``[^1]:``): ``----`` is the 

1508# same thematic break or setext underline to Markdown but opens no YAML 

1509# block for Quarto's yaml.ts, which needs the dashes at the very start of 

1510# a line. Any other text before them on the line leaves them alone, so 

1511# prose ending in `` ---`` keeps its em dash and a URL ending in ``---`` 

1512# keeps its dashes. Pandoc reads no body metadata block with the reader 

1513# format below, wherever the dashes are; the block prefixes are a second 

1514# line against that. Nor does it read a ``%`` title block: Quarto starts the 

1515# body it hands to Pandoc with a line break (``partitionYamlFrontMatter`` 

1516# in src/core/yaml.ts), which already keeps one from opening, and the 

1517# reader format switches the extension off as well. 

1518_QUARTO_READER_FORMAT = "markdown" + "".join( 

1519 f"-{extension}" 

1520 for extension in ( 

1521 "raw_html", 

1522 "raw_tex", 

1523 "raw_attribute", 

1524 "native_divs", 

1525 "native_spans", 

1526 "fenced_divs", 

1527 "bracketed_spans", 

1528 "header_attributes", 

1529 "link_attributes", 

1530 "inline_code_attributes", 

1531 "fenced_code_attributes", 

1532 "yaml_metadata_block", 

1533 "pandoc_title_block", 

1534 ) 

1535) 

1536_ZERO_WIDTH_SPACE = "\u200b" 

1537_HTML_COMMENT_OPEN = re.compile(r"<!-(?=-)") 

1538# Matched against one line at a time; group 1 is the whole prefix. Linear: 

1539# anchored, and each prefix unit starts with a character no other unit 

1540# starts with. 

1541_YAML_BLOCK_DELIMITER = re.compile( 

1542 rf"\A((?:[\s\ufeff\u180e>:~|]|\[\^[^\]]+\]:)*)---(?={_ANY_SPACE}*\Z)" 

1543) 

1544# Every line break any reader uses: Python's ``str.splitlines`` set, which 

1545# includes JavaScript's /m line terminators (\n, \r, U+2028, U+2029). 

1546_ANY_LINE_BREAK = re.compile(r"(\r\n|[\n\r\v\f\x1c-\x1e\x85\u2028\u2029])") 

1547 

1548# Pandoc's ``characterReference``: ``&``, then ``many1Till nonspaceChar 

1549# (char ';')`` where a space is one of these four characters. 

1550_PANDOC_SPACE = re.compile(r"[ \t\n\r]") 

1551_SHORTCODE_CHARACTERS = frozenset( 

1552 "{-_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 

1553) 

1554_NUMERIC_REFERENCE = re.compile(r"#(?:([0-9]{1,8})|[xX]([0-9a-fA-F]{1,8}))") 

1555# Longer than any HTML5 entity name (31 characters) or plain numeric 

1556# reference (``#x`` and 8 digits), so never sliced out of the text. 

1557_LONGEST_REFERENCE = 40 

1558_QUARTO_SHORTCODE_BRACES = re.compile( 

1559 rf"\{{(?=\\*\{{(?:{_ANY_SPACE}|\\)*[<%&])" 

1560) 

1561_QUARTO_SHORTCODE_TOKENS = re.compile( 

1562 r"(?<=b)(?=58fc729\\*-690b\\*-4000\\*-b19f\\*-365a4093b2ff)" 

1563 r"|(?<=c)(?=f5733e5\\*-0370\\*-4aae\\*-8689\\*-61bad1dd9ec0)" 

1564 r"|(?<=q)(?=uarto\\*-shortcode\\*_\\*_)", 

1565 re.IGNORECASE, 

1566) 

1567 

1568 

1569def _reference_is_hazardous(reference: str) -> bool: 

1570 """True if Pandoc decodes ``&<reference>;`` to a character rule B breaks. 

1571 

1572 *reference* is what Pandoc reads between the ``&`` and the ``;``. A 

1573 numeric one is hazardous unless it is a plain decimal or hex number of 

1574 a harmless character; a name is hazardous if it is an HTML5 entity for 

1575 a character that can spell a shortcode or a sentinel. Any other name 

1576 is no entity, so Pandoc keeps it as text. 

1577 """ 

1578 if reference.startswith("#"): 

1579 number = _NUMERIC_REFERENCE.fullmatch(reference) 

1580 if number is None: 

1581 return True 

1582 decimal, hexadecimal = number.groups() 

1583 code = int(decimal, 10) if decimal else int(hexadecimal, 16) 

1584 if not 0 < code <= 0x10FFFF: 1584 ↛ 1585line 1584 didn't jump to line 1585 because the condition on line 1584 was never true

1585 return True 

1586 return chr(code) in _SHORTCODE_CHARACTERS 

1587 value = html5.get(reference + ";") 

1588 return value is not None and bool(_SHORTCODE_CHARACTERS.intersection(value)) 

1589 

1590 

1591def _break_entities(text: str) -> str: 

1592 """Rule B: no entity Pandoc decodes can spell a shortcode or sentinel. 

1593 

1594 The ``;`` and the space after each ``&`` are found by searches that 

1595 only move forward, so the scan is linear. 

1596 """ 

1597 pieces = [] 

1598 position = 0 

1599 semicolon = space = -1 

1600 start = text.find("&") 

1601 while start != -1: 

1602 if semicolon < start + 2: 

1603 semicolon = text.find(";", start + 2) 

1604 if semicolon == -1: 

1605 break 

1606 if space <= start: 

1607 found = _PANDOC_SPACE.search(text, start + 1) 

1608 space = found.start() if found else len(text) 

1609 if space > semicolon and ( 

1610 text[start + 1] == "#" 

1611 if semicolon - start > _LONGEST_REFERENCE 

1612 else _reference_is_hazardous(text[start + 1 : semicolon]) 

1613 ): 

1614 pieces.append(text[position : start + 1]) 

1615 pieces.append(_ZERO_WIDTH_SPACE) 

1616 position = start + 1 

1617 start = text.find("&", start + 1) 

1618 pieces.append(text[position:]) 

1619 return "".join(pieces) 

1620 

1621 

1622def _defuse_quarto_text(text: str) -> str: 

1623 """Break every shortcode and HTML-comment opener in *text*. 

1624 

1625 Rules A-C above, for body text read with ``_QUARTO_READER_FORMAT``. 

1626 Used on the report title too, before ``_quarto_title_markdown`` 

1627 escapes it (the title is not read with that format): Quarto expands 

1628 shortcodes in metadata, and a comment opened in the title could be 

1629 closed in the body. Only zero-width spaces are inserted; removing them 

1630 gives back *text*. Linear: a constant number of scans. 

1631 """ 

1632 text = _break_entities(text) 

1633 text = _HTML_COMMENT_OPEN.sub("<!-" + _ZERO_WIDTH_SPACE, text) 

1634 text = _QUARTO_SHORTCODE_BRACES.sub("{" + _ZERO_WIDTH_SPACE, text) 

1635 return _QUARTO_SHORTCODE_TOKENS.sub(_ZERO_WIDTH_SPACE, text) 

1636 

1637 

1638def _defuse_quarto_body(body: str) -> str: 

1639 """Neutralise the cells, shortcodes and metadata blocks in a report body. 

1640 

1641 Cells are demoted (``_defuse_quarto_cells``), shortcodes and HTML 

1642 comments are broken (``_defuse_quarto_text``) and no line can open a 

1643 YAML metadata block for Quarto (three dashes ending a line after 

1644 nothing but whitespace, ``>``, ``:``, ``~``, ``|`` and footnote labels 

1645 gain a fourth; prose ending in `` ---`` is left alone; Pandoc's own 

1646 metadata blocks, which also open inside a grid-table cell, are 

1647 switched off by ``_QUARTO_READER_FORMAT``). The exported file's 

1648 metadata is then only the exporter's own front matter, which names no 

1649 engine; no cell header is left and no file can be included, so 

1650 Quarto's engine detection finds no language and picks its markdown 

1651 engine, which runs no code, inline `` `r ...` `` and 

1652 `` `{python} ...` `` expressions included. That holds for the file 

1653 rendered on its own: an engine forced from outside it (a project 

1654 ``_quarto.yml``, ``quarto render -M engine:knitr`` or ``knitr::knit``) 

1655 would still evaluate those inline expressions, and a reader format 

1656 that overrides the file's ``from`` from outside would turn the readers 

1657 listed above back on. 

1658 

1659 The steps only insert characters, or remove a cell's opening brace or a 

1660 cell header's braces and the whitespace around them. None of that can 

1661 create an opener another step breaks, so the result is a fixed point. 

1662 Linear: a constant number of scans. 

1663 """ 

1664 body = _defuse_quarto_cells(_defuse_quarto_text(body)) 

1665 parts = _ANY_LINE_BREAK.split(body) 

1666 for index in range(0, len(parts), 2): 

1667 parts[index] = _YAML_BLOCK_DELIMITER.sub(r"\1----", parts[index]) 

1668 return "".join(parts) 

1669 

1670 

1671# Every ASCII punctuation character. Pandoc's markdown reads a backslash 

1672# before any of them as that character (``all_symbols_escapable``). 

1673_ASCII_PUNCTUATION = re.compile(r"([!-/:-@\[-`{-~])") 

1674# Runs of what Pandoc's markdown reader treats as spaces and line endings. 

1675_PANDOC_SPACE_RUN = re.compile(r"[ \t\n\r]+") 

1676 

1677 

1678def _quarto_title_markdown(title: str) -> str: 

1679 """The Markdown source for *title* in the exported front matter. 

1680 

1681 Pandoc reads the front matter with all of its default markdown 

1682 extensions, not with ``_QUARTO_READER_FORMAT`` (see the comment above 

1683 it), so the title gets its own treatment. Runs of spaces, tabs and 

1684 line breaks become one space and the ends are trimmed, so no line can 

1685 start a block such as an indented code block; shortcodes and comment 

1686 openers are broken (``_defuse_quarto_text``); and a backslash goes 

1687 before every ASCII punctuation character. Pandoc then reads the title 

1688 as ``Str`` and ``Space`` nodes only: no raw HTML or raw ``{=format}`` 

1689 inline, no span or attribute, no markup, entity, math or citation. 

1690 The title renders as the same characters, with its whitespace 

1691 collapsed; Markdown formatting, entities and typographic quotes or 

1692 dashes in it are shown as written. Linear. 

1693 """ 

1694 title = _PANDOC_SPACE_RUN.sub(" ", title).strip(" ") 

1695 return _ASCII_PUNCTUATION.sub(r"\\\1", _defuse_quarto_text(title)) 

1696 

1697 

1698# Characters that must be escaped inside a YAML double-quoted scalar: the 

1699# quote and backslash the double-quoted form itself requires, plus the 

1700# control/line-break range ``is_line_breaking_char()`` above defines once 

1701# for every producer in this module (C0, DEL, C1, and the Unicode line 

1702# separators U+2028/U+2029). 

1703_YAML_DQ_SPECIALS_RE = re.compile(r'["\\\x00-\x1f\x7f-\x9f\u2028\u2029]') 

1704 

1705 

1706def _yaml_double_quote(value: str) -> str: 

1707 r"""Render *value* as a YAML double-quoted scalar. 

1708 

1709 Report titles derive from web-influenced content, so a bare 

1710 interpolation into ``title: "..."`` lets a quote end the scalar 

1711 early (front matter stops parsing) or a newline inject further 

1712 front-matter keys. Escaping to YAML double-quoted rules keeps the 

1713 value a single scalar that parses back exactly. 

1714 """ 

1715 

1716 def _escape(match: re.Match) -> str: 

1717 ch = match.group() 

1718 if ch == '"': 

1719 return '\\"' 

1720 if ch == "\\": 

1721 return "\\\\" 

1722 if ch == "\n": 1722 ↛ 1723line 1722 didn't jump to line 1723 because the condition on line 1722 was never true

1723 return "\\n" 

1724 if ch == "\r": 1724 ↛ 1725line 1724 didn't jump to line 1725 because the condition on line 1724 was never true

1725 return "\\r" 

1726 if ch == "\t": 1726 ↛ 1727line 1726 didn't jump to line 1727 because the condition on line 1726 was never true

1727 return "\\t" 

1728 code = ord(ch) 

1729 return f"\\x{code:02x}" if code <= 0xFF else f"\\u{code:04x}" 

1730 

1731 return '"' + _YAML_DQ_SPECIALS_RE.sub(_escape, value) + '"' 

1732 

1733 

1734class QuartoExporter: 

1735 """Export markdown documents to Quarto (.qmd) format.""" 

1736 

1737 def __init__(self): 

1738 # Also match Unicode lenticular brackets 【】 (U+3010 and U+3011) that LLMs sometimes generate 

1739 self.citation_pattern = re.compile( 

1740 r"(?<![\[【])[\[【](\d+)[\]】](?![\]】])" 

1741 ) 

1742 self.comma_citation_pattern = re.compile( 

1743 r"[\[【](\d+(?:\s*,\s*\d+)+)[\]】]" # (#6944) space allowed BEFORE the comma too — a hand-edited `[[2 ,3]]` must stay a group 

1744 ) 

1745 

1746 def export_to_quarto(self, content: str, title: str | None = None) -> str: 

1747 """ 

1748 Convert markdown document to Quarto format. 

1749 

1750 Args: 

1751 content: Markdown content 

1752 title: Document title (if None, will extract from content) 

1753 

1754 Returns: 

1755 Quarto formatted content 

1756 """ 

1757 # Extract title from markdown if not provided 

1758 if not title: 

1759 title_match = re.search(r"^#\s+(.+)$", content, re.MULTILINE) 

1760 title = title_match.group(1) if title_match else "Research Report" 

1761 

1762 # Create Quarto YAML header 

1763 from datetime import UTC, datetime 

1764 

1765 current_date = datetime.now(UTC).strftime("%Y-%m-%d") 

1766 yaml_header = f"""--- 

1767title: {_yaml_double_quote(_quarto_title_markdown(title))} 

1768author: "Local Deep Research" 

1769date: "{current_date}" 

1770from: {_QUARTO_READER_FORMAT} 

1771format: 

1772 html: 

1773 toc: true 

1774 toc-depth: 3 

1775 number-sections: true 

1776 pdf: 

1777 toc: true 

1778 number-sections: true 

1779 colorlinks: true 

1780bibliography: references.bib 

1781csl: apa.csl 

1782--- 

1783 

1784""" 

1785 

1786 # Process content 

1787 processed_content = content 

1788 

1789 # Report math reaches a TeX engine when ``quarto render`` builds the 

1790 # declared pdf format, so spans that use commands outside the math 

1791 # allowlist are escaped to literal text here, exactly as the LaTeX 

1792 # export does. Run before the citation and cell passes so the escaped 

1793 # brackets of a rejected span are never mistaken for a citation. 

1794 processed_content = _defuse_report_math_for_quarto(processed_content) 

1795 

1796 # First handle comma-separated citations like [1, 2, 3] 

1797 def replace_comma_citations(match): 

1798 citation_nums = match.group(1) 

1799 # Split by comma and strip whitespace 

1800 nums = [num.strip() for num in citation_nums.split(",")] 

1801 refs = [f"@ref{num}" for num in nums] 

1802 return f"[{', '.join(refs)}]" 

1803 

1804 processed_content = self.comma_citation_pattern.sub( 

1805 replace_comma_citations, processed_content 

1806 ) 

1807 

1808 # Then convert individual citations to Quarto format [@citation] 

1809 def replace_citation(match): 

1810 citation_num = match.group(1) 

1811 return f"[@ref{citation_num}]" 

1812 

1813 processed_content = self.citation_pattern.sub( 

1814 replace_citation, processed_content 

1815 ) 

1816 

1817 # The report generator's sources sentinel is an HTML comment, which 

1818 # the reader format would show as text (raw HTML is off) and 

1819 # _defuse_quarto_body would break. Drop the one the bibliography is 

1820 # scoped by (the last one on its own line); a quoted one elsewhere 

1821 # is report text and is defused like any other comment. 

1822 sentinel_start, on_sentinel = find_sources_section(processed_content) 

1823 if on_sentinel: 

1824 processed_content = ( 

1825 processed_content[:sentinel_start] 

1826 + processed_content[ 

1827 sentinel_start + len(LDR_APPENDED_SOURCES_SENTINEL) : 

1828 ] 

1829 ) 

1830 

1831 # Generate bibliography file content. It is shown in a code block, 

1832 # which Pandoc does not decode, but readqmd.lua hex-decodes the 

1833 # internal shortcode encodings in CodeBlock text too and 

1834 # shortcodes.lua then expands the result, so a source title or URL 

1835 # carrying one is broken as in the body. Its braces are already 

1836 # escaped (``_escape_bibtex``) or rejected (``_safe_bibtex_url``). 

1837 bib_content = _QUARTO_SHORTCODE_TOKENS.sub( 

1838 _ZERO_WIDTH_SPACE, self._generate_bibliography(content) 

1839 ) 

1840 

1841 # Add note about bibliography file. It is a plain paragraph: the 

1842 # reader format turns fenced divs off, so a callout would show as 

1843 # text. 

1844 bibliography_note = ( 

1845 "\n\n**Bibliography File Required**\n\nThis document requires a `references.bib` file in the same directory with the following content:\n\n```bibtex\n" 

1846 + bib_content 

1847 + "\n```\n" 

1848 ) 

1849 

1850 # The note and its bibtex fence are the exporter's own markup, so 

1851 # of the note only the bibliography entries are defused (above). 

1852 return ( 

1853 yaml_header 

1854 + _defuse_quarto_body(processed_content) 

1855 + bibliography_note 

1856 ) 

1857 

1858 def _generate_bibliography(self, content: str) -> str: 

1859 """Generate BibTeX bibliography from sources.""" 

1860 sources_start, _on_sentinel = find_sources_section(content) 

1861 # Keep accepting bare source lists for programmatic callers. A full 

1862 # report's explicit Sources boundary takes precedence over its body. 

1863 sources_content = ( 

1864 content[sources_start:] if sources_start != -1 else content 

1865 ) 

1866 entries = _collect_bibliography_entries(sources_content) 

1867 

1868 bibliography = "" 

1869 for citation_num in sorted(entries, key=_sort_key): 

1870 title, url = entries[citation_num] 

1871 safe_title = _escape_bibtex(title) 

1872 safe_url = _safe_bibtex_url(url) 

1873 bib_entry = f"@misc{{ref{citation_num},\n" 

1874 bib_entry += f' title = "{{{safe_title}}}",\n' 

1875 if safe_url: 

1876 bib_entry += f" url = {{{safe_url}}},\n" 

1877 bib_entry += f' howpublished = "\\url{{{safe_url}}}",\n' 

1878 bib_entry += f" year = {{{2024}}},\n" 

1879 bib_entry += ' note = "Accessed: \\today"\n' 

1880 bib_entry += "}\n" 

1881 

1882 bibliography += bib_entry + "\n" 

1883 

1884 return bibliography.strip() 

1885 

1886 

1887# One credential check, ``_ris_spells_userinfo``, decides for every RIS 

1888# field built from source text: UR, TI, AU and DO are never written with a 

1889# value it flags. It also reads each value as serialization writes it 

1890# (``_ris_flatten``), so no field is written in a form it flags either. A 

1891# source URL goes through ``_safe_ris_url``, which runs the check last; a 

1892# title, author or DOI the check flags is replaced by ``_safe_ris_url``'s 

1893# userinfo-free URL for it, or dropped (a title, by a placeholder). A 

1894# password can hold a name, a year or a DOI, so no author, no year (PY) 

1895# and no DOI matched in other text is cut out of a line that carries a 

1896# credential (``_ris_line_carries_userinfo``): a line the check flags, or 

1897# one in which a value read from it holds a credential when read as a URL 

1898# as a whole from where the value starts (``_ris_value_starts_userinfo``). 

1899# Nor is a year or DOI taken from a title that its cleaning joins into a 

1900# credential URL. PB is derived from the host of the sanitized UR. TY, Y1, 

1901# DA, LA and ER are fixed or dated, and ID holds the citation number. 

1902# ``_safe_ris_url`` also omits URLs it cannot parse unambiguously; a title, 

1903# author or DOI need not be a URL, so such text stays in those fields 

1904# unless the check flags it. 

1905# 

1906# Userinfo is what precedes the last @ of a URL's authority, as the WHATWG 

1907# URL Standard parses it. The authority ends at the first ``/``, ``?`` or 

1908# ``#``, so a password that holds one of them unencoded is not userinfo, 

1909# and the check does not flag it: ``https://user:2024/Secret@host/x`` has 

1910# the host ``user``, the port 2024 and no credential. Under any scheme, 

1911# and under none (``//host``), an authority follows ``//``. Under 

1912# the schemes the WHATWG URL Standard calls special, it also follows the 

1913# colon and any run of ``/`` or ``\`` (none included): a browser reads 

1914# ``https:user:pass@host`` and ``https:\\user:pass@host`` as credential 

1915# URLs. ``file`` is special too, but a file URL carries no userinfo. A 

1916# value with no scheme is resolved against a base URL, and against a 

1917# special one, such as an ``https`` page, any run of two or more ``/`` or 

1918# ``\`` opens an authority as ``//`` does: a browser reads 

1919# ``\\user:pass@host``, ``/\user:pass@host`` and ``///user:pass@host`` 

1920# there as ``https://user:pass@host``. 

1921_RIS_SPECIAL_SCHEME = r"(?:https?|ftp|wss?)" 

1922 

1923# An authority opened anywhere in a value, so a credential URL wrapped in 

1924# ``<...>``, a markdown link, quotes, parentheses, backticks or emphasis, or 

1925# written inside prose, is still seen; a run of two or more ``/`` or ``\`` 

1926# opens one wherever it appears, as ``//`` does. In each reading of a 

1927# value (``_ris_readings``), a URL inside text ends at whitespace, as a 

1928# linkifier ends it, so an e-mail address later in the text is not read 

1929# as its userinfo. Neither is what follows whitespace inside a wrapped 

1930# URL's authority, as in ``<https://user :pass@host>``; only a value that 

1931# is a URL as a whole is read past it. The lookbehind keeps the ``ws:`` of 

1932# ``news:`` and the ``ftp:`` of ``sftp:`` from opening an authority. 

1933_RIS_EMBEDDED_AUTHORITY_RE = re.compile( 

1934 rf"(?:(?<![a-z0-9+.\-]){_RIS_SPECIAL_SCHEME}:[/\\]*|[/\\]{{2,}})" 

1935 r"([^\s/?#]*)", 

1936 re.IGNORECASE, 

1937) 

1938 

1939# What a value that is a URL as a whole may begin and end with: whitespace, 

1940# controls and U+FEFF. RIS serialization turns controls into spaces, and a 

1941# reader may trim a field: JavaScript's ``trim()`` removes U+FEFF as well, 

1942# which Python's ``strip()`` keeps. Inside a value, U+FEFF is not 

1943# whitespace. 

1944_RIS_TRIMMED = r"[\s\x00-\x20\x7f-\x9f\ufeff]" 

1945 

1946# The authority of a value that is a URL as a whole, past any leading 

1947# ``_RIS_TRIMMED`` characters. It runs up to the first ``/``, ``?`` or 

1948# ``#``, whitespace included, as ``urlsplit`` delimits a netloc after 

1949# ``//``. So a title spelling a URL that ``_safe_ris_url`` would strip a 

1950# credential from is flagged too, as is ``\\ user:pass@host``, which a 

1951# browser reads with the space encoded. 

1952# 

1953# Unless a base URL of the same scheme makes the value relative, a browser 

1954# also reads whitespace after a special scheme's colon, or after the colon 

1955# and one ``/`` or ``\``, as part of the authority: to it, 

1956# ``https:/ user:pass@host`` and ``https: user:pass@host`` carry 

1957# credentials, and so does the title ``HTTP: email me at bob@example.org``, 

1958# whose userinfo it reads as `` email me at bob``. After the colon and one 

1959# ``/`` or ``\``, the authority runs past whitespace as above; no title 

1960# begins that way. So that prose stays a title, an authority opened by the 

1961# colon alone, with no ``/`` or ``\`` after it, may begin with whitespace 

1962# only in the shape ``_RIS_SPACED_AUTHORITY`` matches. Past that 

1963# whitespace must come a token of one or more characters other than 

1964# whitespace, ``/``, ``\``, ``?`` and ``#``; optional whitespace; an @; 

1965# and a host of characters other than those and @, followed by ``/``, 

1966# ``\``, ``?``, ``#`` or nothing but ``_RIS_TRIMMED`` characters, as the 

1967# value may begin with them. ``https: user:pass@host/x`` and 

1968# ``https: user:pass @host`` are flagged. Three titles are not: 

1969# ``HTTP: email me at bob@example.org``, whose would-be userinfo holds 

1970# whitespace; ``HTTP: bob@example.org explains``, whose would-be host 

1971# does; and ``HTTP: @mention``, which has no token before its @. Every 

1972# reading of a value is matched, so a control where this shape takes 

1973# whitespace is read as the space serialization writes for it 

1974# (``_ris_flatten``). The host is matched possessively: it shares controls 

1975# and U+FEFF with ``_RIS_TRIMMED``, and giving them back could only make 

1976# the end of the value be scanned again, in time quadratic in its length. 

1977_RIS_SPACED_AUTHORITY = "".join( 

1978 ( 

1979 r"\s+[^\s/?#\\]+\s*@[^\s/?#@\\]++(?:[/?#\\]|", 

1980 _RIS_TRIMMED, 

1981 r"*\Z)", 

1982 ) 

1983) 

1984# What opens the authority of a URL as a whole. 

1985_RIS_WHOLE_URL_START = ( 

1986 rf"(?:(?:{_RIS_SPECIAL_SCHEME}:)?[/\\]{{2,}}|[a-z][a-z0-9+.\-]*://" 

1987 rf"|{_RIS_SPECIAL_SCHEME}:" 

1988 rf"(?:[/\\]|(?!\s)|(?={_RIS_SPACED_AUTHORITY})))" 

1989) 

1990_RIS_WHOLE_URL_AUTHORITY_RE = re.compile( 

1991 rf"{_RIS_TRIMMED}*{_RIS_WHOLE_URL_START}([^/?#]*)", 

1992 re.IGNORECASE, 

1993) 

1994 

1995# Where a URL as a whole can start in a value read from a line 

1996# (``_ris_line_carries_userinfo``): at the value's start, and after each 

1997# comma, ``and`` or ampersand, where the author pattern splits a list into 

1998# authors. As at the start of a value, the URL may follow whitespace, 

1999# controls and U+FEFF, matched possessively, since none of them can open 

2000# an authority. ``_ris_value_starts_userinfo`` finds where each such 

2001# authority ends. 

2002_RIS_VALUE_URL_START_RE = re.compile( 

2003 rf"(?:\A|,|(?<=\s)(?-i:and)(?=\s)|&){_RIS_TRIMMED}*+" 

2004 rf"{_RIS_WHOLE_URL_START}", 

2005 re.IGNORECASE, 

2006) 

2007_RIS_AUTHORITY_END_RE = re.compile(r"[/?#]") 

2008 

2009# Bounds on the readings of one value, which keep the work of checking it 

2010# small; a value past any of them counts as flagged. Each layer of encoding 

2011# a value carries adds a reading, so an ordinary value has a few, and 64 

2012# leaves room for deliberate nesting. A reading can be longer than the 

2013# value, since NFKC turns U+FDFA alone into 18 characters, so every 

2014# reading is measured, not only the value. RFC 9110 recommends supporting 

2015# URIs of 8,000 octets; no title, author, DOI or link needs eight times 

2016# that. The total leaves room for the value plus three more readings of 

2017# that length. 

2018_RIS_MAX_READINGS = 64 

2019_RIS_MAX_DECODED_LENGTH = 1 << 16 

2020_RIS_MAX_DECODED_TOTAL_LENGTH = 4 * _RIS_MAX_DECODED_LENGTH 

2021 

2022# The WHATWG URL parser deletes every tab, LF and CR from its input before 

2023# it parses it: a browser reads ``ht<TAB>tps:/<LF>/user:pass@host`` as 

2024# ``https://user:pass@host``. So the readings of a value include it with 

2025# them deleted, or one that an entity or percent-escape spells would hide 

2026# a credential URL once decoded: ``&Tab;`` splits the scheme of 

2027# ``ht&Tab;tps:user:pass@host``, and ``%0A`` the slashes of 

2028# ``https:/%0A/user:pass@host``. 

2029_RIS_URL_DELETED_CHARACTERS = str.maketrans("", "", "\t\n\r") 

2030 

2031 

2032def _ris_flatten(value: str) -> str: 

2033 """``value`` as RIS serialization writes it: every character 

2034 ``is_line_breaking_char`` matches is turned into a space, so only the 

2035 exporter's own line breaks separate RIS fields.""" 

2036 return "".join( 

2037 " " if is_line_breaking_char(char) else char for char in value 

2038 ) 

2039 

2040 

2041def _ris_readings(value: str) -> set[str] | None: 

2042 """``value`` and every form decoding turns it into; None past a bound. 

2043 

2044 The decodings are HTML character references, NFKC normalization, 

2045 percent-decoding, the deletion of every tab, LF and CR, as the WHATWG 

2046 URL parser deletes them, and ``_ris_flatten``, which turns every 

2047 control character, U+2028 and U+2029 into a space, as serialization 

2048 writes a field. Each is applied to every reading until none yields a 

2049 new one, so they compose in any order and repeat. An ASCII value 

2050 without ``&``, ``%`` or a control character, which none of them can 

2051 change, is its only reading. Any other value is measured, and so is 

2052 each new reading as a decoding yields it: the result is None once one 

2053 of them is longer than ``_RIS_MAX_DECODED_LENGTH``, once they are 

2054 longer together than ``_RIS_MAX_DECODED_TOTAL_LENGTH``, or once there 

2055 would be more than ``_RIS_MAX_READINGS`` of them. 

2056 """ 

2057 if value.isascii() and not any( 

2058 char in "&%" or is_line_breaking_char(char) for char in value 

2059 ): 

2060 return {value} # None of the decodings can change it. 

2061 if len(value) > _RIS_MAX_DECODED_LENGTH: 

2062 return None 

2063 readings = {value} 

2064 total_length = len(value) 

2065 pending = [value] 

2066 while pending: 

2067 form = pending.pop() 

2068 for decoded in ( 

2069 html.unescape(form), 

2070 unicodedata.normalize("NFKC", form), 

2071 urllib.parse.unquote(form), 

2072 form.translate(_RIS_URL_DELETED_CHARACTERS), 

2073 _ris_flatten(form), 

2074 ): 

2075 if decoded in readings: 

2076 continue 

2077 total_length += len(decoded) 

2078 if ( 

2079 len(readings) == _RIS_MAX_READINGS 

2080 or len(decoded) > _RIS_MAX_DECODED_LENGTH 

2081 or total_length > _RIS_MAX_DECODED_TOTAL_LENGTH 

2082 ): 

2083 return None 

2084 readings.add(decoded) 

2085 pending.append(decoded) 

2086 return readings 

2087 

2088 

2089def _ris_spells_userinfo(value: str) -> bool: 

2090 """Whether some reading of ``value`` puts a credential in a URL authority. 

2091 

2092 A credential is userinfo holding anything other than ``@`` signs; an @ 

2093 outside every authority, as in ``medium.com/@author``, is not one. The 

2094 readings are ``value`` and its decoded forms (``_ris_readings``): a 

2095 consumer that renders ``&commat;``, decodes ``%40`` or folds a 

2096 full-width at sign (U+FF20) reads the @ they spell, and a browser 

2097 deletes the tab, LF or CR that ``&Tab;``, ``&#10;`` or ``%0D`` spells, 

2098 as one reading does. Another is the value as serialization writes it 

2099 (``_ris_flatten``), so whatever the check passes is written in a form 

2100 it passes too. The check is lexical, so no parse error can let a value 

2101 through, and a value past the reading bounds counts as flagged. 

2102 """ 

2103 readings = _ris_readings(value) 

2104 if readings is None: 

2105 return True 

2106 for form in readings: 

2107 whole = _RIS_WHOLE_URL_AUTHORITY_RE.match(form) 

2108 if whole and "@" in whole.group(1).lstrip("@"): 

2109 return True 

2110 if any( 

2111 "@" in match.group(1).lstrip("@") 

2112 for match in _RIS_EMBEDDED_AUTHORITY_RE.finditer(form) 

2113 ): 

2114 return True 

2115 return False 

2116 

2117 

2118def _safe_ris_url(raw_url: str | None) -> str: 

2119 """Return a source URL ready for a RIS field without its userinfo, or "". 

2120 

2121 The literal userinfo of a parseable authority is removed and the rest 

2122 of the URL kept. The URL is omitted instead when it contains a 

2123 line-breaking character; when ``urlsplit`` cannot parse it, or finds no 

2124 host or an empty or invalid port in its authority; when its authority 

2125 holds a backslash; when it has a scheme but no authority, and the scheme 

2126 is http(s) or the URL holds an @; and when ``_ris_spells_userinfo`` 

2127 flags what would be exported: an entity-, percent- or NFKC-spelled 

2128 delimiter left in the authority; a credential URL with no authority 

2129 ``urlsplit`` could strip, inside a wrapper such as ``<...>`` or 

2130 ``[text](...)``, inside prose, after a scheme-less run of slashes and 

2131 backslashes other than ``//``, after U+FEFF, or with its scheme or 

2132 slashes split by an encoded tab, LF or CR; or a value past the bounds 

2133 on its readings. 

2134 """ 

2135 if not isinstance(raw_url, str): 2135 ↛ 2136line 2135 didn't jump to line 2136 because the condition on line 2135 was never true

2136 return "" 

2137 candidate = raw_url.strip() 

2138 if not candidate or any(is_line_breaking_char(char) for char in candidate): 

2139 # urlsplit silently discards some controls while parsing, but RIS 

2140 # consumers may read the original bytes as additional fields. 

2141 return "" 

2142 try: 

2143 parsed = urlsplit(candidate) 

2144 if parsed.netloc: 

2145 if "\\" in parsed.netloc: 

2146 # WHATWG parsers end a special authority at a backslash and 

2147 # urlsplit does not, so the two disagree about the host. 

2148 return "" 

2149 authority = parsed.netloc.rpartition("@")[2] 

2150 clean = parsed._replace(netloc=authority) 

2151 if not clean.hostname or authority.endswith(":"): 2151 ↛ 2152line 2151 didn't jump to line 2152 because the condition on line 2151 was never true

2152 return "" 

2153 _ = clean.port # ValueError for malformed or out-of-range ports. 

2154 candidate = urlunsplit(clean) 

2155 elif parsed.scheme and ( 

2156 parsed.scheme.lower() in _LINKABLE_URL_SCHEMES or "@" in candidate 

2157 ): 

2158 # A scheme followed by user:password@host without // is not a 

2159 # parseable authority; it must not survive as a raw RIS field. 

2160 return "" 

2161 except ValueError: 

2162 return "" 

2163 if _ris_spells_userinfo(candidate): 

2164 return "" 

2165 return candidate 

2166 

2167 

2168def _ris_field_text(value: str) -> str: 

2169 """``value`` for a free-text RIS field, or ``_safe_ris_url(value)`` when 

2170 the credential check flags it ("" when no URL is kept).""" 

2171 if _ris_spells_userinfo(value): 

2172 return _safe_ris_url(value) 

2173 return value 

2174 

2175 

2176def _ris_value_starts_userinfo(value: str) -> bool: 

2177 """Whether, in some reading of ``value``, a URL as a whole that starts 

2178 at its start, or after a comma, ``and`` or ampersand in it 

2179 (``_RIS_VALUE_URL_START_RE``), holds a credential: an @ in its 

2180 authority after any leading @ signs, as ``_ris_spells_userinfo`` reads 

2181 the authority of a value that is a URL as a whole. The authority runs 

2182 to the first ``/``, ``?`` or ``#``. An authority that starts inside 

2183 another ends where that one ends, so each is scanned once, and the work 

2184 is linear in the length of the readings. A value past the reading 

2185 bounds counts as holding one. 

2186 """ 

2187 readings = _ris_readings(value) 

2188 if readings is None: 2188 ↛ 2189line 2188 didn't jump to line 2189 because the condition on line 2188 was never true

2189 return True 

2190 for form in readings: 

2191 authority_end = last_at = -1 

2192 for start in _RIS_VALUE_URL_START_RE.finditer(form): 

2193 position = start.end() 

2194 if position >= authority_end: 

2195 end = _RIS_AUTHORITY_END_RE.search(form, position) 

2196 authority_end = end.start() if end else len(form) 

2197 last_at = form.rfind("@", position, authority_end) 

2198 while position < authority_end and form[position] == "@": 

2199 position += 1 

2200 if last_at >= position: 

2201 return True 

2202 return False 

2203 

2204 

2205# The labels of the lines whose value ``export_to_ris`` reads whole: the 

2206# source URL from a ``URL:`` line and the DOI from a ``DOI:`` line, each 

2207# matched as ``export_to_ris`` matches it. 

2208_RIS_VALUE_LABEL_RE = re.compile(r"URL:|DOI:") 

2209 

2210 

2211_RIS_SOURCE_VALUE_START = r"(?:\A|URL:|DOI:|by\s++|,|(?<=\s)and(?=\s)|&)" 

2212_RIS_SOURCE_VALUE_AUTHORITY_RE = re.compile( 

2213 rf"{_RIS_SOURCE_VALUE_START}" 

2214 rf"{_RIS_TRIMMED}*+{_RIS_WHOLE_URL_START}([^/?#]*)", 

2215 re.IGNORECASE, 

2216) 

2217# A source has metadata after its URL, so a host need not end the record. 

2218# This relaxed tail applies only when userinfo crosses the checked physical 

2219# line. A one-line prose title such as "HTTP: bob@example.org explains" 

2220# therefore keeps the field guard's documented prose exception. 

2221_RIS_SOURCE_SPACED_AUTHORITY_RE = re.compile( 

2222 rf"{_RIS_SOURCE_VALUE_START}{_RIS_TRIMMED}*+{_RIS_SPECIAL_SCHEME}:" 

2223 r"\s++([^\s/?#\\]+\s*@[^\s/?#@\\]++)(?=[/?#\\]|\s|\Z)", 

2224 re.IGNORECASE, 

2225) 

2226 

2227 

2228def _ris_multiline_line_carries_userinfo(text: str, position: int) -> bool: 

2229 """Check a source line in its unsplit record, before cutting fields. 

2230 

2231 A raw LF can cut the @ off a credential URL before a title, URL, DOI, 

2232 author or year is checked. Track the line's span through the same 

2233 decodings as the field guard, so unrelated lines keep their metadata. 

2234 Prefix/suffix comparisons conservatively include a decoded token that 

2235 crosses a span boundary (for example, ``%4<LF>0`` becoming ``@``). 

2236 No raw source text is rewritten or used as a replacement field value. 

2237 """ 

2238 if "\n" not in text: 

2239 return False 

2240 if text.isascii() and not any(char in text for char in "@%&"): 

2241 return False 

2242 if len(text) > _RIS_MAX_DECODED_LENGTH: 2242 ↛ 2243line 2242 didn't jump to line 2243 because the condition on line 2242 was never true

2243 return True 

2244 

2245 start = text.rfind("\n", 0, position) + 1 

2246 end = text.find("\n", position) 

2247 if end == -1: 

2248 end = len(text) 

2249 pending = [(text, start, end)] 

2250 seen = set(pending) 

2251 total_length = len(text) 

2252 decoders = ( 

2253 html.unescape, 

2254 lambda value: unicodedata.normalize("NFKC", value), 

2255 urllib.parse.unquote, 

2256 lambda value: value.translate(_RIS_URL_DELETED_CHARACTERS), 

2257 _ris_flatten, 

2258 ) 

2259 

2260 def common_edge(left: str, right: str, *, reverse: bool = False) -> int: 

2261 if reverse: 

2262 left, right = left[::-1], right[::-1] 

2263 if left.startswith(right): 

2264 return len(right) 

2265 return next( 

2266 (i for i, (a, b) in enumerate(zip(left, right)) if a != b), 

2267 min(len(left), len(right)), 

2268 ) 

2269 

2270 while pending: 

2271 form, start, end = pending.pop() 

2272 for pattern in ( 

2273 _RIS_EMBEDDED_AUTHORITY_RE, 

2274 _RIS_SOURCE_VALUE_AUTHORITY_RE, 

2275 _RIS_SOURCE_SPACED_AUTHORITY_RE, 

2276 ): 

2277 for match in pattern.finditer(form): 

2278 authority = match.group(1) 

2279 if "@" not in authority.lstrip("@"): 

2280 continue 

2281 credential_end = match.start(1) + authority.rfind("@") 

2282 crosses_line = ( 

2283 match.start(1) < start or credential_end + 1 >= end 

2284 ) 

2285 if ( 

2286 start < credential_end 

2287 and end > match.start() 

2288 and ( 

2289 pattern is not _RIS_SOURCE_SPACED_AUTHORITY_RE 

2290 or crosses_line 

2291 ) 

2292 ): 

2293 return True 

2294 

2295 for decode in decoders: 

2296 decoded = decode(form) 

2297 if decoded == form: 

2298 continue 

2299 if len(decoded) > _RIS_MAX_DECODED_LENGTH: 2299 ↛ 2300line 2299 didn't jump to line 2300 because the condition on line 2299 was never true

2300 return True 

2301 before = common_edge(decoded, decode(form[:start])) 

2302 after = len(decoded) - common_edge( 

2303 decoded, decode(form[end:]), reverse=True 

2304 ) 

2305 state = (decoded, min(before, after), max(before, after)) 

2306 if state in seen: 

2307 continue 

2308 total_length += len(decoded) 

2309 if ( 2309 ↛ 2314line 2309 didn't jump to line 2314 because the condition on line 2309 was never true

2310 len(seen) == _RIS_MAX_READINGS 

2311 or len(decoded) > _RIS_MAX_DECODED_LENGTH 

2312 or total_length > _RIS_MAX_DECODED_TOTAL_LENGTH 

2313 ): 

2314 return True 

2315 seen.add(state) 

2316 pending.append(state) 

2317 return False 

2318 

2319 

2320def _ris_line_carries_userinfo( 

2321 text: str, position: int, value_starts: tuple[int, ...] = () 

2322) -> bool: 

2323 """Whether the line of ``text`` that holds ``position`` carries a 

2324 credential, so that no field may be cut out of it. 

2325 

2326 The credential check reads the line itself, and each value read from 

2327 the line from where it starts to the end of the line 

2328 (``_ris_value_starts_userinfo``): the value after a ``URL:`` or ``DOI:`` 

2329 label that begins the line, which ``export_to_ris`` reads as the link 

2330 or the DOI, and the values that start in the line at ``value_starts``, 

2331 where the author and DOI patterns begin to read. In the line, a URL 

2332 after a label or a word ends at whitespace, as in other text, and the 

2333 author and DOI patterns end a value at a full stop, a comma, ``and``, 

2334 an ampersand or whitespace, past any of which a URL as a whole can go 

2335 on. So ``URL: https://user:by Smith@host/`` is not flagged as a line, 

2336 but its value is, with the password ``by Smith``, and the author 

2337 pattern would cut ``Smith@host/`` out of the line. 

2338 """ 

2339 start = text.rfind("\n", 0, position) + 1 

2340 end = text.find("\n", position) 

2341 if end == -1: 

2342 end = len(text) 

2343 if _ris_spells_userinfo(text[start:end]): 

2344 return True 

2345 starts = [value for value in value_starts if start <= value <= end] 

2346 stripped = text[start:end].lstrip() 

2347 label = _RIS_VALUE_LABEL_RE.match(stripped) 

2348 if label: 

2349 starts.append(end - len(stripped) + label.end()) 

2350 return any( 

2351 _ris_value_starts_userinfo(text[value:end]) for value in starts 

2352 ) or _ris_multiline_line_carries_userinfo(text, position) 

2353 

2354 

2355# Metadata clauses cut from a RIS title, applied in this order. Each one 

2356# starts at its keyword: a leading ``\s*`` would retry from every position 

2357# of a whitespace run before failing, which is quadratic in the run length. 

2358# ``_cut_title_clause`` removes the whitespace before the keyword instead. 

2359_RIS_TITLE_CLAUSES = ( 

2360 re.compile(r"DOI:\s*[^\s]+", re.IGNORECASE), 

2361 re.compile(r"Published in.*", re.IGNORECASE), 

2362 re.compile(r"Volume.*", re.IGNORECASE), 

2363 re.compile(r"Pages.*", re.IGNORECASE), 

2364) 

2365 

2366 

2367def _cut_title_clause(clause: re.Pattern, text: str) -> str: 

2368 r"""Remove every *clause* match together with the whitespace before it. 

2369 

2370 Gives the same result as ``re.sub(r"\s*" + clause.pattern, "", text)`` 

2371 in linear time. ``rstrip()`` and ``\s`` treat the same characters as 

2372 whitespace. The strip stops at the end of the previous match, which is 

2373 where the leading ``\s*`` of the next match could start at the earliest. 

2374 """ 

2375 pieces = [] 

2376 position = 0 

2377 for match in clause.finditer(text): 

2378 pieces.append(text[position : match.start()].rstrip()) 

2379 position = match.end() 

2380 if not pieces: 

2381 return text 

2382 pieces.append(text[position:]) 

2383 return "".join(pieces) 

2384 

2385 

2386class RISExporter: 

2387 """Export references to RIS format for reference managers like Zotero.""" 

2388 

2389 def __init__(self): 

2390 self.sources_pattern = re.compile( 

2391 r"^\[(\d+(?:,\s*\d+)*)\]\s*(.+?)(?:\n\s*URL:\s*(.+?))?$", 

2392 re.MULTILINE, 

2393 ) 

2394 

2395 def export_to_ris(self, content: str) -> str: 

2396 """ 

2397 Extract references from markdown and convert to RIS format. 

2398 

2399 Args: 

2400 content: Markdown content with sources 

2401 

2402 Returns: 

2403 RIS formatted references 

2404 """ 

2405 # Find sources section 

2406 sources_start, _on_sentinel = find_sources_section(content) 

2407 if sources_start == -1: 

2408 return "" 

2409 

2410 # Find the end of the first sources section (before any other major section) 

2411 sources_content = content[sources_start:] 

2412 

2413 # Look for the next major section to avoid duplicates 

2414 next_section_markers = [ 

2415 "\n## ALL SOURCES", 

2416 "\n### ALL SOURCES", 

2417 "\n## Research Metrics", 

2418 "\n### Research Metrics", 

2419 "\n## SEARCH QUESTIONS", 

2420 "\n### SEARCH QUESTIONS", 

2421 "\n## DETAILED FINDINGS", 

2422 "\n### DETAILED FINDINGS", 

2423 "\n---", # Horizontal rule often separates sections 

2424 ] 

2425 

2426 sources_end = len(sources_content) 

2427 for marker in next_section_markers: 

2428 pos = sources_content.find(marker) 

2429 if pos != -1 and pos < sources_end: 

2430 sources_end = pos 

2431 

2432 sources_content = sources_content[:sources_end] 

2433 

2434 # Parse sources and generate RIS entries 

2435 ris_entries = {} 

2436 

2437 # Split sources into individual entries 

2438 import re 

2439 

2440 # Pattern to match each source entry. The opener is GROUP-AWARE 

2441 # (``\d+(?:,\s*\d+)*``, the spelling ``_BIB_SOURCES_PATTERN`` above 

2442 # uses for Sources lines; the inline ``comma_citation_pattern``, 

2443 # ``\d+(?:\s*,\s*\d+)+``, also accepts whitespace before a comma 

2444 # since #6944, while ``format_links_to_markdown`` joins a merged 

2445 # opener's indices with ``", "``): 

2446 # ``format_links_to_markdown`` collapses every citation of one source 

2447 # onto a single merged ``[1, 3] Title`` line, which is routine for 

2448 # library documents since per-document keying (#5685). A single-index 

2449 # ``(\d+)`` capture did not merely drop such a line — it MIS-CITED 

2450 # (#5687), because the lookahead did not terminate at a merged opener 

2451 # either, so the PRECEDING entry swallowed the merged line and the 

2452 # last-wins ``URL:`` scan below then handed that entry the merged 

2453 # line's URL: one source's title against another source's link. Hence 

2454 # the group spelling appears TWICE — once in the capture, once in the 

2455 # lookahead — and the loop emits one record PER INDEX so that every 

2456 # ``[N]`` in the body resolves to a reference of its own. 

2457 # 

2458 # Four properties here are deliberate; keep them when editing: 

2459 # 1. This parser stays bounded to the FIRST Sources section by the 

2460 # ``next_section_markers`` scan above. Do not swap in the 

2461 # module-level ``_collect_bibliography_entries`` — that helper 

2462 # is deliberately not section-scoped, which is why #5687 notes 

2463 # RIS is not a mechanical swap even though BibTeX, Quarto and 

2464 # LaTeX were fixed by adopting it. 

2465 # 2. Lenticular "【N】" openers/closers are accepted alongside 

2466 # ASCII "[N]": the inline citation patterns in this file already 

2467 # handle them (some LLMs emit them), so the source-list parser 

2468 # must stay consistent or it would silently drop those entries. 

2469 # 3. Entries are split by LOOKAHEAD under ``DOTALL`` so continuation 

2470 # lines (``URL:``, ``DOI:``, ``Published in``) stay attached to 

2471 # the entry they belong to; a line-anchored ``$`` split would 

2472 # strip them. 

2473 # 4. ``self.sources_pattern`` above is group-aware but UNUSED, and 

2474 # is not this fix — it is single-line and section-unaware, so 

2475 # wiring it in would lose properties 1 and 3. 

2476 source_entry_pattern = re.compile( 

2477 r"^[\[【](\d+(?:,\s*\d+)*)[\]】]\s*(.+?)" 

2478 r"(?=^[\[【]\d+(?:,\s*\d+)*[\]】]|\Z)", 

2479 re.MULTILINE | re.DOTALL, 

2480 ) 

2481 

2482 for match in source_entry_pattern.finditer(sources_content): 

2483 citation_group = match.group(1) 

2484 entry_text = match.group(2).strip() 

2485 

2486 lines = entry_text.split("\n") 

2487 

2488 # Extract URL, DOI, and other metadata from subsequent lines 

2489 url = "" 

2490 metadata = {} 

2491 value_positions = {} 

2492 line_start = len(lines[0]) + 1 

2493 for line in lines[1:]: 

2494 position = line_start 

2495 line_start += len(line) + 1 

2496 line = line.strip() 

2497 if line.startswith("URL:"): 

2498 url = line[4:].strip() 

2499 value_positions["url"] = position 

2500 elif line.startswith("DOI:"): 

2501 metadata["doi"] = line[4:].strip() 

2502 value_positions["doi"] = position 

2503 elif line.startswith("Published in"): 

2504 metadata["journal"] = line[12:].strip() 

2505 # Add more metadata parsing as needed 

2506 elif line: 

2507 # Store other lines as additional metadata 

2508 if "additional" not in metadata: 

2509 metadata["additional"] = [] 

2510 additional = metadata["additional"] 

2511 if isinstance(additional, list): 2511 ↛ 2493line 2511 didn't jump to line 2493 because the condition on line 2511 was always true

2512 additional.append(line) 

2513 

2514 # A line split may already have removed the credential's @. 

2515 # Check the selected values against the unsplit source before 

2516 # the ordinary whole-field sanitizer handles parseable URLs. 

2517 if ( 

2518 url 

2519 and not _ris_spells_userinfo(url) 

2520 and _ris_multiline_line_carries_userinfo( 

2521 entry_text, value_positions["url"] 

2522 ) 

2523 ): 

2524 url = "" 

2525 if ( 

2526 metadata.get("doi") 

2527 and not _ris_spells_userinfo(metadata["doi"]) 

2528 and _ris_multiline_line_carries_userinfo( 

2529 entry_text, value_positions["doi"] 

2530 ) 

2531 ): 

2532 metadata["doi"] = "" 

2533 

2534 # Combine title with additional metadata lines for full context 

2535 full_text = entry_text 

2536 

2537 # One record per index, keyed on the INDIVIDUAL index rather than 

2538 # the group text: a report repeats its Sources block, and the same 

2539 # source may be written ``[1]`` in one block and ``[1, 3]`` in 

2540 # another, so keying on the raw group would let ``ref1`` be emitted 

2541 # twice. Later definitions replace earlier ones, as they do in 

2542 # the inline citation map. ``metadata`` is shared across the 

2543 # indices of one line; ``_create_ris_entry`` only reads it. 

2544 for citation_num in _iter_citation_group(citation_group): 

2545 ris_entries[citation_num] = self._create_ris_entry( 

2546 citation_num, full_text, url, metadata 

2547 ) 

2548 

2549 return "\n".join(ris_entries.values()) 

2550 

2551 def _create_ris_entry( 

2552 self, 

2553 ref_id: str, 

2554 full_text: str, 

2555 url: str = "", 

2556 metadata: dict | None = None, 

2557 ) -> str: 

2558 """Create a single RIS entry.""" 

2559 lines = [] 

2560 

2561 # Parse metadata from full text 

2562 import re 

2563 

2564 if metadata is None: 

2565 metadata = {} 

2566 

2567 # Extract title from first line. NB: split into a *separate* variable — 

2568 # ``lines`` is the RIS-output accumulator initialized above and appended 

2569 # to below; reusing it here previously overwrote it with the source 

2570 # text, so every entry emitted the raw source body before the mandatory 

2571 # leading ``TY - `` tag and reference managers rejected the file. 

2572 text_lines = full_text.split("\n") 

2573 title = text_lines[0].strip() 

2574 

2575 # The year, the author list, and the DOI when no ``DOI:`` line gave 

2576 # one, are cut out of whatever line their match captures from, and 

2577 # the title is cleaned. A cut can drop a credential URL's @ and keep 

2578 # its userinfo (the title ``https://DOI:pass@host`` yields the DOI 

2579 # ``pass@host``), and a password can hold a year or a name 

2580 # (``https://admin:2024@host`` yields the year 2024), so none of the 

2581 # three is cut from a line that carries a credential 

2582 # (``_ris_line_carries_userinfo``), and a flagged title is sanitized 

2583 # whole, not cleaned. A title that its cleaning joins into a 

2584 # credential URL gives no year or DOI either (``flagged_title``). 

2585 year_match = re.search(r"\b(19\d{2}|20\d{2})\b", full_text) 

2586 authors_match = re.search( 

2587 r"\bby\s+([^.\n]+?)(?:\.|\n|$)", full_text, re.IGNORECASE 

2588 ) 

2589 doi = metadata.get("doi") 

2590 doi_match = ( 

2591 None 

2592 if doi 

2593 else re.search(r"DOI:\s*([^\s\n]+)", full_text, re.IGNORECASE) 

2594 ) 

2595 # Where the author and DOI patterns begin to read a value; a line 

2596 # is checked from each of them too. Each line is checked once. 

2597 value_starts = tuple( 

2598 match.start(1) 

2599 for match in (authors_match, doi_match) 

2600 if match is not None 

2601 ) 

2602 line_carries_userinfo: dict[int, bool] = {} 

2603 

2604 def cut_is_safe(match: re.Match) -> bool: 

2605 line_start = full_text.rfind("\n", 0, match.start(1)) + 1 

2606 if line_start not in line_carries_userinfo: 

2607 line_carries_userinfo[line_start] = _ris_line_carries_userinfo( 

2608 full_text, match.start(1), value_starts 

2609 ) 

2610 return not line_carries_userinfo[line_start] 

2611 

2612 # Extract year from full text (looks for 4-digit year) 

2613 year = ( 

2614 year_match.group(1) 

2615 if year_match and cut_is_safe(year_match) 

2616 else None 

2617 ) 

2618 

2619 # Extract authors if present (looks for "by Author1, Author2") 

2620 authors = [] 

2621 if authors_match and cut_is_safe(authors_match): 

2622 # The captured text and each author are checked as written too. 

2623 authors_text = _ris_field_text(authors_match.group(1)) 

2624 # Split at delimiters only, then trim each author. Consuming 

2625 # optional whitespace before a delimiter makes a long run with 

2626 # no delimiter quadratic; lookarounds keep "and" word-scoped. 

2627 author_parts = re.split(r",|(?<=\s)and(?=\s)|&", authors_text) 

2628 authors = [ 

2629 author 

2630 for author in (_ris_field_text(a.strip()) for a in author_parts) 

2631 if author 

2632 ] 

2633 

2634 # Extract DOI from metadata or text 

2635 if doi_match is not None: 

2636 doi = doi_match.group(1) if cut_is_safe(doi_match) else None 

2637 

2638 # Clean title - remove author and metadata info for cleaner title. 

2639 # Every form a cut makes is kept for the credential check below. 

2640 clean_title = title 

2641 title_forms = [title] 

2642 if authors_match and authors_match.start() < len(title): 

2643 clean_title = ( 

2644 title[: authors_match.start()] + title[authors_match.end() :] 

2645 if authors_match.end() < len(title) 

2646 else title[: authors_match.start()] 

2647 ) 

2648 title_forms.append(clean_title) 

2649 for clause in _RIS_TITLE_CLAUSES: 

2650 clean_title = _cut_title_clause(clause, clean_title) 

2651 title_forms.append(clean_title) 

2652 clean_title = clean_title.strip() 

2653 title_forms.append(clean_title) 

2654 

2655 # A title carries a credential when the check flags it as written or 

2656 # in any form a cut makes of it: what a cut leaves can join into a 

2657 # credential URL, and a later cut can cut that URL's @ away again, 

2658 # leaving its userinfo. The first form the check flags is sanitized 

2659 # whole, and the title gives no year and no DOI. 

2660 flagged_title = next( 

2661 ( 

2662 form 

2663 for form in dict.fromkeys(title_forms) 

2664 if _ris_spells_userinfo(form) 

2665 ), 

2666 None, 

2667 ) 

2668 # Cleaning can remove a complete credential clause while leaving a 

2669 # different fragment, or join a fragment to the continuation lines. 

2670 # Check the title we would write together with the uncut remainder. 

2671 fragmented_title = ( 

2672 flagged_title is None 

2673 and _ris_multiline_line_carries_userinfo( 

2674 (clean_title or title) + full_text[len(text_lines[0]) :], 0 

2675 ) 

2676 ) 

2677 if flagged_title is not None or fragmented_title: 

2678 title_end = len(text_lines[0]) 

2679 if year_match and year_match.start(1) < title_end: 

2680 year = None 

2681 if doi_match and doi_match.start(1) < title_end: 

2682 doi = None 

2683 

2684 # TY - Type of reference (ELEC for electronic source/website) 

2685 lines.append("TY - ELEC") 

2686 

2687 # ID - Reference ID 

2688 lines.append(f"ID - ref{ref_id}") 

2689 

2690 # TI - Title. A source's entire title can itself be a URL, or hold 

2691 # one: a title the shared credential check flags is written as its 

2692 # userinfo-free URL, or as a placeholder when ``_safe_ris_url`` 

2693 # keeps no URL for it. The title as written is checked first, since 

2694 # cleaning can cut the @ out of a credential URL; each form a cut 

2695 # makes is checked too, since joining what a cut leaves can spell 

2696 # one (``flagged_title`` above). 

2697 if fragmented_title: 

2698 display_title = "[source URL omitted]" 

2699 elif flagged_title is None: 

2700 display_title = clean_title if clean_title else title 

2701 else: 

2702 display_title = ( 

2703 _safe_ris_url(flagged_title) or "[source URL omitted]" 

2704 ) 

2705 lines.append(f"TI - {display_title}") 

2706 

2707 # AU - Authors 

2708 for author in authors: 

2709 lines.append(f"AU - {author}") 

2710 

2711 # DO - DOI. Often written as a doi.org URL, so it gets the same 

2712 # credential check as the title; one it flags without a URL that 

2713 # ``_safe_ris_url`` keeps is left out. 

2714 if doi: 

2715 doi = _ris_field_text(str(doi)) 

2716 if doi: 

2717 lines.append(f"DO - {doi}") 

2718 

2719 # PY - Publication year (if one was found above) 

2720 if year: 

2721 lines.append(f"PY - {year}") 

2722 

2723 # UR - URL. Legacy reports and LLM-authored Sources may still carry 

2724 # user:password@host even though new reports canonicalize their URLs. 

2725 safe_url = _safe_ris_url(url) 

2726 if safe_url: 

2727 lines.append(f"UR - {safe_url}") 

2728 

2729 # Try to extract domain as publisher 

2730 try: 

2731 from urllib.parse import urlparse 

2732 

2733 parsed = urlparse(safe_url) 

2734 # The host alone. The netloc also carries userinfo and port, 

2735 # so https://user:token@arxiv.org/ wrote the token into the 

2736 # .ris file as the publisher (#6837). The root dot of a fully 

2737 # qualified name is dropped, as utilities/arxiv.py does. 

2738 domain = (parsed.hostname or "").rstrip(".") 

2739 if domain.startswith("www."): 

2740 domain = domain[4:] 

2741 # Extract readable publisher name from domain 

2742 if domain == "github.com" or domain.endswith(".github.com"): 

2743 lines.append("PB - GitHub") 

2744 elif domain == "arxiv.org" or domain.endswith(".arxiv.org"): 

2745 lines.append("PB - arXiv") 

2746 elif domain == "reddit.com" or domain.endswith(".reddit.com"): 

2747 lines.append("PB - Reddit") 

2748 elif ( 

2749 domain == "youtube.com" 

2750 or domain == "m.youtube.com" 

2751 or domain.endswith(".youtube.com") 

2752 ): 

2753 lines.append("PB - YouTube") 

2754 elif domain == "medium.com" or domain.endswith(".medium.com"): 

2755 lines.append("PB - Medium") 

2756 elif domain == "pypi.org" or domain.endswith(".pypi.org"): 

2757 lines.append("PB - Python Package Index (PyPI)") 

2758 else: 

2759 # Use domain as publisher 

2760 lines.append(f"PB - {domain}") 

2761 except (ValueError, AttributeError): 

2762 pass 

2763 

2764 # Y1 - Year accessed (current year) 

2765 from datetime import UTC, datetime 

2766 

2767 current_year = datetime.now(UTC).year 

2768 lines.append(f"Y1 - {current_year}") 

2769 

2770 # DA - Date accessed 

2771 current_date = datetime.now(UTC).strftime("%Y/%m/%d") 

2772 lines.append(f"DA - {current_date}") 

2773 

2774 # LA - Language 

2775 lines.append("LA - en") 

2776 

2777 # ER - End of reference 

2778 lines.append("ER - ") 

2779 

2780 # A field can contain bare CR or Unicode line separators even when 

2781 # extracted from one LF-delimited source line. Flatten them at the 

2782 # serialization boundary so only our separators create RIS fields. 

2783 # The credential check reads every value flattened the same way. 

2784 return "\n".join(_ris_flatten(line) for line in lines) 

2785 

2786 

2787# Body prose is markdown, where "\$" is the escape for a literal 

2788# dollar. The escaper therefore consumes a "\$" pair as ONE token and 

2789# emits "\$" (a literal dollar in LaTeX too); every other special, 

2790# every other backslash included, goes through the _escape_latex_text 

2791# map. Pair-first alternation in a single regex pass means nothing is 

2792# ever escaped twice: "\$" never becomes "\textbackslash{}\$" (which 

2793# would print a stray backslash), and the output never holds "\\" 

2794# (a line break) or a bare "$" (a live math shift). 

2795_LATEX_BODY_TEXT_RE = re.compile( 

2796 r"\\\$|[" + re.escape("".join(_LATEX_TEXT_SPECIALS)) + "]" 

2797) 

2798 

2799 

2800def _escape_latex_body_text(text: str) -> str: 

2801 r"""Escape a prose segment of the export body in one pass. 

2802 

2803 Identical to :func:`_escape_latex_text` except that a markdown 

2804 ``\$`` (escaped dollar) becomes ``\$`` rather than 

2805 ``\textbackslash{}\$``. A backslash run before a dollar pairs only 

2806 its LAST backslash with the dollar; the others are literal 

2807 backslashes (``\\$`` -> ``\textbackslash{}\$``). 

2808 """ 

2809 return _LATEX_BODY_TEXT_RE.sub( 

2810 lambda m: ( 

2811 "\\$" if m.group() == "\\$" else _LATEX_TEXT_SPECIALS[m.group()] 

2812 ), 

2813 text, 

2814 ) 

2815 

2816 

2817# A blank line ends a block: two line breaks separated only by 

2818# horizontal whitespace and carriage returns. Legacy report_content 

2819# rows store CRLF newlines and LLM prose often leaves spaces or tabs 

2820# on the otherwise blank line, so probing for a literal "\n\n" misses 

2821# real paragraph boundaries; the boundary is matched structurally. 

2822_BLOCK_END = re.compile(r"\r?\n[ \t\r]*\r?\n") 

2823 

2824# Verdicts _split_math_segments assigns to each live "$" as the closer 

2825# of the opener before it: acceptable; rejected because a digit follows 

2826# it (a price: "$5-$10"); or rejected because whitespace precedes it. 

2827_CLOSER_OK = 0 

2828_CLOSER_DIGIT = 1 

2829_CLOSER_SPACE = 2 

2830 

2831 

2832def _split_math_segments(text: str) -> list[tuple[str, bool]]: 

2833 r"""Split *text* into ``(segment, is_math)`` pairs, left to right. 

2834 

2835 ``$$...$$`` display spans pair first, then ``$...$`` inline spans. 

2836 Neither kind ever crosses a blank line (pandoc scopes math to a 

2837 single block, so a stray ``$$`` or a price in one paragraph can 

2838 never pair with a delimiter in another; an opener unpaired within 

2839 its block is literal text). An inline span's opening ``$`` must 

2840 not be followed by whitespace, and it closes at the FIRST live 

2841 ``$`` after it in the block, as in pandoc: that closer must not be 

2842 preceded by whitespace and must not be followed by a digit, and if 

2843 it is, the OPENER fails and reads as prose (``$5-$10`` is two 

2844 prices, not ``$5-$`` around ``10``). Rejected closers are never 

2845 stepped over on the way to a farther one, which would emit a span 

2846 holding a raw interior dollar. A closer rejected for the digit 

2847 after it is itself a price and cannot open a span either; pandoc 

2848 would let it open one, so ``$5-$10 per unit ($n$)`` would make 

2849 ``$10 per unit ($`` math there, raw prose between two prices 

2850 (whitespace-rejected closers still open: ``$5 so $x$`` keeps 

2851 ``$x$``). Mistaking math for prose only escapes it; mistaking 

2852 prose for math emits its ``%`` and ``&`` raw. (Mirror case: because 

2853 the rejected closer cannot open, a later ``$`` glued to the text on 

2854 its left can open instead and pair farther on, so 

2855 ``$5-$10 per hour (US$), 50% more (US$)`` makes ``$), 50% more (US$`` 

2856 math; this is rarer than the cases the rule prevents.) A ``$`` 

2857 preceded by an odd run of backslashes is a literal dollar 

2858 (as in ``\$5``), never an opener or a closer; after an even run 

2859 (``\\$``: the backslashes escape each other) the dollar is as 

2860 live as a bare one, in both directions. Every other dollar is a 

2861 literal character inside a text segment. Math segments include their 

2862 delimiters and are returned verbatim. 

2863 """ 

2864 segments: list[tuple[str, bool]] = [] 

2865 start = 0 # start of the current text run 

2866 i = 0 

2867 n = len(text) 

2868 # Whether a "$" can close inline math does not depend on the 

2869 # opener, so every live candidate position and its verdict is 

2870 # collected once up front; the pairing below advances one pointer 

2871 # over them, left to right, and never rescans. A "$" ending an odd 

2872 # backslash run is a literal dollar and never a delimiter in 

2873 # either direction: excluded here so the walk cannot CLOSE a span 

2874 # on it, exactly as the "\x" skip below already keeps it from 

2875 # opening one. A "$" after an even run ("\\$": the backslashes 

2876 # escape each other) is live and stays a closer candidate; a 

2877 # single-character lookbehind excluded it too and left "$x\\$" 

2878 # unpaired. That pattern's lookbehind stops it starting mid-run, 

2879 # so each match spans the full run and the scan stays linear 

2880 # even on backslash walls. 

2881 odd_run_dollars = { 

2882 m.end() - 1 for m in re.finditer(r"(?<!\\)(?:\\\\)*\\\$", text) 

2883 } 

2884 dollars = [ 

2885 m.start() 

2886 for m in re.finditer(r"\$", text) 

2887 if m.start() not in odd_run_dollars 

2888 ] 

2889 # Whitespace is checked first: a candidate with both flaws ("$fee 

2890 # $5$") is not a price and may still open its own span. 

2891 closer_verdict = [ 

2892 _CLOSER_SPACE 

2893 if p > 0 and text[p - 1].isspace() 

2894 else _CLOSER_DIGIT 

2895 if p + 1 < n and text[p + 1] in "0123456789" 

2896 else _CLOSER_OK 

2897 for p in dollars 

2898 ] 

2899 cand = 0 # index into dollars: first candidate after the opener 

2900 price_dollar = -1 # a digit-rejected closer: never an opener 

2901 # First blank line at or after the current position. 

2902 block_end = n 

2903 block_end_known = False 

2904 while i < n: 

2905 if text[i] == "\\": 

2906 # A backslash escapes the next character: "\$" is a 

2907 # literal dollar, never a math delimiter. 

2908 i += 2 

2909 continue 

2910 if text[i] != "$": 

2911 i += 1 

2912 continue 

2913 if i + 1 < n and text[i + 1] == "$": 

2914 # A display opener pairs only inside its own block, so a 

2915 # stray "$$" in one paragraph cannot capture a genuine 

2916 # display span in another (a blank line is never inside 

2917 # math). The closer scan walks the block: "\x" pairs are 

2918 # skipped (an interior "\$" is a literal dollar, and the 

2919 # "$" of "\$$" is not the lead of a closer), the first 

2920 # raw single "$" means the opener is unpaired (a display 

2921 # span has no single-dollar interior), and the first raw 

2922 # "$$" closes. 

2923 if not block_end_known or i > block_end: 

2924 boundary = _BLOCK_END.search(text, i) 

2925 block_end = boundary.start() if boundary is not None else n 

2926 block_end_known = True 

2927 close = -1 

2928 j = i + 2 

2929 while j < block_end: 

2930 if text[j] == "\\": 

2931 j += 2 

2932 elif text[j] == "$": 

2933 if j + 1 < n and text[j + 1] == "$": 

2934 close = j 

2935 break 

2936 else: 

2937 j += 1 

2938 if close != -1: 

2939 segments.append((text[start:i], False)) 

2940 segments.append((text[i : close + 2], True)) 

2941 i = close + 2 

2942 start = i 

2943 else: 

2944 # Unpaired $$ within its block: literal text, like 

2945 # any lone dollar. 

2946 i += 2 

2947 continue 

2948 # Single '$': find a closing '$' the rules accept, without 

2949 # leaving the paragraph (inline math never crosses a blank 

2950 # line). 

2951 if i == price_dollar or (i + 1 < n and text[i + 1].isspace()): 

2952 i += 1 

2953 continue 

2954 # The first blank line at or after i only moves forward as i 

2955 # does, so the search is cached until i passes its match. 

2956 if not block_end_known or i > block_end: 

2957 boundary = _BLOCK_END.search(text, i) 

2958 block_end = boundary.start() if boundary is not None else n 

2959 block_end_known = True 

2960 # The closer is the first live "$" after the opener. Openers 

2961 # only move right, so the pointer does too. 

2962 while cand < len(dollars) and dollars[cand] <= i: 

2963 cand += 1 

2964 verdict = ( 

2965 closer_verdict[cand] 

2966 if cand < len(dollars) and dollars[cand] < block_end 

2967 else None 

2968 ) 

2969 if verdict == _CLOSER_OK: 

2970 closer = dollars[cand] 

2971 segments.append((text[start:i], False)) 

2972 segments.append((text[i : closer + 1], True)) 

2973 i = closer + 1 

2974 start = i 

2975 else: 

2976 # No closer in this block, or the first one is preceded 

2977 # by whitespace or followed by a digit: the opener fails 

2978 # (never stepped past to a farther "$") and is a literal 

2979 # dollar. A digit-rejected closer is a price as well. 

2980 if verdict == _CLOSER_DIGIT: 

2981 price_dollar = dollars[cand] 

2982 i += 1 

2983 segments.append((text[start:], False)) 

2984 return segments 

2985 

2986 

2987# Math in a report may come from retrieved pages through the LLM. TeX executes 

2988# control sequences when a user compiles an exported .tex file, so preserving 

2989# every matched $...$ span verbatim is unsafe. Only math commands that the 

2990# LaTeX kernel itself defines are accepted, because the export preamble loads 

2991# no math package: \text, \binom and \operatorname (amsmath) and \mathbb 

2992# (amsfonts) would be undefined there, so spans using them are rendered as 

2993# literal text. Each accepted command is a math symbol, delimiter 

2994# or accent, or a TeX primitive or kernel macro that only typesets (boxes, 

2995# spacing, font changes). None of them lets the report's text open or write 

2996# a file, run a shell command, change a category code or define a macro. 

2997# Every character in an argument is checked by the same scanner, so an 

2998# allowed command cannot hide a second one. 

2999_SAFE_MATH_COMMANDS = frozenset( 

3000 { 

3001 # Greek letters, lower case. There is no \omicron: it is "o". 

3002 "alpha", 

3003 "beta", 

3004 "gamma", 

3005 "delta", 

3006 "epsilon", 

3007 "varepsilon", 

3008 "zeta", 

3009 "eta", 

3010 "theta", 

3011 "vartheta", 

3012 "iota", 

3013 "kappa", 

3014 "lambda", 

3015 "mu", 

3016 "nu", 

3017 "xi", 

3018 "pi", 

3019 "varpi", 

3020 "rho", 

3021 "varrho", 

3022 "sigma", 

3023 "varsigma", 

3024 "tau", 

3025 "upsilon", 

3026 "phi", 

3027 "varphi", 

3028 "chi", 

3029 "psi", 

3030 "omega", 

3031 # Greek letters, upper case. The others (A, B, E, ...) are Latin. 

3032 "Gamma", 

3033 "Delta", 

3034 "Theta", 

3035 "Lambda", 

3036 "Xi", 

3037 "Pi", 

3038 "Sigma", 

3039 "Upsilon", 

3040 "Phi", 

3041 "Psi", 

3042 "Omega", 

3043 # Binary operators. 

3044 "pm", 

3045 "mp", 

3046 "times", 

3047 "div", 

3048 "cdot", 

3049 "ast", 

3050 "star", 

3051 "circ", 

3052 "bullet", 

3053 "setminus", 

3054 "cap", 

3055 "cup", 

3056 "uplus", 

3057 "sqcap", 

3058 "sqcup", 

3059 "vee", 

3060 "wedge", 

3061 "land", 

3062 "lor", 

3063 "oplus", 

3064 "ominus", 

3065 "otimes", 

3066 "oslash", 

3067 "odot", 

3068 "dagger", 

3069 "ddagger", 

3070 "amalg", 

3071 "diamond", 

3072 "wr", 

3073 "bigcirc", 

3074 "triangleleft", 

3075 "triangleright", 

3076 "bigtriangleup", 

3077 "bigtriangledown", 

3078 # Relations, including the \le, \ge and \ne short forms and \not, 

3079 # which overlays a slash on the relation after it. 

3080 "leq", 

3081 "le", 

3082 "geq", 

3083 "ge", 

3084 "neq", 

3085 "ne", 

3086 "ll", 

3087 "gg", 

3088 "prec", 

3089 "succ", 

3090 "preceq", 

3091 "succeq", 

3092 "sim", 

3093 "simeq", 

3094 "approx", 

3095 "cong", 

3096 "equiv", 

3097 "doteq", 

3098 "propto", 

3099 "asymp", 

3100 "perp", 

3101 "parallel", 

3102 "mid", 

3103 "models", 

3104 "vdash", 

3105 "dashv", 

3106 "smile", 

3107 "frown", 

3108 "bowtie", 

3109 "in", 

3110 "ni", 

3111 "owns", 

3112 "notin", 

3113 "subset", 

3114 "supset", 

3115 "subseteq", 

3116 "supseteq", 

3117 "sqsubseteq", 

3118 "sqsupseteq", 

3119 "not", 

3120 # Arrows. 

3121 "to", 

3122 "gets", 

3123 "leftarrow", 

3124 "rightarrow", 

3125 "leftrightarrow", 

3126 "Leftarrow", 

3127 "Rightarrow", 

3128 "Leftrightarrow", 

3129 "longleftarrow", 

3130 "longrightarrow", 

3131 "longleftrightarrow", 

3132 "Longleftarrow", 

3133 "Longrightarrow", 

3134 "Longleftrightarrow", 

3135 "iff", 

3136 "mapsto", 

3137 "longmapsto", 

3138 "hookleftarrow", 

3139 "hookrightarrow", 

3140 "uparrow", 

3141 "downarrow", 

3142 "updownarrow", 

3143 "Uparrow", 

3144 "Downarrow", 

3145 "Updownarrow", 

3146 "nearrow", 

3147 "searrow", 

3148 "swarrow", 

3149 "nwarrow", 

3150 "leftharpoonup", 

3151 "leftharpoondown", 

3152 "rightharpoonup", 

3153 "rightharpoondown", 

3154 "rightleftharpoons", 

3155 # Other symbols. 

3156 "infty", 

3157 "partial", 

3158 "nabla", 

3159 "forall", 

3160 "exists", 

3161 "neg", 

3162 "lnot", 

3163 "emptyset", 

3164 "aleph", 

3165 "hbar", 

3166 "imath", 

3167 "jmath", 

3168 "ell", 

3169 "wp", 

3170 "Re", 

3171 "Im", 

3172 "prime", 

3173 "top", 

3174 "bot", 

3175 "angle", 

3176 "triangle", 

3177 "surd", 

3178 "flat", 

3179 "natural", 

3180 "sharp", 

3181 "clubsuit", 

3182 "diamondsuit", 

3183 "heartsuit", 

3184 "spadesuit", 

3185 "colon", 

3186 "ldots", 

3187 "cdots", 

3188 "vdots", 

3189 "ddots", 

3190 "dots", 

3191 # Big operators. 

3192 "sum", 

3193 "prod", 

3194 "coprod", 

3195 "int", 

3196 "oint", 

3197 "smallint", 

3198 "bigcap", 

3199 "bigcup", 

3200 "bigsqcup", 

3201 "bigvee", 

3202 "bigwedge", 

3203 "bigodot", 

3204 "bigoplus", 

3205 "bigotimes", 

3206 "biguplus", 

3207 # Named operators, set upright like "log". 

3208 "arccos", 

3209 "arcsin", 

3210 "arctan", 

3211 "arg", 

3212 "cos", 

3213 "cosh", 

3214 "cot", 

3215 "coth", 

3216 "csc", 

3217 "deg", 

3218 "det", 

3219 "dim", 

3220 "exp", 

3221 "gcd", 

3222 "hom", 

3223 "inf", 

3224 "ker", 

3225 "lg", 

3226 "lim", 

3227 "liminf", 

3228 "limsup", 

3229 "ln", 

3230 "log", 

3231 "max", 

3232 "min", 

3233 "Pr", 

3234 "sec", 

3235 "sin", 

3236 "sinh", 

3237 "sup", 

3238 "tan", 

3239 "tanh", 

3240 "bmod", 

3241 "pmod", 

3242 # Delimiters and the commands that size them (\middle is e-TeX). 

3243 "left", 

3244 "right", 

3245 "middle", 

3246 "big", 

3247 "Big", 

3248 "bigg", 

3249 "Bigg", 

3250 "bigl", 

3251 "bigr", 

3252 "Bigl", 

3253 "Bigr", 

3254 "biggl", 

3255 "biggr", 

3256 "Biggl", 

3257 "Biggr", 

3258 "bigm", 

3259 "Bigm", 

3260 "biggm", 

3261 "Biggm", 

3262 "langle", 

3263 "rangle", 

3264 "lfloor", 

3265 "rfloor", 

3266 "lceil", 

3267 "rceil", 

3268 "lbrace", 

3269 "rbrace", 

3270 "lbrack", 

3271 "rbrack", 

3272 "vert", 

3273 "Vert", 

3274 "backslash", 

3275 "lgroup", 

3276 "rgroup", 

3277 "lmoustache", 

3278 "rmoustache", 

3279 "arrowvert", 

3280 "Arrowvert", 

3281 "bracevert", 

3282 # Accents, and lines, braces and arrows set over or under a formula. 

3283 "hat", 

3284 "check", 

3285 "tilde", 

3286 "acute", 

3287 "grave", 

3288 "dot", 

3289 "ddot", 

3290 "breve", 

3291 "bar", 

3292 "vec", 

3293 "mathring", 

3294 "widehat", 

3295 "widetilde", 

3296 "overline", 

3297 "underline", 

3298 "overbrace", 

3299 "underbrace", 

3300 "overrightarrow", 

3301 "overleftarrow", 

3302 # Fractions and roots. 

3303 "frac", 

3304 "sqrt", 

3305 # Math alphabets. 

3306 "mathrm", 

3307 "mathbf", 

3308 "mathit", 

3309 "mathsf", 

3310 "mathtt", 

3311 "mathcal", 

3312 "mathnormal", 

3313 # Math styles and the placement of limits. 

3314 "displaystyle", 

3315 "textstyle", 

3316 "scriptstyle", 

3317 "scriptscriptstyle", 

3318 "limits", 

3319 "nolimits", 

3320 # Spacing. \medspace and \thickspace are left out: the kernel only 

3321 # gained them in 2020, so older TeX installations lack them. 

3322 "quad", 

3323 "qquad", 

3324 "thinspace", 

3325 "negthinspace", 

3326 "enspace", 

3327 } 

3328) 

3329# Control symbols: a backslash and one non-letter. \$ and \\ are the 

3330# literal-dollar and escaped-backslash spans the delimiter scanner already 

3331# supports. \{, \} and \| are delimiters; \, \: \; \! and "\ " are spaces; 

3332# \% \# \& and \_ print the character. "\^" stays out: it would let "\^^" 

3333# hide a ^^ encoding from the check in _math_span_is_safe. 

3334_SAFE_MATH_CONTROL_SYMBOLS = frozenset("$\\{}|,:;! %#&_") 

3335_ASCII_MATH_LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" 

3336# "'" is the prime, as in f'(x). "\r" is handled in _math_span_is_safe. 

3337_SAFE_MATH_CHARACTERS = frozenset( 

3338 _ASCII_MATH_LETTERS + "0123456789 \t\n+-*/=.,:;!?(){}[]^_<>|'" 

3339) 

3340 

3341 

3342def _math_span_is_safe(segment: str) -> bool: 

3343 """Accept only inert math syntax inside a matched inline/display span.""" 

3344 delimiter = "$$" if segment.startswith("$$") else "$" 

3345 body = segment[len(delimiter) : -len(delimiter)] 

3346 depth = 0 

3347 position = 0 

3348 while position < len(body): 

3349 character = body[position] 

3350 if character == "\\": 

3351 position += 1 

3352 if position == len(body): 3352 ↛ 3353line 3352 didn't jump to line 3353 because the condition on line 3352 was never true

3353 return False 

3354 if body[position] in _ASCII_MATH_LETTERS: 

3355 start = position 

3356 while ( 

3357 position < len(body) 

3358 and body[position] in _ASCII_MATH_LETTERS 

3359 ): 

3360 position += 1 

3361 if body[start:position] not in _SAFE_MATH_COMMANDS: 

3362 return False 

3363 continue 

3364 if body[position] not in _SAFE_MATH_CONTROL_SYMBOLS: 

3365 return False 

3366 position += 1 

3367 continue 

3368 if character == "\r": 

3369 # Legacy report_content rows store CRLF newlines, and TeX reads a 

3370 # CRLF pair as one line break. TeX also ends a line at a lone CR, 

3371 # which _BLOCK_END does not see, so "\r\r" could hide a blank line 

3372 # (a paragraph break) inside a span; a lone CR stays literal. 

3373 if not body.startswith("\n", position + 1): 

3374 return False 

3375 position += 1 

3376 continue 

3377 if character not in _SAFE_MATH_CHARACTERS: 

3378 return False 

3379 # TeX turns ^^5c (and longer ^^ encodings) into a backslash before 

3380 # it recognizes control sequences. A character-only allowlist would 

3381 # otherwise accept an encoded \input with no literal backslash. 

3382 if character == "^" and body.startswith("^^", position): 

3383 return False 

3384 if character == "{": 

3385 depth += 1 

3386 elif character == "}": 

3387 depth -= 1 

3388 if depth < 0: 3388 ↛ 3389line 3388 didn't jump to line 3389 because the condition on line 3388 was never true

3389 return False 

3390 position += 1 

3391 return depth == 0 

3392 

3393 

3394# Pandoc's markdown reader treats a backslash before any ASCII punctuation 

3395# character as that literal character (the ``all_symbols_escapable`` 

3396# extension, on by default), so escaping every punctuation character in a 

3397# span renders it as text: a ``$`` no longer shifts into math 

3398# (``tex_math_dollars`` needs an unescaped ``$``) and the backslash of a 

3399# command such as ``\input`` becomes a literal backslash, not a control 

3400# sequence, even while ``raw_tex`` is on. Letters and digits are left as 

3401# they are; only the characters that could carry meaning are neutralised. 

3402_MARKDOWN_PUNCTUATION_RE = re.compile(r"[!-/:-@\[-`{-~]") 

3403 

3404 

3405def _escape_markdown_math_span(segment: str) -> str: 

3406 r"""Render a rejected ``$...$``/``$$...$$`` span as inert Markdown text. 

3407 

3408 Backslash-escapes every ASCII punctuation character, so Pandoc reads the 

3409 span as literal characters rather than math or raw TeX. 

3410 """ 

3411 return _MARKDOWN_PUNCTUATION_RE.sub(r"\\\g<0>", segment) 

3412 

3413 

3414def _defuse_report_math_for_quarto(content: str) -> str: 

3415 r"""Escape every unsafe math span in a Quarto report body. 

3416 

3417 The Quarto front matter declares a ``pdf`` format, so ``quarto render`` 

3418 hands each ``$...$``/``$$...$$`` span to Pandoc's LaTeX writer, which 

3419 emits math verbatim. A span is kept as math only when 

3420 :func:`_math_span_is_safe` accepts it — the same allowlist the LaTeX 

3421 export applies — and every other span is escaped to literal text so it 

3422 never reaches the TeX engine. Prose between spans is returned untouched 

3423 for the citation and cell passes that run after it. 

3424 """ 

3425 parts: list[str] = [] 

3426 for segment, is_math in _split_math_segments(content): 

3427 if is_math and not _math_span_is_safe(segment): 

3428 parts.append(_escape_markdown_math_span(segment)) 

3429 else: 

3430 parts.append(segment) 

3431 return "".join(parts) 

3432 

3433 

3434# The start of a citation link as CitationFormatter writes it: the label 

3435# in double brackets, then the destination. Every hyperlinked mode uses this 

3436# shape; only the label differs ([[1]], [[arxiv.org]], [[arxiv.org-2]], 

3437# [[doi-1]]). 

3438_CITATION_LINK_START_RE = re.compile(r"\[\[([^\[\]\n]+)\]\]\(") 

3439_CITATION_GROUP_LABEL_RE = re.compile(r"\s*\d+(?:\s*,\s*\d+)+\s*") 

3440 

3441 

3442def _link_destination_ends(text: str) -> array: 

3443 """For every index, where a link destination starting there ends. 

3444 

3445 ``_safe_link_destination`` percent-encodes spaces and any unbalanced 

3446 parenthesis, so a destination it wrote ends at the first ``)`` that 

3447 closes no ``(`` of its own, as in ``https://en.wikipedia.org/wiki/A_(b)``. 

3448 ``ends[i]`` is the index of that ``)`` for a destination starting at 

3449 ``i``, or -1 when whitespace or the end of the text comes first. 

3450 

3451 One pass from the right answers every start at once. Scanning forward 

3452 from each ``[[a]](`` instead costs quadratic time on a text full of 

3453 destinations that never close: 20 000 of them took about 40 seconds. 

3454 """ 

3455 ends = array("q", [-1]) * (len(text) + 1) 

3456 for position in range(len(text) - 1, -1, -1): 

3457 character = text[position] 

3458 if character == ")": 

3459 ends[position] = position 

3460 elif character == "(": 

3461 # This parenthesis is closed where a destination starting just 

3462 # after it would end; the destination then carries on from there. 

3463 inner = ends[position + 1] 

3464 ends[position] = -1 if inner == -1 else ends[inner + 1] 

3465 elif not character.isspace(): 

3466 ends[position] = ends[position + 1] 

3467 return ends 

3468 

3469 

3470class LaTeXExporter: 

3471 """Export markdown documents to LaTeX format.""" 

3472 

3473 def __init__(self): 

3474 # Also match Unicode lenticular brackets 【】 (U+3010 and U+3011) that LLMs sometimes generate 

3475 self.citation_pattern = re.compile(r"[\[【](\d+)[\]】]") 

3476 self.comma_citation_pattern = re.compile( 

3477 r"[\[【](\d+(?:\s*,\s*\d+)+)[\]】]" # (#6944) space allowed BEFORE the comma too — a hand-edited `[[2 ,3]]` must stay a group 

3478 ) 

3479 self.heading_patterns = [ 

3480 (re.compile(r"^# (.+)$", re.MULTILINE), r"\\section{\1}"), 

3481 (re.compile(r"^## (.+)$", re.MULTILINE), r"\\subsection{\1}"), 

3482 (re.compile(r"^### (.+)$", re.MULTILINE), r"\\subsubsection{\1}"), 

3483 ] 

3484 self.emphasis_patterns = [ 

3485 (re.compile(r"\*\*(.+?)\*\*"), r"\\textbf{\1}"), 

3486 (re.compile(r"\*(.+?)\*"), r"\\textit{\1}"), 

3487 (re.compile(r"`(.+?)`"), r"\\texttt{\1}"), 

3488 ] 

3489 

3490 def export_to_latex(self, content: str) -> str: 

3491 """ 

3492 Convert markdown document to LaTeX format. 

3493 

3494 Args: 

3495 content: Markdown content 

3496 

3497 Returns: 

3498 LaTeX formatted content 

3499 """ 

3500 latex_content = self._create_latex_header() 

3501 

3502 # Convert markdown to LaTeX. A saved report was citation-formatted 

3503 # before it was stored, so its citations arrive as links. 

3504 entries = self._sources_entries(content) 

3505 body_content = self._unlink_citations(content, entries) 

3506 

3507 # Escape special LaTeX characters while preserving safe math. A 

3508 # left-to-right scanner pairs $$...$$ display spans and $...$ 

3509 # inline spans; accepted spans pass through verbatim and rejected 

3510 # spans are rendered as literal text. Every other dollar 

3511 # is prose. The previous whole-document split made ONE unpaired 

3512 # dollar (a price in prose) invert the parity and silently 

3513 # disable escaping for the rest of the report. 

3514 escaped_parts: list[str] = [] 

3515 at_line_start = True 

3516 for segment, is_math in _split_math_segments(body_content): 

3517 if is_math and _math_span_is_safe(segment): 

3518 escaped_parts.append(segment) 

3519 else: 

3520 lines = segment.split("\n") 

3521 for j, line in enumerate(lines): 

3522 # Escape every LaTeX-special character outside math 

3523 # mode. Markdown markers (*, `, [n]) are not 

3524 # LaTeX-special and survive for their own 

3525 # conversions below. 

3526 heading = ( 

3527 _HEADING_PREFIX_RE.match(line) 

3528 if j > 0 or at_line_start 

3529 else None 

3530 ) 

3531 if heading: 

3532 # Keep the '#' markers intact for the heading 

3533 # conversion; escape the text after them so 

3534 # \section{...} receives literal text only. Only 

3535 # a TRUE line start qualifies: a text segment 

3536 # that resumes mid-line after a math span 

3537 # ("$x$ # y") is prose, and its "#" is escaped. 

3538 lines[j] = heading.group(1) + _escape_latex_body_text( 

3539 heading.group(2) 

3540 ) 

3541 else: 

3542 lines[j] = _escape_latex_body_text(line) 

3543 escaped_parts.append("\n".join(lines)) 

3544 if segment: 

3545 at_line_start = segment.endswith("\n") 

3546 body_content = "".join(escaped_parts) 

3547 

3548 # Convert headings 

3549 for pattern, replacement in self.heading_patterns: 

3550 body_content = pattern.sub(replacement, body_content) 

3551 

3552 # Convert emphasis 

3553 for pattern, replacement in self.emphasis_patterns: 

3554 body_content = pattern.sub(replacement, body_content) 

3555 

3556 # Convert citations to LaTeX \cite{} format 

3557 # A group becomes one \\cite only when the Sources section lists every 

3558 # member, as CitationFormatter only links the members it can resolve; 

3559 # otherwise it is prose such as "versions [4, 5]" and is left as written. 

3560 def cite_group(match: re.Match[str]) -> str: 

3561 nums = [num.strip() for num in match.group(1).split(",")] 

3562 if all(num in entries for num in nums): 

3563 return "\\cite{" + ",".join(nums) + "}" 

3564 return match.group(0) 

3565 

3566 body_content = self.comma_citation_pattern.sub(cite_group, body_content) 

3567 body_content = self.citation_pattern.sub(r"\\cite{\1}", body_content) 

3568 

3569 # Convert lists 

3570 body_content = self._convert_lists(body_content) 

3571 

3572 # Add body content 

3573 latex_content += body_content 

3574 

3575 # Add bibliography section 

3576 latex_content += self._create_bibliography(content) 

3577 

3578 # Add footer 

3579 latex_content += self._create_latex_footer() 

3580 

3581 return latex_content 

3582 

3583 def _sources_entries(self, content: str) -> dict: 

3584 """The Sources entries, from the same slice the bibliography reads.""" 

3585 sources_start, _on_sentinel = find_sources_section(content) 

3586 if sources_start == -1: 

3587 return {} 

3588 return _collect_bibliography_entries(content[sources_start:]) 

3589 

3590 def _unlink_citations(self, content: str, entries: dict) -> str: 

3591 """Turn the formatter's ``[[label]](url)`` citations back into ``[N]``. 

3592 

3593 A number label is the citation index. A group label such as ``1, 2`` 

3594 names every member, which its one URL cannot, so it becomes ``[1, 2]`` 

3595 when the Sources list has every member. Any other label 

3596 (``arxiv.org``, ``arxiv.org-2``, ``doi-1``) says nothing reliable 

3597 about the index, so the link's destination is looked up among the 

3598 Sources entries instead; when two entries share a URL, the lower 

3599 index is used, since both name the same document. A link that 

3600 resolves to no entry is left as written. 

3601 """ 

3602 # Keyed on the destination the formatter would have written. Two URLs 

3603 # that differ only in how a character is spelled, such as `a(b` and 

3604 # `a%28b`, share a key, and the lower index keeps it. 

3605 index_by_destination: Dict[str, str] = {} 

3606 for index in sorted(entries, key=_sort_key): 

3607 destination = _safe_link_destination(entries[index][1]) 

3608 if destination: 

3609 index_by_destination.setdefault(destination, index) 

3610 

3611 parts = [] 

3612 position = 0 

3613 ends = None 

3614 for match in _CITATION_LINK_START_RE.finditer(content): 

3615 if match.start() < position: 

3616 continue 

3617 if ends is None: 

3618 ends = _link_destination_ends(content) 

3619 end = ends[match.end()] 

3620 if end == -1: 

3621 continue 

3622 label = match.group(1) 

3623 if label.isdecimal(): 

3624 index = label 

3625 elif _CITATION_GROUP_LABEL_RE.fullmatch(label): 

3626 members = [member.strip() for member in label.split(",")] 

3627 if not all(member in entries for member in members): 

3628 continue 

3629 index = label.strip() 

3630 else: 

3631 index = index_by_destination.get(content[match.end() : end]) 

3632 if index is None: 

3633 continue 

3634 parts.append(content[position : match.start()]) 

3635 parts.append(f"[{index}]") 

3636 position = end + 1 

3637 parts.append(content[position:]) 

3638 return "".join(parts) 

3639 

3640 def _create_latex_header(self) -> str: 

3641 """Create LaTeX document header.""" 

3642 return r"""\documentclass[12pt]{article} 

3643\usepackage[utf8]{inputenc} 

3644\usepackage{hyperref} 

3645\usepackage{cite} 

3646\usepackage{url} 

3647 

3648\title{Research Report} 

3649\date{\today} 

3650 

3651\begin{document} 

3652\maketitle 

3653 

3654""" 

3655 

3656 def _create_latex_footer(self) -> str: 

3657 """Create LaTeX document footer.""" 

3658 return "\n\\end{document}\n" 

3659 

3660 def _escape_latex(self, text: str) -> str: 

3661 """Escape special LaTeX characters in text.""" 

3662 return _escape_latex_text(text) 

3663 

3664 def _convert_lists(self, content: str) -> str: 

3665 """Convert markdown lists to LaTeX format.""" 

3666 # Simple conversion for bullet points 

3667 content = re.sub(r"^- (.+)$", r"\\item \1", content, flags=re.MULTILINE) 

3668 

3669 # Add itemize environment around list items 

3670 lines = content.split("\n") 

3671 result = [] 

3672 in_list = False 

3673 

3674 for line in lines: 

3675 if line.strip().startswith("\\item"): 

3676 if not in_list: 

3677 result.append("\\begin{itemize}") 

3678 in_list = True 

3679 result.append(line) 

3680 else: 

3681 if in_list and line.strip(): 

3682 result.append("\\end{itemize}") 

3683 in_list = False 

3684 result.append(line) 

3685 

3686 if in_list: 

3687 result.append("\\end{itemize}") 

3688 

3689 return "\n".join(result) 

3690 

3691 def _create_bibliography(self, content: str) -> str: 

3692 """Extract sources and create LaTeX bibliography.""" 

3693 sources_start, _on_sentinel = find_sources_section(content) 

3694 if sources_start == -1: 

3695 return "" 

3696 

3697 sources_content = content[sources_start:] 

3698 entries = _collect_bibliography_entries(sources_content) 

3699 

3700 # The argument is the widest label, which sets the hanging indent. It 

3701 # is never narrower than the two digits it has always been. 

3702 widest = "9" * max([2, *(len(key) for key in entries)]) 

3703 bibliography = f"\n\\begin{{thebibliography}}{{{widest}}}\n" 

3704 

3705 for citation_num in sorted(entries, key=_sort_key): 

3706 title, url = entries[citation_num] 

3707 escaped_title = self._escape_latex(title) 

3708 

3709 # thebibliography numbers entries by POSITION unless a label 

3710 # is given, so without one a gap in the index sequence makes 

3711 # \\cite{5} print [3]. The label is the index the report shows, 

3712 # as written: 007 stays 007, and a non-digit key such as a 7a 

3713 # sub-index would be printed as it is too. 

3714 item = f"\\bibitem[{citation_num}]{{{citation_num}}}" 

3715 safe_url = _safe_bibtex_url(url) 

3716 if safe_url: 

3717 bibliography += f"{item} {escaped_title}. \\url{{{safe_url}}}\n" 

3718 else: 

3719 bibliography += f"{item} {escaped_title}.\n" 

3720 

3721 bibliography += "\\end{thebibliography}\n" 

3722 

3723 return bibliography