From fc57f413bfadc7bedad90418b42eadd35848359b Mon Sep 17 00:00:00 2001 From: Aiham Taleb Date: Mon, 28 Sep 2026 23:52:26 +0400 Subject: [PATCH] Support right-to-left (Arabic/Hebrew) reports - Report panel: make section ids Unicode-aware and unique. The ASCII-only slug collapsed every Arabic heading to "-", so duplicate React keys made editor updates render as appended sections instead of replacing placeholders in place. - Frontend: set dir="rtl" on report and chat markdown when RTL letters dominate, and switch physical left/right classes to logical ones. - PDF: emit dir="rtl" for RTL reports and mirror indents, list padding and blockquote borders. - Charts: send a self-contained Arabic shaping + bidi reordering module to the Code Interpreter sandbox (matplotlib 3.9, no network) ahead of the agent's code; it is a no-op on matplotlib >= 3.11, which handles RTL. --- frontend/src/components/chat/ChatMessage.tsx | 1 + .../src/components/chat/MarkdownRenderer.tsx | 6 +- .../chat/ReportMarkdownRenderer.tsx | 63 ++-- .../components/chat/ResearchReportPanel.tsx | 1 + frontend/src/lib/utils.ts | 12 + frontend/src/test/rtl-report.test.ts | 68 ++++ .../strands-deep-research/pdf_generator.py | 33 +- tools/code_interpreter/execute_python_tool.py | 13 +- tools/code_interpreter/rtl_text.py | 308 ++++++++++++++++++ 9 files changed, 477 insertions(+), 28 deletions(-) create mode 100644 frontend/src/test/rtl-report.test.ts create mode 100644 tools/code_interpreter/rtl_text.py diff --git a/frontend/src/components/chat/ChatMessage.tsx b/frontend/src/components/chat/ChatMessage.tsx index ba353f7..98d1bdb 100644 --- a/frontend/src/components/chat/ChatMessage.tsx +++ b/frontend/src/components/chat/ChatMessage.tsx @@ -78,6 +78,7 @@ export function ChatMessage({ }`} >
= { export function MarkdownRenderer({ content }: { content: string }) { if (!content) return null; return ( -
+
{completePartialMarkdown(content)} diff --git a/frontend/src/components/chat/ReportMarkdownRenderer.tsx b/frontend/src/components/chat/ReportMarkdownRenderer.tsx index c8f6863..5d934d1 100644 --- a/frontend/src/components/chat/ReportMarkdownRenderer.tsx +++ b/frontend/src/components/chat/ReportMarkdownRenderer.tsx @@ -15,6 +15,7 @@ import { ChevronRight, ExternalLink, } from "lucide-react"; +import { detectTextDirection } from "@/lib/utils"; function completePartialMarkdown(text: string): string { const fenceCount = (text.match(/^```/gm) || []).length; @@ -53,12 +54,30 @@ export function extractReportTitle(content: string): string | null { return match ? match[1] : null; } +// Unicode-aware so non-Latin headings (e.g. Arabic) keep distinct ids; an +// ASCII-only slug collapses them all to "-", and the duplicate React keys make +// in-place section updates render as appended blocks. +function slugify(title: string): string { + return ( + title + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, "") || "section" + ); +} + // Parse markdown into sections based on headings -function parseIntoSections(content: string): Section[] { +export function parseIntoSections(content: string): Section[] { const lines = content.split("\n"); const sections: Section[] = []; let currentSection: Section | null = null; let contentLines: string[] = []; + const idCounts = new Map(); + const uniqueId = (base: string) => { + const n = idCounts.get(base) ?? 0; + idCounts.set(base, n + 1); + return n === 0 ? base : `${base}-${n + 1}`; + }; for (const line of lines) { // Check H2 first (more specific) before H1 @@ -74,9 +93,12 @@ function parseIntoSections(content: string): Section[] { const level = h1Match ? 1 : 2; const title = h1Match ? h1Match[1] : h2Match![1]; - const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-"); - - currentSection = { id, level, title, content: "" }; + currentSection = { + id: uniqueId(slugify(title)), + level, + title, + content: "", + }; contentLines = []; } else if (currentSection) { contentLines.push(line); @@ -106,7 +128,7 @@ const markdownComponents: Record = { h2: () => null, h3({ children }: { children?: React.ReactNode }) { return ( -

+

{children}

); @@ -118,7 +140,7 @@ const markdownComponents: Record = { const text = String(children); const isAnchor = href?.startsWith("#"); const isSource = text.startsWith("http") || text.includes("Source"); - // Citation superscript links like [1], [2] — open source URL or scroll to ref + // Citation superscript links like [1], [2] open the source URL or scroll to the ref const isCitation = /^\[\d+\]$/.test(text); if (isCitation) { return ( @@ -159,14 +181,14 @@ const markdownComponents: Record = { }, ul({ children }: { children?: React.ReactNode }) { return ( -
    +
      {children}
    ); }, ol({ children }: { children?: React.ReactNode }) { return ( -
      +
        {children}
      ); @@ -176,7 +198,7 @@ const markdownComponents: Record = { }, blockquote({ children }: { children?: React.ReactNode }) { return ( -
      +
      {children}
      ); @@ -193,7 +215,7 @@ const markdownComponents: Record = { }, th({ children }: { children?: React.ReactNode }) { return ( - + {children} ); @@ -271,14 +293,14 @@ function CollapsibleSection({ ? { wrapper: "mt-6 mb-4 scroll-mt-4", button: - "w-full flex items-center gap-2 px-3 py-3 rounded-xl bg-gradient-to-r from-blue-100 via-blue-50 to-transparent dark:from-blue-900/40 dark:via-blue-950/20 dark:to-transparent hover:from-blue-200 dark:hover:from-blue-900/60 transition-all text-left group border-b-2 border-blue-200 dark:border-blue-800", + "w-full flex items-center gap-2 px-3 py-3 rounded-xl bg-gradient-to-r rtl:bg-gradient-to-l from-blue-100 via-blue-50 to-transparent dark:from-blue-900/40 dark:via-blue-950/20 dark:to-transparent hover:from-blue-200 dark:hover:from-blue-900/60 transition-all text-start group border-b-2 border-blue-200 dark:border-blue-800", text: "text-xl font-bold text-foreground group-hover:text-blue-800 dark:group-hover:text-blue-300 transition-colors", icon: "w-5 h-5 text-blue-600", } : { wrapper: "mt-5 mb-3 scroll-mt-4", button: - "w-full flex items-center gap-2 px-3 py-2.5 rounded-lg bg-gradient-to-r from-blue-50 via-blue-50/50 to-transparent dark:from-blue-950/30 dark:via-blue-950/15 dark:to-transparent hover:from-blue-100 dark:hover:from-blue-950/50 transition-all text-left group", + "w-full flex items-center gap-2 px-3 py-2.5 rounded-lg bg-gradient-to-r rtl:bg-gradient-to-l from-blue-50 via-blue-50/50 to-transparent dark:from-blue-950/30 dark:via-blue-950/15 dark:to-transparent hover:from-blue-100 dark:hover:from-blue-950/50 transition-all text-start group", text: "text-lg font-semibold text-foreground group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors", icon: "w-5 h-5 text-blue-500", }; @@ -295,19 +317,19 @@ function CollapsibleSection({ > {!isCollapsed && section.content && ( -
      +
      >(new Map()); const sections = useMemo(() => parseIntoSections(content), [content]); + const direction = useMemo(() => detectTextDirection(content), [content]); // Track changes between versions useEffect(() => { @@ -414,7 +437,7 @@ export function ReportMarkdownRenderer({ if (!content) return null; return ( -
      +
      {/* Table of Contents */} {toc.length > 3 && (