fix(pdf): render shaped Arabic reports without clipping across pages

Signed-off-by: Haozhe Wu <haozhe_wu@connect.hku.hk>
This commit is contained in:
Haozhe Wu committed 2026-10-04 03:09:56 +08:00
1 parent 8ec3b80f10
commit 2ce2165160
6 files changed
+574 -4

No files matched your search

+3
View File
@@ -70,3 +70,6 @@ nh3>=0.2.18
weasyprint>=60.0
# Pure-Python PDF fallback where weasyprint's system pango/cairo libs are missing
reportlab>=4.0
# Portable Arabic shaping and bidirectional layout for ReportLab fallback
arabic-reshaper>=3.0.1
python-bidi>=0.6.11
+167 -4
View File
@@ -5,10 +5,173 @@ from __future__ import annotations
import os
import re
import tempfile
import unicodedata
from functools import lru_cache
from itertools import groupby
from pathlib import Path
from xml.sax.saxutils import escape
@lru_cache(maxsize=1)
def _arabic_font() -> str:
"""Register the Arabic-capable font already shipped by Matplotlib."""
import matplotlib
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
family = "VibeReportArabic"
path = Path(matplotlib.get_data_path()) / "fonts" / "ttf" / "DejaVuSans.ttf"
pdfmetrics.registerFont(TTFont(family, str(path)))
pdfmetrics.registerFontFamily(family, normal=family, bold=family, italic=family, boldItalic=family)
return family
@lru_cache(maxsize=1)
def _reshaper():
"""Keep Arabic vowel marks when shaping for visual RTL order."""
import arabic_reshaper
return arabic_reshaper.ArabicReshaper(configuration={
"delete_harakat": False, "shift_harakat_position": True,
})
def _visual_line(text: str, direction: str) -> str:
"""Shape Arabic and apply the Unicode bidi algorithm to one logical line."""
from bidi.algorithm import get_display
return get_display(_reshaper().reshape(text), base_dir=direction)
def _font_runs(text: str, cjk: str, arabic: str) -> list[tuple[str, str]]:
"""Select embedded fonts by real glyph coverage; never emit missing boxes."""
from reportlab.pdfbase import pdfmetrics
coverage = {family: pdfmetrics.getFont(family).face.charToGlyph for family in (arabic, cjk)}
def select(char: str) -> str:
for family, cmap in coverage.items():
if cmap.get(ord(char), 0):
return family
raise ValueError(f"PDF fonts do not cover U+{ord(char):04X}")
return [(family, "".join(chars)) for family, chars in groupby(text, key=select)]
def _rtl_lines(text: str, width: float, size: float, cjk: str, arabic: str) -> tuple[list[tuple[str, str]], str]:
"""Wrap logical text before shaping/reordering each visual line.
Reordering the whole paragraph before wrapping reverses its line order.
Widths use the actual shaped glyphs and the font selected for each run.
"""
from bidi.algorithm import get_base_level
from reportlab.pdfbase.pdfmetrics import stringWidth
direction = "R" if get_base_level(text) else "L"
def fits(value: str) -> bool:
visual = _visual_line(value, direction)
return sum(stringWidth(run, family, size) for family, run in _font_runs(visual, cjk, arabic)) <= width
lines: list[str] = []
current = ""
for word in text.split():
candidate = f"{current} {word}" if current else word
if fits(candidate):
current = candidate
continue
if current:
lines.append(current)
current = ""
if fits(word):
current = word
continue
# A long URL or CJK run has no word separator. Split in logical order,
# keeping combining marks attached to their base character.
clusters: list[str] = []
for char in word:
if clusters and unicodedata.combining(char):
clusters[-1] += char
else:
clusters.append(char)
for cluster in clusters:
if current and not fits(current + cluster):
lines.append(current)
current = ""
if not fits(cluster):
raise ValueError("A PDF glyph is wider than the report column")
current += cluster
if current:
lines.append(current)
return [(line, _visual_line(line, direction)) for line in lines], direction
def _paragraph(markup: str, style, cjk: str):
"""Keep the standard layout for LTR/CJK and reorder RTL after line breaks."""
from reportlab.platypus import Flowable, Paragraph
logical = Paragraph(markup, style)
text = logical.getPlainText()
if not any(unicodedata.bidirectional(char) in {"R", "AL"} for char in text):
return logical
class BidirectionalParagraph(Flowable):
def __init__(self, prepared=None, direction=None):
super().__init__()
self.style = style
self.lines = prepared
self.direction = direction
def wrap(self, availWidth, availHeight):
if self.lines is None:
self.lines, self.direction = _rtl_lines(
text, max(1, availWidth - style.leftIndent - style.rightIndent),
style.fontSize, cjk, _arabic_font(),
)
self.width = availWidth
self.height = len(self.lines) * style.leading
return self.width, self.height
def split(self, availWidth, availHeight):
self.wrap(availWidth, availHeight)
count = int(availHeight // style.leading)
if count <= 0:
return []
first = BidirectionalParagraph(self.lines[:count], self.direction)
rest = BidirectionalParagraph(self.lines[count:], self.direction)
first.spaceAfter = 0
rest.spaceBefore = 0
return [first, rest] if rest.lines else [first]
def draw(self):
from reportlab.pdfbase.pdfmetrics import stringWidth
canvas = self.canv
canvas.saveState()
if style.backColor:
canvas.setFillColor(style.backColor)
canvas.rect(0, 0, self.width, self.height, stroke=0, fill=1)
canvas.setFillColor(style.textColor)
for index, (logical_line, visual) in enumerate(self.lines):
runs = _font_runs(visual, cjk, _arabic_font())
width = sum(stringWidth(run, family, style.fontSize) for family, run in runs)
x = self.width - style.rightIndent - width if self.direction == "R" else style.leftIndent
y = self.height - (index + 1) * style.leading + max(0, (style.leading - style.fontSize) / 2)
# PDF ActualText supplies canonical logical text for copy/search;
# glyph positions remain the shaped visual order. Hex UTF-16BE
# keeps user text out of PDF operator syntax.
actual = ("\ufeff" + logical_line).encode("utf-16-be").hex()
canvas._code.append(f"/Span << /ActualText <{actual}> >> BDC")
for family, run in runs:
canvas.setFont(family, style.fontSize)
canvas.drawString(x, y, run)
x += stringWidth(run, family, style.fontSize)
canvas._code.append("EMC")
canvas.restoreState()
return BidirectionalParagraph()
def render_markdown_pdf(content: str, target: Path) -> None:
"""Render a paginated report atomically, retaining an old file on failure.
@@ -54,7 +217,7 @@ def render_markdown_pdf(content: str, target: Path) -> None:
in_code = not in_code
continue
if in_code:
story.append(Paragraph(escape(lines[position - 1]).replace(" ", "&#160;"), code))
story.append(_paragraph(escape(lines[position - 1]).replace(" ", "&#160;"), code, family))
continue
if not line:
continue
@@ -65,7 +228,7 @@ def render_markdown_pdf(content: str, target: Path) -> None:
rows.append([part.strip() for part in lines[position].strip().strip("|").split("|")])
position += 1
columns = max(map(len, rows))
table = Table([[Paragraph(inline(value), cell) for value in row + [""] * (columns - len(row))]
table = Table([[_paragraph(inline(value), cell, family) for value in row + [""] * (columns - len(row))]
for row in rows], colWidths=[width / columns] * columns, repeatRows=1,
splitInRow=1)
table.setStyle(TableStyle([
@@ -79,11 +242,11 @@ def render_markdown_pdf(content: str, target: Path) -> None:
continue
heading = re.match(r"^(#{1,4})\s+(.+)", line)
if heading:
story.append(Paragraph(inline(heading[2]), headings[len(heading[1])]))
story.append(_paragraph(inline(heading[2]), headings[len(heading[1])], family))
else:
line = re.sub(r"^[-*+]\s+", "• ", line)
line = re.sub(r"^>\s?", "", line)
story.append(Paragraph(inline(line), body))
story.append(_paragraph(inline(line), body, family))
if not story:
story.append(Paragraph("&#160;", body))
+140
View File
@@ -0,0 +1,140 @@
"""Portable Arabic report rendering through real fonts, layout, and PDF bytes."""
from __future__ import annotations
import unicodedata
import base64
import re
import zlib
import pypdfium2 as pdfium
import pytest
from reportlab.pdfbase import pdfmetrics
from src.shadow_account.pdf_fallback import _ensure_font
from src.tools import pdf_report
def _text(path):
with pdfium.PdfDocument(path) as document:
return "\n".join(document[index].get_textpage().get_text_range() for index in range(len(document)))
def test_arabic_shapes_and_preserves_latin_numbers_and_diacritics():
logical = "تقرير مُهِمّ: AAPL 123.45 USD و BTC-USDT 67,890.12"
visual = pdf_report._visual_line(logical, "R")
assert "AAPL 123.45 USD" in visual
assert "BTC-USDT 67,890.12" in visual
assert any(0xFE70 <= ord(char) <= 0xFEFF for char in visual)
assert {char for char in logical if unicodedata.combining(char)} <= set(visual)
assert visual != logical and visual != logical[::-1]
def test_every_mixed_script_glyph_has_a_real_embedded_font():
cjk, arabic = _ensure_font(), pdf_report._arabic_font()
assert pdfmetrics.getFont(cjk).face.charToGlyph.get(ord("ت"), 0) == 0
visual = pdf_report._visual_line("تقرير عربي 中国股票 AAPL 123.45 ١٢٣", "R")
runs = pdf_report._font_runs(visual, cjk, arabic)
assert {font for font, _ in runs} == {cjk, arabic}
for font, text in runs:
cmap = pdfmetrics.getFont(font).face.charToGlyph
assert all(cmap.get(ord(char), 0) > 0 for char in text)
def test_wrapping_keeps_logical_paragraph_order_and_column_width():
from bidi.algorithm import get_display
cjk, arabic = _ensure_font(), pdf_report._arabic_font()
source = "البداية " + "تقرير الأسواق العربية والمخاطر " * 8 + "النهاية"
lines, direction = pdf_report._rtl_lines(source, 140, 10, cjk, arabic)
assert direction == "R" and len(lines) > 3
restored = [unicodedata.normalize("NFKC", get_display(visual, base_dir="R")) for _, visual in lines]
assert " ".join(restored) == source
assert restored[0].startswith("البداية")
assert restored[-1].endswith("النهاية")
for _, line in lines:
width = sum(pdfmetrics.stringWidth(text, font, 10) for font, text in pdf_report._font_runs(line, cjk, arabic))
assert width <= 140
def test_real_mixed_arabic_markdown_table_pdf(tmp_path):
target = tmp_path / "report.pdf"
pdf_report.render_markdown_pdf(
"# تقرير الأسواق\n\nتحليل AAPL 123.45 USD و ١٢٣\n\n"
"| السوق | السعر | Notes |\n| --- | --- | --- |\n"
"| الصين 中国 | 600519.SH | تقرير عربي |\n"
"| AAPL | 123.45 USD | 中文报告 |\n\n"
"## English / 中文\n\nA **bold** report with < & > and [source](https://example.com).\n"
"```\nprice = 123.45\n```\n",
target,
)
data = target.read_bytes()
assert data.startswith(b"%PDF-") and data.rstrip().endswith(b"%%EOF")
assert data.count(b"/FontFile2") >= 2 and b"/ToUnicode" in data
text = _text(target)
for value in ["AAPL", "123.45 USD", "600519", "SH", "中国", "中文报告", "English", "price = 123.45"]:
assert value in text
assert "\x00" not in text and "\ufffd" not in text
# PDFium applies its own bidi heuristic across an entire table row; it
# may reorder 600519.SH into SH600519. ActualText is checked independently.
decoded = []
for stream in re.findall(rb"stream\r?\n(.*?)endstream", data, re.S):
try:
decoded.append(zlib.decompress(base64.a85decode(stream.strip(), adobe=True)))
except (ValueError, zlib.error):
continue
canonical = ("\ufeff" + "تحليل AAPL 123.45 USD و ١٢٣").encode("utf-16-be").hex().encode()
assert b"/ActualText <" + canonical + b">" in b"\n".join(decoded)
normalized = unicodedata.normalize("NFKC", text)
assert any(unicodedata.bidirectional(char) == "AL" for char in normalized)
assert not list(tmp_path.glob(".report-*.pdf"))
def test_arabic_paragraph_and_tall_table_cell_paginate(tmp_path):
target = tmp_path / "long.pdf"
text = "البداية " + "تقرير الأسواق العربية AAPL 123.45 والمخاطر. " * 160 + " النهاية"
pdf_report.render_markdown_pdf(
"# تقرير طويل\n\n" + text + "\n\n| الوصف | القيمة |\n| --- | --- |\n| " + text + " | 123.45 |\n",
target,
)
with pdfium.PdfDocument(target) as document:
assert len(document) >= 3
extracted = _text(target)
assert extracted.count("123.45") == 321
@pytest.mark.parametrize("failure", ["missing_glyph", "replace"])
def test_arabic_render_failure_retains_old_target_and_cleans_temporary(tmp_path, monkeypatch, failure):
target = tmp_path / "report.pdf"
target.write_bytes(b"previous PDF")
content = "تقرير عربي"
if failure == "missing_glyph":
content += " \U0001fae0" # Absent from both packaged report fonts.
else:
def denied(*args):
raise OSError("replace denied")
monkeypatch.setattr(pdf_report.os, "replace", denied)
with pytest.raises((ValueError, OSError)):
pdf_report.render_markdown_pdf(content, target)
assert target.read_bytes() == b"previous PDF"
assert not list(tmp_path.glob(".report-*.pdf"))
def test_tall_mixed_table_keeps_every_glyph_on_its_page(tmp_path):
target = tmp_path / "mixed-pages.pdf"
content = ("هذا تقرير للأسواق مع AAPL 123.45 USD و 中文报告 والعائد +5.25%. " * 90)
content += "\n\n| وصف | قيمة |\n| --- | --- |\n| "
content += "تقرير طويل 中文 123.45 USD. " * 160
content += " | 123.45 |\n"
pdf_report.render_markdown_pdf(content, target)
with pdfium.PdfDocument(target) as document:
assert len(document) >= 3
for page_number, page in enumerate(document):
width, height = page.get_size()
text = page.get_textpage()
for index in range(text.count_chars()):
left, bottom, right, top = text.get_charbox(index)
assert left >= -1 and bottom >= -1 and right <= width + 1 and top <= height + 1, (
page_number, index, (left, bottom, right, top), (width, height)
)
@@ -172,6 +172,10 @@ anyio==4.14.2 \
# sse-starlette
# starlette
# watchfiles
arabic-reshaper==3.0.1 \
--hash=sha256:41c5adc2420f85758eada7e880251c4b6a2adbd83377bd27e5d4eba71f648bc7 \
--hash=sha256:a0d9b2a9fa29b5f2c1d705f407adf6ca4242405b9cac0e5cc09e6c4f3f8fb68c
# via -r agent/requirements.txt
attrs==26.1.0 \
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
@@ -2921,6 +2925,133 @@ pyphen==0.17.2 \
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
# via weasyprint
python-bidi==0.6.11 \
--hash=sha256:034090c597af250d699299d7e7f1e83eb016f9e47b3b707bd89ab2bdec77bce0 \
--hash=sha256:0608bddcc1c53dfa5293499de13ca9935b31aa46d1c722c404a88c703d1a4e47 \
--hash=sha256:0d339c4ebf3c2501d7569971a22e762fb45d8af7e19a0573afe260d69b35fb0d \
--hash=sha256:0d496f9fc21b7457e12395e54088ab99776966c663b4cd4a74770c7a6418ab59 \
--hash=sha256:19bae96ff1ee76b3a7dc962598b69426538e3021460cf85a4017437548ab6947 \
--hash=sha256:19d13c70b1f7bb5dc69866a11624f3b78123807b4ede18f4f99f656ed86babec \
--hash=sha256:1a639a2c62f825e72eb42901fd1c69296d09c192ae003195e6cf773f92d6bb42 \
--hash=sha256:1ad5f712a32be30d28eb96e119e85818747a43cea253de6c3160b464b62a5619 \
--hash=sha256:1b41cc6bc9ad78a12da5f987da15e931c771f18ceca58f2fe8ed50f253490a97 \
--hash=sha256:1ffd728f1f7866ff7399906bbc17ef5cf010b90ce56a1e94937b28a1a4cf5a7d \
--hash=sha256:20ab47a4098577fc9a82816c330c89ff597c31ba69c98bc6a1b6a5737b03de05 \
--hash=sha256:239a00b2adb5f897d11d7b7a491d759fb9883e71a71cfd90c4147a733b4df4d3 \
--hash=sha256:2b208fc26ab2c7adfd2c4d84753a5045f664e0bec19ca462af2406132e62f92e \
--hash=sha256:2d6970b09f5a3102c0aa192f5258c585742ab4ebd94f637a635ad3448ccba567 \
--hash=sha256:2ff75d1befc335cbe85f834e81554a024f94d9b5d1dd75a5bd99af81a1cb783c \
--hash=sha256:302f4ca7dabfe447e707d40e98520551181036c15750bdd1e73292ad8b3d8e75 \
--hash=sha256:30d543b5baf9fca5ef5ec95647aa07c5e38fc7fa0f18be0c61d1d6c0a1032c6c \
--hash=sha256:337ec315f8b286399e411f5bfc339fc2124db07757409b0595ceb46de087c4cd \
--hash=sha256:3611d13b53d4c899c4f2a7cd8eb897064e8b5546c3c5d1037dd6209c82858a27 \
--hash=sha256:36f23f12d9b1c56ed8d82e11f56c6cba7bc3f614ee73374bc7772bb2270e0966 \
--hash=sha256:380b70d615647646dbe06f7d95dc30b8fdc9b596dbfa1cd3814feb9805c0c8f2 \
--hash=sha256:397f7f289eba6ce25d99dbea99f873d699bf9aa030074e7fb746d8f93c2fb6f9 \
--hash=sha256:3bdb64ee0a74951465cd4a761e1029e73806ff43b2fe5be98643e52da5cabb66 \
--hash=sha256:3da9e536546f7c62c0da595c8f71a096e0b9a80e94cfd0f329b7b200ef81e7d5 \
--hash=sha256:41c7d1914c1f33954aa2ab0e2c309be5d1d4afc75ce524762eaab4dd825c5ab8 \
--hash=sha256:43f3e81bdd36f49171b7de6cf471086df503c29555f6f7f035ebe8f8ec1da779 \
--hash=sha256:490118f4af5a3535923c76be07e937ef1cb4cf84c489b37471aa95455fe923b3 \
--hash=sha256:493655043ebea6fec0edb76053bdad46f1f7352a759ca4deb733f13b3c09839c \
--hash=sha256:495a76c881d78ab87b57c5270679c9bc3c1de36d8c6596d5e3a5a1b5f9c57471 \
--hash=sha256:4ac819cac1abb15486c48af3399a5c726e89f0977a3aff205ac162533186e756 \
--hash=sha256:4ccf1fe9ecb3b02a1a11b103cd2557e2653d82c141b6e2dccec8177e6af5c4bb \
--hash=sha256:4d00757ef7bbbf14d8628f9bdb6b0e168d5e7b03fec20da3226624f11bffce89 \
--hash=sha256:51915502898c45e9cb36636e974aca068fc5cdb9f06b794f49abea5b12f02016 \
--hash=sha256:519eb90bedb04b5cbba0bfc8062984a5ba054a019d81ada34053dc67397d61cb \
--hash=sha256:539d99efe02f4981171ed57bbc085094ef780405ca14663550a56c6c3e265c34 \
--hash=sha256:555cdf9303c40bae1ab512ca427f1f0316a574bc0a48db22eec76ec0fd1213cf \
--hash=sha256:55f27bd2cdeca96f46448a741d393575fbedfc70e38e09dbb030ed98dba8cf2a \
--hash=sha256:55fa5b22cc8c220a6daf077570a8e29d3b607bd978bd3dbc04b445d01a17de57 \
--hash=sha256:565d819fddb2bbc58c42ca5c97d73da7567f181115011e8f04c76b9d08378dcd \
--hash=sha256:56807e0dde88d5ab96880c9d668bda7bca1df83cd30d20cbff5d6b6e6c1e9276 \
--hash=sha256:56dfa3c1c13cb89da39a4e8f2f9442e4291de01877511a13f830125d36e9ce5b \
--hash=sha256:56f27c1edfd15c12c9c348378ccd79166930d720cf316b1181a0a0ade2146253 \
--hash=sha256:57aa56b1eca5f63ebdd25fe8fad02eab7797fc45ad1efc5eed2a830e2bd2038d \
--hash=sha256:5dd49c11ae87ee6ced68594faa78b34a89a1a3c43647fa6157765f9726f9daf3 \
--hash=sha256:5f3e1743b2d43377c4da5d687a03430a27f5769e158a9f75a50b05e7e82f4d21 \
--hash=sha256:5f9ed312d445a691c0afec995ca4f726abe357d8da500e389f77fb874fa7ef76 \
--hash=sha256:60150bffbe43bfc1e356c0c2900ba643a02ce09c37bc0c553c4d83e5c3de63ad \
--hash=sha256:60d9bf6c60c022657637f64897e63dc3f5b1c07cb7f0e1ead6150aff5150c5ab \
--hash=sha256:619ee3fe03daec8d3ce12239f0c22455676a063b2bcde361caecd788fae5b8d5 \
--hash=sha256:62a6b700f3d7a2c4d52a5dccca765711a2414e734360b00365e155698a26e461 \
--hash=sha256:63d9dcc714d549a5118ebc93b7a7c903a0c1feec8e57f5fcacf98d984b76325b \
--hash=sha256:6623683fe39b9fbf508e3069f17e8e9cab26143f9d9f89c8a8f45424c052df4f \
--hash=sha256:68c3e570f2908ac39db0b269f5648e257fab5204344b9bf6dae80852ae4cbe1c \
--hash=sha256:69d1f4ee17644e8aeac93a7238ee2f28d79b0180815441eb511b77e6585aa971 \
--hash=sha256:6c92d1cad16f9ec2f2a3ae439a0bc3a8e4189ec227987bed03d5b4056d5eb9c5 \
--hash=sha256:6ca92a4e460f7e25e434a6d7982d94a4765ca242f527995da70abb5a32003b8a \
--hash=sha256:6d9ef69c108b31f38e1f281a55fdedad7774bc1e952a45c8c14a18e891eee397 \
--hash=sha256:6dc112e2239f69913273cbb0c050ca816b145b32037d4266c430159c5ddcdab2 \
--hash=sha256:721697187f4da67dafc26f63488f463fa35ff2de8668d959fda773cccdbef0eb \
--hash=sha256:73c38c604bfc01647c69ce38e5cf8b4206e2ede91ca3eb8e5d79b7409f17e0b3 \
--hash=sha256:74457b43db34f984252e915828b5d1a4042a771f44e853a5643506d01562eccb \
--hash=sha256:7738cfc7ee9fcdff3fef76b40007982ce7704010a51df307c4a51d378f5ff1ba \
--hash=sha256:777fb44a6f1e91d6adde36e815024c210b0208f35d00762880257b8fb04dc849 \
--hash=sha256:788c11c84b520ea973992cc95751d56b783ff5857df504c1347d99cacd2fcfe7 \
--hash=sha256:79df1099a08e53edb678236d4d76d8de4e3901bafc84ce1788b71f9b96547325 \
--hash=sha256:7c4dc0d1328321aeb08054be654e1d39f171d5aae8dca931c9332860f18f57d7 \
--hash=sha256:7cb34dd8d22ca1847653c0e1cbba8fd96de09c24b8c83b7805eab270c81e863a \
--hash=sha256:8013c1e6267a929be98d10c3a617171709c8226bd33c6180ec65e560c35b6df8 \
--hash=sha256:80cfe97e2d65981be877ae5bd2338c6919a7cc1171fc308c8b8c7c306ba6ffd8 \
--hash=sha256:83c780f7e4c3dd3f020db75dc425567981e65c6d5571b3c0372203d0df92c834 \
--hash=sha256:83ee87feb5eafc0442e1db0014dad20d52a2a7a140b6cddc8f7bc65918f0a7b4 \
--hash=sha256:879c3fba3e7511c7d01020449d970ca7d6a593f20cfc47c014d3d229a38930b2 \
--hash=sha256:8b6b7fce8f47578be9aebf5a0b6b2d6c157b4e97af7586ecf13bfca5d128deea \
--hash=sha256:8eb09af209cd660fa9689f6ce9e61e73c8afa4829ae61801deea7f6e32263800 \
--hash=sha256:8fbb6d222b50324fb9d49b6ff0f8566fa97b907a68c00e6622fcf34463104f4a \
--hash=sha256:9435acc52438c3c8f5142b9a17a622927618e80a32eed707343bc375cb51cebe \
--hash=sha256:946b7dbec4e64017680f1a66b3a8534659d889018ac83bd2abf958278e6f62b0 \
--hash=sha256:94f7f66bd7680d3f2085adaeea91c01b3281a79bb5041db1a51cbe8af36a9d88 \
--hash=sha256:959335cd3814cb767fb832c5c71cbc838ccd9231a812ef2cb43092a216a91d5b \
--hash=sha256:969ee7db3e169fcc0b2d2d094826e03cc5798dfd6b3571a340ea883672396cb1 \
--hash=sha256:9af2b5c26a3eb960699dff040535a86dc2c0f708087b2d63bcfd6452fe9d0664 \
--hash=sha256:9d18916c97855d68515ca2b3581948b6d864c304cd386210ab5aff5e0fb9be2c \
--hash=sha256:a1115f3f02eb836b39e0c986a6b9e92c4377a1e5a680409650b02ec6b1a795e6 \
--hash=sha256:a1b5ee069001bf7f4fff109598a9396caa96bb35e1b906b2c6d1bab9f9b2c4bd \
--hash=sha256:a4f4a0cd24c09df748bfdc207b089c00e7af19f3151063f4cd74ac658290186b \
--hash=sha256:a52f7ad9ef9091e81869e5d255e796755ccf542ade14dda17647cb7d7ffe1b9c \
--hash=sha256:a873e6f8ab28b5a56201d5cdb98f4c7d2ff88e636a58659c5ac271e287001658 \
--hash=sha256:a9aa2890661b238730e680ccd6eb06f4da625b2dbc1730052dae6f2d88957192 \
--hash=sha256:ab8af1cc29c3f400a0f781d50f8ab52a8c63adf2fcef16f14641cd97e06a80e0 \
--hash=sha256:acccb6d90e694684db2d314db2e2b0d3b8949bf1cfaec6d9808a8889f548add7 \
--hash=sha256:ad5d9b8e8a6c330208eba413db506de58f21dbf88a1f1d5d75ef5f9e0e714adf \
--hash=sha256:af7711cc6eeeadcb1aae877e0736a68e111c73a29562da6106c5e2fbb4dd83b2 \
--hash=sha256:b2c759e13ebb81edaac3041697328bb1ca8433b55281723879f6b54e74881240 \
--hash=sha256:b433840a924c8788f0abbda15d22c71dc636e2078d7d3ba39369cebe4bef74b8 \
--hash=sha256:bde89739a979d9eb3ac48c4882c8a42dd528a7708b0faa99b90b551588bd5f8d \
--hash=sha256:c17393753ddb77377f754ba2a88cacfe3056040a8a5dff8d43f9d5e51bbe1edc \
--hash=sha256:c26cd9d81f820159026b1c99905ca12bf13892e3f6a9303359fef42fe6f39e50 \
--hash=sha256:c6aca20472d7040bc1f0aadb510cb20d40b3d6caa4c8bf82d075ba2650a41652 \
--hash=sha256:c6b3c853f99172ef22e5a16c8114cf243e351c8e70f72a894164088c2c99d9cb \
--hash=sha256:caee7ee3662eab1411b44fef8571b87273cb5235061d7463ebd10e412ac07986 \
--hash=sha256:cd564b8c583eba2d230d02d0467fd840045f08856b6524555cfff7f32af63c72 \
--hash=sha256:ce2ef68dce82ae4067cf28910224d77b45eb2c2b3904db6eb670d1cddb8d0eb2 \
--hash=sha256:cf4e88a6fec81b7155a487cbbea7753a3d9a76dc4d391b4f8958b37227ef2c12 \
--hash=sha256:d14f2d400c75e07d1154299463a3d4d14aa5565b05088b2d9b314ccd9fd6dc3a \
--hash=sha256:d52ec8ccdc2fd5c61749d876a9d1eb0ea6543c1676722e1e3fb9d7800852131c \
--hash=sha256:d5695d87969fed5b3799b8a98848cb04fe2873fded46efe9f3f2f341efd1b829 \
--hash=sha256:d7291e13496cb74fc1b71f7f1e3628586afefa531102bb4fa7af9c2d543efc3c \
--hash=sha256:d73a821873c52635321196cfb8d3a231d7917ca284cf2bcd9422f6deb19db7ca \
--hash=sha256:da51a3a2940478219f19249fcf7cd45e8ddc197982be22efa43c4763e9d2eb57 \
--hash=sha256:dc69d594682fbedec7d0cc81cd4d5f14fb40b6b365587238fafb91509cfea020 \
--hash=sha256:dfbb9ba8343a60daf4ced67c11d551dafe9a3c94892c326e4c216fa2e6eca802 \
--hash=sha256:e142820537e08d567c2c017e26df05f88856a03f0f3948209cfcdc39617df363 \
--hash=sha256:e4ebc24ac38e50676f65daf7ba6c568789660cb60d6dcf2606d4310dba826721 \
--hash=sha256:e6c474618a7b6a50c10007f8a9edb50def6d98d297a07a34dc2fb82c344f2b8b \
--hash=sha256:f0ee4e80dd3b2a46868dcc73e00aa59f99c0e1de54a2ba783faf0fddba2ccce2 \
--hash=sha256:f237ebb570fd8bbe479b6967374d82b7f0b26f9452c276bdd5f793d83e7062bd \
--hash=sha256:f400a30573774a1e90c0d50d43c35d9c004afcf53de801bbfd259f84ea80f31c \
--hash=sha256:f563d20481f7d316adf605bb94d5b7182acecdbc4d431d60473e9b1d526d0210 \
--hash=sha256:f7a8429d0d65232e314b4f494825c1205abcc3039f42bf7da80424a15b731709 \
--hash=sha256:f8655ac559dcfce7179b150ebcc207982d4d60e67b3089b2032eed3a0a78c07a \
--hash=sha256:f99162d6c6c9522c46eb213f1bc932829c2602131676c92f081cb865b8ef6784 \
--hash=sha256:fa2848c3116d619870d114471fcd4a9f1aa43587a002111d7af1e6582f1b57e9 \
--hash=sha256:fc3b0a6e2460f68de9ab98f71e3098bb21bb984563417ad104dec7ab08ebcadc \
--hash=sha256:fccd1808bb427d6a6d34168461bef551faa93ce3dda489fcef9073bcd9b34a5e \
--hash=sha256:ff675d036823bff05a2e0f8cdb13f5414274ff517d13b0d57c15527015eb6acb
# via -r agent/requirements.txt
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
+2
View File
@@ -79,6 +79,8 @@ dependencies = [
# ships binary wheels everywhere and backs the pure-Python PDF fallback
# (shadow_account/pdf_fallback.py) so the PDF renders on any host.
"reportlab>=4.0",
"arabic-reshaper>=3.0.1",
"python-bidi>=0.6.11",
"prompt_toolkit>=3.0.0",
]
+131
View File
@@ -172,6 +172,10 @@ anyio==4.14.2 \
# sse-starlette
# starlette
# watchfiles
arabic-reshaper==3.0.1 \
--hash=sha256:41c5adc2420f85758eada7e880251c4b6a2adbd83377bd27e5d4eba71f648bc7 \
--hash=sha256:a0d9b2a9fa29b5f2c1d705f407adf6ca4242405b9cac0e5cc09e6c4f3f8fb68c
# via -r agent/requirements.txt
attrs==26.1.0 \
--hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \
--hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32
@@ -2974,6 +2978,133 @@ pyphen==0.18.1 \
--hash=sha256:0aa9051e15928cecadd4c632cea0258ba57215b2a197a39baa46abcdb0f47e84 \
--hash=sha256:dbae6fbbe4f01cb206108b43573d857c67107be9d0e38eb1b08d6fa2210634a7
# via weasyprint
python-bidi==0.6.11 \
--hash=sha256:034090c597af250d699299d7e7f1e83eb016f9e47b3b707bd89ab2bdec77bce0 \
--hash=sha256:0608bddcc1c53dfa5293499de13ca9935b31aa46d1c722c404a88c703d1a4e47 \
--hash=sha256:0d339c4ebf3c2501d7569971a22e762fb45d8af7e19a0573afe260d69b35fb0d \
--hash=sha256:0d496f9fc21b7457e12395e54088ab99776966c663b4cd4a74770c7a6418ab59 \
--hash=sha256:19bae96ff1ee76b3a7dc962598b69426538e3021460cf85a4017437548ab6947 \
--hash=sha256:19d13c70b1f7bb5dc69866a11624f3b78123807b4ede18f4f99f656ed86babec \
--hash=sha256:1a639a2c62f825e72eb42901fd1c69296d09c192ae003195e6cf773f92d6bb42 \
--hash=sha256:1ad5f712a32be30d28eb96e119e85818747a43cea253de6c3160b464b62a5619 \
--hash=sha256:1b41cc6bc9ad78a12da5f987da15e931c771f18ceca58f2fe8ed50f253490a97 \
--hash=sha256:1ffd728f1f7866ff7399906bbc17ef5cf010b90ce56a1e94937b28a1a4cf5a7d \
--hash=sha256:20ab47a4098577fc9a82816c330c89ff597c31ba69c98bc6a1b6a5737b03de05 \
--hash=sha256:239a00b2adb5f897d11d7b7a491d759fb9883e71a71cfd90c4147a733b4df4d3 \
--hash=sha256:2b208fc26ab2c7adfd2c4d84753a5045f664e0bec19ca462af2406132e62f92e \
--hash=sha256:2d6970b09f5a3102c0aa192f5258c585742ab4ebd94f637a635ad3448ccba567 \
--hash=sha256:2ff75d1befc335cbe85f834e81554a024f94d9b5d1dd75a5bd99af81a1cb783c \
--hash=sha256:302f4ca7dabfe447e707d40e98520551181036c15750bdd1e73292ad8b3d8e75 \
--hash=sha256:30d543b5baf9fca5ef5ec95647aa07c5e38fc7fa0f18be0c61d1d6c0a1032c6c \
--hash=sha256:337ec315f8b286399e411f5bfc339fc2124db07757409b0595ceb46de087c4cd \
--hash=sha256:3611d13b53d4c899c4f2a7cd8eb897064e8b5546c3c5d1037dd6209c82858a27 \
--hash=sha256:36f23f12d9b1c56ed8d82e11f56c6cba7bc3f614ee73374bc7772bb2270e0966 \
--hash=sha256:380b70d615647646dbe06f7d95dc30b8fdc9b596dbfa1cd3814feb9805c0c8f2 \
--hash=sha256:397f7f289eba6ce25d99dbea99f873d699bf9aa030074e7fb746d8f93c2fb6f9 \
--hash=sha256:3bdb64ee0a74951465cd4a761e1029e73806ff43b2fe5be98643e52da5cabb66 \
--hash=sha256:3da9e536546f7c62c0da595c8f71a096e0b9a80e94cfd0f329b7b200ef81e7d5 \
--hash=sha256:41c7d1914c1f33954aa2ab0e2c309be5d1d4afc75ce524762eaab4dd825c5ab8 \
--hash=sha256:43f3e81bdd36f49171b7de6cf471086df503c29555f6f7f035ebe8f8ec1da779 \
--hash=sha256:490118f4af5a3535923c76be07e937ef1cb4cf84c489b37471aa95455fe923b3 \
--hash=sha256:493655043ebea6fec0edb76053bdad46f1f7352a759ca4deb733f13b3c09839c \
--hash=sha256:495a76c881d78ab87b57c5270679c9bc3c1de36d8c6596d5e3a5a1b5f9c57471 \
--hash=sha256:4ac819cac1abb15486c48af3399a5c726e89f0977a3aff205ac162533186e756 \
--hash=sha256:4ccf1fe9ecb3b02a1a11b103cd2557e2653d82c141b6e2dccec8177e6af5c4bb \
--hash=sha256:4d00757ef7bbbf14d8628f9bdb6b0e168d5e7b03fec20da3226624f11bffce89 \
--hash=sha256:51915502898c45e9cb36636e974aca068fc5cdb9f06b794f49abea5b12f02016 \
--hash=sha256:519eb90bedb04b5cbba0bfc8062984a5ba054a019d81ada34053dc67397d61cb \
--hash=sha256:539d99efe02f4981171ed57bbc085094ef780405ca14663550a56c6c3e265c34 \
--hash=sha256:555cdf9303c40bae1ab512ca427f1f0316a574bc0a48db22eec76ec0fd1213cf \
--hash=sha256:55f27bd2cdeca96f46448a741d393575fbedfc70e38e09dbb030ed98dba8cf2a \
--hash=sha256:55fa5b22cc8c220a6daf077570a8e29d3b607bd978bd3dbc04b445d01a17de57 \
--hash=sha256:565d819fddb2bbc58c42ca5c97d73da7567f181115011e8f04c76b9d08378dcd \
--hash=sha256:56807e0dde88d5ab96880c9d668bda7bca1df83cd30d20cbff5d6b6e6c1e9276 \
--hash=sha256:56dfa3c1c13cb89da39a4e8f2f9442e4291de01877511a13f830125d36e9ce5b \
--hash=sha256:56f27c1edfd15c12c9c348378ccd79166930d720cf316b1181a0a0ade2146253 \
--hash=sha256:57aa56b1eca5f63ebdd25fe8fad02eab7797fc45ad1efc5eed2a830e2bd2038d \
--hash=sha256:5dd49c11ae87ee6ced68594faa78b34a89a1a3c43647fa6157765f9726f9daf3 \
--hash=sha256:5f3e1743b2d43377c4da5d687a03430a27f5769e158a9f75a50b05e7e82f4d21 \
--hash=sha256:5f9ed312d445a691c0afec995ca4f726abe357d8da500e389f77fb874fa7ef76 \
--hash=sha256:60150bffbe43bfc1e356c0c2900ba643a02ce09c37bc0c553c4d83e5c3de63ad \
--hash=sha256:60d9bf6c60c022657637f64897e63dc3f5b1c07cb7f0e1ead6150aff5150c5ab \
--hash=sha256:619ee3fe03daec8d3ce12239f0c22455676a063b2bcde361caecd788fae5b8d5 \
--hash=sha256:62a6b700f3d7a2c4d52a5dccca765711a2414e734360b00365e155698a26e461 \
--hash=sha256:63d9dcc714d549a5118ebc93b7a7c903a0c1feec8e57f5fcacf98d984b76325b \
--hash=sha256:6623683fe39b9fbf508e3069f17e8e9cab26143f9d9f89c8a8f45424c052df4f \
--hash=sha256:68c3e570f2908ac39db0b269f5648e257fab5204344b9bf6dae80852ae4cbe1c \
--hash=sha256:69d1f4ee17644e8aeac93a7238ee2f28d79b0180815441eb511b77e6585aa971 \
--hash=sha256:6c92d1cad16f9ec2f2a3ae439a0bc3a8e4189ec227987bed03d5b4056d5eb9c5 \
--hash=sha256:6ca92a4e460f7e25e434a6d7982d94a4765ca242f527995da70abb5a32003b8a \
--hash=sha256:6d9ef69c108b31f38e1f281a55fdedad7774bc1e952a45c8c14a18e891eee397 \
--hash=sha256:6dc112e2239f69913273cbb0c050ca816b145b32037d4266c430159c5ddcdab2 \
--hash=sha256:721697187f4da67dafc26f63488f463fa35ff2de8668d959fda773cccdbef0eb \
--hash=sha256:73c38c604bfc01647c69ce38e5cf8b4206e2ede91ca3eb8e5d79b7409f17e0b3 \
--hash=sha256:74457b43db34f984252e915828b5d1a4042a771f44e853a5643506d01562eccb \
--hash=sha256:7738cfc7ee9fcdff3fef76b40007982ce7704010a51df307c4a51d378f5ff1ba \
--hash=sha256:777fb44a6f1e91d6adde36e815024c210b0208f35d00762880257b8fb04dc849 \
--hash=sha256:788c11c84b520ea973992cc95751d56b783ff5857df504c1347d99cacd2fcfe7 \
--hash=sha256:79df1099a08e53edb678236d4d76d8de4e3901bafc84ce1788b71f9b96547325 \
--hash=sha256:7c4dc0d1328321aeb08054be654e1d39f171d5aae8dca931c9332860f18f57d7 \
--hash=sha256:7cb34dd8d22ca1847653c0e1cbba8fd96de09c24b8c83b7805eab270c81e863a \
--hash=sha256:8013c1e6267a929be98d10c3a617171709c8226bd33c6180ec65e560c35b6df8 \
--hash=sha256:80cfe97e2d65981be877ae5bd2338c6919a7cc1171fc308c8b8c7c306ba6ffd8 \
--hash=sha256:83c780f7e4c3dd3f020db75dc425567981e65c6d5571b3c0372203d0df92c834 \
--hash=sha256:83ee87feb5eafc0442e1db0014dad20d52a2a7a140b6cddc8f7bc65918f0a7b4 \
--hash=sha256:879c3fba3e7511c7d01020449d970ca7d6a593f20cfc47c014d3d229a38930b2 \
--hash=sha256:8b6b7fce8f47578be9aebf5a0b6b2d6c157b4e97af7586ecf13bfca5d128deea \
--hash=sha256:8eb09af209cd660fa9689f6ce9e61e73c8afa4829ae61801deea7f6e32263800 \
--hash=sha256:8fbb6d222b50324fb9d49b6ff0f8566fa97b907a68c00e6622fcf34463104f4a \
--hash=sha256:9435acc52438c3c8f5142b9a17a622927618e80a32eed707343bc375cb51cebe \
--hash=sha256:946b7dbec4e64017680f1a66b3a8534659d889018ac83bd2abf958278e6f62b0 \
--hash=sha256:94f7f66bd7680d3f2085adaeea91c01b3281a79bb5041db1a51cbe8af36a9d88 \
--hash=sha256:959335cd3814cb767fb832c5c71cbc838ccd9231a812ef2cb43092a216a91d5b \
--hash=sha256:969ee7db3e169fcc0b2d2d094826e03cc5798dfd6b3571a340ea883672396cb1 \
--hash=sha256:9af2b5c26a3eb960699dff040535a86dc2c0f708087b2d63bcfd6452fe9d0664 \
--hash=sha256:9d18916c97855d68515ca2b3581948b6d864c304cd386210ab5aff5e0fb9be2c \
--hash=sha256:a1115f3f02eb836b39e0c986a6b9e92c4377a1e5a680409650b02ec6b1a795e6 \
--hash=sha256:a1b5ee069001bf7f4fff109598a9396caa96bb35e1b906b2c6d1bab9f9b2c4bd \
--hash=sha256:a4f4a0cd24c09df748bfdc207b089c00e7af19f3151063f4cd74ac658290186b \
--hash=sha256:a52f7ad9ef9091e81869e5d255e796755ccf542ade14dda17647cb7d7ffe1b9c \
--hash=sha256:a873e6f8ab28b5a56201d5cdb98f4c7d2ff88e636a58659c5ac271e287001658 \
--hash=sha256:a9aa2890661b238730e680ccd6eb06f4da625b2dbc1730052dae6f2d88957192 \
--hash=sha256:ab8af1cc29c3f400a0f781d50f8ab52a8c63adf2fcef16f14641cd97e06a80e0 \
--hash=sha256:acccb6d90e694684db2d314db2e2b0d3b8949bf1cfaec6d9808a8889f548add7 \
--hash=sha256:ad5d9b8e8a6c330208eba413db506de58f21dbf88a1f1d5d75ef5f9e0e714adf \
--hash=sha256:af7711cc6eeeadcb1aae877e0736a68e111c73a29562da6106c5e2fbb4dd83b2 \
--hash=sha256:b2c759e13ebb81edaac3041697328bb1ca8433b55281723879f6b54e74881240 \
--hash=sha256:b433840a924c8788f0abbda15d22c71dc636e2078d7d3ba39369cebe4bef74b8 \
--hash=sha256:bde89739a979d9eb3ac48c4882c8a42dd528a7708b0faa99b90b551588bd5f8d \
--hash=sha256:c17393753ddb77377f754ba2a88cacfe3056040a8a5dff8d43f9d5e51bbe1edc \
--hash=sha256:c26cd9d81f820159026b1c99905ca12bf13892e3f6a9303359fef42fe6f39e50 \
--hash=sha256:c6aca20472d7040bc1f0aadb510cb20d40b3d6caa4c8bf82d075ba2650a41652 \
--hash=sha256:c6b3c853f99172ef22e5a16c8114cf243e351c8e70f72a894164088c2c99d9cb \
--hash=sha256:caee7ee3662eab1411b44fef8571b87273cb5235061d7463ebd10e412ac07986 \
--hash=sha256:cd564b8c583eba2d230d02d0467fd840045f08856b6524555cfff7f32af63c72 \
--hash=sha256:ce2ef68dce82ae4067cf28910224d77b45eb2c2b3904db6eb670d1cddb8d0eb2 \
--hash=sha256:cf4e88a6fec81b7155a487cbbea7753a3d9a76dc4d391b4f8958b37227ef2c12 \
--hash=sha256:d14f2d400c75e07d1154299463a3d4d14aa5565b05088b2d9b314ccd9fd6dc3a \
--hash=sha256:d52ec8ccdc2fd5c61749d876a9d1eb0ea6543c1676722e1e3fb9d7800852131c \
--hash=sha256:d5695d87969fed5b3799b8a98848cb04fe2873fded46efe9f3f2f341efd1b829 \
--hash=sha256:d7291e13496cb74fc1b71f7f1e3628586afefa531102bb4fa7af9c2d543efc3c \
--hash=sha256:d73a821873c52635321196cfb8d3a231d7917ca284cf2bcd9422f6deb19db7ca \
--hash=sha256:da51a3a2940478219f19249fcf7cd45e8ddc197982be22efa43c4763e9d2eb57 \
--hash=sha256:dc69d594682fbedec7d0cc81cd4d5f14fb40b6b365587238fafb91509cfea020 \
--hash=sha256:dfbb9ba8343a60daf4ced67c11d551dafe9a3c94892c326e4c216fa2e6eca802 \
--hash=sha256:e142820537e08d567c2c017e26df05f88856a03f0f3948209cfcdc39617df363 \
--hash=sha256:e4ebc24ac38e50676f65daf7ba6c568789660cb60d6dcf2606d4310dba826721 \
--hash=sha256:e6c474618a7b6a50c10007f8a9edb50def6d98d297a07a34dc2fb82c344f2b8b \
--hash=sha256:f0ee4e80dd3b2a46868dcc73e00aa59f99c0e1de54a2ba783faf0fddba2ccce2 \
--hash=sha256:f237ebb570fd8bbe479b6967374d82b7f0b26f9452c276bdd5f793d83e7062bd \
--hash=sha256:f400a30573774a1e90c0d50d43c35d9c004afcf53de801bbfd259f84ea80f31c \
--hash=sha256:f563d20481f7d316adf605bb94d5b7182acecdbc4d431d60473e9b1d526d0210 \
--hash=sha256:f7a8429d0d65232e314b4f494825c1205abcc3039f42bf7da80424a15b731709 \
--hash=sha256:f8655ac559dcfce7179b150ebcc207982d4d60e67b3089b2032eed3a0a78c07a \
--hash=sha256:f99162d6c6c9522c46eb213f1bc932829c2602131676c92f081cb865b8ef6784 \
--hash=sha256:fa2848c3116d619870d114471fcd4a9f1aa43587a002111d7af1e6582f1b57e9 \
--hash=sha256:fc3b0a6e2460f68de9ab98f71e3098bb21bb984563417ad104dec7ab08ebcadc \
--hash=sha256:fccd1808bb427d6a6d34168461bef551faa93ce3dda489fcef9073bcd9b34a5e \
--hash=sha256:ff675d036823bff05a2e0f8cdb13f5414274ff517d13b0d57c15527015eb6acb
# via -r agent/requirements.txt
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427